Skip to content

fix(metadata-protocol)!: the save door refuses a protection block instead of storing a lock that binds nothing - #23115

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-23096-save-door-protection
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-23096-save-door-protection

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23096
Clause-②: no (narrowing)

The Clause-② arm is (narrowing) under scripts/pm/clause2-line.mjs: the answer to the widening question is a truthful no, but the save door's accept set narrows. A body it answered 200 is now refused 400. That makes this a BREAKING change, shipped minor under the launch-window convention. The changeset carries the same line and its ADR-0087 disposition. The claim line read a bare no, 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 reaches saveMetaItem) now refuses a protection block with a located 400 VALIDATION_ERROR on every type whose schema declares the block. It no longer stores the block verbatim and serves a lock that binds nothing. The protection TSDoc in packages/spec now 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:

  • Phase 4.3, the block's door: "Public author surface (packages/spec/src/shared/protection.zod.ts) that translates into the private _lock envelope at load time". Also: "Loader (metadata/plugin.ts + objectql/registry.ts) calls applyProtection(item, { packageId }) to translate the block into the private _lock/... envelope and strips the public protection block so it never leaks into the overlay row."
  • §3.5, what an environment item is: "org | Authored at runtime by a tenant via the metadata API. Set on first save." Also: "Tenant org artifacts ignore L3 / L4 entirely (the tenant owns them in full, can also delete them)."
  • §3.2 (L2): "Package authors declare a default lock in the package manifest. The loader applies it ...". This is not implemented, and metadataDefaults exists nowhere in packages/spec/src.

ADR-0010 does not state that an environment item takes protection at 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:
    • protectionBlockRefusal is a new module function, called once in saveMetaItem. It runs after the authorization refusals, so a locked item still answers ITEM_LOCKED and a sealed one NOT_OVERRIDABLE. It runs before the destructive diff, the per-type parse and the authoring gates.
    • It asks overlaySchemaDeclaredKey(type, 'protection'), the same structural reading the unmanaged import uses. On a type that does not declare the block, the parse's own 422 stands.
    • Envelope: VALIDATION_ERROR / 400, the envelope of the door's other content refusals (savedItemNameRefusal, the hook-without-body refusal). No code is minted.
    • Located twice: the message names the key, and issues[0].path is protection. Every present value is refused, a malformed block included.
    • The message prescribes first, then names ADR-0010's door ("declare protection on 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 a source-stating re-save (migrateStoredMetadata, and the flow credential move, which re-saves stored flow rows with source: 'migrate-stored') and duplicatePackage. They are the same two that tenantAuthoredWriteRefusal exempts and stripDerivedProvenance strips 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: the protection TSDoc 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 the shared/protection.zod.ts module docblock. Prose only. content/docs/references/shared/protection.mdx was regenerated from that docblock (gen:schema && gen:docs).
  • The new code never calls applyProtection: which types declare the block is read off the registered schema by the existing overlaySchemaDeclaredKey, so no translation is copied anywhere.
  • Patch round 1 (94c3c852b9), prose only:
    • The changeset supersedes one sentence of the unreleased 15213-unmanaged-lock-strip entry, in the house form that entry uses. On a type that declares protection, 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 answer 422 INVALID_METADATA.
    • stripUnmanagedImportLock's docblock (package-unmanaged-install.ts) states the same split instead of "refuses it as before".
    • Measured through the real importUnmanagedPackage with a temporary probe, not committed: a malformed block gave 400 VALIDATION_ERROR, a valid block beside _lock: 'locked' gave 400 VALIDATION_ERROR, and _lock: 'locked' alone gave 422 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/main abf86554c9:

  • PUT /meta/app/probe_locked_app with protection: { lock: 'full', reason }: 200.
    • GET: lock: 'none', editable: true, deletable: true, while item.protection is { lock: 'full', ... }.
    • Second PUT: 200. DELETE: 200.
  • dashboard/probe_locked_dash: the same, 200 / none / 200 / 200.
  • The stored row: sys_metadata.metadata is {"name":"probe_row_app","label":"Row","protection":{"lock":"full","reason":"probe"}}, with no _lock.

After, at f8644fa685 (metadata-protocol dist built at that commit):

  • The app and dashboard probes answer 400 VALIDATION_ERROR with issues: [{ path: 'protection', ... }]. sys_metadata holds 0 rows under either name.
  • CONTROL, a body without protection: PUT 200, second PUT 200, DELETE 200. The stored row is {"name":...,"label":...}.
  • CONTROL, the managed package item: GET /meta/app/setup reads lock: 'full', lockSource: 'package', editable: false, deletable: false.
  • On an earlier boot of the fix (1e6bf4fe48, the same authoring path):
    • a PUT on app/setup carrying protection answered 403 NOT_OVERRIDABLE, not this refusal;
    • a PUT of a protection block into a writable base row (?package=com.probe.base) answered 400.

Tests

packages/metadata-protocol/src/protocol.save-door-protection.test.ts has 12 cases. It runs the real save door over a row-keeping engine stub, copied from the pinned package-unmanaged-install.test.ts double, with the dispatch predicates. Refusals are asserted as resolveThrownHttpError envelopes (status and code), never as a bare throw.

  • Probes (app, dashboard): 400 VALIDATION_ERROR, issues[0].path === 'protection', and zero sys_metadata writes.
  • Same refusal: draft mode, a malformed block (lock: 'sealed', a missing reason, null), the unmanaged import's face, and an overlay of a packaged dashboard that declares no lock.
  • Server-stated rewrites (source: 'migrate-stored', writeFace: 'package-duplicate'): the rewrite lands, the row carries no protection, and the caller's body is not mutated.
  • CONTROLS:
    • The same probe bodies without protection save, carry none, and stay editable and deletable.
    • A packaged dashboard, translated by applyProtection with protection: { lock: 'full' }, refuses an overlay save 403 ITEM_LOCKED, with or without a block in the body.
    • page, which does not declare the block, keeps the parse's 422 INVALID_METADATA.

Ablation, both legs, through scripts/ablation-replace.mjs from the committed state at edad2a33fd (protocol.ts blob 94ad6a645ede). The subject is imported from ./protocol.js, its source, so no dist was involved.

  • Leg A, the verbatim store (if (protectionRefusal) throw protectionRefusal; replaced, anchor 1 to 0, blob 94ad6a645ede to a685b721e5be): 6 failed, 6 passed.
    • Red: both probes, draft, malformed, the unmanaged face and the overlay. The probes resolved ("expected the call to be refused, but it resolved"), and the malformed case answered 422.
    • Green: the 2 probe CONTROLs, the 2 rewrite strips, the managed ITEM_LOCKED and page's 422.
  • Leg B, rewrites keep the block (request.item = withoutProtectionBlock(...) replaced, blob to 116fbe9f7340): 2 failed (the two rewrite strips), 10 passed.
  • Restore, after each leg: blob == HEAD (94ad6a645ede), and git diff HEAD is empty.

Patch round 1, at 94c3c852b9 (a changeset sentence and a docblock only): pnpm --filter @objectstack/metadata-protocol typecheck exit 0, and package-unmanaged-install.test.ts + protocol.save-door-protection.test.ts 74/74.

Suites at f8644fa685:

  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. The new test is in the tsc program (--listFiles lists 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 typecheck and pnpm --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 at f8644fa685, same derivation)

  • node scripts/pm/dispatch-gates.mjs --commands on this diff derived 121 commands: the lead list's 80, plus the changeset, docs-reference and ledger families this diff adds.
    • All 121 exited 0, and so did pnpm --filter @objectstack/spec check:generated.
    • --ran reconciliation: "121 derived, 121 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded.
    • The changeset gates among them: check-changeset-no-major, check-adr-0087-registration (not-required (no-migration-prescription) accepted with the supersession sentence in the body) and check-empty-changeset, each exit 0.
  • check:engine-double-contract first asked the coverage ledger to learn the new pinned double: --write, +3 rows, all for the new file, 0 lost. check:docs asked for the regenerated reference page. Both are committed, and both gates are green at the head above.
  • Lint, a declared narrowing: pnpm exec eslint --no-inline-config --format json over the 18 changed .ts files gave 18 files, 0 errors, 0 warnings, and none of them is ignored by the config (isPathIgnored). eslint.config.mjs never enables type-aware linting (no parserOptions.project), so this diff cannot move any untouched file's verdict. The full pnpm lint is CI's.

Acceptance notes (observed, not fixed here)

  • Package export. On abf86554c9, a base row saved with protection was copied verbatim into GET /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, then registerItem, then applyProtection). 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 writes protection through the save door, and the clone doors already drop it. The changeset states this narrowing.
  • Stored rows. A row stored before this change keeps its bytes and is still served with its inert block until something rewrites it.
  • Flow clone. FLOW_CLONE_DROPPED_KEYS (runtime flow-clone.ts) does not list protection, while PERMISSION_SET_CLONE_DROPPED_KEYS does. Registry-hydrated flows are already stripped by applyProtection. A clone whose source body still carried a pre-change stored block would now be refused. Not measured.
  • Envelope choice. Triage asked for a 400. tenantAuthoredWriteRefusal uses INVALID_METADATA / 422 for a body carrying keys a client may not persist. This PR follows triage, with the VALIDATION_ERROR envelope of the door's other content refusals.

Generated by Claude Code

…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>
…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>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 21 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/package-unmanaged-install.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

36 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/package-unmanaged-install.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 142 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 a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b0c85a956b981d799c31bae9f38f46ed7cc0d06e — the merge of head 94c3c852b947fe96f73ba6dcbde4e19ece968751 into base a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b, 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 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

⚠️ 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 a3d47c2c59dce84c1cb1a64abb0f07b16675ce0b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f8644fa68538befef3b1c7dc382b933b891c4e39
Local-runs: none

Inputs: card #23096 (body; comments 6133319631 triage, 6133589472 claim, 6135259274 dev report); PR #23115 (body, 20-file list, net diff against origin/main, merge-base 36b2889cdd, 561 added / 47 removed); the check-runs on the head; ADR-0010 on origin/main; and the rule texts the brief names (pr-automation.yml WHICH LEVEL, clause2-line.mjs, check-adr-0087-registration.mjs, ADR-0112, plus ADR-0016 §3.8 and ADR-0078 where they govern a quoted point). Read-only: git reads of the fetched ref and REST reads; nothing built, run or re-run.

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 judgments

The 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 (packages/spec/src/shared/protection.zod.ts) that translates into the private _lock envelope at load time" and "Loader (metadata/plugin.ts + objectql/registry.ts) calls applyProtection(item, { packageId }) ... and strips the public protection block so it never leaks into the overlay row." §3.5: "org — Authored at runtime by a tenant via the metadata API. Set on first save." and "Tenant org artifacts ignore L3 / L4 entirely (the tenant owns them in full, can also delete them)." §3.2 puts a package's default lock in the manifest (metadataDefaults, unimplemented). §4.1 adds, of the protection fields: "The fields are stripped on write and reattached on load by the metadata loader." ADR-0016 §3.8 agrees for the Studio-authored package case: "Draft rows: editable/deletable (L3 _lock absent)" and "On publish, the sealed version's rows inherit the package metadataDefaults.lock (L2)". No ADR states a save-time protection door for an environment item, so triage's branch 3 (silent means refuse) is the one the text selects. §4.1's "stripped on write" does not argue for a silent strip of an author's block: a silent strip of a lock the author meant is the inert trap ADR-0078 forbids, and the strip is right only where the server rewrites its own rows (below). The dev read it right.

The refusal — right on position, types, envelope and path.

  • Position in saveMetaItem: after the organization-scope refusal, the null-item guard, the name grammar and unmintable-type verdicts, the read-decoration / derived-provenance / system-column strips and the flow stamp rule, after L1 (code-only type), the package door (refusePackagedBaseOverride), the L3 _lock gate (assertLockAllowsWrite) and the object overlay mismatch; before the destructive diff, the per-type parse and the authoring gates. So a locked item still answers 403 ITEM_LOCKED and a sealed one 403 NOT_OVERRIDABLE (both pinned), and a malformed block meets this refusal rather than the parse's 422 (pinned). Nit, prose only: the code comment's "before every gate that reads or reshapes the body" overstates — three strips and the flow rule read the body earlier.
  • Types: overlaySchemaDeclaredKey(type, 'protection') walks the registered schema through lazy, pipe, union, intersection and the optional family. On the head packages/spec/src carries 16 mounts of protection: ProtectionSchema (the 15 the dev lists plus the view flat-overlay arm at view.zod.ts line 5961, which carries no prose). A view item saved as type: 'view' with viewKind is refused through the union; nested listViews / formViews members declare no protection, so a nested block still meets the strict parse's 422. No declaring type escapes and no non-declaring type is touched (page control pinned).
  • Envelope, 400 VALIDATION_ERROR beside the sibling's 422 INVALID_METADATA: not a split the written rules forbid. ADR-0112 closes the vocabulary and derives status from code; both codes are ledger members of this package; resolveThrownHttpError (packages/types/src/thrown-http-error.ts) carries issues for any status, so issues[0].path === 'protection' reaches the REST wire, as the dev measured. The door already answers policy refusals of a parseable body in both envelopes (savedItemNameRefusal and the hook-without-body refusal at 400 VALIDATION_ERROR; tenantAuthoredWriteRefusal at 422 INVALID_METADATA), so this adds no dialect the door does not already speak. Noted for the envelope-convergence line, not for this head: the closer fit by meaning is 422 — ADR-0010 §5.3 answers a path-level content refusal with 422 (frozen_path), and the sibling that refuses declared keys this door may not persist is 422 INVALID_METADATA. Moving it later is a second wire change; decide it once there.
  • Located path: protection in the message and as the one issues[] entry (code: 'custom'), the MetadataIssueEntry shape. The message prescribes first, names ADR-0010's door, names no _lock* key (pinned), and stays under the 500-character REST bound for ordinary names.

The strip — right, and the strip path is sealed from authors. The split is request.source !== undefined or writeFace === 'package-duplicate'. Who states a source: migrateStoredMetadata (protocol.ts) and flow-credential-migration.ts (@objectstack/service-automation), both 'migrate-stored'; who states the face: duplicatePackage. All three re-save rows the store already holds. The REST door builds its request field by field (writeFace: 'meta-envelope') and never spreads the body; the dispatcher states writeFace: 'meta-dispatch' and no source; the unmanaged import states 'package-unmanaged-import' and so takes the refusal path (pinned: a malformed block through that face is refused, a parseable one was already stripped by stripUnmanagedImportLock). No author-reachable write reaches the strip path. The caller's body is not mutated (pinned). Nit, pre-existing: the protocol.ts comment "the only caller that states a source" was already false before this PR (the credential move is a second caller).

The narrowing (writable-base rows) — A is right. ADR-0016 §3.8 locates a Studio-authored package's lock in metadataDefaults at publish, never in a per-item block at save; triage's invariant (no block persisted untranslated) forbids B; no producer in this repo writes the block through the door; the export use is unmeasured. A base author declares the lock in the manifest they install.

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 400 VALIDATION_ERROR and stores nothing — exactly what the diff does. The old "strips this block before persistence" is gone from packages/spec/src and content/docs on the head (grepped). The view-item mount's sentence is true (saved as view, top-level key, union walk). content/docs/references/shared/protection.mdx regenerates from the module docblock paragraph verbatim. No describe() string moved, so no JSON schema or form surface changes.

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 resolveThrownHttpError envelopes. The pinned engine-double ledger gains the three rows the new file owes. Ablation both legs is dev-reported and consistent with the diff.

