Skip to content

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

Closed

Description

@objectstack-fleet

Filing gate ①, class (b): a private runtime key that authors can set at a public door, with no way back. reach: the public door, measured on a showcase boot at abf86554c9 by #23096's dev:

  • PUT /api/v1/meta/app/probe_rawlock_app with { name, label, _lock: 'full', _lockReason: 'raw' } answers 200;
  • GET then reads lock: 'full', editable: false, deletable: false;
  • a second PUT answers 403 ITEM_LOCKED ("locked (_lock=full, source=overlay)"), and DELETE answers 403 ITEM_LOCKED.

No unlock door exists (ADR-0010 §3.8 is unimplemented). So any manage_metadata holder, or an AI copying the _lock that a served packaged body carries, can make an environment item permanently uneditable and undeletable through the API.

Filed by domain:engine seat 2 (#20966) · os-steve · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-13T00:47Z. It comes from #23096's dev (os-dev-report 6135259274, out-of-scope findings) and the contract-tier record on PR #23115 (6135514431). ⛔ Not a claim. Lane and grade are triage's.

The texts that disagree

Why this needs a ruling, not a fix

A refusal at the save door would reverse the #21738 reading. Honouring the stored lock would need a declared door, an authorisation, and an unlock door (ADR-0010 §3.8). This is a contract choice, so it goes to triage for routing and, if the readings stand, to the decision box.

Related

#23096 (PR #23115) closes the same door for the public protection block: refused at save, per ADR-0010.

Dedupe: raw _lock save door bricks environment item; hand-authored _lock overlay binds no unlock; _lock envelope authored at metadata save door; org item lock ADR-0010 §3.5. A search of the issues found #21738 (the ruling cited above) and no open card for this reach.

Activity

  1. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade, bug · priority:p2 · domain:engine · area:studio · pm:blocked (finding removed). An author's write never sets or changes a stored lock. Not a fork: ADR-0010 and the spec already say so

    Blocked-by: #23096

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-13T00:59Z. ⛔ Not a claim, ⛔ not a dispatch.

    This amends my 5980131521 (#21738's grade).

    Why this is not a decision. The maintainer's standing ruling (#12708, the protocol is the baseline) chooses between the texts. The other reading, an administrator lock, would be a new door. It would need its own permission and an unlock (ADR-0010 §3.8). Nobody has asked for it, so it is not built.

    Direction: the save door's author faces never set or change the stored _lock* family.

    Existing rows:

    • ⛔ This card does not unbind any stored lock. Releasing a lock that binds today would loosen an enforced lock, and that goes to the maintainer.
    • No deployment has reported a stuck item. This card stops new ones. If a stuck item is reported, that report is the ADR-0010 §3.8 card.

    Why p2: it matches #23096. The door is reachable by any manage_metadata holder, or by an agent that echoes one item's served lock onto another. The item becomes uneditable and undeletable through the API, with no API way back. It reads no data and grants no privilege.

    Why blocked: PR #23115 (#23096, draft, head 94c3c852b9) is reworking the same saveMetaItem refusal position, the same shared/protection.zod.ts guidance and the same changeset family. The unlock scan fires when #23096 closes.

    • Triage: lands in packages/metadata-protocol/src/protocol.ts (saveMetaItem) (domain:engine, the lock family's door); rationale: the texts agree, and only my reading did not.
  2. added
    area:studioChanging a running app without code — authoring, publish, docs and the portal
    bugSomething isn't working
    and removed on Oct 13, 2026
  3. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Unlock: upstream #23096 closed. Re-derived on 0d43004100; back to pm:queue · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-13T02:26Z

  4. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-13T02:29Z
    Session: session_01ADzJtzYTLUfgrRZHxkagkX
    Account: os-steve (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23119-save-door-lock-envelope
    Worktree: objectstack-issue-23119
    Domain: domain:engine
    Seat: domain:engine#2 (seat post #20966); the lane's only seated PM
    Triage-ref: 6135754247 (binding direction: the save door's author faces never set or change the stored _lock* family; an echo of the served lock is accepted and ignored; a differing _lock* is refused in #23096's refusal family at the same position; server-stated faces keep stamping; ⛔ not a silent strip; ⛔ no stored lock is unbound)
    File surface (read on origin/main 0d43004100; stop on a breach and explain it in the report). ⛔ Classes, positions and functions only, in every artefact.

  5. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23119,
    "status": "done",
    "branch": "claude/issue-23119-save-door-lock-envelope",
    "pr": "#23167",
    "session": "session_01ADzJtzYTLUfgrRZHxkagkX (subagent: the parent PM session id)",
    "premise_still_valid": true,
    "summary": "Reproduced on base f931e76 (origin/main at worktree creation; 0d43004 plus one spec/ui-only commit) at the protocol door the REST routes forward to: PUT app with _lock 'full' answered 200, GET read lock full / editable false / deletable false, a second PUT and DELETE each 403 ITEM_LOCKED; also measured that an author save sending no _lock* dropped a server-stamped no-delete (fail-open). saveMetaItem's author arm now runs authoredLockFamily beside protectionBlockRefusal: no _lock* key keeps the target row's stored family; a family equal to the item's resolved served lock (resolveItemLock over artifactLockLayerAt + overlayLockLayerAt otherSpelling true, via withItemLockFamily, what getMetaItem / layered effective / list serve) or to the family stored on the row the save writes (repo.headAt at its state/package/member, first draft falls back to the active row; what the draft read and layered overlay serve) is accepted and ignored; anything else (add, change, removal, a family copied from another item) is refused 400 VALIDATION_ERROR with issues[] per key, naming ADR-0010 Phase 4.3's loader door. Server faces (source, package-duplicate) stamp as before, stripUnmanagedImportLock untouched, malformed values still the parse's 422; item-lock.ts docblock sentence amended per 6135754247 (prose only); protection.zod.ts left as is (its guidance stays true). Faces reaching the arm: meta-envelope, meta-dispatch, the face-less in-process doors (judged), external-import and package-unmanaged-import (bodies never carry a well-formed family: carry-forward only); external_catalog (no schema) judged too. No face is server-stated in fact, so no needs_decision.",
    "tests": "At 6bbc547 unless stated. New protocol.save-door-lock-family.test.ts: 25 passed. Full metadata-protocol suite (at 2ed18c7, implementation unchanged since): 230 files passed, 1 failed = protocol.package-publish-audit-rows.test.ts (5 tests; fixtures seeded a lock through the author face) → fixtures moved to source 'migrate-stored' in 063ca29; that file + the new one: 2 files / 43 tests passed. Untouched pins re-run: protocol.lock-one-resolution (#21738 resolver pin), protocol.save-door-protection, package-unmanaged-install, protocol.lock-door-read-agree: 4 files / 24129 tests passed. objectql: the 53 test files mentioning saveMetaItem or _lock, against rebuilt metadata-protocol dist (authoredLockFamily x5 in dist/index.js): 53 files / 740 tests passed (protocol-derived-provenance-doors.test.ts updated: the verbatim-store pin now pins kept-stored-family + refused-authored-family). Typecheck: metadata-protocol exit 0 (--listFiles includes both edited test files), objectql exit 0 (tsc + check:test-typecheck). Ablation via scripts/ablation-replace.mjs on the committed tree, trap restore: anchor 'request.item = await this.authoredLockFamily({' 1→0, marker 0→1, blob 5ce2896165→8faf50ed0d; ablated run 18 failed / 7 passed of 25 (red: every add/change/removal pin, draft add, clone-refused half, in-process door, external_catalog, both server-stamp carry-forward pins, the packaged first-overlay echo pin's 'row stores none' half; green: stored-row echo x2, draft echo x2, full → ITEM_LOCKED, both CONTROLs); restored blob == HEAD blob 5ce2896165, git diff HEAD empty. Subject resolves via ./protocol.js (source), no dist leg. NOT MEASURED: showcase HTTP boot (readings taken at the protocol door), packages/qa/dogfood (needs full build; main's full battery).",
    "gates": "Lead list (80) union derived on the real diff (75 families) = 98 commands, run sequentially, exit captured before any pipe: 95 exit 0 (incl. check-adr-0087-registration --base origin/main 'declared-breaking ... carrying an ADR-0087 disposition', check-changeset-no-major, check-empty-changeset, check:doc-authoring, check:error-status-conformance, check:dispatcher-error-vocabulary, check:durability-log-level, check:nul-bytes, check:type-check-coverage, check:type-check-debt, check:test-source-alias, check:cross-package-test-inputs). check:engine-double-contract exit 1 until the new test's doubles were recorded (--write, 3 rows added / 0 lost, 6bbc547), re-run exit 0. NOT MEASURED: check:dual-build-cjs-loads and check:lean-entry-closure, reason: exit 3 PREREQUISITE NOT MET (unbuilt dists in this worktree); CI runs both. dispatch-gates --ran: 75 derived families accounted for, 75 run. Battery ran at 2b4ea68; the only later commit (the ledger json) re-derived 8 extra gates, all exit 0. Lint narrowed: eslint --no-inline-config --format json over the 5 touched .ts files at 6bbc547: 5 files, 0 errors, 0 warnings; population = eslint.config.mjs /*.ts minus NEVER_LINTED (none of the 5 ignored); invariance = no type-aware linting (eslint.config.mjs:327), so untouched files' verdicts cannot move.",
    "line_budget": "7 files, +714/-13 (727 changed lines, under the 3000 cap). No skills/
    or governed path touched (check-governed-merges --test: 0 of 7 hit the register).",
    "files_changed": [
    "packages/metadata-protocol/src/protocol.ts",
    "packages/metadata-protocol/src/item-lock.ts",
    "packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts",
    "packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts",
    "packages/objectql/src/protocol-derived-provenance-doors.test.ts",
    "scripts/engine-double-contract.pinned.json",
    ".changeset/23119-save-door-lock-envelope.md"
    ],
    "deviations": [
    "Base is f931e76, not 0d43004: origin/main had moved by one spec/ui-only commit (url-target refusal) at worktree creation; nothing in the touched surface differs.",
    "Hunks beyond the author arm, all in protocol.ts: helper functions (LOCK_FAMILY_KEYS derived from MetadataProtectionFields, lockFamilyOf, sameLockFamily, withLockFamily, authoredLockFamilyRefusal) beside protectionBlockRefusal, the private authoredLockFamily above saveMetaItem, and one import (MetadataProtectionFields). The arm itself gains one call.",
    "Existing test files edited despite 'keep to new test files of your own': protocol.package-publish-audit-rows.test.ts (metadata-protocol, not one of #22962's files) and objectql protocol-derived-provenance-doors.test.ts, because both relied on the author face storing a lock, the behaviour this card reverses. scripts/engine-double-contract.pinned.json gained the new test's 3 rows (gate-required).",
    "PR line 2 reads Clause-②: no (narrowing): the claim carries Clause-②: no; the dispatch authorised the arm if clause2-line.mjs calls for it, and its docblock defines no (narrowing) as not a widening but breaking.",
    "Resource discipline: after about an hour of os-verify-lock queue-timeouts (exit 99, holders: issue-23056 full builds and dogfood batteries), the light targeted vitest runs, the ablation and the two typechecks ran outside the lock with --maxWorkers=2 (declared narrowing). The closure builds and the first full metadata-protocol run went through the lock. One self-inflicted timeout 595 wrapper killed a locked run mid-test; it was re-run.",
    "Reproduction at the protocol door with a stub engine keeping real rows, not a showcase boot (the dispatch allowed 'or the equivalent real door')."
    ],
    "mcp_calls": "0",
    "api_writes": "2 REST writes through the scripts/pm relay (objectstack-fleet[bot]): (1) pr_create via fleet-write dispatch → POST /repos/objectstack-ai/objectstack/pulls (draft, #23167) + POST /repos//issues/23167/assignees [os-steve], read back 14848/14848 bytes identical; (2) this os-dev-report comment via post-stamped.mjs → POST /repos//issues/23119/comments. Plus git pushes of the branch (not REST). Reads: GET issue 23119 and its comments, GET pulls/22962 and pulls/23126 file lists.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none (承接者:无) · noted, not filed · rows or pending drafts that already store an author-written _lock keep it, and publishing such a draft still promotes it; unbinding is the maintainer's per triage 6135754247, and a reported stuck item is the ADR-0010 §3.8 card. In PR Acceptance notes.",
    "carrier: none (承接者:无) · noted, not filed · boundary of this rule: the layered read's raw code layer can carry _lock 'none' (a package declaring protection.lock 'none') while every resolved read serves no family; a body copied from that layer is refused 400 with a one-step remedy. In PR Acceptance notes."
    ]
    }

  6. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 23119,
    "status": "done",
    "round": "patch round 1 (contract review 6138876344, FAIL on 6bbc547)",
    "branch": "claude/issue-23119-save-door-lock-envelope",
    "pr": "#23167",
    "head": "010639549d199247daa802e778966a86f29f6c62",
    "session": "session_01ADzJtzYTLUfgrRZHxkagkX (subagent: the parent PM session id)",
    "premise_still_valid": true,
    "summary": "BLOCKING item closed: .changeset/23119-save-door-lock-envelope.md gains ONE supersession paragraph in the 23096/15213 house form (bc55905), placed after the envelope paragraph and before FROM; .changeset/23096-* and .changeset/15213-* untouched. Optional item taken (0106395): on an item that serves no lock, a family whose _lock is absent or 'none' (a lone prose key, or _lock 'none') is now described as 'They would add lock keys to it' instead of 'set a lock on it'; a binding _lock on an unlocked item keeps 'set a lock on it'. One code line (the effect ternary in authoredLockFamilyRefusal) plus its pin line in the add test; envelope, status, code and issues[] unchanged. No merge of origin/main: git merge-tree origin/main HEAD exit 0, no generated file in the diff.",
    "supersession_sentence_as_written": "This entry supersedes one sentence of the entry 23096-save-door-protection, in this same release. That entry says, under its unchanged behaviour, "A body without protection saves as before". What holds now: a body without protection saves as before only when it also sends no _lock* key, or sends back exactly the _lock* family a read of that item serves; a body whose _lock* family would set, change or remove the item's lock answers 400 VALIDATION_ERROR and writes nothing, as this entry states. Where the two entries disagree, this one is current.",
    "tests": "At 0106395. protocol.save-door-lock-family.test.ts: 1 file / 25 tests passed (the add pin now asserts 'add lock keys to it' for { _lock: 'none' } and { _lockReason } and 'set a lock on it' for full / no-overlay / no-delete). pnpm --filter @objectstack/metadata-protocol run typecheck exit 0. Closure restored from the turbo cache (12 of 12 cached) before the run.",
    "gates": "Re-derived on the real diff at 0106395 (dispatch-gates --commands --repo objectstack-ai/objectstack: 75 families, same set as round 0). Run sequentially, exit captured before any pipe, all 19 exit 0: check-adr-0087-registration --base origin/main ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition'; 23119 entry read as BREAKING+bang+clause-②-narrowing, not-required (no-migration-prescription)) and --self-test, check-changeset-no-major --base origin/main and --self-test, check-empty-changeset --base origin/main and --self-test, check-issue-citations (node and pnpm), check:changeset-gate-self-tests, check:cross-package-test-inputs, check:dispatcher-error-vocabulary, check:doc-authoring, check:durability-log-level, check:engine-double-contract, check:error-status-conformance, check:nul-bytes, check:objectui-changeset, check:pm-changeset-deadline-census, check:test-source-alias. dispatch-gates --ran: 75 derived, 19 run, 0 NOT-MEASURED, 56 UNRUN. The 56 unrun were all exit 0 in round 0 (battery at 2b4ea68 plus the 8 ledger-derived gates at 6bbc547, except the two PREREQUISITE NOT MET ones named then); the delta since is one changeset paragraph, one string ternary in protocol.ts and one pin line, and CI runs every family on the new head.",
    "pr_body_replacements": [
    "In '## Changeset', replace the sentence 'No sentence of a pending 15213-* or 23096-* entry becomes false, so the changeset supersedes none.' with: 'The changeset supersedes one sentence of the pending 23096-save-door-protection entry, its "A body without protection saves as before": that now holds only for a body that also sends no _lock* key or sends back exactly the _lock* family a read of that item serves. No sentence of a pending 15213-* entry becomes false.'",
    "In '## Acceptance notes', first bullet, replace 'LOCK_FAMILY_KEYS (derived from MetadataProtectionFields, so no third hand-kept list)' with 'LOCK_FAMILY_KEYS (derived from MetadataProtectionFields, so this file adds no hand-kept list beside the one item-lock.ts already keeps)'.",
    "Optional, in '## What changes', after the bullet that begins 'The refusal names every _lock* key sent': append the sentence 'On an item that serves no lock, a family that binds nothing (a lone prose key, or _lock: none) is described as adding lock keys rather than setting a lock.'"
    ],
    "files_changed_this_round": [
    ".changeset/23119-save-door-lock-envelope.md (+2)",
    "packages/metadata-protocol/src/protocol.ts (1 line)",
    "packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts (+2/-1)"
    ],
    "line_budget": "7 files in the PR, +717/-13 against merge-base f931e76 (730 changed lines, under the 3000 cap); this round +5/-2.",
    "deviations": [
    "The optional wording change touched code (one line) and its pin, as the patch brief allowed; no other code or test change.",
    "Resource discipline: os-verify-lock queue-timeout again (exit 99 after 540 s; holder pid 27099, issue-23056 run-suites.sh, held 2258 s). The closure was restored from the turbo cache (12 of 12 cached, no compile) and the one-file pin run and the typecheck ran outside the lock with --maxWorkers=2, as a declared narrowing.",
    "Gates: the focused 19 of the 75 re-derived families were run this round (the brief asked for at least the changeset trio, doc-authoring and the --ran reconciliation); 56 left UNRUN and named above."
    ],
    "mcp_calls": "0",
    "api_writes": "1 REST write this round: this os-dev-report comment via scripts/pm/post-stamped.mjs (relay) → POST /repos//issues/23119/comments. Plus git push of bc55905 and 0106395 (not REST). Reads: GET issues/comments/6138876344.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: domain:engine seat 2 (named by the review's flag 6 for the owning seat's list) · noted, not filed · restoreVersion and rollbackMetaItem reach put without the author arm, so a historical body written before this change can bring its author-written _lock* family back; existing state, no new door."
    ]
    }

  7. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR #23167 at 010639549d · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-13T05:57Z

    Basis: triage 6135754247 binds the direction. Also the seat's unlock note 6136862148, the claim 6136889453, report 6138569909, and patch round 1's report 6139133868.

    Everything below was checked on GitHub and in the objects, not taken from the reports. Classes, positions and functions only.

    • Form:
      • draft to main, assignee os-steve;
      • line 1 Fixes #23119, the only closing keyword;
      • line 2 Clause-②: no (narrowing), the arm clause2-line.mjs gives a breaking non-widening.
      • The seat corrected two PR-body passages after round 1, as the role file assigns: the changeset's supersession statement and the hand-kept-list count.
    • Scope:
    • Fix, read in the diff:
      • saveMetaItem's author arm calls authoredLockFamily beside protectionBlockRefusal. A body with no _lock* key keeps the target row's stored family; this also closes the fail-open the dev measured, where such a save dropped a server-stamped no-delete.
      • A family equal to one a read serves is accepted and ignored. That is the item's resolved lock, or the family on the row the save writes.
      • Anything else answers a located 400 VALIDATION_ERROR, naming ADR-0010's loader door.
      • The server arm (source, package-duplicate) and stripUnmanagedImportLock are untouched, and no stored lock is unbound.
      • item-lock.ts: the docblock sentence is amended per the triage; prose only.
    • Evidence:
      • the card's three readings reproduced at the protocol door;
      • 25 new cases;
      • ablation: with the verbatim store back, 18 of 25 go red, and the echo and CONTROL pins stay green;
      • the full metadata-protocol suite, the four untouched lock pins, and 53 objectql files;
      • 98 derived gate commands in round 0 and 19 re-derived in round 1, all exit 0. Two were not measurable locally for lack of built dists; CI runs them.
    • CI on 010639549d: ci-failure --pr 23167 reads GREEN, 40 check runs, none failed.
    • Contract review:
      • FAIL 6138876344 on 6bbc5475cc: the pending 23096-save-door-protection entry's "A body without protection saves as before" turned false, unsuperseded.
      • Then delta PASS 6139270859 on 010639549d: one supersession paragraph in the house form, true at this head; one effect-wording line and its pin; envelope and issues[] unchanged.
    • Findings:
      • Acceptance notes: rows and pending drafts that already store an author-written lock keep it, and publishing such a draft promotes it. That is the triage's own line: this card unbinds nothing, and a reported stuck item is the ADR-0010 §3.8 card.
      • Acceptance notes: a body copied from the layered read's raw code layer with _lock: 'none' is refused loudly, with a one-step remedy.
      • dropped: rollbackMetaItem reaching put through repo.restoreVersion without the arm. It is existing state, not a new door: only a history row written before this change can carry an author lock, and a reported stuck item is the same §3.8 card.
    • Landing: ready and auto-merge through the queue in this act.
  8. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23167 → aa5bfdfed8 · domain:engine seat 2 · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-13T06:32Z

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

Metadata

Metadata

Assignees

Labels

area:studioChanging a running app without code — authoring, publish, docs and the portalbugSomething isn't workingdomain:enginepriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions