Skip to content

fix(metadata-protocol): ADR-0070 D1 delegates its refusal to the repository's create-side emitter, so the hatch clause reaches saveMetaItem (#8361) - #8630

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8361-d1-create-side-hatch-clause
Aug 14, 2026
Merged

fix(metadata-protocol): ADR-0070 D1 delegates its refusal to the repository's create-side emitter, so the hatch clause reaches saveMetaItem (#8361)#8630
os-zhuang merged 1 commit into
mainfrom
claude/issue-8361-d1-create-side-hatch-clause

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8361

The defect, reproduced on the merged ref

OS_METADATA_WRITABLE unlocks a metadata type, never a package's writability. #8146 wrote that sentence into both package-door emitters in SysMetadataRepository. The override one is reachable, and #8184 made it reachable on scoped kernels too. The create one — readOnlyBaseCreateError, 422 WRITABLE_PACKAGE_REQUIRED — was reachable from nowhere an author writes from: saveMetaItem's ADR-0070 D1 gate refuses on a strictly wider predicate (no "did the caller name a base" limb, no registry limbs above it), so it threw first on every kernel, with its own sentence, which carried no hatch clause.

Probed on ffb090e6f7 with OS_METADATA_WRITABLE=permission set and a runtime-only create aimed at a read-only package:

hatch OPEN >> [writable_package_required] Cannot save permission/probe_reviewer: the package
'com.example.showcase' is read-only (provided by code or an installed app).
Switch to a writable package in the package selector, or create a new one, and retry.
hatch SHUT >> ...byte-identical...

The operator is told the base is read-only (true) and never told that the variable they set a moment ago cannot make it writable.

The architecture fork — what I measured, not what is tidier

The card declined to pick between teach D1 the same hatchOpen selection and let D1 delegate to the repository emitter, naming the lane's one-emitter precedent as the default and D1's fires-before-the-repository constraint as the reason it might not apply. I took the delegation reading. Both reasons the default might not apply were measured here, and neither holds:

the named reasonmeasured on ffb090e6f7verdict
"D1 fires before the repository exists in the call"True of the instance (getOverlayRepo is below D1) and irrelevant to the emitter: it is static, SysMetadataRepository is imported at protocol.ts:23, and the override-side site inside this very method has been calling a static sibling since #8184does not hold
"D1 carries its own docs pointer"Both sites set the identical string docs/adr/0070-package-first-authoring.md, because both implement D1. (This is precisely where the create side differs from the override side, whose repository emitter points at ADR-0010 — there the fork was real)does not hold

The one difference that was real: D1's sentence names the item (type/name) and the emitter's named only the type. I closed that by widening the emitter with an optional trailing name rather than forking the sentence — the same class of widening #8184 made when it added hatchOpen and dropped private. Omitted, the emitter's output is byte-identical to what #8146 shipped, which is what the direct put callers still see (pinned).

So the fork resolved by measurement, not by taste — and the reading that survived is the lane's own default. Had the docs pointers diverged, or had the emitter needed an instance, this would have gone back as needs_decision.

Secondary deliverable — where the create-side clause actually was live

Verified by enumeration on the merged ref rather than restated. assertAllowed reaches readOnlyBaseCreateError only from put, with intent: 'runtime-only'and a non-empty packageId (assertDeleteAllowed passes no base, so the delete side can never reach it). Production put callers carrying both facts:

  • saveMetaItemshadowed: D1's predicate is strictly wider, so every write that would reach the door was already thrown
  • SysMetadataRepository.promoteDraft — from publishMetaDraft
  • SysMetadataRepository.restoreVersion — from revertCommit
  • SysMetadataRepository.restoreVersion — from restoreMetaVersion

The last three pass the row's own binding, never a caller-named base, and none passes through D1 — so the clause was exercised only by republish/repair traffic, exactly as the card said. LayeredRepository is not a fourth route: nothing in the repo composes a SysMetadataRepository into one outside a TSDoc example. Recorded in the emitter's TSDoc, in the new test block's docblock, and pinned by the direct put callers keep their sentence verbatim.

What changed

