Skip to content

feat(service-automation): the flow end node honours outcome: 'refused' — a terminal refused run, distinct from failed - #18109

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-15788-flow-end-refused-outcome
Sep 14, 2026
Merged

os-project-manager merged 4 commits into
mainfrom
claude/issue-15788-flow-end-refused-outcome

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #15788

Lane (2) of the #14945 maintainer ruling 2′: the flow end node honours outcome: 'refused'.

The contract this is built against (lane 1, already on main)

Re-measured on origin/main at b5cbfef9c — a card split out of a sequenced ruling says "already landed" about the delivering branch, not about main:

declaration where
ExecutionStatus member refused packages/spec/src/automation/execution.zod.ts:38
outcome: z.enum(['completed','refused']).default('completed') packages/spec/src/automation/builtin-node-config.zod.ts:658
refinement — refused REQUIRES a message; a message on a completed end is refused as a silent no-op same file, :671-690
ExecutionLog.refusalMessage execution.zod.ts:369
AutomationResult.status gains refused, plus AutomationResult.refusalMessage packages/spec/src/contracts/automation-service.ts:405,430
TriggerFlowResponseSchema.data.status / .refusalMessage packages/spec/src/api/automation-api.zod.ts:356,382

packages/spec is untouched by this PR. Nothing in the implementation needed a spec change — the lane-1 contract was complete, including the refusalMessage run-row key and the wire members, so this lane is purely the producer half.

Two places in the tree stated, in words, that this was the missing half, and both are updated here rather than worked around:

The engine now produces it, so the writer, the reader's row gate and the stored column widen in this one change — which is the condition those notes set, not an exception to it.

