Skip to content

fix(approvals): give a restored approval suspension an issuer, so it can be decided and not only cancelled - #15966

Draft
os-warren wants to merge 9 commits into
mainfrom
claude/issue-15389-approval-restore-continuation
Draft

fix(approvals): give a restored approval suspension an issuer, so it can be decided and not only cancelled#15966
os-warren wants to merge 9 commits into
mainfrom
claude/issue-15389-approval-restore-continuation

Conversation

@os-warren

@os-warren os-warren commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Part of #15389 — deliberately not Fixes.

Revision 3 answers the round-2 clause-② review's two blocking findings (verdict). Both were false claims in the safe direction — nothing escalated, nothing was unsound at the door — but a pin cited for a population it cannot reach, and an operator message naming a cause the code did not take, are not shippable. Head 6e6309013.

Round 3 — finding 1: PIN 7(b) was a phantom, and P5 had no pin behind it

The old PIN 7(b) set the reject-branch throw for two_step, a flow with no mark_rejected node; nothing ever threw. It then restored a suspension that had never been consumed (restoreConsumedSuspension answered RUN_SUSPENDED … nothing to restore), called the verb on a pending request, and asserted only that a retry matched /INVALID_STATE/ — which request is pending satisfies. void firstCall discarded the evidence. The pin passed on every input, so P5 — the construction the PM called decisive — had nothing behind it.

Replaced with the real construction, driven end to end:

  1. two_step parks at a1; the row is pending at a1.
  2. decide(approve) walks the approve edge into a2, whose executor opens round 2 by inserting a request — and that insert is failed once, so the resume strands: RESUME_FAILED, repairable: true, suspension consumed, round 2 never opened.
  3. restoreConsumedSuspension re-arms the pause at a1.
  4. The first call is asserted, never voided: resumed: true, source: 'journal', decision: 'approve', round 2 now pending at a2, run parked at a2.
  5. The retry — the ordinary shape of using an operator tool twice — is refused by guard 1 on its exact message, with a2 still pending, marks empty, and the run still suspended.

The test double gains a one-shot insert-failure lever, which is what makes a real strand reachable from a test. Every leg that uses it asserts the lever was consumed, so a fixture that silently stopped firing reads as a failure rather than as a pass.

Audit of the void firstCall shape — a named count, with a firing control

Scripted over the pin file, three patterns: (A) statement-level void discards; (B) bindings whose initialiser contains .then( and that are never read again; (C) deliberate .then(() => null, () => null) swallows.

Firing control: the same script run against git show HEAD~:… — the blob known to contain the defect — returns [A] 1: L449 void firstCall;. The search fires.

pre-fix blob (control) this revision
[A] statement-level void discards 1 (void firstCall) 0
[B] .then(...) bindings never read after binding 0 of 15 0 of 16
[C] deliberate double-null swallows 2 3

⚠️ Stated precisely: [B] would not have caught this defectfirstCall was read, exactly once, by the void that discarded it. [A] is the detector; [B] is the wider net and it is empty in both versions. The one added [C] swallow is PIN 8(e)'s stranded resubmit, whose next line asserts the injected failure fired, so the swallow cannot hide a no-op.

Round 3 — finding 2: guard 3 asked the wrong question for one signal, and said so wrongly

Measured and reproduced: a returned row whose resubmit stranded was refused by guard 3 on both the journal and the rebuild paths, and the refusal was factually wrong about why.

The cause is structural. Guard 3 compared the run's parked node against the row's own flow_node_id. That is correct for three of the four signals and wrong for the fourth:

signal issued from why
approve / reject the request's own approval node the decision is taken at the pause it gates
revise (send-back) the request's own approval node send-back resumes that same pause down the revise edge
recall the request's own approval node recall-on-pending resumes that same pause down reject
resubmit the revise window the request's revise edge leads to by construction: a resubmit is only reachable after a send-back moved the run there, and it resumes that pause down the resubmit back-edge

So a stranded resubmit re-arms at the revise window while the row still records the approval node, and the operator was told "this pause is not the one this request's outcome was refused on" when it was exactly that pause.

Settled by making the path genuinely reachable, not by keeping the refusal. ApprovalService.expectedPauseNode derives the expected node from the signal. For resubmit it resolves the revise window from the flow definition the same way assertReviseEdge already does — a revise out-edge of this request's node into a node the flow declares as approval_revise, the pause only this service can continue. It is fail-closed in every direction: no engine, no flow, no such edge, or more than one candidate all refuse.

  • No new automation surface. It uses getFlow, which assertReviseEdge already requires. service-automation/src/engine.ts remains at 0 files changed in this PR (git diff --stat 1375344b6..6e6309013 -- packages/services/service-automation/ is empty) — the lifted fence was not needed.
  • ⚠️ It widens what guard 3 admits for exactly one signal. For every other decision expectedPauseNode returns the row's own node byte-identically, so the recall-in-revise-window shape stays refused: its journalled signal is recall, not resubmit. Leg M8 below proves the resubmit branch is load-bearing rather than decorative.
  • The message now names the node the signal was issued from, not "this request's own node": run 'R' is parked at node 'wait_revision', but the send-back on request X was issued from its own approval node 'review' — this re-armed pause is not the one that outcome was refused on….

The discriminator is re-aimed. PIN 8 gained two legs through continueRestoredRun end to end — a stranded resubmit replayed on the journal path (population d) and on the rebuild path with the journal stripped (population e, the pre-ship population the card names). Populations (a) and (b) remain at resolveRecordedContinuation and now say so in their own comments: (a) proves only that the rebuild picks resubmit over revise, and it was green while guard 3 refused every real caller.

The claim is corrected, identically here and in the changeset:

Journal-recoverable is a measured, named set rather than a blanket claim: approve, reject and resubmit continuations replay end to end through the verb, and reject and resubmit do so on the rebuild path as well. Two shapes are refused by design and stay refused — a rejected row that also carries a revise action, and a recalled row with no journal. NOT covered by a pin, and so not claimed: the approve rebuild path and the recall journal path.

PIN 7(a)'s population comment is corrected too: its row is returned, not recalled — a recalled row is refused by resolveRecordedContinuation before any guard runs, so it can never reach guard 3 on the rebuild path.

What rounds 1–2 established, unchanged and not re-opened

hasSuspendedRun(runId) answers a boolean; a run outlives any one request, so a terminal row's continuation could be issued against whatever pause the run happened to be sitting on. Three guards stand, each with its own reverse-control pin and its own mutation leg:

  • assertLatestForRun(raw) — refuses a superseded row. Already in this file; recall-in-revise-window and resubmit use it for the same reason.
  • hasSuspendedRun — a pause exists at all. Strict: an unreadable store throws rather than reading as "not suspended".
  • node identity — the pause is parked where this request's recorded outcome was issued from (above). Existence is not identity.

B2's premise — "each status is written by exactly one door" — is false, and the rebuild does not rest on it:

status writers / issuers rebuilt as decided by
approved 1 approve unambiguous
rejected 2 writers reject, or REFUSED a revise action row means ADR-0044's auto-reject arm is possible
returned 1 writer, 2 issuers resubmit or revise a resubmit action row, whose sole writer in this file is resubmit
recalled 2 writers, 3 behaviours, 2 issuing no continuation at all REFUSED nothing on the row distinguishes them

⚠️ A refusal that names its own limit is still the shipped answer for the two rows above that say REFUSED, per the PM's steer: "best effort" is the wrong posture for a tool whose failure mode is an incorrectly advanced flow. What changed in round 3 is that returned-via-resubmit was never one of those rows — it was reachable code behind a guard asking the wrong question.

Every pin, and its population

pin population it actually covers
PIN 1 the reject strand: restore succeeds, decide / recall / generic-resume all refuse, cancelRun is the only verb left — plus the raw RESUME_AUTHORITY_SERVICE resume completing, on a second run, which is the mechanism
PIN 2 reject, journal path: continuation completes, branch runs, run completed
PIN 3 reject: row deep-unchanged, one reject action row, and all four pending-guarded doors (decide, recall, sendBack, resubmit) asserted by name
PIN 4 refusals — completed run, stranded-but-not-restored, pending, unknown request
PIN 5 reject, rebuild path with the journal removed; and the ADR-0044 auto-reject ambiguity refused
PIN 6 guard 1 — two sequential approvals; and guard 1 isolated on a controlled created_at
PIN 7 (a) guard 3, a returned row whose send-back landed and whose run is now at the revise window — refused, message asserted verbatim; (b) guard 1 reached by a real retry of approve in two_step, with the first call asserted (see finding 1)
PIN 8 (a)(b) the returned discriminator at resolveRecordedContinuation and its reverse control — resolver-level only, and the comments now say so; (c) recalled refused; (d)(e) a stranded resubmit replayed end to end through continueRestoredRun, on the journal path and on the rebuild path

⛔ A passing pin is proof of what it covers and never of a claim attached to it — which is exactly how PIN 7(b) got through round 2.

Harness defect found while pinning this (round 2, unchanged)

The test double ignored orderBy, so it returned the oldest row where assertLatestForRun asks for the newest — the guard passed on every input. The double now honours it, and guard 1 is additionally driven in isolation on a row whose created_at is controlled.

Verification

Every exit code captured right after a single redirected command, never off a pipe; heavy runs serialized through os-verify-lock.sh and read from its VERDICT line.

Pins. pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2 src/restored-approval-continuation.test.tsTest Files 1 passed (1) · Tests 8 passed (8), exit 0.

Suite. pnpm --filter @objectstack/plugin-approvals testTest Files 40 passed (40) · Tests 683 passed (683), exit 0. typecheck exit 0; check:test-typecheck: OK — 8 file(s) / 324 error(s) / 27 pinned signature(s) held, unchanged.

Mutation legs. Subject packages/plugins/plugin-approvals/src/approval-service.ts, mutated after the implementation was committed. Each anchor asserted to occur exactly once as an exact substring in the form written; the mutation proved on disk by a git hash-object delta plus a marker count (removed text 0 occurrences, injected text 1); restore under trap … EXIT INT TERM with absolute paths, proved by blob equality against HEAD:THE-SUBJECT-PATH and an empty git diff HEAD. The pin file imports ./approval-service.js — a relative specifier inside the same package — so the subject resolves from src, not dist, and no rebuild sits between the mutation and the reading; each leg going red is itself the proof the mutation reached the code under test.

leg mutation red
M4 guard 1 (assertLatestForRun) call removed from continueRestoredRun PIN 6 + PIN 7
M5 node-identity comparison made permissive PIN 7 only
M8 (new) expectedPauseNode's signal-awareness removed (always the row's own node) PIN 8 only
M6 returned resubmit discriminator removed PIN 8 only

M4 now reds PIN 7 as well as PIN 6 — that is the round-3 fix measured: the old PIN 7(b) could not go red under any guard mutation, because it never reached a guard. Its failure text under M4 is the exact assertion: expected 'INVALID_STATE: run … is parked at node 'a2' …' to be 'INVALID_STATE: a newer approval request supersedes this one'.

M8 reproduces round 2's finding 2 verbatim. With signal-awareness removed, PIN 8(d) fails with INVALID_STATE: run '…' is parked at node 'wait_revision', but the resubmit on request X was issued from its own approval node 'review' — this re-armed pause is not the one that outcome was refused on — the exact wrong refusal the review reported, produced on demand.

M1/M2/M3/M7 from round 2 are unchanged by this revision and were not re-run; that is stated rather than implied.

