Skip to content

spec: ApprovalDecisionResult's docblock does not record that a stranded decision's four facts ride the ERROR body — execution item 2 of #13807's batch #37 ruling #15439

Description

@os-warren

Blocked-by: #13807

Filed by the domain:services execution seat (session 03324ae2-0f5b-5ad2-8a2e-cf4aaff5a909, seat post #6021) as the spec half left unowned by the maintainer's 2026-09-04 ruling on #13807 (director batch #37, verbatim 「同意」 on 1B · 2及, issuecomment-5541861039). Cross-lane request into the domain:spec lane. ⛔ domain:*, type and priority are triage's — this seat does not produce them.

What the ruling owes here

The ruling's execution list, item 2, verbatim:

The ApprovalDecisionResult contract docblock records the declared posture: a finalised decision whose resume fails throws with the decision and run identified, never a half-state; the fields are the published way to read it.

The services half is implemented in PR #15436 (draft, head 13b58ed7d, under Clause-② contract review): serviceResume carries status, and a stranded decision's 500-class RESUME_FAILED error body gains finalized, decision, runId and a repairable flag.

Why it is not in that PR

packages/spec/src/contracts/approval-service.ts is a single-owner lane the implementing seat is read-only in — declared in its claim comment (issuecomment-5541965534) before it started, and honoured. It reported the item rather than editing across the boundary. ⇒ Filed here so the ruling's item 2 has an owner instead of evaporating between two lanes.

What is and is not owed

⚠️ The contract TYPE needs no new fields. Measured on origin/main: ApprovalDecisionResult (packages/spec/src/contracts/approval-service.ts, ~:622) already declares request, finalized, decision, runId?, resumed? and resumeError?, and its resumed docblock already states the #4420 posture — "A decision that finalises a flow-bound request and CANNOT resume its run throws rather than returning resumed: false — a recorded decision whose flow never advances is the zombie half-state of #4420."

The four facts ride the error body, not the success shape. So what is owed is prose, not a field: the docblock should record that when that throw happens, the failure is now readable — finalized / decision / runId / the repairable flag are the published way to read the already-declared posture, rather than the caller being left with a bare 500.

⛔ Do not add the four fields to ApprovalDecisionResult. That would declare a success shape that never carries them.

Sequencing

Blocked-by: #13807 is deliberate: the sentence describes fields that do not exist on main until PR #15436 lands. Writing it earlier would put a false statement in the contract — the same defect class this repo keeps finding, where a surface documents itself as covered by something that has not shipped (see PR #15365's own account of a p0 seam that did exactly that).

⚠️ Whoever takes this: re-read the docblock on the then-current origin/main rather than inheriting the :622 anchor — PR #15436 adds ~52 lines of docblocks to approval-service.ts in plugin-approvals, and this repo's line refs rot fast enough that the #13807 dev found a card ref that had drifted ~223 lines and named the wrong mechanism.

Refs: #13807 (the ruled card) · PR #15436 (the services half) · #4420 (the posture this records) · #13937 / PR #15237 (the shape-4 ruling that excluded compensation) · #15221 (the generic resume door, measured NOT the same seam).

Activity

  1. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    分诊路由 + 状态更正(本评论来自分诊座位)· R+150

    domain:spec · documentation · priority:p2 · pm:queue → pm:blocked。

    为什么改状态

    卡面自己写着「Blocked-by: #13807 是刻意的:这句话描述的字段在 PR #15436 落地前不存在于 main,提前写就是往契约里放一句假话」。⇒ 卡面的内容说它被挡,而标签说它可派发 —— 两者不能同时对,而可派发的那一半会让某个席位真的去写那句假话。

    现验(origin/main,fetch 后实测,2026-09-04T19:3xZ):

    git grep -rn "repairable" origin/main -- packages/plugins/plugin-approvals/src packages/spec/src/contracts/approval-service.ts
    → 零命中
    

    阳性对照(同一次读取,证明该路径可读、该词不是拼错):git grep -rn "RESUME_FAILED" origin/main -- packages/plugins/plugin-approvals/src 命中 approval-service.ts:2750、:2768 与三处测试。⇒ PR #15436 尚未落地,前置未满足。

    解锁判据(可执行,⛔ 别用「PR 看起来合了」代替)

    git grep -q "repairable" origin/main -- packages/plugins/plugin-approvals/src   # exit 0 ⇒ 可解锁
    

    Blocked-by: #13807 / PR #15436 · Unlock-action: 上式为真后回 pm:queue,并在当时的 origin/main 上重读 docblock。

    落点与标签

    落点 packages/spec/src/contracts/approval-service.ts(单一属主车道)⇒ domain:spec;交付物是 docblock 散文 ⇒ documentation。⚠️ 卡面最要紧的一条约束:⛔ 不要把那四个字段加到 ApprovalDecisionResult 上 —— 它们走的是 error body,加进成功形状等于声明一个永远不带它们的形状。⚠️ 且卡面已预告 :622 锚会漂(PR #15436 给该文件加约 52 行 docblock),按文本定位。

    p2:契约散文缺一段,无运行期影响 ⇒ 不是 p1;但它是维护者裁决(#13807 batch #37,「同意」)的执行项 2,⛔ 不是可有可无的整理 ⇒ 也不是 p3。


    Generated by Claude Code

  2. claude commented on Sep 8, 2026

    @claude
    Contributor

    Unlock scan — released to pm:queue. Both limbs of the 放行双查 answered.

    domain:spec execution seat, session session_016N6xmWt5hYm94ffVEwGH8x, 2026-09-08T11:02:53Z. One label write, read back: pm:blocked → pm:queue. No assignee to clear (this card was never dispatched), so no Release: line is owed.

    The release rule has two limbs and ⛔ neither is skippable: ① release only against the condition carried by the most recent conversion comment, never an earlier blocker on the thread; ② refuse when the card carries a merged PR newer than that comment — the card may have moved on after the condition was written.

    Limb ① — Blocked-by: #13807, which is closed / completed. Condition met.

    Limb ② fired: PR #15436 merged 2026-09-04T22:12:26Z, after the 19:28Z conversion comment — and it is the very PR this card was filed alongside (Fixes #13807, the services half of ruling item 2).

    ⚠️ ⭐ So the premise may already be discharged, and this seat could not settle it either way from a count. Reading packages/spec/src/contracts/approval-service.ts on origin/main ce8bfc9d6: ApprovalDecisionResult at :716 already declares finalized, decision, runId and resumed, and its docblock already says, in as many words, that "A decision that finalises a flow-bound request and CANNOT resume its OWN run throws rather than returning resumed: false", naming resumeError / resumeFailure as the published way to read it — and it cites #15556 and the #16472 ruling, so it has been maintained since this card was filed.

    ⇒ That looks like the ruling's item 2 already recorded. ⛔ This seat is not closing another lane's request on a 50-line read of a 927-line file.

    Binding instruction for whoever claims this: the FIRST action is to falsify the premise, against the ruling's item-2 wording quoted in the card body. If the docblock already records the posture, ⛔ stop and report — do not manufacture an edit. A dev that stops with evidence of zero work needed is a good outcome here, not a wasted dispatch.


    Generated by Claude Code

  3. claude commented on Sep 8, 2026

    @claude
    Contributor

    Claim: domain:spec execution seat, /pm-dispatch spec@objectstack. Round R1, wave 13 (2026-09-08T21:58Z).

    Session: session_016N6xmWt5hYm94ffVEwGH8x
    Branch: claude/issue-15439-approval-decision-result-stranded-posture
    Worktree: /home/user/objectstack-issue-15439
    Domain: domain:spec — assigned by triage (5545490398); packages/spec/src/contracts/approval-service.ts is a single-owner lane. ⛔ This seat does not write domain:*.
    File surface: packages/spec/src/contracts/approval-service.ts (the ApprovalDecisionResult docblock only) plus one .changeset/*.md if one is owed. ⛔ Nothing else without stopping and reporting.
    Container & model: claude-opus-5, passed explicitly (SKILL.md:528).

    Clause-②: no
    The deliverable is docblock prose recording a posture the merged services half already
    implements. No accept set moves, no field is added, no export changes. 拉回已声明契约 ⇒ 常规档.
    ⛔ Fence, and it is the card's own loudest instruction: ⛔ do not add finalized / decision /
    runId / the repairable flag to ApprovalDecisionResult.
    Those four ride the ERROR body;
    putting them on the success shape would declare a shape that never carries them — that WOULD
    widen, and it is the failure this card exists to prevent. If the work seems to need it, stop
    and report.

    Thread-read: the card body and both comments read to the end (5545490398 triage routing + the pm:blocked conversion; 5584133022 the unlock scan). ⭐ This card carries NO clause-② ruling. It does carry a maintainer ruling it executes — #13807, director batch #37, 「同意」 on 1B · 2及 — whose item 2 is quoted verbatim in the card body.

    ⭐⭐ The binding first action, quoted verbatim from the unlock scan — ⛔ this is not optional and a zero-work outcome is a GOOD result:

    Binding instruction for whoever claims this: the FIRST action is to falsify the premise, against the ruling's item-2 wording quoted in the card body. If the docblock already records the posture, ⛔ stop and report — do not manufacture an edit. A dev that stops with evidence of zero work needed is a good outcome here, not a wasted dispatch.

    The reason that instruction exists, also verbatim: reading the file at ce8bfc9d6, the previous seat found ApprovalDecisionResult at :716 already declaring finalized, decision, runId and resumed, with a docblock already saying "A decision that finalises a flow-bound request and CANNOT resume its OWN run throws rather than returning resumed: false" and naming resumeError / resumeFailure as the published way to read it — and it "⛔ is not closing another lane's request on a 50-line read of a 927-line file." ⇒ Settle it properly, then act or stop.

    ⚠️ ⛔ Do not inherit any line anchor from this card. The card itself warns the :622 anchor rots (PR #15436 added ~52 docblock lines to approval-service.ts in plugin-approvals), and the unlock scan already found it at :716 on a later ref. Locate by symbol and by text. ⭐ And if you cite anything in the prose you write, cite it by symbol or verbatim snippet, never by line number — this lane landed PR #16956 today repointing seven rotted path:NNN citations, and #16960 is filed on three more off by up to 5,677 lines.

    Blocker verified released, ⛔ not assumed from the label: #13807 is closed / completed, and PR #15436 is merged (state: closed, merged: true, draft: false). ⇒ The fields the sentence describes now exist on main, which is exactly the sequencing the card demanded — writing it earlier would have put a false statement in the contract.

    Serial constraints cleared: contracts/approval-service.ts is disjoint from packages/spec/src/api/websocket.zod.ts (#15243), packages/spec/liveness/book.json (#16136, same wave), and packages/spec/src/system/translation.zod.ts (#16772, parked).
    Patrol anchor #9857 (sweep 2026-09-08T19:46:27Z, re-read 21:53Z): zero rows for this lane, control firing.

    fold-or-serial: dispatched in parallel with #16136, ⛔ not folded — reasoning recorded on that card's claim.


    Generated by Claude Code

  4. claude commented on Sep 8, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 15439,
      "status": "done",
      "branch": "claude/issue-15439-approval-decision-result-stranded-posture",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16983",
      "premise_still_valid": true,
      "falsification_test": {
        "verdict": "PREMISE HELD — one half of ruling item 2 was recorded, the other was not. Work was owed and is done.",
        "measured_on": "origin/main 50dc2141a, ApprovalDecisionResult located BY SYMBOL (git grep), never by the card's rotted :622 anchor",
        "half_A_throws_never_a_half_state": "DISCHARGED before this PR. The `resumed` docblock already read: 'A decision that finalises a flow-bound request and CANNOT resume its OWN run throws rather than returning `resumed: false` - a recorded decision whose flow never advances is the zombie half-state of #4420.'",
        "half_B_with_the_decision_and_run_identified_the_fields_are_the_published_way_to_read_it": "NOT DISCHARGED. Nothing in ApprovalDecisionResult said what the throw carries. Whole-file scan (927 lines, not a 50-line read): the only docblock mentions of `finalized` outside member declarations sit in the ResumeFailureReport docblock - a DIFFERENT symbol, and there it is a '⛔ Not StrandedDecisionDetails' exclusion note drawing a boundary AWAY from the decide door's own throw, not a record of it. The ruling names the ApprovalDecisionResult docblock.",
        "why_the_unlock_scan_could_not_settle_it": "resumeError / resumeFailure ARE 'the published way to read' a resume failure - but of the OTHER event. They carry the #16472 family's failure told BEHIND an answer that still succeeded. A stranded decision does not answer at all; it throws, and its four facts ride the ERROR body. Reading the first as discharging the second is the conflation the new paragraph now closes in the file."
      },
      "summary": "Falsified the premise first, on current origin/main, by symbol. Half A of the #13807 batch-#37 item 2 was already recorded; half B was not, so one paragraph of prose was owed and written. Appended it to the `resumed` docblock of ApprovalDecisionResult: the 500-class RESUME_FAILED carries `finalized`, `decision`, `runId` and `repairable` on its ERROR body as StrandedDecisionDetails (@objectstack/types), the status code does not move, and the four facts must never be added to this success shape - the card's fence, now written into the file. Verified in the MERGED code, not inferred: ApprovalService.resumeRecordedOutcome throws strandedDecisionFailure(..., { finalized: true, decision, runId, repairable }) with `repairable` derived from the engine's 'stranded' discriminator, and rest-server's handleApprovalError spreads strandedDecisionDetails(err) into the RESUME_FAILED body. No member added, no export moved. Every citation in the new prose is a SYMBOL - no path:NNN reference introduced.",
      "files_changed": [
        "packages/spec/src/contracts/approval-service.ts (+20, docblock prose only, inside ApprovalDecisionResult.resumed)",
        ".changeset/approval-decision-result-stranded-throw-is-readable.md (new, @objectstack/spec: patch)"
      ],
      "changeset_decision": "OWED, decided on BOTH halves. Half 1 alone says NO and is wrong here: src/contracts/approval-service.ts is not a *.zod.ts, so it is outside packages/spec files[]. Half 2 decides it: after `pnpm --filter @objectstack/spec build`, the changed sentence 'What that throw carries is published' greps to dist/contracts/index.d.ts and dist/contracts/index.d.mts, and dist IS in files[]. Controls that discriminate - POSITIVE: pre-existing docblock prose from the same interface ('zombie half-state of #4420') is in the same two dist files, so contract docblocks do reach dist; NEGATIVE: text living only in a sibling *.test.ts ('the retired predicate is gone from the contract file') is present in src and ABSENT from dist, so the grep is not matching everything. No other files[] entry carries it (json-schema, liveness, prompts, api-surface, llms.txt, spec-changes.json all answer zero).",
      "tests": "All at final commit 025709234 (git rev-parse --short HEAD), exit codes captured by redirect BEFORE any pipe. pnpm lint (repo-wide, `eslint . --no-inline-config`) EXIT=0 - the full union ran, so no narrowing is claimed and none is owed. pnpm --filter @objectstack/spec test EXIT=0 - 'Test Files 467 passed (467) / Tests 13100 passed (13100)'. pnpm --filter @objectstack/spec typecheck EXIT=0. pnpm --filter @objectstack/spec check:test-typecheck EXIT=0 - 'OK - @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json'. pnpm --filter @objectstack/spec build EXIT=0 - 'check-dts-emitted: 34/34 declared declaration file(s) present'. pnpm --filter @objectstack/spec check:generated EXIT=0 - 'All 15 generated artifacts are up to date' (nothing to regenerate; check:docs, check:api-surface, check:authorable-surface all green). Pin test that reads THIS contract as text: vitest run src/contracts/resume-failure-report.pin.test.ts EXIT=0, 6/6 - and its three textual invariants re-measured after the edit still hold (retired predicate still absent from the file, `resumeError?: string;` still x4, `resumeFailure?: ResumeFailureReport;` still x2). Derived families from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (70 commands, derived AFTER the changeset existed): 69 EXIT=0. PR-shape guards run locally against the real body and commit list: check-partof-closing-keyword EXIT=0 ('1 commit message(s) carry no card-relation trailer'), check-clause2-carriers --pair 16983 EXIT=0 ('the clause-② declaration is readable in the fixed spelling and both carriers agree, and its diff carries no widening tell'). Control-char self-scan (grep -naP over the edited file and the changeset) - no match. No ablation was owed: this diff adds no guard and changes no executable behaviour, so there is nothing that could be made to fail.",
      "not_measured": "pnpm check:dual-build-cjs-loads EXIT=3 = its own PREREQUISITE NOT MET code, explicitly 'NOT a pass: nothing was measured'. It reads built output for ~55 unbuilt packages; clearing it is a whole-repo `pnpm build`, which is CI's run. A docblock inside a .ts file emits no CJS or ESM entry point, so this diff cannot move what it reads. Two sibling gates first exited 3 for the same reason and were then MEASURED GREEN after building their named prerequisites (@objectstack/formula, @objectstack/lint, @objectstack/objectql): check:doc-formula-expressions EXIT=0 and check:lean-entry-closure EXIT=0 ('2 published condition(s) measured from a real load'). Exit 3 is read as NOT MEASURED throughout - never as a pass, never as a finding.",
      "model": "claude-opus-5 (harness-stamped: 'You are powered by the model named Opus 5. The exact model ID is claude-opus-5.')",
      "final_commit": "025709234 (0257092344564d16e642e94cc19c9381c8edddd2)",
      "mcp_calls": "0 - the whole run used the repo-scoped REST channel (probe GET /repos/... answered 200) plus git and local scripts; zero mcp__github__* calls, including the PR creation (POST /pulls, draft=true, HTTP 201).",
      "ci_status": "in_progress - reported at draft-PR time per the dispatch contract; no CI waiting was performed.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the new paragraph is prose that NOTHING pins. resume-failure-report.pin.test.ts states in its own header that anything about StrandedDecisionDetails is '⛔ Not pinned, deliberately' - that exclusion belongs to the #16472 family it gates, so this sentence can rot the way the docblock line references this lane repointed today did. The natural pin (assert the `resumed` docblock names all four facts, with the producer's four keys READ from @objectstack/types rather than re-spelled) is a NEW TEST FILE, and this card's scope is the docblock plus one changeset - so it was not written. Who will touch this file next: this PR is the carrier, and the PM reads this at ACCEPT. Not filed as a card because the dispatch reserves filing to the PM.",
        "noted, not filed: the four facts reach the REST error body as TOP-LEVEL members (`...(code === 'RESUME_FAILED' && stranded ? stranded : {})` in handleApprovalError), whereas the automation resume door publishes its sibling ResumeFailureDetails inside `error.details`. Two carriers of the same vocabulary at two different depths. This is an observation about a shipped shape the #13807 ruling chose, not a defect - recorded so the PM has it, and deliberately not filed."
      ]
    }

    Generated by Claude Code

  5. claude commented on Sep 8, 2026

    @claude
    Contributor

    ACCEPT — PR #16983, reviewed in seat at 2026-09-08T22:46Z

    domain:spec execution seat, session session_016N6xmWt5hYm94ffVEwGH8x. Review run against origin/main eda26cef0. ⛔ Every reading below is this seat's own.

    ⭐⭐ The falsification test returned a SPLIT verdict — which is why it had to be run

    The previous seat could not settle whether the docblock already recorded ruling item 2, and refused to close another lane's request on a partial read. That caution was right, and the answer is finer than either "already done" or "work owed":

    ruling item 2, in halves state before this PR
    "a finalised decision whose resume fails throws… never a half-state" ✅ already discharged
    "…with the decision and run identified; the fields are the published way to read it" ❌ not discharged

    Verified in the tree, not from the report:

    ⭐ And the conflation the previous seat nearly made is named precisely: resumeError / resumeFailure are "the published way to read" a resume failure — of the other event, the #16472 family's failure told behind an answer that still succeeded. A stranded decision does not answer at all. Reading the first as discharging the second is exactly the trap, and the new paragraph closes it in the file.

    ① The posture, verified in the MERGED code rather than inferred

    The dispatch said to read it, not infer it from a PR title. Measured on origin/main:

    plugin-approvals/src/approval-service.ts:3203   throw strandedDecisionFailure(
                                            :3206     { finalized: true, decision, runId, repairable },
    rest/src/rest-server.ts:12649                    const stranded = strandedDecisionDetails(err);
    types/src/stranded-decision.ts:67                export interface StrandedDecisionDetails {
    

    ⇒ The four facts really do ride the error body, repairable really is the engine's 'stranded' discriminator carried through, and the REST door really merges it into the RESUME_FAILED body — presence-gated, per its own comment. The prose describes shipped code.

    ② Three-dot diff (git diff origin/main...0257092344)

    2 files, +31 / −0 — pure addition: one paragraph inside ApprovalDecisionResult.resumed's docblock, plus a changeset. No member added, no export moved, nothing deleted.

    ⭐ The card's fence is now written INTO the file, not just into the card:

    ⛔ They are not members of this result and must never be added to it: they ride the ERROR, so declaring them here would declare a success shape that never carries them. ⛔ Nor are they {@link resumeFailure}…

    ⇒ The next author meets the prohibition at the point of temptation. That is the "a correction must not create the next card" discipline applied forward.

    ⭐ Every citation is a symbol. git diff | grep -E '\.(ts|tsx|mjs|json|mdx):[0-9]+' over the whole patch → no match. Zero new line-number anchors, in the same shift this lane removed seven and filed #16960 on three more.

    ③ Checks, read by name

    31 names on 0257092344: 0 failing, 16 running at review time. ⛔ Not a landing.

    ④ Tier fuse

    131 assistant envelopes, all claude-opus-5, zero others — harness-stamped.

    ⑤ Pair predicate and governed surface, FINAL file list

    --pair 16983 → exit 0. check-governed-merges.mjs --test → not governed.

    The changeset — half 1 said NO and was wrong

    contracts/approval-service.ts is not a *.zod.ts, so half 1 alone answers no. Half 2 decided it: after a build, the added sentence greps to dist/contracts/index.d.ts and .d.mts, and dist is in files[]. Controls that discriminate — positive: pre-existing docblock prose from the same interface reaches the same two dist files, so contract docblocks do ship; negative: text living only in a sibling *.test.ts is in src and absent from dist. Every other files[] entry answers zero.

    ⇒ ⚠️ This is the fourth time today that half 1 alone would have produced the wrong answer on this lane, and the second on this exact file kind. skip-changeset would have shipped an unrecorded change to a published declaration file.

    Handled from the out-of-scope list

    Landing

    ⛔ Not landed. Enqueue when all 31 names close green. No deviation claimed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions