Repository navigation
fix(runtime,spec): the resume door asks the engine whether a status-less exit is repairable - #18792
Conversation
…ess exit is repairable The `400 FLOW_FAILED` details computed `repairable` as `status === 'stranded'`. The subflow delegation exit deliberately stamps no status, so the wire answered `repairable: false` for a run `restoreConsumedSuspension` re-arms as a chain. `IAutomationService` now declares the read-only `inspectConsumedSuspension(runId)` the engine already implements; the door consults it on the status-less arm and relays the verdict, fail-closed both when the member is absent and when the read rejects. The schema's own `.describe()` is rewritten to that truth and the two hand-written docs pages corrected with it. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
… repairable describe Adds the changeset for the spec contract widening and the runtime door change. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 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 15839e289a60c5edb7cfa314bcb2f42a47dc229e && git checkout 15839e289a60c5edb7cfa314bcb2f42a47dc229e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e5705a8ea528573e67eb0ea1ca269dff6c70d512 228275a473565ce3784ca84bea3f1ea80dae9a65 && git checkout -B drift-repro e5705a8ea528573e67eb0ea1ca269dff6c70d512 && git merge --no-ff 228275a473565ce3784ca84bea3f1ea80dae9a65
node scripts/docs-audit/affected-docs.mjs --json e5705a8ea528573e67eb0ea1ca269dff6c70d512
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
NOT measured: the full eslint population locally (CI Implemented-by: VERDICT: PASS Generated by Claude Code |
…ast 100 is UNJUDGED rather than a truncated claim pool (objectstack-ai#18799) Fixes objectstack-ai#18683 Clause-②: no ## The defect `scripts/pm/check-clause2-carriers.mjs` read a card's comment thread with ONE request — `/issues/{n}/comments?per_page=100`, no `page=` ladder, no short-read check — while the two sibling list reads in the same file paged to a declared cap and answered `null` (UNJUDGED, never clean) when they hit it. One file, two OPPOSITE defaults on "I did not read everything", and the fail-OPEN one was the read that arbitrates OWNERSHIP: the governing-claim pool, its membership, and the `Clause-②` declaration read out of it all come from those rows. A thread past 100 comments handed the pool its first page and nothing said the tail had been dropped, so a claim written past row 100 was not superseded — it was never a candidate — and a superseded carrier governed in its place. ## The before-reading, on a 101-row fixture Driven end to end through the real CLI against a stubbed board (`--pair`, no network), on `main` `d9ba33df4c` (script blob `d753e2a8cf06d8f72c436e0a1917b2fecb8be813`). Two fixtures, both 101 rows, both differing from a complete thread only past the page boundary. | fixture | BEFORE (`main`) | AFTER (this PR) | |---|---|---| | 100 claim-free rows, the 101st the only `Claim:` | `card-comments: 100 row(s)` · `claim.selected: none — no comment on this thread carries a line beginning \`Claim:\`` · **exit 4, row C2 `absent`** | `card-comments: 101 row(s)` · the 101st claim is the pool · `claim.clause2-line: DECLARED \`no\`` · **exit 0** | | row 1 an older `Claim:` declaring `yes`, the 101st a newer one declaring `no` | `claim.clause2-line: DECLARED \`yes\`` from the SUPERSEDED carrier, which is not even listed as rejected · **exit 4, row C3** | the newer claim governs, the older is listed REJECTED/SUPERSEDED · `DECLARED \`no\`` · **exit 0** | The second row is the fail-OPEN direction stated as a measurement: one thread, two readings, and they disagree on the declaration itself. ## The ladder, and the cap All three list reads now go through one `pagedListRead` helper — it pages to a declared cap, stops on the FIRST short page (no wasted request), and on the cap files the one shared `pageCapNote` sentence and answers `null`. `readCarrierEvents` (`EVENT_PAGE_CAP` 10) and `readPullFiles` (`FILE_PAGE_CAP` 3) keep their caps to the number; what they gain is that the third read can no longer hold a different default. `COMMENT_PAGE_CAP` is **10** pages = 1,000 comments. Sized on this board, read 2026-09-17 off the open-issue list rows (550 rows listed, cross-checked against `open_issues_count` = 550): - longest open thread of any kind: seat post objectstack-ai#6015 at **895** comments — nine pages; - next four: objectstack-ai#12708 at 365, objectstack-ai#6023 at 241, objectstack-ai#6017 at 206, objectstack-ai#6024 at 187; seat post objectstack-ai#7623 at 71; - longest thread carrying a queue label: objectstack-ai#13799 at **117** (`pm:queue`, p2, unassigned); - longest card in the clause-② population (28 pairs the sweep derived that day): objectstack-ai#17534 at **14**. So ten pages clears the whole board today with a page to spare, and it is the same ten `EVENT_PAGE_CAP` uses — a reader comparing two caps in one file should have to remember one number. ## The input record The diagnosis key stays `comments`, so every sentence already keyed to it still finds its diagnosis. Two declared fields are added to `INPUT_RECORD_PAIR_FIELDS`, one per thread this file reads: ``` pair.1.card-comments: 101 row(s) pair.1.card-comment-pages: 2 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE pair.1.pr-comment-pages: 1 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE ``` and, when the cap is what stopped the read: ``` pair.1.card-comment-pages: CAPPED — 10 of 10 page(s) of 100 comments each were requested and EVERY ONE came back full, so the tail is past the cap and the thread is UNREAD (UNJUDGED) — ⛔ never a truncated pool, ⛔ never a clean reading ``` A thread of exactly 100 rows and a thread whose tail was dropped are the same `100 row(s)` in every other line the block prints; they differ here, because the complete one stopped on a short page and the truncated one did not stop at all. The request ledger PR objectstack-ai#18681 added shows the same ladder from the other side — request objectstack-ai#3 is now `…/comments?per_page=100&page=1` and objectstack-ai#4 is `&page=2`. ## The pins A new `--self-test` battery, `objectstack-ai#18683: the card-comment read pages to a cap — past 100 is UNJUDGED, ⛔ never a truncated pool`, 27 cases, declared in `SELF_TEST_BATTERIES` with the roster floor raised 29 → 30. It drives the ladder with an offline page server that reproduces GitHub's own semantics and counts the requests; ⛔ no network. What it holds: the 101st claim ENTERS the pool and GOVERNS, and its line is what the limb reads; the same thread cut at 100 reads `absent` (the CONTROL — the reading the un-paged read produced); the newer claim past the boundary supersedes the older one inside it, and cut at 100 the superseded carrier's `yes` is what the limb reads; a capped read is `null`, which is neither `missing` nor `absent` nor a carrier but `unreadable`; the ladder stops on the first short page (2 requests for 101 rows, 1 for a short thread, 2 for exactly 100 — a full page is indistinguishable from a finished one); a page that came back unread ends the ladder and the record says the cap was NOT what stopped it; the input record declares and prints both ladder fields; the sibling caps are untouched; and all three reads render ONE cap sentence. ## The census, and the triage's upgrade probe Report-only, no state write. Over the 550 open rows (521 issues, 29 PRs) read on 2026-09-17: - open `pm:queue` / `pm:dispatched` cards: **274**, of which **1** exceeds 100 comments — objectstack-ai#13799 at 117; - all open issues over 100 comments: **8** — objectstack-ai#6015 (895), objectstack-ai#12708 (365), objectstack-ai#6023 (241), objectstack-ai#6017 (206), objectstack-ai#6024 (187), objectstack-ai#6021 (145), objectstack-ai#6367 (127), objectstack-ai#13799 (117). Seven are `pm:seat` posts; - open PRs over 100 comments: **0**; the longest is objectstack-ai#18638 at 10. **The upgrade probe's result: the condition is NOT met today.** The clause-② population is what a `--pair`/sweep derivation actually pairs, not what carries a queue label: the sweep derived **28 pairs from 29 open PRs**, and the longest card thread among them is **14** rows (objectstack-ai#17534). The two open PRs that mention a 100+-comment card in prose — objectstack-ai#18786 (objectstack-ai#6015, objectstack-ai#7623) and objectstack-ai#18765 (objectstack-ai#6024) — deliver objectstack-ai#18693 and objectstack-ai#18652 respectively, both under 10 comments; driven live before and after, both answer exit 0 with an identical pair reading. So no recorded `--pair` verdict on this board today was taken on a truncated pool, and the triage's p1 condition (「找到任一进入条款②认领池、评论数 > 100 的卡并驱动一次」) has no live instance to drive. The exposure is one PR away rather than realised: objectstack-ai#13799 is `pm:queue` at 117 and enters the population the moment a PR delivers it. The cost is unchanged by the ladder, measured on the same board: **64 reads for 28 pairs, before and after**, because every live thread fits one page and the ladder stops on a short page. The live `--pair 18765` input records differ in exactly three lines — the two request paths gaining `&page=1`, and the two new ladder fields.⚠️ One thing the two full sweeps do NOT compare: the sweep's finding COUNT moved 4 → 3 between them, and that is the board, not this diff. `needs:contract-review` was hung on PR objectstack-ai#18792 at `2026-09-17T21:04:46Z`, between the two runs, closing the C1 split on objectstack-ai#18792 / objectstack-ai#17541 on its own. The controlled A/B is the `--pair 18765` diff above. ## The ablation Two legs, each from the COMMITTED fix, each proving the mutation reached disk by blob hash and occurrence count before reading any result, each restored under a `trap` with `git checkout HEAD --` and verified by hash and an empty `git diff HEAD`. HEAD blob `ccd5ad7c9a00fe703d644be261a24f1ed847915a`. | leg | mutation | blob after | self-test | |---|---|---|---| | A — the ENTRY side | `COMMENT_PAGE_CAP` 10 → 1 | `1a0a952d07748101937420006b9475042d93a5cb` | **exit 1, 11 of 865 red** — the 101st-claim pins, the superseding pins, the request-count pins, the input-record pin | | B — the UNJUDGED side | the cap branch returns the pages that DID arrive (the pre-fix fail-OPEN default) | `c176dfe7e54e7de6bc45737487841a346f509b79` | **exit 1, 3 of 865 red** — a capped read is no longer `null`, no longer `unreadable`, and files no cap sentence | Every red in both legs belongs to the new battery; nothing pre-existing went red in either. A third, unplanned reading came for free: leg B's first attempt was a `perl -0pi` substitution whose anchor contained a `/`, so the edit silently did nothing — the on-disk proof refused it with `ABLATION VOID: the edit did not reach disk` instead of reporting a green as a measurement. ## Self-test ``` ✓ check-clause2-carriers self-test: 865 cases pass ``` 838 before, 865 after — the 27 the new battery registers, which is what its floor pins. ## Derived gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no hand-fed path list, re-derived after each `origin/main` merge (identical list both times). All 34 run at head `993cb89e18`, each exit code captured by redirect-then-`$?`: ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-changeset-no-major.mjs --self-test :: exit 0 node scripts/check-ci-filter-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-scripts-symbol-anchors.mjs :: exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:pm-clause2-carriers :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm lint :: exit 0 ``` `--ran` reconciles 34 derived / 34 run / 0 UNRUN. `pnpm check:pm-dispatch-gates` was run detached to a file — 1,788 cases, 748.6s on this box — and waited on in the foreground rather than under a timeout, so it is a measurement and not a SIGTERM. ## Out of scope, deliberately objectstack-ai#18764 (a decorated `**Claim:**` never enters the pool — the ENTRY side) was read and NOT folded in: this card is WHICH rows reach the reader, not what the reader does with them, and the two repairs touch different lines. `claimRetractions` (PR objectstack-ai#18770, the EXIT side) was read for the words it uses and not touched. The header's request-budget paragraph is amended in the same commit, because it stated "2 reads per card" as a fact and the thread is now a ladder — a cost statement that stopped being true is the shape this file exists against. `skip-changeset`: `scripts/pm/**` ships in no package's `files[]`, so nothing published moves. --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ Co-authored-by: Claude <noreply@anthropic.com>
…nifest (objectstack-ai#18803) Fixes objectstack-ai#18776 ## What was wrong `content/docs/api/client-sdk.mdx:337` — the `client.packages` fenced block on the SDK page an integrator reads before the README — showed: ```ts await client.packages.install({ id: 'com.objectstack.plugin-auth', version: '1.0.0' }); ``` Parsed against the contract that door itself declares — `PackageInstallRequestSchema`, whose `manifest` key is `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:244`) — the literal is refused twice: ``` invalid_value at [manifest, type] invalid_type at [manifest, name] ``` `type` and `name` are both required root manifest keys and both were absent, so the example fails when copied verbatim. Same kind as objectstack-ai#18607, one page along. ## The repair Two required keys added, nothing else, in the key order the package's own published README uses for the same door (`packages/client/README.md:247`, landed by objectstack-ai#18607) so the two copies of this example agree: ```ts await client.packages.install({ id: 'com.objectstack.plugin-auth', type: 'plugin', name: 'Auth Plugin', version: '1.0.0', }); ``` ⛔ No schema was touched. This card is an example that is wrong, not a contract that is wrong, and the measurement below did not invert that. ## Triage's escalation probe — the second deliverable Triage attached a mandatory escalation condition: parse every install/bootstrap example across `content/docs/api/**`, and if more than this one is refused, the finding is not one broken example but a published-example surface with no gate. **Method** (⛔ not an eye-pass). A throwaway AST probe over all 13 files of `content/docs/api/**`: `ts/typescript/tsx` fences are parsed with the TypeScript compiler API, every call site in the population below is located mechanically, and its argument literal is parsed against the contract that call site declares. An object member shape the reader does not model **throws** rather than passing quietly, and an empty population **throws** — a probe that measures nothing must not read as green. | door | population | contract it is parsed against | |:---|---:|:---| | `packages.install(<literal>)` | 1 | `PackageInstallRequestSchema` (`manifest: ManifestSchema`) | | `packages.enable/disable/uninstall(<arg>)` | 3 | the declared `id: string` | | `packages.list(<arg?>)` | 2 | the declared `filters?: { status, type, enabled }` | | `new ObjectStackClient(<literal>)` | 4 | `ClientConfig`, read out of `packages/client/src/index.ts` itself | | `defineStack(<literal>)` | 2 | `ObjectStackDefinitionSchema` | | shell fences `pnpm add` / `npm install` | 2 | the named workspace package exists and is not `private` | | **total** | **14** across 3 files | | **Positive control** — the probe must catch the refusal we already know about, or it is not measuring this. It does: ``` BEFORE tree /home/user/objectstack-18776-base @ 6de7a2d (origin/main at the branch point) population: 14 example(s) across 3 file(s) REFUSED: 1 content/docs/api/client-sdk.mdx:337 [packages.install] -> invalid_value at [manifest, type] -> invalid_type at [manifest, name] AFTER tree /home/user/objectstack-issue-18776 @ fd887ab population: 14 example(s) across 3 file(s) (unchanged — the repair moved a verdict, not the corpus) REFUSED: 0 ``` **Verdict: exactly one refusal across `content/docs/api/**`, and it is the one this card names.** The escalation condition is **not met** — nothing here re-grades the card or calls for the separate "published examples have no executability gate" carrier. Two things are reported rather than silently excluded: - `content/docs/api/metadata-api.mdx:108` and `:116` carry the install and publish request bodies as **inline prose with an explicit ellipsis** — `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, ... }`. They omit `type`, but the ellipsis is written in, they are not valid TS/JSON as printed, and nothing can be copied verbatim out of them. On this lane's boundary that is 不完整, not 错误 — outside the probe's parseable population and left alone. - `content/docs/api/environment-routing.mdx:31` is a `defineStack` example whose manifest is an explicit `// ...your manifest, objects, apis, etc.` placeholder. It is accepted by `ObjectStackDefinitionSchema` as written (that key is optional), so it is a pass, not a waiver. ⛔ No gate was built, wired or modified. The card measured why three existing gates cannot see this class and recorded that wiring a census into a **required** gate is a maintainer's floor decision; this PR does not take that decision, and it deliberately does not extend objectstack-ai#18607's README pin to this page for the same reason. ## Verification - **Gate families** — `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **39** families for this change set (1 path). All 39 run on the final commit `fd887ab61`, each exit code captured before any pipe: **39 exit 0**. `--ran` reconciliation: `39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)`. - Four of them first answered a **prerequisite**, not a finding — `check:doc-formula-expressions`, `check:doc-security-posture` and `check:docs-transcript-drift` exited **3** (`@objectstack/lint` / `@objectstack/formula` not built) and `check:skill-examples` exited 1 on the same shape (`packages/client-react/dist` holds no `.d.ts`). Built what each one named, re-ran, all four exit 0. `check:skill-examples` is the gate whose `SDK_DOCS_PAGES` population contains this page. - **`pnpm lint` is CI's run; the local narrowing is measured, not skipped.** ① The universe read from eslint's own config: every `files:` block in `eslint.config.mjs` names `{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and **zero** of them name `md` or `mdx`. ② `eslint --no-inline-config --format json content/docs/api/client-sdk.mdx` reports 1 file, 0 errors, 1 warning — *"File ignored because no matching configuration was supplied."* ③ Invariance: neither `projectService` nor `parserOptions.project` appears in `eslint.config.mjs`, so type-aware linting is off and this diff cannot move the verdict on any untouched file — and the changed file is outside the linted set entirely. - **`check:pm-dispatch-gates`** is not in this change set's derived families (it is not reachable from a `content/docs/**` path), so its detached-run prescription does not apply here. - **Merged `origin/main` at `9846f2763`** before opening; no conflict, and nothing on `main` had touched this file since the branch point. Re-derived the families from the merged tree with `main`'s newer `scripts/pm/dispatch-gates.mjs`: the same 39, no additions. Rebuilt `spec` / `lint` / `formula` / `client-react` after the merge and re-ran all 39 on the merge commit — the reading above is that run. - **No same-file collision with objectstack-ai#18792**, which edits `:767-783` of this file. Untouched here. ## Changeset — measured, not defaulted `skip-changeset`. The criterion is whether anything published moves. - **Positive control** (the instrument can say "this ships"): `npm pack --dry-run --ignore-scripts --json` in `packages/client` lists `README.md` in the tarball — a documentation file that really does publish, and the one that carried the sibling instance in objectstack-ai#18607. - **Reading**: that same listing contains **zero** `content/docs` entries; no `package.json` in the repo declares a `files[]` entry naming `content/docs` or escaping its own directory; `content/` contains no `package.json` at all; and its only consumer, `apps/docs`, is `private: true`. Nothing copies `content/docs/**` into a tarball at build time. ⇒ this diff publishes nothing from any released package. ## Acceptance notes - `content/docs/api/metadata-api.mdx:108` / `:116` omit the required `manifest.type` inside an explicitly-abbreviated inline body. Noted, not filed: 不完整, not an example that fails when copied, and the carrier for the class (published examples parsed by nothing) is the open question already recorded on objectstack-ai#18607's PR — not a new card from this one. - The probe lives in this session's scratchpad and is deliberately not committed: turning it into a test is the census-into-a-required-gate decision this card says is the maintainer's. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ Co-authored-by: claude[bot] <noreply@anthropic.com>
Fixes #17541
Clause-②: yes (widening)
Ruling: batch #148 item 5, letter 1 (comment
5716043121), maintainer 「同意」 2026-09-17T14:27Z.The defect, as the ruling asked for it: a WIRE reading
Step one of the ruling is a measurement through the HTTP route, not through the engine. Driven through
HttpDispatcher.handleAutomationon a parked two-level delegation — the parent parked at itssubflownode, the child parked at a pausing node, the child's downstream node then throwing —POST /automation/parent_flow/runs/:parentRunId/resumeanswered:BEFORE (
origin/mainatf1c9bb3056, the defect reproduced on trunk):AFTER (this branch):
At that same instant, on the same engine, both readings taken in the same probe run:
So the wire told an operator not to retry a repair that works, and the schema's own
.describe()stated the opposite reason — projected verbatim into the generated reference page.What changed
packages/spec—IAutomationServicedeclares the read-only member.inspectConsumedSuspension(runId), optional, exactly the shape the engine already implements publicly (AutomationEngine.inspectConsumedSuspension, landed for #15358). The declared result is deliberately narrower than the implementation's, on the precedent the siblingrestoreConsumedSuspensionset (#16495 route (i)):{ repairable: boolean; runId: string; reason?: string }, withreasontyped as the string the implementation answers rather than as an enumeration this contract would have to keep in step with. The engine's widerConsumedSuspensionInspectionsatisfies it underimplements— proved below, in both directions.packages/runtime— the resume door consults the declared contract. On a400 FLOW_FAILEDwhose result carries astatus, that stamp still decides and the engine is not consulted at all. On a result that stamps none, the door asks the declared optional member and relays itsrepairable.⛔ The door never probes an undeclared member: the member is on
IAutomationServicefirst, and the door reads it through that optionality, the same way it already readsresume. Every way of not getting an answer is FAIL-CLOSED — a service that declares no inspection member answersfalseexactly as it did before, and an inspection that REJECTS (a store it could not read) answersfalseand says so once atwarn. An unreadable store is UNKNOWN, not "nothing to restore", and it is never allowed to replace the400the caller asked for with a500.⛔ The fence the nested-chain work was dispatched with is untouched: a cascade-failed ancestor is still never STAMPED
'stranded'. Its repairability is carried by the journal and REPORTED by the inspection, which is exactly why the door has to ask instead of reading a word. ⛔ And no newAutomationResult.statusmember is minted (ruled-out option 2): there is nothing new for a client to learn, anddetails.repairableis the member a client was already told to branch on.Docs.
ResumeFailureDetailsSchema.repairable's.describe()is rewritten to the truth;content/docs/references/api/automation-api.mdxis REGENERATED with the repo's own tooling (pnpm --filter @objectstack/spec check:generated --fix, which proved exactly that one artifact stale and regenerated only it — a one-line diff). The two hand-written pages were re-read for the same sentence and both carried it, so both are corrected by hand:content/docs/automation/flows.mdxsaidrepairableis "trueexactly whenstatusisstranded", andcontent/docs/api/client-sdk.mdxassertedverdict.data.status === 'stranded'inside itsif (verdict.data.repairable)sample.Tests
Evidence below is from the final commit,
228275a473.A test that fails without the change and passes with it, plus its controls. New, in two layers:
packages/verify/src/automation-resume-delegation-repairable.test.ts— the wire, through the real engine and the real route. Three cases: the DELEGATION exit (repairable: true, nostatus, the wholedetailsparsing underResumeFailureDetailsSchema, and the operator verb then accepting exactly that run and the re-issued resume completing the tree); a CONTROL that the non-delegation stranded exit is unchanged (status: 'stranded',repairable: true); and a FIRING CONTROL — a delegation whose leaf is NOT repairable answersrepairable: falseon that same status-less arm.packages/runtime/src/domains/automation-resume-delegation-repairable.test.ts— the door's shaping with a fake service, so the arms a real engine will not produce on demand are reachable by name: the two fail-closed arms, and two controls proving a stamped'stranded'/'failed'is answered by the STAMP and the engine is never asked.The pre-existing
packages/runtime/src/domains/automation-resume-stranded-details.test.tsis left untouched on purpose and is the standing control that every non-delegation exit answers exactly what it did.Reverse verification — behaviour. The pre-change expression was put back on the committed tree, proven on disk (injected marker count 1, deleted call count 0, blob hash moved off HEAD's),
@objectstack/runtimerebuilt, and the mutation proven to have reached the artifact the suite consumes (node scripts/ablation-dist-preflight.mjs runtime …— marker present in 2 built files). Direction observed, as predicted: turned red, and only there —1 failed | 4 passed, the failure being the DELEGATION wire case, with both controls and the two pre-existing wire cases still green. Restore leg: source restored fromHEAD, whole-treegit status --porcelainclean, blob hash equal toHEAD's, rebuilt,--absentpreflight confirming the marker is gone from all 6 built files, suite green again (5 passed).Reverse verification — types.
AutomationEngine implements IAutomationService, so the new declaration is enforced on the implementer. With the engine'sConsumedSuspensionInspectionmutated to droprunIdfrom its repairable arm,pnpm --filter @objectstack/service-automation exec tsc --noEmitexits 1 naming the member:Restored, the same command exits 0. That is also the proof the typecheck reads the rebuilt spec
.d.tsrather than a cached one.Suites — both projects run where a package has two, exit codes read from
$?after a redirect, never through a pipe:pnpm --filter @objectstack/spec test(--project local)pnpm --filter @objectstack/spec test:repo(--project repo)pnpm --filter @objectstack/runtime test(--project local)pnpm --filter @objectstack/runtime test:repo(--project repo)pnpm --filter @objectstack/verify testpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/runtime typecheckpnpm --filter @objectstack/verify typecheckpnpm --filter @objectstack/service-automation typecheckpnpm --filter '@objectstack/verify^...' buildGates run locally (all exit 0):
pnpm --filter @objectstack/spec check:generated(15 of 15 up to date after the regeneration),check:nul-bytes,check-adr-0087-registration --base origin/main,check-changeset-no-major --base origin/main,check-empty-changeset --base origin/main,check:objectui-changeset,check:pm-widening-tells,check-closing-keyword-parity,check-spec-docblock-symbol-anchors,check-doc-frontmatter,check:docs-single-h1,check:doc-anchors,check-docs-section-name,docs-audit/check-affected-docs,check:route-envelope,check:dispatcher-error-vocabulary,check:cross-package-test-inputs,check:test-source-alias,check-undeclared-dep-imports,check:tier-file-adoption,check:type-check-coverage,check:type-check-debt.The three changeset gates first exited 1 with
no merge base between 'origin/main' and 'HEAD'— a shallow-clone PREREQUISITE, not a verdict.git fetch --deepen 300 origin mainrestored the merge base (f1c9bb3056) and all three then exited 0.Lint: the whole population, not a narrowing.
node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config --format jsonexits 0 over 6834 files (count read from the JSON output), 0 errors and 0 warnings.NOT measured here, deliberately: the rest of CI's gate farm (the derivation names 107 runnable commands plus 50 artifact-roster families, 10 wide-population families and 6 path-scheduled CI jobs), and the
Temporal Conformance/Dogfood/Build Docsjobs, which have no local invocation.Acceptance notes
Two observations found on the way, neither filed — each is an omission rather than an error, and neither is a reproducible defect, a broken declared contract, or an authoring trap:
registerSubflowNodeis exported frompackages/services/service-automation/src/builtin/index.tsbut is not re-exported from that package's rootindex.ts, while five sibling registrars are. Nothing is broken by it:installBuiltinNodesreaches the same executor and is what the new wire test uses. Next party to meet it: an out-of-package test author who wants the subflow node alone.ResumeFailureDetailsSchema.status's own describe names "a subflow child that failed terminally" as the status-less case and does not also name the delegation frame. Incomplete, not false, and the ruling named only therepairabledescribe. Next party to meet it: whoever next edits that enum's text.Generated by Claude Code