Repository navigation
GET /meta/object/:name drops the multi-tenant indexes stamp — the fourth materialization stamp on the #8268 seam, and the one whose converger is a second implementation #8375
Description
Filed by the #8268 dev agent, measured while enumerating the stamps that cross the /meta read-exit materialization seam. Unassigned.
Blocked-by: #8268
Symptom
On a multi-tenant deployment, SchemaRegistry.registerObject stamps a tenant index onto every object it materializes:
registry.getObject('showcase_account').indexes
=> [ { fields: ['organization_id'] } ]
GET /meta/object/:name serves the same object with no indexes key at all when the by-name read is answered from the metadata service or a sys_metadata overlay row. Same object, same moment, same host.
The measurement
Real SchemaRegistry + real ObjectStackProtocolImplementation + real RestServer, metadata service holding the artifact's raw declaration (serviceMode: 'artifact'), multiTenant: true, diffed key by key:
byName vs registry diverge on keys: indexes, nameField
byName.indexes = undefined
registry.indexes = [ { fields: ['organization_id'] } ]
nameField is #8268 and is fixed there. indexes is this issue. With #8268's fix in place the same host reduces to exactly one diverging key:
byName vs registry diverge on keys: indexes
which is pinned as an EXPECTED divergence in packages/rest/src/meta-object-materialization-agreement.test.ts (MEASURES the un-converged multi-tenant indexes stamp), so this issue's fix will turn that pin red and it should be updated in the same PR.
Root cause — and it is the shape d5031f6a1 was about
The read exits converge the #6562 system-column stamp with applyInjectedSystemColumns in packages/metadata-core/src/injected-system-columns.ts. That function is a second implementation of what applySystemFields (packages/spec/src/data) does at registration, and the two have drifted: applySystemFields injects the tenant column and its index, while applyInjectedSystemColumns converges the fields map only.
This is worth stating precisely, because it is the general lesson rather than one bug:
__search(/meta/object/:nameanswers the__searchquestion two different ways — drops it for all 22 package objects, keeps it for all 71 platform objects (and the list route keeps it for both) #8038) converges by delegating to the registry (provisionSearchCompanionOnto) and has not drifted.nameField(GET /meta/object/:namedropsnameFieldfor artifact-ingested objects — the third materialization stamp to diverge on this seam #8268) had no converger at all and was simply missing.- the system-column family converges through a re-implementation, and it is the one that silently disagrees with its producer.
d5031f6a1 fixed the neighbouring getMetaItems read merge by deleting a second implementation and calling the canonical one. The same move applies here.
Where (anchored on function names, not line numbers)
packages/spec/src/data—applySystemFields, the producer; stamps the tenant column and the index.packages/metadata-core/src/injected-system-columns.ts—applyInjectedSystemColumns/resolveInjectedSystemColumns, the read-exit converger that handles the fields map only.packages/metadata-protocol/src/protocol.ts—governServedItemcomposes the converger at every read exit.⚠️ The write path owes any fix here a counterpart, the same waystripInjectedSystemColumnsandstripMaterializedStampsFromare owed:indexesis a concatenated array undermergeObjectDefinitions, not a last-writer-wins scalar, so a strip that is not exactness-bounded will duplicate entries on a GET to PUT round trip. The landed [P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326 round-trip pins catch this.
Reproduction
new SchemaRegistry({ multiTenant: true }), register an object with a title-eligible field asownership: 'own'.- Put the raw declaration into the
metadataservice underget('object', name). - Read
GET /meta/object/:namethrough a realRestServerand compare againstregistry.getObject(name). byName.indexesisundefined; the registry's is[ { fields: ['organization_id'] } ].
The harness in packages/rest/src/meta-object-materialization-agreement.test.ts already builds this host — its measure({ serviceMode: 'artifact', multiTenant: true }) is the reproduction.
Why it matters
indexes is not decoration. A consumer reading the by-name answer concludes the object has no tenant index, which is the input to migration planning, to index-advice tooling, and to any consumer reasoning about query cost. The platform does create the index; only this read denies it.
Dedup
GitHub search: applySystemFields indexes / multi-tenant index / read-exit divergence — nearest neighbours are #8323 (tenant-scoped UNIQUE indexes going global — a driver DDL concern, not a read-serving one) and #6810 (closed; applySystemFields stamping an indexed KEY that FieldSchema rejects — a spec-validity defect on the field, not the table-level indexes array). Neither is a twin. nameField divergence is #8268.
Related
#8268 (the seam and its inventory) · #8038 · #6562 (the 2026-08-08 Option B ruling this converges under) · d5031f6a1 (#8332, the same "two implementations of one job" shape) · ADR-0029 D9
Generated by Claude Code
Activity
test(rest): reference the two filed findings (#8375, #8376) from the …
hotlong commentedon Aug 13, 2026
Triage: lands in⚠️ write-path counterpart note (concatenated
packages/metadata-core(applyInjectedSystemColumns— the drifted re-implementation converger) with the pin update inpackages/rest's materialization-agreement test ⇒domain:metadata, type Bug,pm:blockedwithBlocked-by: #8268added to the body. Rationale for blocking rather than queueing: the card's own measurement is stated relative to #8268's fix ("with #8268's fix in place the same host reduces to exactly one diverging key"), and both cards editpackages/rest/src/meta-object-materialization-agreement.test.ts— same-file serial discipline while #8268 is in flight (PR open). On unlock, re-verify the file surface on the merged ref before dispatch; theindexesundermergeObjectDefinitions— an unbounded strip duplicates entries on GET→PUT round trip) belongs in the dispatch brief. Dedup confirmed as the card states: #8323 is driver DDL, #6810 (closed) is a field-level spec-validity defect — no twin.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Unblocked and dispatched —
Blocked-by: #8268is spent#8268 landed as
75fd301dat 11:08Z today (PR #8380). Triage blocked this card for two reasons and both are now discharged:GET /meta/object/:namedropsnameFieldfor artifact-ingested objects — the third materialization stamp to diverge on this seam #8268's fix" — that fix is onmain, so the predicted post-GET /meta/object/:namedropsnameFieldfor artifact-ingested objects — the third materialization stamp to diverge on this seam #8268 state (byName vs registry diverge on keys: indexes, exactly one key) is now directly measurable rather than hypothetical. The dev is instructed to confirm it as measurement 1 before planning anything.packages/rest/src/meta-object-materialization-agreement.test.ts— same-file serial discipline whileGET /meta/object/:namedropsnameFieldfor artifact-ingested objects — the third materialization stamp to diverge on this seam #8268 is in flight" — fix(metadata-protocol,objectql): the /meta read exits materialize a served base the way the registry materializes its own (#8268) #8380 is merged, so the serialisation is satisfied. The file and its pins are onmain.pm:blocked→pm:dispatched. Triage's unlock instruction — re-verify the file surface on the merged ref before dispatch — is carried into the brief as a first step, not an assumption.Triage's⚠️ note is in the brief as the card's central trap, promoted rather than mentioned:
indexesis a concatenated array undermergeObjectDefinitions, not a last-writer-wins scalar, so a strip that is not exactness-bounded duplicates entries on every GET → PUT cycle. The brief requires a two-cycle round-trip measurement specifically because a strip that never fires and a strip that is correctly bounded are indistinguishable on a single pass, and names the landed #4326 pins as the gate.Also carried in as binding:
d5031f6a1's — delete the second implementation and call the canonical one. ⛔ Not anindexesline added to the copy, which would leave two implementations to drift again. This card's own root-cause section is the argument, and it generalises: the stamp that delegates (__search) has not drifted, the stamp with no converger (nameField) was simply missing, and the stamp converged by a re-implementation is the one that silently disagrees with its producer.MEASURES the un-converged multi-tenant indexes stamppin is supposed to go red — it was written as a deliberate expected-divergence tripwire so this fix fails it on purpose. Update it in the same PR.packages/metadata-protocol/src/protocol.ts(governServedItemcomposes the converger, but the converger is what changes). That file is at its 3-editor cap, so if the fix reaches it the dev stops and reports rather than landing a fourth editor blind.multiTenant: falsethe read must serve no tenant index, or "converged" is indistinguishable from "always stamps an index".Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Claiming this issue.
session_012WMpuAfA2KSdDjGF6tm1bHclaude/issue-8375-converge-multitenant-indexes-stampDispatched with the
Blocked-by: #8268header lifted (#8268 landed as75fd301d). Will branch from currentorigin/mainand re-verify the agreement-test surface on the merged ref before planning.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
{ "issue": 8375, "status": "done", "branch": "claude/issue-8375-converge-multitenant-indexes-stamp", "pr": "https://github.com/objectstack-ai/objectstack/pull/8458", "premise_still_valid": true, "summary": "The tenant-index decision moved into ONE function, `provisionTenantScopeIndex`, called by the producer (`applySystemFields`) and by `materializeBaseLayer` — the #8268 seam every /meta read exit already replays — so the by-name read now serves `indexes: [{ fields: ['organization_id'] }]` on a multi-tenant deployment. TWO CARD CORRECTIONS, both of which change the fix: `applySystemFields` is NOT in `packages/spec/src/data`, it is `packages/objectql/src/registry.ts`; and it is therefore NOT callable from `metadata-core` (objectql -> metadata-protocol -> metadata-core, so the import runs UP the graph) — the PM's layering assumption is falsified, and routing the converger through the producer is impossible, not merely awkward. The delegate-to-the-registry-seam shape is what satisfies the `d5031f6a1` ruling here. The decision also had to leave the injection branch: it was nested under `!schema.fields?.organization_id`, which is false at a read exit because `governServedItem` injects the column first — so the predicate is now `isInjectedColumnDefinition`, preserving producer behaviour exactly. `protocol.ts` was NOT needed, as the PM assumed. A THIRD expected-divergence pin not named in the issue also flipped (`protocol-meta-effective-schema.test.ts`, whose comment read 'If a served-document consumer of indexes[] ever appears, this is the line that says so') — found by running the full suite, not by the brief.", "tests": "All five measurements predicted-then-run; every measurement rebuilt `dist` first (#8246 trap). (1) PREMISE on merged main — predicted the pin passes as written; actual 10/10 pass, asserting divergingKeys === ['indexes'], byName.indexes undefined, registry [{fields:['organization_id']}]. (2) AFTER FIX — predicted the tripwire reddens at `expect(host.byName?.indexes).toBeUndefined()`; actual exactly that line, 'expected [ { fields: [ 'organization_id' ] } ] to be undefined', 9 others green; updated pin + single-tenant control then 11/11. (3) ROUND TRIP GET->PUT->GET->PUT — predicted no growth and the row never carries the stamp; actual 7/7 in new `meta-object-tenant-index-roundtrip.test.ts`. (4) REVERSE VERIFICATION, two arms, direction predicted before running: arm A (seam call removed) predicted read-side red / controls green -> actual agreement 1 failed 10 passed, roundtrip failed at 'cycle 1 served', 6 passed; arm B (strip removed) predicted read-side GREEN and write-side red at the STORED row -> actual agreement 11/11 green, roundtrip 3 failed with first failure 'cycle 1 stored: expected [ { fields: [ 'organization_id' ] } ] to be undefined'. Restored from the commit and verified BYTE-IDENTICAL-TO-COMMIT, then rebuilt and re-confirmed green. (5) SINGLE-TENANT CONTROL — no index on either route, tenant COLUMN still present on both so the case is not vacuous. FULL SUITES post-merge: objectql 3546 passed (198 files), rest 1828 (111), metadata-protocol 1170 (80), metadata-core 162 (10); typecheck green on all four; landed #4326 round-trip pins and #6810 tenant-index pins green (152 passed). GATES: re-derived with `scripts/pm/dispatch-gates.mjs` against actual changed paths — 7 path-matched (authz-resolver, cross-package-test-inputs, durability-log-level, engine-double-contract, filter-alias-parity, meta-type-normalized, engine-split-ratio) + 2 convention-triggered (query-options-erasure, type-check-coverage) + check:nul-bytes; all 10 PASS. Head SHA 2426b670b9eeaa22ee33b000690b94b8f7a8463a.", "open_questions": [ { "question": "The brief's stated failure mode for an unbounded strip — 'DUPLICATE entries, growing the array every cycle' — is not what happens, and the correction matters for how the gate is read. Measured with the strip FULLY ablated over three cycles, the stored row goes undefined -> 1 -> 1 -> 1: it gains one phantom entry and then stabilizes, because `declaresTenantIndex` already guards the append. So a missing strip is a phantom customisation baked into sys_metadata.metadata, its checksum and every history diff — not growth.", "options": [ "A: accept the correction — the anti-vacuity arm must assert the STORED ROW, since a test checking only 'the array does not grow' passes with the strip entirely absent", "B: treat unbounded growth as still reachable by some other path (extender fold / overlay merge concatenation) and file a card to measure it" ], "recommendation": "A. The pin as written asserts both the served list length AND the stored row, and ablation B reddens on the stored-row assertion at cycle 1 — which is the arm that actually discriminates. I did not investigate whether `mergeObjectDefinitions` concatenation can grow the list through the extender fold; `subtractExtenderContributions` (#8027) appears to exist for exactly that, and it is outside this card." } ], "out_of_scope_findings": [ "filed as #8459: an author-declared `organization_id` withholds the platform's tenant index, so the RLS wall's hottest predicate runs unindexed on a multi-tenant deployment — the condition was nesting, not an argued decision; #8375 preserved it exactly rather than widening it, and it needs a real ruling (three options laid out)" ] }Generated by Claude Code
fix(metadata-protocol,objectql): the /meta read exits materialize a s…
fix(objectql): converge the multi-tenant tenant-scope index at every …
fix(objectql): declare the tenant-scope index on an author-declared o…