Repository navigation
fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) - #20853
Conversation
…ule for every flow write door Claude-Session: https://claude.ai/code/session_016SuKjJJSq2iLTm4Z2PbAmY Co-authored-by: Claude <noreply@anthropic.com>
…over the showcase Claude-Session: https://claude.ai/code/session_016SuKjJJSq2iLTm4Z2PbAmY Co-authored-by: Claude <noreply@anthropic.com>
…hat pin guard logic attach a stand-in Claude-Session: https://claude.ai/code/session_016SuKjJJSq2iLTm4Z2PbAmY Co-authored-by: Claude <noreply@anthropic.com>
…set read Claude-Session: https://claude.ai/code/session_016SuKjJJSq2iLTm4Z2PbAmY Co-authored-by: Claude <noreply@anthropic.com>
… clone's save Claude-Session: https://claude.ai/code/session_016SuKjJJSq2iLTm4Z2PbAmY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SuKjJJSq2iLTm4Z2PbAmY Co-authored-by: Claude <noreply@anthropic.com>
…ovenance-server-held
📓 Docs Drift CheckThis PR changes 3 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 32 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 6b89073081646e2345bc4e052d1a27f258626d97 && git checkout 6b89073081646e2345bc4e052d1a27f258626d97
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 05a7547c9f40e0dfab89a34f5e5b60f818af70d2 f0de8fe693acb6e58c037f191581c051b1a9f19e && git checkout -B drift-repro 05a7547c9f40e0dfab89a34f5e5b60f818af70d2 && git merge --no-ff f0de8fe693acb6e58c037f191581c051b1a9f19e
node scripts/docs-audit/affected-docs.mjs --json 05a7547c9f40e0dfab89a34f5e5b60f818af70d2
|
Contract reviewServed-tier: This is the record of record for PR #20853 at Inputs:
Disclosure is kept at the card's level: doors, roles, codes and statuses. The body's package-provenance stamps are named only abstractly here. ① Derived judgments(a) Rule 1 — the engine reads the loader's set: RIGHT, with two named residuals.
(b) Rule 2 — one shared function, and the code: RIGHT.
(c) The agreement edge, judged adversarially: display-only. No reader is fooled.
(d) Rule 3 — the clone: RIGHT and pinned. (e) Fail-closed engine: RIGHT. With no reader attached nothing is packaged, the guards protect nothing and the toggle door refuses every flow (pinned). Every real composition has the protocol: (f) The reversal of #20679's pin: CONSISTENT with ruling point 2. The pin asserted that a body claiming a package on the customer's own flow is accepted 200; the ruling (rendered before that pin's record) says such a body is refused loudly with an existing code, and its point 4 replaced triage's earlier round-trip pin. The assertion now reads 422 (g) Scope (rule 5): RIGHT. The rule answers null for every non-flow type, so (h) Prose, PR body and titles: ACCURATE and within the disclosure discipline. The changeset names exactly the delivered members ( Surface inventory:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…ers by the loader's set (objectstack-ai#20880) Fixes objectstack-ai#20864 Clause-②: yes (widening) Boot-time flow precedence now takes which same-named contender is the packaged one from the loader's set, the reader the engine has held since PR objectstack-ai#20853, instead of from the flow bodies' package-provenance stamps. This finishes rule 1 of the ruling recorded on objectstack-ai#20761 (`5904938166`) for the last reader it names, precedence. `packages/services/service-automation` is a **declared cross-lane touch** (`domain:services`) from the `domain:cli` seat, as it was on objectstack-ai#20761. Base `72f8c3820` (claim), head `6a028afb2b`. `main` was not merged in. ## What changed - **`flow-precedence.ts`.** `resolveFlowPrecedence(items, logger?, packagedFlowOwner?)` and `describeFlowContender(item, packagedFlowOwner?)` now take the reader as an optional last argument. Its type is `PackagedFlowSource`, the one `AutomationEngine.setPackagedFlowSource` already takes. - A contender is packaged only when the set holds its name. - Inside a name the set holds, the loader's entries are told apart from a same-named tenant row by the registry's own per-entry artifact test (`isCodeArtifactBody`). The set's own lookup (`SchemaRegistry.getArtifactItem`) applies that same test to each entry. - With no reader, nothing is packaged. This is the engine's fail-closed answer. - The set is asked once for each contested name. A name with one contender asks nothing. - **Tie-break.** The package-id tie-break now applies within the packaged rank only, as the function's docblock always said. Two tenant-ranked contenders keep their arrival order, so a body's own id cannot win it the armed slot. - **`plugin.ts`.** The one call site passes the engine's own `packagedFlowOwner`, which `packagedFlowReader` has fed since `init()`. Precedence and every other classification the engine makes now read one source. There is no second set. - **Changeset.** `@objectstack/service-automation` gets a `minor` bump, and the changeset carries the one-line migration for direct callers. ## Clause-② reading `yes (widening)`: two barrel-exported functions gain an optional parameter. The claim declared `no` on the condition that no public member is added. A new accepted argument widens the public signature, so this line is re-declared, as the claim instructs. No accept set shrinks. A body's claim of package provenance was never a declared input to precedence. The stamps are server-derived read decorations, which is the same negative boundary the PR objectstack-ai#20853 record judged for this rule. ## Premise, measured before the change (at `72f8c3820`) These runs used a scratch vitest file (not committed) and a real `SchemaRegistry` from `@objectstack/objectql`. - **M1.** A body stamped with a package's provenance, for a name no set holds, was classified `package`. - **M2.** On pure input, a forged package id beside the loader's entry took the armed slot by lexicographic order. - **M3.** A real registry was filled with the loader's shapes and the hydration's shapes (the tenant marker, and the artifact graft). Every code-shaped listed entry had its name in the set: the invariant held for all 4 code-shaped entries among the 6 listed. So, as the PR objectstack-ai#20853 record read, this is not reachable from an authoring door at boot. The change is defence in depth and completes the ruling. The same file after the change (at `6a028afb2b`): - M1 classifies the stamped body as `runtime`. - M3 arms the same flows with the same receipts as before, for the shapes measured both times: - the grafted overlay's name arms the loader entry, by arrival order; - `alpha` wins over `beta`. - A plain overlay beside a loader entry still wins. That is the objectstack-ai#11997 suite's case, and its expectations are unchanged. ## Tests (at `6a028afb2b`) - **`pnpm --filter @objectstack/service-automation test`:** 159 files, 1995 tests passed. - **`pnpm --filter @objectstack/service-automation typecheck`:** `tsc` is clean. `check:test-typecheck` is OK: the test layer compiles, with 0 debt. - **New `flow-precedence-loader-set.test.ts` (11 pins):** - A stamped contender ranks tenant-authored when the set does not hold its name, and the same contender ranks packaged when the set holds it. - A stored tenant row stays tenant-authored inside a held name. - The same two bodies rank by the set, not by their bytes. - The shadowing record and the pull warning name the armed body in both arrival orders. - Tenant-ranked contenders keep arrival order. - With no reader, the classification fails closed. A reader that answers an empty owner fails closed too. - Each contested name is asked about once. - The real `AutomationServicePlugin` boots three times: a held name (answered by a protocol stand-in's `packagedArtifactOwner`), an unheld name, and no protocol service at all. - **`flow-name-shadowing.test.ts` (objectstack-ai#11997):** precedence now gets the registry's artifact-view owner. The owner is attached to the engine and read back through `packagedFlowOwner`, exactly as the pull does. The expectations are unchanged. - **Dogfood regression, `flow-provenance-server-held.dogfood.test.ts` (PR objectstack-ai#20853's suite):** 13 of 13 passed against a rebuilt `dist/`. The dist marker count is 1 for each of the two changed spots. ## Ablations The fix was committed first. Each mutation ran through `scripts/ablation-replace.mjs`. For every one, the anchor went from 1 hit to 0, the blob changed, the restored blob equals `HEAD`, and `git diff HEAD` is empty. The subject resolves by relative import from `src/`, so `dist/` is not on the path. - **A1, the classifier ignores the set (the stamps decide again):** 6 of the 11 pins go red, 4 pure and 2 plugin boots. - **A2, the boot pull stops passing the reader:** 1 pin goes red, the held-name plugin boot. - **A3, the id tie-break orders tenant-ranked contenders again:** 1 pin goes red, the arrival-order pin. All three went red in the predicted direction. ## Gates (at `6a028afb2b`) - **`dispatch-gates --commands`,** run with no paths: the change set was derived from merge base `72f8c3820` and covers 5 paths. That gave 61 commands. Every one ran, and every one exited 0. - `--ran` with exit codes: 61 derived, 61 run, 0 NOT MEASURED, 0 unrun. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET, because 8 packages were not built in this worktree. After they were built (all turbo cache hits), it exited 0. - **Beyond that list:** `check:startup-registry-verdict` and `check:durability-log-level` are green. - **Lint, as a proven narrowing rather than `pnpm lint`:** 1. **The population comes from eslint's own config.** `--print-config` resolves the lint config for each of the 4 changed TypeScript files (6 rules for the source files, 5 for the tests), and none is ignored. The changeset falls outside the config's TypeScript/JavaScript `files`. 2. **The count comes from `--format json`:** 4 files, 0 errors, 0 warnings. 3. **Untouched files cannot change verdict.** No file's resolved config sets `parserOptions.project` or `projectService`, so type-aware linting is off. The custom rules read only the linted AST, plus a config-level baseline that this diff does not touch. ## Acceptance notes 1. **Per-name limit.** The reader answers per name. Inside a held name, a code-shaped entry that is not the loader's (a forged package id with no tenant marker) still ranks packaged under its own id. It can win the lexicographic order (M2, after the change). It is not reachable at boot: every code-shaped registry entry is either the loader's or carries the artifact's own grafted envelope. Closing it needs a per-entry answer from the metadata protocol, which is outside this card's surface. Carrier: none. 2. **Grafted stored rows.** A stored row of a held name hydrates with the artifact's envelope grafted onto it, so it ranks packaged beside the loader entry. Kernel phase order (the loader comes first) then arms the loader body, and the receipt lists that package twice. This PR does not change that: M3 was identical before and after. ADR-0126 §7.1 leaves the shadow diagnostics to objectstack-ai#11997. 3. **Stale docblock.** `FlowContender`'s docblock in `engine.ts` still describes `package` as the per-body artifact test alone. That file is outside this card's surface. Carrier: the next PR that touches that type. 4. **No-reader compositions.** In a composition with no reader, two packages that ship one bare flow name now keep arrival order instead of package-id order, because nothing is packaged there. Every real composition attaches the reader. Session: `session_01VvcEokUG1tvVxkceYfR5XB`, branch `claude/issue-20864-precedence-loader-set`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…as a tenant row, so what they answer 200 for survives a restart (objectstack-ai#20862) (objectstack-ai#20907) Fixes objectstack-ai#20862 Clause-②: no (narrowing) ## What this does `POST /api/v1/automation` and `PUT /api/v1/automation/NAME` registered a flow in the automation engine and wrote no metadata row. The next boot binds flows from the stored metadata, so a created flow was gone after a restart, and an update to a flow stored through `/meta` lost to the stored definition. Both doors now save the flow as a tenant row through the metadata protocol's own `saveMetaItem`, env-wide, which is the path PR objectstack-ai#20853 gave the clone door. This follows the triage direction on the card (`5912757695`) and the maintainer's rule 2 recorded on objectstack-ai#20761 (`5904938166`): every flow written through an authoring door goes through one shared function. - **One path for three doors.** `registerAndSaveFlow` in `packages/runtime/src/domains/automation.ts` registers the definition in the engine, then saves the same definition through `saveMetaItem({ type: 'flow', name, item })`. It passes no organization, no package and no mode, so the row is live (`active`), like the engine registration. The create door, the update door and the clone door all call it. There is no second persistence path, and `saveMetaItem` is called, not changed. - **Engine first, store second.** The engine's registration is still the first check. Its refusal is answered as before (`400 VALIDATION_FAILED`), and nothing is saved. - **A failed save leaves no registration of its own.** The store's refusal is relayed with its own code and status. A new name is withdrawn from the engine. A name the engine already held gets back the definition it held, so a refused update does not take the flow down. - **The refusals in front stay in front.** The objectstack-ai#20679 locked-base refusal on a packaged flow's name (`403 NOT_OVERRIDABLE`) and objectstack-ai#20853's `tenantAuthoredWriteRefusal` (`422 INVALID_METADATA`) still answer before the engine or the store is touched. - **No store, no save.** A composition with no metadata protocol keeps the engine-only registration it always had, as the clone door already did. The runtime pin states this case. - **Draft or live:** the save uses `saveMetaItem`'s default mode, which is `publish`, so the row is `active`. That matches the clone door. A draft row would leave the engine running a flow the next boot does not bind. ### The removal door keeps pace (beyond the card's two arms, declared) Once the create door saves, `DELETE /automation/NAME`, which only unregistered the flow in the engine, would leave the row behind, and the next boot would bind the flow again. This was measured with the create and update half alone on `1921e7e3e2`: a flow created then deleted through the door still read `200` on `GET /meta/flow/NAME`, and `200` on `GET /automation/NAME` after a cold boot. So `unregisterAndDeleteFlow` removes the flow from the engine, then deletes the row through the protocol's own `deleteMetaItem`, env-wide. - The engine's removal refusal (`DELETE_RESTRICTED` / `409`) is still raised before the store is touched. - A name with no row is removed as before. - A delete the store refuses puts the definition back in the engine and relays the refusal. ## Repro, before this change Measured with the booted pin `packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts` on base `2d5fe76f43` plus the pin commit `d09cee8a9c`, through `bootStack` on the showcase with a database file: - `POST /automation` answered `200`. `GET /meta/flow/NAME` answered `404 RESOURCE_NOT_FOUND`. After a cold boot on the same file, `GET /automation/NAME` answered `404`. - `PUT /automation/NAME` on a flow stored through `/meta` answered `200`. `/meta` still served `Stored through /meta`. After the cold boot, `GET /automation/NAME` served `Stored through /meta`, not the update. ## Declared narrowing `Clause-②: no (narrowing)`. The changeset ships `minor` with a BREAKING banner and an ADR-0087 disposition (`not-required (no-migration-prescription)`). Nothing widens. The doors now refuse what the metadata store refuses. Two bodies measured on the showcase answered `200` before and are refused now: - **A flow name with a leading underscore.** `FlowSchema.name` admits it and the metadata item-name grammar does not. The door now answers `400 INVALID_REQUEST`. - **A definition the runtime publish gate refuses.** The measured case is a default edge that carries a condition (`flow-default-edge-with-condition`). The door now answers `422 INVALID_METADATA`. Neither flow could ever be stored, so before this change it ran only until the next restart. The claim's reading was `no`. The measured diff moves the doors' accept set for these bodies, so the declaration names the arm. ## Pins - `packages/runtime/src/domains/automation-authoring-doors-durable.test.ts` (new, 11 cases). It uses a real protocol over a real `SchemaRegistry`, with the store's save and delete spied. It pins: - the save arguments, and that the engine is called first; - a failed create is withdrawn; - a failed update puts back the definition the engine held; - an engine refusal saves nothing; - the locked-base and provenance refusals reach neither the engine nor the store; - the removal door's delete, its undo, and the §7.3 refusal coming first; - a composition with no protocol. - `packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts` (new, 7 cases, cold boot on one database file): - a created flow survives; - an update to a `/meta`-stored flow survives; - a create the store refuses answers `400 INVALID_REQUEST` and `GET` answers `404`; - an update the store refuses answers `422 INVALID_METADATA` and the engine keeps the previous definition; - a flow created then deleted stays deleted; - control: a packaged name is still refused `403 NOT_OVERRIDABLE`, on `PUT` and on a create onto it. - `packages/runtime/src/domains/automation-packaged-base-lock.test.ts`: the harness gains `standInStore()`. Four success-path cases now reach the real save, and this harness's engine has no store (measured: 4 cases at `500`, `this.engine.find is not a function`). They stand the store in, the way the file's own clone case already did. No assertion changed. ## Verification, at head `43cf17d79b` - `pnpm --filter @objectstack/runtime test` (local project): 296 files, 4245 passed, 1 skipped, `VERDICT command-exit 0`. - `pnpm --filter @objectstack/runtime typecheck` (tsc plus the test layer) and `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - Dogfood: the new pin plus `flow-provenance-server-held`, `packaged-flow-write-door-parity`, `automation-flow-clone-door` and `automation-toggle-tenant-scope`: 5 files, 38 tests, exit 0. The runtime `dist/` was rebuilt at this head first. - `node scripts/pm/dispatch-gates.mjs --commands`: all 65 derived commands were run, each exit recorded, and `--ran` answered `65 run, 0 NOT-MEASURED, 0 UNRUN`. - `pnpm lint` (`eslint . --no-inline-config`, whole tree): exit 0. - Also run: `pnpm check:durability-log-level` and `pnpm check:adr-anchors`, exit 0 (on `a783880517`). - **Reverse verification.** These were one-shot ablations through `scripts/ablation-replace.mjs` in wrap mode, against the committed implementation. The runtime pin imports source, so there is no `dist` leg. Each restore was proven: blob equal to HEAD `2df430b334b6`, and `git diff HEAD` empty. - Save call removed: 4 of 11 red. - Delete call removed: 2 of 11 red. - Held-definition restore disabled: 1 of 11 red. - The booted pin's before-leg is the repro above: 5 of 7 red on the unfixed tree. ## Acceptance notes (observations, not filed) - **A tenant-package-bound row.** The update door saves env-wide with no package, as the triage direction says. On a flow stored through `/meta` with `?package=`, the update therefore lands as a package-less row beside the bound one. A bare `PUT /meta/flow/NAME` already does the same today. Which row a later boot binds was NOT MEASURED. Taker: none. - **Item-name grammar and `FlowSchema.name` disagree** on a leading underscore. The refusal is loud and prescriptive at every runtime door, and a code-shipped flow is loaded, not saved, so this is not a silent trap. Taker: none. - **Drafts.** `DELETE` removes the live row only, as `/meta`'s delete without a state does. A pending draft row stays. Taker: none. - **Version history.** Putting back a held definition re-registers it, which adds one entry to the engine's in-memory version history. No runtime door serves that history. Taker: none. - **Stale tree.** `origin/main` moved after the merge `a783880517`. The only gate-family input that changed is `scripts/doc-authoring-prose-id.baseline.json`, which this diff does not touch. CI measures the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…oyment has not installed (objectstack-ai#20863) (objectstack-ai#20959) Fixes objectstack-ai#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. - **One rule, extended in place.** The refusal lives in `tenantAuthoredWriteRefusal` (`packages/metadata-protocol/src/protocol.ts`), the one shared function every flow write door asks, which PR objectstack-ai#20853 established. There is no second check. The rule gains a named-base arm between the locked-base lock and the provenance check. `saveMetaItem` already handed the rule the base it names, so its code is unchanged; only its comment at the hand-off says so. - **"Installed" is the set the metadata write path already resolves a base against.** The arm asks `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. - **Whatever the definition carries.** Both branches that used to admit the save now refuse it: a definition with no provenance of its own, and one whose provenance names that same missing package. - **Order.** A shipped flow is still refused as a locked base first (objectstack-ai#20679's check, reused). With the operator's writable-types hatch open, the lock admits a shipped flow, and a missing base is still refused: the hatch unlocks a type, never a binding. - **Scope.** `flow` only, as the objectstack-ai#20761 ruling's rule 5 requires. Every other type keeps its old handling. The `/automation` create, 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. - **The stored-row sentinel is not a package.** A save naming it is a package-less save, admitted as before. ## 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: - objectql full suite: 349/349 files, 6812/6812 tests. - runtime full suite: 300/300 files, 5000 passed and 5 skipped. - rest full suite: 245/245 files, 4878 passed and 114 skipped. - These three ran at `eb25706394`, before the objectstack-ai#20942 merge. - At `f051bba0e2`: - dogfood, 4 flow files (this pin's file, objectstack-ai#20942's `flow-shipped-name-stored-row-boot`, the clone door and the durable doors): 32/32. - runtime, 6 automation and `/meta` files: 312/312. - objectql's publish-conformance file: 15/15. - `typecheck` exit 0 for objectql, including its test-layer check, and for dogfood. - **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 objectstack-ai#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 - **Outside the claim's file surface:** `packages/objectql/src/publish-package-drafts-response-conformance.test.ts`, +4 lines, in a separate commit (`eb25706394`) that can be dropped on its own. - Its harness staged flow drafts into a package its real registry never held, so any implementation of the ruling turns 5 of its cases red. It now installs that base, with no namespace. No assertion moves, and the file reads 15/15. - The claim did not name it. It is declared here, not taken silently. - The door-level pin went into the existing objectstack-ai#20761 dogfood file rather than a new file, to avoid a second showcase boot in CI. - `main` was merged twice (no rebase). The second merge brought objectstack-ai#20942 (`75519e1c0a`), which edits `protocol.ts` near this rule. Git merged it cleanly, and both changes are present. ## 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 objectstack-ai#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](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…flow for a shipped flow name with a stored row, as the layered read does (objectstack-ai#21002) (objectstack-ai#21116) Fixes objectstack-ai#21002 Clause-②: yes (widening) The published-snapshot read of a flow name a managed package ships now answers the package's flow when a stored row of that name is at rest. This holds on the REST route and on its runtime-dispatcher twin. This is the second half of objectstack-ai#21002, as triage ruled in `5924438659`: option A, scoped by the decision, not by type. The first half, the layered read, landed in PR objectstack-ai#21043.⚠️ This body follows the objectstack-ai#20761 family's disclosure discipline. It talks about doors, roles, codes and statuses only. It has no request body, header or field spelling, and no seeding steps. ## What changed **`packages/rest/src/rest-server.ts`**, the `GET /meta/:type/:name/published` handler: - It still reads the layered answer first. When that answer has a stored layer, the door now asks the protocol's `isShippedFlowName` about the answer's own type and name. - When the predicate holds, the layered read has put the loader's body over the stored row. The door then serves the effective layer, which is the loader's body. - In every other case it serves the stored layer, exactly as before. A protocol that brings no such predicate also keeps today's answer. **`packages/runtime/src/domains/meta.ts`**, the dispatcher twin of that route: - The same change, in the same place. - `MetaDomainProtocol` gains the predicate as an optional member. It is `Pick`ed from `ObjectStackProtocolImplementation`, not restated, so a rename at the producer is a compile error here. This is the same move `domains/automation.ts` makes for `packagedBaseRefusal`. **`packages/metadata-protocol/src/protocol.ts`**, the declared cross-lane surface the claim allows: - `isShippedFlowName` changes from `private` to public. Its body is unchanged. - A docblock paragraph names the doors that ask it. - `getMetaItemLayered`'s effective-layer decision (PR objectstack-ai#21043) is not touched. **`.changeset/21002-published-door-shipped-flow.md`**: `@objectstack/metadata-protocol` `minor`, `@objectstack/rest` `patch`, `@objectstack/runtime` `patch`. ## How the doors learn the decision There is one decision point, the predicate PR objectstack-ai#21043 already calls. - No per-type list, no new response key, no fourth precedence path, and no second copy of the rule. - Each door asks the protocol's own predicate about the type and name the layered answer reports. The layered answer's type is the canonical singular, so the predicate gets exactly the arguments `getMetaItemLayered` used. - `object` is never named. Its effective layer differs from its stored layer by folding and governance, not by this decision, so it is served byte-identically. The predicate was private to the protocol class. A door in another package could not reach it except by copying the rule (the flow-only scoping plus `packagedArtifactOwner`), which the ruling forbids. So making it public is the minimal reachability change. ## Clause ② reads `yes (widening)`, not the claim's `no` The claim's own reading said the dev re-reads the line against the real diff. The real diff adds one public member to a class `@objectstack/metadata-protocol` exports, so its published declaration grows. - The rule `check-changeset-no-major.mjs` quotes says a purely additive widening of a published package's public surface takes at least `minor`. - The two in-family precedents read it the same way. PR objectstack-ai#20817 made `packagedBaseRefusal` public, and PR objectstack-ai#20853 made `tenantAuthoredWriteRefusal` public. Both were declared `Clause-②: yes (widening)` with `@objectstack/metadata-protocol` at `minor`. - No wire shape moves. No request key, response key or accept set changes. `rest` and `runtime` stay `patch`: `MetaDomainProtocol` is not exported from the runtime package entry. If the seat rules this `no`, the revert is two lines: this body's second line, and the changeset's `minor` back to `patch` with its own Clause line. ## Reproduction Showcase composition on a database file, cold boot, signed-in admin. The stored rows were written on a first boot and read on the second. The base is `63d1a7c378`. | case | door | before | after | |:---|:---|:---|:---| | shipped flow name, stored row | REST | `200`, the stored body | `200`, the loader's body | | shipped flow name, stored row | dispatcher twin | `200`, the stored body | `200`, the loader's body | | flow name no package ships, stored row | REST and twin | `200`, the stored body | unchanged, same bytes | | `object`, published stored layer | REST and twin | `200`, the stored layer | unchanged, same bytes | "Same bytes" means the SHA-256 of the served document is equal before and after, on both doors. The dispatcher figures come from a throwaway probe that drove `HttpDispatcher` in-process over the booted kernel. The probe was deleted. ## Pins - **REST unit**, `packages/rest/src/meta-published-overlay.test.ts`: 5 new cases. They use the real protocol and the file's own engine double, with a registry that ships one flow from a package. - A shipped name with a stored row answers the loader's body, equal to the layered effective layer. - The plural type spelling reaches the same decision. - Controls: a flow name no package ships keeps its stored row. An `object`'s published stored row is served as stored, and its effective layer is shown to differ. A protocol without the predicate keeps the stored row. - **Runtime unit**, `packages/runtime/src/domains/meta-published-runtime-publish.test.ts`: the same 5 cases, through the real `HttpDispatcher`. - **Dogfood**, the new file `packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts`: 5 cases, showcase, cold boot. - A store check, plus the layered read putting the loader's body over the stored row. - The REST door answers the loader's body. - The REST door's body equals the layered effective layer. - Controls: a flow name no package ships keeps its stored body. An `object`'s published stored layer is served unchanged, and its effective layer is shown to differ. - No existing `flow-shipped-name-*.dogfood.test.ts` file is edited. Neither unit file gains an engine double, so `scripts/engine-double-contract.pinned.json` is untouched. **Why the dispatcher twin is not in the dogfood file.** The verify harness mounts no dispatcher `/meta` catch-all. That route is reached only on hosts that mount `@objectstack/hono`'s catch-all, so in this composition the twin is not served at all. Driving `HttpDispatcher` in-process from the dogfood package means importing runtime source. `check:test-source-alias` then refuses four new dist-resolved imports for the dogfood package (`metadata-protocol`, `observability`, `rest`, `service-datasource`). Its remedy is to alias them to source in the dogfood vitest config, which is outside this claim's file surface and would change every isolated dogfood test's resolution. So the twin is pinned at the unit level, with the real protocol and the real dispatcher. ## Ablation The fix was committed first. Both mutations went through `scripts/ablation-replace.mjs`, replacing the predicate clause with a constant false. Each leg is shown below. - **REST door** (`rest-server.ts`), at head `292cc60f45`: - The anchor went from 1 to 0 hits, and the blob from `a97cfde7c227` to `418bc95a799c`. - Unit (source-resolved): 2 failed (shipped name, plural spelling), 12 passed. - Rebuild of `@objectstack/rest`. Then `ablation-dist-preflight --absent` found the predicate call absent from all 6 built files. - Dogfood (dist-resolved): 2 failed (the REST door, and its equality with the effective layer), 3 passed (the store check and both controls). - Restore: blob equals HEAD `a97cfde7c227` and `git diff HEAD` is empty. After the rebuild, the preflight found the call present in 2 built files, and the tree was clean. - **Dispatcher twin** (`meta.ts`), at head `292cc60f45`: - The anchor went from 1 to 0 hits, and the blob from `0c4ddceecbb8` to `1e10bb639fc9`. - Unit (source-resolved): 2 failed (shipped name, plural spelling), 8 passed. - Restore: blob equals HEAD and `git diff HEAD` is empty. ## Verification All at head `292cc60f45` unless a line names another. - **Unit pins:** `meta-published-overlay.test.ts` 14 passed (9 existing, 5 new). `meta-published-runtime-publish.test.ts` 10 passed (5 existing, 5 new). Both at `92f242cee4`, and neither file has changed since. - **Whole packages:** - `@objectstack/rest`: 259 files, 5035 passed, 143 skipped, at `b973faeca2`. - `@objectstack/runtime` (`--project local`): 297 files, 4254 passed, 5 skipped, at `b973faeca2`. - The only later change in either package is the reworded docblock and one dropped fixture key in its unit file. Both files were re-run green after it. - `@objectstack/metadata-protocol`: 196 files passed and 3 skipped; 2930 tests passed and 19 skipped, at `92f242cee4`. - **Dogfood:** the new file, 5 passed. - **Typecheck:** `metadata-protocol`, `rest`, `runtime` (including `check:test-typecheck`) and `dogfood` all exit 0. `tsc --listFiles` counts each new or edited test file once in its own program. - **Gates:** `dispatch-gates --repo objectstack-ai/objectstack --commands` derived 68 families. All 68 were run, each exit code recorded before any pipe, and every one is 0. `--ran` reports 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN. - The first pass, at `92f242cee4`, had two non-zero exits. - `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: 8 packages outside the diff had no dist. They were built (41 of 41 turbo cache hits). - `check:test-source-alias` exited 1 on the dogfood dispatcher leg, which was then removed. The reason is under Pins. - **Lint**, a proven narrowing: - Population, from eslint's own config: 6 of the 7 touched paths are linted. The changeset is ignored, with no matching configuration. - Count, from `--format json` with the `pnpm lint` flags: 6 results, 0 errors, 0 warnings. - Invariance: there is no type-aware linting. Every `parserOptions` block is `ecmaVersion` and `sourceType` only. The config reads only two baseline JSON files, and this diff touches neither. So no untouched file's verdict can move. - **Not measured locally, declared to CI:** the Test Core shards, the full Dogfood Regression Gate, Temporal Conformance, Build Core, the type-check lanes, and the runtime `repo` test project. ## Acceptance notes - **Unchanged background fact**, per the review `5924388874`: in the showcase composition, a shipped flow with no stored row answers `501` `NOT_IMPLEMENTED` on the published door. That kernel has no code/package store. This PR does not change that path. - **Not merged with `origin/main`:** 9 commits landed since the base. None of them touches the 7 files here. PR CI tests the merge ref. - **Read, not edited:** `getMetaItemLayered` and the predicate's body. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20761
Clause-②: yes (widening)
What this does
Stage 2 of #20761, under the maintainer's ruling B + A recorded in
5904938166. The platform now trusts only a fact it holds itself about which flows are packaged. For flows, "packaged" means exactly "loaded by the loader from a managed package".@objectstack/metadata-protocol, beside the access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 locked-base verdict. It is asked by the automation create door, the update door, the clone door and the/metaflow write.packagedBaseRefusal, reused, not re-implemented. So a round trip of a shipped flow is refused.422 INVALID_METADATA, a code already registered to this package in the ADR-0112 ledger. No new code is minted. Nothing is written or registered.flow-clone.tsalready did that, and the envelope drop is pinned inautomation-flow-clone.test.ts. The copy is now also judged by the same rule and saved as a tenant row through the metadata protocol's own save, env-wide. It reads back on the metadata door and survives a cold boot. The engine registers the copy first and the store saves it second, and a save that fails withdraws the registration, so no clone is reported that would not survive./meta's handling of every other metadata type is unchanged: the rule answers nothing for them. The two server-stated rewrites of stored rows (stored-metadata migration, package duplication) are not authoring doors and keep their old handling.Declared cross-lane touch
The card is
domain:cli, and its entry is the automation doors inpackages/runtime. The claim (5909772878) declares two cross-lane surfaces, and this PR stays inside them:domain:engine:metadata-protocol(packages/metadata-protocol/src/**). The shared rule and the loader's-set read, both new public members, and/meta's flow write applying the rule.domain:services:service-automation(packages/services/service-automation/src/**). The engine's classification reads the loader's set, and the plugin wiring hands it over.Nothing under
packages/spec/**,packages/objectql/**orpackages/metadata-core/**is touched.The seam
packages/metadata-protocol/src/protocol.tsObjectStackProtocolImplementation.packagedArtifactOwnerpackages/metadata-protocol/src/protocol.tsObjectStackProtocolImplementation.tenantAuthoredWriteRefusal(the lock branch callspackagedBaseRefusal)/metaapplies itpackages/metadata-protocol/src/protocol.tssaveMetaItem, before the stamps are strippedpackages/runtime/src/domains/automation.tsrefuseUnauthoredFlowWrite(POST /,PUT /:name, clone)packages/runtime/src/domains/automation.tsprotocol.saveMetaItempackages/services/service-automation/src/engine.tssetPackagedFlowSource,packagedFlowOwner(every former body-stamp site)packages/services/service-automation/src/plugin.tspackagedFlowReaderPins (ruling point 4)
422 INVALID_METADATA), nothing registered or writtenautomation-tenant-authored-write.test.ts; dogfood403 NOT_OVERRIDABLE) on both doorsprotocol.tenant-authored-write.test.ts; dogfoodpackaged-flow-source.test.ts; dogfood/meta's flow write applies the same rule/metaon another type keeps its old handlingAt least one pin runs through the real HTTP composition:
packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.tsboots the showcase throughbootStackwith a database file, and cold-boots it once.Ablation, run once and not kept. The five fix sources were reverted to the merge base on the working tree, and the three packages were rebuilt. Each marker was proved absent from
dist/withscripts/ablation-dist-preflight.mjs --absent. The dogfood file then read 8 failed / 5 passed of 13. The five that stay green both ways are the preservation pins: the two locked-base refusals (#20679's), the two/metaround trips, and the other-type control. After the restore,git status --porcelainwas empty, the markers were present indist/again, and the file read 13 / 13. A first attempt left@objectstack/service-automation's build red: its barrel still exported the new type. That meant@objectstack/runtime'sdist/was never rebuilt, so the first reading was void. It was redone with the barrel reverted too.Round trips (the triage direction
5903721942)objectui, which is not in this container. What Studio receives was measured on the showcase boot: the served document on both read doors./metais served with none either.os meta registersends a file's contents verbatim toPUT /meta/:type/:name(packages/cli/src/commands/meta/register.ts:59-76), andos meta getprints the served document. So a get → register round trip is the/metaround trip above: a customer flow is accepted, a package-bound customer flow is accepted, and a shipped flow is refused as a locked base. The CLI adds no stamps of its own.Verification (at
f0de8fe69, after mergingorigin/main30839063b)pnpm --filter @objectstack/metadata-protocol exec vitest run: 192 files passed, 3 skipped; 2813 tests passed, 19 skipped.pnpm --filter @objectstack/service-automation exec vitest run: 158 / 158 files, 1984 / 1984 tests.pnpm --filter @objectstack/runtime exec vitest run: 298 / 298 files, 4961 passed, 1 skipped.flow-provenance-server-held,packaged-flow-write-door-parity,automation-flow-clone-door,automation-toggle-tenant-scopeandpackaged-activation-ledger-reachpassed, 42 / 42.typecheck:metadata-protocol,service-automation(withcheck:test-typecheck),runtimeanddogfood, all exit 0.node scripts/pm/dispatch-gates.mjs --commands: all 69 derived families were run, each with its exit code recorded.--ranreconciles 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.check-plugin-teardown-shape --self-testfirst exited 3 on the shallow clone. It exited 0 after deepening, as it prescribes.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET: eight unrelated packages had nodist/. It exited 0 after they were built.pnpm lint:packages/**, which thepackages/**/*.{ts,…}and**/*.{ts,…}blocks ofeslint.config.mjslint.eslint --no-inline-config --format jsonover the 23 changed files: 23 files, 0 errors, 0 warnings.eslint.config.mjs:327-328), so this diff cannot move a verdict on any untouched file.Acceptance notes
The automation create and update doors still persist nothing (not built here, as the claim allowed). Measured on the showcase boot with a database file:
200and reads404after a cold boot;/meta, answers200, and the stored definition wins after a cold boot.The clone's save does not cover these doors for free: making them durable changes both doors' contract. Named in the report; no carrier.
A
/metaflow save naming a package id no package has answers200on the showcase's host-config topology. Measured, out of scope. Named in the report.The loader's set is the registry's view, for both the lock and the engine. They read one source, so they cannot disagree. A dev-time artifact reload that adds a code flow is not re-registered into that view until restart (source reading, not measured). That is the same for the access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 lock.
The engine used to count a flow stored bound to a package as packaged at boot, because its served definition carries that binding as a package stamp (see "Round trips"). It now reads the loader's set, so that misclassification is gone too.
Generated by Claude Code