Repository navigation
fix(metadata-protocol)!: the save door refuses a protection block instead of storing a lock that binds nothing - #23115
Conversation
…ead of storing it inert ADR-0010 states one door for the package author's protection block: the loader translates it at load time (Phase 4.3), and an environment-authored item takes no package lock (section 3.5). The save door stored the block verbatim, so the served item read protection.lock 'full' while every edit and delete succeeded. It now refuses the block with a located 400 VALIDATION_ERROR on every type that declares it, and the protection TSDoc says so. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…the card's probes and its controls Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…pped minor Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…ve-door-protection
…double ledger the new test's engine double Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…tection block instead of failing A row stored before the refusal may carry the block, and the stored migration, the flow credential move and package duplication rewrite such rows. They now drop the block, exactly as they drop the derived provenance stamps, instead of failing the rewrite over a key that never bound. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b0c85a956b981d799c31bae9f38f46ed7cc0d06e && git checkout b0c85a956b981d799c31bae9f38f46ed7cc0d06e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b 94c3c852b947fe96f73ba6dcbde4e19ece968751 && git checkout -B drift-repro a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b && git merge --no-ff 94c3c852b947fe96f73ba6dcbde4e19ece968751
node scripts/docs-audit/affected-docs.mjs --json a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b
|
Contract reviewServed-tier: Inputs: card #23096 (body; comments 6133319631 triage, 6133589472 claim, 6135259274 dev report); PR #23115 (body, 20-file list, net diff against Check-runs on the head, polled 2026-10-13T00:44Z: 31 success, 5 skipped (Console Pin Gate, Dogfood Regression shards, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance — all opt-in or path-gated), 4 still running: Test Core 3/6, 4/6, 5/6, 6/6. Green includes Check Changeset, Governed Surface Queue Guard, Build Core, Test Core 1/6 and 2/6, every Lint family, Type Check source / debt / consumer / workspace, Spec property liveness, Build Docs. Their conclusions are the gate verdicts; nothing below re-derives one. ① Derived judgmentsThe branch — refusal is right against ADR-0010's actual text. The ADR states one door for the block and states what a lock means for an environment item. Phase 4.3: "Public author surface ( The refusal — right on position, types, envelope and path.
The strip — right, and the strip path is sealed from authors. The split is The narrowing (writable-base rows) — A is right. ADR-0016 §3.8 locates a Studio-authored package's lock in The 15 TSDoc mounts, the module docblock and the reference page — true to the behaviour, no claim beyond it. Every mount now says the loader translates and strips, and the metadata API's save door refuses with Tests and ledger. Twelve cases run the real door over a row-keeping double with the producer's dispatch predicates; probes, draft, malformed, unmanaged face, open overlay, two rewrite strips, four controls; refusals asserted as PR-body nit: " ② Semver level
③ Boundary flags
Adopted by Implemented-by: VERDICT: FAIL |
…s the unmanaged-lock-strip note's 422 on declaring types The changeset gains the supersession sentence in the house form, and stripUnmanagedImportLock's docblock no longer says such a body is refused as before. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review after the FAIL record 6135514431 on Check-runs on the head, polled 2026-10-13T01:45Z: 48, all completed, 41 success, 7 skipped, 0 failed, 0 running. The skipped ones are opt-in or path-gated (Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the Dogfood Regression Gate matrix shard, Packed-tarball smoke) plus Check PR Size and Auto Label on the second pr-automation run that the body edit triggered. Green includes Check Changeset (twice, on both runs), Governed Surface Queue Guard, Build Core, Build Docs, Test Core and all six shards, every Lint family, Type Check source / debt / consumer / workspace, and Spec property liveness. Their conclusions are the gate verdicts; nothing below re-derives one. ① Derived judgmentsThe delta is prose only. The supersession paragraph is true against the code at this head, on all three claims.
The three claims partition the sibling sentence's cases completely: a malformed block on a non-declaring type falls under (c), and a valid block beside a valid It names the sibling the way the house form requires. The form is the one the three precedent entries on The docblock is true. Each statement checks against the function it heads and the door it names: the untouched hand-on for a refused envelope value or a malformed block (the two early returns), the 422 for a refused The prior record's ① judgments hold on this head. They were rendered on code this delta does not touch: the refusal branch against ADR-0010, the position of ② Semver levelUnchanged, and the delta gives no reason to move it. ③ Boundary flags
Adopted by Implemented-by: VERDICT: PASS |
Fixes #23096
Clause-②: no (narrowing)
The Clause-② arm is
(narrowing)underscripts/pm/clause2-line.mjs: the answer to the widening question is a truthfulno, but the save door's accept set narrows. A body it answered200is now refused400. That makes this a BREAKING change, shippedminorunder the launch-window convention. The changeset carries the same line and its ADR-0087 disposition. The claim line read a bareno, and the dispatch asked for the arm wherever the repo's rules call for one. They do here.What this does
The metadata save door (
PUT /api/v1/meta/{type}/{name}, which reachessaveMetaItem) now refuses aprotectionblock with a located400 VALIDATION_ERRORon every type whose schema declares the block. It no longer stores the block verbatim and serves a lock that binds nothing. TheprotectionTSDoc inpackages/specnow says what the door does.Which branch, and why: ADR-0010
Triage 6133319631 asked which door ADR-0010 states for locking an environment item. These are the governing sentences:
packages/spec/src/shared/protection.zod.ts) that translates into the private_lockenvelope at load time". Also: "Loader (metadata/plugin.ts+objectql/registry.ts) callsapplyProtection(item, { packageId })to translate the block into the private_lock/... envelope and strips the publicprotectionblock so it never leaks into the overlay row."org| Authored at runtime by a tenant via the metadata API. Set on first save." Also: "Tenantorgartifacts ignore L3 / L4 entirely (the tenant owns them in full, can also delete them)."metadataDefaultsexists nowhere inpackages/spec/src.ADR-0010 does not state that an environment item takes
protectionat save. It states the opposite for the lock itself (§3.5), and it names one door for the block: a code package's loader. That selects triage's refusal branch. Translating instead would open a lock door ADR-0010 does not state, so the translation branch was not taken. The refusal names the door ADR-0010 does state.The change
packages/metadata-protocol/src/protocol.ts:protectionBlockRefusalis a new module function, called once insaveMetaItem. It runs after the authorization refusals, so a locked item still answersITEM_LOCKEDand a sealed oneNOT_OVERRIDABLE. It runs before the destructive diff, the per-type parse and the authoring gates.overlaySchemaDeclaredKey(type, 'protection'), the same structural reading the unmanaged import uses. On a type that does not declare the block, the parse's own422stands.VALIDATION_ERROR/400, the envelope of the door's other content refusals (savedItemNameRefusal, the hook-without-body refusal). No code is minted.issues[0].pathisprotection. Every present value is refused, a malformed block included.protectionon it in a code package's source"). It names no_lock*key.withoutProtectionBlock: the server-stated rewrites strip the block instead of failing. These are asource-stating re-save (migrateStoredMetadata, and the flow credential move, which re-saves stored flow rows withsource: 'migrate-stored') andduplicatePackage. They are the same two thattenantAuthoredWriteRefusalexempts andstripDerivedProvenancestrips for. A row stored before this change may carry the block, and it never bound. Refusing would fail a stored migration, or leave a flow credential in cleartext, over a key that does nothing.packages/spec: theprotectionTSDoc on all 15 mounts (app, view container, view item, dashboard, report, dataset, object, flow, webhook, agent, tool, skill, position, permission set, email template), plus one paragraph in theshared/protection.zod.tsmodule docblock. Prose only.content/docs/references/shared/protection.mdxwas regenerated from that docblock (gen:schema && gen:docs).applyProtection: which types declare the block is read off the registered schema by the existingoverlaySchemaDeclaredKey, so no translation is copied anywhere.94c3c852b9), prose only:15213-unmanaged-lock-stripentry, in the house form that entry uses. On a type that declaresprotection, an item the unmanaged install hands on with its block still in it (a malformed block, or a block beside a refused_lock*value) now meets this door refusal,400 VALIDATION_ERROR, and the install is still rolled back. A refused_lock*value with no block, and a block on a type that does not declare one, still answer422 INVALID_METADATA.stripUnmanagedImportLock's docblock (package-unmanaged-install.ts) states the same split instead of "refuses it as before".importUnmanagedPackagewith a temporary probe, not committed: a malformed block gave400 VALIDATION_ERROR, a valid block beside_lock: 'locked'gave400 VALIDATION_ERROR, and_lock: 'locked'alone gave422 INVALID_METADATA, each rolled back. The probe was restored, blob == HEAD.Readings: the card's probes on a showcase boot (
pnpm dev -- --fresh -p 41099, seeded admin)Before, at
origin/mainabf86554c9:PUT /meta/app/probe_locked_appwithprotection: { lock: 'full', reason }:200.GET:lock: 'none',editable: true,deletable: true, whileitem.protectionis{ lock: 'full', ... }.PUT:200.DELETE:200.dashboard/probe_locked_dash: the same,200/none/200/200.sys_metadata.metadatais{"name":"probe_row_app","label":"Row","protection":{"lock":"full","reason":"probe"}}, with no_lock.After, at
f8644fa685(metadata-protocoldist built at that commit):appanddashboardprobes answer400 VALIDATION_ERRORwithissues: [{ path: 'protection', ... }].sys_metadataholds 0 rows under either name.protection:PUT 200, secondPUT 200,DELETE 200. The stored row is{"name":...,"label":...}.GET /meta/app/setupreadslock: 'full',lockSource: 'package',editable: false,deletable: false.1e6bf4fe48, the same authoring path):PUTonapp/setupcarryingprotectionanswered403 NOT_OVERRIDABLE, not this refusal;PUTof aprotectionblock into a writable base row (?package=com.probe.base) answered400.Tests
packages/metadata-protocol/src/protocol.save-door-protection.test.tshas 12 cases. It runs the real save door over a row-keeping engine stub, copied from the pinnedpackage-unmanaged-install.test.tsdouble, with the dispatch predicates. Refusals are asserted asresolveThrownHttpErrorenvelopes (statusandcode), never as a bare throw.app,dashboard):400 VALIDATION_ERROR,issues[0].path === 'protection', and zerosys_metadatawrites.lock: 'sealed', a missingreason,null), the unmanaged import's face, and an overlay of a packaged dashboard that declares no lock.source: 'migrate-stored',writeFace: 'package-duplicate'): the rewrite lands, the row carries noprotection, and the caller's body is not mutated.protectionsave, carry none, and stay editable and deletable.applyProtectionwithprotection: { lock: 'full' }, refuses an overlay save403 ITEM_LOCKED, with or without a block in the body.page, which does not declare the block, keeps the parse's422 INVALID_METADATA.Ablation, both legs, through
scripts/ablation-replace.mjsfrom the committed state atedad2a33fd(protocol.tsblob94ad6a645ede). The subject is imported from./protocol.js, its source, so no dist was involved.if (protectionRefusal) throw protectionRefusal;replaced, anchor 1 to 0, blob94ad6a645edetoa685b721e5be): 6 failed, 6 passed.ITEM_LOCKEDandpage's 422.request.item = withoutProtectionBlock(...)replaced, blob to116fbe9f7340): 2 failed (the two rewrite strips), 10 passed.== HEAD(94ad6a645ede), andgit diff HEADis empty.Patch round 1, at
94c3c852b9(a changeset sentence and a docblock only):pnpm --filter @objectstack/metadata-protocol typecheckexit 0, andpackage-unmanaged-install.test.ts+protocol.save-door-protection.test.ts74/74.Suites at
f8644fa685:pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. The new test is in the tsc program (--listFileslists it once).pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 229 files passed, 3 skipped; 28236 tests passed, 19 skipped.pnpm --filter @objectstack/spec typecheckandpnpm --filter @objectstack/spec exec vitest run --maxWorkers=2: exit 0, 716 files, 20975 tests passed, 1 todo.Gates, at
94c3c852b9(patch round 1; first run atf8644fa685, same derivation)node scripts/pm/dispatch-gates.mjs --commandson this diff derived 121 commands: the lead list's 80, plus the changeset, docs-reference and ledger families this diff adds.pnpm --filter @objectstack/spec check:generated.--ranreconciliation: "121 derived, 121 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded.check-changeset-no-major,check-adr-0087-registration(not-required (no-migration-prescription)accepted with the supersession sentence in the body) andcheck-empty-changeset, each exit 0.check:engine-double-contractfirst asked the coverage ledger to learn the new pinned double:--write, +3 rows, all for the new file, 0 lost.check:docsasked for the regenerated reference page. Both are committed, and both gates are green at the head above.pnpm exec eslint --no-inline-config --format jsonover the 18 changed.tsfiles gave 18 files, 0 errors, 0 warnings, and none of them is ignored by the config (isPathIgnored).eslint.config.mjsnever enables type-aware linting (noparserOptions.project), so this diff cannot move any untouched file's verdict. The fullpnpm lintis CI's.Acceptance notes (observed, not fixed here)
abf86554c9, a base row saved withprotectionwas copied verbatim intoGET /packages/com.probe.base/export's manifest. Measured:apps: [{ ..., protection: { lock: 'full', ... } }]. By code reading, the package loader translates such a block when that manifest is installed (registerApp, thenregisterItem, thenapplyProtection). With this change a base row can no longer carry the block, so a base author declares the lock in the manifest they install instead. No producer in this repo writesprotectionthrough the save door, and the clone doors already drop it. The changeset states this narrowing.FLOW_CLONE_DROPPED_KEYS(runtimeflow-clone.ts) does not listprotection, whilePERMISSION_SET_CLONE_DROPPED_KEYSdoes. Registry-hydrated flows are already stripped byapplyProtection. A clone whose source body still carried a pre-change stored block would now be refused. Not measured.400.tenantAuthoredWriteRefusalusesINVALID_METADATA/422for a body carrying keys a client may not persist. This PR follows triage, with theVALIDATION_ERRORenvelope of the door's other content refusals.Generated by Claude Code