What changed

  • executeNode's end branch. It opened with if (node.type === 'end') return; — the whole defect: a structural node with no executor and no descriptor, so this was the only place the outcome could be read and nothing read it. It now reads the PARSED config (lane 1's parseEndNodeConfig runs inside FlowNodeSchema, regions included, so outcome is defaulted and a refused with no message was already refused at the flow parse — no second door, no ??, no re-parse) and throws a FlowRefusalSignal, the twin of the existing FlowSuspendSignal.
  • One terminal shape, three producers. finishRefusedRun is called from execute(), resumeInternal and executeWithoutRetry. That chokepoint is not stylistic: this exact file lost successMessage (bug(service-automation): execute() never carries the flow author's successMessage / errorMessage — only resume() does, so a triggered run's friendly text is silently dropped #9414) and the durable pause (bug(service-automation): a retry attempt that PAUSES is recorded as failed and its suspension is never persisted — executeWithoutRetry has no isSuspendSignal arm #9510) by implementing them at one exit and not the others, which made a run's outcome a function of its ROUTE. A triggered run, a resumed screen flow and an attempt under strategy: 'retry' now answer identically.
  • The envelope. success: true (a refusal is a successful evaluation that says no), status: 'refused', refusalMessage, no error, no errorMessage, ⛔ no successMessage, ⛔ no runId. The retry ladder stops on it without a new branch — retryExecution already reads result.success as "this attempt did not fail, stop retrying", which is the true sentence here; ⛔ a refusal must never consume retry budget.
  • Persistence. RunRecord.refusalMessage + a new sys_automation_run.refusal_message column, written always (NULL included — recordTerminal is an upsert, so a rewritten row must CLEAR a refusal it no longer carries) and read back through loadTerminal / runRecordToLogEntry. ⛔ Not folded into error: text in error tells every reader — an operator, the Runs surface, a sweep filtering error IS NOT NULL — that the run broke. Same reason [finding] service-automation: the durable run-history row folds cancelled (and timed_out) into failed — after a restart the Runs surface cannot tell a cancelled run from a failed one #15223 stopped folding cancelled into failed.
  • Never resumed. A refusal writes no continuation, so resume() answers RUN_NOT_FOUND.
  • packages/plugin-approvals, comment only. Its RUN_STATUS_LIVENESS docblock said the service-automation vocabulary "excludes refused on purpose". This PR makes that false, so the paragraph is corrected in the same change. No behaviour moves: that map already classified refused as terminal, and it stays the authority for its own sweep.

The region boundary, made loud

An end declaring outcome: 'refused' inside a structured region is refused with a named message, at the same line where runRegion already refuses a durable pause. Left to propagate, the signal would unwind into the try_catch executor's own catch (err), which reads every throw as the try region FAILING — so an author's refusal would run the error path and the run would still record completed: the pre-#15788 silence with an extra step.

⛔ Nothing an author had is narrowed: before this PR an end in a region was a no-op whatever its outcome, so the shape being made loud has never once been honoured. Whether a refusal should instead PROPAGATE out of a region and terminate the run is a real question and ⛔ not one this lane rules on — the #14945 ruling says nothing about regions, and "prefer failing to falling back" decides the interim.

Before / after

Reproduce first: the pins were written and committed (f525fb87f) before any engine edit, and run against the unfixed tree.

run command result
BEFORE (unfixed engine) vitest run src/end-node-refused-outcome.test.ts 7 failed, 4 passed (11)
AFTER same file, + the region pin 12 passed (12)
AFTER pnpm --filter @objectstack/service-automation test 135 files, 1597 tests, all passing
AFTER pnpm --filter @objectstack/plugin-approvals test 45 files, 738 tests, all passing

The BEFORE failures were the shape of the defect, not of a broken harness:

AssertionError: expected undefined to be 'refused'
AssertionError: expected undefined to be 'Refused: Acme Corp is a confirmed dup…'
AssertionError: expected 'completed' to be 'refused'

The 4 that passed BEFORE are the fences, green on both sides on purpose: a plain end still completes with successMessage, an explicit outcome: 'completed' is the same completion, a genuinely failed run still reads failed (the discriminating control — without it, "the refusal path reads refused" would be consistent with an engine that had started calling everything refused), and a refused run was already not resumable because it writes no continuation.

The interpolation is the screen-description one, and that is pinned by mechanism

The ruling: the refusal message goes through the same interpolation a screen description gets — one implementation, ⛔ never a second template engine.

The implementation is interpolate() in packages/services/service-automation/src/builtin/template.ts, reached through the four-line coercion that screen-nodes.ts held in a local interp closure and read at screen.description (screen-nodes.ts:195 and :279). Those four lines are hoisted verbatim to template.ts as interpolateText; interp now delegates to it and the refusal path calls the same function. Same bytes in, same bytes out — the only change is where the lines live.

Asserting "it substitutes {record.name}" would be far too weak, so the pin drives both slots with six templates whose behaviour is specific to this interpolator and compares the two renderings for equality: dotted-path walk, numeric array indexing, the {$User.Id} context token, the CEL-mirrored numeric stdlib ({round(x)}, #11060), an unresolvable embedded token rendering as empty string, and an object-valued token JSON-serialized rather than [object Object] (#3450).

That pin's ability to FAIL is measured below, not assumed.

Reverse verification

Both legs mutate the committed tree, prove the mutation reached disk before reading any result, restore with git checkout HEAD -- path, and prove byte identity by blob hash. Both scripts carry trap … EXIT INT TERM with absolute paths; the trap is the crash convenience, the hash compare is the proof.

Leg 1 — remove the refusal branch (the fix itself).

HEAD blob           : 86fede46cfbdb50589a3568455ddeab05d2c632d
anchor count before : 1      inject count before : 0
anchor count after  : 0      inject count after  : 1
on-disk mutated     : 1d64fe18affedec59afaa230a4b7d92cf4c21951   (differs — not a no-op)
ABLATED TEST EXIT   : 1
  Tests  8 failed | 56 passed (64)
on-disk restored    : 86fede46cfbdb50589a3568455ddeab05d2c632d   RESTORE: byte-identical to HEAD
git diff HEAD / git status --porcelain : empty

Predicted direction before running: RED. The 8 that reddened are exactly the assertions about the new behaviour — the 6 defect pins, the interpolator-equality pin and the region pin. The set is the right one in both directions:

  • The 4 fences stayed green, because none of them depends on the branch that was removed. A fence that reddened here would mean the change had reached something the ruling names ⛔ do not touch.
  • All 52 suspended-run-store.test.ts cases stayed green, including the 4 new refusal_message ones, and that is correct rather than a gap: they drive ObjectStoreSuspendedRunStore with a RunRecord directly and never enter executeNode. They pin the persistence layer; leg 1 mutated the producer.
  • The mutation was on src/, and the test file imports ./engine.js from inside the same package, so vitest resolves it from source. The RED itself is the proof of that resolution path — a stale-dist reading would have stayed green.

Leg 2 — can the "one interpolator" pin actually fail? The refusal path's interpolateText(…) call was replaced with a plausible second template engine (a naive {token} substitution with dotted-path support — the kind a reviewer waves through).

anchor count after : 0      inject count after : 2
on-disk mutated    : 79dd7a425bce3252d526de885b1c8826451e089c   (differs)
SECOND-ENGINE TEST EXIT : 1
  × renders a refusal `message` byte-identically to a screen `description`
  AssertionError: refusal message for by {$User.Id}: expected 'by ' to be 'by usr_7'
  Tests  1 failed | 11 passed (12)
on-disk restored   : 86fede46cfbdb50589a3568455ddeab05d2c632d   RESTORE: byte-identical to HEAD

The second engine passed the obvious {record.name} probe and was caught at the context token — which is the whole reason the probe set is six templates and not one.

Clause-② re-derivation, from the DELIVERED diff

Clause-②: yes — which is what the claim predicted, re-derived here from the built output rather than inherited.

The test is reachability from the published entry (index.ts re-exports plus the package's exports/files) plus any new key on a published payload. @objectstack/service-automation publishes ["dist","README.md","CHANGELOG.md"] with one entry, ./dist/index.d.ts. After pnpm --filter @objectstack/service-automation build:

carrier evidence
RunRecord gains refusalMessage?: string dist/index.d.ts:743, and type RunRecord is in the entry's export list — a NEW KEY on an already-published payload, the mandatory yes
TerminalRunStatus widens 4 members to 5 dist/index.d.ts:694-696, type TerminalRunStatus exported; the runtime value ships too (dist/index.js: TERMINAL_RUN_STATUSES = ["completed","failed","cancelled","timed_out","refused"])
SysAutomationRun gains the refusal_message field and the refused option dist/index.d.ts:9339, SysAutomationRun exported as a value

⛔ The terminal status is NOT what carries it: refused was already a declared ExecutionStatus member, so status is an existing key taking a newly-legal value. The carrier is the new key.

Discriminating controls, so the probe is not just reporting "everything in my diff is published":

probe hits in dist/index.d.ts reads as
interpolateText — new in this diff, internal to builtin/ 0 in the diff, NOT published
isTerminalRunStatus — exported from engine.ts, not re-exported from the barrel 0 exists in source, NOT on the published surface
RunRecord — published before this diff 18 positive control: the probe can see published symbols
zzzNotASymbol 0 negative control

A probe that answered ">0" for interpolateText and isTerminalRunStatus would have been measuring file text rather than the published surface. It did not.

The changeset is graded minor, as the ruling grades this lane — never patch.

Gates

Derived mechanically from the real change set, not from the dispatch list, and reconciled with --ran:

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  → 9 path(s) vs merge base 3aaea3879 · 65 command(s) derived
node scripts/pm/dispatch-gates.mjs --ran ran.txt --repo objectstack-ai/objectstack
  → Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
    EXIT CODES — all 65 accounted famil(ies) carry one, so the NOT-MEASURED count
    above is DERIVED from them.

Denominator: 65 derived / 65 run / 0 NOT MEASURED / 0 UNRUN. Three needed a prerequisite the first pass did not have and were re-run after clearing it, ⛔ not recorded as failures:

  • check:dual-build-cjs-loads and check:i18n exited 3 — PREREQUISITE NOT MET ("nothing was measured") until turbo run build --filter='./packages/*' --filter='./packages/*/*'; both exit 0 after.
  • check:type-check-debt exited 3 twice: once for the same unbuilt closure, then on an OOM at the --max-old-space-size=4096 this container prefixes onto heavy commands — the gate prints that ceiling itself ("the caller's NODE_OPTIONS, which is tighter"). Re-run without the tightened cap: exit 076/80 workspace packages type-checked, 5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number.

Also run, beyond the derived set:

  • pnpm --filter @objectstack/service-automation typecheck — exit 0, and its check:test-typecheck leg compiles the test layer, so the new pins are type-checked rather than merely executed.
  • Consumer sweep, downstream direction (--filter '...@objectstack/service-automation', the direction a contract widening lands in): 18 packages typecheck green — cli, client, client-react, the four connectors/*, plugin-approvals, both triggers/*, verify, qa/dogfood, qa/downstream-contract, the four examples/*, and service-automation itself.
  • pnpm lint (= eslint . --no-inline-config) over the whole repo: exit 0, no findings. Run at 60ca35a2f, after the final commit — no narrowing to justify.

origin/main was merged at 3aaea3879 through scripts/pm/os-regen-merge.sh; it left no generated artifact to regenerate and no os-regen-pending deferral, and pnpm install --frozen-lockfile was re-run afterwards per the stale-artefact rule.

Acceptance notes

Out of scope, noted rather than filed — each names who would meet it:


Generated by Claude Code

…ting it

Reproduction first: these fail against the current engine, where
`executeNode` opens with `if (node.type === 'end') return;`.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
The engine terminates the run with the `refused` outcome, renders the
authored `message` through the SAME interpolator a screen `description`
uses, persists both on the run record, and never resumes it.
`successMessage` and the paused-run `silent` contract are untouched.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-approvals, @objectstack/service-automation, touching 21 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/src/approval-service.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via refusalMessage (symbol, a field of interface ExecutionLogEntry; a field of interface RunRecord))

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/src/approval-service.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 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 — 7 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 66aa2d98d6d1ea1b12d179e3ec82e9f53697e6b8packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 66aa2d98d6d1ea1b12d179e3ec82e9f53697e6b8

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

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

  • Served-tier: 1/1 CONTRACT_REVIEW_TIER

Independent clause-② review of PR #18109 (card #15788, lane 2 of the #14945 maintainer ruling 2′, comment 5548735593). Reviewed head: 60ca35a2f2ea33de5b256900f57f37471a4a8643 (re-read from the PR immediately before posting; unchanged). Merge base with main: 3aaea38794aa2d439744e34f1760a1027674b541. Everything below was measured in this session on a rebuilt tree, not read off the PR body.

① Derived judgments — acceptance set and public surface

Declaration under test: Clause-②: yes. Floor: a new exported symbol reachable from the published entry, or a new key on an already-published payload. Rebuilt @objectstack/service-automation (exports["."]./dist/index.d.ts, files = dist, README.md, CHANGELOG.md; src/index.ts has no export *, so only named re-exports reach the entry).

item measured judgment
RunRecord.refusalMessage?: string dist/index.d.ts:743; RunRecord is in the entry's export list Correct. New key on an already-published payload. This alone is the mandatory yes.
TerminalRunStatus widens 4→5 dist/index.d.ts:694-696; TerminalRunStatus is in the entry's export list Correct as a type widening. ⚠️ The PR body's sub-claim that "the runtime value ships too" is wrong: TERMINAL_RUN_STATUSES is bundled inside dist/index.js but is not re-exported from src/index.ts, is absent from the d.ts export list, and 'TERMINAL_RUN_STATUSES' in await import(entry) is false. Presence in a bundle is not reachability. The carrier stands on the exported type only. Does not change the yes.
SysAutomationRun gains refusal_message + the refused option dist/index.d.ts:9339; SysAutomationRun exported as a value Correct.
Terminal status is not the carrier refused already at packages/spec/src/automation/execution.zod.ts:38 Correct. status is an existing key taking a newly-legal value.
Control interpolateText 0 word-boundary hits in the d.ts; runtime import: absent Discriminates. New in the diff, not published.
Control isTerminalRunStatus 0 hits; export function at engine.ts:1367 but no barrel re-export; runtime import: absent Discriminates. In the diff's package, not published — the exact case the "not the word export" rule exists for.
Positive control RunRecord / negative control zzzNotASymbol 18 / 0 Probe sees published symbols and nothing else.
FlowRefusalSignal / finishRefusedRun 0 / 1 (private finishRefusedRun; in the class declaration) Neither is reachable. The private member line is a declaration artefact, not a surface.

Acceptance set, verified against the code and the pins:

  • Envelope. finishRefusedRun returns success: true, status: 'refused', refusalMessage, output, durationMs, summary and nothing else — no error, errorMessage, successMessage, runId. Pinned in end-node-refused-outcome.test.ts on all three routes.
  • Retry ladder stops. retryExecution reads if (result.status === 'paused') return result; if (result.success) return result; — a refusal is success: true, so no retry budget is spent. The retry pin asserts attempts === 2 (the ladder really ran) and status === 'refused'.
  • Three producers, one chokepoint. execute(), resumeInternal and executeWithoutRetry each test isRefusalSignal first and call finishRefusedRun. This is the bug(service-automation): execute() never carries the flow author's successMessage / errorMessage — only resume() does, so a triggered run's friendly text is silently dropped #9414 / bug(service-automation): a retry attempt that PAUSES is recorded as failed and its suspension is never persisted — executeWithoutRetry has no isSuspendSignal arm #9510 shape closed at every exit, not one.
  • Same interpolation as a screen description. screen-nodes.ts:197 and :281 render description through interp, which now delegates to template.ts#interpolateText; the end branch in executeNode calls the same interpolateText(endConfig.message, variables, context). The equality pin drives both slots with six interpolator-specific templates. One implementation, no second engine.
  • Persistence. refusal_message written always (?? null) on the upsert, read back ?? undefined; isTerminalRunStatus (the reader's row gate) reads the widened TERMINAL_RUN_STATUSES; retention onlyWhen widened in the same commit. Pinned in suspended-run-store.test.ts (4 new cases, including the NULL-clears-stale-refusal case).
  • Never resumed. No continuation written; resume() answers RUN_NOT_FOUND. Pinned.
  • packages/spec untouched. git diff --name-only <merge-base> HEAD -- packages/spec = 0 files. The lane-1 contract consumed (ExecutionStatus.refused, EndConfigSchema refinement at builtin-node-config.zod.ts:658-690, ExecutionLog.refusalMessage, AutomationResult.refusalMessage, parseEndNodeConfig applied inside FlowNodeSchema at flow.zod.ts:239) is on the merge base.
  • No test weakened. 0 removed lines across the two test files; no .skip / .only / .todo / xit / xdescribe added anywhere in the diff.

Runs in this session (fresh container, dependency closure built first): end-node-refused-outcome.test.ts + suspended-run-store.test.ts 64/64; full @objectstack/service-automation suite 135 files / 1597 passed; @objectstack/plugin-approvals 45 files / 738 passed (one file first failed on an unbuilt sibling @objectstack/trigger-record-change in my container — environment, not the PR; green after building it); package typecheck exit 0 including the test-typecheck leg; eslint --no-inline-config over the 8 changed source files exit 0; check-clause2-carriers.mjs --pair 18109 exit 0; check-widening-tells.mjs --declaration yes --diff exit 0. All check runs on the head are success or skipped, including both Check Changeset runs.

② Semver grading

.changeset/refused-end-node-outcome.md declares @objectstack/service-automation: minor. Matches. An additive widening of a published surface under Clause-②: yes takes at least minor (#16361); patch would be refused and is not what is declared. @objectstack/plugin-approvals carries no changeset entry and needs none: its only change is a docblock paragraph, no published surface moves, and Check Changeset is green on the head.

③ Boundary flags

Non-blocking notes, for the record: plugin.ts:138 and :787 still describe the retention scope as two members (pre-existing, named by the dev, not made false here); the docs-drift bot flags content/docs/automation/flows.mdx, and that page's "Ending a run" section (:511-553, written by lane 1) describes exactly the envelope this PR now produces, so the doc becomes true rather than drifted.

Independence

  • Implemented-by: claude/issue-15788-flow-end-refused-outcome
  • Reviewed-by: session_01YXhJmfUEkpbQsz2xex3mw4

Verdict

PASS. The Clause-②: yes declaration is correct on the delivered diff, the minor grade matches, the refusal envelope and its three producers are as claimed and pinned, the interpolation is the screen-description one by mechanism, packages/spec is untouched, and no test was skipped or weakened. One correction to the PR body's evidence table is recorded above (the runtime TERMINAL_RUN_STATUSES value is not reachable from the entry; the exported type is what widens) — it changes no code and no verdict.


Generated by Claude Code

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

needs:contract-review cleared from BOTH carriers — clause-② contract review PASS.

Record of governing verdict: comment 5658765796 on this PR, judging head 60ca35a2f2ea33de5b256900f57f37471a4a8643 — the head this PR still points at, re-read immediately before this clear (⚠️ a record binds to the head it names; had the head moved, re-verifying would have been necessary and ⛔ not sufficient).

  • Served-tier: 1/1 CONTRACT_REVIEW_TIER — the constant's NAME. ⛔ Not a model identifier: AGENTS.md forbids one in a comment and C7 exits 4 on a model-shaped token (landed 66e34d14d, PR fix(pm): the Served-tier line names the tier constant, never a model identifier — AGENTS.md's comment rule (#18060) #18087; references/contract-review.md :29/:56). This is the first record this seat commissioned under that spelling.
  • Implemented-by: claude/issue-15788-flow-end-refused-outcome — a mode:subagent dev, named by its BRANCH.
  • Reviewed-by: session_01YXhJmfUEkpbQsz2xex3mw4 — a DIFFERENT identity, so the independence pair holds and this is ⛔ not a self-review.
  • Commissioned as a separate session under the maintainer ruling 「你可以直接派fable复核」; ⛔ an in-session subagent carrying a model parameter does not lift the tier, which is exactly what C7 refuses.

The review corrected a real error in this PR's own evidence table, and it is worth reading. The body claimed that for TERMINAL_RUN_STATUSES "the runtime value ships too". Measured on a rebuilt tree, that is wrong: the array is bundled inside dist/index.js, but it is not re-exported from src/index.ts, is absent from the .d.ts export list, and 'TERMINAL_RUN_STATUSES' in await import(entry) reads false.

⚠️ Presence in a bundle is NOT reachability from the published entry. The Clause-②: yes therefore stands on RunRecord.refusalMessage? (a new key on an already-published payload — the mandatory limb) and on the exported type TerminalRunStatus widening 4→5, ⛔ not on a runtime value that never reaches the entry. The verdict and the minor grade are unchanged.

The review also independently confirmed the two escalations already filed by this seat — #18110 (a refused subflow child rolled up as success) and #18112 (the Decision on refusal propagation out of a region) — and verified packages/spec is untouched (0 files against the merge base), that all three terminal producers route through one finishRefusedRun, and that no test was skipped or weakened (0 removed lines across both test files).


Generated by Claude Code

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

2 participants