Skip to content

fix(metadata-protocol): stop persisting derived provenance keys, and restate tenant authorship at hydration for every type - #16796

Draft
os-musk wants to merge 7 commits into
mainfrom
claude/issue-16702-strip-derived-provenance-keys
Draft

fix(metadata-protocol): stop persisting derived provenance keys, and restate tenant authorship at hydration for every type#16796
os-musk wants to merge 7 commits into
mainfrom
claude/issue-16702-strip-derived-provenance-keys

Conversation

@os-musk

@os-musk os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16702

Clause-②: yes

The defect

saveMetaItem persisted the caller's _packageId / _packageVersion / _provenance verbatim, and for every non-object type boot hydration read that stored body back as truth. metadata-read-decorations.ts deliberately does not strip _provenance from a served document, so the ordinary Studio GET /meta/app/pet_hospital then PUT /meta/app/pet_hospital round trip wrote _provenance: 'package' into the tenant's own sys_metadata row. From there the row's own bytes decided: isCodeArtifactBody accepted a truthy non-sentinel _packageId with non-org provenance, SchemaRegistry.getArtifactItem's bare-key fallback returned the overlay as an artifact, isArtifactBacked turned true, and every later write was refused NOT_OVERRIDABLE — permanently, since the next boot re-derived the same verdict from the same row.

The failure also misattributes: the operator is told the item is "provided by a code package" when no code package publishes it at all.

Both doors, because they cover different populations

Door 1 — the write door. saveMetaItem now drops exactly _packageId / _packageVersion / _provenance from the body it persists, one beat after the existing stripReadDecorations call and for the same reason. The _lock* family is deliberately untouched: a lock is author-declarable and dropping one is the fail-open direction, the line cloud PR #2065 drew at its own producer.

This is a new, write-door-scoped strip and not a new member of METADATA_READ_DECORATIONS. That list is shared with every consumer that re-parses a served document, and its own header states on purpose that the protection envelope stays on a served body so provenance survives a re-parse.

Door 2 — hydration. hydrateOverlayIntoRegistry — the one choke point boot, read-side and write-through hydration already share — now states _provenance: 'org' on a copy before mergeArtifactProtection runs. That is the same sentence applyObjectRegistryMutation and the boot object limb have always written, said once for every other type. The order is the whole contract: where a real artifact exists its envelope still overwrites _provenance (and _packageId / _packageVersion / _lock*) on the way out, so ADR-0010 section 3.3 precedence is unchanged in both directions.

Door 2 is not belt-and-braces. Measured, not argued: with door 2 ablated and door 1 live, a row whose stored body carries no protection key at all is still hydrated as a code artifact, because getMetaItems re-stamps _packageId onto the body from the row's package_id column a few frames before handing it to the hydrator. The write-door strip cannot reach that path; the restatement can.

Acceptance criteria, and where each is asserted

New file packages/objectql/src/protocol-derived-provenance-doors.test.ts — a real ObjectQL engine, a real SchemaRegistry, sys_metadata registered, no code package loaded.

criterion assertion
1 — the card's reproduction leg, run as written #16702 — the card's reproduction leg, run as written. Writes the app carrying _provenance: 'package', boots a fresh engine + protocol over the same driver, calls loadMetaFromDb(), then saves again. Output pasted below.
2 — strip exactly three keys, never _lock* two cases under door 1: one asserts the three keys are absent from the persisted body and name / label survive; the other sends _lock / _lockReason / _lockSource / _lockDocsUrl alongside them and asserts all four survive the write.
3 — negative control two cases under criterion 3. A real artifact registered under the composite key app.sdbh:pet_hospital is still refused NOT_OVERRIDABLE / 403 — once with no overlay, and once after a boot that hydrated a tenant overlay of the same name.
4 — both branches two cases under criterion 4. The object branch keeps stamping _provenance: 'org' and stays editable across a restart, and it still registers through registerObject (contributor layers) rather than the shared registerItem path — getObject and getAllObjects(packageId) both answer. The object branch source is untouched by this PR.
5 — mergeArtifactProtection precedence its own two cases. With a real artifact present the hydrated entry carries the artifact's _lock: 'full', _lockReason, _packageId, _packageVersion and _provenance: 'package' while the overlay's authored label still wins; with no artifact the restatement stands and nothing is invented.
6 — the row.package_id reading below. Reported, not fixed here, exactly as triage fenced it.
7 — close the loop with cloud see the report on this branch's dispatch.

Criterion 1, run as written

[#16702 repro] stored row: {"name":"pet_hospital","label":"Pet Hospital"}
[#16702 repro] loadMetaFromDb(): {"loaded":1,"errors":0,"invalid":0,"storeUnavailable":false}
[#16702 repro] second saveMetaItem receipt: {"success":true,"version":"sha256:6e4970111e11bb918a3b5514b20901d9e7ca9244b4434e29ecd1270ad2e0fd26","seq":2,"state":"active","message":"Saved app 'pet_hospital' (env-wide, state=active) [seq=2]"}

On origin/main 73053ed27b, the same path, byte for byte the card's own output:

[#16702 repro] stored row: {"name":"pet_hospital","label":"Pet Hospital","_packageId":"app.sdbh","_packageVersion":"1.0.0","_provenance":"package"}
[#16702 repro] loadMetaFromDb(): {"loaded":1,"errors":0,"invalid":0,"storeUnavailable":false}
Error: [not_overridable] Metadata item 'app/pet_hospital' is provided by a code package and the type has not opted into per-org overlay writes (allowOrgOverride=false).

Criterion 6 — the reading on the row.package_id column path

Triage named one site and asked whether the column is a second poisoning path. The dispatch measured three. Re-derived by symbol on origin/main 73053ed27b (protocol.ts, 21,885 lines; firing controls on the same file: _packageId 34 lines, _provenance 12 lines, a nonsense control 0):

  • :4922 const packageId = row.package_id; — in foldStoredCollection. Stamps _packageId onto a freshly parsed, ephemeral body that is pushed into the runtime authoring gate's reference-resolution universe and returned. Nothing persists it and nothing registers it. Not a poisoning path.
  • :16054 const packageId: string | null = row.package_id ?? null; — in migrateStoredMetadata. Reads the column into the migration report row and passes it as the packageId parameter of the re-save, never as a body key. Not a poisoning path — and after door 1 an --apply migration pass now actively cleans the rows it rewrites.
  • :19972 packageId: row.package_id, — in revertCommit, where row is a commit row and the value goes to recordPackageCommit as a commit column. Not a metadata body at all. Not a poisoning path.

But the pattern row.package_id cannot express the shapes that matter. A whole-file census of the package_id spelling (61 occurrences) finds the cast and destructured forms it misses, and four of those DO write the column into a body: :7208, :7354, :7736, :7805 — all in getMetaItems / getMetaItem, all (record as ...).package_id, each if (recPkg && body._packageId === undefined) body._packageId = recPkg;.

:7208 is the live one. The same overlays array it stamps is handed to hydrateOverlayIntoRegistry at :7302 on an unscoped kernel, so an ordinary list read re-injects _packageId from the column into the in-memory registry entry. That is a genuine second path to isArtifactBacked, it needs no poisoned bytes at rest, and door 1 alone cannot close it. Door 2 does, because isTenantAuthored asks _provenance, not _packageId. Asserted by the read-side hydration seam (getMetaItems) is covered by the same restatement.

No fix for the column path is folded into this PR. None is needed for the defect this card names: the stamp is a legitimate read-side surfacing of the row's package binding (the sidebar package filter consumes it), and door 2 makes it non-load-bearing for the authorization verdict.

Verification

Ablation — two legs, PLANT mode, blob-hash-verified restore. Each leg mutated protocol.ts, rebuilt @objectstack/metadata-protocol (the suite resolves it through dist/), and proved the marker reached the built artifact with scripts/ablation-dist-preflight.mjs before any colour was read.

  • Leg A, door 1 neutered (DERIVED_PROVENANCE_KEYS swapped for a nonsense key): marker present in 2 built files; 2 cases red, both door-1 pins.
  • Leg B, door 2 neutered (_provenance: 'org' swapped for a non-org literal): marker present in 2 built files; 3 cases red — the two at-rest/read-side pins and the no-artifact restatement pin.
  • Both restores: restored blob equals the HEAD blob byte for byte, whole-tree git status --porcelain empty, rebuild plus --absent confirms the marker is gone from all 24 built files, restored suite 11 of 11 green.

One thing stayed green under both legs, and that is the finding, not a defect in the ablation: the reproduction leg itself. Either door alone closes it, which is exactly triage's "they cover different time windows". The layer holding it is the other door. The repro leg's own discriminating power is proven by the pre-fix run with neither door present, which reproduced the 403 verbatim.

Suites. @objectstack/metadata-protocol 169 files / 2,433 tests pass, 2 files / 10 cases skipped — named and counted: the describe.skipIf(!MYSQL_URL) limbs of src/migrations/sys-setting-identity-index.live-mysql.test.ts (5) and src/migrations/seed-tenancy-backfill.live-mysql.test.ts (5), pre-existing and unrelated. @objectstack/objectql 286 files / 4,819 tests pass, 0 skipped. Consumer sweep, because the served shape can change: @objectstack/rest 192 files / 3,241 tests and @objectstack/runtime 240 files / 3,340 tests, both fully green, 0 skipped.

Typecheck legs, read out of each package.json rather than assumed. metadata-protocol: tsc --noEmit, 1 leg. objectql: tsc --noEmit, then tsc --noEmit -p tsconfig.scripts.json, then check:test-typecheck (its own --self-test plus --package packages/objectql --project tsconfig.test.json) — 3 legs. All green.

Existing pins updated, deliberately. Six cases asserted deep equality on a hydrated non-object registry entry and now see one more key. Each was updated to toEqual against the exact new shape rather than relaxed to toMatchObject, so an unexpected extra key still reds them: protocol-meta.test.ts (4), plugin.integration.test.ts (1), and protocol-boot-hydration-scoped.test.ts, whose "registers the row unchanged" case is renamed to what it actually pins — no artifact envelope is grafted, while _lock and _packageId stay absent and _provenance is now the server's own statement.

Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 59 families; all 59 were run and reconciled with --ran:

Run reconciliation — 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED.

57 exit 0. Two exit 3, which is PREREQUISITE NOT MET and is neither a pass nor a finding — check:dual-build-cjs-loads and check:type-check-debt both refuse to measure without a whole-workspace build closure, and both say so in their own output. They are declared to CI, which builds first. check:objectql-double-limit caught a real defect in the new stub driver on its first run (a find double ignoring the caller's bound); corrected in bbe4590c and re-run green.

Lint, as a proven narrowing rather than a skipped run. Population read from eslint's own config: 6,348 tracked files carry a lintable extension and none is removed by the single global-ignore block, whose whole content is node_modules / dist / build / .next / .turbo — untracked build output that a tracked-file census matches 0 files under, as the config's own header records. Files linted, counted from --format json: 5, the entire lintable half of the change set, eslint --no-inline-config exit 0, 0 errors and 0 warnings. Invariance: this repo runs one eslint.config.mjs and it never enables type-aware linting for any file (no parserOptions.project, no typed rules), so no verdict on any of the 6,343 untouched files can depend on this diff. Measured at bbe4590c.

Changeset. minor, on @objectstack/metadata-protocol — raised from patch by the correction immediately below. The original derivation is kept, because it is still right about the DIRECTION of this change: the governing WHICH LEVEL text in .github/workflows/pr-automation.yml says "a fix( that changes no public surface stays patch", and minor is owed for "a purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value)" — and this act is the opposite of additive, since no export is added, no key or value newly accepted, and three keys stop being persisted. none / the skip-changeset label was rejected and stays rejected: AGENTS.md is a floor against none for a bug fix in a released package, not a ceiling, and this diff does publish from a released package. No BREAKING banner and therefore no ADR-0087 disposition: nothing an author can write is removed or renamed, no export or config field changes, the three keys are still accepted on input, and the read side recomputes all three on every read — so there is no FROM-TO mapping to ship and no migration to prescribe. check-adr-0087-registration agrees on this diff.

⚠️ Correction — the level floor here comes from the DECLARATION, not from the direction. The paragraph above weighs only what this diff does to the published surface, and on that axis patch reads right. That is the wrong axis for the FLOOR. check-changeset-no-major's level axis refuses any PR that declares Clause-②: yes while grading a package whose packages/*/src/** it moves as patch, and it never asks which way that surface moved. This PR declares Clause-②: yes (line 3 of this body) and moves packages/metadata-protocol/src/**, so the floor is minor whatever the direction — the level was wrong, not the reasoning about direction. Route 1 of the two the gate names is taken here: "The declaration is right and the level is wrong -> raise it to minor." Route 2 — correcting the declaration at the producer — is the review seat's, and is deliberately not exercised: the Clause-②: line is untouched, no needs:contract-review label was added or removed, and no tolerance was added to the gate. The release effect of the raise is nil: @objectstack/metadata-protocol sits in the single fixed version group in .changeset/config.json (one group, 70 members), so it already moves in lockstep with every other package in that group.

验收备注

Triage's seven, adopted verbatim (5578409434, quoted unchanged):

  1. 复现腿原样跑通并贴出:写入带 _provenance: 'package'app → 新引擎 → loadMetaFromDb() → 再次 saveMetaItem 不再 403。⛔ 不要用改写后的等价脚本代替 —— 要验的正是卡面那条路径。
  2. 只剥三个键:_packageId / _packageVersion / _provenance。⛔ 不要碰 _lock* 家族 —— 锁是作者可声明的,剥掉一个锁是 fail-open 方向,这条线 cloud PR feat(security): ADR-0056 D1 (canonical OWD vocab) + D2 (anonymous deny posture) #2065 在它的生产者侧已经划过,本卡沿用。
  3. 阴性对照必测:一个真的由代码包提供的 app(真 artifact 在场),在修复后必须仍然NOT_OVERRIDABLE 拒绝。一个"无条件放行"的实现会让第 1 条绿掉,同时把包保护整层删掉。
  4. object 与非 object 都要有断言:object 分支今天是对的,回归后必须仍然对。修法若是"给非 object 分支也重述一次",⛔ 不得顺手把 object 分支改写成共用路径而不测它。
  5. mergeArtifactProtection 的优先级不变:真 artifact 在场时,artifact 的信封仍须压过存储副本(卡面在修法 2 的 ⚠️ 里已点名)。这条要单独一个断言。
  6. row.package_id 列那条相邻面给出读数(见上)。
  7. 与 cloud 侧对齐:cloud#2050 / PR feat(security): ADR-0056 D1 (canonical OWD vocab) + D2 (anonymous deny posture) #2065(堵住一个生产者)与 cloud#2069 / PR examples/app-showcase: add an end-to-end external datasource + federated object example #2111(回填)已在路上。本 PR 落地后请在 cloud#2069 上回帖说明门已关,⛔ 不要让两边各自以为对方在管。

Out-of-scope notes, filed nowhere

  • getMetaItem / getMetaItems stamp _packageId onto a served body from the package_id column at four sites (:7208, :7354, :7736, :7805) and never stamp _provenance beside it. Noted, not filed: with door 2 in place the asymmetry no longer decides an authorization verdict, so it is an observation about symmetry, not a reproducible defect.
  • publishMetaItem, rollbackMetaItem and revertCommit write bodies that did not come through saveMetaItem in this call. Every one of them originates from a body saveMetaItem already wrote (a draft, a history entry, a commit item), so door 1 covers them transitively and door 2 covers whatever slipped through earlier. Noted, not filed: no repro.

Draft only. Not ready, not queued, no auto-merge.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 5 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

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.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759packageMentionDocs.

Which tree this was computed on

This run read content/docs from 492ed4ca5444440ffd5d06f822a6460bf2f999b7 — the merge of head a35c00ffa25b41fedcc651b0b6c54303c4200db2 into base c930f859714de408ba0221f435ff957ed9e64759, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 492ed4ca5444440ffd5d06f822a6460bf2f999b7 && git checkout 492ed4ca5444440ffd5d06f822a6460bf2f999b7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c930f859714de408ba0221f435ff957ed9e64759 a35c00ffa25b41fedcc651b0b6c54303c4200db2 && git checkout -B drift-repro c930f859714de408ba0221f435ff957ed9e64759 && git merge --no-ff a35c00ffa25b41fedcc651b0b6c54303c4200db2

node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c930f859714de408ba0221f435ff957ed9e64759 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16796 @ bbe4590c0

Verdict: CHANGES REQUIRED

Ruling implemented: no ruling — no maintainer ## Ruling recorded exists on card #16702 or on this PR. The PR implements the triage seat's preference (os-zhuang, 5578409434: "建议两条都做") as adopted by the PM seat (5578971143), which the card itself left open as "Which one — or both — is a maintainer call." Both seats explicitly declined the decision box on the "zero-cost narrowing" argument. The only maintainer ruling this PR cites is the batch #35 WHICH LEVEL ruling (.github/workflows/pr-automation.yml:667-682), which governs the changeset level, not the fix shape — and the PR's level choice is where it breaks (F1).

Quoted verbatim, the closest thing to a ruling on the card (triage, seat preference, not maintainer):

而且两条修法不互斥,卡面自己也这么说("which one — or both"):(1) 让存量语料向读侧已经在重算的那个真相收敛,(2) 让今天已经写坏的行在不重写的情况下无害。建议两条都做,理由是它们覆盖的是不同的时间窗

Scope vs that preference: exactly — both doors, three keys only, _lock* untouched, object branch source untouched (the only three non-comment code lines that change in protocol.ts are stripDerivedProvenance at the write door, stateTenantAuthorship inside hydrateOverlayIntoRegistry, and the two module-private helpers).

1. Files in the diff vs merge-base 8b37a0973d (6 files, +604/−8)

file status
.changeset/derived-provenance-write-door-and-hydration.md added
packages/metadata-protocol/src/protocol.ts +106/−1
packages/objectql/src/protocol-derived-provenance-doors.test.ts added (440)
packages/objectql/src/protocol-meta.test.ts 4 pins updated
packages/objectql/src/plugin.integration.test.ts 1 pin updated
packages/objectql/src/protocol-boot-hydration-scoped.test.ts 1 pin updated + renamed

Governed paths touched: NO (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** — none). The documentation / tooling labels are the auto-labeler's, not a governed touch. Maintainer-only merge on governed grounds: no.

2. Clause-② re-derived independently: yes — and the body's own patch argument contradicts its own declaration

Re-derived from code, not from the body:

  • Persisted metadata shape narrows. saveMetaItem (protocol.ts:15006) now drops _packageId / _packageVersion / _provenance from every body it persists, for every type including object (the strip sits before the type branch). The stored sys_metadata body is a persisted shape; it changed.
  • Hydrated registry-entry shape widens. hydrateOverlayIntoRegistry (:14131) now registers {...data, _provenance: 'org'} for every non-object row with no artifact. That entry is what SchemaRegistry.getItem / getAllApps / listItems answer — an exported objectql surface — and the PR's own six updated deep-equality pins are the measurement of that change.
  • The accept set of PUT /meta/TYPE/NAME is unchanged (the three keys are still accepted and silently dropped), so the request contract is not narrowed.

Clause-②: yes is correct. The body's argument that the act is "the opposite of additive" and therefore patch is where it goes wrong: the WHICH LEVEL ruling says a fix( that changes no public surface stays patch; this PR declares — correctly — that it changes one.

3. Changeset

  • Package name @objectstack/metadata-protocol: correct (the only package whose src/** moves; it is in the fixed group, version 17.3.0, not private).
  • Level patch: wrong per the gate that enforces the batch [WIP] Add query enhancements and advanced validation features #35 ruling — see F1. scripts/check-changeset-no-major.mjs judgeLevel: "enforce — declared yes, patch on a grown package → exit 1". Both Check Changeset runs on the head are red on exactly this.
  • No **BREAKING** banner and no ADR-0087 disposition: correct — nothing an author can write is removed or renamed; check-adr-0087-registration passes.
  • FROM/TO: the body states what stops being persisted and what is restated; it does not state the served-document residual (F2). Nothing deliberately removed is silently dropped.

4. Tests

  • New file protocol-derived-provenance-doors.test.ts: real ObjectQL + real SchemaRegistry + stub driver (not an engine double). No .skip / .only / .todo / skipIf in any of the four changed test files.
  • Would redden on revert, reasoned from code: the door-1 pins assert not.toHaveProperty('_packageId'|'_packageVersion'|'_provenance') on the stored body; without stripDerivedProvenance the repository's put persists JSON.stringify(body) verbatim (sys-metadata-repository.ts:666) → red. The door-2 pins seed a row with _provenance: 'package' and assert the hydrated entry is 'org'; without stateTenantAuthorship the hydrator registers data as-is → red. The repro leg: with both doors absent, isCodeArtifactBody (metadata-core/src/code-artifact-provenance.ts) accepts truthy non-sentinel _packageId + non-org provenance, getArtifactItem's bare-key fallback (registry.ts:3759/3777) returns the overlay, NOT_OVERRIDABLE follows → red. Not vacuous.
  • Negative control (criterion 3) registers a real artifact under the composite key and asserts 403 both without and with a hydrated overlay: it discriminates an "unconditional allow" implementation.
  • Criterion 5 asserts every artifact envelope key wins over both the stored copy and the 'org' stamp — the ordering contract of door 2 is pinned.
  • Typecheck program: packages/objectql/tsconfig.test.json includes src/**/*; the new file has no entry in the shrink-only debt ledger, so it must compile at zero errors — Type Check · workspace is green on the head.
  • Caveat the body admits: the suite resolves @objectstack/metadata-protocol through dist/, so locally it reddens only after a rebuild. CI orders Build Core before Test Core, so the gate is real there.

5. Persistence / hydration specifics

  • Stops being persisted: exactly _packageId, _packageVersion, _provenance, on the saveMetaItem door only, for all types (object included). _lock / _lockReason / _lockSource / _lockDocsUrl are persisted unchanged (pinned).
  • Restated at hydration: _provenance: 'org' on a copy, for every non-object row reaching hydrateOverlayIntoRegistry (boot loadMetaFromDb, read-side getMetaItems on the unscoped kernel, and the meta overlay: a just-saved overlay is listed but not dispatchable for a short window — a cache between saveMeta and resolveRouteActionDeclaration lags the write #4521 write-through), applied before mergeArtifactProtection so an artifact's _provenance/_packageId/_packageVersion/_lock* still overwrite it. The object limbs (applyObjectRegistryMutation, boot registerObject branch) are byte-for-byte untouched.
  • Backward read compatibility: a row written before this PR still parses and still hydrates (no schema change, no migration of at-rest bytes). It does not hydrate identically: its registry entry's _provenance becomes 'org' where it was previously the stored value or absent — that is the fix, and the six updated pins are the measurement. But the served document for an at-rest poisoned row is not corrected by this PR — see F2. That is a backfill question, not a migration of this PR, and it must be stated.

6. Criterion-6 reading, spot-checked

The body's claim that getMetaItems re-stamps _packageId from the package_id column onto the body handed to the hydrator is confirmed at :7288-7290 ((data as any)._packageId = recPkg) → :7380 (hydrateOverlayIntoRegistry). Door 1 cannot reach it; door 2 makes it non-load-bearing because isTenantAuthored asks _provenance, not _packageId. The "both doors are not redundant" reasoning holds.

7. CI on head bbe4590c0

37 check runs: 29 success · 2 failure · 6 skipped · 0 in progress. Red: Check Changeset ×2 (pr-automation on opened and on labeled), both on the level axis above. Skipped: Packed-tarball smoke ×2 (opt-in), Auto Label, Check PR Size, Build Docs, Console Pin Gate. mergeable_state: unstable (the red gate). Head is 11 commits behind origin/main at review time (base recorded at CI: b38821d1; merge-base now 8b37a097). No reviews on the PR; one bot comment (docs-drift, advisory).

Findings

F1 — CHANGES REQUIRED. Changeset level patch is refused by the gate that enforces the batch #35 ruling, and the refusal is correct. The PR declares Clause-②: yes (and carries the needs:contract-review carrier) while grading @objectstack/metadata-protocol patch; check-changeset-no-major.mjs judgeLevel returns enforce, exit 1 — both Check Changeset runs are red. This seat's independent re-derivation agrees the surface changes (persisted body narrows; hydrated registry-entry shape widens). Expectation: take the gate's route 1 — change the one word in .changeset/derived-provenance-write-door-and-hydration.md to minor, and rewrite the body's "Changeset" paragraph so it no longer argues patch from "not additive" while declaring yes. ⛔ Do not take route 2 (flipping the declaration to no) — the declaration is the accurate half.

F2 — Must be stated in the PR body and changeset: door 2 corrects the authorization verdict, not the served document. getMetaItem step 1 (:7860-7875) serves the raw overlay row body with only _packageId stamped from the column and no _provenance restatement; mergeArtifactProtection only fires when an artifact exists. getMetaItems likewise serves the overlay data (which wins the merge over the restated registry entry). So a row poisoned at rest keeps being served as _provenance: 'package' by GET /meta/app/x — the UI keeps badging it as package-provided — until the row is re-saved (door 1) or backfilled by cloud#2069 / cloud PR #2111. The refusal is gone; the misattribution in the served body is not, for that population. Expectation: one sentence in the changeset and the body naming this residual and that the cloud backfill is load-bearing for it; carry it into the criterion-7 reply on cloud#2069 so cloud does not read "door is shut" as "backfill no longer needed".

F3 — Non-blocking, confirm the body's "noted, not filed" is complete. promoteDraft (:16942) and restoreVersion (rollbackMetaItem :20331, revertCommit :19941) hand a stored draft/history body straight to repo.put without stripDerivedProvenance. A rollback to a pre-PR history version therefore re-persists the three keys into the active row. Door 2 keeps that harmless for hydration; the corpus does not converge on those doors. Expectation: acceptable as scoped, but the body's claim "door 1 covers them transitively" is only true for bodies written after this PR — say so.

F4 — Minor. Door 2 restates only _provenance; a poisoned row's stale _packageVersion survives onto the hydrated entry (non-load-bearing for authorization, cosmetic until backfill). Expectation: none beyond F2's sentence.

F5 — Minor test gap. The strip is type-agnostic, so object bodies also lose the three keys at rest; the criterion-4 object case writes _packageId/_provenance into the request but asserts only the hydrated entry, not the stored body. Expectation: optional one-line not.toHaveProperty on the stored object row, so the type-agnostic scope of door 1 is pinned rather than implied.

F6 — Process, not a defect. The "both doors" decision was taken by triage + PM as a determinacy call, with an explicit invitation to rebut; the card's own text called it a maintainer call. This seat finds the determinacy argument sound (a stored copy is never load-bearing on the read side; only the lie is negated) and the ablation reasoning that the two doors cover different populations verified from code. Expectation: none — recorded so the maintainer sees the decision was a seat's, not theirs.

Summary for the maintainer: the fix is exactly what was dispatched, the pins discriminate, no governed path is touched. The single blocker is one word in the changeset (F1); F2 is a required disclosure, not a code change.


Generated by Claude Code

`check-changeset-no-major`'s level axis refuses a PR that declares
`Clause-②: yes` while grading a package whose `packages/*/src/**` it
moves as `patch`. Route 1 of the two the gate names: the declaration is
right and the level is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

3 participants