Skip to content

fix(metadata-protocol)!: refuse a flow saved into a package this deployment has not installed (#20863) - #20959

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20863-orphan-package-binding-refused
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20863-orphan-package-binding-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20863
Clause-②: no (narrowing)

What this does

A flow saved through the metadata door (PUT /api/v1/meta/flow/:name) may name, as its base, the package it belongs to. When that id was a package this deployment has never installed, the door answered 200, stored the flow live, and served the binding back: a flow bound to a package that does not exist. It now answers 422 WRITABLE_PACKAGE_REQUIRED, and nothing is written, served or registered.

Hypotheses from the dispatch, measured

  • H1 held. Measured on origin/main 31c39964fc against the unmodified rule, with an environment id and without. A definition with no provenance returns null at the "body not code-shipped" branch. A definition whose provenance names the same missing id returns null at the "named base equals the stamp" branch. The ADR-0070 D1 gate further down then admits the save, because its writability predicate reads an unregistered id as a writable authoring workspace. At the door, the ablation's mutated leg below re-measured it on the showcase: 200, state active.
  • H2 held, with one qualification. The reader exists: resolveWritePackageScope (registry getPackage), reused inside the rule. The qualification is that its undefined covers both "the registry does not hold this id" and "the registry cannot be read" (a registry with no package read at all, or one that throws). So on such a registry a named base is refused: the fail-closed direction, the same one the automation engine takes with no loader's-set reader. Every real composition's SchemaRegistry has the read. This is written down in the rule's docblock.
  • H3: WRITABLE_PACKAGE_REQUIRED / 422. Ledger row: packages/spec/src/api/error-code-ledger.zod.ts:634, in the @objectstack/metadata-protocol block (line 590). No code is minted. ADR-0070 D1 decided this code for exactly this condition: a runtime create whose resolved base is missing or read-only. Its remedy is the one this caller needs: choose or create a writable base, or name none. INVALID_METADATA (line 610, same block) was rejected because the definition may be perfectly valid; what is wrong is the base the request names, not a key in the body. The sentence is new, because the D1 emitter's sentence says "read-only", which is false for a package that does not exist. The refusal carries the refused id and the ADR-0070 docs pointer, as that emitter's does.
  • H4 measured, one topology NOT MEASURED. The in-repo environment kernel is the standalone stack (createStandaloneStack, environment id env_local). One-shot boot, not kept as a file, through the kernel's protocol service: both definition shapes answered WRITABLE_PACKAGE_REQUIRED/422, 0 metadata rows, registry item absent. The two controls saved active: no base named, and a base installed through installPackage. The unit pins also run every case with an environment id. The cloud's per-environment kernel manager is not in this repository: NOT MEASURED.
  • H5 held and pinned. Unit level: the store's insert and the registry's registerItem are never reached, for published and drafted saves, on both topologies. Door level: 0 sys_metadata rows under the name, the metadata read answers 404, and the automation read answers 404 (the engine never armed it).

Pins

  • packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts, a new describe block with 7 cases: the refusal across 5 definition shapes and both topologies, plus the plural type spelling; nothing written or registered on published and drafted saves; three controls (no base or the sentinel passes, an installed base passes, a shipped flow is a locked base first whatever base is named); the hatch case; and another type left untouched. The rule's registry double now serves the registry's package read.
  • One door-level pin on the showcase host-config boot, in the existing packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts: one it, so no second boot. Triage's door-level controls are the cases already in that file: a customer flow with no base saves, one in the tenant's installed base saves, and a shipped flow is refused as a locked base.
  • Fixtures re-judged, because they saved flows into a package their registry never held.
    • protocol-publish-drafts-advisories, protocol-publish-drafts-closure and protocol.publish-item-rebind-announce now declare their base installed, with no namespace and no dependencies. The prefix pre-flight and the closure are unchanged, and no assertion moves.
    • protocol.package-closure-gate's "narrows nothing when the registry cannot produce the written package" pinned the very branch this change shuts for a flow save. It now reaches that state the way it still arises: a draft promoted after its package left the registry, beside the installed control, which reports.

Evidence

All runs are on HEAD e201d770b2 unless noted.

  • metadata-protocol, full suite at f051bba0e2: 194 files passed and 3 skipped; 2886 tests passed and 19 skipped; exit 0. typecheck: exit 0. The one file changed after that, protocol.tenant-authored-write.test.ts, re-ran at e201d770b2: 19/19, and typecheck exit 0.
  • Consumers, because the wire answer changed:
  • Red/green ablation of the new arm at e5914e3f2c, through scripts/ablation-replace.mjs, whose restore is armed on exit:
    • The mutation made the arm's condition unsatisfiable. On disk: marker count 1, guard text count 0. Both dists that carry the arm were rebuilt, @objectstack/metadata-protocol and @objectstack/rest (rest bundles a copy). ablation-dist-preflight found the marker in both dists.
    • Mutated leg: the unit file read 3 failed, 16 passed of 19 (the three refusal cases). The dogfood file read 1 failed, 13 passed of 14: the new pin, answering 200 with state active.
    • Restore: the blob equals HEAD, git diff HEAD is empty, and git status --porcelain is empty. After rebuilding both, --absent passed for both dists. The files read 19/19 and 14/14.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands derived 68 commands at e201d770b2 from a tree that was not stale. All 68 were run, and every one exited 0.
  • --ran reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
  • Two first readings were not measurements and were re-run:
    • check:dual-build-cjs-loads answered PREREQUISITE NOT MET (exit 3), because 8 packages outside this closure had no dist/. They were built, and it exited 0.
    • check:engine-double-contract refused an update double this PR had added to the rule's test. The double was dropped rather than growing the pinned ledger, and the gate exited 0.
  • check:adr-0087-registration: one declared-breaking changeset, disposition not-required (no-migration-prescription).
  • pnpm lint: a proven narrowing, not a full run.
    • (1) Population: all 8 changed .ts files are under packages/**, which the packages/**/*.{ts,tsx,mts,cts} and **/*.{ts,…} blocks of eslint.config.mjs lint.
    • (2) eslint --no-inline-config --format json over those files: 8 files, 0 errors, 0 warnings, at e201d770b2.
    • (3) Invariance: the config never enables type-aware linting (eslint.config.mjs lines 326-328), so a verdict on an untouched file cannot move.
  • CI: not awaited.

Changeset

.changeset/20863-orphan-package-binding-refused.md: @objectstack/metadata-protocol minor, BREAKING under the launch-window convention, following PR #20907's shape. It carries one ADR-0087 marker and a line telling the caller what to send instead: an installed base, or no base. The dogfood package is private. The objectql change is a test file, which ships nothing.

Declared deviations

Acceptance notes (noted, not filed)

  • For every type other than flow, the metadata door still stores a row bound to a package id no installed package holds. The ADR-0070 writability predicate reads an unregistered id as a writable authoring workspace. This is kept deliberately: the automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761 ruling's rule 5 leaves other types unchanged. Carrier: none.
  • A host with no package store loses a runtime-created base from the registry at restart. That is an existing degradation, and installPackage states it loudly. After this change, a flow save naming such a lost base is refused rather than stored bound to it. Not measured. Carrier: none.
  • @objectstack/rest's built dist/ carries its own copy of this package's protocol code: the new sentence appears there, and rest lists @objectstack/metadata-protocol as a devDependency. Observed while scoping the ablation's rebuild. Not investigated further. Carrier: none.
  • objectql's publish-conformance harness calls a registry method that SchemaRegistry does not declare (0 hits in registry.ts), behind optional chaining, so those two calls do nothing. This is a reading, not measured. Carrier: none.
  • The protocol.ts ADR anchor does not mention the new named-base arm. It was not added, because the anchor file is outside the claim's surface. The rule's docblock cites ADR-0070 D1 and ADR-0126 §2. Carrier: none.

NOT MEASURED

  • The cloud per-environment kernel manager: it is not in this repository. H4's in-repo environment kernel was measured, as above.
  • Studio's round trip: objectui is not in this container. The server-side refusal is what Studio receives.

Generated by Claude Code

…led package holds (red on main)

WIP: the unit pins measured first against the unmodified rule.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
… package holds

The one shared authoring rule for flows gains a named-base arm between the
locked-base lock and the stamp arm: a save that names, as its base, a package
this deployment has not installed is refused with the existing ADR-0070 D1
code instead of being stored bound to a package that does not exist. The
installed set is the one the metadata write path already resolves a base
against; no second list is read.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
… package no registry held

Three publish harnesses bound their flow drafts to a base their registry
double did not hold; the base is now declared installed (no namespace, no
dependencies, so the closure and the prefix check are unchanged). The
closure-gate case that pinned an unproducible package reaches that state
the way it still arises: a draft promoted after its package left the
registry, beside the installed control.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…ge that is not installed

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…window convention

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…afts are bound to

Outside the claim's declared file surface, and reported as such: the
harness staged flow drafts into a package its registry never held, which
the metadata door now refuses. Harness declaration only; no assertion moves.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…he ledger already records

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/api/index.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/automation/flows.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/data-modeling/drivers.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/data-modeling/objects.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/deployment/cli.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/deployment/environment-variables.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/kernel/contracts/metadata-service.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/permissions/permission-sets.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/plugins/packages.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))

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

  • content/docs/releases/implementation-status.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/releases/v17/17-3.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))
  • content/docs/releases/v17/17-5.mdx (via sys_metadata (literal, a string literal in tenantAuthoredWriteRefusal))

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

What this run could not see
  • the SDK route bridge reached 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 — 11 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 3693a1b50d5bc084494098b1bb9d9b14d984fb4b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 20ea10920ad6c20841a078bafb97194f5d1efafb — the merge of head e201d770b289a3d46c28457bc12bb6cfe3fbbdbc into base 3693a1b50d5bc084494098b1bb9d9b14d984fb4b, 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 20ea10920ad6c20841a078bafb97194f5d1efafb && git checkout 20ea10920ad6c20841a078bafb97194f5d1efafb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3693a1b50d5bc084494098b1bb9d9b14d984fb4b e201d770b289a3d46c28457bc12bb6cfe3fbbdbc && git checkout -B drift-repro 3693a1b50d5bc084494098b1bb9d9b14d984fb4b && git merge --no-ff e201d770b289a3d46c28457bc12bb6cfe3fbbdbc

node scripts/docs-audit/affected-docs.mjs --json 3693a1b50d5bc084494098b1bb9d9b14d984fb4b

⚠️ 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 3693a1b50d5bc084494098b1bb9d9b14d984fb4b → 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: e201d770b289a3d46c28457bc12bb6cfe3fbbdbc
Local-runs: none

Card #20863 (security; disclosure discipline inherited from #20761). Inputs read: the card body and its four comments (triage 5912776325, the claim 5919979573, the os-dev-report 5921152677, the seat's answer 5921171005); #20761's ruling 5904938166 (rules 2 and 5); PR #20853 (76bd58fac4) and PR #20907 (cb4c31dd52); PR #20959's body, file list and net diff against main (merge base 95fed33a20; 9 files, +266/−31); the head's check-runs. The PR head had not moved when read. ⛔ This record spells no request body, header, query parameter or field: the binding is "the base a save names".

Check-runs on the head, read at 2026-09-30T23:15Z (32): 17 success (Build Core, Dogfood Regression Gate 3/3, Dogfood Verify CLI, Check Changeset, Type Check · source gates, Type Check · debt ledger, Check PR Size, Governed Surface Queue Guard, Spec property liveness, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter, and the four claim/queue guards); 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke); 12 in_progress (Test Core 1–6 of 6, Dogfood Regression Gate 1/3 and 2/3, Temporal Conformance, Type Check · workspace, Type Check · consumer gates, Lint & Repo Gates). None concluded failure. Not awaited; the seat lands on green.

main drift: 5 commits on origin/main since the merge base, none touching a file of this PR; GitHub reports the PR mergeable.

① Derived judgments

Each judged on the net diff.

  1. One shared function, no second check, no second registry read — right. The whole product change is one arm inside tenantAuthoredWriteRefusal (packages/metadata-protocol/src/protocol.ts), 19 code lines; nothing else under src/ moves. The arm asks resolveWritePackageScope, the existing private reader over the registry's getPackage that the runtime gate: full runtime-safe rule snapshot for the publish door — the expensive half split out of the object-gating card #9612 closure gate already resolves a base against at the publish gate. No new accessor, no list of packages, no second predicate. saveMetaItem's code is byte-unchanged — its hand-off already carried the named base; only the comment moved (verified in the diff hunk).
  2. Flow-only (automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761 rule 5) — right. if (singular !== 'flow') return null still precedes the arm, and the type is folded at the producer so a plural spelling cannot address around it. Pinned by the unit control (a view naming the same missing base answers null) and by the file's pre-existing dogfood control on a dashboard.
  3. The locked base answers first — right. shipped is computed once; a non-null packagedBaseRefusal returns before the arm. When the lock admits (the operator hatch open for flow), the arm runs, and if (shipped) return null then reproduces main's exact behaviour of skipping the provenance check for a shipped name. The only behavioural delta for a shipped name is the new arm: the hatch unlocks a type, never a binding (pinned).
  4. A save naming no base, and one naming an installed or tenant-created writable base, still save — right. Unit controls: undefined, null, the stored-row sentinel; the tenant's base; and a read-only installed package (installed, so admitted by this rule — writability stays the ADR-0070 D1 gate's question further down, the right division). Door level: the pre-existing cases in the same dogfood file (a customer flow naming no base; one bound to a tenant base created through the package door, which installPackage registers in the registry and persists to sys_packages; the shipped flow refused as a locked base).
  5. The stored-row sentinel is admitted as package-less — right, and the arm's own exclusion is load-bearing. resolveWritePackageScope answers undefined for the sentinel too, so without the explicit exclusion a sentinel save would be refused; the diff carries it and the unit control pins it.
  6. Both topologies — right as code; measurement partial, as declared. The arm reads nothing that depends on environmentId; every unit case runs with and without one. Door level is the showcase host-config boot. The in-repo environment kernel (createStandaloneStack, env_local, composed over ObjectQLPlugin) is the dev's one-shot, not committed. The cloud per-environment kernel manager is NOT MEASURED — the card's own caveat, carried forward.
  7. The fail-closed branch — no in-repo composition reaches it with an installed base. resolveWritePackageScope answers undefined when the registry has no getPackage or the read throws. The only production construction of the protocol is plugin.ts over the ObjectQL engine, whose SchemaRegistry declares getPackage (a Map read; it does not throw); createStandaloneStack composes that plugin. So the branch is met by metadata-only doubles only, as the docblock says. Residual: the cloud kernel manager is out of tree (NOT MEASURED, item 6).
  8. What newly refuses — the narrowing, stated so the release note can be held to it. On the /meta flow save door, a save naming a base the registry does not hold — with no stamp, with a stamp naming that base, or with a stamp naming a real package; draft or publish; both topologies — moves from 200 to 422 WRITABLE_PACKAGE_REQUIRED, and nothing is inserted or registered (unit: insert and registerItem never called; door: zero rows, metadata read 404, automation read 404). Also newly refused, not called out by the dev: a re-save of an EXISTING row that names a missing base — the repository preserves an existing binding on update, so main answered 200 and kept the old binding silently. A bare re-save of a legacy orphan-bound row is still admitted, because rule 3 reads the stored row's binding, which agrees; legacy rows stay editable without naming a base.
  9. The class the arm shuts, named. package-writability.ts deliberately reads "a bare ADR-0048 authoring-workspace id with no registered manifest" as writable; that is the ADR-0070 D1 admission the card measured, and for flow this PR closes it. In this tree no producer of such a bare id exists: the package door and boot rehydration both register through installPackage, and ADR-0070 D3 records the cloud AI studio moving from an auto-created catch-all to a real installPackage. Studio's round trip is NOT MEASURED (objectui is not in tree). The changeset's remedy covers the case (create the base first through the package door, or save without naming one).
  10. Code and members — right. WRITABLE_PACKAGE_REQUIRED is registered to @objectstack/metadata-protocol in the ADR-0112 ledger (error-code-ledger.zod.ts line 634, block at 590; INVALID_METADATA at 610): no code minted. The error's members mirror SysMetadataRepository.readOnlyBaseCreateError (code, status, the refused id, the ADR-0070 docs pointer, whose file exists). The sentence carries no tracker number and says "not installed", which the D1 emitter's "read-only" sentence could not.
  11. No public export or type moves. tenantAuthoredWriteRefusal's signature is unchanged; packages/spec is untouched.

② Semver level

③ Boundary flags

Dev flags:

  • main merged twice; fix(service-automation, metadata-protocol): a shipped flow name arms the loader's body at both boot steps, and a stored row of that name is reported as shadowed (#20913) #20942 (75519e1c0a) edits protocol.ts near the rule. fix(service-automation, metadata-protocol): a shipped flow name arms the loader's body at both boot steps, and a stored row of that name is reported as shadowed (#20913) #20942 is an ancestor of the head, and the net diff against main shows only this PR's hunks in protocol.ts, so both changes are present and the merge added nothing else. Answered.
  • The one door-level pin is an it() in the existing dogfood file. One showcase boot; the new case reads the store through the objectql service the file already uses for the ledger, then the metadata and automation doors; its controls are the file's existing cases. Answered; the right call.
  • An engine double dropped (commit e201d770b2). The update spy and its one not.toHaveBeenCalled() were removed so the double stays within the verbs the engine-double ledger records. Not a weakening: the refusal is thrown before stripDerivedProvenance, well ahead of any repository write; insert and registerItem remain pinned; for a new name the write path is an insert anyway; and the dogfood pin holds zero rows. Answered.
  • Five out_of_scope_findings, carrier: none — each verified: (1) other types still store an orphan binding — true by the arm's flow guard and rule 5; a candidate card in this family, the seat's to file; (2) with no package store, a runtime-created base is lost at restart, and a save naming it now refuses instead of binding to a ghost — consistent with fail-closed; (3) rest bundles the protocol — verified from config (②); (4) the objectql harness's two optional-chained calls name a method registry.ts does not declare (0 hits on the head) — no-ops, test-only; (5) the protocol.ts ADR anchor lists ADR-0005/0029/0087/0119/0126 and no ADR-0070 while the new docblock cites ADR-0070 D1 — the existing D1 emitter site already lived there un-anchored, so this is no new class of omission; a one-line anchor follow-up is the seat's call. All carried as declared.

Open questions (5921171005: A and A):

  • [0] WRITABLE_PACKAGE_REQUIRED / 422 — A holds on the diff. ADR-0070 D1's recorded meaning (a runtime create whose resolved base is missing or read-only) and its remedy (choose or create a writable base, or name none) are this refusal's; INVALID_METADATA would misdescribe a valid definition. Ledger row verified (①.10).
  • [1] Fail closed on an unreadable registry — A holds on the diff. Reusing the reader unchanged keeps one read; admitting would need a presence probe beside it. No in-repo composition reaches the branch with an installed base (①.7).

Declared surface breach — accepted, and right: publish-package-drafts-response-conformance.test.ts +4 installs, on the real SchemaRegistry, the base its drafts were always bound to, with no namespace so the ADR-0028 prefix pre-flight is skipped exactly as before. No assertion moves. Its own commit (eb25706394).

Fixtures re-judged — no assertion weakened or removed; each still tests what it names:

  • protocol-publish-drafts-advisories, protocol-publish-drafts-closure, protocol.publish-item-rebind-announce: only the registry double's getPackage changes, from "nothing" to "the bound base, with a manifest declaring no namespace and no dependencies". The prefix pre-flight keys on manifest.namespace, so it stays skipped; the closure is that base alone; every expect is untouched.
  • protocol.package-closure-gate, "narrows nothing when the registry cannot produce the written package": the original assertion (no advisory when the package is unproducible) is retained, now reached through a draft saved into an installed base and promoted after the base is removed, and a discriminating control is added (the same promotion with the base installed reports). publishMetaItem never asks the rule — only saveMetaItem does — so the route is real.
  • Residual the re-judge makes visible, not this card's door: promoting a draft after its base has left the registry still yields a live flow row bound to a package that does not exist. The card and rule 5 scope the /meta flow SAVE; promotion is another door. carrier: none today — the seat should file it in this family rather than leave it in a test comment.

Review faces:

  • Changeset (ships as CHANGELOG) — every factual sentence judged true: the door and its old and new answers (①.8); the rule and its placement (①.1, ①.3); "the same registry read … no second list" (①.1); "both branches" (①.8); "on a single-kernel host and on an environment kernel alike" (true of the code; cloud NOT MEASURED, ①.6); the code and D1's meaning (①.10); every "Unchanged" item — no-base and installed-base saves (①.4), the lock first (①.3), other types (①.2), the /automation doors (verified on the head: registerAndSaveFlow calls saveMetaItem with type, name and item only, naming no base), and the two server-stated rewrites (the migrate-stored source and the package-duplicate write face are skipped by saveMetaItem's guard, pinned).
  • PR body — judged true on: the ledger lines (590/610/634); eslint.config.mjs 326–328 ("never enables type-aware linting"); rest's devDependency; the dogfood package private; 7 new unit cases (19 in the file); 14 dogfood cases; saveMetaItem's code unchanged; the D1 predicate reading an unregistered id as writable (package-writability.ts); the H2 qualification. Not verifiable here and not needed: the local run counts, the ablation and the 68-gate reconciliation — the head's check-runs are the gate verdicts.
  • Disclosure — inside the card's discipline. The changeset names the route PUT /api/v1/meta/flow/:name (the door's public name, as fix(runtime)!: the /automation create and update doors save the flow as a tenant row, so what they answer 200 for survives a restart (#20862) #20907's changeset names its routes) and says the refusal names the package id the save sent; neither is a body, header, query-parameter or field spelling, and "What to send instead" prescribes in words. The PR body spells nothing. The dogfood pin uses the door's existing query key and the stamp key in test source; both spellings were already in that file on main from PR fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #20853, so this PR adds no new spelling to the public tree. No sentence must change.

Not measured, carried: the cloud kernel manager; Studio's round trip (whether it echoes a legacy row's orphan binding as the named base on re-save — the remedy line covers it).

Implemented-by: claude/issue-20863-orphan-package-binding-refused
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

metadata: the /meta flow save accepts a write bound to a package id that no installed package has, and stores it active

2 participants