Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20862-automation-doors-persist
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20862-automation-doors-persist

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #20853 gave the clone door. This follows the triage direction on the card (5912757695) and the maintainer's rule 2 recorded on #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 access-security.packaged-flow-write-door-parity clauses 2 and 3 fail on main — detail withheld pending maintainer #20679 locked-base refusal on a packaged flow's name (403 NOT_OVERRIDABLE) and fix(automation): which flows are packaged is the loader's fact, and every flow written through an authoring door is tenant-authored (#20761) #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

…s a tenant row through the one persistence path the clone door uses (#20862)

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…row too, so a flow the create door saved does not come back at the next boot (#20862)

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
… in the store where the lock pin reaches it (#20862)

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
…lare the store-refusal narrowing (#20862)

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
… path now; say where (#20862)

Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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/runtime, touching 12 documentable anchor(s).

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

  • content/docs/automation/flows.mdx (via registerFlow (literal, a string literal in Required))
  • content/docs/concepts/metadata-lifecycle.mdx (via saveMetaItem (literal, a string literal in handleAutomationRequest; a string literal in registerAndSaveFlow))
  • content/docs/data-modeling/formulas.mdx (via registerFlow (literal, a string literal in Required))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (literal, a string literal in handleAutomationRequest; a string literal in registerAndSaveFlow))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (literal, a string literal in handleAutomationRequest; a string literal in registerAndSaveFlow))
  • content/docs/kernel/services-checklist.mdx (via deleteMetaItem (literal, a string literal in unregisterAndDeleteFlow), saveMetaItem (literal, a string literal in handleAutomationRequest; a string literal in registerAndSaveFlow))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (literal, a string literal in handleAutomationRequest; a string literal in registerAndSaveFlow))
  • content/docs/permissions/system-context.mdx (via handleAutomationRequest (symbol, a top-level function))

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

  • content/docs/releases/v17/17-0.mdx (via registerFlow (literal, a string literal in Required))
  • content/docs/releases/v17/17-4.mdx (via registerFlow (literal, a string literal in Required))
  • content/docs/releases/v17/17-5.mdx (via registerFlow (literal, a string literal in Required))

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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 26 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 da93a8b3e0bfa0ac5d4d68059b419435eb7c8371 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json da93a8b3e0bfa0ac5d4d68059b419435eb7c8371

⚠️ 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 da93a8b3e0bfa0ac5d4d68059b419435eb7c8371 → 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: 43cf17d79b21cbef2ff9d3631d8569013ba89039
Local-runs: none

Inputs read: card #20862 (body; triage grade 5912757695; the seat's claim 5913606714; the dev's os-dev-report 5916095165), the prior landings PR #20817 (#20679) and PR #20853 (#20761, the maintainer's ruling 5904938166 rule 2 and rule 3), PR #20907's body and file list (5 files, +674/-59, head repo = base repo, draft), the net diff against the merge-base ca5408c62 (the merge a783880517's second parent, so the diff is exactly the five files), and the head's check-runs. Source read at the head by sha: the automation domain, the metadata protocol's save and delete, the sys_metadata repository, the objectql registry, the automation engine and plugin, and the flow precedence resolver. Nothing was built, run, re-run or ablated here.

Check-runs on the head, collapsed to latest per name, all converged: 35 runs, 32 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), none of them required). The seven required contexts are all success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset is success. The file list touches no governed surface.

① Derived judgments

(a) One save path, same scope, same publish mode — right. At the head the domain holds exactly one saveMetaItem call site and exactly one deleteMetaItem call site (base: one save site, in the clone arm; no delete site). registerAndSaveFlow is module-private (no export), as are unregisterAndDeleteFlow, restoreFlowRegistration and the two helper types, so nothing widens the package surface. The save is saveMetaItem({ type: 'flow', name, item }) — no organization, no package, no mode — which is byte-for-byte the clone arm's call at the base: env-wide (the repository targets the package_id IS NULL row), the protocol's default mode is publish (protocol line 16605), so the row lands active, and the registry write-through registers it as a runtime row. saveMetaItem is consulted, not edited (no metadata-protocol file in the diff). The three doors (create, update, clone) all go through the helper, so they cannot drift.

Clone door behaviour: unchanged on every single-request path. At the base the clone arm resolved the protocol, registered, saved, and on a failed save unregistered the target; at the head the helper resolves the protocol first (same position), adds one read probe (getFlow on the target name, which the collision probe just proved absent, so held is null), registers, saves, and on a failed save takes the held-is-null branch of restoreFlowRegistration, which is unregisterFlow — the base's undo. The response is still the served clone plus the notice. The only measurable difference is one extra engine read on the target name.

(b) Order and rollback — consistent; the "held definition back" reading is right. Engine first: registerFlow runs inside the helper's try, a throw is answered through flowDefinitionRefusal as 400 VALIDATION_FAILED, and the store is never reached (runtime pin: saveMetaItem not called). Store second: a throw from saveMetaItem is relayed through errorFromThrown with the store's own code and status (measured on the showcase: 400 INVALID_REQUEST, 422 INVALID_METADATA), and the engine is restored: a name the engine did not hold is unregistered; a name it held gets held re-registered, where held is the engine's own in-process parsed copy (getFlow returns the raw FlowParsed, credentials intact — not the served projection), re-run through registerFlow's validators and trigger re-arm, the same idempotent path the boot pull and the reload re-sync take. Reading the claim's "a failed save withdraws the engine registration" as "withdraws THIS request's registration" is the only reading consistent with the card's own defect statement: unregistering an already-held name on a refused update would take a live, stored flow out of the engine until the next boot, i.e. a refusal that also loses a flow the store still holds. After a refused update the engine and the store both hold the previous definition. Pinned in both files (identity toBe(before) in the runtime pin; the previous label on /automation and on /meta after a gate-refused update in the booted pin) and by the dev's third ablation (restore disabled: 1 of 11 red).

Where engine and store can still disagree: (i) a saveMetaItem that throws after repo.put landed (audit, projector or event tail) leaves the row while the engine is put back, and (ii) the DELETE mirror, a deleteMetaItem that throws after the row was removed while the engine re-registers the flow — in both the caller gets an error, and the next boot binds the store. Both are the protocol's own post-persist tail and have the same shape the clone arm already carried at the base; the door does not open a new one. (iii) restoreFlowRegistration is not inside a try: a throw from re-registering held would leave this request's definition registered — unreachable in practice, since held passed these same validators in this process. On the DELETE undo the engine re-derives its state through registerFlow (status-disabled from the definition, trigger re-armed, the scheduled-work policy re-judged at arming), and the durable enable state lives in the ledger map that withdrawFlow does not clear, so a flow put back keeps its enable state. None of this is a defect of the order chosen; all of it is observation, not filed (no measured reach).

(c) The refusals in front stay in front — right, on all three doors plus DELETE. Create: refuseUnauthoredFlowWrite (the #20853 rule, which itself reuses #20679's packagedBaseRefusal) is asked before keepStoredFlowCredentials and before the helper. Update: the same, after the envelope check, as on /meta. Clone: the same, after the source and collision probes. DELETE: refusePackagedFlowBaseChange(…, 'delete') before unregisterAndDeleteFlow, so a locked base (403 NOT_OVERRIDABLE) is refused before the engine's own 409 DELETE_RESTRICTED and before any store call. Neither the engine nor the store is reached on a refusal — pinned (runtime: registerFlow and saveMetaItem never called for a packaged name and for a claimed package; dogfood control: 403 NOT_OVERRIDABLE on the update door and on a create onto the packaged name, definition byte-identical after). The store asks the same one rule again inside saveMetaItem — a second ask of one function, no second implementation. The no-protocol fallback (engine-only registration and removal) is right and matches the posture the two front refusals and the base clone arm documented: a composition with no metadata store has nothing to persist into and no /meta door to be at parity with; a slot that is wired and fails to resolve still fails loud through resolveServiceOrLoud (the lock pin's composition-edge case, unchanged). Pinned in both directions (create and delete).

(d) The DELETE arm — required, the protocol's own path, no reach beyond /meta's. Required: the dev measured on 1921e7e3e2 (create and update durable, DELETE unchanged) that a flow created then deleted through the door read 200 on /meta and 200 on /automation after the cold boot — with durable creates, an engine-only DELETE is the card's defect in the other direction (a 200 for a removal the platform does not keep), so the change makes it necessary; it stays inside the claim's file surface. Protocol's own path: deleteMetaItem({ type: 'flow', name }) — no organization, no state, no package — is the same call the REST /meta DELETE route sends without a state (that route adds only the org scope its helper derives, which for a type with no per-org channel is env-wide, plus optional parent-version, actor and storage-drop extras). Reach: it removes the ACTIVE overlay row of that name; a pending draft survives, exactly as /meta's delete without a state. A packaged flow's artifact definition is never a row deleteMetaItem can remove: for a packaged name the lock answers first; under the operator hatch the protocol's removal helper admits it, finds no overlay row, answers "already at artifact default" success and restores the artifact's registry view — so the door still unregisters the engine copy as it did before and touches no artifact, and the next boot binds the artifact again as before. A tenant row bound to a package is reachable — deleteMetaItem without a package resolves "any package" (the repository's documented fallback) — exactly as /meta DELETE without a state does today; nothing beyond it. Order and undo mirror the write path; pinned in both files and by ablation (delete call removed: 2 of 11 red).

(e) The changeset — accurate, matches the diff, claims nothing undelivered, carries the fix. '@objectstack/runtime': minor, a fix(runtime)!: title, Clause-②: no (narrowing), a **BREAKING** banner, and one ADR-0087 marker not-required (no-migration-prescription) — a category in the gate's closed vocabulary, and the right one: no stored row changes shape and nothing an author wrote is renamed or removed. Every prose bullet corresponds to a code path read above: env-wide live save through saveMetaItem; engine first with 400 VALIDATION_FAILED; a store refusal relayed with its own code and status, a create withdrawn, an update put back; DELETE through deleteMetaItem with 409 DELETE_RESTRICTED still first, a name with no row removed as before, a refused delete put back and relayed; the two front refusals unchanged; no protocol, engine-only. The stated narrowing (two bodies 200 → 400 INVALID_REQUEST / 422 INVALID_METADATA) is the measured pair, labelled as measured, under a generic sentence that declares the whole class ("refuse a definition the metadata store refuses"). The migration line gives the one-line fix per case: rename to a name starting with a letter; fix the definition as the gate's finding says.

② Semver level

The narrowing is real: both measured bodies were engine-accepted and store-refused before, so the doors answered 200 for them; now the door answers the store's refusal. Complete list: the measured pair is not the exhaustive set of store refusals the doors can now relay — by source reading, on an environment kernel the protocol's per-item lock (403 ITEM_LOCKED) and the protocol's layered-envelope-body refusal (422 INVALID_METADATA) are also newly reachable through these doors, 403 NOT_CREATABLE is not (the flow type allows runtime creation), and 403 NOT_OVERRIDABLE was already in front. The changeset declares the class in its generic sentence and enumerates the measured members as measured, which is the right shape; the class is what the accept set moved by.

no (narrowing) + minor + BREAKING is right under the changeset rule: the value answers whether the accept set widens — nothing widens here (no new route, body, export or code) — and (narrowing) is the one arm the closed pair allows beside it; a narrowing is BREAKING and so owes the banner, the bang, at least minor under the launch-window convention (check-changeset-no-major), the migration line and one ADR-0087 disposition — all present, and both gates are green on the head. The claim's Clause-②: no was a pre-diff reading with the dev instructed to re-read against the measured diff; the re-declaration is the measured one.

③ Boundary flags

Deviations:

  • DELETE arm (beyond the claim's enumerated arms). Answered in ①(d): required by the change, the protocol's own path, same reach as /meta DELETE without a state, inside the claim's file surface, pinned in both new files and by ablation. Accepted.
  • Edit to automation-packaged-base-lock.test.ts outside the named pin files. Measured necessity (four success-path cases now reach the real saveMetaItem over a store-less harness and answered 500); +16/-0, harness standInStore() plus four call sites, no assertion changed; the file's own clone case already stood the store in identically at the base; the composition-edge controls (no protocol; a wired slot that fails to resolve does not fail open) are untouched. Accepted. Coverage note, not a defect: the hatch case now stands the store in, so an operator-hatch overlay write through /automation has no real-store pin at door level (the booted pin does not exercise the hatch).
  • Clause-② re-declaration. Right — ②.
  • Failed-update reading. Right — ①(b).
  • One merge of main (a783880517). Its second parent is the merge-base, the net diff is the five files, CI measured the merge ref on this head; origin/main moving 11 commits since (one gate-input baseline this diff does not touch) changes no judgment here. Accepted.

Out-of-scope findings (all carrier: none, noted, not filed):

  1. Update through the automation door beside a /meta row bound to a package. Source reading at the head confirms the first half: the repository keys rows by (org, type, name, package), a package-less save targets the package_id IS NULL row, so the update lands as a second row beside the bound one. The second half is not resolvable by source reading either: at boot both rows hydrate into the registry under the bare name (the hydration registers without a package id), so the later-hydrated row overwrites the earlier one in store-read order and the precedence resolver receives only one contender — the winner is order-dependent, not the resolver's rank. Pre-existing on a bare /meta PUT (same put, same hydration); this PR gives the door parity with it, and an update that used to be lost outright after a boot is now at worst an order-dependent tie. Not filed is right under the filing gate: no measured reach. It is a source-read metadata-authoring trap on the /meta door first, and belongs with the flow-precedence work this seat holds; a card once measured, on that door.
  2. FlowSchema.name admits a leading underscore the item-name grammar refuses. This one has a measured reach: the booted pin measured the door refusing a name the spec admits (400 INVALID_REQUEST), so a flow shipped in code under such a name loads and registers while no runtime door can ever persist it — declared but not enforced, on packages/spec. The refusal is loud and prescriptive, so it is not a silent trap and the dev was right not to touch spec inside this file surface; but it is a spec-lane card (narrow the flow name grammar to the item-name grammar, the ADR-0049 enforce-or-remove route), not a PR note. Escalated to the seat to file.
  3. DELETE removes the active row only; a pending draft survives. Same contract as /meta DELETE without a state; the automation door has no draft concept (its save is live). Not filed is right: no measured reach beyond the /meta contract it mirrors.
  4. Putting back a held definition adds one in-memory version-history entry. No door serves it; withdrawFlow resets it. Not filed is right.

Reviewer's own observations, not filed: the post-persist throw windows in ①(b), the protocol's tail and not the door's; the hatch coverage note above.

Implemented-by: claude/issue-20862-automation-doors-persist
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 17:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit cb4c31d Sep 30, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20862-automation-doors-persist branch September 30, 2026 18:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ion run proved stale (objectstack-ai#21339)

Docs-only checklist revision from the 17.6.0 release-verification run
objectstack-ai#21330 (subject `617f25f8`, Console pin `31971ff1e28f`). Every change
follows the checklist README's lifecycle rule: the item's `revision`
bumps and one `history` entry says what changed, why, and cites the run.
No product code, no `content/docs/**`, no generated file.

## Stale clauses the run proved (each a FAIL in objectstack-ai#21330 with disposition
stale-clause / assertion-defect)

| item | rev | evidence | changed by |
|---|---|---|---|
| `access-security.audit-log-browser` | 2 → 3 | admin `GET
/data/sys_audit_log?filter={"action":"delete"}` → 0 rows; the row is
stored with correct attribution | `30c530e5` (objectstack-ai#21194): the ledger serves
a non-system reader, admins included, only rows about records it can
read |
| `api-backend.filter-comparand-conformance` | 2 → 3 | POST `/query` →
400 `VALIDATION_FAILED` at `query.where.f_number.$eq`; GET `$filter` and
engine → 400 `INVALID_FILTER`; no door returns rows | objectstack-ai#20116 (`cfc3bcf1`
objectstack-ai#20247, `dd1b8031` objectstack-ai#20325) — the split query-contract-matrix rev 3
already records |
| `api-backend.date-range-preset-matrix` | 1 → 2 | equality
`{"signed_on":"today"}` → 400 `INVALID_FILTER` (temporal door);
`$gte:"this_week"` → 400 with `bareDateRangePresetComparandMessage` | by
design: `18.filter-preset-ordering-comparand-refused.ts` judges ordering
positions only |
| `records-forms.import-transform-matrix` | 1 → 2 | 400
`UNSUPPORTED_TRANSFORM` names the missing sandbox, 0 rows — but no
`framework#2611` | `f115b1f` (objectstack-ai#21188): refusals state decisions in
words, not tracker numbers |
| `studio-authoring.view-authoring-live` | 1 → 2 | `GET
/meta/view?object=repair_asset` serves `repair_asset.default` /
`repair_asset.form` with the authored config; container name 0 hits | by
design: `expandViewContainer` (objectstack-ai#7163, objectstack-ai#7736, objectstack-ai#13407) |

## Expected-fail notes 17.6.0 has made pass (clauses held in objectstack-ai#21330;
only their framing was stale)

| item | rev | measured | fixed by |
|---|---|---|---|
| `automation.packaged-flow-subflow-disable-refusal` | 1 → 2 | caller
off → child's disable retry 200, ledger `active=false`; caller-first
enable 409 `RESOURCE_CONFLICT` | `36d043b` objectstack-ai#20724, `0d9349f` objectstack-ai#20759; the
enable guard is `679f95e` objectstack-ai#20711 (step 6 now enables the child first) |
| `automation.packaged-flow-clone-contract` | 1 → 2 | clone survives a
cold restart and fires; still unreachable from Studio | durability
`cb4c31d` objectstack-ai#20907; reachability now filed as objectstack-ai#21332 (clause unchanged,
still expected to fail) |
| `access-security.packaged-flow-write-door-parity` | 1 → 2 | `PUT` /
`DELETE /automation/showcase_urgent_task_alert` → 403 `NOT_OVERRIDABLE`,
flow unchanged | `4b45afae` (objectstack-ai#20817); knownGap names the existing pin
`packaged-flow-write-door-parity.dogfood.test.ts` |

No clause was weakened: each still refuses the original failure mode
(rows returned, a served delete row, a 200-with-zero-rows), and the
clone clause keeps its expected fail.

## Validation

- `node scripts/check-platform-checklist.mjs` → `OK — 15 areas, 269
items (265 active, 2 planned)`; symbol anchors and line-citation sweep
green.
- `api-backend.json` is re-serialized in its existing canonical 2-space
form; the other four files are edited in place in their existing mixed
formatting.

Not in this PR (listed on objectstack-ai#21330's close-out instead): the other
checklist-accuracy findings the run collected, and the two `planned`
picklist items, which can only be promoted by a run in which they pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6

Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

2 participants