D1 now calls the repository's emitter instead of spelling a second sentence for one condition. hatchOpen is computed at D1 — unlike the override site, which passes a literal false because reaching it proves the hatch is shut. Both directions are live at D1: permission (allowRuntimeCreate: true) arrives with the hatch shut, and job (neither channel) arrives only because the hatch opened isOverlayAllowed. It is read through this class's own memoised reader — the one the admission limb used — so hatchOpen cannot disagree with the limb that let the write through.

Scope — no acceptance decision moves

D1's predicate is byte-for-byte unchanged; only the error object it throws changed. Same code, status, packageId, docs. Pinned in both directions: a create into a writable base and a package-less create still land; the read-only create is still refused. This card changes what the operator is told, not what is refused.

Verification

Ablation, both directions, run against the committed fix (restored byte-identically afterwards — git hash-object matches the committed blob):

  • (a) revert the fix, keep the pins — 4 red / 35 green, as predicted before running: the hatch-OPEN case, the anti-fork case, the job case and the two-kernel case go red. The hatch-SHUT case stays green, which is exactly why (a) alone cannot separate this deliverable from a vacuous pin.
  • (b) over-broad — force the clause unconditionally at D1 (hatchOpen passed as a literal true) — 2 red / 37 green: with the hatch CLOSED the same request does NOT mention it and the sentence is the repository's, not a copy of it. Every hatch-OPEN case stays green, so the OPEN cases alone would not have caught "reports the repository message everywhere".

The anti-fork pin compares saveMetaItem's message against the emitter's own output rather than a literal, so a re-spelling at D1 goes red even if it is word-perfect on the day it lands. No pin asserts a list length or delta; the row assertions are membership (toContainEqual) and emptiness.

Suites (dependency closure built first; metadata-protocol rebuilt before the downstream runs, per the dist-resolution trap):

  • @objectstack/metadata-protocol — 87 files, 1282 tests, all pass (39 in the touched file, 10 of them new)
  • @objectstack/objectqlprotocol-save-meta-repo-path.test.ts — 13 pass
  • @objectstack/runtimepackages-readonly-gate + package-duplicate-adopt-org-scope — 27 pass
  • @objectstack/dogfoodpackage-first-authoring.dogfood.test.ts — 5 pass, against a real booted stack
  • tsc --noEmit on the package: 63 errors, all pre-existing test-layer lib errors; none in the three edited files

Gates — the derived union (scripts/pm/dispatch-gates.mjs re-run against the real changed paths), all green: check:nul-bytes, check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:filter-alias-parity, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major, check-cross-package-test-inputs, check-empty-changeset.

Note for review

scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json was left alone. Anchors are one file per path and that file already carries another author's ADR-0029/ADR-0119 invariant; the D1 region names ADR-0070 inline and both sites carry the docs pointer, which is Prime Directive #13's core requirement. Extending someone else's anchor on this hot file seemed the wrong call to make unasked.


Generated by Claude Code

…sitory's create-side emitter (#8361)
`saveMetaItem`'s D1 gate spelled its own `WRITABLE_PACKAGE_REQUIRED` sentence,
which shadowed #8146's hatch-aware create-side emitter on every kernel: D1
refuses on a strictly wider predicate, so every write that would have reached
`SysMetadataRepository.readOnlyBaseCreateError` had already been thrown by D1 —
and D1's sentence carried no hatch clause. An operator with `OS_METADATA_WRITABLE`
set was never told the hatch does not reach package writability.
D1 now calls that emitter (the create-side mirror of #8184's override-side
delegation), computing `hatchOpen` from this class's own memoised reader — the
one the admission limb used. Both named reasons the one-emitter default might
not apply were measured and did not hold: the emitter is static (no repository
instance needed) and both sites already carried the identical ADR-0070 `docs`
pointer.
The emitter gained an optional trailing `name` so the delegated sentence keeps
naming the item; omitted, its output is byte-identical for the direct `put`
callers (promoteDraft / restoreVersion / revertCommit), which were the only
callers reaching the hatch clause at all.
D1's predicate is untouched — no create is newly refused or newly admitted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
@vercel

vercelBot commented Aug 14, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 14, 2026 4:15am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

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

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

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

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

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 14, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 14, 2026 04:43
@os-zhuang
os-zhuang added this pull request to the merge queueAug 14, 2026
Merged via the queue into main with commit 74f2f11Aug 14, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8361-d1-create-side-hatch-clause branch August 14, 2026 04:57
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude