Skip to content

fix(metadata-protocol): let an org-scoped caller see env-wide sys_metadata rows (#7819 tier 2) - #7871

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7819-org-scope-tier2
Aug 12, 2026
Merged

fix(metadata-protocol): let an org-scoped caller see env-wide sys_metadata rows (#7819 tier 2)#7871
huangyiirene merged 1 commit into
mainfrom
claude/issue-7819-org-scope-tier2

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#7819

Tier 2 — duplicatePackage and reassignOrphanedMetadata. Tier 1 landed as 756fd12 (#7857). With these two, 0 strict-equality sites remain in protocol.ts (control: 6 $or sites), 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, not sys_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) and POST /packages/:id/adopt-orphans (:669), each forwarding await deps.resolveActiveOrganizationId(_context) — the same door tier 1 measured.

(b) Can env-wide rows exist in sys_metadata by then? Yes, and not incidentally:

  • a saveMetaItem from a session with no active org writes organization_id = NULL, and resolveActiveOrganizationId answers undefined both for such a session and for any throw on the auth seam;
  • for the orphan site specifically, a saveMetaItem naming no package at all still succeeds today and lands package_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 source

Measured 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 object rows missing it came out empty, so a copied view was renamed iojn2_list while its data.object still read iojn_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[] with NOT_OVERRIDABLE: object is declared allowOrgOverride=false, so stamping the request's org onto the copy is refused — boot hydration loads env-wide rows only, and an org-scoped object row would vanish on the next restart (ADR-0005, #6190).

Since an object therefore cannot exist org-scoped, every object row in a source package is env-wide, and an org-scoped duplicatePackagecould 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 no organizationId every 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 order resolveMetaItemOrgScope applies — pinned as its own case.

reassignOrphanedMetadata — the sharper member

Measured 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 at package_id = null with 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-org where is {} and already scans every organization's rows; narrowing either door to organization_id IS NULL would re-create this bug pointed the other way. Both doors are pinned as they stand so they cannot drift silently.

⚠️ Whether the orphan door should be that wide is #7780's open product question — a maintainer call, explicitly not decided here. Evidence bearing on it is posted on #7780 rather than acted on in this diff.

Why the family's $or, checked rather than assumed

Tier 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: where is { 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 real ObjectQL over a real SqlDriver on better-sqlite3, seeded through the real publish path, because the question is whether organization_id = 'org' matches a NULL column: a property of the driver's SQL, not of a stub's filter(). Every existing suite over these two methods either stubs engine.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 in packages/runtime because metadata-protocol cannot import objectql (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.

⚠️ These suites resolve @objectstack/metadata-protocol through its dist and source-map traces back to src, 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.

GateResult
@objectstack/metadata-protocolpass — 72 files / 1066 tests
@objectstack/objectqlpass — 186 files / 3288 tests
@objectstack/runtimepass — 136 files / 2083 tests (baseline 135 / 2071; +1 file, +12 tests = this pin)
@objectstack/clientpass — 21 files / 282 tests
@objectstack/qa-dogfood (ADR-0070 D4/D5 on a real booted stack)pass — 91 files / 584 tests
check:durability-log-levelpass — 24 durability seams, 63 read seams
check:changeset-gate-self-testspass — 118 + 153 + 117 assertions
check:nul-bytespass — 7203 files (see below)
Build closurepass — full pnpm build, 71/71
typecheck (@objectstack/runtime)pass
eslint on changed filespass
CIin_progress — the PM owns CI convergence and the ready-flip

⚠️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 dropped protocol.ts out 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 metadata name could 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 and reassignOrphanedMetadata's no-org doors are untouched.


Generated by Claude Code

…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
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 1:12am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@huangyiirene@claude