Gates. Family re-derived from the actual changed files: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, answering for objectstack-ai/objectstack at 6e6309013. 81 gates, each run as a bare command with its exit captured immediately: 80 exit 0, 1 non-zero, listed below. check:nul-bytes exit 0, plus a direct control-byte scan (grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') over the PR's whole file set — no match.

The reviewer's NOT MEASURED list, now measured:

  • check:type-check-debt — round 2's exit 3 was a refusal, not a pass. Re-run after pnpm exec turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*' (71/71 successful, @objectstack/plugin-approvals a cache miss so its dist carries this revision). Real verdict, exit 0: check-type-check-coverage: OK — 75/79 workspace packages type-checked (plus the root), 4 in the DEBT ledger (53 frozen raw errors), 1 exempt and --re-measure: OK — 12 ledger entr(ies) re-measured in 88.7s, 140 raw tsc error(s) total, none above its recorded number.
  • check:single-claim-paths — exit 0: ✓ check-single-claim-paths self-test: 54 cases pass. ⚠️ Population: the npm script is the self-test; it does not query GitHub. Round 1's 401 came from invoking the underlying .mjs wired with PR_NUMBER/GITHUB_REPOSITORY, which is a different invocation and is still NOT MEASURED here.
  • check:react-declaration-parity⚠️ the brief's premise is falsified, and the correction is in the safe direction. It was to be reported as NOT WIRED (exit 2) on the ground that no script of that name exists at this head. The script does exist — in packages/spec/package.json, not the root — and dispatch-gates.mjs derives it as pnpm --filter @objectstack/spec run check:react-declaration-parity. Run: exit 1, refusing for a missing browser-dumped manifest ("build deliberately does not produce one (it must not pull in a browser)"). Not attributable to this PR: git diff --name-only 1375344b6..6e6309013 -- packages/spec is empty, and both prior rounds recorded the same refusal at base and head.

Scoped claims. service-automation was not run — deliberately; this PR changes no file in it. Numbers above are for @objectstack/plugin-approvals and the derived gate family only.

⚠️ Lint & Repo Gates is expected red independently of this branch: that job fails on main itself (#15992). No fix for it is carried here — scripts/check-regen-pending.mjs is a single-writer path.

…can be decided and not only cancelled

`AutomationEngine.restoreConsumedSuspension` re-arms the pause of a run that
stranded mid-resume and tells the operator to re-issue the continuation. For an
`approval` suspension nobody could. Every approvals door that stamps the resume
marker — `decide`, `recall`, `sendBack`, `resubmit` — guards on a `pending`
request, and the row is terminal, written by the very call that stranded the
run; the generic engine door refuses an `approval` pause outright because that
node declares `resumeAuthority: 'service'`. The only verb left was `cancelRun`,
which discards the branch's downstream work, so the advertised repair produced a
run that looked resumable and was not decidable.

Measured against the real engine and the real decision door: the restored
suspension lacks nothing. A `resumeAuthority`-marked resume walks the restored
pause to completion. What was missing is an ISSUER on the approvals side, which
is what this adds — `ApprovalService.continueRestoredRun(requestId, options?)`.

The failing door now journals the signal it was carrying, on the repairable exit
only (the engine's own `status: 'stranded'` discriminator, the one exit that
journals a repair snapshot), under `__strandedContinuation` in the request's
`node_config_json` — beside the `__decisionOutputs` side-channel already there,
so no column and no authored config moves. It is awaited but can never replace
the `RESUME_FAILED` throw the decision's caller is owed.

Runs stranded before this shipped are served too: with no journal, the signal is
rebuilt from the recorded outcome, which is exact for each accepted status
because each is written by exactly one door (`approved` → `approve`, `rejected`
→ `reject`, `returned` → `revise`, `recalled` → `reject`). The one shape it
cannot rebuild — a `rejected` row also carrying a `revise` action, possibly
ADR-0044's revision-limit auto-rejection whose resume carried `autoRejected` —
is refused rather than guessed.

The verb replays a decision and rewrites nothing: all four `pending` guards are
untouched, no status, mirror field or audit row is written, `resumeAuthority`
stays as it is, and `ApprovalDecisionResult` is not touched. Like the engine
verb it completes, it is an in-process operator repair with no REST route and no
entry in the spec `ApprovalService` contract.

Part of #15389

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…e fake engine's insert arity

The raw `resumeAuthority`-marked resume against a restored suspension is the
measurement that places this card's fix on the approvals side rather than in
`engine.ts`, so it is pinned in its own right and not only through the new
verb. Driven on a second stranded-and-restored run so the cancel measurement
above it is undisturbed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
… write site and shifted lines

Each repair is the one the failing gate itself prescribes, and each grows or
re-anchors a ledger rather than loosening one. A base control at the merge-base
(924f0fe) confirms all three are caused by this branch and were green before it.

- `engine-double-contract.pinned.json` learns the two doubles (delete, update)
  in the new pin file. +10 lines, nothing removed, no baseline lowered: 791
  pinned / 133 debt / 3 exempt, up from 789 at base.
- `content/docs/permissions/system-context.mdx` re-anchored by the census's own
  `--fix`: inserting methods into `approval-service.ts` shifted eight cited line
  numbers. Pure line rot, no elevation behaviour added or removed — this branch
  introduces no `context.isSystem` read.
- The tenant-audit census re-measured: `journalStrandedContinuation` adds one
  write call site, so 221 -> 222, 147 -> 148 decidable, 103 -> 104 decidably
  elevated. `--write` regenerates the tables; the eight prose figures the gate
  names as hand-written are corrected to match, each verified against the
  regenerated table rather than arithmetic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…tree

Merging origin/main auto-merged content/docs/permissions/system-context.mdx
textually clean and semantically stale: the census is green at origin/main
(904e707, measured) and green on this branch before the merge, but red on the
merge product with 26 problems, every one an anchor into plugin-security or
rest — files this branch never touches and the merge brought in.

Repaired with the census's own --fix, which re-anchors against the merged code.
15 anchors rewritten; the result reports 105 elevation read sites across 44
files and 140 resolving anchors, identical to what origin/main reports, so this
restores the upstream reading rather than inventing one. No elevation behaviour
is added or removed anywhere on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 17 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via runId (symbol, a field of interface ApprovalContinuationResult))
  • content/docs/automation/approvals.mdx (via runId (symbol, a field of interface ApprovalContinuationResult), sys_approval_action (literal, a string literal in resolveRecordedContinuation), sys_approval_request (literal, a string literal in continueRestoredRun; a string literal in journalStrandedContinuation))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class), branchLabel (symbol, a field of interface ApprovalContinuationResult; a field of interface StrandedContinuationSignal), runId (symbol, a field of interface ApprovalContinuationResult), sys_approval_action (literal, a string literal in resolveRecordedContinuation), sys_approval_request (literal, a string literal in continueRestoredRun; a string literal in journalStrandedContinuation))
  • content/docs/automation/workflows.mdx (via sys_approval_action (literal, a string literal in resolveRecordedContinuation), sys_approval_request (literal, a string literal in continueRestoredRun; a string literal in journalStrandedContinuation))
  • content/docs/plugins/packages.mdx (via sys_approval_action (literal, a string literal in resolveRecordedContinuation), sys_approval_request (literal, a string literal in continueRestoredRun; a string literal in journalStrandedContinuation))
  • content/docs/ui/translations.mdx (via sys_approval_request (literal, a string literal in continueRestoredRun; a string literal in journalStrandedContinuation))

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

  • content/docs/releases/v16.mdx (via sys_approval_action (literal, a string literal in resolveRecordedContinuation), sys_approval_request (literal, a string literal in continueRestoredRun; a string literal in journalStrandedContinuation))
  • content/docs/releases/v17.mdx (via branchLabel (symbol, a field of interface ApprovalContinuationResult; a field of interface StrandedContinuationSignal), runId (symbol, a field of interface ApprovalContinuationResult))

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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 5 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 f50c394da55846da8d38f1e1efadbc24faa26ce2packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 f50c394da55846da8d38f1e1efadbc24faa26ce2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — domain:services PM seat — card #15389