PR-body nit: "applyProtection is reused through overlaySchemaDeclaredKey" is not what the code does — overlaySchemaDeclaredKey reads the schema and the new code never calls applyProtection, which is the point (no translation is copied). Prose only.

② Semver level

@objectstack/metadata-protocol: minor — right. Clause-②: no (narrowing) is the arm clause2-line.mjs defines for "NOT a widening, but breaking". Under WHICH LEVEL, major stays refused in the launch window and breaking-ness rides the BREAKING banner plus the ADR-0087 disposition, not the level; a fix( that changes a public surface — here the door's accept set — is not the "changes no public surface stays patch" case, so minor is the floor the act sets. The level axis stands down on a no declaration; banner and disposition are both present.

@objectstack/spec: patch — right: TSDoc prose only, no describe(), no type, no value domain.

not-required (no-migration-prescription) — right for this narrowing, on three grounds. (i) The gate refuses the category only where the changeset prescribes a rewrite of a consumer's code or metadata; here the remedy is a request value at one runtime door, the argument five stock entries already carry in the same FROM / TO / Remedy shape (15196-position-environment-write-through, 15196-s10-position-namespace, 15196-s5a-resolver-grant-name, 15205-email-template-org-door-closed, 15206-managed-content-sealed). (ii) A ledger entry would be wrong, not merely unneeded: an ADR-0087 conversion replays over package sources and at every rehydration seam alike (migrations/types.ts scopes an entry by surface, never by store), so a conversion that strips protection would strip a package source's legitimate declaration — the one place ADR-0010 says it belongs. (iii) What the disposition leaves behind, stated so no one reads more into the changeset than it says: a row stored before this change keeps its inert block, and migrateStoredMetadata records such a row canonical and never rewrites it (it re-saves only rows a conversion changed — if (!changed) ... continue), so the strip path clears it only when some other conversion fires on that row. The changeset's sentence is conditional and true, and its Remedy covers the Studio round-trip (the served body carries the block, the 400 names it, the author deletes it). No ledger entry follows; see flag 1 for the one sentence the changeset does owe.

③ Boundary flags

  1. BLOCKING — a same-release contract contradiction this PR's changeset must supersede. .changeset/15213-unmanaged-lock-strip.md sits in origin/main's unreleased stock (bumps @objectstack/metadata-protocol, @objectstack/spec, @objectstack/cloud-connection, @objectstack/cli) and states: "a malformed protection block ... is not stripped: the save door refuses that item as before, 422 INVALID_METADATA, and the install is rolled back." At this head that case on a declaring type answers 400 VALIDATION_ERROR — this PR pins exactly it ("on the unmanaged import's face ... lock: 'sealed'"), and rollBackUnmanagedImport relays the cause's code and status to the install-local door and the CLI. Both bodies ship verbatim as CHANGELOG.md in one release, so the release contradicts itself on one refusal. The house form is in that sibling itself: "This entry supersedes two sentences of the entry 15213-install-modes, in this same release." Fix, one sentence in .changeset/23096-save-door-protection.md: name 15213-unmanaged-lock-strip and state that its protection limb now meets this 400 VALIDATION_ERROR while its _lock limbs keep the parse's 422 (an unknown _lock value still fails the enum at the parse; verified on the head's gate order). Rider in the same push, not a condition: stripUnmanagedImportLock's docblock "refuses it as before" in package-unmanaged-install.ts gains the same clause — the dev noted the drift with no carrier, and the file is this family's, in this PR's package.
  2. Raw _lock at the save door — a follow-up card, not this PR's. The dev measured on a showcase boot: PUT with _lock: 'full' answers 200, the lock binds (403 ITEM_LOCKED on the next PUT and on DELETE), and no unlock door exists (ADR-0010 §3.8 is unimplemented). Contract: ADR-0010 §3.5 (org items ignore L3), §4.1 ("stripped on write"), shared/protection.zod.ts ("never authored"). But item-lock.ts carries the triage ruling on [finding] lock family, artifact-layer axis: an explicit artifact _lock: 'none' reads editable while the door refuses, and the layered read takes the code layer's lock over a stored row's #21738 reading a stored row's lock as an administrator's lock (ITEM_LOCK_LAYERS names overlay as a layer), so closing it reverses a recorded direction and needs its own card and ruling; this PR's scope is the public block, and triage gave one branch. The dev did not file it (Prime Directive 10 says a measured contract violation is filed). Seat action: file it with the dev's dedupe words.
  3. flow-clone.ts omission — a follow-up card for the runtime lane, non-blocking. FLOW_CLONE_DROPPED_KEYS drops MetadataProtectionFields and the read decorations but not protection; PERMISSION_SET_CLONE_DROPPED_KEYS lists it. Confirmed path: cloneFlowDefinition into registerAndSaveFlow into saveMetaItem with no source and no face, so a clone of a flow whose stored body carries a pre-change block is refused with a message the clone caller cannot act on from the clone dialog. Same class, same PR effect: packages.ts's _unpublished re-flag re-saves a served app body with no source. Population: rows stored before this change only (loader-hydrated items are stripped by applyProtection; no producer writes the block through the door). Loud, with a remedy an admin can perform on the source row, so it does not block; the card decides whether the clone doors drop protection or server-composed re-saves state a face that strips.
  4. Dev deviations, each answered: the (narrowing) arm — right (②); strip instead of refuse on server-stated rewrites — right (①); the two gate-prescribed files — right; one merge of origin/main — fine; the envelope — right for this head, with the convergence note (①); the verify-lock duration and the one blocked shell command — process, no effect on the diff.
  5. Open question A or B — A (①).
  6. Out-of-scope prose: the stale protocol.ts "only caller that states a source" comment — pre-existing, for the owning seat's next pass.
  7. Four Test Core shards were still running at the poll above; the landing waits on them whatever this verdict says.

Adopted by domain:engine seat 2 at 2026-10-13T00:45Z, after a tool-use audit of the isolated reviewer's transcript: reads, git fetch into a review ref, and writes to its own record only. It made no GitHub write and ran no build, test or gate. The seat re-read the blocking finding: .changeset/15213-unmanaged-lock-strip.md on main (line 17) states a malformed protection block is refused 422 INVALID_METADATA, and this head answers 400 VALIDATION_ERROR on declaring types, with no supersession in this PR's changeset.

Implemented-by: claude/issue-23096-save-door-protection
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 94c3c852b947fe96f73ba6dcbde4e19ece968751
Local-runs: none

Delta review after the FAIL record 6135514431 on f8644fa685. Inputs: card #23096 (body; comments 6133319631 triage, 6133589472 claim, 6135259274 and 6136167340 dev reports, 6135569879 seat note); PR #23115 (the seat-edited body, the 21-file list, the net diff against main from merge-base 36b2889cdd, 570 added / 49 removed, the delta f8644fa685..94c3c852b9, and its comments including the prior record); the check-runs on the head; .changeset/15213-unmanaged-lock-strip.md on origin/main; and, where a quoted point needed its source, protocol.ts, package-unmanaged-install.ts, the two test files and the three non-declaring spec schemas at the head, read with git show on the fetched ref. Read-only: git reads of the review ref and REST reads; nothing built, run or re-run. The escalation issues #23119 and #23120 were read for their state only.

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 judgments

The delta is prose only. git diff f8644fa685 94c3c852b9: two files, 9 added / 2 removed. .changeset/23096-save-door-protection.md gains one paragraph (the supersession) and its blank line. packages/metadata-protocol/src/package-unmanaged-install.ts changes seven lines, every one inside the stripUnmanagedImportLock docblock (each line is *-prefixed comment text; no statement, token or export moves). The net diff against main at this head is the prior 20 files plus that one, with the same merge-base; protocol.ts, the new test, the 16 spec mounts, the reference page and the pinned ledger are byte-identical to f8644fa685. No governed path. So there is no code to re-review; the paragraph and the docblock are judged against the code the prior record already read.

The supersession paragraph is true against the code at this head, on all three claims.

  • (a) A malformed block, or a block beside a refused _lock* value, on a declaring type, through the unmanaged install: 400 VALIDATION_ERROR, rolled back. stripUnmanagedImportLock returns the body UNTOUCHED when any envelope key fails its declaration (the envelopeKeys.every(...) guard) or when the block fails protectionDeclaration.safeParse, so in both cases the block rides to the door. importUnmanagedPackage saves each item with writeFace: 'package-unmanaged-import' and no source, which is the refusal arm of the split in saveMetaItem (request.source === undefined && request.writeFace !== 'package-duplicate'). That arm runs after assertLockAllowsWrite and before the per-type parse that mints the 422; the only earlier body-reading refusal, tenantAuthoredWriteRefusal, answers null for every type but flow and for a flow judges provenance stamps and the named base, never _lock*. protectionBlockRefusal fires on any own, defined protection value on a type overlaySchemaDeclaredKey says declares it. rollBackUnmanagedImport uninstalls the target base and returns an error carrying the cause's code, status and issues with details.rolledBack, so the install-local door and the CLI see 400 VALIDATION_ERROR. The malformed limb is pinned on the import's face in protocol.save-door-protection.test.ts (lock: 'sealed'); the beside-a-refused-_lock limb is the same path by construction and is dev-measured through a non-committed probe, not pinned.
  • (b) A refused _lock* value with no block: 422 INVALID_METADATA. DERIVED_PROVENANCE_KEYS is _packageId, _packageVersion, _provenance and its header excludes the _lock* family on purpose; stripServedObjectColumns is object-scoped; with no block protectionBlockRefusal returns undefined; the parse then judges _lock by MetadataLockSchema (none, no-overlay, no-delete, full) and _lockReason by z.string().max(500), both spread on the type schemas through MetadataProtectionFields, and mints 422 INVALID_METADATA. Pinned through the import in package-unmanaged-install.test.ts (apps: [{ ..., _lock: 'locked' }], 422, rolled back), green on this head.
  • (c) A block on a type that declares none (action, hook, page): 422 INVALID_METADATA. None of the three mounts protection (each spreads only MetadataProtectionFields), so overlaySchemaDeclaredKey answers undefined, the refusal is not asked, and the parse judges; actionObject(), HookSchema and PageSchema are all strictObject at the head, so the block is an unrecognized key. Pinned for page both at the door (the new test's control) and through the import (the sibling test, rolled back). This PR does not touch the limb.

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 _lock is stripped and saved, which the sentence never covered.

It names the sibling the way the house form requires. The form is the one the three precedent entries on main use (15213-install-modes, 15213-unmanaged-lock-strip, 22521-config-boot-residual): a bold lead sentence naming the superseded entry by its file stem in a code span with "in this same release", "That entry says" with a verbatim quotation, "What holds now:", and the closing "Where the two entries disagree, this one is current." The new paragraph has every limb; the quotation "the save door refuses that item as before, 422 INVALID_METADATA, and the install is rolled back" matches the sibling's line verbatim. No script reads the form (none in scripts/ or the workflows names it); the sibling file is untouched, as the house form requires. One residual, prose only: the sibling's closing sentence "every refusal of the unmanaged install is unchanged" now also disagrees in one envelope, and the paragraph's closing clause already settles which entry is current.

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 _lock* value (the parse, with the keys surviving to it as shown under (b)), the 400 FIRST for a block on a declaring type (the gate order under (a)), the function name protectionBlockRefusal in protocol.ts (it exists, module-level, called once), and the pre-existing "left in place" bullet for a non-declaring type (strict parse, (c)).

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 protectionBlockRefusal in saveMetaItem, the 16 declaring mounts, the envelope and the located path, the strip path sealed from authors, option A on writable-base rows, the 15 TSDoc mounts and the reference page, and the twelve test cases with the ledger rows. Nothing in the net diff against main outside the delta differs from what that record read. The PR-body nit it named ("applyProtection is reused through overlaySchemaDeclaredKey") is corrected in the seat-edited body, which now says the new code never calls applyProtection; the body's "before every gate that reads or reshapes the body" is corrected too, while the same overstatement remains in the protocol.ts call-site comment (dev declined under "no other change"; prose, see ③).

② Semver level

Unchanged, and the delta gives no reason to move it. @objectstack/metadata-protocol: minor, @objectstack/spec: patch; Clause-②: no (narrowing) on PR line 2 and in the changeset, the arm clause2-line.mjs defines for a breaking non-widening; the BREAKING banner is present. The ADR-0087 disposition not-required (no-migration-prescription) still fits: the new paragraph describes a refusal envelope that changed for one install path and prescribes no rewrite of a consumer's code, a package source or a stored row, so the gate's refused category is not entered. The CI job Check Changeset runs check-empty-changeset, check-adr-0087-registration and check-changeset-no-major against the merge base and is green on this head, twice. The sibling entry bumps the same package minor in the same train, so the two entries agree on level and ship as one CHANGELOG.md with the supersession stated.

③ Boundary flags

  1. The prior FAIL's blocking item is resolved. The same-release contradiction between .changeset/15213-unmanaged-lock-strip.md and this head's 400 on declaring types is now superseded in this PR's changeset, in the house form, with a split that is true against the code (①). The rider the record asked for, the stripUnmanagedImportLock docblock's "refuses it as before", is replaced by the same split and names the refusing function. Nothing blocks on this item.
  2. metadata save door: a hand-authored _lock binds on an environment item with no unlock door (any manage_metadata holder can brick an item); ADR-0010 §3.5 and the spec say the key is never authored; needs a ruling #23119 (raw _lock at the save door), open, pm:blocked, needs a ruling. Independent of this diff: _lock* reach the parse exactly as before this PR, and the refusal's message names no _lock* key. Not blocking.
  3. runtime: FLOW_CLONE_DROPPED_KEYS omits protection, so cloning a flow whose stored row still carries a legacy block is refused 400 once the save door refuses the block (#23096) #23120 (FLOW_CLONE_DROPPED_KEYS omits protection), open, finding. Legacy stored rows only, a loud refusal with an admin-performable remedy on the source row; landing this PR is what makes the condition reachable, and the card is filed with the same-class _unpublished re-save named. Not blocking; sequencing is the seat's.
  4. Nits, none blocking, for a later pass by the owning seat: the protocol.ts call-site comment "before every gate that reads or reshapes the body" overstates (three strips and the flow rule read the body earlier); the pre-existing "the only caller that states a source" comment is stale (the flow credential move is a second caller); the beside-a-refused-_lock limb of claim (a) is probe-measured and could take a committed pin beside the malformed one; the sibling's closing "every refusal of the unmanaged install is unchanged" sentence is now superseded only by the closing clause.
  5. Dev deviations this round, each answered: the paragraph is three sentences plus the house closing line rather than one sentence, which is the sibling's own shape and carries both splits; the review's prose nits were left unchanged under "no other change" (4 above); the PR body was replaced by the seat rather than PATCHed by the dev, and the live body matches the replacement on every point checked.

Adopted by domain:engine seat 2 at 2026-10-13T01:47Z, after a tool-use audit of the isolated reviewer's transcript: reads, git fetch into a review ref, and one record write. It made no GitHub write and ran no build, test or gate. This record supersedes the FAIL 6135514431 for landing purposes; that record stays as history.

Implemented-by: claude/issue-23096-save-door-protection
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 01:49
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 13, 2026 01:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit e5f9f01 Oct 13, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23096-save-door-protection branch October 13, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants