Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): let an org-scoped caller see env-wide sys_metadata rows (#7819 tier 2) - #7871
Conversation
…adata rows (#7819 tier 2) `duplicatePackage` and `reassignOrphanedMetadata` scanned `sys_metadata` with a strict `organization_id` equality, which matches no row whose column is NULL, so an org-scoped caller could not see any row recorded env-wide. Both scans now accept org-scoped or env-wide rows — the same `$or` the rest of this family carries. These two sites were filed UNVERIFIED, so step one was a reachability measurement on a real engine, not an edit. Both halves came back live: each has one production caller forwarding `resolveActiveOrganizationId`, and env-wide rows in that table are ordinary — a `saveMetaItem` naming no package at all still succeeds today and lands `package_id = null, organization_id = null`, which is exactly the orphan the scan could not see. Both symptoms were worse than projected: - `duplicatePackage` answered `{success: true, copiedCount: 1, failedCount: 0}` over a mixed-scope source — and because the rename map is built only from the rows the scan returns, a copied view kept pointing at the SOURCE package's object: a duplicate silently wired back to the base it was cloned from. - Widening the scan alone was not a fix. `object` is `allowOrgOverride=false`, so stamping the request's org onto a copy is refused with `NOT_OVERRIDABLE`. An object therefore cannot exist org-scoped, so every object row is env-wide and an org-scoped `duplicatePackage` could never copy one — ADR-0070 D4's "duplicate base" could not duplicate what a base is mostly made of whenever an org was active. Each copy now lands in the scope of the row it came from, the same rule #7559 gave `revertCommit`. - `reassignOrphanedMetadata` left env-wide orphans at `package_id = null` and reported success. Finding orphans is its entire purpose, and ADR-0070 D5 makes the unit explicitly the ENVIRONMENT, so the strict equality made it inert for an org-scoped caller in the deployment it was designed for. The no-org branch is deliberately NOT narrowed on either site, and both doors are pinned as they stand. Whether the orphan door should be as wide as it is remains #7780's open product question, which is not decided here. Pinned on a real ObjectQL over a real SqlDriver in `packages/runtime` (12 cases). Reverse verification predicted 5 red and measured 5 failed | 7 passed. Fixes#7819 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DPXtEEaejmVQDY7GPUWv4z
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 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:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7819
Tier 2 —
duplicatePackageandreassignOrphanedMetadata. Tier 1 landed as756fd12(#7857). With these two, 0 strict-equality sites remain inprotocol.ts(control: 6$orsites), so this closes the card.⛔ Step one was a measurement, and it could have ended here
These two sites were filed UNVERIFIED — a grep match with a plausible mechanism, on a different table (
sys_metadata, notsys_metadata_commit) with callers nobody had driven. "Latent, not live" would have been a complete outcome and no PR. Reachability was measured on a real engine before a line was edited, and it has two halves:(a) Does any caller pass an org? Yes — one production caller each, both in
packages/runtime/src/domains/packages.ts:POST /packages/:id/duplicate(:696) andPOST /packages/:id/adopt-orphans(:669), each forwardingawait deps.resolveActiveOrganizationId(_context)— the same door tier 1 measured.(b) Can env-wide rows exist in
sys_metadataby then? Yes, and not incidentally:saveMetaItemfrom a session with no active org writesorganization_id = NULL, andresolveActiveOrganizationIdanswersundefinedboth for such a session and for any throw on the auth seam;saveMetaItemnaming no package at all still succeeds today and landspackage_id = null, organization_id = null. The current write path mints exactly the orphan the scan could not see, so that population is live, not the legacy residue the docstring can be read as describing. That is pinned as its own case.Both projected symptoms then reproduced on a real driver — and both were worse than the card projected.
duplicatePackage— a partial copy reporting success, and a copy wired back to its sourceMeasured before the fix: a source package holding one env-wide row and one org-scoped row, duplicated by an org caller, answered
{success: true, copiedCount: 1, failedCount: 0}.The sharper consequence is the rename map, built only from the rows the scan returns. With the env-wide
objectrows missing it came out empty, so a copied view was renamediojn2_listwhile itsdata.objectstill readiojn_widget— a duplicate silently wired back to the base it was cloned from, reporting success. An all-env-wide source degraded just as quietly the other way:{success: false, copiedCount: 0, failedCount: 0}— nothing copied, nothing named as failed.⭐ Widening the scan alone was NOT a fix
With the scan widened and the write left as it was, the object copy landed in
failed[]withNOT_OVERRIDABLE:objectis declaredallowOrgOverride=false, so stamping the request's org onto the copy is refused — boot hydration loads env-wide rows only, and an org-scopedobjectrow would vanish on the next restart (ADR-0005, #6190).Since an
objecttherefore cannot exist org-scoped, every object row in a source package is env-wide, and an org-scopedduplicatePackagecould never copy a single one. Objects being what a base is mostly made of, ADR-0070 D4's "duplicate base" gesture was structurally unable to duplicate a base whenever an org was active. That is a larger defect than the card projected, and a read-only fix would have left it in place while looking complete.So each copy now lands in the scope of the row it came from, not the request's — the same rule #7559 gave
revertCommit, for the same stated reason: this loop now processes a batch that "legitimately mixes an env-wide artifact with an org overlay." Scoped to the org-scoped door alone; with noorganizationIdevery copy is still written env-wide, byte-identical to before.One hazard this fix introduces rather than inherits
Widening the scan makes a collision newly possible: an item can now appear twice, as an env-wide row plus this org's overlay of it. Both copies would land on the same target key (
type, name, organization_id, COALESCE(package_id, '')), so the surviving body would be decided by driver row order. The caller's own org now shadows env-wide — ADR-0005 overlay precedence, the same orderresolveMetaItemOrgScopeapplies — pinned as its own case.reassignOrphanedMetadata— the sharper memberMeasured before the fix: two orphans, one env-wide and one org-scoped, adopted by an org caller answered
{success: true, reassignedCount: 1}, leaving the env-wide orphan atpackage_id = nullwith nothing reporting it skipped. Finding orphans is this method's entire purpose, so a class of orphan it structurally cannot see is a wrong answer, not a partial one.ADR-0070 D5 settles the scope question the widening raises (an org-scoped caller now rebinds rows every org can see): the unit is explicitly the environment — "bulk-assign legacy orphans to a default base named for the environment", completing when "an environment has no orphans" — in a deployment model whose own words are "there is no per-org overlay dimension here… the relevant axis is code package vs writable base, not 'org'." Under the model this method was designed for, every orphan is env-wide, so the strict equality made it inert for an org-scoped caller in precisely that deployment.
The no-org branch is deliberately NOT narrowed
On both sites, exactly as #7705, #7779 and tier 1 left theirs. The exposure is worst at
reassignOrphanedMetadata, whose no-orgwhereis{}and already scans every organization's rows; narrowing either door toorganization_id IS NULLwould re-create this bug pointed the other way. Both doors are pinned as they stand so they cannot drift silently.Why the family's
$or, checked rather than assumedTier 1 found its own sites were not the family's plain scan scoping — they were primary-key lookups, so the predicate read like an authorization filter and the remedy needed an argument. These two are the opposite:
whereis{ package_id, state }and{}respectively, both genuine scans, so this is the same shape #7705 and #7779 already carry and the family remedy applies directly.Pin
packages/runtime/src/package-duplicate-adopt-org-scope.integration.test.ts— a realObjectQLover a realSqlDriveron better-sqlite3, seeded through the real publish path, because the question is whetherorganization_id = 'org'matches a NULL column: a property of the driver's SQL, not of a stub'sfilter(). Every existing suite over these two methods either stubsengine.find(packages/objectql/src/protocol-package-lifecycle.test.ts) or never passes an org (the ADR-0070 dogfood) — which is exactly why none of them could see this family. It lives inpackages/runtimebecausemetadata-protocolcannot importobjectql(dependency cycle).12 cases: the premise measured out of SQLite; the live-orphan producer; the positive for each site; the reference-rewrite consequence; the org-shadows-env precedence; both negative directions per site (another organization's rows, another package's rows, owned rows); and the no-org door on each site.
Reverse verification, direction predicted before running: restoring the strict equality turns red exactly the two positives, the reference-rewrite case, and the two orphan cases that assert the env-wide orphan is adopted — five — leaving the negative directions and both no-org doors green, since strict equality is narrower than the
$or. Measured: 5 failed | 7 passed, exactly those five.@objectstack/metadata-protocolthrough itsdistand source-map traces back tosrc, so a source-only revert measures nothing while looking like it measured something (tier 1's finding). The package was rebuilt between every measurement above.Gates
Named by what exercises the symbol, not by path-scoped derivation — the omission that sent tier 1's first head red.
@objectstack/metadata-protocol@objectstack/objectql@objectstack/runtime@objectstack/client@objectstack/qa-dogfood(ADR-0070 D4/D5 on a real booted stack)check:durability-log-levelcheck:changeset-gate-self-testscheck:nul-bytespnpm build, 71/71typecheck(@objectstack/runtime)check:nul-bytescaught a real defect in my own diff and is worth recording: the separator in the new dedup key materialised as a raw 0x00 byte rather than the escape text, which renders as nothing and would have droppedprotocol.tsout of every grep-based lint. Now `` as escape text — byte-identical at runtime, and a better separator than the space I had intended, which a metadatanamecould contain.Scope
Two hunks in
protocol.ts(both tier-2 sites, plus the copy-scope line the object case forced), one new pin, one changeset.duplicatePackage's andreassignOrphanedMetadata's no-org doors are untouched.Generated by Claude Code