Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): converge the multi-tenant tenant-scope index at every /meta read exit (#8375) - #8458
Conversation
…/meta read exit (#8375) `GET /meta/object/:name` served a multi-tenant object with no `indexes` key at all when the answer came from the `metadata` service or a `sys_metadata` overlay row, while the registry's own resolved schema — and the list read — carried `indexes: [{ fields: ['organization_id'] }]`. The cause was a second implementation, not a missing line. `applyInjectedSystemColumns` (`@objectstack/metadata-core`) converges the #6562 system-column stamp at the read exits and cannot import its producer (`applySystemFields`, `@objectstack/objectql`) without running UP the dependency graph, so it re-implemented the half it could reach — the fields map — and omitted the index. So the decision moves into ONE function, `provisionTenantScopeIndex`, called by the producer and by `materializeBaseLayer` — the #8268 seam every read exit already replays. Adding a stamp there IS the convergence, which is the property that seam exists to provide. The write path owes the counterpart, and `indexes` concatenates under `mergeObjectDefinitions`: `stripProvisionedTenantIndexFrom` removes the LAST entry identical to the platform's own and keeps the removal only when re-stamping the remainder reproduces the arriving list byte-for-byte. A named entry, an author's tenant index ordered before their others, and the same entry on a single-tenant deployment are all kept. Two deliberate expected-divergence pins flip to assert convergence, as each was written to: the `indexes` case in the rest materialization-agreement suite, and the #6562/#6810 residual in `protocol-meta-effective-schema`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
…verge-multitenant-indexes-stamp
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
os-zhuang
commented
Aug 13, 2026
PM note before review — three things I got wrong, and one of them was a vacuous gateCI is running; this is not the review. But three assertions in my dispatch brief were falsified by measurement, and the third is serious enough that it needs recording before anyone reads the diff. 1. My ruled fix shape was impossible as statedI ruled: "the fix must delete the second implementation and call the canonical one ( So the copy could not be deleted in favour of a call. The resolution here honours the intent — one implementation rather than two — by a mechanism I did not name: the decision moves into a single 2. #8268's seam promise paid off immediately, which is the real headline
Also worth recording: the decision had to move out of the injection branch. It was nested under 3. |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8375
On a multi-tenant deployment
GET /meta/object/:nameserved the object with noindexeskey at all whenever the answer came from themetadataservice or asys_metadataoverlay row, while the registry's own resolved schema — and the list read — carriedindexes: [{ fields: ['organization_id'] }]. Same object, same moment, same host.The root cause was a second implementation, not a missing line
The read exits converge the #6562 system-column stamp with
applyInjectedSystemColumns(@objectstack/metadata-core). That function cannot import its producer:applySystemFieldslives in@objectstack/objectql, andobjectql→metadata-protocol→metadata-core, so the import runs UP the dependency graph. So it re-implemented the half it could reach — the fields map — and silently omitted the half it could not, the index.applySystemFieldsis not inpackages/spec/src/data; it ispackages/objectql/src/registry.ts. And it is therefore not callable frommetadata-core— routing the converger through it inverts the layering. Adding anindexesline to the copy was ruled out for the right reason; calling the producer from the copy is impossible for a second one.So the decision moves into one function,
provisionTenantScopeIndex, called by the producer and bymaterializeBaseLayer— the #8268 seam every read exit already replays. That seam's doc promises "a fourth stamp added to this method is converged at the read exits the day it is added, with no fourth method and no fourth card". This is that fourth stamp, and the promise held: no edit topackages/metadata-protocol/src/protocol.tswas needed (it is at its 3-editor cap), and no new method anywhere.The decision also had to move out of the injection branch. It was nested under
!schema.fields?.organization_id, and at a read exit the column is already present —governServedItemrunsapplyInjectedSystemColumnsbefore the seam — so that condition is false exactly where the convergence is needed. The predicate is now "the object carries a tenant column the PLATFORM provisioned", viaisInjectedColumnDefinition, which answers the same question at either site in either order.The write path owes a counterpart, and
indexesconcatenatesstripProvisionedTenantIndexFromremoves the last entry identical to the platform's own (the seam appends) and keeps the removal only when re-stamping the remainder reproduces the arriving list byte-for-byte — recomputed through the stamping function itself, so strip and stamp cannot drift. An emptied list drops the key rather than storingindexes: [].That one comparison separates every case: an author's named entry is never a candidate; an author's own tenant index ordered before their others fails the comparison on order; the identical entry on a single-tenant deployment fails it because the seam would add nothing there. The residual it cannot separate — an author whose last index is byte-identical to the platform's on a multi-tenant host — is the trade
stripProvisionedPrimaryFromdocuments, bounded the same way: the entry is re-derived at every load, so no resolved answer changes.Measured
mainindexesOne prediction was wrong, and it is recorded rather than retro-fitted. The brief expected an unbounded strip to grow the array every cycle. Measured with the strip fully ablated across three cycles, the stored row goes
undefined → 1 → 1 → 1: it gains one phantom entry and then stabilizes, becausedeclaresTenantIndexalready guards the append. The real defect of a missing strip is a phantom customisation baked intosys_metadata.metadata, its checksum and every history diff — not growth. This matters for the gate: a test asserting only "length does not grow" would pass with the strip entirely absent. The pin asserts the stored row, which is what ablation B reddens, atcycle 1 stored.Two expected-divergence pins flip, as each was written to
Both were deliberate tripwires, so failing them is the design working:
MEASURES the un-converged multi-tenant indexes stamp(rest materialization-agreement) → now asserts agreement on every key, plus a single-tenant control.applySystemFieldsstampsindexedonorganization_id— a keyFieldSchemarejects by name, so every registry-backed object read answers_diagnostics: { valid: false }#6810 residual inprotocol-meta-effective-schema.test.ts, whose own comment read "If a served-document consumer ofindexes[]ever appears, this is the line that says so." It appeared. This file is not named in the issue — it surfaced from the full suite run.Verification
objectql3546 ✅ ·rest1828 ✅ ·metadata-protocol1170 ✅ ·metadata-core162 ✅ (all post-merge, after rebuildingdist)applySystemFieldsstampsindexedonorganization_id— a keyFieldSchemarejects by name, so every registry-backed object read answers_diagnostics: { valid: false }#6810 tenant-index pins: greenscripts/pm/dispatch-gates.mjsagainst the actual changed paths, pluscheck:nul-bytes— all passdistfirst — these suites resolvemetadata-protocol/objectqlthrough it.Generated by Claude Code