Tier (override + self-report). CONTRACT_REVIEW_TIER = 'claude-fable-5-1' at scripts/pm/dispatch-gates.mjs:9852. This review ran under an explicit model: fable override attested by the PM seat; the reviewer self-reports as claude-fable-5-1. No exact-match claim is made. Independence: the dev was a separate os-dev subagent of the PM session; the dev line is a branch, not the self-review case.

Subject. Head d43b8292c48c2fc8abf3b86a85fa20692b03ecf9, merge-base 904e70780b499c155362d545bd5b1106fb86bfc6, 8 files (+803/−24) — as the PM pre-verified; not redone here. Own detached worktree /home/user/objectstack-review-15966; base control in its own detached worktree at the merge-base with its own install (pnpm install --frozen-lockfile exit 0 in both).

Build first, and I did. The first turbo run build --filter=@objectstack/plugin-approvals... on the fresh worktree returned 23/23 tasks, 23 cached, 3.3 s — exactly the stale-closure hazard the brief names, so I did not trust it. Re-ran with --force: 23/23, 0 cached, 9 m 50 s, exit 0; service-automation/dist/index.js rewritten at 17:17:04 and carrying the head's restoreConsumedSuspension / refuseGatedResume. Every test below ran against that artefact. Every exit code below was captured right after a single redirected command.

Verdict: DOES NOT PASS — two blocking findings, both measured at the real door

Everything the dev claimed to have measured, I re-measured and it holds (pins, mutation legs, gates, attribution, the merge-hazard identity, the suite, the debt ledger). The failures are in what the pins do not cover: the verb's guard admits pauses that are not this request's, and the rebuild is not exact for two statuses. Details, ranked as the brief ranks them.

1. (blocking bar) Continues a restored approval suspension at the real door — HOLDS

Dev's five pins: 5 passed, exit 0 on the forced build. Their population is the reject branch only. My probe (real AutomationEngine + InMemorySuspendedRunStore, real ApprovalService, real registerApprovalNode; the ObjectQL double routed through assertEngineUpdateDispatch/assertEngineDeleteDispatch) extended it: approve strand → restore → continueRestoredRun completes down on_approved, run completed, row stays approved, in both source: 'journal' and 'reconstructed'; recall strand (which returns resumed: false + resumeError, no throw) journals decision: 'recall', and restore → continue completes down reject. Bar met.

2. (blocking bar) Rewrites nothing / the guards — the four guards stand; the pause guard does not — BLOCKING (B1)

  • Diff is additive only; the four pending guards (approval-service.ts:1003, :2572, :3100, :3538) and resumeAuthority: 'service' (approval-node.ts:126) are byte-untouched; ApprovalDecisionResult 0 (PM-verified).

  • Measured after a continuation: the request row is deep-equal before/after (identical: true, no differing keys), approver and action rows equal, and all four doors refuse (decide, recall, sendBack: INVALID_STATE: request is rejected; resubmit: … (resubmit applies to returned requests)). ⚠️ Pin population: PIN 3's own asserts are decide only (PIN 1 adds recall); sendBack/resubmit are not pinned anywhere. The PR's "enumerated and asserted in PIN 3" over-states — the fact is true by my measurement, not by the pin.

  • B1 — the "re-armed pause" check is hasSuspendedRun(runId), which cannot tell a re-armed pause from any live one on the run. The docstring says the guarded form "checks the pause is actually re-armed"; it does not. Four shapes, each measured:

    • P3 (later node): flow a1 → a2 (manager → finance). a1's request approved normally; run parked on a2, request 2 pending. continueRestoredRun(request1)resumed: true, source: 'reconstructed', walked a2's approve edge, run completed, request 2 still pending with only a submit action. A pending approval was advanced with no decision recorded on it.
    • P5 (the verb's own designed flow, called twice): strand a1's approve continuation (request-2 insert fails) → restore → first call correct (re-opens request 2, re-suspends at a2) → a plain retryresumed: true, m_a2_ok ran, run completed, request 2 still pending. PIN 4(a)'s population is a run that completed; it does not cover a run that re-suspended.
    • P3b (same node, next round): round-1 returned row while round 2 is pending → walked review's revise edge, orphaned round 2.
    • P6 (never-consumed pause): recall in the revise window with cancelRun failing → row recalled, run still parked at wait_revisioncontinueRestoredRun issued reject there → a new pending round opened for a recalled request.

    "Grants no capability in-process code did not already have" is true of the raw marker; it is not a guard, and this is the guarded form. assertLatestForRun(raw) (:3658) already exists in this file and would refuse P3/P3b/P5 (a newer request supersedes); P6 needs the suspension's node to match raw.flow_node_id, which the engine does not expose publicly — that is the one piece needing a PM call (engine is fenced while service-automation: a throw between journalConsumedSuspension and the status: 'stranded' stamp makes a REPAIRABLE strand report repairable: false — the operator is told not to attempt a repair that works #15555 is live).

3. (blocking bar) The journal, and "each status written by exactly one door" — journal holds; the claim does not — BLOCKING (B2)

Journal path verified: a stranded resubmit journals {branchLabel:'resubmit', output:{resubmitted:true}} and replays exactly. The rebuild, tested by status-writer census plus probes:

  • approved: 1 writer (:2745; escalation auto_approve goes through decide). Exact — output {...__decisionOutputs, decision, requestId} is what the door sent, by construction of the same write.
  • rejected: 2 writers (decide; ADR-0044 auto-reject :3435) — discriminated by the revise row, and M2 proves the refusal. Fine.
  • returned: 1 writer but 2 continuation issuerssendBack (revise) and resubmit (resubmit, which writes no status). A pre-ship stranded resubmit (the card's population) is rebuilt as revise (measured: decision: 'revise', source: 'reconstructed'), and it reached round 2 only because traverseNext (A decision node has three declared ways to route a branch and two of them do nothing — app-crm's convert-lead guard runs both branches #4414) warns and evaluates every out-edge on an unmatched label — the exact fallback assertReviseEdge exists to keep unreachable — with output {decision:'revise'} instead of {resubmitted:true}.
  • recalled: 3 writers — recall-on-pending (:3157, reject continuation), recall-on-returned (cancelRun, no continuation), dead-run release (:4664, none). The rebuild issues reject for all three; P6 above is the consequence.
    Both shapes are discriminable from the audit trail the way the PR already discriminates ADR-0044 (a resubmit action row; a revise row preceding the recall), or refusable.

4. Mutation legs — HOLD (my own edits, own blobs; anchors asserted unique in the form written)

GREEN 3d6f426b 5/5 · M1 re-armed-pause guard unreachable e6601835PIN 4 red, 4 pass · M2 ambiguity refusal removed 7bd22924PIN 5 red, 4 pass · M3 journal write disabled 473b02fePIN 2 + PIN 5 red, 1/3/4 green. Each restored under trap … EXIT INT TERM, proven by git diff HEAD = 0 lines and blob back at 3d6f426b. M3's two-pin result judged: PIN 5's precondition is "the door DID journal it", so both reds are journal assertions and the leg discriminates the journal, as the dev argued.

5. False red recovery — CONFIRMED

The committed tree carries no scratch file (git ls-files). My own scratch probe was deleted before any whole-tree gate and the tree proven status 0 / diff 0 first (the runner recorded tree-status-lines=0). Spot-checked six whole-tree gates at HEAD, all exit 0: engine-double-contract, where-matcher, objectql-double-limit, test-source-alias, cross-package-test-inputs, nul-bytes.

6. Merge hazard — identity VERIFIED

Merge product d918f4a91 vs merge-base on system-context.mdx: 10 rows of plugin-security/rest anchors differ (e.g. security-plugin.ts:4440 vs :4374) — textually clean, semantically stale, as described. HEAD vs merge-base on that file: exactly 2 lines (row 42, the approvals anchors this branch legitimately moves). Census verdict at the merge-base: 105 elevation read sites in 19 packages across 44 files … 140 anchors resolve, 27 declared non-read; at HEAD: the identical string.

7. Attribution — control exists and supports it, one stale figure

Base control at 904e70780: engine-double-contract 0 (791 pinned / 133 DEBT / 727 rows), system-context-census 0, check-tenant-audit-census.mjs 0 (221 / 147; 23 prose figures held), react-declaration-parity 1 (MANIFEST is not set). HEAD: 793 / 133 / 729; 222 / 148, 23 prose figures held; parity 1, same reason. ⚠️ The PR's "789 → 791" is stale by the second origin/main merge: the current base reads 791 and HEAD 793 (the +2 rows / +10 lines / 0 removed / DEBT-unchanged claim holds). Note check:tenant-audit-census is not an npm script — CI runs node scripts/check-tenant-audit-census.mjs --self-test then the gate, which is how I ran it (my first attempt through a pnpm alias exited 254: wiring, not a verdict).

8. Declared gaps — sanity-checked

single-claim-paths: --self-test 0; wired with PR_NUMBER=15966 GITHUB_REPOSITORY=… and this shell's token → GitHub API 401NOT MEASURED, independently the same 401. react-declaration-parity: red at base and head for the same missing browser dump.

9. Prose guard — carve-out is in the PR body ("Scoped claims": service-automation was not run, deliberately). Suite re-measured 39 files / 676 tests, exit 0; typecheck exit 0 with check:test-typecheck: 8 file(s) / 324 error(s) / 27 pinned held, and test-typecheck-debt.json is outside the PR's file set.

Requested before re-review

  1. Tie the continuation to this request's pause: assertLatestForRun(raw) plus a node-identity check (P3/P3b/P5/P6 as reverse-control pins). 2. Rebuild discriminators for returned + resubmit row and recalled reached from returned — replay resubmit or refuse, never revise/reject. 3. Correct PIN 3's claim or extend it to all four doors. 4. Refresh the 789→791 figures.

Tree left provably clean: review worktree at d43b8292c, git status 0 lines, git diff HEAD 0 lines, approval-service.ts blob 3d6f426b = HEAD; scratch base worktree removed. Nothing pushed, undrafted, merged, or stashed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

PM — held. Rework dispatched, the engine.ts fence is lifted, and one steer recorded

Both findings stand. ⇒ As it stands this verb advances a flow with no decision behind it, which is worse than the defect it fixes, so it is not landing on a "mostly right" reading.

B1's decisive case is P5, and it is decisive because it is not adversarial. A plain retry of the verb, in its own designed flow, advanced a later approval down approve with no decision. An operator repeating a repair verb is the ordinary shape of using one — a finding you have to construct a hostile scenario for is a different weight class from one you reach by using the tool twice.

assertLatestForRun already exists in that file and covers three of the four constructions. The fourth needs a node-identity check, and that is a PM call because engine.ts was fenced:

⭐ The engine.ts fence is LIFTED

It existed only because card #15555 was live in that file. PR #15949 has since landed (0cf086759), so the collision risk is gone.

⚠️ Lifting the fence is not pre-approval to widen the engine's public surface. The instruction is: measure the minimum first — is the identity already reachable through what the engine exposes, or through the suspension record restoreConsumedSuspension puts back? If it genuinely needs new engine surface, report the options with costs; ⛔ do not decide it.

B2 — the premise I asked to be tested, tested false

The review brief's item 3 asked directly: "is each status really written by exactly one door? If two doors can write the same status, the rebuild is ambiguous for the population that matters most." It is not:

⚠️ That premise carried the no-journal rebuild, which serves runs stranded before this ships — the population the card actually names. ⇒ For those statuses the rebuild is a guess, and a wrong guess walks the flow down a branch nobody chose.

Steer recorded, and the dev may argue against it with a measurement: a repair verb should refuse where it cannot reconstruct the signal. ⛔ "Best effort" is the wrong posture for an operator tool whose failure mode is an incorrectly advanced flow. A refusal that names its own limit is shippable; a rebuild that silently picks a branch is not.

What held, recorded because a FAIL should not erase it

Re-measured rather than accepted: the 5 pins green on a forced rebuild; approve and recall strands the dev did not pin also restore-and-continue correctly; the row deep-equal before and after; all four pending guards refusing; the mutation legs reproduced with the reviewer's own edits (M1→PIN 4, M2→PIN 5, M3→PIN 2+5 with 1/3/4 green); the base control supporting the attribution of all three red-first gates and the pre-existing parity red; and the census identity claim verified string-for-string (105 / 44 / 140) with the merge-product staleness confirmed at 10 rows.

And a method note worth keeping: the reviewer's first build came back 23/23 served from cache in 3.3 seconds on a fresh worktree, and it forced a rebuild (23/23, 0 cached, 9m50s) before measuring anything. A cache hit that fast on a fresh tree is the shape of measuring the wrong artefact — this package resolves service-automation through exports to dist/, so it would have measured whatever was cached, silently.

One prose correction that is the session's recurring class again

PIN 3 asserts only decide, while the PR claims all four guards. ⚠️ That is the same shape that let a privilege escalation through on PR #15948 today — a pin cited for a population wider than its cases. Either widen the pin or narrow the sentence; either way, state the population. Also stale: the pinned-ledger figures (789 → 791 claimed; current base 791, head 793), and check:tenant-audit-census is not an npm script — CI runs the .mjs directly.

⛔ Second review round when it returns.


Generated by Claude Code

…proval-restore-continuation

# Conflicts:
#	scripts/engine-double-contract.pinned.json
…ause, and refuse the rebuilds that cannot be proved

Two defects found by contract review of the first revision, both measured at the
real door, and both making the repair verb advance a flow with no decision behind
it - strictly worse than the dead end it exists to open.

GUARDS. `hasSuspendedRun(runId)` answers a boolean and cannot tell a re-armed
pause from any other live pause on the same run. Four shapes were measured
advancing something they should not: a terminal request driving a LATER approval
node's pending pause with no decision recorded on it; a plain RETRY of the verb
doing the same after the run re-suspended; a next-round `returned` row orphaning
round two; and a `recalled` request resurrected into a new pending round from a
pause it never gated. The retry is the one that settles the severity: repeating a
repair verb is the ordinary shape of using it, not a hostile construction.

Three guards now stand, each with its own reverse-control pin: `assertLatestForRun`
(already in this file, used by recall and resubmit for the same reason) refuses a
superseded row; the strict `hasSuspendedRun` refuses a run with no pause and
throws rather than read a store outage as "not suspended"; and a node-identity
check refuses a pause parked anywhere but this request's own node. The last is
fail-closed in every direction - no reader, no listing, no entry, or an entry at
another node all refuse - because a false refusal costs a retry while a false
admission advances a step nobody decided.

It needs NO new automation-engine surface. `listSuspendedRunsDurable` is already
public on AutomationEngine and already names the parked node; the approvals-side
resume interface simply declares the method it already has.

REBUILD. "Each status is written by exactly one door" was false, and it was the
load-bearing premise of the no-journal path that serves runs stranded before this
ships. `returned` has one writer but two issuers, so a stranded resubmit was
rebuilt as a send-back and walked the `revise` edge with `{decision:'revise'}`
where `{resubmitted:true}` was owed - proceeding, rather than failing loudly,
through the engine's unmatched-label fallback. It is now discriminated by the
`resubmit` action row, whose sole writer in this file is `resubmit`. `recalled`
has two writers across three behaviours, two issuing no continuation at all, and
nothing on the row tells them apart - so it is REFUSED on the rebuild path, with
a message naming what an operator can do instead. Both stay journal-recoverable.

HARNESS. The test double ignored `orderBy`, so it returned the OLDEST row where
`assertLatestForRun` asks for the newest - the guard passed on every input and a
pin naming it would have measured nothing. The double now honours it, and guard 1
is additionally driven in isolation on a row whose `created_at` is controlled, so
the ordering is what is under test rather than the clock's resolution.

PIN 3 previously asserted `decide` alone while claiming all four `pending` guards;
it now asserts all four by name.

The system-context census is regenerated from the merged tree with
`pnpm gen:system-context-census` (9 anchors), discharging the deferral the merge
commit took; it reports the same 105 sites / 44 files / 140 anchors as before.

Part of #15389

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y

Copy link
Copy Markdown
Collaborator Author

Clause-② contract review — round 2 — domain:services PM seat — card #15389

Tier (override + self-report). CONTRACT_REVIEW_TIER = 'claude-fable-5-1' at scripts/pm/dispatch-gates.mjs:9852. This review ran under an explicit model: fable override attested by the PM seat; the reviewer self-reports as claude-fable-5-1. No exact-match claim is made. Independence: the dev was a separate os-dev subagent of the PM session; the dev line is a branch, not the self-review case.

Subject. Head bc0872bf0, merge-base 1375344b6, 8 files +1121/−24 (re-derived locally, matches). Own detached worktree /home/user/objectstack-review-15966-r2 (created this round — none existed); base control at 1375344b6 in its own detached worktree, own pnpm install --frozen-lockfile (exit 0 in both). The engine fence (service-automation/src/engine.ts 0 files) was PM-verified and not redone.

Build first, and I did. Fresh worktree, no dist/. turbo run build --filter=@objectstack/plugin-approvals... --force: 23/23 tasks, 0 cached, 4m14s, exit 0; service-automation/dist/index.d.ts rewritten 18:33 and declaring listSuspendedRunsDurable at :2484. Everything below ran against that artefact. Every exit code captured right after a single redirected command; every Bash call carried its own cd.

Verdict: DOES NOT PASS — narrowly

Both round-1 blockers are fixed in the code: I could not advance a flow through any of the four B1 constructions, and the B2 discriminators rest on premises I re-measured as true. Two things are not what the PR says they are, and both sit on ranked blocking bars: the decisive P5 case has a phantom pin, and the returned-via-resubmit population the PR reports as served is refused by guard 3 on both the journal and rebuild paths. Direction is safe in every case — nothing advances — so the ask is narrow.

1. (blocking bar) The four B1 constructions — all refused at the real door; the decisive one has no real pin

Re-driven myself: real AutomationEngine + InMemorySuspendedRunStore, real ApprovalService, real registerApprovalNode, the same ObjectQL-dispatch double with orderBy honoured.

  • P5 (the verb's own designed flow, called twice): two_step; a1's approve continuation stranded by failing the a2 request insert once → RESUME_FAILED, repairable: true → restore restored: true at a1first call resumed: true, source: 'journal', round-2 request now pending at a2, run parked at a2retry refused INVALID_STATE: a newer approval request supersedes this one (guard 1; created_at .712 vs .722). a2 still pending, no mark ran, run not completed.
  • P3 (later node): refused (guard 1); a2 pending, marks empty, parked a2.
  • P3b (same node, next round): refused (guard 1; .730 vs .732); round 2 pending, 2 rows, marks empty.
  • P6 (recall in the revise window with cancelRun throwing → row recalled, run parked at wait_revision): refused by the recalled rebuild refusal, before guards 2/3; still 1 row, no new round, pause intact.
  • Reverse control: with M4+M5 both applied (both identity guards off) P5, P3 and P3b all advance — so the refusals above are the guards' doing, not the harness's.
  • Fail-closed, measured set: no listSuspendedRunsDurable → "cannot report WHERE"; listing empty → "parked at no node this engine can see"; row without flow_node_id/current_step → "records no approval node"; pause at a foreign node → PIN 7(a) and my resubmit probe (§3). Guard 2 is strict: a throwing hasSuspendedRun propagates untouched (engine contract at engine.ts:5017, loadSuspendedRunStrict). The pause survived all four refusals and then continued to completion.

PIN 7(b) — "a plain retry" — is a phantom. Replayed verbatim with the discarded values printed: rejectBranchThrows is set but two_step has no mark_rejected; decide is never called; restoreConsumedSuspension answers { restored: false, refusal: 'RUN_SUSPENDED', "… already resumable — nothing to restore" }; the "first call" is INVALID_STATE: request is pending — only a request whose recorded outcome…; the retry is the same message; r1 is pending before and after; void firstCall hides all of it. The pin never reaches guards 1–3 — it drives PIN 4(c)'s population twice. Under M4+M5, PIN 7 goes red through (a) only. The PR's stated population for it ("the first call consumes the re-armed pause; a second must not walk whatever the run parked on next") is false, and "the review's own reproductions are now reverse-control pins" is false for P5 — the case the PM called decisive. Same class as PIN 3 last round. Also: PIN 7(a)'s comment and the PR table call its row recalled; the row is returned (a recalled row cannot reach guard 3 on the rebuild path at all).

2. (blocking bar) The fourth case and the engine surface — HOLDS, both halves

listSuspendedRunsDurable is a public instance method at engine.ts:6537 (no modifier; AutomationEngine exported at index.ts:4), and the built dist/index.d.ts:2484 declares it with the identical Promise<Array<{ runId; flowName; nodeId; correlation? }>> shape the approvals interface declares. git diff --stat base..head -- packages/services/service-automation/ is empty. No contract fiction; typecheck exit 0 on the built closure.

3. (blocking bar) B2 — premises verified; recalled refusal real; the resubmit half is unreachable and "both stay journal-recoverable" is false for it

  • "Exactly one writer" of action: 'resubmit' — all 14 sys_approval_action inserts enumerated: 13 literal (submit, ooo_substitute, recall ×2, cancel, revise, reject, resubmit at :3589 once, reassign, remind, request_info, comment, escalate) and one variable-valued (:2687, action: input.decision), which :2579 validates to approve|reject first. Holds. recalled: 2 writers (:3174 recall, :4778 sweep), 3 behaviours (recall-on-pending resumes reject; recall-in-window calls cancelRun; the sweep resumes nothing). Holds.
  • recalled refusal: real, before any resume, and the message names cancelRun('<run>') (P6). recall-on-pending strand → journal decision: 'recall' → restore → verb: resumed: true, source: 'journal', mark_rejected ran. Journal-recoverable ✓.
  • returned via resubmit is NOT journal-recoverable through this verb. Measured end-to-end: sendBackreturned, run parked at wait_revision; resubmit stranded (round-2 insert fails once) → RESUME_FAILED, repairable: true, journal { branchLabel: 'resubmit', output: { resubmitted: true }, decision: 'resubmit' } ✓; restore → restored: true, re-armed at wait_revision (the engine's own "consumed at node 'wait_revision'"); raw.flow_node_id = 'review'guard 3 refuses: run … is parked at node 'wait_revision', not at request …'s own node 'review' — this pause is not the one this request's outcome was refused on — factually wrong for this population: it is exactly that pause. The rebuild path refuses identically, after the resolver has correctly returned resubmit and before anything acts on it. With guard 3 off (M4+M5 leg) the same continuation completes correctly on both paths (resumed: true, decision: 'resubmit', round 2 opened, parked at review) — so guard 3 is the only thing in the way, and its premise ("this request's own node") is wrong for a resubmit signal, whose pause sits at the revise-window node by construction. The returned+resubmit discriminator (M6, PIN 8(a)) is green over code the verb never acts on. The changeset, the docstring table and the PR body all state this population as served, and the operator gets a misleading refusal.

4. Harness — fix confirmed; the one sibling is §1

The double honours orderBy (order, not direction); PIN 6's isolated guard-1 case exact-matches the supersede message on a controlled created_at, and M4 turns exactly that case red. The remaining phantom in the file is PIN 7(b) — not a double behaviour, a construction that never reaches the code under test. Note, pre-existing and not this PR's: P3b is refused by guard 1 alone (same node, so guard 3 cannot help), and assertLatestForRun rests on millisecond created_at ordering with no tie-break — recall/resubmit already share that.

5. Mutation legs — all seven reproduce one-to-one

My own edits; each anchor asserted to occur exactly once as an exact substring in the form written; git hash-object delta + marker count; restore under trap … EXIT INT TERM, proven each time by the blob back at 8514677bd and git diff HEAD = 0.
M1 3429f44da → PIN 4 only · M2 b16f88198 → PIN 5 only · M3 e354fd158 → PIN 2 + 5 · M4 c1ebd7e56 → PIN 6 only · M5 797cbf36e → PIN 7 only · M6 49f440411 → PIN 8 only · M7 286030865 → PIN 8 only. Combined M4+M5 6425d1d7b: PIN 6 + 7 red; my P5/P3/P3b advance. Green baseline 8/8, exit 0.

6. PIN 3 and populations

PIN 3 asserts all four doors by name — verified. Stated populations match the pins for PINs 1–6 and 8, and 7(a) modulo recalledreturned; 7(b)'s does not (§1).

7. Merge — identity VERIFIED; the guard's premise reproduced

bc279a12a parents d43b8292c + 1375344b6. pinned.json: merge product = upstream side +10 lines (the two new rows), head = merge product, and --write at head is a fixpoint (0 diff). system-context.mdx is merge=os-regen (.gitattributes:154); the merge product carries flow.zod.ts:702 where main has :743 (the driver kept the branch's side on a line only main changed), and the census on the merge product is exit 1, 2 problems over 140 anchors / 105 sites, both on that anchor — stale as claimed. .githooks/pre-commitcheck-regen-pending.mjs defers a merge commit and refuses the next; head's regen diff vs the merge product is exactly 9 anchors (row 42's eight + flow.zod.ts:702→743); pnpm gen:system-context-census at head is a fixpoint (0 diff — not hand-edited). Census verdict string at head and base: character-identical (105 sites / 19 packages / 44 files / 140 anchors / 27 non-read / 19 rows / 2 unheld).

8. Figures and gaps

Base 793 pinned / 133 DEBT / 3 exempt; head 795 / 133 / 3; exit 0 both. node scripts/check-tenant-audit-census.mjs: base 221, head 222, 148 decidable, 23 prose figures held, exit 0 both. Suite 40 files / 683 tests, exit 0; typecheck exit 0, 8 / 324 / 27 held. Whole-tree at head, probe deleted and tree proven status 0 / diff 0 first: nul-bytes, test-source-alias, cross-package-test-inputs, where-matcher, objectql-double-limit, adr-0087-registration --base 1375344b6 --head bc0872bf0 — all exit 0. NOT MEASURED by me: check:type-check-debt answered exit 3 (its prerequisite refusal — I built the approvals closure, 23 of 71, not the workspace), so the dev's "0 after building" is not independently confirmed; check:single-claim-paths (token; not attempted); react-declaration-parity (no script of that name in package.json at this head; pre-existing per both prior measurements). None reported as a pass.

9. Prose guard

"Fail-closed in every direction" — measured set in §1, holds. "Exactly one writer" — measured set in §3, holds. "Both stay journal-recoverable" — false for returned-via-resubmit (§3), unqualified in the changeset, the docstring table and the PR body. "The review's own reproductions are now reverse-control pins" — false for P5 (§1).

Requested before re-review (narrow)

  1. Guard 3 for a resubmit signal. Two shapes I measured as sufficient; the dev chooses: (a) make the expected node signal-aware — for decision === 'resubmit' the pause belongs at the revise-window node the request's revise edge leads to (journal the node the engine reported consumed at strand time and compare to that; the rebuild path can derive it from the flow definition) — or (b) keep the refusal and make it honest: refuse resubmit strands with a message that says the verb cannot re-issue a resubmit, and strike "rebuilt as resubmit" / "both stay journal-recoverable" from the changeset, docstring and PR. Either way PIN 8 needs a leg through continueRestoredRun end-to-end, not the private resolver alone.
  2. Replace PIN 7(b) with a real retry pin — the P5 construction above (strand a1's approve by failing the a2 request insert once; restore; assert the first call resumed: true and round 2 pending at a2; retry refused; round 2 still pending; no mark) — and assert the first call rather than void it. Correct PIN 7(a)'s population comment and the PR row (returned, not recalled).
  3. Qualify the two prose claims in §9.

Tree left provably clean: review worktree at bc0872bf0, git status 0 lines, git diff HEAD 0 lines, approval-service.ts blob 8514677bd = HEAD, probe file deleted before any whole-tree gate, stash empty; scratch base worktree returned to 1375344b6 (0 status lines) and removed. Nothing pushed, undrafted, merged, or stashed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Claim — round 3 rework

Taking round 3 of the rework on this PR, continuing the existing claim on #15389.

  • Session: session_01XpTx2tbq3pZRYAdoGt6E6Y
  • Branch: claude/issue-15389-approval-restore-continuation (existing PR branch — no new PR)
  • Worktree: /home/user/objectstack-15966-r3 at bc0872bf0 (own worktree; the review worktrees objectstack-review-15966* are untouched)

Scope, exactly as the round-3 verdict (comment 5554014979) ranks it:

  1. Finding 1 — PIN 7(b) is a phantom: the construction never reaches guards 1–3, so P5 has no real pin. Either make it drive P5 through the real door with a proven red leg, or delete it and retract the claim. Plus an audit of the void <promise> shape across the pin file, with a firing control.
  2. Finding 2returned-via-resubmit is refused by guard 3 on both paths with an operator message that names a cause the code did not take. Correct the message, settle the path, correct "both stay journal-recoverable" in the changeset and PR body identically, re-aim the B2 discriminator.
  3. The NOT MEASURED list: check:type-check-debt (exit 3 was a refusal, not a pass) and check:single-claim-paths on a full build; react-declaration-parity reported as NOT WIRED (exit 2), not substituted.

⛔ Not re-opening anything round 2 settled (the four B1 refusals at the real door, the firing reverse control, listSuspendedRunsDurable public with engine.ts untouched, the single resubmit writer, the recalled refusal, the seven mutation legs, the merge-product staleness).


Generated by Claude Code

…d replace a phantom pin

Two things this PR claimed were not what it said they were. Both were in the
safe direction — nothing escalated — but a false claim is not shippable.

1. Guard 3 compared the run's parked node against the request row's own
   `flow_node_id`. That is right for `approve`, `reject`, `revise` and
   `recall`, all of which are issued at the request's own approval node. It is
   WRONG for `resubmit`: a resubmit is only reachable after a send-back moved
   the run to the revise window, and it resumes THAT pause down the
   `resubmit` back-edge. So a stranded resubmit re-arms at the revise window
   while the row still records the approval node, and guard 3 refused it — on
   both the journal and the rebuild paths — with a message telling the
   operator the pause was not this request's when it was exactly this
   request's. The expected node is now signal-aware
   (`ApprovalService.expectedPauseNode`), derived from the flow definition the
   same way `assertReviseEdge` already derives the revise window, fail-closed
   in every direction, and needing no automation surface that method did not
   already use. The refusal message now names the node the signal was issued
   from rather than "this request's own node".

   ⚠️ It widens what guard 3 admits for exactly one signal. Every other
   decision resolves byte-identically to the row's own node, so the
   recall-in-revise-window shape stays refused: its journalled signal is
   `recall`, not `resubmit`.

2. PIN 7(b) could not reach the code it named. It set the reject-branch throw
   for a flow with no `mark_rejected` node, never decided anything, restored a
   suspension that had never been consumed, and asserted a refusal that was
   only `request is pending` — then discarded the first call with `void`, which
   is what hid it. It passed on every input, and P5 therefore had no pin behind
   it. Replaced with the real construction: strand the first approval's
   `approve` continuation by failing the next round's request insert once,
   restore, ASSERT the first call resumes and parks the run at the second
   approval node, then retry and assert guard 1 refuses by exact message with
   the second node still pending and no branch run.

   The test double gains a one-shot insert failure lever, which is what makes a
   real strand reachable from a test; every leg that uses it asserts the lever
   was consumed, so a fixture that silently stopped firing reads as a failure
   rather than as a pass.

3. PIN 8 gained two legs through `continueRestoredRun` end to end — the
   resubmit replay on the journal path and on the rebuild path — because its
   discriminator was green over code the verb never reached. PIN 7(a)'s
   population comment is corrected: the row is `returned`, not `recalled` (a
   `recalled` row is refused before any guard runs).

4. The changeset's "every one of these is journal-recoverable" is replaced by a
   measured, named set, with the two by-design refusals named and the two
   shapes no pin covers stated as not claimed.

Part of #15389

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
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/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants