Repository navigation
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
Conversation
…s a cold boot (#20862) Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…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>
…tomation-doors-persist
… path now; say where (#20862) Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 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 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
|
Contract reviewServed-tier: Inputs read: card #20862 (body; triage grade Check-runs on the head, collapsed to latest per name, all converged: 35 runs, 32 ① Derived judgments(a) One save path, same scope, same publish mode — right. At the head the domain holds exactly one 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 ( (b) Order and rollback — consistent; the "held definition back" reading is right. Engine first: Where engine and store can still disagree: (i) a (c) The refusals in front stay in front — right, on all three doors plus DELETE. Create: (d) The DELETE arm — required, the protocol's own path, no reach beyond (e) The changeset — accurate, matches the diff, claims nothing undelivered, carries the fix. ② Semver levelThe narrowing is real: both measured bodies were engine-accepted and store-refused before, so the doors answered
③ Boundary flagsDeviations:
Out-of-scope findings (all
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: VERDICT: PASS Generated by Claude Code |
…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>
…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>
Fixes #20862
Clause-②: no (narrowing)
What this does
POST /api/v1/automationandPUT /api/v1/automation/NAMEregistered 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/metalost to the stored definition. Both doors now save the flow as a tenant row through the metadata protocol's ownsaveMetaItem, 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.registerAndSaveFlowinpackages/runtime/src/domains/automation.tsregisters the definition in the engine, then saves the same definition throughsaveMetaItem({ 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, andsaveMetaItemis called, not changed.400 VALIDATION_FAILED), and nothing is saved.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'stenantAuthoredWriteRefusal(422 INVALID_METADATA) still answer before the engine or the store is touched.saveMetaItem's default mode, which ispublish, so the row isactive. 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 on1921e7e3e2: a flow created then deleted through the door still read200onGET /meta/flow/NAME, and200onGET /automation/NAMEafter a cold boot. SounregisterAndDeleteFlowremoves the flow from the engine, then deletes the row through the protocol's owndeleteMetaItem, env-wide.DELETE_RESTRICTED/409) is still raised before the store is touched.Repro, before this change
Measured with the booted pin
packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.tson base2d5fe76f43plus the pin commitd09cee8a9c, throughbootStackon the showcase with a database file:POST /automationanswered200.GET /meta/flow/NAMEanswered404 RESOURCE_NOT_FOUND. After a cold boot on the same file,GET /automation/NAMEanswered404.PUT /automation/NAMEon a flow stored through/metaanswered200./metastill servedStored through /meta. After the cold boot,GET /automation/NAMEservedStored through /meta, not the update.Declared narrowing
Clause-②: no (narrowing). The changeset shipsminorwith 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 answered200before and are refused now:FlowSchema.nameadmits it and the metadata item-name grammar does not. The door now answers400 INVALID_REQUEST.flow-default-edge-with-condition). The door now answers422 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 realSchemaRegistry, with the store's save and delete spied. It pins:packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts(new, 7 cases, cold boot on one database file):/meta-stored flow survives;400 INVALID_REQUESTandGETanswers404;422 INVALID_METADATAand the engine keeps the previous definition;403 NOT_OVERRIDABLE, onPUTand on a create onto it.packages/runtime/src/domains/automation-packaged-base-lock.test.ts: the harness gainsstandInStore(). Four success-path cases now reach the real save, and this harness's engine has no store (measured: 4 cases at500,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
43cf17d79bpnpm --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) andpnpm --filter @objectstack/dogfood typecheck: exit 0.flow-provenance-server-held,packaged-flow-write-door-parity,automation-flow-clone-doorandautomation-toggle-tenant-scope: 5 files, 38 tests, exit 0. The runtimedist/was rebuilt at this head first.node scripts/pm/dispatch-gates.mjs --commands: all 65 derived commands were run, each exit recorded, and--rananswered65 run, 0 NOT-MEASURED, 0 UNRUN.pnpm lint(eslint . --no-inline-config, whole tree): exit 0.pnpm check:durability-log-levelandpnpm check:adr-anchors, exit 0 (ona783880517).scripts/ablation-replace.mjsin wrap mode, against the committed implementation. The runtime pin imports source, so there is nodistleg. Each restore was proven: blob equal to HEAD2df430b334b6, andgit diff HEADempty.Acceptance notes (observations, not filed)
/metawith?package=, the update therefore lands as a package-less row beside the bound one. A barePUT /meta/flow/NAMEalready does the same today. Which row a later boot binds was NOT MEASURED. Taker: none.FlowSchema.namedisagree 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.DELETEremoves the live row only, as/meta's delete without a state does. A pending draft row stays. Taker: none.origin/mainmoved after the mergea783880517. The only gate-family input that changed isscripts/doc-authoring-prose-id.baseline.json, which this diff does not touch. CI measures the merge ref.Generated by Claude Code