Decisions log
Append one entry per decision: date, work package, decision, reason, alternatives rejected.
- 2026-10-25 · WP0 · Orion is built framework-agnostic from the start, modelled on
polaris-core: a framework-free core with ports and defaults, host integrations as packages. · Reason: the Polaris extraction cost a full program; the ports Orion needs (storage, dispatcher, tenancy, signer, clock, logger) are few, and its hard parts never touch a framework. · Rejected: Univeros-only, extract later. - 2026-10-25 · WP0 · One repository,
github.com/univeros/orion, holds every package including the Univeros module (packages/univeros); packages are split-published later touniveros/orion.<package>. · Reason: avoid theuniveros/polaris+univeros/polaris-coresplit, where the module lags the core and is proven separately; one CI run proves the module on every PR, one version line. · Rejected: a second repository for the module. - 2026-10-25 · WP0 · Composer vendor
orion-search, PHP namespaceOrion\; the module isuniveros/orionunderUniveros\Orion\. The root manifest isuniveros/orion-monorepobecause a root package cannot require a path package of the same name. · Reason: the Packagist vendororionis owned by a third party (orion/cep, 2017);orionphpis taken;orion-searchandorion-phpwere free on 2026-10-25. · Rejected:orion(unavailable),univeros/orion-corefor the agnostic packages (ties the agnostic library to the framework’s name). - 2026-10-25 · WP0 · MIT for every package, the module included. · Reason: the module is glue with no competitive value in hiding, and the framework is MIT; a proprietary package inside a public MIT monorepo is awkward. Reversible per package until the first tag. · Rejected: proprietary module as
univeros/polarisis. - 2026-10-25 · WP0 · The storage port must run on the host’s live database connection, and
Outbox::record()runs inside the caller’s transaction; a storage conformance test enforces it. · Reason: the lossless-reindex claim (v1 §8.3) holds only if the outbox commits with the entity write; the second-connection workarounduniveros/polarisuses would silently break it. · Rejected: a dedicated Orion connection beside the ORM’s. - 2026-10-25 · WP0 · Core owns receipt signing (Ed25519 via libsodium,
kid) and an append-only hash-chainedorion_receipttable. · Reason: the framework has no append-only event log anduniveros/securityis a symmetric encrypter only, so v1 §11’s reuse assumption does not hold even inside Univeros. · Rejected: depending on a framework event log. - 2026-10-25 · WP0 · Tenancy is an explicit choice at boot:
Tenancy::single()or aTenantResolver; no implicit default. · Reason: a forgotten tenant filter leaks data; the config must make the single-tenant case a visible decision. · Rejected: defaulting to single-tenant. - 2026-10-25 · WP0 · The
search:YAML is a runtime manifest loaded by core (thepolaris/coreapi/**/*.yamlmodel); the byte-stable Action/Input/Responder/SDK slice is emitted only by the Univeros module. · Reason: code generation into a host’s shape is host-specific; loading at runtime works in every host. · Rejected: generation in core. - 2026-10-25 · WP0 · PHPStan level 8, PSR-12, PHPUnit 12, PHP >= 8.3 with the platform pinned to 8.3.0. · Reason: greenfield; matches
univeros/framework(level 8) rather thanpolaris-core(level 5). · Rejected: level 5. - 2026-10-25 · WP0 · The split workflow is not configured yet. · Reason: the owner asked to split later; the package-to-repository map (
univeros/orionthe module touniveros/orion.univeros, the rest touniveros/orion.<package>) is recorded here so the workflow is a copy ofpolaris-core’ssplit.ymlwith an explicit matrix when the time comes. - 2026-10-25 · WP0 · No pull requests and no co-authored commits: work lands on
mainin commits authored by the owner alone; acceptance is recorded in the epic’s closing comment. · Reason: owner’s workflow; the repository has one author. · Rejected: thepolaris-corebranch-and-PR convention. - 2026-10-25 · WP1 · The
Storageport takes thepolaris/coreDatabaseAdaptercriteria grammar: criteria arecolumn => value, ANDed; a scalar is equality, a list is IN (an empty list matches nothing), null is IS NULL, and a typedOrion\Storage\Conditionis a comparison (lt,lte,gt,gte,ne,notNull); comparing against NULL never matches, as in SQL. Values crossing the port are scalars, null andDateTimeImmutable; adapters return raw driver values. Methods:findOne,findMany(order, limit, offset),insert,update,delete,count,transaction(callable)(a savepoint when nested, rollback when the callable throws).dialect()andIncrementare left out. · Reason: proven in polaris across in-memory, PDO and Cycle adapters; it is enough for outbox, registry, alias and receipt access, and small enough that every host connection (PDO, CycleDatabaseInterface, DBAL, Laravel) can implement it on its live connection, which the same-connection invariant needs. No core code generates SQL, sodialect()belongs to thepdoinstaller; outbox offset allocation is designed in WP4 and addsIncrementor its own primitive then. · Rejected: an expression tree with OR and LIKE (no core caller needs it, and every adapter would reimplement it), raw SQL through the port (breaks non-PDO hosts). - 2026-10-25 · WP1 ·
Document::$versionis a plain non-negativeint, the outbox offset. · Reason: v1 §8.3 and §13 define the version as the outbox offset and use it only for ordering; it maps one to one onto engine external versions (Elasticsearch and OpenSearchversion_type=externalare non-negative longs, Solr_version_), and a value object would add no invariant beyond>= 0. · Rejected: aVersionvalue object. - 2026-10-25 · WP1 · “ASTs round-trip through their builders” means: the fluent builder and the declarative form (
Query::fromArray(),IndexSchema::fromArray()) produce equal ASTs, andfromArray(toArray())is the identity. The declarative readers refuse unknown keys and name the path of the first bad value (query.filters[0].type). · Reason: v1 §5 promises declarative construction for agents and §12 loads schemas fromsearch:YAML; a typo that silently drops a filter or a field is the failure mode to rule out. · Rejected: ignoring unknown keys; a separate DTO layer for agents. - 2026-10-25 · WP1 ·
IndexSchema::native(string $engine, array $settings)stores plain data per engine name, replacing earlier settings for that engine. · Reason: the plan is a pure diff (v1 §8.1) and receipts carry a schema hash (§11); a closure, as in the v1 §6 sample, can be neither hashed, diffed, nor loaded from a manifest. · Rejected: a callback receiving the engine’s mapping builder. - 2026-10-25 · WP1 · Schema change classification: a new field (sub-fields included) is additive; every other difference to index state is breaking (removal, type, language, folding, any flag, vector dims or metric, object to nested). Query-time attributes (
boost) are not index state, no engine can report them, and they are left out of the diff, so a boost change never shows as drift (it takes effect when queries compile from the registered schema). Adapters report a native type with no neutral equivalent asFieldType::Unknown, never declarable at any depth, so any plan touching it is breaking. Object fields are diffed by dotted path. A missing live index plans every field as added. · Reason: v1 §8.1 requires total classification failing closed; classifying by “only additions are additive” makes any attribute added later breaking by construction. Flag changes are breaking because Elasticsearch cannot enabledoc_valuesor indexing in place, and core classifies once for every engine. The org key and native settings are not diffed in WP1 (LiveSchemacarries neither); WP3 diffs them against the registered schema and fails closed. · Rejected: per-engine classification in adapters (orchestration in adapters); classifying a boost change as additive (a plan that can never become empty); treating flag removal as additive. - 2026-10-25 · WP1 · The four lexical verbs are one
Clausevalue with aClauseKind(matchis a reserved word in PHP, soMatchcannot be a class), and a query’s clauses and filters are conjunctive.vector()holds one k-NN clause;fusionis null unless set, and WP8 decides the default when both lexical and vector retrieval are present. Offset pagination only; cursor pagination joins with WP8 query hardening behindCapability::CursorPagination. Sorting defaults to ascending; relevance isSort::SCOREwith an explicit direction. · Reason: the smallest AST covering v1 §5’s list; conjunction is the common case and the only one every engine compiles identically. · Rejected: a class per lexical verb; implicit should-semantics. - 2026-10-25 · WP1 · Reciprocal rank fusion in core is deterministic for identical inputs: rankings are summed in source-name order (float addition is not associative), a duplicate inside one ranking counts once at its best rank and does not consume a rank, equal scores break by id in byte order, and the
FusionTracelists every contribution by source then rank.Fusion::weighted()exists in the AST for engines with native hybrid fusion, and core refuses to compute it withUnsupportedClause(Capability::HybridFusion). · Reason: v1 §5.1: rank fusion is the only portable promise; score magnitudes are not comparable across retrievers, so core must not pretend to weigh them. · Rejected: normalised-score fusion in core. - 2026-10-25 · WP1 ·
Configlands withStorage,EngineRegistryandTenancyrequired (no defaults, no nulls) and the PSR ports (ClockInterface,LoggerInterface,EventDispatcherInterface,CacheInterface) plusmanifestDirectoriesnullable with core defaults (system clock,NullLogger, null dispatcher, in-memory cache,api).Dispatcher,ReceiptSignerandMetricsjoinConfigin WP4 as nullable named parameters, a non-breaking addition; the devInMemoryengine lands in WP2 with the engine conformance suite. · Reason: PSR shapes are fixed; the Orion-owned port contracts are designed with the outbox drain and receipts, and defining them now would freeze guesses. · Rejected: placeholder interfaces for the WP4 ports. - 2026-10-25 · WP1 · Booting without a tenancy choice fails in PHP itself:
Config::$tenancyis a required, non-nullable constructor parameter, andTenancyhas a private constructor with exactly two factories,single()andresolvedBy(TenantResolver).TenantResolver::resolve(): TenantScopeis the provisional port shape; WP6 may revise it withScopeInjector. · Reason: the type system makes “no choice” unrepresentable without a runtime check to forget. · Rejected: a runtime check over a nullable parameter; an environment-variable default. - 2026-10-25 · WP1 ·
EngineRegistrytakes engines as named variadic arguments (new EngineRegistry(elasticsearch: $adapter)) typedSearchAdapter&SchemaAdapter&WriteAdapter. · Reason: PHP enforces the intersection type natively, which a phpdoc-typed array cannot; no fourth “Engine” interface is needed. · Rejected: anEngineinterface extending the three roles; an array checked withinstanceofat runtime. - 2026-10-25 · WP1 ·
IndexNameaccepts lowercase ASCII letters, digits,_and-, starting with a letter or digit, at most 255 bytes. · Reason: the intersection of Elasticsearch, OpenSearch, Solr and Meilisearch naming rules, so a name valid in one engine is valid in all. · Rejected: per-adapter validation. - 2026-10-25 · WP1 · Orion’s tables are data in
Orion\Storage\Schema(orion_outbox,orion_index,orion_alias,orion_receipt); their columns are provisional until the WP that first writes them (outbox and receipts in WP4, registry and alias in WP3) and may change without migration before the first tag. The conformance case publishes its own entity table as data so an adapter’s test installs it beside Orion’s. · Reason: installers (WP2) need the shape; nothing has installed it yet. · Rejected: SQL DDL in core. - 2026-10-25 · WP1 ·
orion-search/testingrequiresphpunit/phpunit(^12.5). · Reason: it ships abstract PHPUnit test cases (StorageConformanceTestCase) that adapter packages extend. Core is unchanged. · Rejected: a framework-neutral assertion layer. - 2026-10-25 · WP1 · Declarative input is bounded and grammatical, after the security review. Names use
\z, not$(which admits a trailing newline). Query fields are a field name or a dotted path of names ([A-Za-z][A-Za-z0-9_]*), so*,_source,_idand Solr function or local-params syntax are refused everywhere a field appears, highlights included;_scoreis accepted only asSort::SCORE. Schema field names may not start with_. Limits: page size 1000 and an offset that fits an int,k10000, vector length anddims4096, facet size 1000, terms 1000, string values 4096 bytes, bool filter depth 10, object field depth 10. Numbers must be finite. Language hints are ISO 639 codes. Messages quote and truncate echoed input. Values are literals: adapters compile them into structured queries, never into query strings. · Reason: agents and manifests construct these ASTs, and the compiled form reaches engine URLs and query DSLs; the limits are the engines’ own ceilings (Elasticsearch’skand dims) or far above any real query. Deep-paging ceilings and per-field type checks against the schema are the adapters’ and the compile step’s (WP8). · Rejected: per-call overridable limits (no caller needs one yet); unbounded input relying on the engine to refuse. - 2026-10-25 · WP1 · The storage contract is tightened, after the security review. Table and column names are developer constants, and every adapter still checks them with
Orion\Storage\Identifier([a-z_][a-z0-9_]{0,62}) before quoting; directions are exactlyascordesc; limit and offset are non-negative;update()anddelete()refuse empty criteria; condition values are scalars, null orDateTimeImmutable. Orion’s table definitions are checked by the same rule, and theorion_index.schemacolumn isdefinition(SCHEMAis reserved in MySQL). The conformance case asserts all of it, plus bound values (x' OR '1'='1is stored and matched literally). The same-connection test now writes the entity through an abstractinsertOnHostConnection()(the host’s own connection) and the outbox row through the adapter, so an adapter holding a second connection fails it, which a mutation run confirmed.InMemoryStoragecompares as SQL does: numbers numerically whatever their PHP type, dates as UTC instants. · Reason: drivers cannot bind identifiers, so the identifier rule is the injection boundary; the receipts table must not be wipeable through a generic call; a test that wrote both rows through the adapter could not detect the failure it was named for. · Rejected: an explicittruncate()(no caller); documenting the identifier rule without a conformance test. - 2026-10-25 · WP1 · PHPStan runs with
treatPhpDocTypesAsCertain: false(level 8 unchanged). · Reason: value objects re-check their phpdoc shapes at runtime (list, finite, element types) because callers without phpstan reach them; with phpdoc types treated as certain, phpstan reports those checks as always true. · Rejected: widening parameter types toarrayto make the checks look necessary. - 2026-10-25 · WP1 · Carried from the WP1 security review to the WPs that own them. WP6: the adapter contracts stay exactly as v1 §7 (the WP1 brief requires it), so
SearchAdapter::search()andraw()accept an unscopedQueryandGraph::engines()is public; WP6 decides whether adapters take only aScopedQuerytoken thatScopeInjectoralone can mint, or engines stop being reachable outside the scoped search service, and adds a reflection test that no public path accepts an unscoped query. WP6 also: boot fails when a resolver tenancy meets an index withoutorgKey(and when single tenancy meets one with it); on a shared index the writer sets the org key from the scope and derives tenant-qualified engine ids, so tenants cannot overwrite or delete each other’s documents; the scope applies to the k-NN leg, to every ranking before core fusion, and to facets;resolve()runs per operation, there is no “any tenant” scope, and system jobs (drain, reindex) iterate tenants explicitly. WP3: org key and native settings are diffed against the registered schema. WP8: values are escaped per engine and checked against field types. Declined: an opt-in flag fornativeinIndexSchema::fromArray(), because schemas come from repository manifests and no agent tool constructs one (v1 §15 lists only read-only schema tools). · Reason: each needs the enforcement, registry or compiler that its WP builds; logging them keeps the review’s findings from being lost. - 2026-10-25 · WP2 · Three changes to the v1 §7 adapter contracts, made before the first engine adapter so no adapter is reworked:
WriteAdapter::delete(IndexName, Tombstone ...)where aTombstonecarries the delete’s outbox offset as its version;SchemaAdapter::drop(IndexName);SearchAdapter::raw(IndexName, callable $native, Filter ...$scope), whose neutral scope filters the adapter compiles and ANDs with the native query. This supersedes the WP1 choice to keep §7 verbatim. · Reason: lossless replay (v1 §8.3) needs deletes version-stamped like writes, or a stale replayed write resurrects a deleted document and a stale delete removes a newer one; the lifecycle drops the old physical index (§8.3 step 5) and conformance must clean up a real engine; an opaque native callable cannot be scoped after the fact, so tenancy (WP6) needs the adapter to AND neutral filters into it. · Rejected: unversioned deletes; leavingraw()unscopable until WP6 (every adapter built before then would change); aScopedQuerytoken now (the org-scale decision that shapes it is WP6’s). - 2026-10-25 · WP2 ·
EngineConformanceTestCase(inorion-search/testing) defines the portable engine semantics: without an explicit sort, hits are compared as sets;matchis analysed and case-insensitive (single-term behaviour only is promised; multi-term operators differ per engine),termis exact,prefixmatches the start of a term,phraseneeds adjacency and order; clauses and filters are conjunctive andshouldmeans at least one; a document carrying an undeclared field fails alone, with aFailedoutcome, so no engine grows its mapping silently (dynamic mapping is off); applying a plan with breaking changes throwsLogicException; a query with both lexical and vector retrieval on an engine withoutHybridFusionraisesUnsupportedClause(core fuses those legs itself in WP8); each capability is asserted both ways, honoured when reported and refused withUnsupportedClausewhen not; version-stamped writes and tombstones converge in any arrival order (equal versions are stale). Every test runs on a fresh, randomly named index dropped afterwards. · Reason: v1 §18.1 makes conformance the gate between adapters; it can only assert what every engine can honour, and ranking is not deterministic (§17). · Rejected: asserting order for relevance-ranked results; letting engines map unknown fields dynamically. - 2026-10-25 · WP2 · The dev
InMemoryengine lives in core (Orion\Adapter\InMemory) and passes the engine conformance suite: exact k-NN, facets, highlights, version-stamped writes with tombstones kept forever; no native fusion, nested documents, aliases or cursors. Its analysis is ASCII lowercasing and splitting on non-alphanumerics, because core has nombstringorintldependency. Itsraw()callable iscallable(array $source): bool. Without a sort it orders by score, then id, so development runs are repeatable; that is a property of this engine, never a promise of the AST. · Reason: revision §1 puts a dev engine in core so hosts and tests run without a service. · Rejected: a dev engine inorion-search/testingonly (hosts want it in development, not just tests). - 2026-10-25 · WP2 ·
orion-search/pdo:PdoStorageruns on the host’s PDO and never reconfigures it (it refuses a connection not inERRMODE_EXCEPTION, PHP’s default since 8.0, instead of changing it);transaction()opens a transaction only when the connection has none, and otherwise joins the host’s as a savepoint, soOutbox::record()inside an ORM’s transaction commits and rolls back with it. Dates are bound as UTC with microseconds; identifiers are checked and quoted;Installer::install()creates only the tables the connection lacks. The storage conformance case gainedinHostTransaction()and two tests for exactly that scenario; a mutation run (alwaysbeginTransaction(), aspolaris/pdodoes) fails both. CI runs PostgreSQL 17 and MySQL 8.4 as services and phpunit with--fail-on-skipped, so a missing service fails the build instead of skipping. A schema inspector and diff (revision §1) are deferred to the doctor checks that need them (WP5). · Reason: the same-connection invariant (revision §4) is only real if the adapter joins the transaction the host’s code already opened; a skipped service-backed suite in CI would be a silent pass. · Rejected: settingERRMODE_EXCEPTIONon the host’s connection; nesting by a private depth counter only. - 2026-10-25 · WP2 ·
orion-search/elasticsearchsupports Elasticsearch 8.19 and 9.x with client^8.19 || ^9.0; the host installs the client major matching its server (client 9 sendscompatible-with=9, which 8.x refuses). Conformance runs against 9.5 (main CI job) and 8.19 with client 8.19 (its own CI job, groupelasticsearch8). Mappings aredynamic: strict;integer→long,float→double, and any other native numeric type inspects asUnknown(fail closed rather than hide a precision change);filterable→index,sortable/facetable→doc_values, with the neutral attributes also kept in mapping_metaonly to split sortable from facetable where the native mapping agrees (native wins on any disagreement); language hints map one to one onto built-in analyzers, folding onto Orion-named custom analyzers, soinspect()reads both back; writes and deletes useversion_type: externalwith the outbox offset (409 = stale).HybridFusionis false because the RRF retriever needs a paid license; core fuses.QueryCompileris a pure function from AST to DSL, the basis of WP9’scompile_query. Query-timeboostis not applied yet: compiling needs the registered schema, which WP8 brings. · Reason: many production clusters are still on 8.x; strict mappings and external versions are what make the conformance promises (no silent mapping growth, convergent replay) true on Elasticsearch. · Rejected: client 9 only; dynamic mappings;integer/float32-bit types; native fusion behind a license check. - 2026-10-25 · WP2 · Hardening after the WP2 code and security reviews. (1) Under scope filters, a native body may use only keys that act within the matched documents (
query,knn,size,from,sort,_source,highlight,post_filter,track_total_hits,min_score,search_after,rescore,collapse,timeout); anything that reads index-wide (aggregations,globalin particular, suggesters, retrievers, runtime fields, script fields) is refused withInvalidArgumentException, because the scope becomes the tenant boundary in WP6; the filters are ANDed into every k-NN entry, a single or listedknnandfilteralike, and nomatch_allis added beside a k-NN-only body (that would union every scoped document into it). Unscoped bodies are untouched: single tenancy keeps the full escape hatch. The conformance case gainednativeShapes()andnativeScopeEscapes()hooks to prove both per engine. (2) Highlight fragments are HTML-safe: the stored text is escaped and<em>is the only markup (Elasticsearch’shtmlencoder; the in-memory engine escapes the same way); conformance asserts it. (3) Writing to or deleting from a missing index fails instead of letting Elasticsearch auto-create it with a dynamic mapping; bulk responses are trusted for nothing (outcomes keyed on the requested ids, an incomplete response fails every item, only a 2xx applies, 409 is stale, a 404 applies only for a delete without an error), and failure reasons are truncated. (4)native('elasticsearch')takessettingsandmappingsonly, and cannot loosendynamic: strictor overwrite Orion’s_meta;apply()rewrites only Orion’s_metakey. (5) Stemmers for folded text are named filters; a plan adding a field that needs an analyzer the live index lacks is refused byapply()withLogicException, as breaking plans are: core classifies it additive (it is, for engines that can add analysis), so WP3’s planner must treat an adapter’s refusal as “requires reindex”.MigrationPlan::applyTo()moves the shared apply logic into core. (6)Storage::update()returns rows matched, changed or not;PdoStoragecounts first on MySQL, which reports changed rows. Savepoint names are process-wide, so two storages on one connection never collide (MySQL silently replaces a same-named savepoint); a failing rollback never hides the exception that caused it;Installerrefuses to run inside a MySQL transaction, which DDL would commit. (7) The in-memory engine now fails a document whose value its field type rejects (non-integer ininteger, wrong vector dimensions) and honoursprefixon keyword fields, matching Elasticsearch; conformance asserts both,termexactness, write-replaces-document, and delete-before-older-write. · Reason: each was a confirmed failure or divergence; the conformance case is where a portable promise lives, so each fix landed there first. · Rejected: a denylist of native keys (fails open on the next Elasticsearch feature); “rows changed” semantics forupdate()(differs per database). - 2026-10-25 · WP2 · Carried from the WP2 reviews. WP4: Elasticsearch keeps external-version tombstones for
index.gc_deletes(60 s by default), after which a stale replayed write could resurrect a deleted document; fault injection decides the retention the reindex window needs and whetherMapping::createraises it. WP8: query-timeboost;prefixcost limits; date math in string range bounds;_sourcefiltering; deep paging beyondmax_result_windowsurfaced asUnsupportedClauserather than a client exception. Known divergence: the in-memory engine folds only ASCII case (core has nombstring), somatchon non-ASCII text differs from Elasticsearch; the conformance corpus stays ASCII and the dev engine documents it. - 2026-10-26 · WP3 · The index registry is one
orion_indexrow per versioned physical index (namethe physical index,alias,engine,version,definitionas the declaredIndexSchema::toArray()JSON,schema_hash,created_at; unique on alias and version) and oneorion_aliasrow per alias (alias,engine,physical,swapped_at), written for every engine, native aliases or not.IndexRegistrationis the row’s declarative form:fromRow()refuses unknown columns, re-validates the definition throughIndexSchema::fromArray()and refuses a definition whose hash does not match the stored one. · Reason: a reindex (v1 §8.3) builds version N+1 beside version N, so the registry must describe each physical index with its own schema, and receipts namefrom_versionandto_version; recording the alias row on every engine gives core one answer to “what is current” (AliasManager::current()) for status, doctor checks and the determinism harness, withAliasManager::target()handing adapters the alias where the engine resolves it and the physical index where core keeps the indirection; the hash check makes a registry edit fail closed instead of steering the planner. · Rejected: aphysicalcolumn on a one-row-per-aliasorion_index(cannot hold the version being built); an alias row only on engines without native aliases (two code paths for “current”). - 2026-10-26 · WP3 ·
MigrationPlan::between(desired, live, registered)diffs what no live index reports, the org key and the per-enginenative()settings, against the schema as registered, under the paths_org_keyand_native.<engine>(field names cannot start with_, so they never collide); every such difference, an addition included, is breaking. A live index with no registration fails closed: whatever the desired schema declares there is breaking. An index that is neither live nor registered is a create and diffs nothing but its fields. Native settings compare canonically (keys sorted at every depth) and an empty object equals none.Change::$before/$afterwidened toField|array|string|null, andChange,MigrationPlangainedtoArray()for printing and comparison. · Reason: the WP1 carry-over; changing the tenant field or an engine’s analysis settings can never be applied to a live index, and treating the registration as the only record of them keeps the plan a pure function of (desired, live, registered). · Rejected: a second list of setting changes beside the field changes (consumers would forget one); classifying a native addition as additive (no engine applies settings in place, and core classifies once for every engine). - 2026-10-26 · WP3 ·
IndexSchema::hash()is sha256 over the canonical JSON oftoArray(), the whole declaration, query-time attributes included, so equal declarations hash equal whatever order they were written in;IndexSchema::named()andMigrationPlan::named()re-address a declaration or a plan to the physical index behind an alias. · Reason: receipts (WP4) carry the schema hash of what was applied, and the registry compares it; the plan is computed for the alias the application names and applied to the physical index the engine knows. · Rejected: hashing only index state (a boost change would not change the registered schema). - 2026-10-26 · WP3 ·
SchemaAdapter::alias(IndexName $alias, IndexName $physical)is the atomic swap primitive, the one addition to the v1 §7 contracts this WP makes; an engine withoutCapability::NativeAliasesrefuses it withUnsupportedClause, and the conformance case asserts both ways (searching and inspecting through the alias reach the physical index; a second swap moves it and leaves the old index as it was). Elasticsearch implements it as oneupdate_aliasesrequest (removefrom*withmust_exist: false, thenadd).AliasManager::swap()writes the alias row and calls the engine inside one storage transaction, so an engine refusal rolls the row back, and refuses to move an alias to another engine. · Reason: v1 §8.2, the swap is atomic on the engine and the registry; a fourth adapter interface for aliases would be a second source of truth besideCapability::NativeAliases. · Rejected: anAliasAdapterinterface; adapters doing the swap orchestration (rule 3). - 2026-10-26 · WP3 ·
Lifecycle\Planneris the schema lifecycle for one engine (Graph::planner($engine)):plan()is the pure diff;apply()createsalias_v1and swaps the alias onto it when Orion never built the alias, applies an additive plan in place (re-addressed to the physical index, then re-records the schema), and reportsOperation::ReindexRequiredwithout touching anything for a breaking plan or for an additive plan the adapter refuses withLogicException(the WP2 carry-over).create()refuses, withLogicException, an alias or physical name already on the engine but not registered, and drops the index it built when the registry or alias write fails.Outcome(operation, the current registration, the plan) is a pure function of the inputs and is the seam the signed receipt (WP4) will be computed over;Operation’s values are the receipt’soperation. · Reason: v1 §8.1 and the WP2 refusal; adopting an index Orion did not build would register a schema the live index may not have, so fail closed and name it. · Rejected: aLifecycleclass beside aPlanner(one class plans and applies; the Reindexer joins it in WP4); applying a plan through the alias (engines without native aliases have no such index). - 2026-10-26 · WP3 ·
LifecycleDeterminismTestCase(inorion-search/testing) is the determinism harness of v1 §18.3 for plan and apply: one scenario (create, no-op, additive, additive-or-refused, breaking) runs twice from a fresh in-memory storage with a fixed clock on the same engine and alias, and every plan, outcome, registry row, alias row and inspected live index must be identical; it also asserts idempotence, that breaking plans and refused additive plans leave index, registration and alias untouched, that the org key and native drift fail closed, that an alias belongs to the engine that built it, and that unregistered indices are refused. It runs on the in-memory engine and on Elasticsearch 9 and 8.19. Receipts are WP4, so the harness asserts plans and outcomes and leaves “identical receipts” as the one assertion to add over the sameOutcome. · Reason: v1 §17 makes plan and apply the deterministic axis; the harness is the gate for the lifecycle WPs. · Rejected: comparing system-clock timestamps (never identical); a PDO-backed harness (storage conformance proves adapters; the harness proves the lifecycle). - 2026-10-26 · WP3 · Carried forward. WP4:
nextVersion()is read without a lock, soReindexLockarrives with the Reindexer; a crash betweenensure()and the registry transaction leaves an unregistered physical index thatcreate()then refuses, and a crash after the alias row commits but before the engine swap is acknowledged leaves the two disagreeing, both for fault injection to recover (anorion doctor --fixor the Reindexer’s resume); dropping old versions after the grace window and forgetting them from the registry. WP5:orion_alias_resolvescomparesAliasManager::current()with what the engine reports;orion_registry_currentregisters manifests missing fromorion_index. WP6:AliasManager::target()is where the scoped search service hands adapters the index. WP8: the manifest names the engine an index is built on;Graph::planner($engine)takes it from there. - 2026-10-26 · WP3 · Hardening after the WP3 code and security reviews. (1)
create()claims the registry row first (IndexRegistry::register(), insert only, the database’s primary key arbitrating concurrent creates), then builds the index and swaps the alias; on failure it drops the index it owns and releases the claim, and a failing drop is logged through the PSR logger, never allowed to hide the failure that caused it. Before: ensure, then register, then drop on failure, which let the loser of a concurrent create drop the winner’s live index becauseensure()is idempotent. (2) Adapters refuse a plan they cannot apply in place withOrion\Adapter\RequiresReindex(aLogicException, so the WP2 contract holds), thrown byMigrationPlan::applyTo()for breaking plans and by Elasticsearch for new analysis; the planner catches only it, so a programming error or a missing index no longer reads as “reindex required”. The conformance case asserts the type. This refines the epic’s wording (“apply()throwingLogicException”). (3) A registered physical index missing from the engine fails closed inplan()andapply()(planning against nothing would make every field additive); a registration with no alias row (a crash between claim and swap) is refused rather than built beside; an alias name shaped like a physical one (shop_v1) is refused. (4)AliasManagervalidates on write and on read that the alias row’s physical index is registered as a version of that alias on that engine, so a corrupted or tampered row fails closed instead of redirecting reads, writes or an in-place apply to another index;current()returns the validatedIndexRegistration. (5) An empty plan whose declaration still differs from the registration (a boost change) refreshes the registration, because queries compile from it (WP8). (6) The storage conformance case round-trips a registry row through the real database, since JSONB and MySQL JSON reorder keys: the hash is canonical and survives it. (7)schema_hashis corruption detection, not authentication: the registry is inside the trust boundary (whoever writesorion_indexcan already rewrite the data it indexes); WP4 signs the hash into receipts, which is where tamper evidence belongs. · Reason: each was a confirmed failure path; the fixes landed as tests first (planner, alias manager, determinism harness, conformance). · Rejected: a MAC over the registry row (no key exists before WP4);ensure()reporting whether it created the index (the claim makes ownership explicit without changing the contract); carrying the engine’s refusal text intoOutcome(engine-specific text in a value meant to compare equal across engines). - 2026-10-26 · WP4 · Outbox offsets come from a counter row, through a new
Storage::increment(table, criteria, column): ?intthat adds one to the single matching row and returns the new value (null when none matches,LogicExceptionwhen several do). The row stays locked until the surrounding transaction ends, so offsets per index are dense and committed in order: a drain or replay readingoffset > cursorcan never miss a row that commits later with a lower offset, and a rolled-back write rolls its offset back too. Counters live inorion_counter(name,value): the outbox head per index (outbox:<index>), the drain cursors, the receipt sequence.orion_outboxis keyed on (index_name,outbox_offset) and unique on (index_name,document_id); itsidcolumn is gone. A PDO test proves the lock on PostgreSQL, MySQL and SQLite with a second connection that gives up waiting; the SQLite conformance run moved to a database file so a second connection sees the same database. · Reason: the WP1 carry-over; an identity column or a UUID v7 orders by allocation, not commit, so a reader can pass an offset whose transaction commits later, which loses that write; a compare-and-set retry loop reads a stale snapshot forever under MySQL’s repeatable read; every host connection (PDO, Cycle, DBAL, Laravel) hasSET c = c + 1. The cost is that writers to one index queue on its counter until they commit: correctness over write throughput, per index rather than global so indices do not contend. · Rejected: auto-increment or UUID ordering (allocation order); a sequencer assigning offsets after commit (needs commit order, which only the database knows); one global counter (every writer of every index queues behind one row); a dialect upsert in the port. - 2026-10-26 · WP4 · The outbox is a compacted log:
Outbox::index(IndexName, id, fields)andOutbox::delete(IndexName, id)take the index’s next offset, delete the document’s previous entry and insert the new one in one transaction (joining the host’s as a savepoint), soorion_outboxholds exactly the latest write or delete of every document per index. The payload is the projected document as JSON (zero fractions kept, so10.0stays a float through JSONB and MySQL JSON);Entryis the row’s declarative form (fromRow()refuses unknown columns, an unknown operation, a delete with a payload);Entry::toDocument()andtoTombstone()carry the offset as the version.Graph::outbox()exposes it. Row helpers moved toSupport\Row, which now reads stored dates as UTC whatever PHP’s default zone (adapters store UTC; reading in the default zone was a latent bug inIndexRegistration::fromRow()); named counters go throughStorage\Counters(next()in commit order,advance()forward only). · Reason: v1 §8.3 makes the database, through the outbox, the reindex source; a log keeping every write would grow without bound and make a full replay re-send every superseded version (and replaying a stale write after a delete is what Elasticsearch’sgc_deleteswindow would otherwise have to cover), while a compacted log replays each document exactly once, at its latest state. Delete then insert, rather than an update, because MySQL’s update count comes from a snapshot read; the index’s counter lock serialises writers, so the insert never races. · Rejected: an append-only outbox with a separate pruner (unbounded until pruned, and replay re-sends history); storing only a reference and re-projecting at drain time (core cannot load host entities); aProjectordependency in the outbox (the projector is host code; the outbox takes its output). - 2026-10-26 · WP4 ·
Outbox\Drainwrites an index’s entries in offset order and in batches to the index its alias points at (AliasManager::targetOf(): the alias on engines with native aliases, the physical index elsewhere), resolving the alias after each batch is read; its cursor is the counterdrain:<index>, moved forward only, after the batch is written and in one storage transaction with the batch’s dead letters. A run stops at the head it saw when it started and reportsDrainResult(from, to, head, lag, applied, stale, failed); an index whose alias Orion has not built waits with its lag reported.Outbox\Deliverysends entries as documents and tombstones versioned by offset and accounts for every entry by id, so an entry the engine reports nothing for is a failure, never a write. Per-document failures go toorion_dead_letter(one row per entry: error truncated to 2000 bytes of valid UTF-8 by the newAssert::truncate(), attempts, failed_at);DeadLetters::retry()resends them, and a letter is cleared when its entry or any newer entry of the same document reaches the index, or when compaction removed its entry. Two ports joinConfigas nullable named parameters, closing the WP1 carry-over for them:Outbox\Dispatcher::dispatch(IndexName)(core defaultSyncDispatcher, which drains at once; call it after the recording transaction commits, never inside it) andObservability\Metricswithcounter()andgauge()(core defaultNullMetrics; the drain reportsorion.outbox.applied,.stale,.failedand the.laggauge per index).orion-search/testinggainsFaultyEngine(kill before or after a verb, or mid-bulk after N documents),EngineKilledandSettableClock. · Reason: v1 §10 and §8.3: a killed engine must cost a retry, never a document, so the cursor trails the engine; resolving the alias after reading a batch means any entry read after a swap commits goes to the new index, which is what lets the reindexer’s final catch-up close the swap window (next entry). A dispatcher cannot run inside the host’s transaction because the drain would write documents the host may still roll back, and core cannot hook the host’s commit, so the call is the host’s (or the module’s after-commit listener). The metrics port is Polaris’scounter()plusgauge(), because lag is a level, not a count. · Rejected: advancing the cursor before writing (loses the batch on a crash); writing to the physical index on every engine (an engine alias moved without its row, the mid-swap failure, would then split reads from writes); recording the dispatch insideOutbox::index(); a dead-letter table keyed by document (loses which version failed). - 2026-10-26 · WP4 · Receipts.
Receipt\Receiptrecords one lifecycle operation:sequence,operation(Lifecycle\Operation, which gainsreindexedanddropped),index(the alias),engine,from_version(null for a create),to_version,document_count,schema_hash,from_offset,to_offset,previous_hash,hash,kid,signature,recorded_at. Its message is the canonical JSON of every field buthashandsignature(time as UTC with microseconds),hashis that message’s sha256 andsignaturean Ed25519 signature over the message, base64.orion_receiptis keyed onsequence(theidcolumn is gone, so two identical runs produce identical receipts), versions are integers, andengineis new.ReceiptStoreonly appends and reads: the sequence comes from thereceiptcounter, whose lock serialises appends so each chains to the receipt committed before it and the chain cannot fork, and it refuses to extend a chain whose previous receipt is missing.Verifierwalks the chain and reports, per receipt, a hash that does not match its contents, a gap in the sequence, aprevious_hashthat is not the previous receipt’s hash, and a signature that does not verify under its kid, plus a chain that ends before the last sequence issued (truncation).ReceiptSigneris the port (kid(),sign(),verify(message, signature, kid)), joiningConfigas a nullable named parameter;SodiumSignertakes a 64-byte secret key, a seed or base64 key material, plus earlier kids’ public keys so receipts signed before a rotation still verify, marks key parameters#[SensitiveParameter]and shows only its kid when dumped. The planner appends a receipt forcreatedandapplied, inside the storage transaction that swaps the alias or updates the registration, so neither commits without its receipt (a create whose receipt fails is released like any failed create);unchangedandreindex_requiredchange nothing and are not receipted.Outcomecarries the receipt. A query-time change (a boost) now reportsapplied, notunchanged: it changes the registered schema, which receipts attest. The determinism harness compares receipts across runs and verifies the chain; the storage conformance case proves a chain round-trips and verifies on each database, microseconds included. · Reason: revision §3 and v1 §11: the chain makes an edit, removal or reorder evident, the signature makes a rewrite need the key, and the sequence counter makes cutting the tail evident. Without a signer the lifecycle still runs and issues no receipts: a key is a deployment secret core cannot invent, and an ephemeral or database-held key would sign receipts that prove nothing; this is how “core default: Ed25519 through libsodium” is read here (the implementation is core’s, the key is the host’s), and the doctor check (WP5) warns when receipts are off. · Rejected: an ephemeral key generated at boot; a key stored beside the receipts; unsigned receipts with an empty signature; signing the hash instead of the message (verifiers then trust the hash function twice); a UUID per receipt (identical runs could not produce identical receipts). - 2026-10-26 · WP4 ·
Lifecycle\Reindexer(Graph::reindexer($engine)) rebuilds an alias by replay (v1 §8.3): it claims the next version (theorion_reindexrow, keyed on the alias, and the registry row, in one transaction), builds it from the schema, replays the compacted outbox from offset 0 in batches until a read comes back short, swaps the alias inside a transaction that first re-checks the lease, replays once more from where it stopped, then deletes the job and appends areindexedreceipt (from the version it replaced to the new one, the writes replayed, offsets 0 to the last replayed) in one transaction. The window between the last read and the swap is closed by that second replay together with the drain resolving the alias after each read: an entry committed before the second replay’s read is replayed, and one committed after it is read by the drain after the swap and written to the new version. Tests prove both halves by mutation: dropping the second replay loses a write committed during the last pre-swap batch, and pinning the drain’s alias before its read loses a write the drain reads after a swap. The job row is the lock (ReindexLockin revision §6 is this row, not a class):holder(a UUID v7 per run) owns it untillease_until(300 s by default), renewed every batch by a compare-and-set on the holder, so a run that lost its lease stops; a run that fails gives the lease up so an immediate retry resumes, a run that dies holds it until it expires; the next run resumes frompositionwithdocumentscarried, so an interrupted reindex’s receipt counts what an uninterrupted one would, provided it builds the same schema, otherwise it must be aborted first (abort()drops the version being built and forgets it, refused while the lease is held or once the alias already points at it). A document the new version refuses stops the replay before its batch (ReplayRefused): a swap never lands on a version missing documents.Planner::apply()refuses while a reindex is pending, because an in-place change to the old version would not reach the new one.prune($alias, $graceSeconds)drops every version the alias no longer points at once the grace window since the last swap has passed, keeping a version being built, and receipts each drop (dropped,to_versionthe dropped version).ReindexResultreports index, versions, physical index, writes replayed, last offset and the receipt. The determinism harness gained a reindex scenario (write, drain, reindex with a breaking change in batches of two, write, drain, prune) compared across runs, on the in-memory engine and Elasticsearch. · Reason: v1 §8.3 asks for a replay from the database, a concurrent-reindex lock and an atomic swap; the compacted outbox makes a replay from 0 complete, versions make overlaps converge, and a lease with resume turns a crash into a delay, never a restart from zero. · Rejected: the drain tailing into the version being built (the drain would have to know about jobs, and an index not yet created would fail its batches); afromOffsetoption (a fresh version needs every document; resume is the only partial replay); swapping with refused documents and leaving them to the dead letters (search would silently lose them); dropping the old version in the same run (the grace window outlives the run). - 2026-10-26 · WP4 ·
FaultInjectionTestCase(inorion-search/testing) is the fault-injection harness of v1 §18.4 and the WP4 acceptance gate: with the application writing and deleting throughout, the engine is killed mid-bulk (the drain), mid-reindex (a replay bulk, after part of it reached the engine) and mid-swap, and the database mid-swap (dying on the alias row write, and committing it but never answering); after recovery, which is running the drain or the reindex again, the index behind the alias holds exactly the latest state of every document, deletes included, the drain has no lag and no dead letters, at most one reindex is receipted and the chain verifies. On an engine without native aliases the swap is a row write only, so the engine-side swap faults are the database-side ones. It runs on the in-memory engine and Elasticsearch 9 and 8.19; a mutation that advances the drain cursor before writing fails it.FaultyStorageandStorageKilledjoinFaultyEngineinorion-search/testing. The WP2 carry-over on Elasticsearch’sgc_deletes(60 s) is settled without raising it: replay reads the compacted outbox in offset order, so a reindex only ever sends each document’s latest entry and never replays a write over a later delete; the one overlap is a write the post-swap catch-up read just before a delete committed, sent after the drain applied that delete to the new version, a window of one batch round trip, well inside 60 s. · Reason: revision §7 makes “fault injection loses no document” the acceptance criterion, and a crash of the process or the database is as real as one of the engine; asserting the full final state, not a count, is what makes “no document lost” checkable. · Rejected: raisingindex.gc_deletesinMapping::create(it keeps tombstones longer on every index to cover a window the compacted log already closes); asserting document counts only (a resurrected or stale document would pass). - 2026-10-26 · WP4 ·
orion-search/cli(bin/orion,Orion\Cli\, onsymfony/console^7.0 || ^8.0, 7.4 under the 8.3 platform pin) shipsplan,apply,reindex(with--abort),prune,drift,status,flush,verify,capabilities,outbox:drain,outbox:retryandreceipts:keygen. Every command prints its result as data: YAML by default, canonical JSON (keys sorted at every depth, pretty-printed) with--json, so the same state and arguments print the same bytes, which tests prove in process and across two freshbin/orionprocesses; a failure is printed as data too (error.type,error.message) with exit code 1, and exit code 3 means action required (applymet a breaking change,drift --checkfound drift);verifyexits 1 on a broken chain. Mutating commands take--dry-run, which reports the plan, the next version, the entries a replay would read or the versions a prune would drop, and changes nothing (Reindexer::prunable(),Outbox::size()andReceiptStore::count()were added for it). The application comes from the host (new Application($orion), as the Univeros module will register the commands) or from--bootstrap/ORION_BOOTSTRAP, a PHP file returningOrionorConfig, thepolaris/clipattern. Until thesearch:manifest (WP8), the declared schema is a--schemafile in the declarative form, JSON or YAML, and must name the index the command is given; the engine is--engine, else the engine that built the index, else the only one configured.doctoris not in this package yet: its checks are WP5’s (revision §5).bin/check-importsnow confinesSymfony\Component\Console\tocliand the host packages (univeros,symfony,laravel,yii), with a test. · Reason: v1 §14 and revision §7: an agent branches on output, so output is data with stable exit codes, never prose; canonical JSON makes “deterministic” a byte comparison. · Rejected: JSON only (people read the CLI too, and YAML of the same data costs one function); human prose output (two formats to keep in step); inventing a manifest format for the CLI before WP8 (the--schemafile is the schema’s own declarative form, which the manifest will contain); reading the index schema from the registry forplanandapply(the declaration is what changes; the registry is what it is compared against). - 2026-10-26 · WP4 · Hardening after the WP4 code and security reviews, each fix test-first and the race fixes mutation-checked. (1) The drain clears dead letters only up to the highest offset its batch wrote, so a concurrent drain’s newer, refused entry keeps its letter. (2)
abort()first takes the job by compare-and-set on its holder (a run that took it over meanwhile wins, and nothing is dropped), then drops the version and forgets it with the job in one transaction. (3) A job row is checked against the registry before it is acted on: its physical index must be a version of its alias (IndexNamer::versionOf(), enforced byReindexJobitself), registered for that alias on the job’s engine, and no older than the version the alias points at, so a forged or corrupted row cannot steer an abort, a replay or a drop onto another index. (4)prune()re-checks each version inside the transaction that drops it, after the receipt lock: the alias row first, then the job, so a version that became current or is being built is skipped (swaps only move to newly claimed versions, so a version that is neither can never become current again); the drop happens inside that transaction, the registry row is forgotten with a row-count check (two prunes cannot both receipt one drop), and a failed commit leaves the version registered for the next prune instead of an index nothing knows about. (5) A receipt is appended first in every transaction that issues one, and the outbox head it records is read after the sequence lock (ReceiptStore::appendAtHead()): under MySQL’s repeatable read, an earlier read in the transaction fixed a snapshot in which a receipt committed meanwhile was missing, failing the operation with “Receipt N is missing”; a two-connection test reproduces it on MySQL with the old order and passes on MySQL and PostgreSQL with the new one. (6)Planner::apply()re-checks for a pending reindex inside its receipt transaction. (7) A reindex resumed after its swap committed does not swap again, so the prune grace window keeps counting from the real swap. (8)Storage::insert()throwsStorage\DuplicateKeyon a repeated primary or unique key (PDO maps PostgreSQL 23505, MySQL 1062 and SQLite’s UNIQUE failures;InMemoryStoragenow enforces the keys of the tables it is given, Orion’s by default); counters, the registry and the reindex claim catch only that, so a deadlock or a lost connection is never mistaken for a lost race. (9) Verification needs no secret:ReceiptVerifier(public keys;ReceiptSignerextends it) withSodiumVerifier, passed asConfig::$receiptVerifier, whichGraph::verifier()prefers. (10)Verifier::verify($sequence, $hash)andorion verify --head=<sequence>:<hash>check the chain still reaches a head recorded outside the database, which catches a tail cut together with the sequence counter;verifyprints the head to record. (11)verifychecks every alias’s current version, engine and schema against its lastcreated,appliedorreindexedreceipt, so a change made with receipts off, or written into the registry by hand, is reported. (12) The signed message is prefixed withorion-receipt-v1\n(domain separation); a signature must be canonical base64; kids are quoted in breaks; aSodiumSignerrefuses to be serialized. (13) Document ids must be UTF-8; engine reasons lose control characters before they are stored (PostgreSQL refuses NUL in text). (14)AliasManagerTest’s fake engine moved toorion-search/testingasAliasingEngine, so the pdo package’s tests do not reach into core’s. · Reason: each was a confirmed path to a lost update, a blocked recovery, a dropped live index, a spurious failure, or a check that could be passed without the key. · Rejected: areceiptsRequiredswitch inConfig(it prevents only what a host already configures, while the registry cross-check detects an unreceipted change whoever made it); a receipt forabort(the version it drops was never live or receipted, and the live state is what verification attests); a lease on the drain (bounding the clear by offset makes concurrent drains safe; ordering between them is the versions’ job); a validity window per kid (key revocation belongs with the module’s key management, WP5);sodium_memzeroon the key (PHP strings are copied freely; it would give false comfort).document_counton a reindex receipt counts index writes replayed, including a document rewritten during the run; it is not the index’s size, which no engine reports exactly. - 2026-10-26 · WP4 · Carried forward. WP5: the doctor checks read
Drain::status()fororion_outbox_lag,DeadLetters::count()fororion_dead_lettersandVerifier::verify()fororion_receipts_verify(warn when no signer or verifier is configured);--fixfor aPlanner::create()claim left without an alias row (a process crash between claim and swap) and for an unregistered<alias>_v<n>left on the engine; the module keysSodiumSignerandSodiumVerifierfrom the host’s key material and owns key rotation and revocation; shipping the printed receipt head to an append-only log. WP6: the outbox, counters, drain, reindex, prune and abort key on the index name alone, so a tenant scope must enter through the index name or the registry; system jobs (drain, reindex) iterate tenants explicitly. WP8: thesearch:manifest replaces the CLI’s--schemafiles and names each index’s engine. CI: the receipt-ordering and fault-injection suites run in the main job and theelasticsearch8job once billing (#13) is restored; neither has run in CI yet. - 2026-10-26 · WP5 · Orion’s storage SQL is one class for every connection:
Orion\Pdo\SqlStoragebuilds the SQL (identifiers checked and quoted, values bound, dates UTC with microseconds) over anExecutorport (select,execute,transaction,isDuplicate,dialect);PdoExecutorholds the PDO behaviour (joins the host’s transaction as a savepoint) andPdoStoragedelegates to them, unchanged for callers. The Univeros module runs the same SQL on the host’s Cycle connection throughCycleExecutor: transactions go through Cycle’s owntransaction(), which counts nesting per connection and uses savepoints, so the outbox joins the host’s unit of work; values are bound as typedParameters (Cycle binds everything as a string otherwise); reads use the write driver, never a read replica, because Orion reads what its own transaction wrote. The storage conformance case, same-connection tests included, passes on Cycle over SQLite, PostgreSQL 17 and MySQL 8.4 (the WP5 acceptance criterion), with the counter lock proven across two Cycle connections on PostgreSQL and MySQL (SQLite locks the whole database).CycleInstallerinstalls the tables through Cycle, and the module’s migration (InstallOrion) calls it on the default database inside the migrator’s transaction, clearing Cycle’s statement cache first (SQLite refusesDROP TABLEwith an open cursor, asuniveros/polarisfound). · Reason: revision §4 requires the outbox on the host’s live connection, and Cycle keepsgetPDO()protected, so the second PDOuniveros/polarisuses would break the invariant; one SQL proven by one conformance case cannot drift between connections. · Rejected: Cycle’s query builder (it would reimplement the criteria grammar, the MySQL matched-row count and microsecond dates, and drift from what PDO proves); a second PDO opened fromDatabaseSettings(two transactions). - 2026-10-26 · WP5 ·
univeros/orionis one line inconfig/modules.php(new Univeros\Orion\Module()).apply()binds oneOrion, itsGraph,OutboxandDispatcherlazily, sobin/altair db:migrateruns before Orion is configured;Bootstrap\OrionConfigbuilds theConfigfrom what the container binds (always winning) and the environment: storage on the Cycle connectionCycleOrmConfigurationconfigures;ORION_TENANCY=singleor a boundTenancy, with no default; receipt signing fromORION_RECEIPT_KID,ORION_RECEIPT_SECRET_KEY(base64) andORION_RECEIPT_PREVIOUS_KEYS, verification fromORION_RECEIPT_PUBLIC_KEYS(kid:base64lists), and a half-set or ambiguous key configuration (a kid without a key, a kid named twice) refused rather than silently leaving receipts off; the frameworkMeterthroughMeterMetrics(counters and the lag gauge); with the bus,BusDispatchersendsDrainIndexand the module registersDrainIndexHandler, which ignores an index Orion has not built (anyone who can enqueue can name one); clock, logger, events and cache from their PSR bindings. Registrations into the doctor and the bus are container decorators, so they do not depend on the order configurations and modules apply in. The console adds theorion-search/clicommands asorion:*(Console\Commands::all($container), Orion resolved when a command first needs it, via a lazyCli\Context).bin/altair manifest:generategets an Orion section (AgentSpec\ManifestSection) rendered from the manifests alone. Engines are the application’s to bind (new EngineRegistry(elasticsearch: ElasticsearchEngine::connect($url)), the new factory in the adapter package);ORION_ENGINE=memorygives the development engine. · Reason: revision §5. Engines deviate from §5’s “the engine named by ORION_ENGINE”:bin/check-imports’ engine-client pattern (Elastic\|Elasticsearch\) also matches Orion’s own adapter namespaceOrion\Elasticsearch\, CLAUDE.md says to move code rather than loosen the gate, and the module referencing an adapter would also tie it to one engine package; whether the pattern should be narrowed to the client namespaces is the owner’s call (asked). · Rejected: building engines from a class name in the environment (configuration that executes code by name); a requiredConfigfield for engines in the module (bound engines already win). - 2026-10-26 · WP5 · The
search:manifest’s index part moves forward from WP8 into core, because the doctor checks (orion_registry_current,orion_schema_drift) need the declared indices:Orion\Manifest\Loaderreads everysearch.yamlor*.search.yaml(.yml) under the manifest directories, one index each:index,schema(its fields, asIndexSchema::fromArray()’sfields), optionalengine,org_key,native;sourceandsearchare reserved for WP8 (checked to be maps, not interpreted). Unknown keys, invalid schemas, aschemathat is not a map, PHP-object YAML tags (PARSE_EXCEPTION_ON_INVALID_TYPE) and an index declared twice are refused with the file named; files are read once by real path, in path order.Graph::manifest()loads it (Config::$manifestDirectories: a directory the application names must exist; the defaultapiis optional). The module resolves the default against the project root (Composer’s root package), never the working directory, which under php-fpm, the built-in server or a worker is elsewhere. The CLI takes an index’s schema and engine from the manifest when--schemais not given,driftdefaults to every declared index, and--schemaaccepts a manifest file as well as the schema’s own form. · Reason: WP8 brings the endpoint (search:), not the index declaration; naming the filessearch.yamlkeeps otherapi/specs out. · Rejected: a module-only list of schemas bound in the container (two declarations to keep in step once WP8 lands); reading every*.yamlunderapi/(other modules’ specs live there). - 2026-10-26 · WP5 ·
SchemaAdapter::resolve(IndexName $alias): ?IndexNamejoins the contract: the physical index the engine’s alias points at, or null;UnsupportedClausewithoutCapability::NativeAliases. Elasticsearch reads it withexists_aliasandget_aliasand refuses an alias over several indices; the conformance case asserts both ways. · Reason: the WP3 and WP4 hand-forward:orion_alias_resolvescomparesAliasManager::current()with the engine, which is how a swap the engine made and the database never recorded is found. · Rejected: inferring the target by inspecting the alias’s mapping (two versions can share one). - 2026-10-26 · WP5 · The seven doctor checks of revision §5, in
Univeros\Orion\Doctor, run throughProbe(the graph resolved when a check runs, so misconfiguration is a failed check, not a failed boot; failure messages with URL credentials redacted):orion_engine_reachable(error),orion_registry_current(warn;--fixbuilds declared indices, after clearing a crashed create’s leftovers: a claim never aliased, only when older than 300 seconds and on the declared engine, or an unregistered first version, each only when it holds no documents after a refresh, each drop logged),orion_schema_drift(error withbin/altair orion:reindex <index>as the agent action when breaking, warn when additive including a query-time change;--fixapplies additive drift through the planner, receipted),orion_alias_resolves(error when the row, the registry and the engine disagree, or the version is missing),orion_outbox_lag(warn aboveORION_DOCTOR_MAX_LAG, 1000 by default; an unreadable value is an error),orion_dead_letters(error, retry command),orion_receipts_verify(error on a break; warn when receipts are off, and differently when receipts exist but no key is configured to verify them). A fix reports success only when a fresh run finds nothing left. All depend ondatabase_reachable. The framework’sDoctorConfigurationbuilds itsDoctoraround the registry object it created, so a module’s registry decorator never runs onbin/altair doctor; the module also decoratesDoctorto resolve the registry first (univeros/polarisregisters its checks the same way and is likely affected: worth an upstream fix). · Reason: revision §5 and the WP4 hand-forwards; a fix that drops anything must not be able to drop a create in flight, another engine’s index, or data. · Rejected: auto-dropping non-empty leftovers; fixing breaking drift. - 2026-10-26 · WP5 · The harness pattern:
LifecycleDeterminismTestCaseandFaultInjectionTestCasegain acreateStorage()hook (in memory by default), and the module’s suite runs both on its Cycle storage, a database dying mid-swap included.examples/univerosis the demo host:config/modules.phpwith the one line, the app module binding Elasticsearch and servingPOST /products,DELETE /products/{id}andGET /products/searchfrom a PSR-15 middleware (each write and its outbox entry in one Cycle transaction, the drain dispatched after the commit and a failing drain never failing the committed write),api/products/search.yaml,bin/setup(a.envwritten under a 0077 umask with a generated receipt key,db:migrate,orion:apply) andbin/walkthrough.sh(writes, search, a versioned delete, a breaking manifest change caught bydrift --checkand refused byapply, a write while version 1 serves,reindex, search on version 2 with nothing lost,prune,verify, and the seven doctor checks required ok). CI gains ademojob running it against an Elasticsearch service. The endpoint fixtures of revision §5’s harness wait for the endpoint (WP8). · Reason: revision §5 and §7: “walkthrough green in CI”. · Rejected: a CLI-only walkthrough (the point is the application’s own transaction). - 2026-10-26 · WP5 · Hardening after the WP5 code and security reviews, each test-first. (1)
CycleExecutorreads on the write driver: Cycle’squery()uses the read driver, a replica on a read/write split host, which would read stale offsets and leases and break the same-connection invariant. (2) On MySQL,SqlStorage::update()counts its matches withSELECT COUNT(*) … FOR UPDATE, a current read, instead of a snapshot read: under repeatable read a compare-and-set another transaction had already won counted as a match (a two-connection test reproduced it; this was latent since WP2 and reachable through every lease and claim). (3) to (5) as above: the claim grace and engine check, redaction, the bus handler guard, the fail-closed receipt settings, the project-root manifest default, order-independent registration, fix re-checks, the lag threshold. (6) The demo validates and bounds its input and notes that its writes need the application’s authentication. · Reason: each was a confirmed or reproduced failure path. · Rejected: areceiptsRequiredswitch (the registry cross-check inverifydetects what it would prevent); a size cap on manifests (they are repository files, not input). - 2026-10-26 · WP5 · Carried forward. WP6: the module binds
Tenancyfromorion-search/polariswhenuniveros/polarisis present (todayORION_TENANCY=singleor a binding); the outbox, counters, drain, reindex, prune, abort and the doctor checks key on the index name alone. WP8: the module serves thesearch:endpoints throughorion-search/psr15behind a module middleware, and the harness replays recorded endpoint fixtures through the Relay pipeline; the manifest’ssourceandsearchkeys get their meaning. WP9: the core MCP tools adapted toAltair\Mcp. Upstream:univeros/doctor’sDoctorConfigurationshould resolve its registry through the container. Owner: whetherbin/check-imports’ engine-client patterns should exclude Orion’s own adapter namespaces (then the module can readORION_ENGINE=elasticsearch). CI: none of this has run in CI (billing, #13). - 2026-10-26 · WP5 ·
bin/check-importsmatches root namespaces only: each pattern must start a line or follow a non-name character, optionally through the\that fully qualifies it, soOrion\Elasticsearch\is no longer mistaken for the Elasticsearch client whileuse Elastic\…andnew \Elastic\…outside the adapter still fail (tests for both). The module now builds the engine as revision §5 says:ORION_ENGINE=elasticsearchwithORION_ELASTICSEARCH_URL(comma-separated hosts) throughElasticsearchEngine::connect(), refused with the variable named when the URL is missing, the engine unknown or the adapter not installed (orion-search/elasticsearchstays a suggestion); a boundEngineRegistrystill wins, andexamples/univerosdrops its engine binding for the two variables. Upstream, theDoctorConfigurationregistry bug is univeros/framework#243. · Reason: the owner’s call on the open WP5 question; a vendor namespace is a root namespace, so anchoring narrows the gate to what it was meant to catch and loosens nothing it was meant to catch. · Rejected: excludingOrion\by name (one more special case per adapter); keeping the application-side binding. - 2026-10-26 · WP6 · Org scale (revision §8.1, v1 §20.1) is settled as a shared index with an injected org filter, the owner’s call. Under
Tenancy::resolvedBy()every index declares an org key and every operation runs as the tenant the resolver names at that moment, never cached: theSearcherANDsterm(orgKey, tenant)beneath the caller’s filters (so noshouldormust_notcan widen it, and Elasticsearch puts it inside each k-NN entry’s filter, before the top k), the escape hatch included;Outbox::index()anddelete()record under the engine idtenant:idand stamp the org key (a document that names another tenant is refused), so tenants sharing an id never touch each other’s document; every hit coming back must carry the tenant’s prefix or the search fails (LogicException) and returns nothing, and hits are handed back under the application’s ids. A tenant is printable UTF-8 of at most 128 bytes without:; the application’s id loses the tenant’s length plus one of the outbox’s 255 bytes, refused naming its room. The lifecycle (outbox order, drain, reindex, prune, abort, the doctor checks) stays keyed on the index name: every entry already carries its tenant in its id and its org key, so a replay rebuilds every tenant’s documents exactly, and no system job needs a tenant or an “any tenant” scope. Shared statistics (BM25 term frequencies across tenants) and timing remain side channels of the layout, not returned documents. · Reason: the TenantScope contract is the same either way (v1 §9); a shared index costs nothing per organization, and isolation is proven byTenancyIsolationTestCaserather than assumed. · Rejected: index-per-org (an index, alias, counter and lifecycle per organization, and every system job iterating tenants; it can join later behind the sameTenantScopeif a large organization needs physical isolation); trusting the projector to set the org key. - 2026-10-26 · WP6 · The enforcement points.
ScopeInjectorturns the tenancy into each operation’sIndexScope(unscoped under single tenancy, where every method is the identity, so single-tenant behaviour is unchanged) and refuses a schema that does not fit: an org key exactly when tenants are resolved, checked inPlanner::apply(),Reindexer::reindex()and on every search and write; under a resolver, writing to an index not yet built is refused, because its org key is unknown. TheSearcher(Graph::searcher()) is the one way Orion queries an engine;NoUnscopedQueryPathTestreads every public method of every package (found through eachcomposer.json’s PSR-4 roots) and fails on any new one that takes aQuery(union and nullable types included) or a native callable outside theSearcher,IndexScope, the engine adapters and Elasticsearch’sQueryCompiler, which executes nothing.Graph::engines()stays public: the application constructs its adapters and holds them anyway, so hiding them would protect nothing, and the lifecycle, the CLI and the doctor need them. The failure contract for WP8:NoTenant(no tenant in the context; unauthorized),LogicException(configuration that does not fit, or a foreign hit; a server error),InvalidArgumentException(input, or an index not built). Hybrid retrieval has no core fusion yet (both engines refuse it); when WP8 adds it, each leg is built from the scoped query. · Reason: the WP1 security review asked for aScopedQuerytoken or engines unreachable outside the scoped service, plus a reflection test. · Rejected: aScopedQuerytoken in the adapter contracts (PHP cannot restrict who mints it, and every adapter would change for what the reflection test already gives); making single tenancy look up registrations on every write (it would refuse writes beforeapply, which single tenancy has always allowed). - 2026-10-26 · WP6 ·
orion-search/polaris(Orion\Polaris\ActiveOrganization, onpolaris/core^0.11 only, as revision §1 assigns) resolves the tenant from theorgclaim of the verified token a closure returns; no token, no active organization (as Polaris issues right after login, beforeswitch-org), or an org Orion cannot hold isNoTenant. Inuniveros/orion, a boundTenancywins; otherwise, when univeros/polaris is configured (detected by itsPolaris\Authorization\Gatebinding, which the Altair container reports for explicit registrations only), the module bindsActiveOrganizationoverHttp\RequestToken, a PSR-15 middleware just inside the action (MiddlewarePriority::ACTION - 10, read from univeros/http when installed) that holds the request’s token frompolaris.tokenoraltair:http:tokenand restores the previous one when the request ends;ORION_TENANCY=singleis then refused (only an explicitTenancy::single()binding shares one index across organizations) and a missingorion-search/polarisnames the package.RequestTokenis one shared object: sequential workers (php-fpm, RoadRunner, FrankenPHP) are supported, runtimes interleaving requests in one process (coroutines, fibers) are not, and Orion’s operations belong in actions or middleware after it. TheSearcheris bound in the container and the demo searches through it. Filtering hits through the PolarisGate(revision §1) waits for WP8: it needs the permission thesearch:endpoint declares. · Reason: v1 §9: the tenant is the Polaris organization, read per request from the token because switching organizations issues a new one. · Rejected: requiring univeros/http in the module (itsneomerx/cors-psr7pinspsr/http-message^1, which the Elasticsearch 9 client’s ^2 excludes, so the monorepo would not resolve; Univeros hosts run client 8.19 against Elasticsearch 9 for the same reason); defaulting to single tenancy where Polaris runs. - 2026-10-26 · WP6 · Hardening after the WP6 code and security reviews, each fix test-first and the key ones mutation-checked (dropping the injected filter, dropping id qualification and post-filtering the k-NN leg each fail the isolation case). (1) Under a scope, Elasticsearch’s escape hatch refuses document references anywhere in the body (
index,_index,more_like_this,percolate,indexed_shape) andwrapper(a query hidden from inspection): a terms lookup of another tenant’s document matched the tenant’s own hits on its values, an oracle verified against a live server, and anindexnaming another index also probed which indices exist. A field literally namedindexcannot be named in a scoped native body; the neutral query has no such limit. (2) Native mappings may not retype, normalize or shadow (runtime field) the org key, norcopy_tointo it: any of these lets one tenant’s filter match another’s documents. (3) An index holding documents never changes its org key, adding one included: entries recorded under another key (or none) would replay into documents no tenant can find; the path is a new index recorded again per tenant. (4) The isolation harness asserts each query’s exact ids and a k-NN whose nearest documents are all foreign; the resolver it uses throwsNoTenant, as real ones do. (5)bin/check-importsreads every PHP source underpackages/andexamples/(vendor excluded,.phtmland.incincluded), matchesusestatements in any case (elsewhere case matters, soelasticsearch\nin a string is not a namespace) and sees group use; it remains a lint, not a parser. · Reason: each was a verified or reproduced path. · Rejected: an allow-list of Elasticsearch query types (it would refuse ordinary queries Orion has no reason to judge); restricting tenants to[a-z0-9_-](exact keyword matching already keepsAcmeandacmeapart, and Polaris ids are UUIDs). - 2026-10-26 · WP6 · Carried forward. WP7: the Solr adapter passes
TenancyIsolationTestCasewith its ownnativeShapes()andnativeScopeEscapes(), including Solr’s document-reading constructs ({!join fromIndex=…},mltby id). WP8: thesearch:endpoint andorion-search/psr15call theSearcher, mapNoTenantto 401 and the other two to 500 and 4xx, filter hits through the PolarisGatewith the endpoint’s permission, and build each hybrid leg from the scoped query; the endpoint fixtures run with two tenants. WP9:compile_querycompiles the scoped query (IndexScope::query()), so an agent sees the tenant filter in the DSL it inspects, and every MCP tool that searches goes through theSearcher. Upstream: univeros/http’sneomerx/cors-psr7dependency pinspsr/http-message^1. CI: none of this has run in CI (billing, #13). - 2026-10-26 · WP7 ·
orion-search/solr(Orion\Solr\, onsolarium/solarium^6.4 || ^7.0 as the transport andext-curl) supports SolrCloud 9.x and 10.x, and only SolrCloud: collections, the Schema API and atomic collection aliases (CREATEALIAS, soCapability::NativeAliases) are what the lifecycle needs; user-managed (standalone) Solr has no aliases and shares one schema per configset, and waits until someone asks. Each physical index is a collection made from Orion’s configset (configset/: no dynamic or copy fields, so an undeclared field fails its document), uploaded file by file under a name carrying a hash of its files and copied per collection (orion.<collection>, a name no base can take), because SolrCloud keeps the managed schema inside the configset and fields added to one collection would otherwise reach every other. Writes run tolerant (an error fails its document only, read from the response header by id), parse date strings into date fields only, and are versioned by the outbox offset throughDocBasedVersionConstraintsProcessorFactory(orion_version, deletes byorion_delete_version, one request per tombstone because Solr takes a delete’s version as a request parameter): an update or delete not above the stored version is stale, and a delete leaves a tombstone (orion_tombstone) that every search excludes, twice (the compiler’s filter and the configset’sappends). The source comes back exactly as written from a storedorion_sourcefield; only text is stored besides it, for highlighting. · Reason: the conformance case’s lossless-replay semantics (version-stamped writes and deletes, a delete beating an older write of a document never indexed) need external versions with tombstones, which this processor gives on every Solr 9 and 10 cluster without plugins. · Rejected: the_defaultconfigset (schemaless fields accept anything); a configset archive (needsext-zip);ignoreOldUpdates=true(a stale write would be silently accepted, so stale and applied could not be told apart). - 2026-10-26 · WP7 · The neutral schema needed no Lucene-shaped change (the WP7 acceptance criterion): the field type’s name carries what a Solr field definition cannot (
orion_keyword_sort_facet,orion_text_en_folded,orion_vector_3_cosine), so the schema Solr reports reads back as the declaration with no side metadata, and a type Orion did not name, or whose definition no longer matches its name, reads back as unknown (fail closed, as the Elasticsearch adapter’s_metadoes). Objects flatten into dotted fields; vectors take a field type per dimension and metric, because Solr defines both on the type; every field is single-valued. Languages map onto the analysis Lucene has for each (the Elasticsearch adapter’s list, a stemmer or Snowball, the language’s own tokenizer or lower-casing where it needs one, without stop words), checked live on both Solr versions. Adapter-level differences, not model changes:matchis dismax with one term required, where only quotes and a leading+or-are syntax (dropped), so on a keyword it matches whitespace-separated exact values; a field holding a list fails its document where Elasticsearch accepts it; there are no nested documents (Capability::NestedDocumentsfalse). Hybrid fusion is left to core. · Reason: revision §7 asks for any Lucene-shaped change to be logged; none was needed. - 2026-10-26 · WP7 · The compiler (
QueryCompiler, pure) binds every value the caller gave as its own parameter and references it ({!term f=brand v=$o1},{!bool must=$o2 ...}), so no value can change a query’s shape; field names are already restricted to names. Filters are filter queries (the k-NN leg’s pre-filters); ashouldbesidemustis wrapped so it still requires a match, and a lonemust_notexcludes from everything. The index’s field types are read only when a query needs them: a date string on a date field (a term or a range bound) is an instant, a whole float on an integer field is an integer, an object exists when any of its fields does. The escape hatch (raw(),callable(): arrayof request parameters) is unrestricted under single tenancy; under a scope (NativeScope) it admits only what can be checked, because Solr cannot be told which query parsers to refuse and local parameters nest, quote, escape and dereference past any pattern: Lucene, dismax or edismax syntax with no local parameters, nested queries or magic fields anywhere (read after unescaping\uXXXXand backslashes), no function reading collection-wide statistics (docfreq,idf,ttf,maxdoc,numdocs, …), exactly named parameters that act within the matched documents, highlight parameters that only display, at most a page of rows; the scope’s filter queries and the tombstone filter are appended, the envelope’s fields and JSON forced. · Reason: the WP6 hand-forward, and the WP7 security review, which turned the first design (a parser deny-list) into a verified cross-tenant oracle on both versions. · Rejected: a parser deny-list (bypassed by nesting,type=$tand escaped_query_); a local-params parser in PHP (a second, divergent implementation of Solr’s grammar at the tenant boundary). - 2026-10-26 · WP7 · Hardening after the WP7 code and security reviews, each fix test-first and the guards mutation-checked. Security: the escape-hatch guard above, every bypass the review verified in the isolation and unit suites;
docfreq-style statistics refused; upload limits back to Solr’s defaults. Code: five portable behaviours the same neutral query broke on Solr, now in the shared conformance case and green on every engine (missing sort values come last either way, only a matching field is highlighted,matchon a keyword finds a hyphenated value, term filters read dates and whole floats as their fields declare,existson an object); a deleted document is found by no query, match-all and the escape hatch included, also in the shared case (Solr keeps tombstones as documents; mutation-checked).ensure()survives what a crash or a concurrent build leaves: the base configset is uploaded again every time (an interrupted upload stayed listed with a file missing and broke every later collection, reproduced), an existing collection is completed with the declared fields it lacks, a concurrent creation is joined, a failing rollback keeps the original error, and an outage is not reported as a missing index; a document using one of Orion’s own fields or not encodable fails alone, and declaring one is refused. Documented, not changed: a batch never carries one id twice (the outbox is compacted, soUpdateResponsekeys outcomes by id); dropping an alias’s name is a no-op; BM25 statistics stay shared across tenants on a shared index. · Reason: each was a verified or reproduced path. · Rejected: an allow-list of Solr query parsers through a server plugin (it would need a jar on every cluster). - 2026-10-26 · WP7 · Carried forward. WP8: the Solr adapter joins the property-based query-equivalence suite against Elasticsearch, which is where the documented
matchand multi-value differences should be pinned or closed; core-side hybrid fusion runs each leg throughSolrEngine::search(); the endpoint maps the Solr adapter’sRuntimeException(“Solr refused to …”) like Elasticsearch’s. WP9:compile_queryon Solr returnsQueryCompiler::compile()’s parameters for the scoped query. WP10: OpenSearch can reuse the Elasticsearch adapter’s compiler and the lessons here: a scoped escape hatch is an allow-list, never a deny-list. CI: the Solr 10 and 9.11 services join the main job (ORION_SOLR_URL,ORION_SOLR9_URL); none of this has run in CI (billing, #13). - 2026-10-26 · WP8 · Every query is bound to the registered schema before any engine sees it:
Orion\Query\Binder(pure, called by theSearcher) refuses a field the index does not declare, a clause on a type it cannot read (match, phrase and prefix read text or keyword; term also numbers, dates and booleans), a filter on a field not flaggedfilterable(range on keyword, number or date only; exists on any field), a sort on a field notsortable, a facet on one notfacetable, a highlight on a non-text field, a vector of the wrong dimensions, and a value not of the field’s type (whole numbers for integer, numbers for float, booleans, ISO 8601 dates; a string for keyword), with the path named; it applies the schema’s query-time boost to each clause as a weight.Clausegainsfields(weight by field,Clause::match()taking one field, a list, or weights by field) andweighted(); the declarative form addsfieldsandboost; adapters compile what they are given (multi_match best_fieldsandboost, dismaxqfwith^weightand{!boost}, the in-memory engine’s weighted token count), and the in-memory engine no longer applies the schema boost itself, so boost reaches every engine the same way, from core. Deep paging:Page::WINDOW(10 000, Elasticsearch’smax_result_window, the lowest of the engines) is the one offset ceiling, refused by the searcher asUnsupportedClause::deepPaging()(capabilityCursorPagination) on every engine. · Reason: the WP1 and WP2 hand-forwards (values checked against field types and flags, query-time boost from the registered schema, deep-paging ceilings asUnsupportedClause); one check in core answers every engine alike and before compilation, which the endpoint turns into a 400 with its path. · Rejected: per-engine checks in the compilers (three divergent answers to the same mistyped query); the Solr compiler reading the registered boost (the adapter contract takes no schema). - 2026-10-26 · WP8 · Hybrid retrieval is fused in core by the
Searcherwhen the engine lacksCapability::HybridFusion(every engine today): reciprocal rank is the default when a query has lexical clauses and a vector clause and no fusion; each leg is the scoped query (the tenant filter beneath both rankings) without its sorts, the lexical leg keeping facets and highlights, each read to the page’s depth, at mostSearcher::FUSION_WINDOW(1000; deeper isUnsupportedClause::deepPaging()); the page is sliced from the fused ranking, the total counts the fused candidates and is an estimate, a sort other than relevance is refused (relevance ascending reverses the fusion), weighted fusion is refused as before. The isolation case gains two hybrid queries (one whose k nearest are all foreign). · Reason: v1 §5.1 (reciprocal rank is the portable default) and the WP6 hand-forward (each leg built from the scoped query). · Rejected: fusion in adapters (orchestration), asking the engine for both legs in one request (no engine without native fusion can). - 2026-10-26 · WP8 · No engine returns vector fields in a hit’s source: Elasticsearch 9 leaves
dense_vectorout of_sourceon its own, the adapter’s mapping excludes every vector path from_source(mappings._source.excludes) so 8.19 does the same, the in-memory engine strips vectors from every hit and the Solr adapter leaves lists (a vector, the only list its fields accept) out of the storedorion_source; the conformance case pins it on every engine and version. Solr sorts by relevance through itsscorepseudo-field (the neutral_scorecompiled to it; it was passed through and refused), pinned in conformance. · Reason: the endpoint fixtures replayed on Elasticsearch showed both; a hit must be the same on every engine, and a thousand floats per hit serve no client. · Rejected:_sourcefiltering as a query feature (the WP2 hand-forward; what a client does not need by default it can ask the application for). - 2026-10-26 · WP8 · The
search:manifest’ssearchandsourcekeys have their meaning.search({@see Orion\Manifest\Endpoint}) declaresendpoint(GET /pathreading the query string, orPOST /pathreading a JSON body), an optionalpermissionthe host enforces,query_fields(text or keyword fields, as a list or weights by field), an optionalhybrid(vector_field, optionalfusion; POST only, the vector travels in the body),filterable,sortable(_scoreincluded),facets,highlight,default_sort(fieldor-field) andpage_size(the default and the most a request may ask, 20 by default); every field is checked against the schema’s types and flags when the manifest loads, a route declared twice is refused.source({@see Orion\Manifest\Source}) names theentityandprojectorclasses;Orion\Projection\Projector(project(object): Document) is the contract (v1 §13); core records the names and instantiates nothing. · Reason: v1 §12; a request may then only name what the endpoint declares, and the endpoint only what the schema flags. · Rejected: facets chosen per request (the manifest’s are always computed), asizeabove the endpoint’s page size (secure by default). - 2026-10-26 · WP8 ·
orion-search/psr15(Orion\Psr15\, the PSR-7/15/17 interfaces andpsr/logonly):SearchMiddlewareserves every declared endpoint under the host’s mount prefix, exact path, 405 withAllowfor another method, pass-through otherwise;SearchHandlerruns one endpoint: the host’sAuthorizerport for the declared permission (an endpoint declaring one with no authorizer configured answers 500, never open),SearchRequest(qover the query fields,filter[field]as a term,filter[field][]as terms,filter[field][gt|gte|lt|lte]as a range,sortasfield,-fieldor a list,page,size,vectoron a hybrid endpoint, with k the page’s depth; query-string text read as the field’s type; a JSON body at most 1 MiB, an object, 32 levels), the scopedSearcher, and theEnvelope(hits with id, score, source and highlights; total, total_is_estimate, facets, took_ms, page, size, fusion with its contributions; empty maps as objects). The failure contract of WP6, as RFC 9457 problems underhttps://orion-search.dev/problems/:NoTenant401,Forbidden403,InvalidArgumentException400 with its message,UnsupportedClause400 with the capability, anything else 500 with the cause logged and never echoed.RequestTenantis the tenant middleware for hosts without Polaris: a closure reads the tenant from the request (an attribute the host’s authentication set, never a client header), held while the request is handled and restored after, aTenantResolverthat throwsNoTenantoutside one. · Reason: revision §1 and §5; the module and any PSR-15 host share one implementation. · Rejected: 422 for input (one status for “what you sent does not fit”), a handler per index in the host’s router (the manifest is the route table), echoing engine errors (they name indices and fields). - 2026-10-26 · WP8 · In
univeros/orion,SearchEndpoints(a module middleware atRequestToken::priority() + 1, built on the first request) serves the endpoints throughorion-search/psr15, with PSR-17 factories from the container or laminas-diactoros (which the skeleton ships), the permission checked through the PolarisGatewith the request’s token when univeros/polaris is configured (GateAuthorizer: no token isNoTenant, a token without the permissionForbidden) or a boundAuthorizer, the prefix fromORION_ENDPOINT_PREFIX. The module also registers one framework route per endpoint (RoutesProviderInterface, the actionorion.search.actionbound to univeros/http’sActionoverUnservedSearch, a domain never reached), because the skeleton’s dispatcher answers an unrouted path with 404 before the action stage, and the token is only captured after it; the routes are read from the manifests through the process environment, since the route table is collected before the container is handed over. The agent manifest section lists each endpoint and permission. The hit filter of revision §1 is this permission check:polaris/core’sGatedecides per permission, not per document, so filtering every hit through it with the endpoint’s permission is the endpoint check; a per-document policy would slot into theAuthorizerport. · Reason: revision §5 and the WP5 and WP6 hand-forwards;RequestTokenmust run first, so the endpoint cannot sit before the dispatcher asPolarisMiddlewaredoes. · Rejected: scaffolding an Action, Input, Domain and Responder per endpoint (the v1 §12 slice; one generic route and one middleware serve every endpoint byte-identically), requiring univeros/http in the module (its CORS dependency still pins psr/http-message 1). - 2026-10-26 · WP8 · The endpoint fixtures (
Orion\Testing\Endpoint\EndpointFixtureTestCase,Harness,Fixture): two tenants with the same ids on one shared index behind a GET endpoint with a permission and a hybrid POST endpoint (packages/testing/fixtures/manifests), the requests naming their tenant and user as the host’s authentication would resolve them; recorded once through the bare PSR-15 pipeline on the in-memory engine (ORION_RECORD_FIXTURES=1), keyed by the test, and replayed through the bare pipeline on Elasticsearch 9 and 8.19 and Solr 10 and 9, and through the Univeros module (an Altair container booted as an application boots it, Cycle storage, a real PolarisGateover fake repositories, the module’s middleware in priority order). Scores and timings are masked; hit order is compared only where the request sorted; a real engine’s leftovers of a previous run are dropped first. The harness stands in for univeros/http’s pipeline with the same orderingModuleMiddleware::collect()gives Relay, because univeros/http cannot be installed beside the Elasticsearch 9 client; the demo walkthrough runs the real pipeline. · Reason: revision §5 and §7 (recorded endpoint fixtures through the PSR-15 and Univeros harnesses). · Rejected: fixtures per engine (one recording proves the promise). - 2026-10-26 · WP8 · Property-based query equivalence (
Orion\Testing\Equivalence\QueryEquivalenceTestCase,QueryGenerator,Seeded, no dependency: a 31-bit linear congruential generator,ORION_SEED): over a generated corpus of 40 documents, 120 generated queries (match on one or several fields, term, prefix, phrase, term, terms, range, exists and bool filters nested twice, a total order or none, facets, pages, k-NN and fused hybrids) run through the searcher on the engine under test and on the in-memory engine, whose semantics the conformance case defines, and must answer the same ids, total and facet counts, the same order where the query orders totally. A vector-bearing query is held to the approximate promise real engines make (Elasticsearch quantises to int8 by default, Solr’s HNSW is approximate): as many hits as the oracle, each among the oracle’s k + 3 nearest. Green on Elasticsearch 9, Solr 10 and Solr 9 for several seeds. Pinned, not closed, by the generator and the case’s documentation: stop words and inflected words (each engine’s language analysis differs; the vocabulary has neither), several words in amatchon a keyword (Solr’s dismax reads them as alternatives; Elasticsearch and the in-memory engine match the whole value), list values (Solr’s fields are single-valued), a page of a relevance ranking (only a totally ordered query pages portably), highlight fragments, scores. · Reason: v1 §18.2 and the WP7 hand-forward (the owner’s call: a seeded generator inorion-search/testing, no require-dev library). · Rejected: closing the keywordmatchdivergence in the Solr compiler (it needs the field types, a schema round trip per query, where the adapter contract carries no schema); comparing Elasticsearch to Solr directly (each equals the oracle, so they equal each other). - 2026-10-26 · WP8 · Hardening after the WP8 code and security reviews, each fix test-first, the tenant invariant mutation-checked (dropping the injected filter fails the fixture cases and the isolation case). Code: the tenant is resolved before the “not built” check, so a request without one learns nothing; a manifest declaring weighted fusion, a non-relevance default sort on a hybrid endpoint, or a non-positive query-field weight is refused when it loads instead of failing every request; the binder refuses calendar-invalid dates, integers beyond 64 bits and a
termon analysed text (exact on one engine’s token, not another’s); the in-memory engine scores a multi-field match by its best field, asbest_fieldsand dismax rank; the response is encoded inside the handler’s failure contract. Security (no cross-tenant path was found; ~60 hostile requests on three engines): every endpoint response isCache-Control: private, no-storeandnosniff, since one URL answers each tenant differently; a sort naming a field twice and aqover 64 words are refused (Solr’s clause limit turned a longqinto a 500); the body size is checked before it is read; the fixture case asserts on every response, before comparing it to the recording, that each hit, facet count and total is the requesting tenant’s, so a re-recording can never bless a leak, and it names the org key as a filter and a sort (refused). Noted, not changed: an endpoint without apermissionis served to whoever the host lets reach it (under single tenancy without Polaris, anyone; “public” is the declaration’s meaning, documented); on such an endpoint, parameter errors are answered before the tenant check, which tells a caller the endpoint’s declared vocabulary (the manifest is the API’s documentation anyway; dimensions and the schema are checked after the tenant); an index missing on the engine while registered names the physical index in a 400 (an operator’s mismatch, not reachable from input). · Reason: each was verified or reproduced. · Rejected: a 503 for an index not built (WP6’s contract puts it under input, 4xx, and the owner decided it); subclassing every input exception to tell client errors from engine ones (the binder refuses what an engine would, so an engine’s refusal is a mismatch worth seeing). - 2026-10-26 · WP8 · Carried forward. Open from WP1/WP2, deliberately: cursor pagination behind
Capability::CursorPagination(the offset ceiling refuses deeper pages and names it; Elasticsearch needs a point in time andsearch_after, SolrcursorMarkwith the unique key as tiebreaker; it joins when a consumer needs it),prefixcost limits,_sourcefiltering (vectors are now never returned; the rest waits for a need). WP9:compile_querycompiles the bound, scoped query (Binder::bind()thenIndexScope::query()), so an agent sees the weights and the tenant filter;EndpointandSourceare what the MCP surface lists. WP10: OpenSearch reuses the Elasticsearch compiler’smulti_matchand the endpoint fixtures run on it. Upstream: the Univeros skeleton binds no PSR-17 factories (the module falls back to laminas-diactoros); univeros/http’s CORS dependency pins psr/http-message 1. CI: none of this has run in CI (billing, #13). - 2026-10-27 · WP9 · The agent surface lives in core as
Orion\Tools(v1 §15, revision §6 lists noTools/directory; it joins the layout): aToolis a name, a description, the JSON schema of its input and the call, JSON in and JSON out, framework-free, over theGraph.Tools::for($graph)builds the eight:list_indices,describe_index,capabilities,compile_query,search,plan(read-only) andapply,reindex(mutating). Input is checked at the boundary (Input::object()with the tool’s keys, the index name,confirma boolean,batcha positive integer) and the query tools take a query’s declarative form (Query::fromArray()), so the searcher’s binding judges every field and value as it does for the endpoint.describe_indexreads the manifest’s declaration and the engine’s live fields; no tool constructs anIndexSchema(the WP1 decision holds). · Reason: a host adapts tools, it does not define them; the Univeros module, a later standaloneorion-search/mcpand the CLI then agree on what an agent can do. · Rejected: tools in the module only (the next host would rewrite them); the CLI commands reused as tools (they are Symfony Console; the tools return data the console then prints, not the reverse, andCli\Context’s declared-schema and engine resolution is duplicated inTools\Declaredrather than moved, so the CLI’s contract stays). - 2026-10-27 · WP9 ·
compile_queryisSearcher::compile(): the same steps assearch()(the tenant first, the index’s current registration, the query bound to the registered schema, the offset window, the tenant scope injected, the split into a lexical and a vector leg when the engine lacksCapability::HybridFusion), ending in the engine’s native form instead of a request. Engines expose that form through a new optional port,Adapter\QueryCompiling::compile(IndexName, Query), whichsearch()itself goes through (Elasticsearch: the client’sindexandbody; Solr: the select path and parameters, with the field types read from the live schema as a search reads them; in memory: the neutral query, which is what that engine runs). The answer is aQuery\Compilation: engine, the physical index targeted, the bound and scoped query, and eithernativeor the twolegswith thefusion. The conformance case requires every engine to be aQueryCompilingwhose form is deterministic and JSON.NoUnscopedQueryPathTestreviewsQueryCompilingandCompilation;search()andcompile()share private helpers so they cannot drift apart. · Reason: the acceptance criterion is inspectable native DSL without executing, and an agent is only helped if what it sees is exactly what the engine receives, tenant filter included. · Rejected:compile()onSearchAdapter(a contract change every adapter and the module rely on; an optional port is additive, and the searcher refuses an engine without it with aLogicException); a compiler that takes a schema (the adapter contract takes none; Solr’s types come from the live index, as insearch()). - 2026-10-27 · WP9 · Mutating tools are gated twice, as v1 §15 asks (“exposed but gated behind explicit confirmation”): in core,
applyandreindexwithoutconfirm: truereturn the preview (the CLI’s--dry-runshape: what would happen, the plan, the next version, the outbox size) and change nothing; in the module, a confirmed call also needsmcp:serve --allow-writes(and not--readonly), the framework’s own switch for its migrations, refused as aGuardrailExceptionthe server reports as a tool error. A preview never needs permission. · Reason: an agent sees before it acts, and an operator decides at server start whether an agent may act at all. · Rejected: a confirmation token round trip (nothing to bind it to in a stateless tool call). - 2026-10-27 · WP9 · In
univeros/orion, the tools areMcp\*Toolclasses implementingAltair\Mcp\Contracts\McpToolInterface, one per core tool, each carrying#[McpTool(name: 'orion__' . <Tool>::NAME, description: <Tool>::DESCRIPTION, inputSchema: Schema/<name>.json)]: the name and description are the core constants, the schema file is generated from the core tool’s schema andMcpToolsTestkeeps the copies equal. The framework discovers user tools by scanning directories thatmcp:servedoes not expose, and it binds itsToolRegistryafter the modules apply, soModule::apply()registers the descriptors through$container->extend(ToolRegistry::class, ...), which survives that rebinding (the pattern the doctor checks use);bin/altair mcp:toolsandmcp:servethen list and serveorion__*, proven in the demo walkthrough over stdio. · Reason: revision §5 assigns the MCP adaptation to the module and the framework has no module hook for tools; a decorator is the one seam that works. · Upstream: aMcpToolsProviderInterfacefor modules would remove the decorator (to be filed on univeros/framework with #243). - 2026-10-27 · WP9 ·
univeros/mcpcannot be installed in this repository: it requiresuniveros/introspection→univeros/http→neomerx/cors-psr7, which pinspsr/http-message1, while the Elasticsearch 9 client needs 2 (the wall WP8 met withuniveros/http). The seven contracts the module adapts to (McpTool,McpToolInterface,ServerMode,McpException,GuardrailException,ToolRegistry,ToolDescriptor) are copied verbatim from univeros/mcp v2.6.0 intopackages/univeros/tests/Stubs, mapped by the root’s and the module’sautoload-devonly, excluded from PHPStan and phpcs, and documented there. A host that installs the real package never reads them; the demo (full framework) proves the real registry and server.univeros/mcpis asuggestof the module. · Reason: the module must compile and be tested here, and the alternative (requiring the package in the module) would make the module uninstallable beside Elasticsearch 9. · Rejected: arequire-devon univeros/mcp with the ES client downgraded (the root suite runs ES 9); hand-written stubs (verbatim copies cannot drift in meaning). - 2026-10-27 · WP9 ·
ResultSet,HitandFusionTracehavetoArray()in core (empty maps as objects, so JSON keeps their shape);Psr15\Envelopecomposes the response from them withpageandsize, byte-identical to before (the endpoint fixtures pin it), and thesearchtool returns the same shape.FaultyEngine::claim(Capability ...)lets a test steer core’s path (native fusion) without an engine that has it. · Reason: one serialisation of a result, used by the endpoint and the tools. - 2026-10-27 · WP9 · Carried forward. Open, deliberately:
list_indicesreports no document count (the adapter contracts have no count primitive; it joins when a consumer needs it); the tools run as the host process, so under a resolver tenancy a call without a tenant in context isNoTenant(an agent acting for a tenant needs a resolver the host wires, as the endpoints get theirs from the token); a standaloneorion-search/mcpserver over the core tools (revision §1 lists it as later). WP10: OpenSearch implementsQueryCompilinglike Elasticsearch (the client’sindexandbody) and the conformance case checks it. WP11: Meilisearch’s native form is its search request; itsmaxTotalHitsis belowPage::WINDOW. Upstream: a module hook for MCP tools in univeros/framework (see above). CI: none of this has run in CI (billing, #13). - 2026-10-27 · WP9 · Hardening after the WP9 code and security reviews (no critical or high findings; live bypasses on three engines found no cross-tenant path). (1) The acceptance is proven per engine, not only structurally: the conformance case sends what
compile()showed through the adapter’s ownraw()and requires the same ids, total and facets assearch()on Elasticsearch 9 and 8.19 and Solr 10 and 9 (compiledAsNative()), socompile()cannot drift from the requestsearch()sends. (2) The declarative query form is bounded in every list (Query::MAX_CLAUSES64,MAX_FILTERS64,MAX_SORTS8,MAX_FACETS16,MAX_HIGHLIGHTS16,Filter::MAX_CHILDREN64 per bool list) andQuerySchemasays so withmaxItems, so one tool call cannot build an arbitrarily large engine request. (3) The tools are the operator’s:list_indices,describe_index,capabilitiesandplananswer without a tenant and show schema-level facts only (names, versions, hashes, the org key’s name, never a tenant’s value or count);applyandreindexact on the whole index, every tenant included, as the CLI does, and their descriptions say so;searchandcompile_queryare the two that need a tenant and fail closed asNoTenantwithout one. Overmcp:serve’s stdio there is no request tenant, so on a multi-tenant host those two need a resolver the host wires. (4) The module gate is tested in every mode (readonlywins overallowWrites;reindexas well asapply) and the demo proves the real wiring:orion__apply confirm:trueovermcp:serveis refused without--allow-writesand answersunchangedwith it. (5).gitattributesexport-ignores tests, fixtures and the dev stubs. Noted, not changed: engine error text reaches the agent as the tool error (the operator’s own agent;Assert::show()truncates input echoes);QuerySchemais a guide looser than the parser (core refuses what it does not express, naming the path);ResultSet::map()is a public helper theHitform shares. - 2026-10-27 · WP10 ·
orion-search/opensearch(Orion\OpenSearch\, onopensearch-project/opensearch-php^2.7, one client major for OpenSearch 2.19 and 3.x) is the Elasticsearch adapter’s shape over OpenSearch’s client: strict mappings with Orion’s_meta, externally versioned bulk writes and deletes read back by the ids Orion sent, atomic alias swaps, the same Query DSL for every lexical clause, filter, sort, facet and highlight.OpenSearchEngine::connect($url)builds the client through the package’s Guzzle factory (one base URI; a cluster sits behind a load balancer or the host builds the client itself, since the multi-hostClientBuilderis deprecated in the client).BulkResponseand most ofMappingare copies of the Elasticsearch package’s; the two clients differ in transport (OpenSearch’s answers decoded arrays), the two engines in vectors, and neither package may depend on the other’s client. · Reason: revision §1 and §7 (WP10 proves the split-package model is cheap: the package is one day’s work, copied where the engines agree and changed only where they do not). · Rejected: a sharedorion-search/elastic-commonpackage for the copied parts (a third package with no client of its own, kept in step with two forks that drift apart; the copy is 500 lines and the conformance case, not shared code, is what keeps the two honest); the deprecatedClientBuilderfor several hosts. - 2026-10-27 · WP10 · Vectors go through the bundled k-NN plugin: a
knn_vectorfield on the Lucene engine (HNSW; the neutral metric as its space type,cosinesimil,innerproductorl2, read back from the method or from the field for an index created elsewhere),index.knnon for any index holding one (the plugin refuses a method-backed field without it), and the neutral vector query as the plugin’sknnquery insidequery, its filters as the query’s ownfilterso the neighbours are gathered within them rather than cut down afterwards.index.knnis a static setting, so a vector added to an index created without one isRequiresReindex, checked live before the mapping update. Hybrid fusion is left to core (Capability::HybridFusionfalse): OpenSearch’s native hybrid search needs a search pipeline (a cluster resource the lifecycle would have to own) whose score normalisation is not the deterministic reciprocal-rank fusion the determinism boundary promises. · Reason: v1 §16 (vectors yes, native fusion left to core where it would cost determinism) and the conformance case’s vector and hybrid tests, green on 3.9 and 2.19. · Rejected: the faiss engine (itscosinesimiland filtering support vary by version where Lucene’s hold on both lines); native hybrid through a pipeline (non-deterministic across versions and shards, and a resource outside the index). - 2026-10-27 · WP10 · The scoped escape hatch refuses what OpenSearch adds to the Elasticsearch 7.10 search API besides what the Elasticsearch adapter refuses:
search_pipeline(processors run outside the query),ext(rerankers and neural context),derivedfields (scripts) andknnat the top level (not OpenSearch’s shape). The scope is ANDed with the query and injected into everyknnquery’s filter anywhere in the tree, so a scoped native k-NN still returns the tenant’s nearest neighbours rather than whatever survives post-filtering. The isolation and conformance suites run their native shapes and escapes on both server lines. · Reason: revision §7 WP6 (no escape hatch returns a foreign document) and WP10 (conformance on both lines). · Rejected: allowingsearch_pipelineunder a scope (a pipeline can add results or rewrite the query). - 2026-10-27 · WP10 · Carried forward. WP11: Meilisearch has no aliases (the core indirection table of v1 §9), async writes awaited through its task API (
PendingTask), amaxTotalHitsdefault belowPage::WINDOWto raise when the index is ensured,filterableAttributesandsortableAttributesset from the schema, its own native form forQueryCompiling(the search body) on one private builder so the conformance replay proof holds; the acceptance line is that nothing leaked upward into the ports. Open from WP10, deliberately: cursor pagination (reported as a capability, the offset window is the ceiling, as on Elasticsearch); OpenSearch’s AWS-signed client and the Symfony HTTP client (the host builds the client and passes it); OpenSearch 1.x (end of life); theknn_vectornested-document variant. CI: none of this has run in CI (billing, #13). - 2026-10-27 · WP10 · Hardening after the WP10 code and security reviews. A
knnquery anywhere in a native body gathers neighbours, and the security review showed that one outsidequery(a post filter, a rescore, a highlight query, another k-NN query’s filter, or a node decoded as an object) escaped the scope injection: no foreign document came back, but whether an own document did told where the foreign vectors lay, a proximity oracle. The scoped escape hatch now takes the body as the JSON it would be sent as (objects as objects, so an empty{}survives and a serializable object cannot hide a reference), and ANDs the scope into everyknnquery at any depth, recursing into their filters; the isolation suites probe each placement with the foreign tenant’s nearest vector. The same gap held in the Elasticsearch adapter for theknnquery clause of 8.12 and later, which scoped only the top-levelknnentries; it is closed the same way, every k-NN filter now written as oneboolfilter. Also from the reviews: Guzzle is a runtime requirement oforion-search/opensearch(connect()and the module’sORION_ENGINE=opensearchbuild the client through the Guzzle factory, so a host without it failed fatally instead of being told); a vector query’s page is cut at k (the plugin returns k neighbours per shard, so a wider page on a multi-shard index returned more than k); only a floatknn_vectoron the Lucene engine reads back as a vector (another engine, a byte or binary vector, or a model-backed field is unknown and fails a plan closed);index.knnwritten flat in native settings is replaced, not contradicted; native mappings cannot shadow the org key with aderivedfield; native settings cannot name an ingest or default search pipeline (the one would rewrite a document after Orion stamped it, the other a query after the scope restricted it). Not changed: a native body with a field literally namedknnis scoped as a k-NN query and fails to parse (the engine’s error, not a leak; no neutral field may carry that name on a scoped index, documented here); BM25 scores carry index-wide term statistics on a shared index (inherent,explainstays refused); engine error text still reaches the caller (it names the caller’s own request). · Reason: revision §7 WP6 (no escape hatch returns a foreign document, and now, no escape hatch tells a tenant where another tenant’s documents lie). · Rejected: refusingknnoutsidequery(a scoped post filter or rescore by vector is a legitimate native query); normalising the body with associative arrays (an empty object would become an empty list the engine rejects). - 2026-10-27 · WP11 ·
orion-search/meilisearch(Orion\Meilisearch\, onmeilisearch/meilisearch-php^1.17 with Guzzle as its runtime HTTP client, for Meilisearch 1.13 and later) keeps the neutral schema where a schemaless engine can hold it: the index settings carry what the engine enforces (filterable, sortable and displayed attributes, the user-provided embedders, the pagination windowPage::WINDOWasmaxTotalHits,Facet::MAX_SIZEasmaxValuesPerFacet), and a declaration document Orion writes into the index (orion-schema, a tombstone no search returns) carries the declared fields.inspect()reads the declaration and keeps each field only where the settings still serve it (its shadow filterable, sortable where declared, its embedder of the declared dimensions), so a setting someone else changed reads back as unknown and fails a plan closed, as the Elasticsearch adapter’s_metadoes. A document is validated against the declaration before it is sent (an undeclared field or a wrong type fails alone, as a strict mapping would), carries its source underorion_src, Orion’s own attributes, and one shadow attribute per field for the compiler. Every write is a task:index()anddelete()returnBulkResult::pending()andapply()PendingTask::pending(), awaited through the ports as v1 §10 planned;flush($index, await: true)waits for every task of the index. Versions are Orion’s: a write or delete reads the stored version first and is stale when not newer, a delete leaves a tombstone every search excludes (the outbox is the one writer per index, so read-then-write holds;Capability::OptimisticConcurrencyis reported, “partial” in v1 §16’s words). Aliases are core’s indirection (Capability::NativeAliasesfalse,alias()andresolve()refuse as the in-memory engine does); nested documents are not supported; one geo field per index (_geo). The id is encoded to what the engine accepts (dand base64url, the original kept beside it). Nothing changed in the ports. · Reason: revision §7 WP11 (conformance with emulated aliases, core RRF and async writes awaited; nothing leaked upward) and v1 §16. · Rejected: a metadata index beside the data (one more index to drop and keep in step; the declaration lives and dies with its index); refusing documents without a vector (the engine wants_vectors.<embedder>: nullto opt out, which the encoder writes for every declared embedder). - 2026-10-27 · WP11 · Orion’s lexical clauses are filters on the shadow attributes, never Meilisearch’s
q:qrequires every word (or drops words from the end), tolerates typos and completes the last word, none of which is the portable match the conformance case defines (any analysed token, exactly; a term exact; a prefix the start of a token; a phrase the tokens in sequence). A text field’s shadow is its tokens, each hex-encoded, delimited by a letter no hex digit is (x72756ex73686f65x); a keyword’s shadow is its value hex-encoded; the compiler writesCONTAINS "x<hex>x",CONTAINS "x<hex>",STARTS WITH "<hex>",= "<hex>"andIN [...], numbers and booleans as such, a date as its instant. Hex because the engine’s string filters and facets are case-insensitive (acmematchesACME), a letter delimiter because the engine trims a filter value’s whitespace (CONTAINS " 63"matched insideblack), and a value in hex can never change an expression’s shape. Relevance ranking is therefore the engine’s only for the escape hatch and for vectors; a filter-only lexical query ranks as the engine stores it, andSort::SCOREis accepted and dropped.qstays available throughraw(), with the host’s own typo, synonym and ranking settings onorion_src. ThecontainsFilterexperimental feature is turned on atensure()(instance-wide; a node that refuses it, Meilisearch Cloud among them, cannot serve Orion’s queries and says so). · Reason: WP8’s acceptance (property-based equivalence with the oracle), whichqcannot pass whatever the settings. · Rejected:qwith typo tolerance and prefix search disabled per index (still every word required or dropped, not any); federated multi-search per token for OR semantics (no sort, no exact totals); amatchingStrategy(none is “any”). - 2026-10-27 · WP11 · Vectors are user-provided embeddings ranked by cosine similarity, the one metric the engine offers (another metric is refused at
ensure()asUnsupportedClause); the neutral vector query is a semantic search (vector,hybrid.semanticRatio: 1) whoselimitis k and whoseoffsetthe page, the total the neighbours returned, and a semantic search’s facets are counted by reading the window itself, because the engine counts every candidate. A vector field cannot join an index in place: the documents already indexed carry no embedding for it and the engine refuses an embedder its documents do not feed, so the plan isRequiresReindex. Hybrid fusion is core’s (Capability::HybridFusionfalse). Meilisearch 1.13’s approximate index strays further from the exact neighbours than 1.56’s and differently per build (the eighth neighbour of a seeded query ranks eleventh or twelfth of forty in ten builds, and beyond the fourteenth in one), so no slack that every build passes would still check anything: the equivalence suite runs on the current line only, and the 1.13 line is held to conformance, isolation, determinism, fault injection and the endpoint fixtures, with its vector search returning what the engine ranks nearest rather than provably the k nearest (stated in the package README). Under fusion the equivalence case now lets the fused hit count differ by the slack, because an approximate neighbour may coincide with a lexical match where the exact one did not. · Reason: v1 §16 and §18.2; the approximate promise is the engine’s, and a promise that varies per build is not one to state. · Rejected: exact search through a script or a wider limit cut client-side (the ranking is the engine’s); a slack wide enough for every 1.13 build (it would admit a neighbour from the far half of the corpus). - 2026-10-27 · WP11 · Hardening after the WP11 code and security reviews. The engine stops matching a filterable string somewhere past 460 bytes (measured: 460 matches, 470 does not, the attribute’s name counts), so a text field’s shadow is chunked into strings of at most 400 bytes overlapping by eight tokens (any string of a list matches a filter, and a phrase up to the overlap sits whole in one chunk; the code review reproduced a 300-word body losing its late tokens silently), and a keyword value is at most 200 bytes, refused per document beyond. The declared schema is read for every search and write rather than cached per instance, so a field another process added is seen at once (a long-running outbox worker would otherwise have refused it until a restart). A write this instance sent and nobody awaited is settled before the next version read, so a stale write cannot overtake it within the instance (the security review resurrected a deleted document by not awaiting; across processes the outbox remains the one writer per index, stated in the README). An id the engine cannot hold once encoded (511 bytes) fails alone before the batch, where the engine would have failed the whole batch and echoed the id into every other document’s outcome; a task that did not succeed (canceled included) fails what it carried. A vector page past the k-th neighbour starts at k, so the total stays k. A semantic search’s facets are counted from one read of the window and keyed as the engine’s distribution is keyed (dates as ISO instants, numbers as PHP prints them), and the engine sorts facet values by count so its cut at
maxValuesPerFacetkeeps the most frequent.flush()pages through the index’s tasks. The HTTP client has timeouts. A fractional value on an integer field equals nothing and bounds round to the integers they admit; a prefix on a number is refused; a range without bounds matches nothing. A date without a zone is UTC whatever the process default. Native settings are an allow-list (what shapesqand relevance for the escape hatch: searchable attributes, ranking rules, synonyms, stop words, typo tolerance, dictionary, separator tokens, proximity precision, localized attributes, search cutoff, facet search, prefix search); two fields cannot share a shadow attribute (a.banda__b). ThecontainsFilterexperimental feature is read before it is turned on, and the refusal names the key actions it needs. Not changed: the engine’s error text reaches the caller (it names the caller’s own request and the shadow attributes, the same for every tenant); tombstones are kept for every deleted id (index growth, not a leak); two writes of one id in one batch are the engine’s to order. The security review found no cross-tenant read, count, rank, facet or highlight path on either line: the native body’s filter is one ANDed list element after the tombstone exclusion and the tenant filter, and every value is hex. · Reason: revision §7 (nothing silently wrong; no escape hatch returns a foreign document). · Rejected: refusing long text (a search engine indexes descriptions); a per-index lease in core for the write race (the outbox already serialises; a lease is a WP of its own if a second writer ever exists). - 2026-10-27 · 1.0.0 · The first tag is
v1.0.0on the monorepo, cut once every work package of revision §7 was done and CI had run green on every engine (the repository went public that day, which lifted the Actions billing block of #13; Actions minutes are free for public repositories). The version line moves to^1.0between the packages and1.x-devfor themainbranch alias and the root’s path repositories, so a split-published package at 1.0.0 satisfies its siblings. The release candidate the plan foresaw was skipped at the owner’s call: the last adapter had had its say on the ports and nothing in them changed. · Reason: revision §1 (every package on one version line, released together) and the owner’s decision. · Not done yet: the per-package read-only repositories (univeros/orion.<package>) and Packagist, which make the tag installable without a clone; they are the next step.