Repository navigation
fix(metadata-protocol)!: refuse a flow saved into a package this deployment has not installed (#20863) - #20959
Conversation
…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>
…phan-package-binding-refused
…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>
…phan-package-binding-refused
…he ledger already records Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: Card #20863 ( Check-runs on the head, read at 2026-09-30T23:15Z (32): 17
① Derived judgmentsEach judged on the net diff.
② Semver level
③ Boundary flagsDev flags:
Open questions (5921171005: A and A):
Declared surface breach — accepted, and right: Fixtures re-judged — no assertion weakened or removed; each still tests what it names:
Review faces:
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: VERDICT: PASS Generated by Claude Code |
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 answered200, stored the flow live, and served the binding back: a flow bound to a package that does not exist. It now answers422 WRITABLE_PACKAGE_REQUIRED, and nothing is written, served or registered.tenantAuthoredWriteRefusal(packages/metadata-protocol/src/protocol.ts), the one shared function every flow write door asks, which 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 established. There is no second check. The rule gains a named-base arm between the locked-base lock and the provenance check.saveMetaItemalready handed the rule the base it names, so its code is unchanged; only its comment at the hand-off says so.resolveWritePackageScope, the registry's package read that the runtime authoring gate uses for its package closure. It is not the loader's managed set alone: a tenant's own writable base, created through the package door and rehydrated from the package store at boot, is installed and ships no flow. No new registry read and no second list of packages.flowonly, as 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 requires. Every other type keeps its old handling. The/automationcreate, update and clone doors name no base, so they do not move. The two server-stated rewrites (stored-metadata migration and package duplication) are not judged by the rule, as before.Hypotheses from the dispatch, measured
origin/main31c39964fcagainst the unmodified rule, with an environment id and without. A definition with no provenance returnsnullat the "body not code-shipped" branch. A definition whose provenance names the same missing id returnsnullat 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, stateactive.resolveWritePackageScope(registrygetPackage), reused inside the rule. The qualification is that itsundefinedcovers 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'sSchemaRegistryhas the read. This is written down in the rule's docblock.WRITABLE_PACKAGE_REQUIRED/ 422. Ledger row:packages/spec/src/api/error-code-ledger.zod.ts:634, in the@objectstack/metadata-protocolblock (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.createStandaloneStack, environment idenv_local). One-shot boot, not kept as a file, through the kernel's protocol service: both definition shapes answeredWRITABLE_PACKAGE_REQUIRED/422, 0 metadata rows, registry item absent. The two controls savedactive: no base named, and a base installed throughinstallPackage. 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.registerItemare never reached, for published and drafted saves, on both topologies. Door level: 0sys_metadatarows under the name, the metadata read answers404, and the automation read answers404(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.packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts: oneit, 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.protocol-publish-drafts-advisories,protocol-publish-drafts-closureandprotocol.publish-item-rebind-announcenow 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
e201d770b2unless noted.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 ate201d770b2: 19/19, andtypecheckexit 0.eb25706394, before the 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 merge.f051bba0e2:flow-shipped-name-stored-row-boot, the clone door and the durable doors): 32/32./metafiles: 312/312.typecheckexit 0 for objectql, including its test-layer check, and for dogfood.e5914e3f2c, throughscripts/ablation-replace.mjs, whose restore is armed on exit:@objectstack/metadata-protocoland@objectstack/rest(rest bundles a copy).ablation-dist-preflightfound the marker in both dists.200with stateactive.git diff HEADis empty, andgit status --porcelainis empty. After rebuilding both,--absentpassed for both dists. The files read 19/19 and 14/14.Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 68 commands ate201d770b2from a tree that was not stale. All 68 were run, and every one exited 0.--ranreconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsanswered PREREQUISITE NOT MET (exit 3), because 8 packages outside this closure had nodist/. They were built, and it exited 0.check:engine-double-contractrefused anupdatedouble 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, dispositionnot-required (no-migration-prescription).pnpm lint: a proven narrowing, not a full run..tsfiles are underpackages/**, which thepackages/**/*.{ts,tsx,mts,cts}and**/*.{ts,…}blocks ofeslint.config.mjslint.eslint --no-inline-config --format jsonover those files: 8 files, 0 errors, 0 warnings, ate201d770b2.eslint.config.mjslines 326-328), so a verdict on an untouched file cannot move.Changeset
.changeset/20863-orphan-package-binding-refused.md:@objectstack/metadata-protocolminor, 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
packages/objectql/src/publish-package-drafts-response-conformance.test.ts, +4 lines, in a separate commit (eb25706394) that can be dropped on its own.mainwas merged twice (no rebase). The second merge brought 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), which editsprotocol.tsnear this rule. Git merged it cleanly, and both changes are present.Acceptance notes (noted, not filed)
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.installPackagestates 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 builtdist/carries its own copy of this package's protocol code: the new sentence appears there, and rest lists@objectstack/metadata-protocolas a devDependency. Observed while scoping the ablation's rebuild. Not investigated further. Carrier: none.SchemaRegistrydoes not declare (0 hits inregistry.ts), behind optional chaining, so those two calls do nothing. This is a reading, not measured. Carrier: none.protocol.tsADR 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
Generated by Claude Code