Phase Outcomes: no supersede path when a posted outcome's premise turns out false #45

Open
opened 2026-08-14 13:01:51 +00:00 by jbr870 · 49 comments
Owner

PREQ: Phase Outcome supersede — a typed correction path for a false terminal record

Created: 2026-08-26

History. This issue began as a retrospective finding (rank 9 of 13 in #37). Its original body
recorded the evidence below and proposed two parts; this PREQ replaces that body after a
requirements pass on 2026-08-26, reviewed by a Tier 3 five-lens adversarial panel. The original
evidence is preserved verbatim under Current state. Sibling case for decisions: #36.

Problem

A phase lead posts a terminal Phase Outcome, and then learns that something it states is false.
There is no way to say so. phase-outcome:v1 is immutable by design (forge-contract §8) — editing one
"silently corrupts the audit chain" — so the only act available is an untyped prose comment that no
reader and no helper connects to the record it corrects.

Every other durable record in the suite already has a correction path. This is the only one that does not:

Record Correction path today
decision-resolution:v1 latest-wins lookup (§9), plus withdrawn / superseded-by statuses
sreq:v1, test-plan:v1, qa-report:v1 mutable / latest-wins — post a new one
phase-outcome:v1 none — immutable, and its findings[] carry no annulment

A re-run already posts a new outcome at the next ordinal, and the phase lookup already returns the
highest one — so "what phase am I in" self-corrects on a re-run. What has no path is the record's
content: a false finding stays live, and a Pending Decision raised on a false premise stays open.

Users:

  • Primary: the phase lead (a skill run, attended or unattended) that discovers, after posting,
    that its own terminal record states something untrue — and today can only leave it standing.
  • Primary: the operator who finds a false outcome later, from a different session, and today
    has no deliberate, recorded act available.
  • Secondary: the downstream gatedecisions-settled-check.md and the phase lookup — which
    today cannot tell a live premise from an annulled one.
  • Secondary: the later reader (human or retrospective) for whom the forge record is the only
    account of what happened. This project declares observability: mode: none with the reason "run
    traceability is the forge record (Phase Outcomes, deliverables), not logs"
    — so a knowingly-false
    Phase Outcome is a hole in the only audit surface this repo has.

Current state — the recorded incident (feature-230, /dev:technical-plan):

During /technical-plan on #230 the five-reviewer expert panel returned nothing. Between 12:21 and
12:34 local the lead spawned seven agents (both fable and opus), ran two trivial control probes,
and used sentinel-file wait loops, then concluded the subagent transport was broken. It wrote the
SREQ solo, posted it, and posted the Phase Outcome at 12:37:13.

The panel results arrived at 12:37:47 — thirty seconds later. They carried two blocking
concerns the solo plan had missed. The SREQ had to be rewritten, re-posted, and a correction comment
filed; the already-posted Phase Outcome carried a finding ("panel could not be run") that was now
false. ~25 minutes of rework, and the record was briefly wrong.

The SREQ recovered on its own — deliverables are latest-wins. The Phase Outcome did not, and the
"correction comment" that was filed is not a record: nothing resolves it, no helper links it to the
outcome it corrects, and a reader who finds the outcome does not find the correction.

A second instance, observed while gathering this PREQ (2026-08-26). The Tier 3 panel that reviewed
this very requirement spawned five lenses; two of them went idle without delivering their findings.
Read as "the panel returned nothing", that is the feature-230 shape exactly — and would have written
the same false claim into this PREQ's own Phase Outcome. A single ping to each recovered both, and
both returned substantive concerns that changed this document. The pattern is not a one-off.

Why prevention alone is not enough, and why the stated prevention would not have worked. The lead
in the feature-230 incident did account for its seven agents — two control probes, sentinel-file wait
loops, both models — and concluded they were unrecoverable. A rule requiring each spawned agent to be
"retired, reported, or declared unrecoverable" is satisfied by exactly that reasoning, and the panel
results still arrive 34 seconds later. Prevention only bites when unrecoverable is an act, not a
judgement
; and even then, the cases a 34-second window cannot catch still need a way back.

Proposed Solution

Two halves of one failure — recovery and prevention.

1. A supersede path (recovery). A typed, attributed record that annuls part or all of a posted
Phase Outcome. It names its target — one or more findings, or the whole outcome — and a reason. It is
always forward: the annulled record is never edited or deleted, and stays readable as history. Both
actors may post one: a skill (so an unattended run can correct itself rather than hand off on a
record it knows is false), and the operator, at any later time from any session, through a command
in the shape of /dev:resolve.

Annulment is honoured in one place: the suite's Phase Outcome read helper. The contract already
forbids a skill from re-implementing scan-and-resolve (§9 — "Every skill consumes it via
decision_resolution.read_state. No skill re-implements scan-and-resolve."
). Putting annulment behind
read_all / read_latest binds every compliant consumer by construction, including ones written
before this feature existed, rather than by an enumerated list that goes stale.

What the readers then do:

  • Annulled findings are absent from the set a consumer acts on; the original record stays intact.
  • The phase lookup walks past an annulled outcome to the newest surviving one — the same carve-out
    that already exists for next_state: skipped, not a new concept. With no survivor it reports
    backlog, which is already the suite's answer for an issue with no Phase Outcome at all.
  • The decisions-settled gate stops counting Pending Decisions belonging to an annulled outcome: a
    question raised on a premise that no longer holds has nothing to answer.

A supersede is itself correctable, by the rule the suite already uses. The newest supersede naming
a given target is the one that counts — latest-wins per target, exactly as decision-resolution:v1
behaves under §9. A supersede posted in error is corrected by posting a corrected one, and a duplicate
or concurrent supersede resolves without a special case. This deliberately avoids recreating, one level
up, the very gap this feature closes.

2. A pre-post roll-call (prevention). Every terminal Phase Outcome carries a roll-call — one
entry per agent the phase spawned, each disposed reported or terminated. A phase that spawned
nothing declares an explicitly empty roll-call; absence is refused and never read as "spawned
none"
, so omission stops being an escape route. The posting helper refuses a missing or malformed
roll-call.

The teeth are in what is not an accepted disposition: there is no unrecoverable. You terminate the
agent — so a late report cannot arrive — or you wait. "I judged the transport broken" stops being
something the record can say.

Enforcement is deliberately on the record's shape, not the harness. A helper that enumerated live
agents would need harness-specific knowledge in the shipped tier, which the stack-agnostic rule
forbids. The shape check cannot prove a roll-call complete — that limit is accepted knowingly — but
it converts a silent judgement into a written, attributable claim, which is the difference that matters.

Scope: Standard — both halves, because they are prevention and recovery for one failure, and
because half of each was already shown to be insufficient alone.

User Stories

  • As a phase lead, I want to correct my own terminal record when its premise turns out false, so
    that an unattended run does not hand off on a statement I already know is wrong.
  • As an operator, I want a single deliberate command to annul a false outcome found weeks later
    from a different session, so that correcting the record is a recorded act and not an untyped comment.
  • As a later reader of the issue, I want a false statement to be visibly annulled at the point I
    read it, so that I can trust the record without cross-checking every prose comment on the issue.
  • As a downstream gate, I want annulled outcomes and their decisions excluded from what I act on,
    so that a run neither blocks on, nor proceeds from, a premise that no longer holds.
  • As a phase lead about to post, I want to be stopped while an agent I spawned is still outstanding,
    so that I cannot record "X could not be run" while X is still running.

Acceptance Criteria

Reading an annulled record

  • Given a posted Phase Outcome containing a false finding, when a supersede naming that finding is
    posted, then reading the issue through the suite's Phase Outcome read helper reports that finding
    as annulled, carrying its reason and the identity of the record that annulled it.
  • Given an annulled finding, when any consumer obtains the issue's findings through the read helper,
    then the annulled finding is not in the set the consumer acts on — and the original Phase Outcome
    comment is still present, unedited, and still readable as history.
  • Given a consumer written before this feature that reads Phase Outcomes through the read helper,
    when it runs against an issue carrying supersede records, then it reads without error and, with no
    change to its own code, no longer acts on annulled data.

Effect on pipeline state

  • Given an outcome-level supersede, when the current pipeline phase is looked up, then it resolves
    to the newest surviving outcome's next_state; the annulled outcome is walked past, exactly
    as next_state: skipped already is.
  • Given an issue whose every Phase Outcome has been annulled, when the current pipeline phase is
    looked up, then it reports backlog — the same result the suite already gives an issue that has
    no Phase Outcome at all.
  • Given a finding-level supersede, when the pipeline phase is looked up, then it is unchanged —
    including when every finding in the outcome has been annulled individually. Moving the phase
    requires annulling the outcome.
  • Given an annulled outcome that carries open blocking Pending Decisions, when the decisions-settled
    gate runs, then those decisions no longer block.

Posting and correcting a supersede

  • Given any supersede record, when it is read, then it names who posted it (the producing skill, or
    the operator), exactly what it annuls, and a non-empty reason.
  • Given a false outcome from an earlier run, when the operator invokes the supersede command from a
    different session naming the target and a reason, then the record is posted and the target reads
    as annulled.
  • Given a supersede posted in error, when a later supersede naming the same target is posted, then
    the later one is what counts — the same latest-wins rule the suite already applies to decision
    resolutions.
  • Given a supersede whose target does not exist — an unknown outcome, or a finding not present in
    the named outcome — when it is posted, then it is refused, naming the bad target.
  • Given a supersede missing its reason or its attribution, when it is posted, then it is refused,
    naming what is missing.

Roll-call

  • Given any terminal Phase Outcome, when it is posted, then it carries a roll-call: one entry per
    agent the phase spawned, each disposed reported or terminated, or an explicitly empty
    roll-call when the phase spawned none.
  • Given a terminal Phase Outcome with no roll-call at all, when the post is attempted, then it is
    refused — absence is never read as "spawned none".
  • Given a roll-call entry disposed as anything other than reported or terminated — including
    unrecoverable — when the post is attempted, then it is refused.

Out of Scope

  • The general decision withdrawal path (#36). This feature only stops decisions belonging to an
    annulled outcome from blocking. A decision mooted by anything else — a superseding QA round, a
    declaration change — stays #36's problem.
  • Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1). Already latest-wins, and the
    recorded incident's SREQ recovered on its own.
  • Editing or deleting a posted Phase Outcome. Remains a contract violation (§8). This feature adds a
    forward correction path and never a mutation.
  • A roll-call that sees live agents. Would require harness-specific knowledge in the shipped helper
    tier, breaching the stack-agnostic rule. The gate checks the record's shape instead, and the resulting
    limit — a roll-call cannot be proved complete — is accepted knowingly.
  • Migrating history. Records posted before this feature simply carry no supersede; nothing is
    rewritten. An old outcome can still be annulled going forward, because a supersede is a new record.

Dependencies

  • forge-contract.md §8 (immutability) and §9 (the lookup rule) — this feature extends both. §7's
    reader test governs whether it can stay additive within phase-outcome:v1 or forces a :v2 bump;
    a bump is expensive (cross-version reads are not supported) and should be avoided if semantics allow.
  • _shared/procedures/phase-outcome.mdpost, read_all, read_latest are where annulment is
    produced and honoured, including the existing skipped walk-back this reuses. Centralising it here is
    what makes the compatibility criterion above true.
  • _shared/procedures/decisions-settled-check.md — the gate that must stop counting an annulled
    outcome's decisions.
  • /dev:resolve — the precedent for the operator-facing command shape (structured mode, explicit
    target ID, rationale) and the existing consumer of a finding via finding_ref.
  • _shared/procedures/wait-discipline.md §1a/§1b — the roll-call is the post-time counterpart to
    its spawn-time rules; the two must read as one discipline, not two competing ones.
  • Adapter-neutral. Must work on local-fs, tea-cli, glab-cli and gh-cli through the existing
    contract operations. No new adapter primitive should be needed.
  • Sibling: #36 (decisions). No ordering constraint — neither blocks the other.
  • No external systems of record. The forge is the only store, already contracted.

Timeline

Milestone Date Notes
Requirements complete 2026-08-26 Tier 3 panel, 5 lenses, 34 concerns routed
Development complete
QA complete
UAT approved

Notes

  • Constraints: the portability baseline (POSIX glue; helper tier bash ≥ 3.2, jq, git); no new
    adapter primitive; avoid a phase-outcome:v1:v2 bump if the semantics allow, per §7's reader test.

  • Settled during the panel round (2026-08-26):

    • A late report arriving after an agent was recorded terminated is exactly what the supersede
      path is for — the recovery case working as designed, not a separate mechanism.
    • Any posted Phase Outcome may be superseded, terminal or not. "Terminal" qualifies only which
      outcomes must carry a roll-call.
    • "Within its own run" was dropped as a constraint on a skill's supersede. It is not externally
      observable, and attribution already records who posted the record. It remains intent, not a rule.
  • Deferred to /dev:technical-plan (panel detail, deliberately not settled in a PREQ): the ordering
    key for "newest surviving outcome"; whether "spawned agents" counts transitive children; the precise
    definitions of reported and terminated and whether they are exclusive; the required fields of a
    roll-call entry and of a supersede record (which fix what "malformed" means); the attribution value
    used for the operator; whether one supersede may target findings across multiple outcomes; and the
    enumeration of terminal next_state values.

  • Key decisions (2026-08-26):

    1. Both halves ship together — prevention and recovery for one failure. Rejected: supersede alone
      (leaves the cause in place), roll-call as text only (§1a is already such a rule, and the incident
      happened with it in place).
    2. A supersede names its target — findings or a whole outcome. Rejected: findings-only (no path
      for a wrong next_state), outcome-only (annuls true findings alongside the false one).
    3. Skill and operator may both post one — so an unattended run can self-correct, and a
      months-later discovery is still correctable.
    4. The phase lookup walks past an annulled outcome, reusing the existing skipped carve-out; with
      no survivor it reports backlog. Rejected: forcing a replacement outcome (heavy for a one-line
      correction), leaving the label untouched (record and label then disagree).
    5. An annulled outcome's decisions stop blocking. Rejected: routing them to #36 (not started —
      that is the 22-hour failure #36 was filed for), and refusing to annul while decisions are open
      (deadlocks when the blocking decision is the false thing).
    6. The roll-call is enforced on the record's shape, not on live agents — the only portable
      enforcement. unrecoverable is deliberately not an accepted disposition; that omission is the teeth.
    7. Annulment is honoured inside the read helper, not by an enumerated consumer list — three lenses
      found that an enumerated list makes the compatibility promise and the exclusion promise contradict.
    8. A supersede is itself correctable, latest-wins per target. Rejected: leaving it out of scope
      (two lenses noted this recreates the original gap one level up, on the same "no recorded instance"
      reasoning that left it open before), and one-supersede-per-target (makes an error permanent).
    9. A phase that spawned no agents declares an empty roll-call; absence is refused. Rejected:
      absence-means-none (the evasion is one omission wide).
# PREQ: Phase Outcome supersede — a typed correction path for a false terminal record **Created:** 2026-08-26 > **History.** This issue began as a retrospective finding (rank 9 of 13 in #37). Its original body > recorded the evidence below and proposed two parts; this PREQ replaces that body after a > requirements pass on 2026-08-26, reviewed by a Tier 3 five-lens adversarial panel. The original > evidence is preserved verbatim under *Current state*. Sibling case for decisions: #36. ## Problem A **phase lead** posts a terminal Phase Outcome, and then learns that something it states is false. There is no way to say so. `phase-outcome:v1` is immutable by design (forge-contract §8) — editing one "silently corrupts the audit chain" — so the only act available is an untyped prose comment that no reader and no helper connects to the record it corrects. Every other durable record in the suite already has a correction path. This is the only one that does not: | Record | Correction path today | |---|---| | `decision-resolution:v1` | latest-wins lookup (§9), plus `withdrawn` / `superseded-by` statuses | | `sreq:v1`, `test-plan:v1`, `qa-report:v1` | mutable / latest-wins — post a new one | | **`phase-outcome:v1`** | **none** — immutable, and its `findings[]` carry no annulment | A re-run already posts a new outcome at the next ordinal, and the phase lookup already returns the highest one — so *"what phase am I in"* self-corrects on a re-run. What has no path is the record's **content**: a false finding stays live, and a Pending Decision raised on a false premise stays open. **Users:** - **Primary:** the **phase lead** (a skill run, attended or unattended) that discovers, after posting, that its own terminal record states something untrue — and today can only leave it standing. - **Primary:** the **operator** who finds a false outcome later, from a different session, and today has no deliberate, recorded act available. - **Secondary:** the **downstream gate** — `decisions-settled-check.md` and the phase lookup — which today cannot tell a live premise from an annulled one. - **Secondary:** the **later reader** (human or retrospective) for whom the forge record is the only account of what happened. This project declares `observability: mode: none` with the reason *"run traceability is the forge record (Phase Outcomes, deliverables), not logs"* — so a knowingly-false Phase Outcome is a hole in the only audit surface this repo has. **Current state** — the recorded incident (feature-230, `/dev:technical-plan`): > During `/technical-plan` on #230 the five-reviewer expert panel returned nothing. Between 12:21 and > 12:34 local the lead spawned seven agents (both fable and opus), ran two trivial control probes, > and used sentinel-file wait loops, then concluded the subagent transport was broken. It wrote the > SREQ solo, posted it, and posted the Phase Outcome at 12:37:13. > > **The panel results arrived at 12:37:47 — thirty seconds later.** They carried two blocking > concerns the solo plan had missed. The SREQ had to be rewritten, re-posted, and a correction comment > filed; the already-posted Phase Outcome carried a finding ("panel could not be run") that was now > false. ~25 minutes of rework, and the record was briefly wrong. The SREQ recovered on its own — deliverables are latest-wins. The Phase Outcome did not, and the "correction comment" that was filed is not a record: nothing resolves it, no helper links it to the outcome it corrects, and a reader who finds the outcome does not find the correction. **A second instance, observed while gathering this PREQ (2026-08-26).** The Tier 3 panel that reviewed this very requirement spawned five lenses; **two of them went idle without delivering their findings.** Read as "the panel returned nothing", that is the feature-230 shape exactly — and would have written the same false claim into this PREQ's own Phase Outcome. A single ping to each recovered both, and both returned substantive concerns that changed this document. The pattern is not a one-off. **Why prevention alone is not enough, and why the stated prevention would not have worked.** The lead in the feature-230 incident *did* account for its seven agents — two control probes, sentinel-file wait loops, both models — and concluded they were unrecoverable. A rule requiring each spawned agent to be "retired, reported, or declared unrecoverable" is satisfied by exactly that reasoning, and the panel results still arrive 34 seconds later. Prevention only bites when *unrecoverable* is an **act, not a judgement**; and even then, the cases a 34-second window cannot catch still need a way back. ## Proposed Solution Two halves of one failure — recovery and prevention. **1. A supersede path (recovery).** A typed, attributed record that **annuls** part or all of a posted Phase Outcome. It names its target — one or more findings, or the whole outcome — and a reason. It is always **forward**: the annulled record is never edited or deleted, and stays readable as history. Both actors may post one: a **skill** (so an unattended run can correct itself rather than hand off on a record it knows is false), and the **operator**, at any later time from any session, through a command in the shape of `/dev:resolve`. **Annulment is honoured in one place: the suite's Phase Outcome read helper.** The contract already forbids a skill from re-implementing scan-and-resolve (§9 — *"Every skill consumes it via `decision_resolution.read_state`. No skill re-implements scan-and-resolve."*). Putting annulment behind `read_all` / `read_latest` binds every compliant consumer by construction, including ones written before this feature existed, rather than by an enumerated list that goes stale. What the readers then do: - Annulled findings are absent from the set a consumer acts on; the original record stays intact. - The **phase lookup walks past** an annulled outcome to the newest surviving one — the same carve-out that already exists for `next_state: skipped`, not a new concept. With no survivor it reports `backlog`, which is already the suite's answer for an issue with no Phase Outcome at all. - The **decisions-settled gate stops counting** Pending Decisions belonging to an annulled outcome: a question raised on a premise that no longer holds has nothing to answer. **A supersede is itself correctable, by the rule the suite already uses.** The newest supersede naming a given target is the one that counts — latest-wins per target, exactly as `decision-resolution:v1` behaves under §9. A supersede posted in error is corrected by posting a corrected one, and a duplicate or concurrent supersede resolves without a special case. This deliberately avoids recreating, one level up, the very gap this feature closes. **2. A pre-post roll-call (prevention).** Every terminal Phase Outcome carries a **roll-call** — one entry per agent the phase spawned, each disposed `reported` or `terminated`. A phase that spawned nothing declares an **explicitly empty** roll-call; **absence is refused and never read as "spawned none"**, so omission stops being an escape route. The posting helper refuses a missing or malformed roll-call. The teeth are in what is *not* an accepted disposition: there is no `unrecoverable`. You terminate the agent — so a late report cannot arrive — or you wait. "I judged the transport broken" stops being something the record can say. Enforcement is deliberately on the **record's shape, not the harness**. A helper that enumerated live agents would need harness-specific knowledge in the shipped tier, which the stack-agnostic rule forbids. The shape check cannot *prove* a roll-call complete — that limit is accepted knowingly — but it converts a silent judgement into a written, attributable claim, which is the difference that matters. **Scope:** Standard — both halves, because they are prevention and recovery for one failure, and because half of each was already shown to be insufficient alone. ## User Stories - As a **phase lead**, I want to correct my own terminal record when its premise turns out false, so that an unattended run does not hand off on a statement I already know is wrong. - As an **operator**, I want a single deliberate command to annul a false outcome found weeks later from a different session, so that correcting the record is a recorded act and not an untyped comment. - As a **later reader of the issue**, I want a false statement to be visibly annulled at the point I read it, so that I can trust the record without cross-checking every prose comment on the issue. - As a **downstream gate**, I want annulled outcomes and their decisions excluded from what I act on, so that a run neither blocks on, nor proceeds from, a premise that no longer holds. - As a **phase lead about to post**, I want to be stopped while an agent I spawned is still outstanding, so that I cannot record "X could not be run" while X is still running. ## Acceptance Criteria **Reading an annulled record** - [ ] Given a posted Phase Outcome containing a false finding, when a supersede naming that finding is posted, then reading the issue through the suite's Phase Outcome read helper reports that finding as annulled, carrying its reason and the identity of the record that annulled it. - [ ] Given an annulled finding, when any consumer obtains the issue's findings through the read helper, then the annulled finding is not in the set the consumer acts on — and the original Phase Outcome comment is still present, unedited, and still readable as history. - [ ] Given a consumer written before this feature that reads Phase Outcomes through the read helper, when it runs against an issue carrying supersede records, then it reads without error and, with no change to its own code, no longer acts on annulled data. **Effect on pipeline state** - [ ] Given an outcome-level supersede, when the current pipeline phase is looked up, then it resolves to the newest **surviving** outcome's `next_state`; the annulled outcome is walked past, exactly as `next_state: skipped` already is. - [ ] Given an issue whose every Phase Outcome has been annulled, when the current pipeline phase is looked up, then it reports `backlog` — the same result the suite already gives an issue that has no Phase Outcome at all. - [ ] Given a finding-level supersede, when the pipeline phase is looked up, then it is unchanged — including when every finding in the outcome has been annulled individually. Moving the phase requires annulling the outcome. - [ ] Given an annulled outcome that carries open blocking Pending Decisions, when the decisions-settled gate runs, then those decisions no longer block. **Posting and correcting a supersede** - [ ] Given any supersede record, when it is read, then it names who posted it (the producing skill, or the operator), exactly what it annuls, and a non-empty reason. - [ ] Given a false outcome from an earlier run, when the operator invokes the supersede command from a different session naming the target and a reason, then the record is posted and the target reads as annulled. - [ ] Given a supersede posted in error, when a later supersede naming the same target is posted, then the later one is what counts — the same latest-wins rule the suite already applies to decision resolutions. - [ ] Given a supersede whose target does not exist — an unknown outcome, or a finding not present in the named outcome — when it is posted, then it is refused, naming the bad target. - [ ] Given a supersede missing its reason or its attribution, when it is posted, then it is refused, naming what is missing. **Roll-call** - [ ] Given any terminal Phase Outcome, when it is posted, then it carries a roll-call: one entry per agent the phase spawned, each disposed `reported` or `terminated`, or an explicitly empty roll-call when the phase spawned none. - [ ] Given a terminal Phase Outcome with no roll-call at all, when the post is attempted, then it is refused — absence is never read as "spawned none". - [ ] Given a roll-call entry disposed as anything other than `reported` or `terminated` — including `unrecoverable` — when the post is attempted, then it is refused. ## Out of Scope - **The general decision withdrawal path (#36).** This feature only stops decisions belonging to an *annulled outcome* from blocking. A decision mooted by anything else — a superseding QA round, a declaration change — stays #36's problem. - **Correcting deliverables** (`sreq:v1`, `qa-report:v1`, `test-plan:v1`). Already latest-wins, and the recorded incident's SREQ recovered on its own. - **Editing or deleting a posted Phase Outcome.** Remains a contract violation (§8). This feature adds a forward correction path and never a mutation. - **A roll-call that sees live agents.** Would require harness-specific knowledge in the shipped helper tier, breaching the stack-agnostic rule. The gate checks the record's shape instead, and the resulting limit — a roll-call cannot be *proved* complete — is accepted knowingly. - **Migrating history.** Records posted before this feature simply carry no supersede; nothing is rewritten. An old outcome can still be annulled going forward, because a supersede is a new record. ## Dependencies - **`forge-contract.md` §8 (immutability) and §9 (the lookup rule)** — this feature extends both. §7's reader test governs whether it can stay additive within `phase-outcome:v1` or forces a `:v2` bump; a bump is expensive (cross-version reads are not supported) and should be avoided if semantics allow. - **`_shared/procedures/phase-outcome.md`** — `post`, `read_all`, `read_latest` are where annulment is produced and honoured, including the existing `skipped` walk-back this reuses. Centralising it here is what makes the compatibility criterion above true. - **`_shared/procedures/decisions-settled-check.md`** — the gate that must stop counting an annulled outcome's decisions. - **`/dev:resolve`** — the precedent for the operator-facing command shape (structured mode, explicit target ID, rationale) and the existing consumer of a finding via `finding_ref`. - **`_shared/procedures/wait-discipline.md` §1a/§1b** — the roll-call is the post-time counterpart to its spawn-time rules; the two must read as one discipline, not two competing ones. - **Adapter-neutral.** Must work on `local-fs`, `tea-cli`, `glab-cli` and `gh-cli` through the existing contract operations. No new adapter primitive should be needed. - **Sibling:** #36 (decisions). No ordering constraint — neither blocks the other. - **No external systems of record.** The forge is the only store, already contracted. ## Timeline | Milestone | Date | Notes | |-----------|------|-------| | Requirements complete | 2026-08-26 | Tier 3 panel, 5 lenses, 34 concerns routed | | Development complete | | | | QA complete | | | | UAT approved | | | ## Notes - **Constraints:** the portability baseline (POSIX glue; helper tier bash ≥ 3.2, `jq`, `git`); no new adapter primitive; avoid a `phase-outcome:v1` → `:v2` bump if the semantics allow, per §7's reader test. - **Settled during the panel round (2026-08-26):** - A **late report arriving after an agent was recorded `terminated`** is exactly what the supersede path is for — the recovery case working as designed, not a separate mechanism. - **Any posted Phase Outcome may be superseded**, terminal or not. "Terminal" qualifies only which outcomes must carry a roll-call. - **"Within its own run" was dropped as a constraint** on a skill's supersede. It is not externally observable, and attribution already records who posted the record. It remains intent, not a rule. - **Deferred to `/dev:technical-plan` (panel detail, deliberately not settled in a PREQ):** the ordering key for "newest surviving outcome"; whether "spawned agents" counts transitive children; the precise definitions of `reported` and `terminated` and whether they are exclusive; the required fields of a roll-call entry and of a supersede record (which fix what "malformed" means); the attribution value used for the operator; whether one supersede may target findings across multiple outcomes; and the enumeration of terminal `next_state` values. - **Key decisions (2026-08-26):** 1. **Both halves ship together** — prevention and recovery for one failure. Rejected: supersede alone (leaves the cause in place), roll-call as text only (§1a is already such a rule, and the incident happened with it in place). 2. **A supersede names its target** — findings *or* a whole outcome. Rejected: findings-only (no path for a wrong `next_state`), outcome-only (annuls true findings alongside the false one). 3. **Skill and operator may both post one** — so an unattended run can self-correct, and a months-later discovery is still correctable. 4. **The phase lookup walks past an annulled outcome**, reusing the existing `skipped` carve-out; with no survivor it reports `backlog`. Rejected: forcing a replacement outcome (heavy for a one-line correction), leaving the label untouched (record and label then disagree). 5. **An annulled outcome's decisions stop blocking.** Rejected: routing them to #36 (not started — that is the 22-hour failure #36 was filed for), and refusing to annul while decisions are open (deadlocks when the blocking decision *is* the false thing). 6. **The roll-call is enforced on the record's shape, not on live agents** — the only portable enforcement. `unrecoverable` is deliberately not an accepted disposition; that omission is the teeth. 7. **Annulment is honoured inside the read helper**, not by an enumerated consumer list — three lenses found that an enumerated list makes the compatibility promise and the exclusion promise contradict. 8. **A supersede is itself correctable, latest-wins per target.** Rejected: leaving it out of scope (two lenses noted this recreates the original gap one level up, on the same "no recorded instance" reasoning that left it open before), and one-supersede-per-target (makes an error permanent). 9. **A phase that spawned no agents declares an empty roll-call**; absence is refused. Rejected: absence-means-none (the evasion is one omission wide).
Author
Owner

Test Plan: phase-outcome-supersede (issue #45)

Validation cases derived from the PREQ alone, before any design exists. They name only what the PREQ
names and assert outcomes a person can observe. Lanes are deliberately absent — an absent lane means
e2e-browser by default, and /dev:technical-plan 2.6b assigns the real lanes once the design is known.

Prerequisites

The state the scenarios need. How each state is produced is a design decision; the PREQ requires only
that each be reachable.

  • A feature issue on a forge the suite supports, on which Phase Outcomes can be posted and read
  • At least three Phase Outcomes on one issue, so "walk past one" and "newest surviving" are
    distinguishable from "the only one"
  • One Phase Outcome carrying two or more findings, at least one of which is to be annulled while
    the others stand
  • One Phase Outcome carrying an open, blocking Pending Decision
  • An issue with exactly one Phase Outcome, so the all-annulled case is reachable
  • An issue with no Phase Outcome at all, as the control for the phase-lookup fallback
  • A consumer of Phase Outcomes that predates this feature and is not modified during the run —
    used unmodified, to check the compatibility promise
  • A phase run that spawns at least one agent, and a phase run that spawns none
  • Operator access from a session other than the one that posted the outcome being corrected

Required Test Data

  • Issue A: three Phase Outcomes, the middle one carrying two findings
  • Issue B: one Phase Outcome only, carrying one blocking Pending Decision
  • Issue C: no Phase Outcomes (control)
  • A reason string for each supersede, and one empty reason for the refusal case
  • A target reference that does not exist, and a finding ID belonging to a different outcome

Test Scenarios

Scenario 1: A false finding reads as annulled

Acceptance criterion: "Given a posted Phase Outcome containing a false finding, when a supersede naming that finding is posted, then reading the issue through the suite's Phase Outcome read helper reports that finding as annulled, carrying its reason and the identity of the record that annulled it."

  1. On issue A, read the Phase Outcomes and note the two findings on the middle outcome
  2. Post a supersede naming one of those findings, with a reason
  3. Read the issue's Phase Outcomes again through the same read path
  4. Verify: the named finding is reported as annulled
  5. Verify: the reason you supplied is readable alongside it
  6. Verify: you can tell from what you read which record annulled it

Expected outcome: the false statement is marked wrong at the place a reader encounters it, and the reader can follow it to the correction.

Scenario 2: An annulled finding drops out of what consumers act on, but not out of history

Acceptance criterion: "Given an annulled finding, when any consumer obtains the issue's findings through the read helper, then the annulled finding is not in the set the consumer acts on — and the original Phase Outcome comment is still present, unedited, and still readable as history."

  1. Continue from Scenario 1
  2. Obtain the issue's findings the way a consumer does
  3. Verify: the annulled finding is absent from that set
  4. Verify: the outcome's other finding is still present — annulling one did not annul its neighbours
  5. Open the original Phase Outcome comment directly on the forge
  6. Verify: the comment is still there, its text unchanged, and it shows no sign of having been edited

Expected outcome: consumers stop acting on the false finding while the record of it having been said survives intact.

Scenario 3: An unmodified pre-existing consumer honours annulment for free

Acceptance criterion: "Given a consumer written before this feature that reads Phase Outcomes through the read helper, when it runs against an issue carrying supersede records, then it reads without error and, with no change to its own code, no longer acts on annulled data."

  1. Take a consumer of Phase Outcomes that existed before this feature and change nothing about it
  2. Run it against issue A, which now carries a supersede
  3. Verify: it completes without error
  4. Verify: it does not act on the annulled finding
  5. Verify: no edit was required to that consumer to get either result

Expected outcome: honouring annulment is inherited by everything that reads through the shared path, rather than being a promise each consumer must separately keep.

Scenario 4: The phase lookup walks past an annulled outcome

Acceptance criterion: "Given an outcome-level supersede, when the current pipeline phase is looked up, then it resolves to the newest surviving outcome's next_state; the annulled outcome is walked past, exactly as next_state: skipped already is."

  1. On issue A, note the phase reported before any correction — it comes from the newest outcome
  2. Post a supersede annulling that newest outcome as a whole, with a reason
  3. Look up the current pipeline phase
  4. Verify: the reported phase is the one carried by the newest outcome that is still standing
  5. Verify: this matches how the suite already behaves for an outcome marked skipped

Expected outcome: the pipeline's idea of where the feature is stops being driven by a record that has been withdrawn.

Scenario 5: Every outcome annulled falls back to the pre-pipeline state

Acceptance criterion: "Given an issue whose every Phase Outcome has been annulled, when the current pipeline phase is looked up, then it reports backlog — the same result the suite already gives an issue that has no Phase Outcome at all."

  1. On issue B, which has exactly one Phase Outcome, post a supersede annulling it
  2. Look up the current pipeline phase for issue B
  3. Verify: it reports backlog
  4. Look up the current pipeline phase for issue C, which has never had an outcome
  5. Verify: issue C reports the same thing — the two cases agree

Expected outcome: annulling everything leaves the issue at the same starting point as an issue that never began, with no undefined or error state in between.

Scenario 6: Annulling every finding does not move the phase

Acceptance criterion: "Given a finding-level supersede, when the pipeline phase is looked up, then it is unchanged — including when every finding in the outcome has been annulled individually. Moving the phase requires annulling the outcome."

  1. On a fresh issue with one outcome carrying two findings, note the reported phase
  2. Post a supersede annulling the first finding; look up the phase
  3. Verify: the phase is unchanged
  4. Post a second supersede annulling the remaining finding; look up the phase again
  5. Verify: the phase is still unchanged, even though no finding in that outcome now stands
  6. Post an outcome-level supersede against the same outcome; look up the phase
  7. Verify: only now does the phase move

Expected outcome: annulling content and withdrawing a record are distinct acts with distinct effects, and doing the first repeatedly never silently becomes the second.

Scenario 7: Decisions on an annulled outcome stop blocking

Acceptance criterion: "Given an annulled outcome that carries open blocking Pending Decisions, when the decisions-settled gate runs, then those decisions no longer block."

  1. On issue B, confirm the outcome carries an open, blocking Pending Decision
  2. Run the decisions-settled gate; verify: it reports the run as blocked
  3. Post a supersede annulling that outcome, with a reason
  4. Run the decisions-settled gate again
  5. Verify: it no longer reports the run as blocked on that decision
  6. Verify: the decision itself is still visible in the issue's history

Expected outcome: a question raised on a premise that no longer holds stops holding the run up, without the question being erased from the record.

Scenario 8: A supersede says who, what and why

Acceptance criterion: "Given any supersede record, when it is read, then it names who posted it (the producing skill, or the operator), exactly what it annuls, and a non-empty reason."

  1. Read a supersede posted by a skill during a run
  2. Verify: it identifies the skill that posted it
  3. Verify: it identifies exactly what it annuls, precisely enough to find that target
  4. Verify: it carries a reason, and the reason is not blank
  5. Read a supersede posted by the operator
  6. Verify: it is distinguishable as an operator act rather than a skill's

Expected outcome: a reader who finds a correction can tell who made it and why, without asking anyone.

Scenario 9: The operator corrects an old outcome from a different session

Acceptance criterion: "Given a false outcome from an earlier run, when the operator invokes the supersede command from a different session naming the target and a reason, then the record is posted and the target reads as annulled."

  1. Identify a Phase Outcome posted by an earlier run, from a session that has since ended
  2. From a new session, as the operator, invoke the supersede command naming that outcome and a reason
  3. Verify: the command reports the record as posted
  4. Read the issue's Phase Outcomes
  5. Verify: the target reads as annulled, with the operator's reason

Expected outcome: correcting the record does not require being the session that wrote it, or being anywhere near the original run.

Scenario 10: A supersede posted in error is corrected by a later one

Acceptance criterion: "Given a supersede posted in error, when a later supersede naming the same target is posted, then the later one is what counts — the same latest-wins rule the suite already applies to decision resolutions."

  1. Post a supersede annulling a finding that was actually correct, with a reason
  2. Verify: the finding now reads as annulled
  3. Post a second supersede naming the same target, correcting the mistake, with a reason
  4. Read the issue again
  5. Verify: the later supersede is what governs the target's state
  6. Verify: the first, mistaken supersede is still readable in the history

Expected outcome: the correction mechanism can itself be corrected, so a slip does not become permanent.

Scenario 11: A supersede with no such target is refused

Acceptance criterion: "Given a supersede whose target does not exist — an unknown outcome, or a finding not present in the named outcome — when it is posted, then it is refused, naming the bad target."

  1. Attempt to post a supersede naming a Phase Outcome that does not exist on the issue
  2. Verify: it is refused, and the message names the target it could not find
  3. Attempt to post a supersede naming a real outcome but a finding that belongs to a different one
  4. Verify: it is refused, and the message names that target too
  5. Verify: neither attempt left a record on the issue

Expected outcome: a supersede cannot point at nothing, and a mistyped target fails loudly instead of annulling something unintended or silently doing nothing.

Scenario 12: A supersede missing its reason or attribution is refused

Acceptance criterion: "Given a supersede missing its reason or its attribution, when it is posted, then it is refused, naming what is missing."

  1. Attempt to post a supersede with a valid target but no reason
  2. Verify: it is refused, and the message says the reason is what is missing
  3. Attempt to post one with an empty-string reason
  4. Verify: it is refused the same way — blank does not satisfy the requirement
  5. Attempt to post one with no attribution
  6. Verify: it is refused, naming attribution as what is missing

Expected outcome: an unattributed or unexplained annulment cannot enter the record at all.

Scenario 13: A terminal outcome carries a roll-call

Acceptance criterion: "Given any terminal Phase Outcome, when it is posted, then it carries a roll-call: one entry per agent the phase spawned, each disposed reported or terminated, or an explicitly empty roll-call when the phase spawned none."

  1. Run a phase that spawns two agents and let both report; post its terminal outcome
  2. Read the posted outcome
  3. Verify: it carries two roll-call entries, one per agent, each disposed reported
  4. Run a phase that spawns two agents, terminate one before posting, let the other report
  5. Verify: the outcome carries one entry disposed reported and one disposed terminated
  6. Run a phase that spawns no agents at all and post its terminal outcome
  7. Verify: it carries a roll-call that is explicitly empty, not an absent one

Expected outcome: every terminal record states what happened to everything the phase started, including stating that it started nothing.

Scenario 14: A terminal outcome with no roll-call is refused

Acceptance criterion: "Given a terminal Phase Outcome with no roll-call at all, when the post is attempted, then it is refused — absence is never read as 'spawned none'."

  1. Attempt to post a terminal Phase Outcome with the roll-call omitted entirely
  2. Verify: the post is refused
  3. Verify: the message says what is missing
  4. Verify: no outcome was posted to the issue
  5. Add an explicitly empty roll-call and post again
  6. Verify: this one is accepted — the empty declaration is what absence was not

Expected outcome: omitting the roll-call stops being a way to say nothing was spawned, so silence is never mistaken for an answer.

Scenario 15: A roll-call cannot say "unrecoverable"

Acceptance criterion: "Given a roll-call entry disposed as anything other than reported or terminated — including unrecoverable — when the post is attempted, then it is refused."

  1. Run a phase that spawns an agent which does not respond
  2. Attempt to post the terminal outcome with that agent's entry disposed unrecoverable
  3. Verify: the post is refused
  4. Attempt again with some other invented disposition
  5. Verify: it is refused too
  6. Terminate the agent, record it as terminated, and post
  7. Verify: this one is accepted

Expected outcome: judging an agent dead is no longer something the record can express — the lead must end it or wait for it, which is what stops a late report arriving after the record claims it never will.

Scenario 16: Edge cases

  1. Superseding an already-annulled outcome. Post a supersede against an outcome that is already
    annulled. Verify: the result is either a clear refusal or an inert, recorded no-op — not a silent
    success that changes nothing, and not an error a reader cannot interpret.
  2. A late report after termination. Let an agent recorded as terminated deliver a report anyway,
    after the outcome is posted. Verify: the supersede path is available and sufficient to correct the
    outcome — no separate mechanism is needed and none is offered.
  3. Two supersedes against one target at once. Post two supersedes naming the same target from two
    sessions. Verify: one of them governs, deterministically, and reading the issue afterwards gives the
    same answer every time.
  4. First correction on a fresh issue. On an issue with exactly one outcome and no prior supersede,
    annul a finding. Verify: the read path handles the first-ever supersede the same as a later one.
  5. An issue with no outcomes. Attempt to post a supersede on issue C, which has none. Verify: it is
    refused, naming the missing target rather than failing obscurely.
  6. Reading an issue with no supersedes at all. Read an untouched issue after this feature ships.
    Verify: nothing about the output changed for issues that were never corrected.

Expected outcome: the correction path behaves predictably at its boundaries — first use, concurrent use, repeated use, and no use.

Notes

  • These cases are deliberately silent on record shape, field names, ordering keys and command flags.
    Those are design decisions; /dev:technical-plan settles them and annotates the lanes here.
  • Scenario 3 must use a genuinely unmodified consumer. Adapting it to pass would test the adaptation,
    not the compatibility promise.
  • Scenario 13's roll-call completeness is a self-report by design. These cases check that the record is
    well-formed and attributable, not that it is true — the PREQ accepts that limit explicitly, because
    the alternative needs harness knowledge the suite is not allowed to have.
<!-- test-plan:v1 issue=45 skill=requirements --> # Test Plan: phase-outcome-supersede (issue #45) Validation cases derived from the PREQ alone, before any design exists. They name only what the PREQ names and assert outcomes a person can observe. **Lanes are deliberately absent** — an absent lane means `e2e-browser` by default, and `/dev:technical-plan` 2.6b assigns the real lanes once the design is known. ## Prerequisites The *state* the scenarios need. How each state is produced is a design decision; the PREQ requires only that each be reachable. - [ ] A feature issue on a forge the suite supports, on which Phase Outcomes can be posted and read - [ ] At least three Phase Outcomes on one issue, so "walk past one" and "newest surviving" are distinguishable from "the only one" - [ ] One Phase Outcome carrying two or more findings, at least one of which is to be annulled while the others stand - [ ] One Phase Outcome carrying an open, blocking Pending Decision - [ ] An issue with exactly one Phase Outcome, so the all-annulled case is reachable - [ ] An issue with no Phase Outcome at all, as the control for the phase-lookup fallback - [ ] A consumer of Phase Outcomes that predates this feature and is not modified during the run — used unmodified, to check the compatibility promise - [ ] A phase run that spawns at least one agent, and a phase run that spawns none - [ ] Operator access from a session other than the one that posted the outcome being corrected ### Required Test Data - [ ] Issue A: three Phase Outcomes, the middle one carrying two findings - [ ] Issue B: one Phase Outcome only, carrying one blocking Pending Decision - [ ] Issue C: no Phase Outcomes (control) - [ ] A reason string for each supersede, and one empty reason for the refusal case - [ ] A target reference that does not exist, and a finding ID belonging to a different outcome ## Test Scenarios ### Scenario 1: A false finding reads as annulled **Acceptance criterion:** "Given a posted Phase Outcome containing a false finding, when a supersede naming that finding is posted, then reading the issue through the suite's Phase Outcome read helper reports that finding as annulled, carrying its reason and the identity of the record that annulled it." 1. On issue A, read the Phase Outcomes and note the two findings on the middle outcome 2. Post a supersede naming one of those findings, with a reason 3. Read the issue's Phase Outcomes again through the same read path 4. Verify: the named finding is reported as annulled 5. Verify: the reason you supplied is readable alongside it 6. Verify: you can tell from what you read which record annulled it **Expected outcome:** the false statement is marked wrong at the place a reader encounters it, and the reader can follow it to the correction. ### Scenario 2: An annulled finding drops out of what consumers act on, but not out of history **Acceptance criterion:** "Given an annulled finding, when any consumer obtains the issue's findings through the read helper, then the annulled finding is not in the set the consumer acts on — and the original Phase Outcome comment is still present, unedited, and still readable as history." 1. Continue from Scenario 1 2. Obtain the issue's findings the way a consumer does 3. Verify: the annulled finding is absent from that set 4. Verify: the outcome's *other* finding is still present — annulling one did not annul its neighbours 5. Open the original Phase Outcome comment directly on the forge 6. Verify: the comment is still there, its text unchanged, and it shows no sign of having been edited **Expected outcome:** consumers stop acting on the false finding while the record of it having been said survives intact. ### Scenario 3: An unmodified pre-existing consumer honours annulment for free **Acceptance criterion:** "Given a consumer written before this feature that reads Phase Outcomes through the read helper, when it runs against an issue carrying supersede records, then it reads without error and, with no change to its own code, no longer acts on annulled data." 1. Take a consumer of Phase Outcomes that existed before this feature and change nothing about it 2. Run it against issue A, which now carries a supersede 3. Verify: it completes without error 4. Verify: it does not act on the annulled finding 5. Verify: no edit was required to that consumer to get either result **Expected outcome:** honouring annulment is inherited by everything that reads through the shared path, rather than being a promise each consumer must separately keep. ### Scenario 4: The phase lookup walks past an annulled outcome **Acceptance criterion:** "Given an outcome-level supersede, when the current pipeline phase is looked up, then it resolves to the newest surviving outcome's `next_state`; the annulled outcome is walked past, exactly as `next_state: skipped` already is." 1. On issue A, note the phase reported before any correction — it comes from the newest outcome 2. Post a supersede annulling that newest outcome as a whole, with a reason 3. Look up the current pipeline phase 4. Verify: the reported phase is the one carried by the newest outcome that is still standing 5. Verify: this matches how the suite already behaves for an outcome marked skipped **Expected outcome:** the pipeline's idea of where the feature is stops being driven by a record that has been withdrawn. ### Scenario 5: Every outcome annulled falls back to the pre-pipeline state **Acceptance criterion:** "Given an issue whose every Phase Outcome has been annulled, when the current pipeline phase is looked up, then it reports `backlog` — the same result the suite already gives an issue that has no Phase Outcome at all." 1. On issue B, which has exactly one Phase Outcome, post a supersede annulling it 2. Look up the current pipeline phase for issue B 3. Verify: it reports `backlog` 4. Look up the current pipeline phase for issue C, which has never had an outcome 5. Verify: issue C reports the same thing — the two cases agree **Expected outcome:** annulling everything leaves the issue at the same starting point as an issue that never began, with no undefined or error state in between. ### Scenario 6: Annulling every finding does not move the phase **Acceptance criterion:** "Given a finding-level supersede, when the pipeline phase is looked up, then it is unchanged — including when every finding in the outcome has been annulled individually. Moving the phase requires annulling the outcome." 1. On a fresh issue with one outcome carrying two findings, note the reported phase 2. Post a supersede annulling the first finding; look up the phase 3. Verify: the phase is unchanged 4. Post a second supersede annulling the remaining finding; look up the phase again 5. Verify: the phase is still unchanged, even though no finding in that outcome now stands 6. Post an outcome-level supersede against the same outcome; look up the phase 7. Verify: only now does the phase move **Expected outcome:** annulling content and withdrawing a record are distinct acts with distinct effects, and doing the first repeatedly never silently becomes the second. ### Scenario 7: Decisions on an annulled outcome stop blocking **Acceptance criterion:** "Given an annulled outcome that carries open blocking Pending Decisions, when the decisions-settled gate runs, then those decisions no longer block." 1. On issue B, confirm the outcome carries an open, blocking Pending Decision 2. Run the decisions-settled gate; verify: it reports the run as blocked 3. Post a supersede annulling that outcome, with a reason 4. Run the decisions-settled gate again 5. Verify: it no longer reports the run as blocked on that decision 6. Verify: the decision itself is still visible in the issue's history **Expected outcome:** a question raised on a premise that no longer holds stops holding the run up, without the question being erased from the record. ### Scenario 8: A supersede says who, what and why **Acceptance criterion:** "Given any supersede record, when it is read, then it names who posted it (the producing skill, or the operator), exactly what it annuls, and a non-empty reason." 1. Read a supersede posted by a skill during a run 2. Verify: it identifies the skill that posted it 3. Verify: it identifies exactly what it annuls, precisely enough to find that target 4. Verify: it carries a reason, and the reason is not blank 5. Read a supersede posted by the operator 6. Verify: it is distinguishable as an operator act rather than a skill's **Expected outcome:** a reader who finds a correction can tell who made it and why, without asking anyone. ### Scenario 9: The operator corrects an old outcome from a different session **Acceptance criterion:** "Given a false outcome from an earlier run, when the operator invokes the supersede command from a different session naming the target and a reason, then the record is posted and the target reads as annulled." 1. Identify a Phase Outcome posted by an earlier run, from a session that has since ended 2. From a new session, as the operator, invoke the supersede command naming that outcome and a reason 3. Verify: the command reports the record as posted 4. Read the issue's Phase Outcomes 5. Verify: the target reads as annulled, with the operator's reason **Expected outcome:** correcting the record does not require being the session that wrote it, or being anywhere near the original run. ### Scenario 10: A supersede posted in error is corrected by a later one **Acceptance criterion:** "Given a supersede posted in error, when a later supersede naming the same target is posted, then the later one is what counts — the same latest-wins rule the suite already applies to decision resolutions." 1. Post a supersede annulling a finding that was actually correct, with a reason 2. Verify: the finding now reads as annulled 3. Post a second supersede naming the same target, correcting the mistake, with a reason 4. Read the issue again 5. Verify: the later supersede is what governs the target's state 6. Verify: the first, mistaken supersede is still readable in the history **Expected outcome:** the correction mechanism can itself be corrected, so a slip does not become permanent. ### Scenario 11: A supersede with no such target is refused **Acceptance criterion:** "Given a supersede whose target does not exist — an unknown outcome, or a finding not present in the named outcome — when it is posted, then it is refused, naming the bad target." 1. Attempt to post a supersede naming a Phase Outcome that does not exist on the issue 2. Verify: it is refused, and the message names the target it could not find 3. Attempt to post a supersede naming a real outcome but a finding that belongs to a different one 4. Verify: it is refused, and the message names that target too 5. Verify: neither attempt left a record on the issue **Expected outcome:** a supersede cannot point at nothing, and a mistyped target fails loudly instead of annulling something unintended or silently doing nothing. ### Scenario 12: A supersede missing its reason or attribution is refused **Acceptance criterion:** "Given a supersede missing its reason or its attribution, when it is posted, then it is refused, naming what is missing." 1. Attempt to post a supersede with a valid target but no reason 2. Verify: it is refused, and the message says the reason is what is missing 3. Attempt to post one with an empty-string reason 4. Verify: it is refused the same way — blank does not satisfy the requirement 5. Attempt to post one with no attribution 6. Verify: it is refused, naming attribution as what is missing **Expected outcome:** an unattributed or unexplained annulment cannot enter the record at all. ### Scenario 13: A terminal outcome carries a roll-call **Acceptance criterion:** "Given any terminal Phase Outcome, when it is posted, then it carries a roll-call: one entry per agent the phase spawned, each disposed `reported` or `terminated`, or an explicitly empty roll-call when the phase spawned none." 1. Run a phase that spawns two agents and let both report; post its terminal outcome 2. Read the posted outcome 3. Verify: it carries two roll-call entries, one per agent, each disposed reported 4. Run a phase that spawns two agents, terminate one before posting, let the other report 5. Verify: the outcome carries one entry disposed reported and one disposed terminated 6. Run a phase that spawns no agents at all and post its terminal outcome 7. Verify: it carries a roll-call that is explicitly empty, not an absent one **Expected outcome:** every terminal record states what happened to everything the phase started, including stating that it started nothing. ### Scenario 14: A terminal outcome with no roll-call is refused **Acceptance criterion:** "Given a terminal Phase Outcome with no roll-call at all, when the post is attempted, then it is refused — absence is never read as 'spawned none'." 1. Attempt to post a terminal Phase Outcome with the roll-call omitted entirely 2. Verify: the post is refused 3. Verify: the message says what is missing 4. Verify: no outcome was posted to the issue 5. Add an explicitly empty roll-call and post again 6. Verify: this one is accepted — the empty declaration is what absence was not **Expected outcome:** omitting the roll-call stops being a way to say nothing was spawned, so silence is never mistaken for an answer. ### Scenario 15: A roll-call cannot say "unrecoverable" **Acceptance criterion:** "Given a roll-call entry disposed as anything other than `reported` or `terminated` — including `unrecoverable` — when the post is attempted, then it is refused." 1. Run a phase that spawns an agent which does not respond 2. Attempt to post the terminal outcome with that agent's entry disposed `unrecoverable` 3. Verify: the post is refused 4. Attempt again with some other invented disposition 5. Verify: it is refused too 6. Terminate the agent, record it as terminated, and post 7. Verify: this one is accepted **Expected outcome:** judging an agent dead is no longer something the record can express — the lead must end it or wait for it, which is what stops a late report arriving after the record claims it never will. ### Scenario 16: Edge cases 1. **Superseding an already-annulled outcome.** Post a supersede against an outcome that is already annulled. Verify: the result is either a clear refusal or an inert, recorded no-op — not a silent success that changes nothing, and not an error a reader cannot interpret. 2. **A late report after termination.** Let an agent recorded as `terminated` deliver a report anyway, after the outcome is posted. Verify: the supersede path is available and sufficient to correct the outcome — no separate mechanism is needed and none is offered. 3. **Two supersedes against one target at once.** Post two supersedes naming the same target from two sessions. Verify: one of them governs, deterministically, and reading the issue afterwards gives the same answer every time. 4. **First correction on a fresh issue.** On an issue with exactly one outcome and no prior supersede, annul a finding. Verify: the read path handles the first-ever supersede the same as a later one. 5. **An issue with no outcomes.** Attempt to post a supersede on issue C, which has none. Verify: it is refused, naming the missing target rather than failing obscurely. 6. **Reading an issue with no supersedes at all.** Read an untouched issue after this feature ships. Verify: nothing about the output changed for issues that were never corrected. **Expected outcome:** the correction path behaves predictably at its boundaries — first use, concurrent use, repeated use, and no use. ## Notes - These cases are deliberately silent on record shape, field names, ordering keys and command flags. Those are design decisions; `/dev:technical-plan` settles them and annotates the lanes here. - Scenario 3 must use a genuinely unmodified consumer. Adapting it to pass would test the adaptation, not the compatibility promise. - Scenario 13's roll-call completeness is a self-report by design. These cases check that the record is well-formed and attributable, not that it is true — the PREQ accepts that limit explicitly, because the alternative needs harness knowledge the suite is not allowed to have.
Author
Owner
{
  "next_state": "planning",
  "produced": [
    {
      "kind": "preq",
      "ref": "issue-body",
      "summary": "PREQ for phase-outcome-supersede — 5 user stories, 15 acceptance criteria; Tier 3 panel reviewed"
    },
    {
      "kind": "test-plan",
      "ref": "comment:2084",
      "summary": "16 validation scenarios covering 15/15 acceptance criteria, lanes deliberately unassigned"
    }
  ],
  "findings": [
    {
      "category": "in-scope-deferrable",
      "summary": "Panel challenge (simpler-alternative lens): finding-level annulment is unevidenced — the one recorded incident was whole-premise-false, not one false finding among true ones. Dropping it would remove 3 of the 15 acceptance criteria.",
      "reasoning": "Recorded at the operator's direction rather than acted on: scope was held at both halves. Re-test at /dev:technical-plan — if finding-level annulment proves expensive to design, this is the pre-argued case for reducing to outcome-level only.",
      "id": "F-PO-45-1-1"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Panel challenge (simpler-alternative lens): prevention is severable from recovery — the roll-call (4 acceptance criteria) addresses a different failure moment, has its own dependency on wait-discipline, and could ship as its own issue.",
      "reasoning": "Recorded at the operator's direction rather than acted on: scope was held at both halves, because recovery alone leaves the cause in place. Re-test at /dev:technical-plan if the combined design is large.",
      "id": "F-PO-45-1-2"
    },
    {
      "category": "out-of-scope",
      "summary": "Second observed instance of the idle-without-reporting pattern: two of the five PREQ panel lenses reviewing THIS requirement went idle without delivering; a single ping recovered both, and both returned concerns that changed the document.",
      "reasoning": "Not a work item for this feature — it is corroborating evidence for its premise, and an independent second source for wait-discipline.md §1a beyond the feature-230 incident. Recorded here so the recurrence is countable rather than lost with the session.",
      "id": "F-PO-45-1-3"
    },
    {
      "category": "out-of-scope",
      "summary": "The general decision withdraw/supersede path — a decision mooted by anything other than an annulled outcome (a superseding QA round, a declaration change).",
      "reasoning": "Owned by sibling issue #36. This feature only stops decisions belonging to an annulled outcome from blocking; widening it would duplicate #36's scope.",
      "id": "F-PO-45-1-4"
    },
    {
      "category": "out-of-scope",
      "summary": "Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1).",
      "reasoning": "Already mutable/latest-wins per forge-contract §8 — they self-correct on re-post, and the recorded incident's SREQ did exactly that.",
      "id": "F-PO-45-1-5"
    },
    {
      "category": "out-of-scope",
      "summary": "Editing or deleting a posted Phase Outcome as a correction route.",
      "reasoning": "Remains a contract violation (forge-contract §8) — it corrupts the audit chain that derives decision IDs from ordinals. This feature adds a forward correction path and never a mutation.",
      "id": "F-PO-45-1-6"
    },
    {
      "category": "out-of-scope",
      "summary": "A roll-call that enumerates live agents to verify its own completeness.",
      "reasoning": "Would require harness-specific knowledge in the shipped helper tier, breaching the stack-agnostic rule in CLAUDE.md. The accepted consequence is that a roll-call cannot be proved complete — only well-formed and attributable.",
      "id": "F-PO-45-1-7"
    },
    {
      "category": "out-of-scope",
      "summary": "Migrating or retro-annotating Phase Outcomes posted before this feature exists.",
      "reasoning": "Nothing needs rewriting: a supersede is a new record, so an old outcome remains correctable going forward without touching history.",
      "id": "F-PO-45-1-8"
    }
  ],
  "pending_decisions": [],
  "suite": {
    "source": "git",
    "sha": "8de068ebafabe5e9693b6cd0c7c87929d3b852dd",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-45-1 skill=requirements --> ```json { "next_state": "planning", "produced": [ { "kind": "preq", "ref": "issue-body", "summary": "PREQ for phase-outcome-supersede — 5 user stories, 15 acceptance criteria; Tier 3 panel reviewed" }, { "kind": "test-plan", "ref": "comment:2084", "summary": "16 validation scenarios covering 15/15 acceptance criteria, lanes deliberately unassigned" } ], "findings": [ { "category": "in-scope-deferrable", "summary": "Panel challenge (simpler-alternative lens): finding-level annulment is unevidenced — the one recorded incident was whole-premise-false, not one false finding among true ones. Dropping it would remove 3 of the 15 acceptance criteria.", "reasoning": "Recorded at the operator's direction rather than acted on: scope was held at both halves. Re-test at /dev:technical-plan — if finding-level annulment proves expensive to design, this is the pre-argued case for reducing to outcome-level only.", "id": "F-PO-45-1-1" }, { "category": "in-scope-deferrable", "summary": "Panel challenge (simpler-alternative lens): prevention is severable from recovery — the roll-call (4 acceptance criteria) addresses a different failure moment, has its own dependency on wait-discipline, and could ship as its own issue.", "reasoning": "Recorded at the operator's direction rather than acted on: scope was held at both halves, because recovery alone leaves the cause in place. Re-test at /dev:technical-plan if the combined design is large.", "id": "F-PO-45-1-2" }, { "category": "out-of-scope", "summary": "Second observed instance of the idle-without-reporting pattern: two of the five PREQ panel lenses reviewing THIS requirement went idle without delivering; a single ping recovered both, and both returned concerns that changed the document.", "reasoning": "Not a work item for this feature — it is corroborating evidence for its premise, and an independent second source for wait-discipline.md §1a beyond the feature-230 incident. Recorded here so the recurrence is countable rather than lost with the session.", "id": "F-PO-45-1-3" }, { "category": "out-of-scope", "summary": "The general decision withdraw/supersede path — a decision mooted by anything other than an annulled outcome (a superseding QA round, a declaration change).", "reasoning": "Owned by sibling issue #36. This feature only stops decisions belonging to an annulled outcome from blocking; widening it would duplicate #36's scope.", "id": "F-PO-45-1-4" }, { "category": "out-of-scope", "summary": "Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1).", "reasoning": "Already mutable/latest-wins per forge-contract §8 — they self-correct on re-post, and the recorded incident's SREQ did exactly that.", "id": "F-PO-45-1-5" }, { "category": "out-of-scope", "summary": "Editing or deleting a posted Phase Outcome as a correction route.", "reasoning": "Remains a contract violation (forge-contract §8) — it corrupts the audit chain that derives decision IDs from ordinals. This feature adds a forward correction path and never a mutation.", "id": "F-PO-45-1-6" }, { "category": "out-of-scope", "summary": "A roll-call that enumerates live agents to verify its own completeness.", "reasoning": "Would require harness-specific knowledge in the shipped helper tier, breaching the stack-agnostic rule in CLAUDE.md. The accepted consequence is that a roll-call cannot be proved complete — only well-formed and attributable.", "id": "F-PO-45-1-7" }, { "category": "out-of-scope", "summary": "Migrating or retro-annotating Phase Outcomes posted before this feature exists.", "reasoning": "Nothing needs rewriting: a supersede is a new record, so an old outcome remains correctable going forward without touching history.", "id": "F-PO-45-1-8" } ], "pending_decisions": [], "suite": { "source": "git", "sha": "8de068ebafabe5e9693b6cd0c7c87929d3b852dd", "dirty": false } } ```
Author
Owner

Software Requirements: 45-phase-outcome-supersede

Context

phase-outcome:v1 is the suite's terminal handoff record and is immutable by contract (§8) — so
it is the only durable record with no correction path. A phase lead that discovers, after posting,
that its own record states something false can do nothing about it; a false finding stays live and a
Pending Decision raised on a false premise stays open. The recorded incident: a five-lens review
panel returned nothing, the lead concluded the transport was broken and wrote "panel could not be
run"
into its Phase Outcome, and the panel's results arrived 34 seconds later carrying two
blocking concerns. This slice adds a supersede path (recovery) and a roll-call (prevention),
which the PREQ ships together because each was shown insufficient alone.

Approaches Considered

Approach A: A new sibling record kind, honoured inside the read helper

Summary: annulment is a new append-only comment kind, phase-outcome-supersede:v1, and the one
place that honours it is phase_outcome.read_all — so every compliant consumer inherits the
behaviour unmodified.
Pros: phase-outcome:v1's wire format is untouched, so no :v2 bump and no history goes
invisible; the annulled comment is never edited, so §8 stands unweakened; the compatibility promise
is true by construction rather than by an enumerated consumer list; latest-wins-per-target reuses
§9's rule verbatim, and the append-only active/withdrawn shape reuses folded-finding:v1's
proven pattern; no new adapter primitive.
Cons: read_all gains a second comment scan per call; the record set a consumer sees is no
longer literally the comment set on the issue, so history needs an explicit opt-in flag.
Effort: Medium

Approach B: Annulment carried inside a new Phase Outcome

Summary: add a supersedes[] body field to phase-outcome:v1; correcting a record means minting
a new outcome that names what it annuls.
Pros: no new record kind; the corrector is itself a first-class outcome, so it carries its own
attribution, roll-call and ordinal, and the ledger remains the whole story in one chain.
Cons: ruinously heavy for the actual use case. An operator annulling one false finding months
later must mint a whole Phase Outcome — which forces them to choose a next_state (moving pipeline
state as a side effect of a correction), supply a roll-call for a run that never happened, and pick
a producer name from a closed enum that has no operator in it. The PREQ rejected forcing a
replacement outcome for exactly this weight (decision 4).
Effort: Medium

Approach C: Reuse decision-resolution:v1

Summary: express annulment as an "annulment decision" resolved through /dev:resolve.
Pros: zero new schema; reuses the §9 lookup rule and the whole existing operator surface.
Cons: a decision is a question; an annulment is an assertion. It needs a Pending Decision
to exist first, which the false outcome never raised — so a self-correcting skill would be answering
its own invented question. And decision IDs are derived from Phase Outcome ordinals, so an operator
arriving weeks later has no ID to name.
Effort: Low

Decision

Selected: Approach A — a new sibling record kind, honoured inside the read helper.

Rationale: it is the only shape that keeps both promises the PREQ treats as non-negotiable — the
annulled record is never touched (§8 stands) and the exclusion is inherited by consumers written
before the feature existed (honoured in one place, not an enumerated list). B and C both fail the
second promise or pay for the first with a correction act heavier than the thing it corrects.

Architecture

Component Overview

  producer skill                  operator
        |                            |
        | phase_outcome.supersede    | /dev:resolve --supersede
        v                            v
   bin/phase-outcome-supersede.sh  (validates target, reason, attribution)
        |
        |  post_comment            <!-- phase-outcome-supersede:v1 ref=… by=… -->
        v
  ┌───────────────────────── the issue's comment ledger ─────────────────────────┐
  │  phase-outcome:v1  ×N   (immutable, dense ordinals)                          │
  │  phase-outcome-supersede:v1  ×M  (append-only, latest-wins per target)       │
  └──────────────────────────────────────────────────────────────────────────────┘
        ^                                   |
        | scan_comments ×2                  | _po_annulment  (bin/_lib.sh)
        |                                   v
   bin/phase-outcome-read-all.sh  ──►  filtered ledger  ──►  every consumer
        ^                                                    (read_latest, decisions-settled,
        |                                                     /dev:resolve, integrate, promote,
   bin/phase-outcome-read-latest.sh                           fold-/promotion-candidates, …)

Data Flow

Annulling. A skill (mid-run, on discovering its own record is false) or the operator (later, from
any session) calls the supersede helper with a target, a reason and an attribution. The helper reads
the unfiltered ledger, refuses an unresolvable target, and posts one append-only comment.

Reading. read_all scans the Phase Outcome records exactly as today, runs the §8 immutability
check, parses, applies the foreign-issue gate, and enforces ordinal density on the raw ledger.
Only then does it scan the supersede kind, resolve latest-wins per target through _po_annulment,
and project the filtered view. read_latest wraps read_all, so the current-phase lookup inherits
annulment with no code of its own.

Preventing. phase_outcome.post requires a roll_call array. It validates the array's shape
and refuses absence — it never inspects live agents.

The supersede record

<!-- phase-outcome-supersede:v1 ref={PO-id | F-id} by={producer|operator} -->

{ "status": "active" | "withdrawn",
  "target": "{ref}",     // echoes the header; the header is authoritative
  "by":     "{ref}",     // echoes the header; the header is authoritative
  "reason": "<non-empty>" }

scope (outcome vs finding) is derived from ref's form (PO-… vs F-PO-…) and deliberately
not stored — a third copy of the same fact is a third thing that can disagree. The body echoes
target/by for a human reading the comment, exactly as folded-finding:v1 echoes its header
tokens; the post helper refuses any header/body mismatch, and readers treat the header as
authoritative for identity
.

Matching ref is exact-token, never substring. F-PO-45-3-2 is a substring-prefix of
F-PO-45-3-21, and this suite has already shipped that bug once (ref=D-PO-2-4-1 matched
ref=D-PO-2-4-12, observed live). The reader parses the header's key=value attributes with the
anchored-header regex and compares with jq string equality — it never greps the raw body.

Read-side gating of supersede records — fail closed

The supersede kind removes data from what consumers act on, so a record of it that cannot be
trusted must never be honoured silently, and must never be dropped silently either. Four disjoint
outcomes in _po_annulment, mirroring the family's existing partition:

What was scanned What happens
Anchored + parseable + ref names a target on this issue Honoured (latest-wins per target).
Anchored + unparseable body, or unknown status Hard refusal of the read (EXIT_ENV), naming the comment id and the offending element — the _po_parse treatment. Silently ignoring it could resurrect a finding that was genuinely annulled.
Anchored + ref claims a target on a different issue Hard refusal, same gate and wording as _po_foreign_gate. A session must not annul another issue's records.
Anchored + well-formed but the target does not exist on this issue (a dangling, hand-posted record) Inert + warned on stderr every read, naming the comment id — the near-miss treatment. It annuls nothing, and refusing the whole read would let one hand-posted comment take an issue offline.
Edited (edited_ms > created_ms) §8-strength hard refusal (EXIT_IMMUTABLE). Detected by the same signal every adapter already renders (local-fs pins mtime to epoch_ms; remote adapters render created_at/updated_at) — no new mechanism. One edited record refuses the whole issue read, §8 parity. That an edit can therefore take an issue's reads offline is tamper-evidence working as intended, not a defect.

External Data Contracts

Boundary / source Operation Real shape (verified) Provenance
Gitea (git.wihslon.com / jbr870/devwork-skills) via the tea-cli adapter — the comment ledger this feature reads and writes scan_comments(issue, header) read; post_comment(issue, body) write [{comment_id: string, created_ms: number, edited_ms: number, raw_body: string}]; line 1 of a record round-trips byte-identical, read back as <!-- phase-outcome:v1 id=PO-45-1 skill=requirements --> recordedexternal-contracts/forge-comment-ledger.provenance.json

The capture confirms the two facts the design rests on: an HTML-comment header with space-delimited
key=value attributes survives the round trip (so a new sibling kind needs no new adapter
primitive
), and the scan envelope already carries created_ms + edited_ms — the ordering key for
latest-wins-per-target and the §8 edit signal are both present today. No boundary is unverified, so
this phase raises no precondition.

Observability & Audit

Not applicable as a policy obligation: the project declares observability: mode: none, so the
applicability test does not intersect and no logging/audit AC rows are owed. Recorded here only
because the reason for that none is load-bearing for this feature — "run traceability is the forge
record (Phase Outcomes, deliverables), not logs"
. The record trail is this project's only audit
surface, which is why §8's tamper-evidence is preserved rather than relaxed, and why the supersede
record itself carries mandatory attribution and reason.

The honest limit on attribution. by is an unverified self-report, exactly as the roll-call
is. Nothing in the forge binds it to the comment's actual author, and the suite deliberately adds no
adapter primitive to fetch one. It is a claim, recorded and permanent — which is the same standing
the suite already gives a /dev:resolve resolution posted by an autonomous run. State this in the
schema beside the roll-call's limit; do not let a reader mistake by=operator for proof of a human.

Key Decisions

Each of these settles something the PREQ deliberately deferred to this phase, or something the
review panel raised.

Decision Choice Rationale
Wire form of an annulment A new kind phase-outcome-supersede:v1 (see The supersede record) A sibling kind leaves phase-outcome:v1's wire format untouched, so §7's reader test is trivially passed and no bump is owed.
Target granularity One record, one target. ref is a PO id (PO-45-3) or a finding id (F-PO-45-3-2) Finding IDs embed their PO id, so a single ref identifies both cases and latest-wins keys on one value. N targets = N records. (Settles "may one supersede target findings across multiple outcomes" — no.)
Identity conflicts Header ref/by are authoritative; the body echoes them; post refuses a mismatch; scope is derived, never stored Three copies of one fact is three things that can disagree. Mirrors folded-finding:v1's header/body echo.
ref matching Exact header-attribute token equality in jq — never a raw-body substring F-PO-45-3-2 prefixes F-PO-45-3-21; this exact bug class already shipped once.
Ordering: "newest surviving outcome" The PO ordinal read_all already sorts by it and enforces density; ordinals are minted monotonically and are the ledger's identity. Timestamps are the supersede key, not the outcome key.
Ordering: which supersede governs a target Latest by (created_ms, comment_id) Literally §9's rule and its deterministic same-second tiebreaker; a concurrent pair resolves without a special case.
Un-annulling status: withdrawn, append-only — never an edit or a delete. withdrawn carries the same mandatory reason + by and the same refusal set as active The folded-finding:v1 pattern. Un-annulling restores a record to force, so it deserves equal attribution rigour.
Precedence between outcome- and finding-level records on one PO Independent, per target. Annulling PO-45-3 does not moot a withdrawn on F-PO-45-3-2; each target resolves on its own, and an annulled outcome leaves the actionable set whatever its findings say Per-target latest-wins needs no precedence rule; adding one would create a second lookup to keep consistent.
Attribution value for the operator The reserved literal by=operator; every other value must be a canonical producer name operator is disjoint from the closed producer enum (requirements, technical-plan, develop, rebase, qa, integrate, promote, vibecoding, resolve, setup), so skill-vs-operator is decidable from the value alone. Its truthfulness is a claim, not a proof — see Observability & Audit.
Where annulment is honoured phase-outcome-read-all.sh, via one _po_annulment in bin/_lib.sh The contract already forbids re-implementing scan-and-resolve (§9). One implementation is what makes the compatibility criterion true by construction.
What a default read returns Annulled outcomes absent from the array; annulled findings moved out of findings[] into an additive annulled_findings[]; plus an additive annulled_count on every surviving outcome's sibling projection Absence is what gives an unmodified consumer the exclusion for free (read_latest's last becomes the newest survivor; an empty array → nullbacklog). annulled_count lets a consumer know filtered history exists without opting into it — the visibility hook that keeps an annulment from being invisible at a human touchpoint.
Reading history --include-annulled returns the whole ledger with superseded: {…}|null on each outcome and finding The history door, and what the supersede helper itself reads. Additive; default behaviour unchanged. The two modes have different output shapes — the schema says so, so no consumer mixes them.
Density check placement On the raw ledger, before filtering. The filtered array is deliberately ordinal-sparse Removing an outcome would otherwise trip the existing [.[].ordinal] == [range(1;length+1)] dense check. Consumers must not re-assert density on read output, nor compute len+1 from it.
Decisions paired to an annulled finding Left in pending_decisions[] — the gate keeps counting them. /dev:resolve presents such a decision but restricts it to accept Dropping them was the draft's plan and two lenses caught it: a finding-level supersede would then silently pass the decisions-settled gate while the outcome and its next_state still stand — pipeline state advancing on annulled evidence, which AC-6 forbids. The real hazard (resolving defer-to-issue spawning a sibling issue from a finding annulled as false) is closed at the acting site instead of by hiding the question.
Decisions on an annulled outcome Stop blocking, per AC-7 — regardless of who posted the supersede AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. The security question that raises is surfaced as a finding + decision, not resolved unilaterally.
What post sees The raw ledger, always Ordinal minting, the collision check, the foreign-issue gate, the duplicate-ordinal gate and the timeout-retry guard must not shift when a record is annulled: an annulled outcome keeps its ordinal forever, so ordinals stay dense and monotonic.
Roll-call dispositions reported | terminated, exactly one per entry, exactly one entry per spawned agent, agent names unique within a roll-call. No unrecoverable; any other value refused reported = the lead read the result, or reconstructed the outcome from the tree per wait-discipline.md §1a step 3. terminated = the lead ended the agent before posting, so no late report can arrive. A terminated agent whose report was already read is reported. The omission is the teeth.
Roll-call scope Direct spawns only — transitive children are not counted A lead can only account for what it started; a teammate's children are that teammate's roll-call. Enumerating them would need harness knowledge the stack-agnostic rule forbids. Stated as an accepted limit beside the existing one.
Roll-call absence --roll-call omitted, or given an empty string, is refused; [] is a valid "spawned none". The flag still accepts @file or an inline literal via _json_arg The evasion is one omission wide (PREQ decision 9), so the parser keeps an explicit was-the-flag-seen sentinel — but bypassing _json_arg entirely would silently drop @file support and break symmetry with --produced/--findings. Sentinel for absence, _json_arg for the value.
Roll-call versioning + enforcement seam Optional in the schema, required by the post helper. Readers MUST NOT refuse its absence Producer-side narrowing, which §7 allows without a bump: no existing record becomes unreadable and no reader rejects one. Pre-feature records read roll_call: null, exactly as suite does. Enforcement lives only at the post seam, so a hand-posted record bypasses it — which is why readers must tolerate absence.
Which outcomes need a roll-call Every phase-outcome:v1 Every Phase Outcome is the terminal handoff of a phase run (schema line 1). This dissolves the deferred "enumeration of terminal next_state values": the qualifier is the record kind, not a state list — and it leaves no escape hatch.
Roll-call vs the retry guard Included in the normalized comparison (unlike suite), sorted by agent before comparing. A landed pre-feature record with roll_call: null never matches an incoming [] suite is excluded because it describes the writer; a roll-call describes the run's content. Sorting matters: array-order jitter between two attempts would otherwise defeat the guard and mint a duplicate outcome — the failure the guard exists to prevent.
Exit codes EXIT_USAGE (2) for missing/empty --roll-call, --reason, --by, a bad disposition or a malformed entry; EXIT_FINDING (6) for an unresolvable target; EXIT_ENV (7) for a foreign or unparseable supersede at read time; EXIT_IMMUTABLE (4) for an edited one Callers branch on rc; an unknown target is a state-dependent record-shape refusal, not a usage error, and leaving the mapping implicit invites drift.
Superseding an already-annulled target Accepted, and a record is posted; the helper returns already_annulled: true It must be accepted — re-annulling after a withdrawn, and recording a better reason, are both this path. Posting the record is what makes it an inert, recorded no-op rather than a silent one.
Operator surface Extend /dev:resolve (--supersede <target> --reason "…", plus an interactive menu entry) The PREQ asked for "a command in the shape of /dev:resolve". One orchestrator surface, and it avoids adding a shipped skill (which also carries the SHIPPED_SKILLS lint step). Rejected: a new /dev:supersede skill. The helper is the real API — a skill correcting itself mid-run calls it directly.

Technical Risks

Risk Likelihood Impact Mitigation
Making --roll-call mandatory breaks every existing producer call site at once High High It is a mechanical, enumerable edit (see Implementation Scope), and the shell suites fail loudly the moment one is missed. Sequencing is the mitigation: land the call sites and suites first, flip the requirement on last. A shared fixture helper in test/ keeps the flag-day to one edit per suite.
$PO_ORDINAL = len(prior_outcomes) + 1 silently drifts once outcomes can be filtered out Medium High Already wrong for a gapped ledger (post mints max+1); annulment makes it wrong more often. Fixed in this slice and covered by a mechanical scan for other len(...)+1-on-read-output call sites — develop is one of N, not necessarily the only one.
A reader assumes read_all's array is still the literal comment set, or re-asserts density on it Medium Medium --include-annulled is the documented history door; annulled_count signals filtered history in default mode; the schema, procedure doc and contract all state the default is the actionable set, not the ledger, and that it is ordinal-sparse.
Second comment scan per read_all, amplified repo-wide by the candidate sweepspromotion-candidates.sh / fold-candidates.sh go from one to two forge calls per open issue, including issues that can never carry a supersede Medium Low Accepted knowingly, not discovered. The performance lens found no blocking concern at this load (tens of comments per issue, a handful of reads per phase, tens of open issues; seconds at worst on a remote adapter). Two lenses independently noted the available lever: phase-outcome-supersede:v1 contains the substring phase-outcome, so one scan could feed both filters locally. Not taken now — it entangles the anchored-header / near-miss discrimination (#54) for a cost nobody is paying. If the sweeps ever feel slow, fix it there (one comment fetch per issue, reused for both kinds), not in read_all.
The two scans in read_all are not atomic Low Low Benign: a supersede posted between them simply takes effect on the next read. Documented rather than locked.

Expert Review

Panel: Tier 3, five lenses, on fable. Reviewer reports carried 12 blocking concerns; 11 are
actioned below and 1 is surfaced as a finding for the orchestrator.

Process note, recorded because this feature is about exactly this. Four of the five reviewers
went idle without delivering their reports. Following wait-discipline.md §1a, the tree was read
before any conclusion was drawn — all four had written complete reviews; the reports were dropped,
not the work — and every one was recovered and used. Had the panel been recorded as "could not be
run", this SREQ would have shipped without 8 of its blocking corrections, reproducing the
feature-230 incident inside the plan for the fix. This phase's own roll-call records all five as
reported, four of them reconstructed from the tree.

Reviewers

  • Solution Architect — read-side gating of supersede records was unstated; dropping decisions on finding-level annulment exceeds the AC and lets state advance on annulled evidence.
  • Database Expert — the ref-substring hazard would recur; three copies of the target have no conflict rule; the filtered array is ordinal-sparse and consumers must be told.
  • Backend Developer — exit-code mapping unspecified; bypassing _json_arg for --roll-call silently drops @file; roll-call array-order jitter defeats the retry guard.
  • Security Specialistby is a self-declared claim and the plan said so for the roll-call but not for attribution; a supersede can dissolve a blocking approval gate; the foreign-issue gate was unstated for the new kind.
  • Performance Engineer — no blocking concern; the doubled scan is acceptable at this load, but the repo-wide sweep amplification should be chosen, not discovered.

Changes Made

  • Added Read-side gating of supersede records — fail closed: a four-way partition (honour / hard-refuse unparseable / hard-refuse foreign / inert-and-warn on a dangling target) plus the §8 edit refusal, with per-adapter detection and the "one edited record refuses the issue read" parity spelled out. (3 lenses raised this independently.)
  • Reversed the draft's decision to drop pending decisions on finding-level annulment. They now stay in pending_decisions[] and the gate keeps counting them; /dev:resolve restricts such a decision to accept so no sibling issue is spawned from a finding annulled as false.
  • Specified exact-token ref matching in jq against parsed header attributes, never a raw-body substring, naming the shipped prefix-collision bug it prevents.
  • Made the header authoritative for identity, dropped scope from the record as derived, and made a header/body mismatch a post-time refusal.
  • Documented the full exit-code mapping.
  • Kept _json_arg (and therefore @file) for --roll-call's value while adding a was-the-flag-seen sentinel for the absence refusal; added per-entry validation including unique agent names.
  • Sort roll_call by agent in the retry-guard normalization, and defined null vs [] as an explicit non-match.
  • Stated that roll-call enforcement lives only at the post seam, so readers must not refuse its absence.
  • Stated that by is an unverified self-report, with the same candour the roll-call's limit already had, and added annulled_count to default read output so an annulment cannot be invisible at a human touchpoint.
  • Required the same reason + by rigour on withdrawn as on active.
  • Defined per-target independence between outcome- and finding-level records on one PO.
  • Documented the filtered array as ordinal-sparse, and widened the $PO_ORDINAL fix to a mechanical scan for other len+1-on-read-output sites.
  • Added the sweep amplification to Technical Risks as an accepted cost with a named future lever, and the non-atomic two-scan race as a benign, documented behaviour.
  • Named the same-created_ms tiebreak as an explicit case in test/supersede-flow.sh (AC-10), and a shared fixture helper for the roll-call flag-day (Technical Risks).
  • Added the --include-annulled output-shape note to the schema deliverable so no consumer mixes modes.

Noted (not actioned)

  • Single-scan optimization (one scan_comments for the common phase-outcome prefix, partitioned twice locally). Raised by two lenses, so it is promoted into the plan — but as a documented, deliberately-declined lever in Technical Risks rather than as work in this slice: the performance lens found no blocking cost at this load, and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand.
  • Barring a skill-posted supersede from dissolving a blocking approval decision. Real and worth deciding, but AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. Note also that it grants no new capability: an autonomous run can already self-answer a gate through /dev:resolve's structured mode. Surfaced as a finding with a defer-to-issue recommendation so a human decides it on its own merits.

Acceptance Criteria

ID Criterion (from PREQ) Verification approach
AC-1 A supersede naming a false finding makes the read helper report that finding as annulled, carrying its reason and the identity of the annulling record Integration: test/supersede-flow.sh — post PO, supersede one finding, assert annulled_findings[0] carries reason, by and the annulling comment_id
AC-2 The annulled finding is absent from the set a consumer acts on; its sibling finding survives; the original comment is present, unedited, unchanged Integration: assert findings[] excludes it and still contains the sibling; assert the PO comment's raw_body is byte-identical and edited_ms == created_ms
AC-3 A consumer written before this feature, unmodified, reads without error and no longer acts on annulled data Integration: run promotion-candidates.sh and fold-candidates.sh unmodified against an issue carrying supersedes; assert exit 0 and that the annulled outcome's state is not what they report. Mechanical: a grep-scan asserting no consumer outside read-all.sh/_lib.sh references the supersede kind — the "honoured in one place" constraint, enforced the way the helper-funnel rule requires
AC-4 An outcome-level supersede makes the phase lookup resolve to the newest surviving outcome, walked past as skipped already is Integration: three POs, annul the newest, assert read_latest returns the middle one's next_state
AC-5 With every outcome annulled the phase lookup reports backlog — the same answer as an issue with no outcome Integration: annul the only PO on issue B; assert read_latestnull; assert issue C (no POs) → null; assert the two agree
AC-6 A finding-level supersede leaves the phase unchanged, including when every finding is annulled individually; only an outcome-level supersede moves it Integration: annul finding 1 → phase unchanged; annul finding 2 → still unchanged; assert the outcome's pending decisions are still counted at each step (the gate must not advance on annulled-finding evidence); annul the outcome → phase moves
AC-7 An annulled outcome's open blocking Pending Decisions stop blocking the decisions-settled gate Integration: assert the decision id is enumerable before and absent from read_all's pending_decisions[] after — the exact set decisions-settled-check.md iterates — and still present under --include-annulled
AC-8 Any supersede names who posted it, exactly what it annuls, and a non-empty reason Integration: assert by, target, reason on a skill-posted and an operator-posted record; assert by=operator is distinguishable from every producer name; assert a header/body mismatch is refused
AC-9 The operator can supersede an outcome from an earlier run, from a different session human-uat: operator runs /dev:resolve --supersede on this repo's own tracker from a fresh session (dogfooding). Helper-level half also covered by supersede-flow.sh (the helper carries no session state)
AC-10 A supersede posted in error is corrected by a later one naming the same target — latest-wins Integration: annul, assert annulled; post a withdrawn for the same target, assert restored; assert the first record is still readable; named case: two records on one target sharing created_ms resolve deterministically by comment_id; named case: F-PO-x-y-2 is unaffected by a supersede targeting F-PO-x-y-21
AC-11 A supersede whose target does not exist is refused, naming the bad target, leaving no record Integration: unknown PO id → exit 6, stderr names the target, comment count unchanged; a finding id belonging to a different outcome → same; an issue with no outcomes at all → same. Read side: a hand-posted dangling supersede is inert, warns on stderr naming the comment id, and does not fail the read
AC-12 A supersede missing its reason or its attribution is refused, naming what is missing Integration: absent reason, empty-string reason, absent by, and an invalid by → four refusals at exit 2, each naming the missing or invalid element; the same four for status: withdrawn
AC-13 Every Phase Outcome carries a roll-call: one entry per spawned agent disposed reported/terminated, or an explicitly empty one Integration: post with two reported; with one reported + one terminated; with [] — assert each round-trips through read_all; assert @file and inline forms both work
AC-14 A Phase Outcome with no roll-call at all is refused — absence is never read as "spawned none" Integration: omit --roll-call → exit 2 naming the missing element, no comment posted; --roll-call '' → same; then post the same payload with [] → accepted. Read side: a pre-feature record with no roll_call reads as null without error — enforcement is post-only
AC-15 A roll-call entry disposed anything other than reported/terminatedunrecoverable included — is refused Integration: unrecoverable → refused; an invented value → refused; a missing/empty agent → refused; two entries with the same agent → refused; then terminated → accepted
AC-16 (derived — correctness rider on the read-shape change) No consumer computes an ordinal or a count from read_all's filtered output; develop's $PO_ORDINAL is highest-ordinal + 1 Mechanical: a grep-scan over the shipped tree for len(/length-plus-one arithmetic on Phase-Outcome read output, asserting zero hits; integration: a ledger with an annulled outcome yields the same ordinal from the skill's stated formula as post actually mints
AC-17 (derived — retry-guard integrity under the new field) A timeout retry carrying the same roll-call in a different array order is absorbed, not duplicated Integration: post, then re-post an identical payload with roll_call entries reordered → asserts retried: true and no new ordinal; a landed roll_call: null record vs an incoming [] → not a match, mints a new ordinal

Coverage note. e2e, a11y and security-browser are declared not_applicable for this
project, so no browser lane exists to route to. Every automated row above is carried by the shell
test harness under plugin/skills/_shared/procedures/test/, which was confirmed green in this
session (smoke.sh, typed-scan.sh, lint-conventions.sh all rc=0) — the lane boots here today.

On AC-13's honesty. The roll-call is a self-report, and these rows check that the record is
well-formed and attributable, never that it is true. The same applies to by in AC-8. The PREQ
accepts the roll-call limit explicitly; this SREQ extends the same candour to attribution. Do not let
a green AC-13 or AC-8 be read as proof that a phase enumerated its agents faithfully, or that
by=operator means a human was present.

Implementation Scope

Areas

Area Files / directories involved Nature of change
New record schema plugin/skills/_shared/schemas/phase-outcome-supersede.v1.md; the table + anchored-header notes in schemas/README.md new
Supersede helper plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh new
Annulment lookup + read gating plugin/skills/_shared/procedures/bin/_lib.sh — add _po_annulment beside _po_foreign_gate, carrying the four-way fail-closed partition extend
Read path bin/phase-outcome-read-all.sh (annulment pass, --include-annulled, annulled_count, density moved onto the raw ledger) modify
Post path bin/phase-outcome-post.sh (--roll-call: sentinel-for-absence + _json_arg for the value, per-entry validation, agent-sorted in the retry-guard normalization) modify
Phase Outcome schema schemas/phase-outcome.v1.md — optional roll_call, the post-only enforcement seam, the normalization-excluded-set note, both projected read shapes extend
Forge contract forge-contract.md §2.1 (phase_outcome.supersede), §7 (why no bump), §8 (the new immutable kind), §9 (latest-wins per target) extend
Procedure docs procedures/phase-outcome.md (the new op + both read shapes), decisions-settled-check.md (the gate inherits exclusion; finding-level annulment does not unblock), wait-discipline.md (the roll-call as §1a/§1b's post-time counterpart) modify
Operator surface plugin/skills/resolve/SKILL.md--supersede structured mode, interactive entry, frontmatter description, and the accept-only restriction on a decision whose finding_ref is annulled extend
Producer call sites requirements/SKILL.md (2), technical-plan/SKILL.md (3), develop/SKILL.md (3, plus the $PO_ORDINAL fix), integrate/SKILL.md (1), promote/SKILL.md (2), procedures/rebase-onto-base.md (1), procedures/qa-playbook.md §8 (the per-round PO — the domain-spawning phase where the roll-call matters most) modify
Test suites procedures/test/supersede-flow.sh (new) + a shared roll-call fixture helper; smoke.sh, typed-scan.sh, typed-scan-family.sh, provenance.sh, disposition-flow.sh, promotion-flow.sh, release-flow.sh, supervise/test/supervisor-flow.sh (every phase-outcome-post.sh call gains --roll-call) new / modify

File Boundaries

Three independent tracks once the schema is fixed:

  • Read track_lib.sh _po_annulment + read-all.sh.
  • Write trackphase-outcome-supersede.sh + the new schema doc.
  • Roll-call trackpost.sh + the ~12 producer call sites + the existing suites.

They touch _lib.sh and post.sh disjointly (_po_annulment is a new function; --roll-call is a
new arm in post.sh's parser), so they can proceed in parallel. Doc/contract edits follow whichever
track settles the behaviour they describe. resolve/SKILL.md is touched by the write track (the
--supersede door) and by the read track (the accept-only restriction) — assign it to one owner.

Dependencies & Sequencing

  1. The schema doc first — both the read and write tracks encode its field names, its
    header-authoritative identity rule and its latest-wins rule; settling it late means two
    implementations drift.
  2. Read and write tracks in parallel; supersede-flow.sh needs both to be exercisable end-to-end.
  3. The roll-call track is independent of both and can land first. Its own internal order is
    load-bearing: post.sh must not require --roll-call until the call sites and suites pass it,
    or every suite goes red at once. Land the fixture helper, then the call sites and suites, then
    flip the requirement on.

Constraints & Non-Goals

Constraints:

  • Portability baseline: POSIX skill-authored glue; helper tier bash ≥ 3.2 + jq + git, no
    python/node, no bashisms in emitted shell.
  • No new adapter primitivepost_comment + scan_comments only (verified against the real
    forge; see External Data Contracts).
  • No phase-outcome:v1:v2 bump. Cross-version reads are unsupported, so a bump would make
    every existing record invisible to the new reader.
  • The annulled record is never edited or deleted; §8 is preserved, not relaxed.
  • Enforcement of the roll-call is on the record's shape, never on live agents, and lives only at the
    post seam.

Non-goals (do NOT build):

  • The general decision-withdrawal path (#36). This slice only stops decisions belonging to an
    annulled outcome from blocking.
  • Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1) — already latest-wins.
  • Editing or deleting a posted Phase Outcome — remains a contract violation.
  • A roll-call that sees live agents — needs harness knowledge the stack-agnostic rule forbids; the
    resulting "cannot be proved complete" limit is accepted knowingly.
  • Migrating history — records posted before this feature simply carry no supersede and no roll-call;
    nothing is rewritten.
  • Verifying by against the comment's real author — would need a new adapter primitive; the field
    is a recorded claim, and the schema says so.
<!-- sreq:v1 issue=45 skill=technical-plan --> # Software Requirements: 45-phase-outcome-supersede ## Context `phase-outcome:v1` is the suite's terminal handoff record and is **immutable** by contract (§8) — so it is the only durable record with no correction path. A phase lead that discovers, after posting, that its own record states something false can do nothing about it; a false finding stays live and a Pending Decision raised on a false premise stays open. The recorded incident: a five-lens review panel returned nothing, the lead concluded the transport was broken and wrote *"panel could not be run"* into its Phase Outcome, and the panel's results arrived **34 seconds later** carrying two blocking concerns. This slice adds a **supersede** path (recovery) and a **roll-call** (prevention), which the PREQ ships together because each was shown insufficient alone. ## Approaches Considered ### Approach A: A new sibling record kind, honoured inside the read helper **Summary:** annulment is a new append-only comment kind, `phase-outcome-supersede:v1`, and the one place that honours it is `phase_outcome.read_all` — so every compliant consumer inherits the behaviour unmodified. **Pros:** `phase-outcome:v1`'s wire format is untouched, so no `:v2` bump and no history goes invisible; the annulled comment is never edited, so §8 stands unweakened; the compatibility promise is true *by construction* rather than by an enumerated consumer list; latest-wins-per-target reuses §9's rule verbatim, and the append-only `active`/`withdrawn` shape reuses `folded-finding:v1`'s proven pattern; no new adapter primitive. **Cons:** `read_all` gains a second comment scan per call; the record set a consumer sees is no longer literally the comment set on the issue, so history needs an explicit opt-in flag. **Effort:** Medium ### Approach B: Annulment carried inside a new Phase Outcome **Summary:** add a `supersedes[]` body field to `phase-outcome:v1`; correcting a record means minting a new outcome that names what it annuls. **Pros:** no new record kind; the corrector is itself a first-class outcome, so it carries its own attribution, roll-call and ordinal, and the ledger remains the whole story in one chain. **Cons:** ruinously heavy for the actual use case. An operator annulling one false finding months later must mint a whole Phase Outcome — which forces them to choose a `next_state` (moving pipeline state as a *side effect of a correction*), supply a roll-call for a run that never happened, and pick a producer name from a closed enum that has no `operator` in it. The PREQ rejected forcing a replacement outcome for exactly this weight (decision 4). **Effort:** Medium ### Approach C: Reuse `decision-resolution:v1` **Summary:** express annulment as an "annulment decision" resolved through `/dev:resolve`. **Pros:** zero new schema; reuses the §9 lookup rule and the whole existing operator surface. **Cons:** a decision is a **question**; an annulment is an **assertion**. It needs a Pending Decision to exist first, which the false outcome never raised — so a self-correcting skill would be answering its own invented question. And decision IDs are derived from Phase Outcome ordinals, so an operator arriving weeks later has no ID to name. **Effort:** Low ## Decision **Selected:** Approach A — a new sibling record kind, honoured inside the read helper. **Rationale:** it is the only shape that keeps both promises the PREQ treats as non-negotiable — the annulled record is never touched (§8 stands) and the exclusion is inherited by consumers written before the feature existed (honoured in one place, not an enumerated list). B and C both fail the second promise or pay for the first with a correction act heavier than the thing it corrects. ## Architecture ### Component Overview ``` producer skill operator | | | phase_outcome.supersede | /dev:resolve --supersede v v bin/phase-outcome-supersede.sh (validates target, reason, attribution) | | post_comment <!-- phase-outcome-supersede:v1 ref=… by=… --> v ┌───────────────────────── the issue's comment ledger ─────────────────────────┐ │ phase-outcome:v1 ×N (immutable, dense ordinals) │ │ phase-outcome-supersede:v1 ×M (append-only, latest-wins per target) │ └──────────────────────────────────────────────────────────────────────────────┘ ^ | | scan_comments ×2 | _po_annulment (bin/_lib.sh) | v bin/phase-outcome-read-all.sh ──► filtered ledger ──► every consumer ^ (read_latest, decisions-settled, | /dev:resolve, integrate, promote, bin/phase-outcome-read-latest.sh fold-/promotion-candidates, …) ``` ### Data Flow **Annulling.** A skill (mid-run, on discovering its own record is false) or the operator (later, from any session) calls the supersede helper with a target, a reason and an attribution. The helper reads the **unfiltered** ledger, refuses an unresolvable target, and posts one append-only comment. **Reading.** `read_all` scans the Phase Outcome records exactly as today, runs the §8 immutability check, parses, applies the foreign-issue gate, and enforces ordinal density **on the raw ledger**. Only then does it scan the supersede kind, resolve latest-wins per target through `_po_annulment`, and project the filtered view. `read_latest` wraps `read_all`, so the current-phase lookup inherits annulment with no code of its own. **Preventing.** `phase_outcome.post` requires a `roll_call` array. It validates the array's *shape* and refuses absence — it never inspects live agents. ### The supersede record ``` <!-- phase-outcome-supersede:v1 ref={PO-id | F-id} by={producer|operator} --> { "status": "active" | "withdrawn", "target": "{ref}", // echoes the header; the header is authoritative "by": "{ref}", // echoes the header; the header is authoritative "reason": "<non-empty>" } ``` `scope` (outcome vs finding) is **derived from `ref`'s form** (`PO-…` vs `F-PO-…`) and deliberately not stored — a third copy of the same fact is a third thing that can disagree. The body echoes `target`/`by` for a human reading the comment, exactly as `folded-finding:v1` echoes its header tokens; the **post helper refuses any header/body mismatch**, and **readers treat the header as authoritative for identity**. **Matching `ref` is exact-token, never substring.** `F-PO-45-3-2` is a substring-prefix of `F-PO-45-3-21`, and this suite has already shipped that bug once (`ref=D-PO-2-4-1` matched `ref=D-PO-2-4-12`, observed live). The reader parses the header's `key=value` attributes with the anchored-header regex and compares with **jq string equality** — it never greps the raw body. ### Read-side gating of supersede records — fail closed The supersede kind *removes data from what consumers act on*, so a record of it that cannot be trusted must never be honoured silently, and must never be *dropped* silently either. Four disjoint outcomes in `_po_annulment`, mirroring the family's existing partition: | What was scanned | What happens | | --- | --- | | Anchored + parseable + `ref` names a target on **this** issue | Honoured (latest-wins per target). | | Anchored + **unparseable** body, or unknown `status` | **Hard refusal** of the read (`EXIT_ENV`), naming the comment id and the offending element — the `_po_parse` treatment. Silently ignoring it could resurrect a finding that was genuinely annulled. | | Anchored + `ref` claims a target on a **different issue** | **Hard refusal**, same gate and wording as `_po_foreign_gate`. A session must not annul another issue's records. | | Anchored + well-formed but the target **does not exist** on this issue (a dangling, hand-posted record) | **Inert + warned on stderr every read**, naming the comment id — the near-miss treatment. It annuls nothing, and refusing the whole read would let one hand-posted comment take an issue offline. | | **Edited** (`edited_ms > created_ms`) | **§8-strength hard refusal** (`EXIT_IMMUTABLE`). Detected by the same signal every adapter already renders (local-fs pins mtime to `epoch_ms`; remote adapters render `created_at`/`updated_at`) — no new mechanism. One edited record refuses the whole issue read, §8 parity. That an edit can therefore take an issue's reads offline is **tamper-evidence working as intended**, not a defect. | ### External Data Contracts | Boundary / source | Operation | Real shape (verified) | Provenance | | ----------------- | --------- | --------------------- | ---------- | | Gitea (`git.wihslon.com` / `jbr870/devwork-skills`) via the `tea-cli` adapter — the comment ledger this feature reads and writes | `scan_comments(issue, header)` read; `post_comment(issue, body)` write | `[{comment_id: string, created_ms: number, edited_ms: number, raw_body: string}]`; line 1 of a record round-trips **byte-identical**, read back as `<!-- phase-outcome:v1 id=PO-45-1 skill=requirements -->` | `recorded` → `external-contracts/forge-comment-ledger.provenance.json` | The capture confirms the two facts the design rests on: an HTML-comment header with space-delimited `key=value` attributes survives the round trip (so a new sibling kind needs **no new adapter primitive**), and the scan envelope already carries `created_ms` + `edited_ms` — the ordering key for latest-wins-per-target and the §8 edit signal are both present today. No boundary is `unverified`, so this phase raises **no precondition**. ### Observability & Audit Not applicable as a *policy* obligation: the project declares `observability: mode: none`, so the applicability test does not intersect and no logging/audit AC rows are owed. Recorded here only because the reason for that `none` is load-bearing for this feature — *"run traceability is the forge record (Phase Outcomes, deliverables), not logs"*. The record trail **is** this project's only audit surface, which is why §8's tamper-evidence is preserved rather than relaxed, and why the supersede record itself carries mandatory attribution and reason. **The honest limit on attribution.** `by` is an **unverified self-report**, exactly as the roll-call is. Nothing in the forge binds it to the comment's actual author, and the suite deliberately adds no adapter primitive to fetch one. It is a *claim*, recorded and permanent — which is the same standing the suite already gives a `/dev:resolve` resolution posted by an autonomous run. State this in the schema beside the roll-call's limit; do not let a reader mistake `by=operator` for proof of a human. ### Key Decisions Each of these settles something the PREQ deliberately deferred to this phase, or something the review panel raised. | Decision | Choice | Rationale | | --- | --- | --- | | Wire form of an annulment | A new kind `phase-outcome-supersede:v1` (see *The supersede record*) | A sibling kind leaves `phase-outcome:v1`'s wire format untouched, so §7's reader test is trivially passed and no bump is owed. | | Target granularity | **One record, one target.** `ref` is a PO id (`PO-45-3`) or a finding id (`F-PO-45-3-2`) | Finding IDs embed their PO id, so a single `ref` identifies both cases and latest-wins keys on one value. N targets = N records. (Settles *"may one supersede target findings across multiple outcomes"* — no.) | | Identity conflicts | Header `ref`/`by` are **authoritative**; the body echoes them; post refuses a mismatch; `scope` is derived, never stored | Three copies of one fact is three things that can disagree. Mirrors `folded-finding:v1`'s header/body echo. | | `ref` matching | Exact header-attribute token equality in jq — never a raw-body substring | `F-PO-45-3-2` prefixes `F-PO-45-3-21`; this exact bug class already shipped once. | | Ordering: "newest surviving outcome" | The PO **ordinal** | `read_all` already sorts by it and enforces density; ordinals are minted monotonically and are the ledger's identity. Timestamps are the *supersede* key, not the outcome key. | | Ordering: which supersede governs a target | Latest by `(created_ms, comment_id)` | Literally §9's rule and its deterministic same-second tiebreaker; a concurrent pair resolves without a special case. | | Un-annulling | `status: withdrawn`, append-only — never an edit or a delete. **`withdrawn` carries the same mandatory `reason` + `by` and the same refusal set as `active`** | The `folded-finding:v1` pattern. Un-annulling restores a record to force, so it deserves equal attribution rigour. | | Precedence between outcome- and finding-level records on one PO | **Independent, per target.** Annulling `PO-45-3` does not moot a `withdrawn` on `F-PO-45-3-2`; each target resolves on its own, and an annulled outcome leaves the actionable set whatever its findings say | Per-target latest-wins needs no precedence rule; adding one would create a second lookup to keep consistent. | | Attribution value for the operator | The reserved literal `by=operator`; every other value must be a canonical producer name | `operator` is disjoint from the closed producer enum (`requirements`, `technical-plan`, `develop`, `rebase`, `qa`, `integrate`, `promote`, `vibecoding`, `resolve`, `setup`), so skill-vs-operator is decidable from the value alone. Its truthfulness is a claim, not a proof — see *Observability & Audit*. | | Where annulment is honoured | `phase-outcome-read-all.sh`, via one `_po_annulment` in `bin/_lib.sh` | The contract already forbids re-implementing scan-and-resolve (§9). One implementation is what makes the compatibility criterion true by construction. | | What a default read returns | Annulled **outcomes** absent from the array; annulled **findings** moved out of `findings[]` into an additive `annulled_findings[]`; plus an additive `annulled_count` on every surviving outcome's sibling projection | Absence is what gives an unmodified consumer the exclusion for free (`read_latest`'s `last` becomes the newest survivor; an empty array → `null` → `backlog`). `annulled_count` lets a consumer *know filtered history exists* without opting into it — the visibility hook that keeps an annulment from being invisible at a human touchpoint. | | Reading history | `--include-annulled` returns the whole ledger with `superseded: {…}\|null` on each outcome and finding | The history door, and what the supersede helper itself reads. Additive; default behaviour unchanged. The two modes have **different output shapes** — the schema says so, so no consumer mixes them. | | Density check placement | On the **raw** ledger, before filtering. The **filtered** array is deliberately ordinal-sparse | Removing an outcome would otherwise trip the existing `[.[].ordinal] == [range(1;length+1)]` dense check. Consumers must not re-assert density on read output, nor compute `len+1` from it. | | Decisions paired to an annulled **finding** | **Left in `pending_decisions[]`** — the gate keeps counting them. `/dev:resolve` presents such a decision but restricts it to `accept` | Dropping them was the draft's plan and two lenses caught it: a finding-level supersede would then silently pass the decisions-settled gate while the outcome and its `next_state` still stand — pipeline state advancing on annulled evidence, which AC-6 forbids. The real hazard (resolving `defer-to-issue` spawning a sibling issue **from a finding annulled as false**) is closed at the acting site instead of by hiding the question. | | Decisions on an annulled **outcome** | Stop blocking, per AC-7 — regardless of who posted the supersede | AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. The security question that raises is surfaced as a finding + decision, not resolved unilaterally. | | What `post` sees | The **raw** ledger, always | Ordinal minting, the collision check, the foreign-issue gate, the duplicate-ordinal gate and the timeout-retry guard must not shift when a record is annulled: an annulled outcome keeps its ordinal forever, so ordinals stay dense and monotonic. | | Roll-call dispositions | `reported` \| `terminated`, exactly one per entry, exactly one entry per spawned agent, **`agent` names unique within a roll-call**. **No `unrecoverable`**; any other value refused | `reported` = the lead **read** the result, or reconstructed the outcome from the tree per `wait-discipline.md` §1a step 3. `terminated` = the lead ended the agent before posting, so no late report can arrive. A terminated agent whose report was already read is `reported`. The omission is the teeth. | | Roll-call scope | **Direct spawns only** — transitive children are not counted | A lead can only account for what it started; a teammate's children are that teammate's roll-call. Enumerating them would need harness knowledge the stack-agnostic rule forbids. Stated as an accepted limit beside the existing one. | | Roll-call absence | `--roll-call` omitted, or given an **empty string**, is refused; `[]` is a valid "spawned none". The flag still accepts `@file` or an inline literal via `_json_arg` | The evasion is one omission wide (PREQ decision 9), so the parser keeps an explicit *was-the-flag-seen* sentinel — but bypassing `_json_arg` entirely would silently drop `@file` support and break symmetry with `--produced`/`--findings`. Sentinel for absence, `_json_arg` for the value. | | Roll-call versioning + enforcement seam | **Optional in the schema, required by the post helper.** Readers MUST NOT refuse its absence | Producer-side narrowing, which §7 allows without a bump: no existing record becomes unreadable and no reader rejects one. Pre-feature records read `roll_call: null`, exactly as `suite` does. Enforcement lives **only** at the post seam, so a hand-posted record bypasses it — which is why readers must tolerate absence. | | Which outcomes need a roll-call | **Every** `phase-outcome:v1` | Every Phase Outcome *is* the terminal handoff of a phase run (schema line 1). This dissolves the deferred *"enumeration of terminal `next_state` values"*: the qualifier is the record kind, not a state list — and it leaves no escape hatch. | | Roll-call vs the retry guard | **Included** in the normalized comparison (unlike `suite`), **sorted by `agent`** before comparing. A landed pre-feature record with `roll_call: null` never matches an incoming `[]` | `suite` is excluded because it describes the *writer*; a roll-call describes the *run's content*. Sorting matters: array-order jitter between two attempts would otherwise defeat the guard and mint a duplicate outcome — the failure the guard exists to prevent. | | Exit codes | `EXIT_USAGE` (2) for missing/empty `--roll-call`, `--reason`, `--by`, a bad disposition or a malformed entry; `EXIT_FINDING` (6) for an unresolvable target; `EXIT_ENV` (7) for a foreign or unparseable supersede at read time; `EXIT_IMMUTABLE` (4) for an edited one | Callers branch on rc; an unknown target is a state-dependent record-shape refusal, not a usage error, and leaving the mapping implicit invites drift. | | Superseding an already-annulled target | Accepted, and a record **is** posted; the helper returns `already_annulled: true` | It must be accepted — re-annulling after a `withdrawn`, and recording a better reason, are both this path. Posting the record is what makes it an *inert, recorded* no-op rather than a silent one. | | Operator surface | Extend `/dev:resolve` (`--supersede <target> --reason "…"`, plus an interactive menu entry) | The PREQ asked for "a command in the shape of `/dev:resolve`". One orchestrator surface, and it avoids adding a shipped skill (which also carries the `SHIPPED_SKILLS` lint step). Rejected: a new `/dev:supersede` skill. The **helper** is the real API — a skill correcting itself mid-run calls it directly. | ## Technical Risks | Risk | Likelihood | Impact | Mitigation | | --- | --- | --- | --- | | Making `--roll-call` mandatory breaks every existing producer call site at once | High | High | It is a mechanical, enumerable edit (see *Implementation Scope*), and the shell suites fail loudly the moment one is missed. Sequencing is the mitigation: land the call sites and suites first, flip the requirement on last. A shared fixture helper in `test/` keeps the flag-day to one edit per suite. | | `$PO_ORDINAL = len(prior_outcomes) + 1` silently drifts once outcomes can be filtered out | Medium | High | Already wrong for a gapped ledger (`post` mints `max+1`); annulment makes it wrong more often. Fixed in this slice **and** covered by a mechanical scan for other `len(...)+1`-on-read-output call sites — `develop` is one of N, not necessarily the only one. | | A reader assumes `read_all`'s array is still the literal comment set, or re-asserts density on it | Medium | Medium | `--include-annulled` is the documented history door; `annulled_count` signals filtered history in default mode; the schema, procedure doc and contract all state the default is the *actionable* set, not the ledger, and that it is ordinal-sparse. | | Second comment scan per `read_all`, **amplified repo-wide by the candidate sweeps** — `promotion-candidates.sh` / `fold-candidates.sh` go from one to two forge calls per open issue, including issues that can never carry a supersede | Medium | Low | **Accepted knowingly, not discovered.** The performance lens found no blocking concern at this load (tens of comments per issue, a handful of reads per phase, tens of open issues; seconds at worst on a remote adapter). Two lenses independently noted the available lever: `phase-outcome-supersede:v1` *contains* the substring `phase-outcome`, so one scan could feed both filters locally. Not taken now — it entangles the anchored-header / near-miss discrimination (#54) for a cost nobody is paying. If the sweeps ever feel slow, fix it **there** (one comment fetch per issue, reused for both kinds), not in `read_all`. | | The two scans in `read_all` are not atomic | Low | Low | Benign: a supersede posted between them simply takes effect on the next read. Documented rather than locked. | ## Expert Review **Panel: Tier 3, five lenses, on `fable`.** Reviewer reports carried 12 blocking concerns; 11 are actioned below and 1 is surfaced as a finding for the orchestrator. > **Process note, recorded because this feature is about exactly this.** Four of the five reviewers > went **idle without delivering their reports**. Following `wait-discipline.md` §1a, the tree was read > before any conclusion was drawn — all four had *written* complete reviews; the reports were dropped, > not the work — and every one was recovered and used. Had the panel been recorded as "could not be > run", this SREQ would have shipped without 8 of its blocking corrections, reproducing the > feature-230 incident inside the plan for the fix. This phase's own roll-call records all five as > `reported`, four of them reconstructed from the tree. ### Reviewers - **Solution Architect** — read-side gating of supersede records was unstated; dropping decisions on finding-level annulment exceeds the AC and lets state advance on annulled evidence. - **Database Expert** — the `ref`-substring hazard would recur; three copies of the target have no conflict rule; the filtered array is ordinal-sparse and consumers must be told. - **Backend Developer** — exit-code mapping unspecified; bypassing `_json_arg` for `--roll-call` silently drops `@file`; roll-call array-order jitter defeats the retry guard. - **Security Specialist** — `by` is a self-declared claim and the plan said so for the roll-call but not for attribution; a supersede can dissolve a blocking approval gate; the foreign-issue gate was unstated for the new kind. - **Performance Engineer** — no blocking concern; the doubled scan is acceptable at this load, but the repo-wide sweep amplification should be *chosen*, not discovered. ### Changes Made - Added **Read-side gating of supersede records — fail closed**: a four-way partition (honour / hard-refuse unparseable / hard-refuse foreign / inert-and-warn on a dangling target) plus the §8 edit refusal, with per-adapter detection and the "one edited record refuses the issue read" parity spelled out. *(3 lenses raised this independently.)* - **Reversed the draft's decision to drop pending decisions on finding-level annulment.** They now stay in `pending_decisions[]` and the gate keeps counting them; `/dev:resolve` restricts such a decision to `accept` so no sibling issue is spawned from a finding annulled as false. - Specified **exact-token `ref` matching** in jq against parsed header attributes, never a raw-body substring, naming the shipped prefix-collision bug it prevents. - Made the **header authoritative for identity**, dropped `scope` from the record as derived, and made a header/body mismatch a post-time refusal. - Documented the full **exit-code mapping**. - Kept `_json_arg` (and therefore `@file`) for `--roll-call`'s *value* while adding a was-the-flag-seen **sentinel** for the absence refusal; added per-entry validation including **unique `agent` names**. - **Sort `roll_call` by `agent`** in the retry-guard normalization, and defined `null` vs `[]` as an explicit non-match. - Stated that roll-call enforcement lives **only at the post seam**, so **readers must not refuse its absence**. - Stated that **`by` is an unverified self-report**, with the same candour the roll-call's limit already had, and added **`annulled_count`** to default read output so an annulment cannot be invisible at a human touchpoint. - Required the same `reason` + `by` rigour on **`withdrawn`** as on `active`. - Defined **per-target independence** between outcome- and finding-level records on one PO. - Documented the filtered array as **ordinal-sparse**, and widened the `$PO_ORDINAL` fix to a **mechanical scan** for other `len+1`-on-read-output sites. - Added the **sweep amplification** to Technical Risks as an accepted cost with a named future lever, and the **non-atomic two-scan race** as a benign, documented behaviour. - Named the **same-`created_ms` tiebreak** as an explicit case in `test/supersede-flow.sh` (AC-10), and a **shared fixture helper** for the roll-call flag-day (Technical Risks). - Added the **`--include-annulled` output-shape note** to the schema deliverable so no consumer mixes modes. ### Noted (not actioned) - **Single-scan optimization** (one `scan_comments` for the common `phase-outcome` prefix, partitioned twice locally). Raised by two lenses, so it is promoted into the plan — but as a **documented, deliberately-declined lever** in Technical Risks rather than as work in this slice: the performance lens found no blocking cost at this load, and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand. - **Barring a skill-posted supersede from dissolving a blocking `approval` decision.** Real and worth deciding, but AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. Note also that it grants no *new* capability: an autonomous run can already self-answer a gate through `/dev:resolve`'s structured mode. Surfaced as a finding with a `defer-to-issue` recommendation so a human decides it on its own merits. ## Acceptance Criteria | ID | Criterion (from PREQ) | Verification approach | | --- | --- | --- | | AC-1 | A supersede naming a false finding makes the read helper report that finding as annulled, carrying its reason and the identity of the annulling record | Integration: `test/supersede-flow.sh` — post PO, supersede one finding, assert `annulled_findings[0]` carries `reason`, `by` and the annulling `comment_id` | | AC-2 | The annulled finding is absent from the set a consumer acts on; its sibling finding survives; the original comment is present, unedited, unchanged | Integration: assert `findings[]` excludes it and still contains the sibling; assert the PO comment's `raw_body` is byte-identical and `edited_ms == created_ms` | | AC-3 | A consumer written before this feature, **unmodified**, reads without error and no longer acts on annulled data | Integration: run `promotion-candidates.sh` and `fold-candidates.sh` **unmodified** against an issue carrying supersedes; assert exit 0 and that the annulled outcome's state is not what they report. **Mechanical**: a grep-scan asserting no consumer outside `read-all.sh`/`_lib.sh` references the supersede kind — the "honoured in one place" constraint, enforced the way the helper-funnel rule requires | | AC-4 | An outcome-level supersede makes the phase lookup resolve to the newest **surviving** outcome, walked past as `skipped` already is | Integration: three POs, annul the newest, assert `read_latest` returns the middle one's `next_state` | | AC-5 | With every outcome annulled the phase lookup reports `backlog` — the same answer as an issue with no outcome | Integration: annul the only PO on issue B; assert `read_latest` → `null`; assert issue C (no POs) → `null`; assert the two agree | | AC-6 | A finding-level supersede leaves the phase unchanged, including when every finding is annulled individually; only an outcome-level supersede moves it | Integration: annul finding 1 → phase unchanged; annul finding 2 → still unchanged; **assert the outcome's pending decisions are still counted at each step** (the gate must not advance on annulled-finding evidence); annul the outcome → phase moves | | AC-7 | An annulled outcome's open blocking Pending Decisions stop blocking the decisions-settled gate | Integration: assert the decision id is enumerable before and absent from `read_all`'s `pending_decisions[]` after — the exact set `decisions-settled-check.md` iterates — and still present under `--include-annulled` | | AC-8 | Any supersede names who posted it, exactly what it annuls, and a non-empty reason | Integration: assert `by`, `target`, `reason` on a skill-posted and an operator-posted record; assert `by=operator` is distinguishable from every producer name; assert a header/body mismatch is refused | | AC-9 | The operator can supersede an outcome from an earlier run, from a different session | **human-uat**: operator runs `/dev:resolve --supersede` on this repo's own tracker from a fresh session (dogfooding). Helper-level half also covered by `supersede-flow.sh` (the helper carries no session state) | | AC-10 | A supersede posted in error is corrected by a later one naming the same target — latest-wins | Integration: annul, assert annulled; post a `withdrawn` for the same target, assert restored; assert the first record is still readable; **named case: two records on one target sharing `created_ms` resolve deterministically by `comment_id`**; **named case: `F-PO-x-y-2` is unaffected by a supersede targeting `F-PO-x-y-21`** | | AC-11 | A supersede whose target does not exist is refused, naming the bad target, leaving no record | Integration: unknown PO id → exit 6, stderr names the target, comment count unchanged; a finding id belonging to a **different** outcome → same; an issue with no outcomes at all → same. **Read side**: a hand-posted dangling supersede is inert, warns on stderr naming the comment id, and does not fail the read | | AC-12 | A supersede missing its reason or its attribution is refused, naming what is missing | Integration: absent reason, empty-string reason, absent `by`, and an invalid `by` → four refusals at exit 2, each naming the missing or invalid element; the same four for `status: withdrawn` | | AC-13 | Every Phase Outcome carries a roll-call: one entry per spawned agent disposed `reported`/`terminated`, or an explicitly empty one | Integration: post with two `reported`; with one `reported` + one `terminated`; with `[]` — assert each round-trips through `read_all`; assert `@file` and inline forms both work | | AC-14 | A Phase Outcome with **no** roll-call at all is refused — absence is never read as "spawned none" | Integration: omit `--roll-call` → exit 2 naming the missing element, no comment posted; `--roll-call ''` → same; then post the same payload with `[]` → accepted. **Read side**: a pre-feature record with no `roll_call` reads as `null` **without** error — enforcement is post-only | | AC-15 | A roll-call entry disposed anything other than `reported`/`terminated` — `unrecoverable` included — is refused | Integration: `unrecoverable` → refused; an invented value → refused; a missing/empty `agent` → refused; two entries with the same `agent` → refused; then `terminated` → accepted | | AC-16 | *(derived — correctness rider on the read-shape change)* No consumer computes an ordinal or a count from `read_all`'s **filtered** output; `develop`'s `$PO_ORDINAL` is highest-ordinal + 1 | **Mechanical**: a grep-scan over the shipped tree for `len(`/`length`-plus-one arithmetic on Phase-Outcome read output, asserting zero hits; integration: a ledger with an annulled outcome yields the same ordinal from the skill's stated formula as `post` actually mints | | AC-17 | *(derived — retry-guard integrity under the new field)* A timeout retry carrying the same roll-call in a different array order is absorbed, not duplicated | Integration: post, then re-post an identical payload with `roll_call` entries reordered → asserts `retried: true` and no new ordinal; a landed `roll_call: null` record vs an incoming `[]` → not a match, mints a new ordinal | **Coverage note.** `e2e`, `a11y` and `security-browser` are declared `not_applicable` for this project, so no browser lane exists to route to. Every automated row above is carried by the shell test harness under `plugin/skills/_shared/procedures/test/`, which was confirmed green in this session (`smoke.sh`, `typed-scan.sh`, `lint-conventions.sh` all rc=0) — the lane boots here today. **On AC-13's honesty.** The roll-call is a **self-report**, and these rows check that the record is well-formed and attributable, never that it is *true*. The same applies to `by` in AC-8. The PREQ accepts the roll-call limit explicitly; this SREQ extends the same candour to attribution. Do not let a green AC-13 or AC-8 be read as proof that a phase enumerated its agents faithfully, or that `by=operator` means a human was present. ## Implementation Scope ### Areas | Area | Files / directories involved | Nature of change | | --- | --- | --- | | New record schema | `plugin/skills/_shared/schemas/phase-outcome-supersede.v1.md`; the table + anchored-header notes in `schemas/README.md` | new | | Supersede helper | `plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh` | new | | Annulment lookup + read gating | `plugin/skills/_shared/procedures/bin/_lib.sh` — add `_po_annulment` beside `_po_foreign_gate`, carrying the four-way fail-closed partition | extend | | Read path | `bin/phase-outcome-read-all.sh` (annulment pass, `--include-annulled`, `annulled_count`, density moved onto the raw ledger) | modify | | Post path | `bin/phase-outcome-post.sh` (`--roll-call`: sentinel-for-absence + `_json_arg` for the value, per-entry validation, agent-sorted in the retry-guard normalization) | modify | | Phase Outcome schema | `schemas/phase-outcome.v1.md` — optional `roll_call`, the post-only enforcement seam, the normalization-excluded-set note, both projected read shapes | extend | | Forge contract | `forge-contract.md` §2.1 (`phase_outcome.supersede`), §7 (why no bump), §8 (the new immutable kind), §9 (latest-wins per target) | extend | | Procedure docs | `procedures/phase-outcome.md` (the new op + both read shapes), `decisions-settled-check.md` (the gate inherits exclusion; finding-level annulment does **not** unblock), `wait-discipline.md` (the roll-call as §1a/§1b's post-time counterpart) | modify | | Operator surface | `plugin/skills/resolve/SKILL.md` — `--supersede` structured mode, interactive entry, frontmatter description, **and the `accept`-only restriction on a decision whose `finding_ref` is annulled** | extend | | **Producer call sites** | `requirements/SKILL.md` (2), `technical-plan/SKILL.md` (3), `develop/SKILL.md` (3, **plus the `$PO_ORDINAL` fix**), `integrate/SKILL.md` (1), `promote/SKILL.md` (2), `procedures/rebase-onto-base.md` (1), `procedures/qa-playbook.md` §8 (the per-round PO — the domain-spawning phase where the roll-call matters most) | modify | | Test suites | `procedures/test/supersede-flow.sh` (new) + a shared roll-call fixture helper; `smoke.sh`, `typed-scan.sh`, `typed-scan-family.sh`, `provenance.sh`, `disposition-flow.sh`, `promotion-flow.sh`, `release-flow.sh`, `supervise/test/supervisor-flow.sh` (every `phase-outcome-post.sh` call gains `--roll-call`) | new / modify | ### File Boundaries Three independent tracks once the schema is fixed: - **Read track** — `_lib.sh` `_po_annulment` + `read-all.sh`. - **Write track** — `phase-outcome-supersede.sh` + the new schema doc. - **Roll-call track** — `post.sh` + the ~12 producer call sites + the existing suites. They touch `_lib.sh` and `post.sh` disjointly (`_po_annulment` is a new function; `--roll-call` is a new arm in `post.sh`'s parser), so they can proceed in parallel. Doc/contract edits follow whichever track settles the behaviour they describe. `resolve/SKILL.md` is touched by the write track (the `--supersede` door) and by the read track (the `accept`-only restriction) — assign it to one owner. ### Dependencies & Sequencing 1. **The schema doc first** — both the read and write tracks encode its field names, its header-authoritative identity rule and its latest-wins rule; settling it late means two implementations drift. 2. Read and write tracks in parallel; `supersede-flow.sh` needs both to be exercisable end-to-end. 3. **The roll-call track is independent of both** and can land first. Its own internal order is load-bearing: `post.sh` must not *require* `--roll-call` until the call sites and suites pass it, or every suite goes red at once. Land the fixture helper, then the call sites and suites, then flip the requirement on. ## Constraints & Non-Goals **Constraints:** - Portability baseline: POSIX skill-authored glue; helper tier bash ≥ 3.2 + `jq` + `git`, no `python`/`node`, no bashisms in emitted shell. - **No new adapter primitive** — `post_comment` + `scan_comments` only (verified against the real forge; see *External Data Contracts*). - **No `phase-outcome:v1` → `:v2` bump.** Cross-version reads are unsupported, so a bump would make every existing record invisible to the new reader. - The annulled record is **never** edited or deleted; §8 is preserved, not relaxed. - Enforcement of the roll-call is on the record's shape, never on live agents, and lives only at the post seam. **Non-goals (do NOT build):** - The general decision-withdrawal path (#36). This slice only stops decisions belonging to an *annulled outcome* from blocking. - Correcting deliverables (`sreq:v1`, `qa-report:v1`, `test-plan:v1`) — already latest-wins. - Editing or deleting a posted Phase Outcome — remains a contract violation. - A roll-call that sees live agents — needs harness knowledge the stack-agnostic rule forbids; the resulting "cannot be proved complete" limit is accepted knowingly. - Migrating history — records posted before this feature simply carry no supersede and no roll-call; nothing is rewritten. - Verifying `by` against the comment's real author — would need a new adapter primitive; the field is a recorded claim, and the schema says so.
Author
Owner

Test Plan: phase-outcome-supersede (issue #45)

Validation cases derived from the PREQ alone, before any design existed. They name only what the PREQ
names and assert outcomes a person can observe. Lanes were assigned by /dev:technical-plan 2.6b on
2026-08-26
, once the design existed; the scenarios themselves are unchanged from the PREQ-derived
original. No scenario is routed to e2e-browser: this project declares the e2e, a11y and
security-browser domains not_applicable, so there is no browser lane here to route to. Every
automated case is carried by the shell test harness under plugin/skills/_shared/procedures/test/,
which was confirmed green in this session (smoke.sh, typed-scan.sh, lint-conventions.sh all rc=0)
before any lane was assigned.

Prerequisites

The state the scenarios need. How each state is produced is a design decision; the PREQ requires only
that each be reachable.

  • A feature issue on a forge the suite supports, on which Phase Outcomes can be posted and read
  • At least three Phase Outcomes on one issue, so "walk past one" and "newest surviving" are
    distinguishable from "the only one"
  • One Phase Outcome carrying two or more findings, at least one of which is to be annulled while
    the others stand
  • One Phase Outcome carrying an open, blocking Pending Decision
  • An issue with exactly one Phase Outcome, so the all-annulled case is reachable
  • An issue with no Phase Outcome at all, as the control for the phase-lookup fallback
  • A consumer of Phase Outcomes that predates this feature and is not modified during the run —
    used unmodified, to check the compatibility promise
  • A phase run that spawns at least one agent, and a phase run that spawns none
  • Operator access from a session other than the one that posted the outcome being corrected

Required Test Data

  • Issue A: three Phase Outcomes, the middle one carrying two findings
  • Issue B: one Phase Outcome only, carrying one blocking Pending Decision
  • Issue C: no Phase Outcomes (control)
  • A reason string for each supersede, and one empty reason for the refusal case
  • A target reference that does not exist, and a finding ID belonging to a different outcome

Test Scenarios

Scenario 1: A false finding reads as annulled

Acceptance criterion: "Given a posted Phase Outcome containing a false finding, when a supersede naming that finding is posted, then reading the issue through the suite's Phase Outcome read helper reports that finding as annulled, carrying its reason and the identity of the record that annulled it."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. On issue A, read the Phase Outcomes and note the two findings on the middle outcome
  2. Post a supersede naming one of those findings, with a reason
  3. Read the issue's Phase Outcomes again through the same read path
  4. Verify: the named finding is reported as annulled
  5. Verify: the reason you supplied is readable alongside it
  6. Verify: you can tell from what you read which record annulled it

Expected outcome: the false statement is marked wrong at the place a reader encounters it, and the reader can follow it to the correction.

Scenario 2: An annulled finding drops out of what consumers act on, but not out of history

Acceptance criterion: "Given an annulled finding, when any consumer obtains the issue's findings through the read helper, then the annulled finding is not in the set the consumer acts on — and the original Phase Outcome comment is still present, unedited, and still readable as history."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. Continue from Scenario 1
  2. Obtain the issue's findings the way a consumer does
  3. Verify: the annulled finding is absent from that set
  4. Verify: the outcome's other finding is still present — annulling one did not annul its neighbours
  5. Open the original Phase Outcome comment directly on the forge
  6. Verify: the comment is still there, its text unchanged, and it shows no sign of having been edited

Expected outcome: consumers stop acting on the false finding while the record of it having been said survives intact.

Scenario 3: An unmodified pre-existing consumer honours annulment for free

Acceptance criterion: "Given a consumer written before this feature that reads Phase Outcomes through the read helper, when it runs against an issue carrying supersede records, then it reads without error and, with no change to its own code, no longer acts on annulled data."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh, which drives promotion-candidates.sh and fold-candidates.sh unmodified as the pre-existing consumers, plus a mechanical grep-scan asserting no consumer outside the read helper references the supersede kind.

  1. Take a consumer of Phase Outcomes that existed before this feature and change nothing about it
  2. Run it against issue A, which now carries a supersede
  3. Verify: it completes without error
  4. Verify: it does not act on the annulled finding
  5. Verify: no edit was required to that consumer to get either result

Expected outcome: honouring annulment is inherited by everything that reads through the shared path, rather than being a promise each consumer must separately keep.

Scenario 4: The phase lookup walks past an annulled outcome

Acceptance criterion: "Given an outcome-level supersede, when the current pipeline phase is looked up, then it resolves to the newest surviving outcome's next_state; the annulled outcome is walked past, exactly as next_state: skipped already is."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. On issue A, note the phase reported before any correction — it comes from the newest outcome
  2. Post a supersede annulling that newest outcome as a whole, with a reason
  3. Look up the current pipeline phase
  4. Verify: the reported phase is the one carried by the newest outcome that is still standing
  5. Verify: this matches how the suite already behaves for an outcome marked skipped

Expected outcome: the pipeline's idea of where the feature is stops being driven by a record that has been withdrawn.

Scenario 5: Every outcome annulled falls back to the pre-pipeline state

Acceptance criterion: "Given an issue whose every Phase Outcome has been annulled, when the current pipeline phase is looked up, then it reports backlog — the same result the suite already gives an issue that has no Phase Outcome at all."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. On issue B, which has exactly one Phase Outcome, post a supersede annulling it
  2. Look up the current pipeline phase for issue B
  3. Verify: it reports backlog
  4. Look up the current pipeline phase for issue C, which has never had an outcome
  5. Verify: issue C reports the same thing — the two cases agree

Expected outcome: annulling everything leaves the issue at the same starting point as an issue that never began, with no undefined or error state in between.

Scenario 6: Annulling every finding does not move the phase

Acceptance criterion: "Given a finding-level supersede, when the pipeline phase is looked up, then it is unchanged — including when every finding in the outcome has been annulled individually. Moving the phase requires annulling the outcome."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. On a fresh issue with one outcome carrying two findings, note the reported phase
  2. Post a supersede annulling the first finding; look up the phase
  3. Verify: the phase is unchanged
  4. Post a second supersede annulling the remaining finding; look up the phase again
  5. Verify: the phase is still unchanged, even though no finding in that outcome now stands
  6. Post an outcome-level supersede against the same outcome; look up the phase
  7. Verify: only now does the phase move

Expected outcome: annulling content and withdrawing a record are distinct acts with distinct effects, and doing the first repeatedly never silently becomes the second.

Scenario 7: Decisions on an annulled outcome stop blocking

Acceptance criterion: "Given an annulled outcome that carries open blocking Pending Decisions, when the decisions-settled gate runs, then those decisions no longer block."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. On issue B, confirm the outcome carries an open, blocking Pending Decision
  2. Run the decisions-settled gate; verify: it reports the run as blocked
  3. Post a supersede annulling that outcome, with a reason
  4. Run the decisions-settled gate again
  5. Verify: it no longer reports the run as blocked on that decision
  6. Verify: the decision itself is still visible in the issue's history

Expected outcome: a question raised on a premise that no longer holds stops holding the run up, without the question being erased from the record.

Scenario 8: A supersede says who, what and why

Acceptance criterion: "Given any supersede record, when it is read, then it names who posted it (the producing skill, or the operator), exactly what it annuls, and a non-empty reason."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. Read a supersede posted by a skill during a run
  2. Verify: it identifies the skill that posted it
  3. Verify: it identifies exactly what it annuls, precisely enough to find that target
  4. Verify: it carries a reason, and the reason is not blank
  5. Read a supersede posted by the operator
  6. Verify: it is distinguishable as an operator act rather than a skill's

Expected outcome: a reader who finds a correction can tell who made it and why, without asking anyone.

Scenario 9: The operator corrects an old outcome from a different session

Acceptance criterion: "Given a false outcome from an earlier run, when the operator invokes the supersede command from a different session naming the target and a reason, then the record is posted and the target reads as annulled."

Lane: human-uat — owner: the operator, at the UAT gate, dogfooding /dev:resolve --supersede against this repo's own tracker from a session other than the one that posted the outcome. The helper-level half (a supersede posted from a fresh process, carrying no session state) is additionally covered by plugin/skills/_shared/procedures/test/supersede-flow.sh.

  1. Identify a Phase Outcome posted by an earlier run, from a session that has since ended
  2. From a new session, as the operator, invoke the supersede command naming that outcome and a reason
  3. Verify: the command reports the record as posted
  4. Read the issue's Phase Outcomes
  5. Verify: the target reads as annulled, with the operator's reason

Expected outcome: correcting the record does not require being the session that wrote it, or being anywhere near the original run.

Scenario 10: A supersede posted in error is corrected by a later one

Acceptance criterion: "Given a supersede posted in error, when a later supersede naming the same target is posted, then the later one is what counts — the same latest-wins rule the suite already applies to decision resolutions."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. Post a supersede annulling a finding that was actually correct, with a reason
  2. Verify: the finding now reads as annulled
  3. Post a second supersede naming the same target, correcting the mistake, with a reason
  4. Read the issue again
  5. Verify: the later supersede is what governs the target's state
  6. Verify: the first, mistaken supersede is still readable in the history

Expected outcome: the correction mechanism can itself be corrected, so a slip does not become permanent.

Scenario 11: A supersede with no such target is refused

Acceptance criterion: "Given a supersede whose target does not exist — an unknown outcome, or a finding not present in the named outcome — when it is posted, then it is refused, naming the bad target."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. Attempt to post a supersede naming a Phase Outcome that does not exist on the issue
  2. Verify: it is refused, and the message names the target it could not find
  3. Attempt to post a supersede naming a real outcome but a finding that belongs to a different one
  4. Verify: it is refused, and the message names that target too
  5. Verify: neither attempt left a record on the issue

Expected outcome: a supersede cannot point at nothing, and a mistyped target fails loudly instead of annulling something unintended or silently doing nothing.

Scenario 12: A supersede missing its reason or attribution is refused

Acceptance criterion: "Given a supersede missing its reason or its attribution, when it is posted, then it is refused, naming what is missing."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. Attempt to post a supersede with a valid target but no reason
  2. Verify: it is refused, and the message says the reason is what is missing
  3. Attempt to post one with an empty-string reason
  4. Verify: it is refused the same way — blank does not satisfy the requirement
  5. Attempt to post one with no attribution
  6. Verify: it is refused, naming attribution as what is missing

Expected outcome: an unattributed or unexplained annulment cannot enter the record at all.

Scenario 13: A terminal outcome carries a roll-call

Acceptance criterion: "Given any terminal Phase Outcome, when it is posted, then it carries a roll-call: one entry per agent the phase spawned, each disposed reported or terminated, or an explicitly empty roll-call when the phase spawned none."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh. NOTE: this checks that the record is well-formed and attributable, never that it is true; the roll-call is a self-report by the PREQ's own accepted limit. The "a real phase populated it" reading is dogfooded at the UAT gate, not asserted here.

  1. Run a phase that spawns two agents and let both report; post its terminal outcome
  2. Read the posted outcome
  3. Verify: it carries two roll-call entries, one per agent, each disposed reported
  4. Run a phase that spawns two agents, terminate one before posting, let the other report
  5. Verify: the outcome carries one entry disposed reported and one disposed terminated
  6. Run a phase that spawns no agents at all and post its terminal outcome
  7. Verify: it carries a roll-call that is explicitly empty, not an absent one

Expected outcome: every terminal record states what happened to everything the phase started, including stating that it started nothing.

Scenario 14: A terminal outcome with no roll-call is refused

Acceptance criterion: "Given a terminal Phase Outcome with no roll-call at all, when the post is attempted, then it is refused — absence is never read as 'spawned none'."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. Attempt to post a terminal Phase Outcome with the roll-call omitted entirely
  2. Verify: the post is refused
  3. Verify: the message says what is missing
  4. Verify: no outcome was posted to the issue
  5. Add an explicitly empty roll-call and post again
  6. Verify: this one is accepted — the empty declaration is what absence was not

Expected outcome: omitting the roll-call stops being a way to say nothing was spawned, so silence is never mistaken for an answer.

Scenario 15: A roll-call cannot say "unrecoverable"

Acceptance criterion: "Given a roll-call entry disposed as anything other than reported or terminated — including unrecoverable — when the post is attempted, then it is refused."

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh

  1. Run a phase that spawns an agent which does not respond
  2. Attempt to post the terminal outcome with that agent's entry disposed unrecoverable
  3. Verify: the post is refused
  4. Attempt again with some other invented disposition
  5. Verify: it is refused too
  6. Terminate the agent, record it as terminated, and post
  7. Verify: this one is accepted

Expected outcome: judging an agent dead is no longer something the record can express — the lead must end it or wait for it, which is what stops a late report arriving after the record claims it never will.

Scenario 16: Edge cases

Lane: integration-covered — plugin/skills/_shared/procedures/test/supersede-flow.sh. All six edge cases are mechanical, including "a late report after termination": what it asserts is that the supersede path is sufficient and that no separate mechanism is offered — a grep-scan for any competing correction path plus the Scenario 1-4 assertions, not a live agent.

  1. Superseding an already-annulled outcome. Post a supersede against an outcome that is already
    annulled. Verify: the result is either a clear refusal or an inert, recorded no-op — not a silent
    success that changes nothing, and not an error a reader cannot interpret.
  2. A late report after termination. Let an agent recorded as terminated deliver a report anyway,
    after the outcome is posted. Verify: the supersede path is available and sufficient to correct the
    outcome — no separate mechanism is needed and none is offered.
  3. Two supersedes against one target at once. Post two supersedes naming the same target from two
    sessions. Verify: one of them governs, deterministically, and reading the issue afterwards gives the
    same answer every time.
  4. First correction on a fresh issue. On an issue with exactly one outcome and no prior supersede,
    annul a finding. Verify: the read path handles the first-ever supersede the same as a later one.
  5. An issue with no outcomes. Attempt to post a supersede on issue C, which has none. Verify: it is
    refused, naming the missing target rather than failing obscurely.
  6. Reading an issue with no supersedes at all. Read an untouched issue after this feature ships.
    Verify: nothing about the output changed for issues that were never corrected.

Expected outcome: the correction path behaves predictably at its boundaries — first use, concurrent use, repeated use, and no use.

Notes

  • These cases are deliberately silent on record shape, field names, ordering keys and command flags.
    Those are design decisions; /dev:technical-plan settles them and annotates the lanes here.
  • Scenario 3 must use a genuinely unmodified consumer. Adapting it to pass would test the adaptation,
    not the compatibility promise.
  • Scenario 13's roll-call completeness is a self-report by design. These cases check that the record is
    well-formed and attributable, not that it is true — the PREQ accepts that limit explicitly, because
    the alternative needs harness knowledge the suite is not allowed to have.
<!-- test-plan:v1 issue=45 skill=technical-plan --> # Test Plan: phase-outcome-supersede (issue #45) Validation cases derived from the PREQ alone, before any design existed. They name only what the PREQ names and assert outcomes a person can observe. **Lanes were assigned by `/dev:technical-plan` 2.6b on 2026-08-26**, once the design existed; the scenarios themselves are unchanged from the PREQ-derived original. No scenario is routed to `e2e-browser`: this project declares the `e2e`, `a11y` and `security-browser` domains `not_applicable`, so there is no browser lane here to route to. Every automated case is carried by the shell test harness under `plugin/skills/_shared/procedures/test/`, which was confirmed green in this session (`smoke.sh`, `typed-scan.sh`, `lint-conventions.sh` all rc=0) before any lane was assigned. ## Prerequisites The *state* the scenarios need. How each state is produced is a design decision; the PREQ requires only that each be reachable. - [ ] A feature issue on a forge the suite supports, on which Phase Outcomes can be posted and read - [ ] At least three Phase Outcomes on one issue, so "walk past one" and "newest surviving" are distinguishable from "the only one" - [ ] One Phase Outcome carrying two or more findings, at least one of which is to be annulled while the others stand - [ ] One Phase Outcome carrying an open, blocking Pending Decision - [ ] An issue with exactly one Phase Outcome, so the all-annulled case is reachable - [ ] An issue with no Phase Outcome at all, as the control for the phase-lookup fallback - [ ] A consumer of Phase Outcomes that predates this feature and is not modified during the run — used unmodified, to check the compatibility promise - [ ] A phase run that spawns at least one agent, and a phase run that spawns none - [ ] Operator access from a session other than the one that posted the outcome being corrected ### Required Test Data - [ ] Issue A: three Phase Outcomes, the middle one carrying two findings - [ ] Issue B: one Phase Outcome only, carrying one blocking Pending Decision - [ ] Issue C: no Phase Outcomes (control) - [ ] A reason string for each supersede, and one empty reason for the refusal case - [ ] A target reference that does not exist, and a finding ID belonging to a different outcome ## Test Scenarios ### Scenario 1: A false finding reads as annulled **Acceptance criterion:** "Given a posted Phase Outcome containing a false finding, when a supersede naming that finding is posted, then reading the issue through the suite's Phase Outcome read helper reports that finding as annulled, carrying its reason and the identity of the record that annulled it." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. On issue A, read the Phase Outcomes and note the two findings on the middle outcome 2. Post a supersede naming one of those findings, with a reason 3. Read the issue's Phase Outcomes again through the same read path 4. Verify: the named finding is reported as annulled 5. Verify: the reason you supplied is readable alongside it 6. Verify: you can tell from what you read which record annulled it **Expected outcome:** the false statement is marked wrong at the place a reader encounters it, and the reader can follow it to the correction. ### Scenario 2: An annulled finding drops out of what consumers act on, but not out of history **Acceptance criterion:** "Given an annulled finding, when any consumer obtains the issue's findings through the read helper, then the annulled finding is not in the set the consumer acts on — and the original Phase Outcome comment is still present, unedited, and still readable as history." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. Continue from Scenario 1 2. Obtain the issue's findings the way a consumer does 3. Verify: the annulled finding is absent from that set 4. Verify: the outcome's *other* finding is still present — annulling one did not annul its neighbours 5. Open the original Phase Outcome comment directly on the forge 6. Verify: the comment is still there, its text unchanged, and it shows no sign of having been edited **Expected outcome:** consumers stop acting on the false finding while the record of it having been said survives intact. ### Scenario 3: An unmodified pre-existing consumer honours annulment for free **Acceptance criterion:** "Given a consumer written before this feature that reads Phase Outcomes through the read helper, when it runs against an issue carrying supersede records, then it reads without error and, with no change to its own code, no longer acts on annulled data." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh`, which drives `promotion-candidates.sh` and `fold-candidates.sh` **unmodified** as the pre-existing consumers, plus a mechanical grep-scan asserting no consumer outside the read helper references the supersede kind. 1. Take a consumer of Phase Outcomes that existed before this feature and change nothing about it 2. Run it against issue A, which now carries a supersede 3. Verify: it completes without error 4. Verify: it does not act on the annulled finding 5. Verify: no edit was required to that consumer to get either result **Expected outcome:** honouring annulment is inherited by everything that reads through the shared path, rather than being a promise each consumer must separately keep. ### Scenario 4: The phase lookup walks past an annulled outcome **Acceptance criterion:** "Given an outcome-level supersede, when the current pipeline phase is looked up, then it resolves to the newest surviving outcome's `next_state`; the annulled outcome is walked past, exactly as `next_state: skipped` already is." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. On issue A, note the phase reported before any correction — it comes from the newest outcome 2. Post a supersede annulling that newest outcome as a whole, with a reason 3. Look up the current pipeline phase 4. Verify: the reported phase is the one carried by the newest outcome that is still standing 5. Verify: this matches how the suite already behaves for an outcome marked skipped **Expected outcome:** the pipeline's idea of where the feature is stops being driven by a record that has been withdrawn. ### Scenario 5: Every outcome annulled falls back to the pre-pipeline state **Acceptance criterion:** "Given an issue whose every Phase Outcome has been annulled, when the current pipeline phase is looked up, then it reports `backlog` — the same result the suite already gives an issue that has no Phase Outcome at all." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. On issue B, which has exactly one Phase Outcome, post a supersede annulling it 2. Look up the current pipeline phase for issue B 3. Verify: it reports `backlog` 4. Look up the current pipeline phase for issue C, which has never had an outcome 5. Verify: issue C reports the same thing — the two cases agree **Expected outcome:** annulling everything leaves the issue at the same starting point as an issue that never began, with no undefined or error state in between. ### Scenario 6: Annulling every finding does not move the phase **Acceptance criterion:** "Given a finding-level supersede, when the pipeline phase is looked up, then it is unchanged — including when every finding in the outcome has been annulled individually. Moving the phase requires annulling the outcome." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. On a fresh issue with one outcome carrying two findings, note the reported phase 2. Post a supersede annulling the first finding; look up the phase 3. Verify: the phase is unchanged 4. Post a second supersede annulling the remaining finding; look up the phase again 5. Verify: the phase is still unchanged, even though no finding in that outcome now stands 6. Post an outcome-level supersede against the same outcome; look up the phase 7. Verify: only now does the phase move **Expected outcome:** annulling content and withdrawing a record are distinct acts with distinct effects, and doing the first repeatedly never silently becomes the second. ### Scenario 7: Decisions on an annulled outcome stop blocking **Acceptance criterion:** "Given an annulled outcome that carries open blocking Pending Decisions, when the decisions-settled gate runs, then those decisions no longer block." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. On issue B, confirm the outcome carries an open, blocking Pending Decision 2. Run the decisions-settled gate; verify: it reports the run as blocked 3. Post a supersede annulling that outcome, with a reason 4. Run the decisions-settled gate again 5. Verify: it no longer reports the run as blocked on that decision 6. Verify: the decision itself is still visible in the issue's history **Expected outcome:** a question raised on a premise that no longer holds stops holding the run up, without the question being erased from the record. ### Scenario 8: A supersede says who, what and why **Acceptance criterion:** "Given any supersede record, when it is read, then it names who posted it (the producing skill, or the operator), exactly what it annuls, and a non-empty reason." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. Read a supersede posted by a skill during a run 2. Verify: it identifies the skill that posted it 3. Verify: it identifies exactly what it annuls, precisely enough to find that target 4. Verify: it carries a reason, and the reason is not blank 5. Read a supersede posted by the operator 6. Verify: it is distinguishable as an operator act rather than a skill's **Expected outcome:** a reader who finds a correction can tell who made it and why, without asking anyone. ### Scenario 9: The operator corrects an old outcome from a different session **Acceptance criterion:** "Given a false outcome from an earlier run, when the operator invokes the supersede command from a different session naming the target and a reason, then the record is posted and the target reads as annulled." **Lane:** human-uat — owner: the operator, at the UAT gate, dogfooding `/dev:resolve --supersede` against this repo's own tracker from a session other than the one that posted the outcome. The helper-level half (a supersede posted from a fresh process, carrying no session state) is additionally covered by `plugin/skills/_shared/procedures/test/supersede-flow.sh`. 1. Identify a Phase Outcome posted by an earlier run, from a session that has since ended 2. From a new session, as the operator, invoke the supersede command naming that outcome and a reason 3. Verify: the command reports the record as posted 4. Read the issue's Phase Outcomes 5. Verify: the target reads as annulled, with the operator's reason **Expected outcome:** correcting the record does not require being the session that wrote it, or being anywhere near the original run. ### Scenario 10: A supersede posted in error is corrected by a later one **Acceptance criterion:** "Given a supersede posted in error, when a later supersede naming the same target is posted, then the later one is what counts — the same latest-wins rule the suite already applies to decision resolutions." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. Post a supersede annulling a finding that was actually correct, with a reason 2. Verify: the finding now reads as annulled 3. Post a second supersede naming the same target, correcting the mistake, with a reason 4. Read the issue again 5. Verify: the later supersede is what governs the target's state 6. Verify: the first, mistaken supersede is still readable in the history **Expected outcome:** the correction mechanism can itself be corrected, so a slip does not become permanent. ### Scenario 11: A supersede with no such target is refused **Acceptance criterion:** "Given a supersede whose target does not exist — an unknown outcome, or a finding not present in the named outcome — when it is posted, then it is refused, naming the bad target." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. Attempt to post a supersede naming a Phase Outcome that does not exist on the issue 2. Verify: it is refused, and the message names the target it could not find 3. Attempt to post a supersede naming a real outcome but a finding that belongs to a different one 4. Verify: it is refused, and the message names that target too 5. Verify: neither attempt left a record on the issue **Expected outcome:** a supersede cannot point at nothing, and a mistyped target fails loudly instead of annulling something unintended or silently doing nothing. ### Scenario 12: A supersede missing its reason or attribution is refused **Acceptance criterion:** "Given a supersede missing its reason or its attribution, when it is posted, then it is refused, naming what is missing." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. Attempt to post a supersede with a valid target but no reason 2. Verify: it is refused, and the message says the reason is what is missing 3. Attempt to post one with an empty-string reason 4. Verify: it is refused the same way — blank does not satisfy the requirement 5. Attempt to post one with no attribution 6. Verify: it is refused, naming attribution as what is missing **Expected outcome:** an unattributed or unexplained annulment cannot enter the record at all. ### Scenario 13: A terminal outcome carries a roll-call **Acceptance criterion:** "Given any terminal Phase Outcome, when it is posted, then it carries a roll-call: one entry per agent the phase spawned, each disposed `reported` or `terminated`, or an explicitly empty roll-call when the phase spawned none." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh`. NOTE: this checks that the record is well-formed and attributable, never that it is true; the roll-call is a self-report by the PREQ's own accepted limit. The "a real phase populated it" reading is dogfooded at the UAT gate, not asserted here. 1. Run a phase that spawns two agents and let both report; post its terminal outcome 2. Read the posted outcome 3. Verify: it carries two roll-call entries, one per agent, each disposed reported 4. Run a phase that spawns two agents, terminate one before posting, let the other report 5. Verify: the outcome carries one entry disposed reported and one disposed terminated 6. Run a phase that spawns no agents at all and post its terminal outcome 7. Verify: it carries a roll-call that is explicitly empty, not an absent one **Expected outcome:** every terminal record states what happened to everything the phase started, including stating that it started nothing. ### Scenario 14: A terminal outcome with no roll-call is refused **Acceptance criterion:** "Given a terminal Phase Outcome with no roll-call at all, when the post is attempted, then it is refused — absence is never read as 'spawned none'." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. Attempt to post a terminal Phase Outcome with the roll-call omitted entirely 2. Verify: the post is refused 3. Verify: the message says what is missing 4. Verify: no outcome was posted to the issue 5. Add an explicitly empty roll-call and post again 6. Verify: this one is accepted — the empty declaration is what absence was not **Expected outcome:** omitting the roll-call stops being a way to say nothing was spawned, so silence is never mistaken for an answer. ### Scenario 15: A roll-call cannot say "unrecoverable" **Acceptance criterion:** "Given a roll-call entry disposed as anything other than `reported` or `terminated` — including `unrecoverable` — when the post is attempted, then it is refused." **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh` 1. Run a phase that spawns an agent which does not respond 2. Attempt to post the terminal outcome with that agent's entry disposed `unrecoverable` 3. Verify: the post is refused 4. Attempt again with some other invented disposition 5. Verify: it is refused too 6. Terminate the agent, record it as terminated, and post 7. Verify: this one is accepted **Expected outcome:** judging an agent dead is no longer something the record can express — the lead must end it or wait for it, which is what stops a late report arriving after the record claims it never will. ### Scenario 16: Edge cases **Lane:** integration-covered — `plugin/skills/_shared/procedures/test/supersede-flow.sh`. All six edge cases are mechanical, including "a late report after termination": what it asserts is that the supersede path is sufficient and that no separate mechanism is offered — a grep-scan for any competing correction path plus the Scenario 1-4 assertions, not a live agent. 1. **Superseding an already-annulled outcome.** Post a supersede against an outcome that is already annulled. Verify: the result is either a clear refusal or an inert, recorded no-op — not a silent success that changes nothing, and not an error a reader cannot interpret. 2. **A late report after termination.** Let an agent recorded as `terminated` deliver a report anyway, after the outcome is posted. Verify: the supersede path is available and sufficient to correct the outcome — no separate mechanism is needed and none is offered. 3. **Two supersedes against one target at once.** Post two supersedes naming the same target from two sessions. Verify: one of them governs, deterministically, and reading the issue afterwards gives the same answer every time. 4. **First correction on a fresh issue.** On an issue with exactly one outcome and no prior supersede, annul a finding. Verify: the read path handles the first-ever supersede the same as a later one. 5. **An issue with no outcomes.** Attempt to post a supersede on issue C, which has none. Verify: it is refused, naming the missing target rather than failing obscurely. 6. **Reading an issue with no supersedes at all.** Read an untouched issue after this feature ships. Verify: nothing about the output changed for issues that were never corrected. **Expected outcome:** the correction path behaves predictably at its boundaries — first use, concurrent use, repeated use, and no use. ## Notes - These cases are deliberately silent on record shape, field names, ordering keys and command flags. Those are design decisions; `/dev:technical-plan` settles them and annotates the lanes here. - Scenario 3 must use a genuinely unmodified consumer. Adapting it to pass would test the adaptation, not the compatibility promise. - Scenario 13's roll-call completeness is a self-report by design. These cases check that the record is well-formed and attributable, not that it is true — the PREQ accepts that limit explicitly, because the alternative needs harness knowledge the suite is not allowed to have.
Author
Owner
{
  "next_state": "developing",
  "produced": [
    {
      "kind": "sreq",
      "ref": "comment:2193",
      "summary": "SREQ for 45-phase-outcome-supersede (Tier 3)"
    },
    {
      "kind": "test-plan",
      "ref": "comment:2197",
      "summary": "Test plan for 45-phase-outcome-supersede — 16 scenarios, lanes annotated (15 integration-covered, 1 human-uat)"
    }
  ],
  "findings": [
    {
      "category": "out-of-scope",
      "summary": "The general decision withdrawal path (#36) — a decision mooted by anything other than an annulled outcome (a superseding QA round, a declaration change) still has no path.",
      "reasoning": "Flagged as out of scope in the approved PREQ.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "id": "F-PO-45-2-1"
    },
    {
      "category": "out-of-scope",
      "summary": "Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1).",
      "reasoning": "Flagged as out of scope in the approved PREQ — these are already mutable/latest-wins, and the recorded incident's SREQ recovered on its own.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "id": "F-PO-45-2-2"
    },
    {
      "category": "out-of-scope",
      "summary": "Editing or deleting a posted Phase Outcome — remains a contract violation (§8); this feature adds a forward correction path and never a mutation.",
      "reasoning": "Flagged as out of scope in the approved PREQ.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "id": "F-PO-45-2-3"
    },
    {
      "category": "out-of-scope",
      "summary": "A roll-call that sees live agents. The gate checks the record's shape instead, so a roll-call cannot be proved complete — a limit accepted knowingly.",
      "reasoning": "Flagged as out of scope in the approved PREQ: enumerating live agents would need harness-specific knowledge in the shipped helper tier, breaching the stack-agnostic rule.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "id": "F-PO-45-2-4"
    },
    {
      "category": "out-of-scope",
      "summary": "Migrating history. Records posted before this feature carry no supersede and no roll-call; nothing is rewritten.",
      "reasoning": "Flagged as out of scope in the approved PREQ — an old outcome can still be annulled going forward, because a supersede is a new record.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "id": "F-PO-45-2-5"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Single-scan optimization: phase-outcome-supersede:v1 contains the substring 'phase-outcome', so one scan_comments pass could feed both anchored-header filters locally, halving read_all's forge round trips back to today's count.",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). Raised independently by the performance and architecture lenses, so it is promoted into the plan as a documented, deliberately-declined lever in Technical Risks rather than as work in this slice: the performance lens found NO blocking cost at the stated load (tens of comments per issue, a handful of reads per phase, tens of open issues), and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand, with the named future lever recorded (fix it in the candidate sweeps — one comment fetch per issue reused for both kinds — not in read_all).",
      "proposed_action": "accept",
      "id": "F-PO-45-2-6"
    },
    {
      "category": "in-scope-deferrable",
      "summary": "Should a supersede posted by a SKILL (by != operator) be barred from dissolving a blocking `approval` Pending Decision, so an autonomous run cannot annul the outcome carrying its own human gate and proceed?",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens raised it as blocking. It is NOT actioned here because AC-7 states plainly that an annulled outcome's open blocking decisions stop blocking, with no qualification by attribution — narrowing it inside the design would be the design overruling the requirement, which the provenance split exists to prevent. Two mitigations ARE in this slice: `by` is documented as an unverified self-report, and `annulled_count` in default read output keeps an annulment visible at a human touchpoint without opting into history. Note also that it grants no NEW capability — an autonomous run can already self-answer a gate through /dev:resolve's structured mode. It is a real product-and-security question that deserves deciding on its own merits, so it warrants a sibling issue rather than a silent narrowing here.",
      "proposed_action": "defer-to-issue",
      "id": "F-PO-45-2-7"
    }
  ],
  "pending_decisions": [
    {
      "id": "D-PO-45-2-1",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'The general decision withdrawal path (#36) — a decision mooted by anything other than an annulled outcome (a superseding QA round, a declaration change) still has no path.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-45-2-1",
      "reasoning": "Flagged as out of scope in the approved PREQ."
    },
    {
      "id": "D-PO-45-2-2",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1).'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-45-2-2",
      "reasoning": "Flagged as out of scope in the approved PREQ — these are already mutable/latest-wins, and the recorded incident's SREQ recovered on its own."
    },
    {
      "id": "D-PO-45-2-3",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Editing or deleting a posted Phase Outcome — remains a contract violation (§8); this feature adds a forward correction path and never a mutation.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-45-2-3",
      "reasoning": "Flagged as out of scope in the approved PREQ."
    },
    {
      "id": "D-PO-45-2-4",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'A roll-call that sees live agents. The gate checks the record's shape instead, so a roll-call cannot be proved complete — a limit accepted knowingly.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-45-2-4",
      "reasoning": "Flagged as out of scope in the approved PREQ: enumerating live agents would need harness-specific knowledge in the shipped helper tier, breaching the stack-agnostic rule."
    },
    {
      "id": "D-PO-45-2-5",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Out of scope: 'Migrating history. Records posted before this feature carry no supersede and no roll-call; nothing is rewritten.'. Spawn a sibling issue, or accept (no follow-up)?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-45-2-5",
      "reasoning": "Flagged as out of scope in the approved PREQ — an old outcome can still be annulled going forward, because a supersede is a new record."
    },
    {
      "id": "D-PO-45-2-6",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'Single-scan optimization: phase-outcome-supersede:v1 contains the substring 'phase-outcome', so one scan_comments pass could feed both anchored-header filters locally, halving read_all's forge round trips back to today's count.'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "accept",
      "finding_ref": "F-PO-45-2-6",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). Raised independently by the performance and architecture lenses, so it is promoted into the plan as a documented, deliberately-declined lever in Technical Risks rather than as work in this slice: the performance lens found NO blocking cost at the stated load (tens of comments per issue, a handful of reads per phase, tens of open issues), and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand, with the named future lever recorded (fix it in the candidate sweeps — one comment fetch per issue reused for both kinds — not in read_all)."
    },
    {
      "id": "D-PO-45-2-7",
      "type": "scope-disposition",
      "blocking": false,
      "question": "Expert-review note (not actioned): 'Should a supersede posted by a SKILL (by != operator) be barred from dissolving a blocking `approval` Pending Decision, so an autonomous run cannot annul the outcome carrying its own human gate and proceed?'. Spawn a sibling issue, or accept the disposition?",
      "options": [
        "defer-to-issue",
        "accept"
      ],
      "recommended": "defer-to-issue",
      "finding_ref": "F-PO-45-2-7",
      "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens raised it as blocking. It is NOT actioned here because AC-7 states plainly that an annulled outcome's open blocking decisions stop blocking, with no qualification by attribution — narrowing it inside the design would be the design overruling the requirement, which the provenance split exists to prevent. Two mitigations ARE in this slice: `by` is documented as an unverified self-report, and `annulled_count` in default read output keeps an annulment visible at a human touchpoint without opting into history. Note also that it grants no NEW capability — an autonomous run can already self-answer a gate through /dev:resolve's structured mode. It is a real product-and-security question that deserves deciding on its own merits, so it warrants a sibling issue rather than a silent narrowing here."
    }
  ],
  "suite": {
    "source": "git",
    "sha": "8de068ebafabe5e9693b6cd0c7c87929d3b852dd",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-45-2 skill=technical-plan --> ```json { "next_state": "developing", "produced": [ { "kind": "sreq", "ref": "comment:2193", "summary": "SREQ for 45-phase-outcome-supersede (Tier 3)" }, { "kind": "test-plan", "ref": "comment:2197", "summary": "Test plan for 45-phase-outcome-supersede — 16 scenarios, lanes annotated (15 integration-covered, 1 human-uat)" } ], "findings": [ { "category": "out-of-scope", "summary": "The general decision withdrawal path (#36) — a decision mooted by anything other than an annulled outcome (a superseding QA round, a declaration change) still has no path.", "reasoning": "Flagged as out of scope in the approved PREQ.", "proposed_action": "defer-to-issue", "target": null, "id": "F-PO-45-2-1" }, { "category": "out-of-scope", "summary": "Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1).", "reasoning": "Flagged as out of scope in the approved PREQ — these are already mutable/latest-wins, and the recorded incident's SREQ recovered on its own.", "proposed_action": "defer-to-issue", "target": null, "id": "F-PO-45-2-2" }, { "category": "out-of-scope", "summary": "Editing or deleting a posted Phase Outcome — remains a contract violation (§8); this feature adds a forward correction path and never a mutation.", "reasoning": "Flagged as out of scope in the approved PREQ.", "proposed_action": "defer-to-issue", "target": null, "id": "F-PO-45-2-3" }, { "category": "out-of-scope", "summary": "A roll-call that sees live agents. The gate checks the record's shape instead, so a roll-call cannot be proved complete — a limit accepted knowingly.", "reasoning": "Flagged as out of scope in the approved PREQ: enumerating live agents would need harness-specific knowledge in the shipped helper tier, breaching the stack-agnostic rule.", "proposed_action": "defer-to-issue", "target": null, "id": "F-PO-45-2-4" }, { "category": "out-of-scope", "summary": "Migrating history. Records posted before this feature carry no supersede and no roll-call; nothing is rewritten.", "reasoning": "Flagged as out of scope in the approved PREQ — an old outcome can still be annulled going forward, because a supersede is a new record.", "proposed_action": "defer-to-issue", "target": null, "id": "F-PO-45-2-5" }, { "category": "in-scope-deferrable", "summary": "Single-scan optimization: phase-outcome-supersede:v1 contains the substring 'phase-outcome', so one scan_comments pass could feed both anchored-header filters locally, halving read_all's forge round trips back to today's count.", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). Raised independently by the performance and architecture lenses, so it is promoted into the plan as a documented, deliberately-declined lever in Technical Risks rather than as work in this slice: the performance lens found NO blocking cost at the stated load (tens of comments per issue, a handful of reads per phase, tens of open issues), and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand, with the named future lever recorded (fix it in the candidate sweeps — one comment fetch per issue reused for both kinds — not in read_all).", "proposed_action": "accept", "id": "F-PO-45-2-6" }, { "category": "in-scope-deferrable", "summary": "Should a supersede posted by a SKILL (by != operator) be barred from dissolving a blocking `approval` Pending Decision, so an autonomous run cannot annul the outcome carrying its own human gate and proceed?", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens raised it as blocking. It is NOT actioned here because AC-7 states plainly that an annulled outcome's open blocking decisions stop blocking, with no qualification by attribution — narrowing it inside the design would be the design overruling the requirement, which the provenance split exists to prevent. Two mitigations ARE in this slice: `by` is documented as an unverified self-report, and `annulled_count` in default read output keeps an annulment visible at a human touchpoint without opting into history. Note also that it grants no NEW capability — an autonomous run can already self-answer a gate through /dev:resolve's structured mode. It is a real product-and-security question that deserves deciding on its own merits, so it warrants a sibling issue rather than a silent narrowing here.", "proposed_action": "defer-to-issue", "id": "F-PO-45-2-7" } ], "pending_decisions": [ { "id": "D-PO-45-2-1", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'The general decision withdrawal path (#36) — a decision mooted by anything other than an annulled outcome (a superseding QA round, a declaration change) still has no path.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-45-2-1", "reasoning": "Flagged as out of scope in the approved PREQ." }, { "id": "D-PO-45-2-2", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1).'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-45-2-2", "reasoning": "Flagged as out of scope in the approved PREQ — these are already mutable/latest-wins, and the recorded incident's SREQ recovered on its own." }, { "id": "D-PO-45-2-3", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Editing or deleting a posted Phase Outcome — remains a contract violation (§8); this feature adds a forward correction path and never a mutation.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-45-2-3", "reasoning": "Flagged as out of scope in the approved PREQ." }, { "id": "D-PO-45-2-4", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'A roll-call that sees live agents. The gate checks the record's shape instead, so a roll-call cannot be proved complete — a limit accepted knowingly.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-45-2-4", "reasoning": "Flagged as out of scope in the approved PREQ: enumerating live agents would need harness-specific knowledge in the shipped helper tier, breaching the stack-agnostic rule." }, { "id": "D-PO-45-2-5", "type": "scope-disposition", "blocking": false, "question": "Out of scope: 'Migrating history. Records posted before this feature carry no supersede and no roll-call; nothing is rewritten.'. Spawn a sibling issue, or accept (no follow-up)?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-45-2-5", "reasoning": "Flagged as out of scope in the approved PREQ — an old outcome can still be annulled going forward, because a supersede is a new record." }, { "id": "D-PO-45-2-6", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'Single-scan optimization: phase-outcome-supersede:v1 contains the substring 'phase-outcome', so one scan_comments pass could feed both anchored-header filters locally, halving read_all's forge round trips back to today's count.'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "accept", "finding_ref": "F-PO-45-2-6", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). Raised independently by the performance and architecture lenses, so it is promoted into the plan as a documented, deliberately-declined lever in Technical Risks rather than as work in this slice: the performance lens found NO blocking cost at the stated load (tens of comments per issue, a handful of reads per phase, tens of open issues), and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand, with the named future lever recorded (fix it in the candidate sweeps — one comment fetch per issue reused for both kinds — not in read_all)." }, { "id": "D-PO-45-2-7", "type": "scope-disposition", "blocking": false, "question": "Expert-review note (not actioned): 'Should a supersede posted by a SKILL (by != operator) be barred from dissolving a blocking `approval` Pending Decision, so an autonomous run cannot annul the outcome carrying its own human gate and proceed?'. Spawn a sibling issue, or accept the disposition?", "options": [ "defer-to-issue", "accept" ], "recommended": "defer-to-issue", "finding_ref": "F-PO-45-2-7", "reasoning": "Reviewer suggestion acknowledged but not actioned in this SREQ — see Expert Review > Noted (not actioned). The security lens raised it as blocking. It is NOT actioned here because AC-7 states plainly that an annulled outcome's open blocking decisions stop blocking, with no qualification by attribution — narrowing it inside the design would be the design overruling the requirement, which the provenance split exists to prevent. Two mitigations ARE in this slice: `by` is documented as an unverified self-report, and `annulled_count` in default read output keeps an annulment visible at a human touchpoint without opting into history. Note also that it grants no NEW capability — an autonomous run can already self-answer a gate through /dev:resolve's structured mode. It is a real product-and-security question that deserves deciding on its own merits, so it warrants a sibling issue rather than a silent narrowing here." } ], "suite": { "source": "git", "sha": "8de068ebafabe5e9693b6cd0c7c87929d3b852dd", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "The PREQ scopes this feature to decisions belonging to an *annulled outcome* only, and names #36 as the sibling case for every other way a decision is mooted (a superseding QA round, a declaration change, a rework). #36 is open and is precisely that scope — it already carries the two recorded failures (a blocking decision open for 22 hours after a later QA round superseded it) and the proposed shape (a `withdrawn` terminal plus one rule in the decisions-settled check). Folding the finding there keeps the evidence for one problem in one place. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.",
  "rejected_alternative": "Spawning a new sibling issue — the default shape of defer-to-issue — was rejected because it would duplicate #36's scope exactly and split the evidence for one problem across two tickets."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-1 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "The PREQ scopes this feature to decisions belonging to an *annulled outcome* only, and names #36 as the sibling case for every other way a decision is mooted (a superseding QA round, a declaration change, a rework). #36 is open and is precisely that scope — it already carries the two recorded failures (a blocking decision open for 22 hours after a later QA round superseded it) and the proposed shape (a `withdrawn` terminal plus one rule in the decisions-settled check). Folding the finding there keeps the evidence for one problem in one place. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.", "rejected_alternative": "Spawning a new sibling issue — the default shape of defer-to-issue — was rejected because it would duplicate #36's scope exactly and split the evidence for one problem across two tickets." } ```
Author
Owner

Linked: this issue is sibling #36 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #36 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Deliverable correction is already solved — `sreq:v1`, `test-plan:v1` and `qa-report:v1` are mutable/latest-wins, and the recorded feature-230 incident's SREQ recovered on its own, which is precisely the contrast that made the Phase Outcome's *lack* of a path visible. The stub therefore tracks a settled boundary rather than open work: it is the durable record of why deliverables were deliberately left out of the supersede design, so a later reader does not re-propose extending supersede to cover them. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.",
  "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; on the merits there is no work here, and the spawned stub is a boundary record, not a backlog item."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-2 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Deliverable correction is already solved — `sreq:v1`, `test-plan:v1` and `qa-report:v1` are mutable/latest-wins, and the recorded feature-230 incident's SREQ recovered on its own, which is precisely the contrast that made the Phase Outcome's *lack* of a path visible. The stub therefore tracks a settled boundary rather than open work: it is the durable record of why deliverables were deliberately left out of the supersede design, so a later reader does not re-propose extending supersede to cover them. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.", "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; on the merits there is no work here, and the spawned stub is a boundary record, not a backlog item." } ```
Author
Owner

Linked: this issue is sibling #418 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #418 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Editing or deleting a posted Phase Outcome stays a contract violation (§8): this feature adds a forward correction path and never a mutation, which is exactly what lets the annulled record survive as history. The stub records that boundary so the immutability invariant is not later relaxed on the argument that \"we now have a correction mechanism anyway\" — the opposite is true, the mechanism exists so §8 need not bend. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.",
  "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; on the merits this is a standing prohibition, not a task."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-3 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Editing or deleting a posted Phase Outcome stays a contract violation (§8): this feature adds a forward correction path and never a mutation, which is exactly what lets the annulled record survive as history. The stub records that boundary so the immutability invariant is not later relaxed on the argument that \"we now have a correction mechanism anyway\" — the opposite is true, the mechanism exists so §8 need not bend. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.", "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; on the merits this is a standing prohibition, not a task." } ```
Author
Owner

Linked: this issue is sibling #419 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #419 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "A roll-call that enumerates live agents would need harness-specific knowledge inside the shipped helper tier, which the stack-agnostic rule forbids — so enforcement is on the record's shape, and a roll-call cannot be *proved* complete. That limit is accepted knowingly in the PREQ and restated in the SREQ's acceptance criteria. The stub carries the limit forward as a tracked, revisitable item: if the harness ever exposes a portable way to enumerate a phase's own spawns, this is the ticket that says what it would buy. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.",
  "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; the limit is real and currently unfixable, so the stub is a watch item rather than work."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-4 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "A roll-call that enumerates live agents would need harness-specific knowledge inside the shipped helper tier, which the stack-agnostic rule forbids — so enforcement is on the record's shape, and a roll-call cannot be *proved* complete. That limit is accepted knowingly in the PREQ and restated in the SREQ's acceptance criteria. The stub carries the limit forward as a tracked, revisitable item: if the harness ever exposes a portable way to enumerate a phase's own spawns, this is the ticket that says what it would buy. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.", "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; the limit is real and currently unfixable, so the stub is a watch item rather than work." } ```
Author
Owner

Linked: this issue is sibling #420 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #420 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "Nothing is rewritten: records posted before this feature simply carry no supersede and no roll-call, and both readers tolerate their absence by design (`roll_call` reads `null` like `suite`, and enforcement lives only at the post seam). An old outcome remains correctable going forward, because a supersede is a new record rather than an edit — which is the whole point of the forward-only design. The stub records that no migration is owed, so nobody later writes one. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.",
  "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; there is no migration to perform."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-5 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "Nothing is rewritten: records posted before this feature simply carry no supersede and no roll-call, and both readers tolerate their absence by design (`roll_call` reads `null` like `suite`, and enforcement lives only at the post seam). An old outcome remains correctable going forward, because a supersede is a new record rather than an edit — which is the whole point of the forward-only design. The stub records that no migration is owed, so nobody later writes one. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.", "rejected_alternative": "Accepting with no follow-up — rejected only because the operator instructed defer-to-issue for every out-of-scope item; there is no migration to perform." } ```
Author
Owner

Linked: this issue is sibling #421 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #421 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "The performance lens found no blocking cost at the measured load (tens of comments per issue, a handful of read_all calls per phase, tens of open issues in the candidate sweeps), so the second scan is a cost chosen with the numbers in hand rather than one discovered later. The optimization is already promoted into the plan as a documented, deliberately-declined lever in the SREQ's Technical Risks, together with where to apply it if the sweeps ever feel slow (one comment fetch per issue reused for both kinds, in the sweeps — not in read_all). Taking it now would entangle the anchored-header / near-miss discrimination that #54 hardened, for a cost nobody is paying. Recording the sign-off is the entire effect needed. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.",
  "rejected_alternative": "Spawning a follow-up issue — rejected because the lever and its trigger condition are already written into the SREQ, so a ticket would duplicate a decision that is documented where the next implementer will actually read it."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-6 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "The performance lens found no blocking cost at the measured load (tens of comments per issue, a handful of read_all calls per phase, tens of open issues in the candidate sweeps), so the second scan is a cost chosen with the numbers in hand rather than one discovered later. The optimization is already promoted into the plan as a documented, deliberately-declined lever in the SREQ's Technical Risks, together with where to apply it if the sweeps ever feel slow (one comment fetch per issue reused for both kinds, in the sweeps — not in read_all). Taking it now would entangle the anchored-header / near-miss discrimination that #54 hardened, for a cost nobody is paying. Recording the sign-off is the entire effect needed. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.", "rejected_alternative": "Spawning a follow-up issue — rejected because the lever and its trigger condition are already written into the SREQ, so a ticket would duplicate a decision that is documented where the next implementer will actually read it." } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "rationale": "This is the one finding with genuine open substance. The security lens raised it as blocking; it was not actioned inside the design because AC-7 states without qualification that an annulled outcome's blocking decisions stop blocking, and narrowing that inside the SREQ would be the design overruling the requirement. Two mitigations did land in this slice — `by` is documented as an unverified self-report, and `annulled_count` keeps an annulment visible in default read output at a human touchpoint. It also grants no NEW capability: an autonomous run can already self-answer a gate through /dev:resolve's structured mode. That combination makes it a real product-and-security question that deserves deciding on its own evidence rather than being settled as a side effect of this feature's design. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.",
  "rejected_alternative": "Accepting the disposition — rejected because, unlike the other deferrals, this one names an unresolved hazard to a human approval gate rather than a settled boundary; closing it silently would bury the only blocking concern the panel raised that this slice does not answer."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-7 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "rationale": "This is the one finding with genuine open substance. The security lens raised it as blocking; it was not actioned inside the design because AC-7 states without qualification that an annulled outcome's blocking decisions stop blocking, and narrowing that inside the SREQ would be the design overruling the requirement. Two mitigations did land in this slice — `by` is documented as an unverified self-report, and `annulled_count` keeps an annulment visible in default read output at a human touchpoint. It also grants no NEW capability: an autonomous run can already self-answer a gate through /dev:resolve's structured mode. That combination makes it a real product-and-security question that deserves deciding on its own evidence rather than being settled as a side effect of this feature's design. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\"), which is the authority for taking each producer recommendation as given.", "rejected_alternative": "Accepting the disposition — rejected because, unlike the other deferrals, this one names an unresolved hazard to a human approval gate rather than a settled boundary; closing it silently would bury the only blocking concern the panel raised that this slice does not answer." } ```
Author
Owner

Linked: this issue is sibling #422 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #422 (recorded by the devwork pipeline).
Author
Owner
{
  "status": "resolved",
  "chosen_option": "defer-to-issue",
  "target_issue_ref": "36",
  "rationale": "The PREQ scopes this feature to decisions belonging to an *annulled outcome* only, and names #36 as the sibling case for every other way a decision is mooted (a superseding QA round, a declaration change, a rework). #36 is open and is precisely that scope — it already carries the two recorded failures (a blocking decision open for 22 hours after a later QA round superseded it) and the proposed shape (a `withdrawn` terminal plus one rule in the decisions-settled check). Folding the finding there keeps the evidence for one problem in one place. The finding was folded into #36 as a `folded-finding:v1` record; this resolution restates that with the `target_issue_ref` the fold shape requires, which the first resolution comment omitted. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\").",
  "rejected_alternative": "Spawning a new sibling issue — the default shape of defer-to-issue — was rejected because it would duplicate #36's scope exactly and split the evidence for one problem across two tickets."
}

<!-- decision-resolution:v1 ref=D-PO-45-2-1 --> ```json { "status": "resolved", "chosen_option": "defer-to-issue", "target_issue_ref": "36", "rationale": "The PREQ scopes this feature to decisions belonging to an *annulled outcome* only, and names #36 as the sibling case for every other way a decision is mooted (a superseding QA round, a declaration change, a rework). #36 is open and is precisely that scope — it already carries the two recorded failures (a blocking decision open for 22 hours after a later QA round superseded it) and the proposed shape (a `withdrawn` terminal plus one rule in the decisions-settled check). Folding the finding there keeps the evidence for one problem in one place. The finding was folded into #36 as a `folded-finding:v1` record; this resolution restates that with the `target_issue_ref` the fold shape requires, which the first resolution comment omitted. Resolved under the operator's standing instruction of 2026-08-26 (\"resolve all decisions as recommended\").", "rejected_alternative": "Spawning a new sibling issue — the default shape of defer-to-issue — was rejected because it would duplicate #36's scope exactly and split the evidence for one problem across two tickets." } ```
Author
Owner

Linked: this issue is sibling #452 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #452 (recorded by the devwork pipeline).
Author
Owner

Linked: this issue is sibling #453 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #453 (recorded by the devwork pipeline).
Author
Owner

Software Requirements: 45-phase-outcome-supersede

Context

phase-outcome:v1 is the suite's terminal handoff record and is immutable by contract (§8) — so
it is the only durable record with no correction path. A phase lead that discovers, after posting,
that its own record states something false can do nothing about it; a false finding stays live and a
Pending Decision raised on a false premise stays open. The recorded incident: a five-lens review
panel returned nothing, the lead concluded the transport was broken and wrote "panel could not be
run"
into its Phase Outcome, and the panel's results arrived 34 seconds later carrying two
blocking concerns. This slice adds a supersede path (recovery) and a roll-call (prevention),
which the PREQ ships together because each was shown insufficient alone.

Approaches Considered

Approach A: A new sibling record kind, honoured inside the read helper

Summary: annulment is a new append-only comment kind, phase-outcome-supersede:v1, and the one
place that honours it is phase_outcome.read_all — so every compliant consumer inherits the
behaviour unmodified.
Pros: phase-outcome:v1's wire format is untouched, so no :v2 bump and no history goes
invisible; the annulled comment is never edited, so §8 stands unweakened; the compatibility promise
is true by construction rather than by an enumerated consumer list; latest-wins-per-target reuses
§9's rule verbatim, and the append-only active/withdrawn shape reuses folded-finding:v1's
proven pattern; no new adapter primitive.
Cons: read_all gains a second comment scan per call; the record set a consumer sees is no
longer literally the comment set on the issue, so history needs an explicit opt-in flag.
Effort: Medium

Approach B: Annulment carried inside a new Phase Outcome

Summary: add a supersedes[] body field to phase-outcome:v1; correcting a record means minting
a new outcome that names what it annuls.
Pros: no new record kind; the corrector is itself a first-class outcome, so it carries its own
attribution, roll-call and ordinal, and the ledger remains the whole story in one chain.
Cons: ruinously heavy for the actual use case. An operator annulling one false finding months
later must mint a whole Phase Outcome — which forces them to choose a next_state (moving pipeline
state as a side effect of a correction), supply a roll-call for a run that never happened, and pick
a producer name from a closed enum that has no operator in it. The PREQ rejected forcing a
replacement outcome for exactly this weight (decision 4).
Effort: Medium

Approach C: Reuse decision-resolution:v1

Summary: express annulment as an "annulment decision" resolved through /dev:resolve.
Pros: zero new schema; reuses the §9 lookup rule and the whole existing operator surface.
Cons: a decision is a question; an annulment is an assertion. It needs a Pending Decision
to exist first, which the false outcome never raised — so a self-correcting skill would be answering
its own invented question. And decision IDs are derived from Phase Outcome ordinals, so an operator
arriving weeks later has no ID to name.
Effort: Low

Decision

Selected: Approach A — a new sibling record kind, honoured inside the read helper.

Rationale: it is the only shape that keeps both promises the PREQ treats as non-negotiable — the
annulled record is never touched (§8 stands) and the exclusion is inherited by consumers written
before the feature existed (honoured in one place, not an enumerated list). B and C both fail the
second promise or pay for the first with a correction act heavier than the thing it corrects.

Architecture

Component Overview

  producer skill                  operator
        |                            |
        | phase_outcome.supersede    | /dev:resolve --supersede
        v                            v
   bin/phase-outcome-supersede.sh  (validates target, reason, attribution)
        |
        |  post_comment            <!-- phase-outcome-supersede:v1 ref=… by=… -->
        v
  ┌───────────────────────── the issue's comment ledger ─────────────────────────┐
  │  phase-outcome:v1  ×N   (immutable, dense ordinals)                          │
  │  phase-outcome-supersede:v1  ×M  (append-only, latest-wins per target)       │
  └──────────────────────────────────────────────────────────────────────────────┘
        ^                                   |
        | scan_comments ×2                  | _po_annulment  (bin/_lib.sh)
        |                                   v
   bin/phase-outcome-read-all.sh  ──►  filtered ledger  ──►  every consumer
        ^                                                    (read_latest, decisions-settled,
        |                                                     /dev:resolve, integrate, promote,
   bin/phase-outcome-read-latest.sh                           fold-/promotion-candidates, …)

Data Flow

Annulling. A skill (mid-run, on discovering its own record is false) or the operator (later, from
any session) calls the supersede helper with a target, a reason and an attribution. The helper reads
the unfiltered ledger, refuses an unresolvable target, and posts one append-only comment.

Reading. read_all scans the Phase Outcome records exactly as today, runs the §8 immutability
check, parses, applies the foreign-issue gate, and enforces ordinal density on the raw ledger.
Only then does it scan the supersede kind, resolve latest-wins per target through _po_annulment,
and project the filtered view. read_latest wraps read_all, so the current-phase lookup inherits
annulment with no code of its own.

Preventing. phase_outcome.post requires a roll_call array. It validates the array's shape
and refuses absence — it never inspects live agents.

The supersede record

<!-- phase-outcome-supersede:v1 ref={PO-id | F-id} by={producer|operator} -->

{ "status": "active" | "withdrawn",
  "target": "{ref}",     // echoes the header; the header is authoritative
  "by":     "{ref}",     // echoes the header; the header is authoritative
  "reason": "<non-empty>" }

scope (outcome vs finding) is derived from ref's form (PO-… vs F-PO-…) and deliberately
not stored — a third copy of the same fact is a third thing that can disagree. The body echoes
target/by for a human reading the comment, exactly as folded-finding:v1 echoes its header
tokens; the post helper refuses any header/body mismatch, and readers treat the header as
authoritative for identity
.

Matching ref is exact-token, never substring. F-PO-45-3-2 is a substring-prefix of
F-PO-45-3-21, and this suite has already shipped that bug once (ref=D-PO-2-4-1 matched
ref=D-PO-2-4-12, observed live). The reader parses the header's key=value attributes with the
anchored-header regex and compares with jq string equality — it never greps the raw body.

Read-side gating of supersede records — fail closed

The supersede kind removes data from what consumers act on, so a record of it that cannot be
trusted must never be honoured silently, and must never be dropped silently either. Four disjoint
outcomes in _po_annulment, mirroring the family's existing partition:

What was scanned What happens
Anchored + parseable + ref names a target on this issue Honoured (latest-wins per target).
Anchored + unparseable body, or unknown status Hard refusal of the read (EXIT_ENV), naming the comment id and the offending element — the _po_parse treatment. Silently ignoring it could resurrect a finding that was genuinely annulled.
Anchored + ref claims a target on a different issue Hard refusal, same gate and wording as _po_foreign_gate. A session must not annul another issue's records.
Anchored + well-formed but the target does not exist on this issue (a dangling, hand-posted record) Inert + warned on stderr every read, naming the comment id — the near-miss treatment. It annuls nothing, and refusing the whole read would let one hand-posted comment take an issue offline.
Edited (edited_ms > created_ms) §8-strength hard refusal (EXIT_IMMUTABLE). Detected by the same signal every adapter already renders (local-fs pins mtime to epoch_ms; remote adapters render created_at/updated_at) — no new mechanism. One edited record refuses the whole issue read, §8 parity. That an edit can therefore take an issue's reads offline is tamper-evidence working as intended, not a defect.

External Data Contracts

Boundary / source Operation Real shape (verified) Provenance
Gitea (git.wihslon.com / jbr870/devwork-skills) via the tea-cli adapter — the comment ledger this feature reads and writes scan_comments(issue, header) read; post_comment(issue, body) write [{comment_id: string, created_ms: number, edited_ms: number, raw_body: string}]; line 1 of a record round-trips byte-identical, read back as <!-- phase-outcome:v1 id=PO-45-1 skill=requirements --> recordedexternal-contracts/forge-comment-ledger.provenance.json

The capture confirms the two facts the design rests on: an HTML-comment header with space-delimited
key=value attributes survives the round trip (so a new sibling kind needs no new adapter
primitive
), and the scan envelope already carries created_ms + edited_ms — the ordering key for
latest-wins-per-target and the §8 edit signal are both present today. No boundary is unverified, so
this phase raises no precondition.

Observability & Audit

Not applicable as a policy obligation: the project declares observability: mode: none, so the
applicability test does not intersect and no logging/audit AC rows are owed. Recorded here only
because the reason for that none is load-bearing for this feature — "run traceability is the forge
record (Phase Outcomes, deliverables), not logs"
. The record trail is this project's only audit
surface, which is why §8's tamper-evidence is preserved rather than relaxed, and why the supersede
record itself carries mandatory attribution and reason.

The honest limit on attribution. by is an unverified self-report, exactly as the roll-call
is. Nothing in the forge binds it to the comment's actual author, and the suite deliberately adds no
adapter primitive to fetch one. It is a claim, recorded and permanent — which is the same standing
the suite already gives a /dev:resolve resolution posted by an autonomous run. State this in the
schema beside the roll-call's limit; do not let a reader mistake by=operator for proof of a human.

Key Decisions

Each of these settles something the PREQ deliberately deferred to this phase, or something the
review panel raised.

Decision Choice Rationale
Wire form of an annulment A new kind phase-outcome-supersede:v1 (see The supersede record) A sibling kind leaves phase-outcome:v1's wire format untouched, so §7's reader test is trivially passed and no bump is owed.
Target granularity One record, one target. ref is a PO id (PO-45-3) or a finding id (F-PO-45-3-2) Finding IDs embed their PO id, so a single ref identifies both cases and latest-wins keys on one value. N targets = N records. (Settles "may one supersede target findings across multiple outcomes" — no.)
Identity conflicts Header ref/by are authoritative; the body echoes them; post refuses a mismatch; scope is derived, never stored Three copies of one fact is three things that can disagree. Mirrors folded-finding:v1's header/body echo.
ref matching Exact header-attribute token equality in jq — never a raw-body substring F-PO-45-3-2 prefixes F-PO-45-3-21; this exact bug class already shipped once.
Ordering: "newest surviving outcome" The PO ordinal read_all already sorts by it and enforces density; ordinals are minted monotonically and are the ledger's identity. Timestamps are the supersede key, not the outcome key.
Ordering: which supersede governs a target Latest by (created_ms, comment_id) Literally §9's rule and its deterministic same-second tiebreaker; a concurrent pair resolves without a special case.
Un-annulling status: withdrawn, append-only — never an edit or a delete. withdrawn carries the same mandatory reason + by and the same refusal set as active The folded-finding:v1 pattern. Un-annulling restores a record to force, so it deserves equal attribution rigour.
Precedence between outcome- and finding-level records on one PO Independent, per target. Annulling PO-45-3 does not moot a withdrawn on F-PO-45-3-2; each target resolves on its own, and an annulled outcome leaves the actionable set whatever its findings say Per-target latest-wins needs no precedence rule; adding one would create a second lookup to keep consistent.
Attribution value for the operator The reserved literal by=operator; every other value must be a canonical producer name operator is disjoint from the closed producer enum (requirements, technical-plan, develop, rebase, qa, integrate, promote, vibecoding, resolve, setup), so skill-vs-operator is decidable from the value alone. Its truthfulness is a claim, not a proof — see Observability & Audit.
Where annulment is honoured phase-outcome-read-all.sh, via one _po_annulment in bin/_lib.sh The contract already forbids re-implementing scan-and-resolve (§9). One implementation is what makes the compatibility criterion true by construction.
What a default read returns Annulled outcomes absent from the array; annulled findings moved out of findings[] into an additive annulled_findings[]; plus an additive annulled_count on every surviving outcome's sibling projection Absence is what gives an unmodified consumer the exclusion for free (read_latest's last becomes the newest survivor; an empty array → nullbacklog). annulled_count lets a consumer know filtered history exists without opting into it — the visibility hook that keeps an annulment from being invisible at a human touchpoint.
Reading history --include-annulled returns the whole ledger with superseded: {…}|null on each outcome and finding The history door, and what the supersede helper itself reads. Additive; default behaviour unchanged. The two modes have different output shapes — the schema says so, so no consumer mixes them.
Density check placement On the raw ledger, before filtering. The filtered array is deliberately ordinal-sparse Removing an outcome would otherwise trip the existing [.[].ordinal] == [range(1;length+1)] dense check. Consumers must not re-assert density on read output, nor compute len+1 from it.
Decisions paired to an annulled finding Left in pending_decisions[] — the gate keeps counting them. /dev:resolve presents such a decision but restricts it to accept Dropping them was the draft's plan and two lenses caught it: a finding-level supersede would then silently pass the decisions-settled gate while the outcome and its next_state still stand — pipeline state advancing on annulled evidence, which AC-6 forbids. The real hazard (resolving defer-to-issue spawning a sibling issue from a finding annulled as false) is closed at the acting site instead of by hiding the question.
Decisions on an annulled outcome Stop blocking, per AC-7 — regardless of who posted the supersede AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. The security question that raises is surfaced as a finding + decision, not resolved unilaterally.
What post sees The raw ledger, always Ordinal minting, the collision check, the foreign-issue gate, the duplicate-ordinal gate and the timeout-retry guard must not shift when a record is annulled: an annulled outcome keeps its ordinal forever, so ordinals stay dense and monotonic.
Roll-call dispositions reported | terminated, exactly one per entry, exactly one entry per spawned agent, agent names unique within a roll-call. No unrecoverable; any other value refused reported = the lead read the result, or reconstructed the outcome from the tree per wait-discipline.md §1a step 3. terminated = the lead ended the agent before posting, so no late report can arrive. A terminated agent whose report was already read is reported. The omission is the teeth.
Roll-call scope Direct spawns only — transitive children are not counted A lead can only account for what it started; a teammate's children are that teammate's roll-call. Enumerating them would need harness knowledge the stack-agnostic rule forbids. Stated as an accepted limit beside the existing one.
Roll-call absence --roll-call omitted, or given an empty string, is refused; [] is a valid "spawned none". The flag still accepts @file or an inline literal via _json_arg The evasion is one omission wide (PREQ decision 9), so the parser keeps an explicit was-the-flag-seen sentinel — but bypassing _json_arg entirely would silently drop @file support and break symmetry with --produced/--findings. Sentinel for absence, _json_arg for the value.
Roll-call versioning + enforcement seam Optional in the schema, required by the post helper. Readers MUST NOT refuse its absence Producer-side narrowing, which §7 allows without a bump: no existing record becomes unreadable and no reader rejects one. Pre-feature records read roll_call: null, exactly as suite does. Enforcement lives only at the post seam, so a hand-posted record bypasses it — which is why readers must tolerate absence.
Which outcomes need a roll-call Every phase-outcome:v1 Every Phase Outcome is the terminal handoff of a phase run (schema line 1). This dissolves the deferred "enumeration of terminal next_state values": the qualifier is the record kind, not a state list — and it leaves no escape hatch.
Roll-call vs the retry guard Included in the normalized comparison (unlike suite), sorted by agent before comparing. A landed pre-feature record with roll_call: null never matches an incoming [] suite is excluded because it describes the writer; a roll-call describes the run's content. Sorting matters: array-order jitter between two attempts would otherwise defeat the guard and mint a duplicate outcome — the failure the guard exists to prevent.
Exit codes EXIT_USAGE (2) for missing/empty --roll-call, --reason, --by, a bad disposition or a malformed entry; EXIT_FINDING (6) for an unresolvable target; EXIT_ENV (7) for a foreign or unparseable supersede at read time; EXIT_IMMUTABLE (4) for an edited one Callers branch on rc; an unknown target is a state-dependent record-shape refusal, not a usage error, and leaving the mapping implicit invites drift.
Superseding an already-annulled target Accepted, and a record is posted; the helper returns already_annulled: true It must be accepted — re-annulling after a withdrawn, and recording a better reason, are both this path. Posting the record is what makes it an inert, recorded no-op rather than a silent one.
Operator surface Extend /dev:resolve (--supersede <target> --reason "…", plus an interactive menu entry) The PREQ asked for "a command in the shape of /dev:resolve". One orchestrator surface, and it avoids adding a shipped skill (which also carries the SHIPPED_SKILLS lint step). Rejected: a new /dev:supersede skill. The helper is the real API — a skill correcting itself mid-run calls it directly.

Technical Risks

Risk Likelihood Impact Mitigation
Making --roll-call mandatory breaks every existing producer call site at once High High It is a mechanical, enumerable edit (see Implementation Scope), and the shell suites fail loudly the moment one is missed. Sequencing is the mitigation: land the call sites and suites first, flip the requirement on last. A shared fixture helper in test/ keeps the flag-day to one edit per suite.
$PO_ORDINAL = len(prior_outcomes) + 1 silently drifts once outcomes can be filtered out Medium High Already wrong for a gapped ledger (post mints max+1); annulment makes it wrong more often. Fixed in this slice and covered by a mechanical scan for other len(...)+1-on-read-output call sites — develop is one of N, not necessarily the only one.
A reader assumes read_all's array is still the literal comment set, or re-asserts density on it Medium Medium --include-annulled is the documented history door; annulled_count signals filtered history in default mode; the schema, procedure doc and contract all state the default is the actionable set, not the ledger, and that it is ordinal-sparse.
Second comment scan per read_all, amplified repo-wide by the candidate sweepspromotion-candidates.sh / fold-candidates.sh go from one to two forge calls per open issue, including issues that can never carry a supersede Medium Low Accepted knowingly, not discovered. The performance lens found no blocking concern at this load (tens of comments per issue, a handful of reads per phase, tens of open issues; seconds at worst on a remote adapter). Two lenses independently noted the available lever: phase-outcome-supersede:v1 contains the substring phase-outcome, so one scan could feed both filters locally. Not taken now — it entangles the anchored-header / near-miss discrimination (#54) for a cost nobody is paying. If the sweeps ever feel slow, fix it there (one comment fetch per issue, reused for both kinds), not in read_all.
The two scans in read_all are not atomic Low Low Benign: a supersede posted between them simply takes effect on the next read. Documented rather than locked.

Expert Review

Panel: Tier 3, five lenses, on fable. Reviewer reports carried 12 blocking concerns; 11 are
actioned below and 1 is surfaced as a finding for the orchestrator.

Process note, recorded because this feature is about exactly this. Four of the five reviewers
went idle without delivering their reports. Following wait-discipline.md §1a, the tree was read
before any conclusion was drawn — all four had written complete reviews; the reports were dropped,
not the work — and every one was recovered and used. Had the panel been recorded as "could not be
run", this SREQ would have shipped without 8 of its blocking corrections, reproducing the
feature-230 incident inside the plan for the fix. This phase's own roll-call records all five as
reported, four of them reconstructed from the tree.

Reviewers

  • Solution Architect — read-side gating of supersede records was unstated; dropping decisions on finding-level annulment exceeds the AC and lets state advance on annulled evidence.
  • Database Expert — the ref-substring hazard would recur; three copies of the target have no conflict rule; the filtered array is ordinal-sparse and consumers must be told.
  • Backend Developer — exit-code mapping unspecified; bypassing _json_arg for --roll-call silently drops @file; roll-call array-order jitter defeats the retry guard.
  • Security Specialistby is a self-declared claim and the plan said so for the roll-call but not for attribution; a supersede can dissolve a blocking approval gate; the foreign-issue gate was unstated for the new kind.
  • Performance Engineer — no blocking concern; the doubled scan is acceptable at this load, but the repo-wide sweep amplification should be chosen, not discovered.

Changes Made

  • Added Read-side gating of supersede records — fail closed: a four-way partition (honour / hard-refuse unparseable / hard-refuse foreign / inert-and-warn on a dangling target) plus the §8 edit refusal, with per-adapter detection and the "one edited record refuses the issue read" parity spelled out. (3 lenses raised this independently.)
  • Reversed the draft's decision to drop pending decisions on finding-level annulment. They now stay in pending_decisions[] and the gate keeps counting them; /dev:resolve restricts such a decision to accept so no sibling issue is spawned from a finding annulled as false.
  • Specified exact-token ref matching in jq against parsed header attributes, never a raw-body substring, naming the shipped prefix-collision bug it prevents.
  • Made the header authoritative for identity, dropped scope from the record as derived, and made a header/body mismatch a post-time refusal.
  • Documented the full exit-code mapping.
  • Kept _json_arg (and therefore @file) for --roll-call's value while adding a was-the-flag-seen sentinel for the absence refusal; added per-entry validation including unique agent names.
  • Sort roll_call by agent in the retry-guard normalization, and defined null vs [] as an explicit non-match.
  • Stated that roll-call enforcement lives only at the post seam, so readers must not refuse its absence.
  • Stated that by is an unverified self-report, with the same candour the roll-call's limit already had, and added annulled_count to default read output so an annulment cannot be invisible at a human touchpoint.
  • Required the same reason + by rigour on withdrawn as on active.
  • Defined per-target independence between outcome- and finding-level records on one PO.
  • Documented the filtered array as ordinal-sparse, and widened the $PO_ORDINAL fix to a mechanical scan for other len+1-on-read-output sites.
  • Added the sweep amplification to Technical Risks as an accepted cost with a named future lever, and the non-atomic two-scan race as a benign, documented behaviour.
  • Named the same-created_ms tiebreak as an explicit case in test/supersede-flow.sh (AC-10), and a shared fixture helper for the roll-call flag-day (Technical Risks).
  • Added the --include-annulled output-shape note to the schema deliverable so no consumer mixes modes.

Noted (not actioned)

  • Single-scan optimization (one scan_comments for the common phase-outcome prefix, partitioned twice locally). Raised by two lenses, so it is promoted into the plan — but as a documented, deliberately-declined lever in Technical Risks rather than as work in this slice: the performance lens found no blocking cost at this load, and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand.
  • Barring a skill-posted supersede from dissolving a blocking approval decision. Real and worth deciding, but AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. Note also that it grants no new capability: an autonomous run can already self-answer a gate through /dev:resolve's structured mode. Surfaced as a finding with a defer-to-issue recommendation so a human decides it on its own merits.

Acceptance Criteria

ID Criterion (from PREQ) Verification approach
AC-1 A supersede naming a false finding makes the read helper report that finding as annulled, carrying its reason and the identity of the annulling record Integration: test/supersede-flow.sh — post PO, supersede one finding, assert annulled_findings[0] carries reason, by and the annulling comment_id
AC-2 The annulled finding is absent from the set a consumer acts on; its sibling finding survives; the original comment is present, unedited, unchanged Integration: assert findings[] excludes it and still contains the sibling; assert the PO comment's raw_body is byte-identical and edited_ms == created_ms
AC-3 A consumer written before this feature, unmodified, reads without error and no longer acts on annulled data Integration: run promotion-candidates.sh and fold-candidates.sh unmodified against an issue carrying supersedes; assert exit 0 and that the annulled outcome's state is not what they report. Mechanical: a grep-scan asserting no consumer outside read-all.sh/_lib.sh references the supersede kind — the "honoured in one place" constraint, enforced the way the helper-funnel rule requires
AC-4 An outcome-level supersede makes the phase lookup resolve to the newest surviving outcome, walked past as skipped already is Integration: three POs, annul the newest, assert read_latest returns the middle one's next_state
AC-5 With every outcome annulled the phase lookup reports backlog — the same answer as an issue with no outcome Integration: annul the only PO on issue B; assert read_latestnull; assert issue C (no POs) → null; assert the two agree
AC-6 A finding-level supersede leaves the phase unchanged, including when every finding is annulled individually; only an outcome-level supersede moves it Integration: annul finding 1 → phase unchanged; annul finding 2 → still unchanged; assert the outcome's pending decisions are still counted at each step (the gate must not advance on annulled-finding evidence); annul the outcome → phase moves
AC-7 An annulled outcome's open blocking Pending Decisions stop blocking the decisions-settled gate Integration: assert the decision id is enumerable before and absent from read_all's pending_decisions[] after — the exact set decisions-settled-check.md iterates — and still present under --include-annulled
AC-8 Any supersede names who posted it, exactly what it annuls, and a non-empty reason Integration: assert by, target, reason on a skill-posted and an operator-posted record; assert by=operator is distinguishable from every producer name; assert a header/body mismatch is refused
AC-9 The operator can supersede an outcome from an earlier run, from a different session human-uat: operator runs /dev:resolve --supersede on this repo's own tracker from a fresh session (dogfooding). Helper-level half also covered by supersede-flow.sh (the helper carries no session state)
AC-10 A supersede posted in error is corrected by a later one naming the same target — latest-wins Integration: annul, assert annulled; post a withdrawn for the same target, assert restored; assert the first record is still readable; named case: two records on one target sharing created_ms resolve deterministically by comment_id; named case: F-PO-x-y-2 is unaffected by a supersede targeting F-PO-x-y-21
AC-11 A supersede whose target does not exist is refused, naming the bad target, leaving no record Integration: unknown PO id → exit 6, stderr names the target, comment count unchanged; a finding id belonging to a different outcome → same; an issue with no outcomes at all → same. Read side: a hand-posted dangling supersede is inert, warns on stderr naming the comment id, and does not fail the read
AC-12 A supersede missing its reason or its attribution is refused, naming what is missing Integration: absent reason, empty-string reason, absent by, and an invalid by → four refusals at exit 2, each naming the missing or invalid element; the same four for status: withdrawn
AC-13 Every Phase Outcome carries a roll-call: one entry per spawned agent disposed reported/terminated, or an explicitly empty one Integration: post with two reported; with one reported + one terminated; with [] — assert each round-trips through read_all; assert @file and inline forms both work
AC-14 A Phase Outcome with no roll-call at all is refused — absence is never read as "spawned none" Integration: omit --roll-call → exit 2 naming the missing element, no comment posted; --roll-call '' → same; then post the same payload with [] → accepted. Read side: a pre-feature record with no roll_call reads as null without error — enforcement is post-only
AC-15 A roll-call entry disposed anything other than reported/terminatedunrecoverable included — is refused Integration: unrecoverable → refused; an invented value → refused; a missing/empty agent → refused; two entries with the same agent → refused; then terminated → accepted
AC-16 (derived — correctness rider on the read-shape change) No consumer computes an ordinal or a count from read_all's filtered output; develop's $PO_ORDINAL is highest-ordinal + 1 Mechanical: a grep-scan over the shipped tree for len(/length-plus-one arithmetic on Phase-Outcome read output, asserting zero hits; integration: a ledger with an annulled outcome yields the same ordinal from the skill's stated formula as post actually mints
AC-17 (derived — retry-guard integrity under the new field) A timeout retry carrying the same roll-call in a different array order is absorbed, not duplicated Integration: post, then re-post an identical payload with roll_call entries reordered → asserts retried: true and no new ordinal; a landed roll_call: null record vs an incoming [] → not a match, mints a new ordinal

Coverage note. e2e, a11y and security-browser are declared not_applicable for this
project, so no browser lane exists to route to. Every automated row above is carried by the shell
test harness under plugin/skills/_shared/procedures/test/, which was confirmed green in this
session (smoke.sh, typed-scan.sh, lint-conventions.sh all rc=0) — the lane boots here today.

On AC-13's honesty. The roll-call is a self-report, and these rows check that the record is
well-formed and attributable, never that it is true. The same applies to by in AC-8. The PREQ
accepts the roll-call limit explicitly; this SREQ extends the same candour to attribution. Do not let
a green AC-13 or AC-8 be read as proof that a phase enumerated its agents faithfully, or that
by=operator means a human was present.

Implementation Scope

Areas

Area Files / directories involved Nature of change
New record schema plugin/skills/_shared/schemas/phase-outcome-supersede.v1.md; the table + anchored-header notes in schemas/README.md new
Supersede helper plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh new
Annulment lookup + read gating plugin/skills/_shared/procedures/bin/_lib.sh — add _po_annulment beside _po_foreign_gate, carrying the four-way fail-closed partition extend
Read path bin/phase-outcome-read-all.sh (annulment pass, --include-annulled, annulled_count, density moved onto the raw ledger) modify
Post path bin/phase-outcome-post.sh (--roll-call: sentinel-for-absence + _json_arg for the value, per-entry validation, agent-sorted in the retry-guard normalization) modify
Phase Outcome schema schemas/phase-outcome.v1.md — optional roll_call, the post-only enforcement seam, the normalization-excluded-set note, both projected read shapes extend
Forge contract forge-contract.md §2.1 (phase_outcome.supersede), §7 (why no bump), §8 (the new immutable kind), §9 (latest-wins per target) extend
Procedure docs procedures/phase-outcome.md (the new op + both read shapes), decisions-settled-check.md (the gate inherits exclusion; finding-level annulment does not unblock), wait-discipline.md (the roll-call as §1a/§1b's post-time counterpart) modify
Operator surface plugin/skills/resolve/SKILL.md--supersede structured mode, interactive entry, frontmatter description, and the accept-only restriction on a decision whose finding_ref is annulled extend
Producer call sites requirements/SKILL.md (2), technical-plan/SKILL.md (3), develop/SKILL.md (3, plus the $PO_ORDINAL fix), integrate/SKILL.md (1), promote/SKILL.md (2), procedures/rebase-onto-base.md (1), procedures/qa-playbook.md §8 (the per-round PO — the domain-spawning phase where the roll-call matters most) modify
Mechanical gates scripts/lint-conventions.sh — home of the repo's mechanical checks, and therefore where AC-3's "no consumer outside the read helper references the supersede kind" scan and AC-16's "no len+1 arithmetic on Phase-Outcome read output" scan land. Omitted from the first draft of this table; added 2026-08-31 after the collision review showed this file is contended by every concurrent feature. modify
Test suites procedures/test/supersede-flow.sh (new) + a shared roll-call fixture helper; the suites that actually invoke phase-outcome-post.sh, verified by grep against the merged base: smoke.sh, typed-scan.sh, provenance.sh, disposition-flow.sh, promotion-flow.sh, supervise/test/supervisor-flow.sh, and the harness fixture supervise/test/fixtures/bin/claude (each call gains --roll-call). NOT typed-scan-family.sh, release-flow.sh or the new pipeline-identity.sh — none of them post a Phase Outcome. new / modify

File Boundaries

Three independent tracks once the schema is fixed:

  • Read track_lib.sh _po_annulment + read-all.sh.
  • Write trackphase-outcome-supersede.sh + the new schema doc.
  • Roll-call trackpost.sh + the ~12 producer call sites + the existing suites.

They touch _lib.sh and post.sh disjointly (_po_annulment is a new function; --roll-call is a
new arm in post.sh's parser), so they can proceed in parallel. Doc/contract edits follow whichever
track settles the behaviour they describe. resolve/SKILL.md is touched by the write track (the
--supersede door) and by the read track (the accept-only restriction) — assign it to one owner.

Dependencies & Sequencing

  1. The schema doc first — both the read and write tracks encode its field names, its
    header-authoritative identity rule and its latest-wins rule; settling it late means two
    implementations drift.
  2. Read and write tracks in parallel; supersede-flow.sh needs both to be exercisable end-to-end.
  3. The roll-call track is independent of both and can land first. Its own internal order is
    load-bearing: post.sh must not require --roll-call until the call sites and suites pass it,
    or every suite goes red at once. Land the fixture helper, then the call sites and suites, then
    flip the requirement on.

Constraints & Non-Goals

Constraints:

  • Portability baseline: POSIX skill-authored glue; helper tier bash ≥ 3.2 + jq + git, no
    python/node, no bashisms in emitted shell.
  • No new adapter primitivepost_comment + scan_comments only (verified against the real
    forge; see External Data Contracts).
  • No phase-outcome:v1:v2 bump. Cross-version reads are unsupported, so a bump would make
    every existing record invisible to the new reader.
  • The annulled record is never edited or deleted; §8 is preserved, not relaxed.
  • Enforcement of the roll-call is on the record's shape, never on live agents, and lives only at the
    post seam.

Non-goals (do NOT build):

  • The general decision-withdrawal path (#36). This slice only stops decisions belonging to an
    annulled outcome from blocking.
  • Correcting deliverables (sreq:v1, qa-report:v1, test-plan:v1) — already latest-wins.
  • Editing or deleting a posted Phase Outcome — remains a contract violation.
  • A roll-call that sees live agents — needs harness knowledge the stack-agnostic rule forbids; the
    resulting "cannot be proved complete" limit is accepted knowingly.
  • Migrating history — records posted before this feature simply carry no supersede and no roll-call;
    nothing is rewritten.
  • Verifying by against the comment's real author — would need a new adapter primitive; the field
    is a recorded claim, and the schema says so.

Base and concurrency (recorded 2026-08-31)

This plan was authored against main at 8de068e and has since been rebased onto bd2532c
(release 0.4.0)
, 48 commits later. All seven pre-existing shell suites and lint-conventions.sh
run green on that base, so nothing the plan depends on was disturbed. Two concurrent features landed
in between, and both touch this slice's surface:

  • #47 (deployed, shipped in 0.4.0) rewrote _prim in bin/_lib.sh into a capability-gated
    dispatch and claimed exit codes 8 (EXIT_UNSUPPORTED) and 9 (EXIT_QUERY). This slice's
    mapping — 2 / 4 / 6 / 7 — is unaffected, and it must stay unaffected: do not mint a new exit
    code for the supersede helper. It also rewrote forge-contract.md §2.1/§2.2, which is where this
    slice adds phase_outcome.supersede — extend the merged table, do not restore the older one.
    _po_annulment reaches the forge through _typed_scan_prim scan_comments, and
    scan_comments is a required primitive, so the new gating changes nothing for this slice.
  • #43's code is merged (its edits to develop/SKILL.md and qa-playbook.md are on this base),
    but the issue is back at phase/requirements — a QA round posted PO-43-11 skill=qa → requirements. Its feature is therefore not finished, and it may re-open exactly the two files
    this slice's roll-call track needs. Re-check before the roll-call track reaches them.

The phase-outcome-post.sh call-site inventory was re-counted against the merged base and is
unchanged: 12 invocations across requirements (2), technical-plan (3), develop (3),
integrate (1), promote (2), and rebase-onto-base.md (1), plus qa-playbook.md §8's per-round
Phase Outcome, which is prose rather than an emitted call.

<!-- sreq:v1 issue=45 skill=technical-plan --> # Software Requirements: 45-phase-outcome-supersede ## Context `phase-outcome:v1` is the suite's terminal handoff record and is **immutable** by contract (§8) — so it is the only durable record with no correction path. A phase lead that discovers, after posting, that its own record states something false can do nothing about it; a false finding stays live and a Pending Decision raised on a false premise stays open. The recorded incident: a five-lens review panel returned nothing, the lead concluded the transport was broken and wrote *"panel could not be run"* into its Phase Outcome, and the panel's results arrived **34 seconds later** carrying two blocking concerns. This slice adds a **supersede** path (recovery) and a **roll-call** (prevention), which the PREQ ships together because each was shown insufficient alone. ## Approaches Considered ### Approach A: A new sibling record kind, honoured inside the read helper **Summary:** annulment is a new append-only comment kind, `phase-outcome-supersede:v1`, and the one place that honours it is `phase_outcome.read_all` — so every compliant consumer inherits the behaviour unmodified. **Pros:** `phase-outcome:v1`'s wire format is untouched, so no `:v2` bump and no history goes invisible; the annulled comment is never edited, so §8 stands unweakened; the compatibility promise is true *by construction* rather than by an enumerated consumer list; latest-wins-per-target reuses §9's rule verbatim, and the append-only `active`/`withdrawn` shape reuses `folded-finding:v1`'s proven pattern; no new adapter primitive. **Cons:** `read_all` gains a second comment scan per call; the record set a consumer sees is no longer literally the comment set on the issue, so history needs an explicit opt-in flag. **Effort:** Medium ### Approach B: Annulment carried inside a new Phase Outcome **Summary:** add a `supersedes[]` body field to `phase-outcome:v1`; correcting a record means minting a new outcome that names what it annuls. **Pros:** no new record kind; the corrector is itself a first-class outcome, so it carries its own attribution, roll-call and ordinal, and the ledger remains the whole story in one chain. **Cons:** ruinously heavy for the actual use case. An operator annulling one false finding months later must mint a whole Phase Outcome — which forces them to choose a `next_state` (moving pipeline state as a *side effect of a correction*), supply a roll-call for a run that never happened, and pick a producer name from a closed enum that has no `operator` in it. The PREQ rejected forcing a replacement outcome for exactly this weight (decision 4). **Effort:** Medium ### Approach C: Reuse `decision-resolution:v1` **Summary:** express annulment as an "annulment decision" resolved through `/dev:resolve`. **Pros:** zero new schema; reuses the §9 lookup rule and the whole existing operator surface. **Cons:** a decision is a **question**; an annulment is an **assertion**. It needs a Pending Decision to exist first, which the false outcome never raised — so a self-correcting skill would be answering its own invented question. And decision IDs are derived from Phase Outcome ordinals, so an operator arriving weeks later has no ID to name. **Effort:** Low ## Decision **Selected:** Approach A — a new sibling record kind, honoured inside the read helper. **Rationale:** it is the only shape that keeps both promises the PREQ treats as non-negotiable — the annulled record is never touched (§8 stands) and the exclusion is inherited by consumers written before the feature existed (honoured in one place, not an enumerated list). B and C both fail the second promise or pay for the first with a correction act heavier than the thing it corrects. ## Architecture ### Component Overview ``` producer skill operator | | | phase_outcome.supersede | /dev:resolve --supersede v v bin/phase-outcome-supersede.sh (validates target, reason, attribution) | | post_comment <!-- phase-outcome-supersede:v1 ref=… by=… --> v ┌───────────────────────── the issue's comment ledger ─────────────────────────┐ │ phase-outcome:v1 ×N (immutable, dense ordinals) │ │ phase-outcome-supersede:v1 ×M (append-only, latest-wins per target) │ └──────────────────────────────────────────────────────────────────────────────┘ ^ | | scan_comments ×2 | _po_annulment (bin/_lib.sh) | v bin/phase-outcome-read-all.sh ──► filtered ledger ──► every consumer ^ (read_latest, decisions-settled, | /dev:resolve, integrate, promote, bin/phase-outcome-read-latest.sh fold-/promotion-candidates, …) ``` ### Data Flow **Annulling.** A skill (mid-run, on discovering its own record is false) or the operator (later, from any session) calls the supersede helper with a target, a reason and an attribution. The helper reads the **unfiltered** ledger, refuses an unresolvable target, and posts one append-only comment. **Reading.** `read_all` scans the Phase Outcome records exactly as today, runs the §8 immutability check, parses, applies the foreign-issue gate, and enforces ordinal density **on the raw ledger**. Only then does it scan the supersede kind, resolve latest-wins per target through `_po_annulment`, and project the filtered view. `read_latest` wraps `read_all`, so the current-phase lookup inherits annulment with no code of its own. **Preventing.** `phase_outcome.post` requires a `roll_call` array. It validates the array's *shape* and refuses absence — it never inspects live agents. ### The supersede record ``` <!-- phase-outcome-supersede:v1 ref={PO-id | F-id} by={producer|operator} --> { "status": "active" | "withdrawn", "target": "{ref}", // echoes the header; the header is authoritative "by": "{ref}", // echoes the header; the header is authoritative "reason": "<non-empty>" } ``` `scope` (outcome vs finding) is **derived from `ref`'s form** (`PO-…` vs `F-PO-…`) and deliberately not stored — a third copy of the same fact is a third thing that can disagree. The body echoes `target`/`by` for a human reading the comment, exactly as `folded-finding:v1` echoes its header tokens; the **post helper refuses any header/body mismatch**, and **readers treat the header as authoritative for identity**. **Matching `ref` is exact-token, never substring.** `F-PO-45-3-2` is a substring-prefix of `F-PO-45-3-21`, and this suite has already shipped that bug once (`ref=D-PO-2-4-1` matched `ref=D-PO-2-4-12`, observed live). The reader parses the header's `key=value` attributes with the anchored-header regex and compares with **jq string equality** — it never greps the raw body. ### Read-side gating of supersede records — fail closed The supersede kind *removes data from what consumers act on*, so a record of it that cannot be trusted must never be honoured silently, and must never be *dropped* silently either. Four disjoint outcomes in `_po_annulment`, mirroring the family's existing partition: | What was scanned | What happens | | --- | --- | | Anchored + parseable + `ref` names a target on **this** issue | Honoured (latest-wins per target). | | Anchored + **unparseable** body, or unknown `status` | **Hard refusal** of the read (`EXIT_ENV`), naming the comment id and the offending element — the `_po_parse` treatment. Silently ignoring it could resurrect a finding that was genuinely annulled. | | Anchored + `ref` claims a target on a **different issue** | **Hard refusal**, same gate and wording as `_po_foreign_gate`. A session must not annul another issue's records. | | Anchored + well-formed but the target **does not exist** on this issue (a dangling, hand-posted record) | **Inert + warned on stderr every read**, naming the comment id — the near-miss treatment. It annuls nothing, and refusing the whole read would let one hand-posted comment take an issue offline. | | **Edited** (`edited_ms > created_ms`) | **§8-strength hard refusal** (`EXIT_IMMUTABLE`). Detected by the same signal every adapter already renders (local-fs pins mtime to `epoch_ms`; remote adapters render `created_at`/`updated_at`) — no new mechanism. One edited record refuses the whole issue read, §8 parity. That an edit can therefore take an issue's reads offline is **tamper-evidence working as intended**, not a defect. | ### External Data Contracts | Boundary / source | Operation | Real shape (verified) | Provenance | | ----------------- | --------- | --------------------- | ---------- | | Gitea (`git.wihslon.com` / `jbr870/devwork-skills`) via the `tea-cli` adapter — the comment ledger this feature reads and writes | `scan_comments(issue, header)` read; `post_comment(issue, body)` write | `[{comment_id: string, created_ms: number, edited_ms: number, raw_body: string}]`; line 1 of a record round-trips **byte-identical**, read back as `<!-- phase-outcome:v1 id=PO-45-1 skill=requirements -->` | `recorded` → `external-contracts/forge-comment-ledger.provenance.json` | The capture confirms the two facts the design rests on: an HTML-comment header with space-delimited `key=value` attributes survives the round trip (so a new sibling kind needs **no new adapter primitive**), and the scan envelope already carries `created_ms` + `edited_ms` — the ordering key for latest-wins-per-target and the §8 edit signal are both present today. No boundary is `unverified`, so this phase raises **no precondition**. ### Observability & Audit Not applicable as a *policy* obligation: the project declares `observability: mode: none`, so the applicability test does not intersect and no logging/audit AC rows are owed. Recorded here only because the reason for that `none` is load-bearing for this feature — *"run traceability is the forge record (Phase Outcomes, deliverables), not logs"*. The record trail **is** this project's only audit surface, which is why §8's tamper-evidence is preserved rather than relaxed, and why the supersede record itself carries mandatory attribution and reason. **The honest limit on attribution.** `by` is an **unverified self-report**, exactly as the roll-call is. Nothing in the forge binds it to the comment's actual author, and the suite deliberately adds no adapter primitive to fetch one. It is a *claim*, recorded and permanent — which is the same standing the suite already gives a `/dev:resolve` resolution posted by an autonomous run. State this in the schema beside the roll-call's limit; do not let a reader mistake `by=operator` for proof of a human. ### Key Decisions Each of these settles something the PREQ deliberately deferred to this phase, or something the review panel raised. | Decision | Choice | Rationale | | --- | --- | --- | | Wire form of an annulment | A new kind `phase-outcome-supersede:v1` (see *The supersede record*) | A sibling kind leaves `phase-outcome:v1`'s wire format untouched, so §7's reader test is trivially passed and no bump is owed. | | Target granularity | **One record, one target.** `ref` is a PO id (`PO-45-3`) or a finding id (`F-PO-45-3-2`) | Finding IDs embed their PO id, so a single `ref` identifies both cases and latest-wins keys on one value. N targets = N records. (Settles *"may one supersede target findings across multiple outcomes"* — no.) | | Identity conflicts | Header `ref`/`by` are **authoritative**; the body echoes them; post refuses a mismatch; `scope` is derived, never stored | Three copies of one fact is three things that can disagree. Mirrors `folded-finding:v1`'s header/body echo. | | `ref` matching | Exact header-attribute token equality in jq — never a raw-body substring | `F-PO-45-3-2` prefixes `F-PO-45-3-21`; this exact bug class already shipped once. | | Ordering: "newest surviving outcome" | The PO **ordinal** | `read_all` already sorts by it and enforces density; ordinals are minted monotonically and are the ledger's identity. Timestamps are the *supersede* key, not the outcome key. | | Ordering: which supersede governs a target | Latest by `(created_ms, comment_id)` | Literally §9's rule and its deterministic same-second tiebreaker; a concurrent pair resolves without a special case. | | Un-annulling | `status: withdrawn`, append-only — never an edit or a delete. **`withdrawn` carries the same mandatory `reason` + `by` and the same refusal set as `active`** | The `folded-finding:v1` pattern. Un-annulling restores a record to force, so it deserves equal attribution rigour. | | Precedence between outcome- and finding-level records on one PO | **Independent, per target.** Annulling `PO-45-3` does not moot a `withdrawn` on `F-PO-45-3-2`; each target resolves on its own, and an annulled outcome leaves the actionable set whatever its findings say | Per-target latest-wins needs no precedence rule; adding one would create a second lookup to keep consistent. | | Attribution value for the operator | The reserved literal `by=operator`; every other value must be a canonical producer name | `operator` is disjoint from the closed producer enum (`requirements`, `technical-plan`, `develop`, `rebase`, `qa`, `integrate`, `promote`, `vibecoding`, `resolve`, `setup`), so skill-vs-operator is decidable from the value alone. Its truthfulness is a claim, not a proof — see *Observability & Audit*. | | Where annulment is honoured | `phase-outcome-read-all.sh`, via one `_po_annulment` in `bin/_lib.sh` | The contract already forbids re-implementing scan-and-resolve (§9). One implementation is what makes the compatibility criterion true by construction. | | What a default read returns | Annulled **outcomes** absent from the array; annulled **findings** moved out of `findings[]` into an additive `annulled_findings[]`; plus an additive `annulled_count` on every surviving outcome's sibling projection | Absence is what gives an unmodified consumer the exclusion for free (`read_latest`'s `last` becomes the newest survivor; an empty array → `null` → `backlog`). `annulled_count` lets a consumer *know filtered history exists* without opting into it — the visibility hook that keeps an annulment from being invisible at a human touchpoint. | | Reading history | `--include-annulled` returns the whole ledger with `superseded: {…}\|null` on each outcome and finding | The history door, and what the supersede helper itself reads. Additive; default behaviour unchanged. The two modes have **different output shapes** — the schema says so, so no consumer mixes them. | | Density check placement | On the **raw** ledger, before filtering. The **filtered** array is deliberately ordinal-sparse | Removing an outcome would otherwise trip the existing `[.[].ordinal] == [range(1;length+1)]` dense check. Consumers must not re-assert density on read output, nor compute `len+1` from it. | | Decisions paired to an annulled **finding** | **Left in `pending_decisions[]`** — the gate keeps counting them. `/dev:resolve` presents such a decision but restricts it to `accept` | Dropping them was the draft's plan and two lenses caught it: a finding-level supersede would then silently pass the decisions-settled gate while the outcome and its `next_state` still stand — pipeline state advancing on annulled evidence, which AC-6 forbids. The real hazard (resolving `defer-to-issue` spawning a sibling issue **from a finding annulled as false**) is closed at the acting site instead of by hiding the question. | | Decisions on an annulled **outcome** | Stop blocking, per AC-7 — regardless of who posted the supersede | AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. The security question that raises is surfaced as a finding + decision, not resolved unilaterally. | | What `post` sees | The **raw** ledger, always | Ordinal minting, the collision check, the foreign-issue gate, the duplicate-ordinal gate and the timeout-retry guard must not shift when a record is annulled: an annulled outcome keeps its ordinal forever, so ordinals stay dense and monotonic. | | Roll-call dispositions | `reported` \| `terminated`, exactly one per entry, exactly one entry per spawned agent, **`agent` names unique within a roll-call**. **No `unrecoverable`**; any other value refused | `reported` = the lead **read** the result, or reconstructed the outcome from the tree per `wait-discipline.md` §1a step 3. `terminated` = the lead ended the agent before posting, so no late report can arrive. A terminated agent whose report was already read is `reported`. The omission is the teeth. | | Roll-call scope | **Direct spawns only** — transitive children are not counted | A lead can only account for what it started; a teammate's children are that teammate's roll-call. Enumerating them would need harness knowledge the stack-agnostic rule forbids. Stated as an accepted limit beside the existing one. | | Roll-call absence | `--roll-call` omitted, or given an **empty string**, is refused; `[]` is a valid "spawned none". The flag still accepts `@file` or an inline literal via `_json_arg` | The evasion is one omission wide (PREQ decision 9), so the parser keeps an explicit *was-the-flag-seen* sentinel — but bypassing `_json_arg` entirely would silently drop `@file` support and break symmetry with `--produced`/`--findings`. Sentinel for absence, `_json_arg` for the value. | | Roll-call versioning + enforcement seam | **Optional in the schema, required by the post helper.** Readers MUST NOT refuse its absence | Producer-side narrowing, which §7 allows without a bump: no existing record becomes unreadable and no reader rejects one. Pre-feature records read `roll_call: null`, exactly as `suite` does. Enforcement lives **only** at the post seam, so a hand-posted record bypasses it — which is why readers must tolerate absence. | | Which outcomes need a roll-call | **Every** `phase-outcome:v1` | Every Phase Outcome *is* the terminal handoff of a phase run (schema line 1). This dissolves the deferred *"enumeration of terminal `next_state` values"*: the qualifier is the record kind, not a state list — and it leaves no escape hatch. | | Roll-call vs the retry guard | **Included** in the normalized comparison (unlike `suite`), **sorted by `agent`** before comparing. A landed pre-feature record with `roll_call: null` never matches an incoming `[]` | `suite` is excluded because it describes the *writer*; a roll-call describes the *run's content*. Sorting matters: array-order jitter between two attempts would otherwise defeat the guard and mint a duplicate outcome — the failure the guard exists to prevent. | | Exit codes | `EXIT_USAGE` (2) for missing/empty `--roll-call`, `--reason`, `--by`, a bad disposition or a malformed entry; `EXIT_FINDING` (6) for an unresolvable target; `EXIT_ENV` (7) for a foreign or unparseable supersede at read time; `EXIT_IMMUTABLE` (4) for an edited one | Callers branch on rc; an unknown target is a state-dependent record-shape refusal, not a usage error, and leaving the mapping implicit invites drift. | | Superseding an already-annulled target | Accepted, and a record **is** posted; the helper returns `already_annulled: true` | It must be accepted — re-annulling after a `withdrawn`, and recording a better reason, are both this path. Posting the record is what makes it an *inert, recorded* no-op rather than a silent one. | | Operator surface | Extend `/dev:resolve` (`--supersede <target> --reason "…"`, plus an interactive menu entry) | The PREQ asked for "a command in the shape of `/dev:resolve`". One orchestrator surface, and it avoids adding a shipped skill (which also carries the `SHIPPED_SKILLS` lint step). Rejected: a new `/dev:supersede` skill. The **helper** is the real API — a skill correcting itself mid-run calls it directly. | ## Technical Risks | Risk | Likelihood | Impact | Mitigation | | --- | --- | --- | --- | | Making `--roll-call` mandatory breaks every existing producer call site at once | High | High | It is a mechanical, enumerable edit (see *Implementation Scope*), and the shell suites fail loudly the moment one is missed. Sequencing is the mitigation: land the call sites and suites first, flip the requirement on last. A shared fixture helper in `test/` keeps the flag-day to one edit per suite. | | `$PO_ORDINAL = len(prior_outcomes) + 1` silently drifts once outcomes can be filtered out | Medium | High | Already wrong for a gapped ledger (`post` mints `max+1`); annulment makes it wrong more often. Fixed in this slice **and** covered by a mechanical scan for other `len(...)+1`-on-read-output call sites — `develop` is one of N, not necessarily the only one. | | A reader assumes `read_all`'s array is still the literal comment set, or re-asserts density on it | Medium | Medium | `--include-annulled` is the documented history door; `annulled_count` signals filtered history in default mode; the schema, procedure doc and contract all state the default is the *actionable* set, not the ledger, and that it is ordinal-sparse. | | Second comment scan per `read_all`, **amplified repo-wide by the candidate sweeps** — `promotion-candidates.sh` / `fold-candidates.sh` go from one to two forge calls per open issue, including issues that can never carry a supersede | Medium | Low | **Accepted knowingly, not discovered.** The performance lens found no blocking concern at this load (tens of comments per issue, a handful of reads per phase, tens of open issues; seconds at worst on a remote adapter). Two lenses independently noted the available lever: `phase-outcome-supersede:v1` *contains* the substring `phase-outcome`, so one scan could feed both filters locally. Not taken now — it entangles the anchored-header / near-miss discrimination (#54) for a cost nobody is paying. If the sweeps ever feel slow, fix it **there** (one comment fetch per issue, reused for both kinds), not in `read_all`. | | The two scans in `read_all` are not atomic | Low | Low | Benign: a supersede posted between them simply takes effect on the next read. Documented rather than locked. | ## Expert Review **Panel: Tier 3, five lenses, on `fable`.** Reviewer reports carried 12 blocking concerns; 11 are actioned below and 1 is surfaced as a finding for the orchestrator. > **Process note, recorded because this feature is about exactly this.** Four of the five reviewers > went **idle without delivering their reports**. Following `wait-discipline.md` §1a, the tree was read > before any conclusion was drawn — all four had *written* complete reviews; the reports were dropped, > not the work — and every one was recovered and used. Had the panel been recorded as "could not be > run", this SREQ would have shipped without 8 of its blocking corrections, reproducing the > feature-230 incident inside the plan for the fix. This phase's own roll-call records all five as > `reported`, four of them reconstructed from the tree. ### Reviewers - **Solution Architect** — read-side gating of supersede records was unstated; dropping decisions on finding-level annulment exceeds the AC and lets state advance on annulled evidence. - **Database Expert** — the `ref`-substring hazard would recur; three copies of the target have no conflict rule; the filtered array is ordinal-sparse and consumers must be told. - **Backend Developer** — exit-code mapping unspecified; bypassing `_json_arg` for `--roll-call` silently drops `@file`; roll-call array-order jitter defeats the retry guard. - **Security Specialist** — `by` is a self-declared claim and the plan said so for the roll-call but not for attribution; a supersede can dissolve a blocking approval gate; the foreign-issue gate was unstated for the new kind. - **Performance Engineer** — no blocking concern; the doubled scan is acceptable at this load, but the repo-wide sweep amplification should be *chosen*, not discovered. ### Changes Made - Added **Read-side gating of supersede records — fail closed**: a four-way partition (honour / hard-refuse unparseable / hard-refuse foreign / inert-and-warn on a dangling target) plus the §8 edit refusal, with per-adapter detection and the "one edited record refuses the issue read" parity spelled out. *(3 lenses raised this independently.)* - **Reversed the draft's decision to drop pending decisions on finding-level annulment.** They now stay in `pending_decisions[]` and the gate keeps counting them; `/dev:resolve` restricts such a decision to `accept` so no sibling issue is spawned from a finding annulled as false. - Specified **exact-token `ref` matching** in jq against parsed header attributes, never a raw-body substring, naming the shipped prefix-collision bug it prevents. - Made the **header authoritative for identity**, dropped `scope` from the record as derived, and made a header/body mismatch a post-time refusal. - Documented the full **exit-code mapping**. - Kept `_json_arg` (and therefore `@file`) for `--roll-call`'s *value* while adding a was-the-flag-seen **sentinel** for the absence refusal; added per-entry validation including **unique `agent` names**. - **Sort `roll_call` by `agent`** in the retry-guard normalization, and defined `null` vs `[]` as an explicit non-match. - Stated that roll-call enforcement lives **only at the post seam**, so **readers must not refuse its absence**. - Stated that **`by` is an unverified self-report**, with the same candour the roll-call's limit already had, and added **`annulled_count`** to default read output so an annulment cannot be invisible at a human touchpoint. - Required the same `reason` + `by` rigour on **`withdrawn`** as on `active`. - Defined **per-target independence** between outcome- and finding-level records on one PO. - Documented the filtered array as **ordinal-sparse**, and widened the `$PO_ORDINAL` fix to a **mechanical scan** for other `len+1`-on-read-output sites. - Added the **sweep amplification** to Technical Risks as an accepted cost with a named future lever, and the **non-atomic two-scan race** as a benign, documented behaviour. - Named the **same-`created_ms` tiebreak** as an explicit case in `test/supersede-flow.sh` (AC-10), and a **shared fixture helper** for the roll-call flag-day (Technical Risks). - Added the **`--include-annulled` output-shape note** to the schema deliverable so no consumer mixes modes. ### Noted (not actioned) - **Single-scan optimization** (one `scan_comments` for the common `phase-outcome` prefix, partitioned twice locally). Raised by two lenses, so it is promoted into the plan — but as a **documented, deliberately-declined lever** in Technical Risks rather than as work in this slice: the performance lens found no blocking cost at this load, and the change entangles the anchored-header / near-miss discrimination that #54 hardened. Accepted as a cost chosen with the numbers in hand. - **Barring a skill-posted supersede from dissolving a blocking `approval` decision.** Real and worth deciding, but AC-7 does not qualify by attribution, and narrowing it here would be the design overruling the requirement. Note also that it grants no *new* capability: an autonomous run can already self-answer a gate through `/dev:resolve`'s structured mode. Surfaced as a finding with a `defer-to-issue` recommendation so a human decides it on its own merits. ## Acceptance Criteria | ID | Criterion (from PREQ) | Verification approach | | --- | --- | --- | | AC-1 | A supersede naming a false finding makes the read helper report that finding as annulled, carrying its reason and the identity of the annulling record | Integration: `test/supersede-flow.sh` — post PO, supersede one finding, assert `annulled_findings[0]` carries `reason`, `by` and the annulling `comment_id` | | AC-2 | The annulled finding is absent from the set a consumer acts on; its sibling finding survives; the original comment is present, unedited, unchanged | Integration: assert `findings[]` excludes it and still contains the sibling; assert the PO comment's `raw_body` is byte-identical and `edited_ms == created_ms` | | AC-3 | A consumer written before this feature, **unmodified**, reads without error and no longer acts on annulled data | Integration: run `promotion-candidates.sh` and `fold-candidates.sh` **unmodified** against an issue carrying supersedes; assert exit 0 and that the annulled outcome's state is not what they report. **Mechanical**: a grep-scan asserting no consumer outside `read-all.sh`/`_lib.sh` references the supersede kind — the "honoured in one place" constraint, enforced the way the helper-funnel rule requires | | AC-4 | An outcome-level supersede makes the phase lookup resolve to the newest **surviving** outcome, walked past as `skipped` already is | Integration: three POs, annul the newest, assert `read_latest` returns the middle one's `next_state` | | AC-5 | With every outcome annulled the phase lookup reports `backlog` — the same answer as an issue with no outcome | Integration: annul the only PO on issue B; assert `read_latest` → `null`; assert issue C (no POs) → `null`; assert the two agree | | AC-6 | A finding-level supersede leaves the phase unchanged, including when every finding is annulled individually; only an outcome-level supersede moves it | Integration: annul finding 1 → phase unchanged; annul finding 2 → still unchanged; **assert the outcome's pending decisions are still counted at each step** (the gate must not advance on annulled-finding evidence); annul the outcome → phase moves | | AC-7 | An annulled outcome's open blocking Pending Decisions stop blocking the decisions-settled gate | Integration: assert the decision id is enumerable before and absent from `read_all`'s `pending_decisions[]` after — the exact set `decisions-settled-check.md` iterates — and still present under `--include-annulled` | | AC-8 | Any supersede names who posted it, exactly what it annuls, and a non-empty reason | Integration: assert `by`, `target`, `reason` on a skill-posted and an operator-posted record; assert `by=operator` is distinguishable from every producer name; assert a header/body mismatch is refused | | AC-9 | The operator can supersede an outcome from an earlier run, from a different session | **human-uat**: operator runs `/dev:resolve --supersede` on this repo's own tracker from a fresh session (dogfooding). Helper-level half also covered by `supersede-flow.sh` (the helper carries no session state) | | AC-10 | A supersede posted in error is corrected by a later one naming the same target — latest-wins | Integration: annul, assert annulled; post a `withdrawn` for the same target, assert restored; assert the first record is still readable; **named case: two records on one target sharing `created_ms` resolve deterministically by `comment_id`**; **named case: `F-PO-x-y-2` is unaffected by a supersede targeting `F-PO-x-y-21`** | | AC-11 | A supersede whose target does not exist is refused, naming the bad target, leaving no record | Integration: unknown PO id → exit 6, stderr names the target, comment count unchanged; a finding id belonging to a **different** outcome → same; an issue with no outcomes at all → same. **Read side**: a hand-posted dangling supersede is inert, warns on stderr naming the comment id, and does not fail the read | | AC-12 | A supersede missing its reason or its attribution is refused, naming what is missing | Integration: absent reason, empty-string reason, absent `by`, and an invalid `by` → four refusals at exit 2, each naming the missing or invalid element; the same four for `status: withdrawn` | | AC-13 | Every Phase Outcome carries a roll-call: one entry per spawned agent disposed `reported`/`terminated`, or an explicitly empty one | Integration: post with two `reported`; with one `reported` + one `terminated`; with `[]` — assert each round-trips through `read_all`; assert `@file` and inline forms both work | | AC-14 | A Phase Outcome with **no** roll-call at all is refused — absence is never read as "spawned none" | Integration: omit `--roll-call` → exit 2 naming the missing element, no comment posted; `--roll-call ''` → same; then post the same payload with `[]` → accepted. **Read side**: a pre-feature record with no `roll_call` reads as `null` **without** error — enforcement is post-only | | AC-15 | A roll-call entry disposed anything other than `reported`/`terminated` — `unrecoverable` included — is refused | Integration: `unrecoverable` → refused; an invented value → refused; a missing/empty `agent` → refused; two entries with the same `agent` → refused; then `terminated` → accepted | | AC-16 | *(derived — correctness rider on the read-shape change)* No consumer computes an ordinal or a count from `read_all`'s **filtered** output; `develop`'s `$PO_ORDINAL` is highest-ordinal + 1 | **Mechanical**: a grep-scan over the shipped tree for `len(`/`length`-plus-one arithmetic on Phase-Outcome read output, asserting zero hits; integration: a ledger with an annulled outcome yields the same ordinal from the skill's stated formula as `post` actually mints | | AC-17 | *(derived — retry-guard integrity under the new field)* A timeout retry carrying the same roll-call in a different array order is absorbed, not duplicated | Integration: post, then re-post an identical payload with `roll_call` entries reordered → asserts `retried: true` and no new ordinal; a landed `roll_call: null` record vs an incoming `[]` → not a match, mints a new ordinal | **Coverage note.** `e2e`, `a11y` and `security-browser` are declared `not_applicable` for this project, so no browser lane exists to route to. Every automated row above is carried by the shell test harness under `plugin/skills/_shared/procedures/test/`, which was confirmed green in this session (`smoke.sh`, `typed-scan.sh`, `lint-conventions.sh` all rc=0) — the lane boots here today. **On AC-13's honesty.** The roll-call is a **self-report**, and these rows check that the record is well-formed and attributable, never that it is *true*. The same applies to `by` in AC-8. The PREQ accepts the roll-call limit explicitly; this SREQ extends the same candour to attribution. Do not let a green AC-13 or AC-8 be read as proof that a phase enumerated its agents faithfully, or that `by=operator` means a human was present. ## Implementation Scope ### Areas | Area | Files / directories involved | Nature of change | | --- | --- | --- | | New record schema | `plugin/skills/_shared/schemas/phase-outcome-supersede.v1.md`; the table + anchored-header notes in `schemas/README.md` | new | | Supersede helper | `plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh` | new | | Annulment lookup + read gating | `plugin/skills/_shared/procedures/bin/_lib.sh` — add `_po_annulment` beside `_po_foreign_gate`, carrying the four-way fail-closed partition | extend | | Read path | `bin/phase-outcome-read-all.sh` (annulment pass, `--include-annulled`, `annulled_count`, density moved onto the raw ledger) | modify | | Post path | `bin/phase-outcome-post.sh` (`--roll-call`: sentinel-for-absence + `_json_arg` for the value, per-entry validation, agent-sorted in the retry-guard normalization) | modify | | Phase Outcome schema | `schemas/phase-outcome.v1.md` — optional `roll_call`, the post-only enforcement seam, the normalization-excluded-set note, both projected read shapes | extend | | Forge contract | `forge-contract.md` §2.1 (`phase_outcome.supersede`), §7 (why no bump), §8 (the new immutable kind), §9 (latest-wins per target) | extend | | Procedure docs | `procedures/phase-outcome.md` (the new op + both read shapes), `decisions-settled-check.md` (the gate inherits exclusion; finding-level annulment does **not** unblock), `wait-discipline.md` (the roll-call as §1a/§1b's post-time counterpart) | modify | | Operator surface | `plugin/skills/resolve/SKILL.md` — `--supersede` structured mode, interactive entry, frontmatter description, **and the `accept`-only restriction on a decision whose `finding_ref` is annulled** | extend | | **Producer call sites** | `requirements/SKILL.md` (2), `technical-plan/SKILL.md` (3), `develop/SKILL.md` (3, **plus the `$PO_ORDINAL` fix**), `integrate/SKILL.md` (1), `promote/SKILL.md` (2), `procedures/rebase-onto-base.md` (1), `procedures/qa-playbook.md` §8 (the per-round PO — the domain-spawning phase where the roll-call matters most) | modify | | Mechanical gates | `scripts/lint-conventions.sh` — home of the repo's mechanical checks, and therefore where AC-3's "no consumer outside the read helper references the supersede kind" scan and AC-16's "no `len+1` arithmetic on Phase-Outcome read output" scan land. Omitted from the first draft of this table; added 2026-08-31 after the collision review showed this file is contended by every concurrent feature. | modify | | Test suites | `procedures/test/supersede-flow.sh` (new) + a shared roll-call fixture helper; the suites that actually invoke `phase-outcome-post.sh`, verified by grep against the merged base: `smoke.sh`, `typed-scan.sh`, `provenance.sh`, `disposition-flow.sh`, `promotion-flow.sh`, `supervise/test/supervisor-flow.sh`, and the harness fixture `supervise/test/fixtures/bin/claude` (each call gains `--roll-call`). NOT `typed-scan-family.sh`, `release-flow.sh` or the new `pipeline-identity.sh` — none of them post a Phase Outcome. | new / modify | ### File Boundaries Three independent tracks once the schema is fixed: - **Read track** — `_lib.sh` `_po_annulment` + `read-all.sh`. - **Write track** — `phase-outcome-supersede.sh` + the new schema doc. - **Roll-call track** — `post.sh` + the ~12 producer call sites + the existing suites. They touch `_lib.sh` and `post.sh` disjointly (`_po_annulment` is a new function; `--roll-call` is a new arm in `post.sh`'s parser), so they can proceed in parallel. Doc/contract edits follow whichever track settles the behaviour they describe. `resolve/SKILL.md` is touched by the write track (the `--supersede` door) and by the read track (the `accept`-only restriction) — assign it to one owner. ### Dependencies & Sequencing 1. **The schema doc first** — both the read and write tracks encode its field names, its header-authoritative identity rule and its latest-wins rule; settling it late means two implementations drift. 2. Read and write tracks in parallel; `supersede-flow.sh` needs both to be exercisable end-to-end. 3. **The roll-call track is independent of both** and can land first. Its own internal order is load-bearing: `post.sh` must not *require* `--roll-call` until the call sites and suites pass it, or every suite goes red at once. Land the fixture helper, then the call sites and suites, then flip the requirement on. ## Constraints & Non-Goals **Constraints:** - Portability baseline: POSIX skill-authored glue; helper tier bash ≥ 3.2 + `jq` + `git`, no `python`/`node`, no bashisms in emitted shell. - **No new adapter primitive** — `post_comment` + `scan_comments` only (verified against the real forge; see *External Data Contracts*). - **No `phase-outcome:v1` → `:v2` bump.** Cross-version reads are unsupported, so a bump would make every existing record invisible to the new reader. - The annulled record is **never** edited or deleted; §8 is preserved, not relaxed. - Enforcement of the roll-call is on the record's shape, never on live agents, and lives only at the post seam. **Non-goals (do NOT build):** - The general decision-withdrawal path (#36). This slice only stops decisions belonging to an *annulled outcome* from blocking. - Correcting deliverables (`sreq:v1`, `qa-report:v1`, `test-plan:v1`) — already latest-wins. - Editing or deleting a posted Phase Outcome — remains a contract violation. - A roll-call that sees live agents — needs harness knowledge the stack-agnostic rule forbids; the resulting "cannot be proved complete" limit is accepted knowingly. - Migrating history — records posted before this feature simply carry no supersede and no roll-call; nothing is rewritten. - Verifying `by` against the comment's real author — would need a new adapter primitive; the field is a recorded claim, and the schema says so. ## Base and concurrency (recorded 2026-08-31) This plan was authored against `main` at `8de068e` and has since been **rebased onto `bd2532c` (release 0.4.0)**, 48 commits later. All seven pre-existing shell suites and `lint-conventions.sh` run green on that base, so nothing the plan depends on was disturbed. Two concurrent features landed in between, and both touch this slice's surface: - **#47** (`deployed`, shipped in 0.4.0) rewrote `_prim` in `bin/_lib.sh` into a capability-gated dispatch and **claimed exit codes 8 (`EXIT_UNSUPPORTED`) and 9 (`EXIT_QUERY`)**. This slice's mapping — 2 / 4 / 6 / 7 — is unaffected, and it must **stay** unaffected: do not mint a new exit code for the supersede helper. It also rewrote `forge-contract.md` §2.1/§2.2, which is where this slice adds `phase_outcome.supersede` — extend the merged table, do not restore the older one. `_po_annulment` reaches the forge through `_typed_scan` → `_prim scan_comments`, and `scan_comments` is a *required* primitive, so the new gating changes nothing for this slice. - **#43**'s code is merged (its edits to `develop/SKILL.md` and `qa-playbook.md` are on this base), but the **issue is back at `phase/requirements`** — a QA round posted `PO-43-11 skill=qa → requirements`. Its feature is therefore *not* finished, and it may re-open exactly the two files this slice's roll-call track needs. Re-check before the roll-call track reaches them. The `phase-outcome-post.sh` call-site inventory was re-counted against the merged base and is unchanged: 12 invocations across `requirements` (2), `technical-plan` (3), `develop` (3), `integrate` (1), `promote` (2), and `rebase-onto-base.md` (1), plus `qa-playbook.md` §8's per-round Phase Outcome, which is prose rather than an emitted call.
Author
Owner
{
  "waves": [
    { "wave": 1, "work_units": [
      { "id": "WU-45-3-1", "title": "Schema & contract foundation (docs first)",
        "files": [
          "plugin/skills/_shared/schemas/phase-outcome-supersede.v1.md",
          "plugin/skills/_shared/schemas/README.md",
          "plugin/skills/_shared/schemas/phase-outcome.v1.md",
          "plugin/skills/_shared/forge-contract.md"
        ],
        "acceptance_criteria": [
          {"id": "AC-8",  "scope": "record schema only: header-authoritative ref/by, mandatory reason, by=operator reserved"},
          {"id": "AC-13", "scope": "phase-outcome.v1.md: optional roll_call field + disposition enum"},
          {"id": "AC-14", "scope": "phase-outcome.v1.md: post-only enforcement seam; readers MUST NOT refuse absence"},
          {"id": "AC-16", "scope": "phase-outcome.v1.md: default read output documented as ordinal-sparse"}
        ] }
    ] },
    { "wave": 2, "work_units": [
      { "id": "WU-45-3-2", "title": "Roll-call track: --roll-call at the post seam, call sites, suite flag-day",
        "files": [
          "plugin/skills/_shared/procedures/bin/phase-outcome-post.sh",
          "plugin/skills/_shared/procedures/test/fixtures/roll-call.sh",
          "plugin/skills/_shared/procedures/test/roll-call.sh",
          "plugin/skills/_shared/procedures/test/smoke.sh",
          "plugin/skills/_shared/procedures/test/typed-scan.sh",
          "plugin/skills/_shared/procedures/test/provenance.sh",
          "plugin/skills/_shared/procedures/test/disposition-flow.sh",
          "plugin/skills/_shared/procedures/test/promotion-flow.sh",
          "plugin/skills/supervise/test/supervisor-flow.sh",
          "plugin/skills/supervise/test/fixtures/bin/claude",
          "plugin/skills/requirements/SKILL.md",
          "plugin/skills/technical-plan/SKILL.md",
          "plugin/skills/develop/SKILL.md",
          "plugin/skills/integrate/SKILL.md",
          "plugin/skills/promote/SKILL.md",
          "plugin/skills/_shared/procedures/rebase-onto-base.md",
          "plugin/skills/_shared/procedures/wait-discipline.md"
        ],
        "acceptance_criteria": [
          {"id": "AC-13"}, {"id": "AC-14"}, {"id": "AC-15"}, {"id": "AC-17"},
          {"id": "AC-16", "scope": "develop/SKILL.md 3.1: $PO_ORDINAL = highest ordinal + 1, not len+1"}
        ] }
    ] },
    { "wave": 3, "work_units": [
      { "id": "WU-45-3-3", "title": "Read track: _po_annulment fail-closed gating + filtered/history read shapes",
        "files": [
          "plugin/skills/_shared/procedures/bin/_lib.sh",
          "plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh",
          "plugin/skills/_shared/procedures/test/supersede-read.sh"
        ],
        "acceptance_criteria": [
          {"id": "AC-1"}, {"id": "AC-2"}, {"id": "AC-4"}, {"id": "AC-5"}, {"id": "AC-6"}, {"id": "AC-7"},
          {"id": "AC-10", "scope": "read side: latest-wins per target, same-created_ms tiebreak by comment_id, F-…-2 vs F-…-21 prefix isolation"},
          {"id": "AC-11", "scope": "read side: dangling supersede inert, warns on stderr, does not fail the read"},
          {"id": "AC-14", "scope": "read side: a record with no roll_call reads as null without error"}
        ] }
    ] },
    { "wave": 4, "work_units": [
      { "id": "WU-45-3-4", "title": "Write track: phase-outcome-supersede.sh + end-to-end supersede-flow suite",
        "files": [
          "plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh",
          "plugin/skills/_shared/procedures/test/supersede-flow.sh"
        ],
        "acceptance_criteria": [
          {"id": "AC-8"}, {"id": "AC-12"},
          {"id": "AC-9",  "scope": "helper-level half only; the fresh-session operator half is the human-uat lane"},
          {"id": "AC-10", "scope": "write round-trip: active -> withdrawn -> active, first record still readable"},
          {"id": "AC-11", "scope": "write side: unresolvable target refused at exit 6, no record posted"},
          {"id": "AC-3",  "scope": "integration half: promotion-candidates.sh and fold-candidates.sh unmodified, exit 0"},
          {"id": "AC-16", "scope": "integration half: annulled ledger yields the same ordinal from the formula as post mints"}
        ] }
    ] },
    { "wave": 5, "work_units": [
      { "id": "WU-45-3-5", "title": "Operator surface, procedure docs, mechanical gates",
        "files": [
          "plugin/skills/resolve/SKILL.md",
          "plugin/skills/_shared/procedures/phase-outcome.md",
          "plugin/skills/_shared/procedures/decisions-settled-check.md",
          "scripts/lint-conventions.sh",
          "scripts/test-lint-conventions.sh"
        ],
        "acceptance_criteria": [
          {"id": "AC-3",  "scope": "mechanical grep-scan: no consumer outside read-all.sh/_lib.sh references the supersede kind"},
          {"id": "AC-16", "scope": "mechanical grep-scan: no len+1 arithmetic on Phase-Outcome read output"},
          {"id": "AC-7",  "scope": "/dev:resolve restricts a decision whose finding_ref is annulled to accept"},
          {"id": "AC-9",  "scope": "operator door: /dev:resolve --supersede structured mode + interactive entry"}
        ] }
    ] }
  ]
}

Five work units, five waves — strictly sequential. This repo is a single shared worktree, so
3.1's isolation rule forfeits intra-wave parallelism; one WU per wave makes that explicit and
removes all file contention. Every file above is owned by exactly one unit.

Why this order, and not the SREQ's three-track order. The SREQ's File Boundaries section
offers read/write/roll-call as parallel tracks, and notes the roll-call track "can land first".
Here it must: WU-45-3-2 flips --roll-call from unknown-arg to mandatory at the post seam, and
both later suites (supersede-read.sh, supersede-flow.sh) call phase-outcome-post.sh to build
their fixtures. Landing roll-call after them would turn two green suites red the moment the
requirement flipped. Sequencing it second — after the schema, before anything that posts — is what
keeps the flag-day to one wave.

  • WU-45-3-1 — schema & contract, docs first. The SREQ's own sequencing rule ("settling it late
    means two implementations drift"). Artifact-shape: the ACs it carries are schema rows, not
    behaviour, so per 3.2 it runs Implementer-only and the skip is recorded in its outcome summary.
  • WU-45-3-2 — roll-call track. Internal order is load-bearing and briefed as such: accept the
    flag optionally, land the shared fixture helper, update every call site and suite, then flip
    the requirement on. Also carries AC-16's skill-text half, because develop/SKILL.md is both a
    producer call site and the home of the $PO_ORDINAL formula — one owner per file.
  • WU-45-3-3 — read track. _po_annulment in _lib.sh plus the two projected read shapes in
    read-all.sh. Its suite hand-posts supersede comments as fixtures, so it does not depend on the
    write helper — which is also exactly what AC-11's dangling-record case needs.
  • WU-45-3-4 — write track. Depends on WU-45-3-3: the helper's already_annulled answer comes
    from _po_annulment, and it reads history through --include-annulled.
  • WU-45-3-5 — surfaces and gates. resolve/SKILL.md is touched by both tracks per the SREQ, so
    it gets a single owner here. scripts/lint-conventions.sh carries AC-3's and AC-16's mechanical
    scans; it is contended by every concurrent feature, so it lands last and alone.

Concurrency note carried forward from the plan. #43's code is merged into this base but its
issue is back at phase/requirements, so develop/SKILL.md and qa-playbook.md may re-open.
WU-45-3-2 owns develop/SKILL.md; qa-playbook.md carries no emitted phase-outcome-post.sh
call on this base (verified by grep), so it is deliberately not in any unit's file list.

<!-- wu-plan:v1 po=PO-45-3 skill=develop --> ```json { "waves": [ { "wave": 1, "work_units": [ { "id": "WU-45-3-1", "title": "Schema & contract foundation (docs first)", "files": [ "plugin/skills/_shared/schemas/phase-outcome-supersede.v1.md", "plugin/skills/_shared/schemas/README.md", "plugin/skills/_shared/schemas/phase-outcome.v1.md", "plugin/skills/_shared/forge-contract.md" ], "acceptance_criteria": [ {"id": "AC-8", "scope": "record schema only: header-authoritative ref/by, mandatory reason, by=operator reserved"}, {"id": "AC-13", "scope": "phase-outcome.v1.md: optional roll_call field + disposition enum"}, {"id": "AC-14", "scope": "phase-outcome.v1.md: post-only enforcement seam; readers MUST NOT refuse absence"}, {"id": "AC-16", "scope": "phase-outcome.v1.md: default read output documented as ordinal-sparse"} ] } ] }, { "wave": 2, "work_units": [ { "id": "WU-45-3-2", "title": "Roll-call track: --roll-call at the post seam, call sites, suite flag-day", "files": [ "plugin/skills/_shared/procedures/bin/phase-outcome-post.sh", "plugin/skills/_shared/procedures/test/fixtures/roll-call.sh", "plugin/skills/_shared/procedures/test/roll-call.sh", "plugin/skills/_shared/procedures/test/smoke.sh", "plugin/skills/_shared/procedures/test/typed-scan.sh", "plugin/skills/_shared/procedures/test/provenance.sh", "plugin/skills/_shared/procedures/test/disposition-flow.sh", "plugin/skills/_shared/procedures/test/promotion-flow.sh", "plugin/skills/supervise/test/supervisor-flow.sh", "plugin/skills/supervise/test/fixtures/bin/claude", "plugin/skills/requirements/SKILL.md", "plugin/skills/technical-plan/SKILL.md", "plugin/skills/develop/SKILL.md", "plugin/skills/integrate/SKILL.md", "plugin/skills/promote/SKILL.md", "plugin/skills/_shared/procedures/rebase-onto-base.md", "plugin/skills/_shared/procedures/wait-discipline.md" ], "acceptance_criteria": [ {"id": "AC-13"}, {"id": "AC-14"}, {"id": "AC-15"}, {"id": "AC-17"}, {"id": "AC-16", "scope": "develop/SKILL.md 3.1: $PO_ORDINAL = highest ordinal + 1, not len+1"} ] } ] }, { "wave": 3, "work_units": [ { "id": "WU-45-3-3", "title": "Read track: _po_annulment fail-closed gating + filtered/history read shapes", "files": [ "plugin/skills/_shared/procedures/bin/_lib.sh", "plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh", "plugin/skills/_shared/procedures/test/supersede-read.sh" ], "acceptance_criteria": [ {"id": "AC-1"}, {"id": "AC-2"}, {"id": "AC-4"}, {"id": "AC-5"}, {"id": "AC-6"}, {"id": "AC-7"}, {"id": "AC-10", "scope": "read side: latest-wins per target, same-created_ms tiebreak by comment_id, F-…-2 vs F-…-21 prefix isolation"}, {"id": "AC-11", "scope": "read side: dangling supersede inert, warns on stderr, does not fail the read"}, {"id": "AC-14", "scope": "read side: a record with no roll_call reads as null without error"} ] } ] }, { "wave": 4, "work_units": [ { "id": "WU-45-3-4", "title": "Write track: phase-outcome-supersede.sh + end-to-end supersede-flow suite", "files": [ "plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh", "plugin/skills/_shared/procedures/test/supersede-flow.sh" ], "acceptance_criteria": [ {"id": "AC-8"}, {"id": "AC-12"}, {"id": "AC-9", "scope": "helper-level half only; the fresh-session operator half is the human-uat lane"}, {"id": "AC-10", "scope": "write round-trip: active -> withdrawn -> active, first record still readable"}, {"id": "AC-11", "scope": "write side: unresolvable target refused at exit 6, no record posted"}, {"id": "AC-3", "scope": "integration half: promotion-candidates.sh and fold-candidates.sh unmodified, exit 0"}, {"id": "AC-16", "scope": "integration half: annulled ledger yields the same ordinal from the formula as post mints"} ] } ] }, { "wave": 5, "work_units": [ { "id": "WU-45-3-5", "title": "Operator surface, procedure docs, mechanical gates", "files": [ "plugin/skills/resolve/SKILL.md", "plugin/skills/_shared/procedures/phase-outcome.md", "plugin/skills/_shared/procedures/decisions-settled-check.md", "scripts/lint-conventions.sh", "scripts/test-lint-conventions.sh" ], "acceptance_criteria": [ {"id": "AC-3", "scope": "mechanical grep-scan: no consumer outside read-all.sh/_lib.sh references the supersede kind"}, {"id": "AC-16", "scope": "mechanical grep-scan: no len+1 arithmetic on Phase-Outcome read output"}, {"id": "AC-7", "scope": "/dev:resolve restricts a decision whose finding_ref is annulled to accept"}, {"id": "AC-9", "scope": "operator door: /dev:resolve --supersede structured mode + interactive entry"} ] } ] } ] } ``` **Five work units, five waves — strictly sequential.** This repo is a single shared worktree, so 3.1's isolation rule forfeits intra-wave parallelism; one WU per wave makes that explicit and removes all file contention. Every file above is owned by exactly one unit. **Why this order, and not the SREQ's three-track order.** The SREQ's *File Boundaries* section offers read/write/roll-call as parallel tracks, and notes the roll-call track "can land first". Here it *must*: WU-45-3-2 flips `--roll-call` from unknown-arg to mandatory at the post seam, and both later suites (`supersede-read.sh`, `supersede-flow.sh`) call `phase-outcome-post.sh` to build their fixtures. Landing roll-call after them would turn two green suites red the moment the requirement flipped. Sequencing it second — after the schema, before anything that posts — is what keeps the flag-day to one wave. - **WU-45-3-1 — schema & contract, docs first.** The SREQ's own sequencing rule ("settling it late means two implementations drift"). Artifact-shape: the ACs it carries are schema *rows*, not behaviour, so per 3.2 it runs Implementer-only and the skip is recorded in its outcome summary. - **WU-45-3-2 — roll-call track.** Internal order is load-bearing and briefed as such: accept the flag optionally, land the shared fixture helper, update every call site and suite, *then* flip the requirement on. Also carries AC-16's skill-text half, because `develop/SKILL.md` is both a producer call site and the home of the `$PO_ORDINAL` formula — one owner per file. - **WU-45-3-3 — read track.** `_po_annulment` in `_lib.sh` plus the two projected read shapes in `read-all.sh`. Its suite hand-posts supersede comments as fixtures, so it does not depend on the write helper — which is also exactly what AC-11's dangling-record case needs. - **WU-45-3-4 — write track.** Depends on WU-45-3-3: the helper's `already_annulled` answer comes from `_po_annulment`, and it reads history through `--include-annulled`. - **WU-45-3-5 — surfaces and gates.** `resolve/SKILL.md` is touched by both tracks per the SREQ, so it gets a single owner here. `scripts/lint-conventions.sh` carries AC-3's and AC-16's mechanical scans; it is contended by every concurrent feature, so it lands last and alone. **Concurrency note carried forward from the plan.** #43's code is merged into this base but its issue is back at `phase/requirements`, so `develop/SKILL.md` and `qa-playbook.md` may re-open. WU-45-3-2 owns `develop/SKILL.md`; `qa-playbook.md` carries no emitted `phase-outcome-post.sh` call on this base (verified by grep), so it is deliberately **not** in any unit's file list.
Author
Owner

Dev journal — WU-45-3-1 (supersede record schema, roll_call, contract sections)

2026-08-31T10:20 DISCOVERY: the phase-outcome:v1 prefilter cannot see a supersede record at all

type: discovery
timestamp: 2026-08-31T10:20
where: plugin/skills/_shared/procedures/bin/_lib.sh:225
tags: [anchored-header, typed-scan, prefilter, nesting]

Context: the WU brief asked whether _typed_scan's outcome 4 ("an anchored header of a DIFFERENT
kind → excluded silently") covers phase-outcome-supersede:v1 being returned by a scan for
phase-outcome:v1, since the new token nests with an existing Phase Outcome kind.

Pattern: it never gets that far. _typed_scan appends the version itself
(_prim scan_comments --issue "$issue" --header "${kind}:v1", _lib.sh:225), so the adapter's
substring test is against phase-outcome:v1not the bare token phase-outcome. A supersede
header reads <!-- phase-outcome-supersede:v1 ref=… by=… -->, which interposes -supersede
between phase-outcome and :v1, so contains($p) is false on every adapter
(tea-cli/bin/scan_comments.sh:63, glab-cli/bin/scan_comments.sh:33,
local-fs/bin/scan_comments.sh:31 — all the same case-exact substring test). Verified by running
the three predicates rather than reading them. The reverse direction is impossible for the same
reason: the longer token is not a substring of the shorter header.

Outcome 4 is nevertheless the correct backstop: the supersede header does match _typed_scan's
$anykind pattern (_lib.sh:232) and not the phase-outcome:v1 pattern, so if the prefilter were
ever widened to the bare token — the single-scan optimization the SREQ records as deliberately
declined — the record would be excluded silently, never mis-warned as a near-miss.

Why it matters: the brief's premise ("the token contains phase-outcome, so the prefilter can
see it") is true of the bare token only, and the prefilter does not use the bare token. Both
layers are now written into schemas/README.md as a worked example, in that order, so the next
reader gets the real mechanism rather than the plausible one.

2026-08-31T10:45 DISCOVERY: _po_foreign_gate's segment index does not transfer to a finding ref

type: discovery
timestamp: 2026-08-31T10:45
where: plugin/skills/_shared/procedures/bin/_lib.sh:323
tags: [finding, foreign-gate, read-track]

Context: the SREQ specifies the supersede foreign-issue refusal as "same gate and wording as
_po_foreign_gate", which reads like an invitation to reuse the function.

Pattern: it cannot be reused verbatim. _po_foreign_gate extracts the issue with
(.id | split("-")[1]), which is right for a PO-{issue}-{ordinal} id and wrong for a finding
ref
: F-PO-45-3-2 splits to ["F","PO","45","3","2"], putting the issue at index 2. A read-track
implementer that calls the existing helper on a supersede ref would compare PO against 45 and
refuse every finding-level supersede as foreign.

Why it matters: it is a silent-looking bug with a loud symptom (EXIT_ENV on a valid record),
and it lands in the fail-closed path where the refusal looks legitimate. Recorded as an explicit
paragraph in phase-outcome-supersede.v1.md under Read-side gating, together with the numeric
comparison rule the existing gate carries for the --issue 070 case, so the later WU has it at the
point of use.

2026-08-31T11:05 DISCOVERY: the phase_outcome.post signature in the schema was already stale

type: discovery
timestamp: 2026-08-31T11:05
where: plugin/skills/_shared/schemas/phase-outcome.v1.md:177
claude_md_gap: false
tags: [finding, documentation-drift]

Context: adding roll_call meant touching the Producer helper section, which states
phase_outcome.post(issue, skill, next_state, produced, findings, pending_decisions).

Pattern: that signature already omitted suite — a field the helper stamps itself — and gave no
hint that the argument list is not the body. It is a small drift, but it is the line an implementer
reads to know what to pass. Extended it with roll_call and an explicit note that the schema marks
the field optional while the post helper requires it, since that asymmetry is the whole enforcement
design and a bare signature hides it.

Why it matters: a finding, not a learning — the signature line in a schema doc is not covered
by any gate, so it drifts silently every time the helper grows an argument. Worth a mechanical check
one day; out of scope here.

2026-08-31T11:30 COMPLETE

type: complete
variant: development
timestamp: 2026-08-31T11:30
iterations: 4

Hardest part: deciding how much of the SREQ's rationale to carry into the schema versus leave on
the issue — the read-side gating table and the by-is-a-claim paragraph are only useful if a reader
who never sees the SREQ still gets the reason, so both were written out in full rather than
referenced.

If I did this again: I would check the prefilter's actual argument (${kind}:v1, not $kind)
before accepting the framing of the nesting question — three minutes of running the predicate
replaced an assumption that would have shipped a wrong worked example into the README.

<!-- dev-journal:v1 wu=WU-45-3-1 skill=develop --> # Dev journal — WU-45-3-1 (supersede record schema, `roll_call`, contract sections) ## 2026-08-31T10:20 DISCOVERY: the `phase-outcome:v1` prefilter cannot see a supersede record at all ```yaml type: discovery timestamp: 2026-08-31T10:20 where: plugin/skills/_shared/procedures/bin/_lib.sh:225 tags: [anchored-header, typed-scan, prefilter, nesting] ``` **Context:** the WU brief asked whether `_typed_scan`'s outcome 4 ("an anchored header of a DIFFERENT kind → excluded silently") covers `phase-outcome-supersede:v1` being returned by a scan for `phase-outcome:v1`, since the new token nests with an existing *Phase Outcome* kind. **Pattern:** it never gets that far. `_typed_scan` appends the version itself (`_prim scan_comments --issue "$issue" --header "${kind}:v1"`, `_lib.sh:225`), so the adapter's substring test is against `phase-outcome:v1` — **not** the bare token `phase-outcome`. A supersede header reads `<!-- phase-outcome-supersede:v1 ref=… by=… -->`, which interposes `-supersede` between `phase-outcome` and `:v1`, so `contains($p)` is false on every adapter (`tea-cli/bin/scan_comments.sh:63`, `glab-cli/bin/scan_comments.sh:33`, `local-fs/bin/scan_comments.sh:31` — all the same case-exact substring test). Verified by running the three predicates rather than reading them. The reverse direction is impossible for the same reason: the longer token is not a substring of the shorter header. Outcome 4 is nevertheless the correct **backstop**: the supersede header does match `_typed_scan`'s `$anykind` pattern (`_lib.sh:232`) and not the `phase-outcome:v1` pattern, so if the prefilter were ever widened to the bare token — the single-scan optimization the SREQ records as deliberately declined — the record would be excluded silently, never mis-warned as a near-miss. **Why it matters:** the brief's premise ("the token contains `phase-outcome`, so the prefilter can see it") is true of the *bare* token only, and the prefilter does not use the bare token. Both layers are now written into `schemas/README.md` as a worked example, in that order, so the next reader gets the real mechanism rather than the plausible one. ## 2026-08-31T10:45 DISCOVERY: `_po_foreign_gate`'s segment index does not transfer to a finding ref ```yaml type: discovery timestamp: 2026-08-31T10:45 where: plugin/skills/_shared/procedures/bin/_lib.sh:323 tags: [finding, foreign-gate, read-track] ``` **Context:** the SREQ specifies the supersede foreign-issue refusal as "same gate and wording as `_po_foreign_gate`", which reads like an invitation to reuse the function. **Pattern:** it cannot be reused verbatim. `_po_foreign_gate` extracts the issue with `(.id | split("-")[1])`, which is right for a `PO-{issue}-{ordinal}` id and **wrong for a finding ref**: `F-PO-45-3-2` splits to `["F","PO","45","3","2"]`, putting the issue at index 2. A read-track implementer that calls the existing helper on a supersede `ref` would compare `PO` against `45` and refuse every finding-level supersede as foreign. **Why it matters:** it is a silent-looking bug with a loud symptom (`EXIT_ENV` on a valid record), and it lands in the fail-closed path where the refusal looks legitimate. Recorded as an explicit paragraph in `phase-outcome-supersede.v1.md` under **Read-side gating**, together with the numeric comparison rule the existing gate carries for the `--issue 070` case, so the later WU has it at the point of use. ## 2026-08-31T11:05 DISCOVERY: the `phase_outcome.post` signature in the schema was already stale ```yaml type: discovery timestamp: 2026-08-31T11:05 where: plugin/skills/_shared/schemas/phase-outcome.v1.md:177 claude_md_gap: false tags: [finding, documentation-drift] ``` **Context:** adding `roll_call` meant touching the **Producer helper** section, which states `phase_outcome.post(issue, skill, next_state, produced, findings, pending_decisions)`. **Pattern:** that signature already omitted `suite` — a field the helper stamps itself — and gave no hint that the argument list is not the body. It is a small drift, but it is the line an implementer reads to know what to pass. Extended it with `roll_call` and an explicit note that the schema marks the field optional while the post helper requires it, since that asymmetry is the whole enforcement design and a bare signature hides it. **Why it matters:** a *finding*, not a learning — the signature line in a schema doc is not covered by any gate, so it drifts silently every time the helper grows an argument. Worth a mechanical check one day; out of scope here. ## 2026-08-31T11:30 COMPLETE ```yaml type: complete variant: development timestamp: 2026-08-31T11:30 iterations: 4 ``` **Hardest part:** deciding how much of the SREQ's rationale to carry into the schema versus leave on the issue — the read-side gating table and the `by`-is-a-claim paragraph are only useful if a reader who never sees the SREQ still gets the *reason*, so both were written out in full rather than referenced. **If I did this again:** I would check the prefilter's actual argument (`${kind}:v1`, not `$kind`) before accepting the framing of the nesting question — three minutes of running the predicate replaced an assumption that would have shipped a wrong worked example into the README.
Author
Owner
{
  "outcome": "complete",
  "summary": "Schema and contract foundation: new phase-outcome-supersede:v1 schema doc, optional roll_call plus both projected read shapes in phase-outcome.v1.md, README table + worked nesting example, forge-contract §2.1/§7/§8/§9. Artifact-shape WU — Test Writer skipped per /dev:develop 3.2 (ACs are schema rows, not behaviour); verified by lint-conventions, test-lint-conventions, audit-doc-structure and all nine pre-existing suites, all rc=0.",
  "commit_refs": [
    "4205f02"
  ],
  "journal_ref": "2521",
  "dependencies_satisfied": [],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-45-3-1 skill=develop --> ```json { "outcome": "complete", "summary": "Schema and contract foundation: new phase-outcome-supersede:v1 schema doc, optional roll_call plus both projected read shapes in phase-outcome.v1.md, README table + worked nesting example, forge-contract §2.1/§7/§8/§9. Artifact-shape WU — Test Writer skipped per /dev:develop 3.2 (ACs are schema rows, not behaviour); verified by lint-conventions, test-lint-conventions, audit-doc-structure and all nine pre-existing suites, all rc=0.", "commit_refs": [ "4205f02" ], "journal_ref": "2521", "dependencies_satisfied": [], "dependencies_violated": [] } ```
Author
Owner

Dev journal — WU-45-3-2 (roll-call track, issue #45)

2026-08-31T08:20 DISCOVERY: read_all had to be touched for roll_call to round-trip

type: discovery
timestamp: 2026-08-31T08:20
where: plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh
tags: [file-boundary, projection, roll-call]

Context: WU-45-3-2's file boundary excludes phase-outcome-read-all.sh (the read track owns it),
but test/roll-call.sh asserts three AC-13 round-trips and one AC-14 read-side null through
read_all. read_all builds its output with an explicit map({...}) projection, so a body key it
does not name simply does not exist in its output — roll_call was invisible.

Pattern: added exactly one additive projection line, roll_call: (.body.roll_call // null),
immediately beside the existing suite: (.body.suite // null). Nothing else in the file changed.

Why it matters: the boundary as written is unsatisfiable — no change to post.sh alone can make
AC-13's round-trip assertions pass. The read track's later work rewrites this same map({...}) for
annulment and must preserve the line. Reported to the lead.

2026-08-31T08:35 DISCOVERY: an explicit projection makes every additive body field a two-file change

type: discovery
timestamp: 2026-08-31T08:35
where: plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh
tags: [schema, additive-fields, product-defect]

Context: phase-outcome.v1.md §Versioning says optional top-level body fields are additive and
"every reader projects the keys it knows". In practice read_all's allow-list projection means a new
optional field is silently dropped by every consumer until someone remembers to add a line here.

Why it matters: the schema's promise reads as "add the field and readers cope"; the code's shape
is "add the field and readers ignore it until edited". suite (#34) and roll_call (#45) have now
each paid this tax. Worth a note in the schema, or a projection that merges unknown body keys through.

2026-08-31T09:05 DISCOVERY: procedures/phase-outcome.md now documents a call that the helper refuses

type: discovery
timestamp: 2026-08-31T09:05
where: plugin/skills/_shared/procedures/phase-outcome.md:18
claude_md_gap: false
tags: [out-of-boundary, stale-doc, product-defect]

Context: the operations table there gives the post helper's usage string:
phase-outcome-post.sh --issue N --skill NAME --next-state STATE [--produced @F] [--findings @F] [--pending @F]. As of this unit that command exits 2 — --roll-call is required.

Why it matters: it is the canonical operations reference every producer skill is pointed at, so
a lead that copies it gets a refusal. The file is outside WU-45-3-2's boundary (a later doc work unit
owns it), so it was deliberately left alone rather than edited. It must be updated before the
feature lands.
Reported to the lead.

2026-08-31T09:10 DISCOVERY: three flag-day suite call sites are guarded by 2>&1 || true

type: discovery
timestamp: 2026-08-31T09:10
where: plugin/skills/supervise/test/fixtures/bin/claude:133
tags: [test-quality, silent-failure]

Context: the supervise harness's claude stub posts a real Phase Outcome with
--skill supervise-stub — a producer name that is not in _VALID_PRODUCERS, so the call already
failed before this unit and will keep failing. It is invisible because the invocation ends
>/dev/null 2>&1 || true.

Why it matters: it is a fixture that has quietly not been doing its job, and the || true
guarantees no flag day will ever reveal it. --roll-call '[]' was added anyway (so the call is
correct the day the producer name is fixed), but the real defect is the swallowed status. Same
principle as the baseline's rc-from-the-command rule, applied to a fixture.

2026-08-31T09:20 COMPLETE

type: complete
variant: development
timestamp: 2026-08-31T09:20
iterations: 9

Hardest part: deciding what each producer's roll-call should actually say. The mechanical half
(a flag, a validator, 20 call sites) took one pass; writing guidance that is true per phase —
/dev:develop enumerating Test Writer/Implementer pairs by their wu-status.md names, a QA round
enumerating per-domain-per-stage agents, /dev:integrate and /dev:promote claiming [] because
they genuinely spawn nothing — is where the unit's value is, and none of it is find-and-replace.

If I did this again: flip the requirement on first, in a throwaway commit, purely to enumerate
the call sites the suites actually exercise, then revert and follow the ordered sequence. The SREQ's
call-site inventory was accurate for skill text but the suite counts drifted (7 in typed-scan.sh,
not 10), and a one-minute red run would have settled that before the grep did.

2026-08-31T09:40 DISCOVERY: test-run-resource-claims.sh CR-70 is red on this repo, on main too

type: discovery
timestamp: 2026-08-31T09:40
where: scripts/test-run-resource-claims.sh:95
tags: [harness, pre-existing, product-defect, out-of-boundary]

Context: scenario_cr70_cr57_comment_misstates_journal_spelling reads
REAL_JOURNAL="$REPO_ROOT/.devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md" and
returns 1 when the file is missing. That path holds only SREQ.md and test-plan.md — in this
worktree and in the main checkout. .devwork/ is gitignored staging, so the journal the
scenario measures was never committed and is now gone.

Why it matters: test-run-resource-claims.sh is one of CLAUDE.md's four required verification
commands, and it is currently red at 1 FAIL for everyone, independent of any feature branch. Verified
unrelated to this unit: git diff 4e2ca40..HEAD --name-only touches neither scripts/ nor that
folder. A repo-level harness whose input lives only in gitignored staging cannot stay green; either
commit the journal (or a fixture copy of it) or make the scenario report undetermined the way
scenario_cr54_root_guard already does, rather than failing.

<!-- dev-journal:v1 wu=WU-45-3-2 skill=develop --> # Dev journal — WU-45-3-2 (roll-call track, issue #45) ## 2026-08-31T08:20 DISCOVERY: read_all had to be touched for roll_call to round-trip ```yaml type: discovery timestamp: 2026-08-31T08:20 where: plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh tags: [file-boundary, projection, roll-call] ``` **Context:** WU-45-3-2's file boundary excludes `phase-outcome-read-all.sh` (the read track owns it), but `test/roll-call.sh` asserts three AC-13 round-trips and one AC-14 read-side null through `read_all`. `read_all` builds its output with an explicit `map({...})` projection, so a body key it does not name simply does not exist in its output — `roll_call` was invisible. **Pattern:** added exactly one additive projection line, `roll_call: (.body.roll_call // null)`, immediately beside the existing `suite: (.body.suite // null)`. Nothing else in the file changed. **Why it matters:** the boundary as written is unsatisfiable — no change to `post.sh` alone can make AC-13's round-trip assertions pass. The read track's later work rewrites this same `map({...})` for annulment and must preserve the line. Reported to the lead. ## 2026-08-31T08:35 DISCOVERY: an explicit projection makes every additive body field a two-file change ```yaml type: discovery timestamp: 2026-08-31T08:35 where: plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh tags: [schema, additive-fields, product-defect] ``` **Context:** `phase-outcome.v1.md` §Versioning says optional top-level body fields are additive and "every reader projects the keys it knows". In practice `read_all`'s allow-list projection means a new optional field is silently dropped by every consumer until someone remembers to add a line here. **Why it matters:** the schema's promise reads as "add the field and readers cope"; the code's shape is "add the field and readers ignore it until edited". `suite` (#34) and `roll_call` (#45) have now each paid this tax. Worth a note in the schema, or a projection that merges unknown body keys through. ## 2026-08-31T09:05 DISCOVERY: `procedures/phase-outcome.md` now documents a call that the helper refuses ```yaml type: discovery timestamp: 2026-08-31T09:05 where: plugin/skills/_shared/procedures/phase-outcome.md:18 claude_md_gap: false tags: [out-of-boundary, stale-doc, product-defect] ``` **Context:** the operations table there gives the post helper's usage string: `phase-outcome-post.sh --issue N --skill NAME --next-state STATE [--produced @F] [--findings @F] [--pending @F]`. As of this unit that command exits 2 — `--roll-call` is required. **Why it matters:** it is the canonical operations reference every producer skill is pointed at, so a lead that copies it gets a refusal. The file is outside WU-45-3-2's boundary (a later doc work unit owns it), so it was deliberately left alone rather than edited. **It must be updated before the feature lands.** Reported to the lead. ## 2026-08-31T09:10 DISCOVERY: three flag-day suite call sites are guarded by `2>&1 || true` ```yaml type: discovery timestamp: 2026-08-31T09:10 where: plugin/skills/supervise/test/fixtures/bin/claude:133 tags: [test-quality, silent-failure] ``` **Context:** the supervise harness's `claude` stub posts a real Phase Outcome with `--skill supervise-stub` — a producer name that is **not** in `_VALID_PRODUCERS`, so the call already failed before this unit and will keep failing. It is invisible because the invocation ends `>/dev/null 2>&1 || true`. **Why it matters:** it is a fixture that has quietly not been doing its job, and the `|| true` guarantees no flag day will ever reveal it. `--roll-call '[]'` was added anyway (so the call is correct the day the producer name is fixed), but the real defect is the swallowed status. Same principle as the baseline's rc-from-the-command rule, applied to a fixture. ## 2026-08-31T09:20 COMPLETE ```yaml type: complete variant: development timestamp: 2026-08-31T09:20 iterations: 9 ``` **Hardest part:** deciding what each producer's roll-call should actually *say*. The mechanical half (a flag, a validator, 20 call sites) took one pass; writing guidance that is true per phase — `/dev:develop` enumerating Test Writer/Implementer pairs by their `wu-status.md` names, a QA round enumerating per-domain-per-stage agents, `/dev:integrate` and `/dev:promote` claiming `[]` because they genuinely spawn nothing — is where the unit's value is, and none of it is find-and-replace. **If I did this again:** flip the requirement on *first*, in a throwaway commit, purely to enumerate the call sites the suites actually exercise, then revert and follow the ordered sequence. The SREQ's call-site inventory was accurate for skill text but the suite counts drifted (7 in `typed-scan.sh`, not 10), and a one-minute red run would have settled that before the grep did. ## 2026-08-31T09:40 DISCOVERY: `test-run-resource-claims.sh` CR-70 is red on this repo, on main too ```yaml type: discovery timestamp: 2026-08-31T09:40 where: scripts/test-run-resource-claims.sh:95 tags: [harness, pre-existing, product-defect, out-of-boundary] ``` **Context:** `scenario_cr70_cr57_comment_misstates_journal_spelling` reads `REAL_JOURNAL="$REPO_ROOT/.devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md"` and returns 1 when the file is missing. That path holds only `SREQ.md` and `test-plan.md` — in this worktree **and in the main checkout**. `.devwork/` is gitignored staging, so the journal the scenario measures was never committed and is now gone. **Why it matters:** `test-run-resource-claims.sh` is one of CLAUDE.md's four required verification commands, and it is currently red at 1 FAIL for everyone, independent of any feature branch. Verified unrelated to this unit: `git diff 4e2ca40..HEAD --name-only` touches neither `scripts/` nor that folder. A repo-level harness whose input lives only in gitignored staging cannot stay green; either commit the journal (or a fixture copy of it) or make the scenario report *undetermined* the way `scenario_cr54_root_guard` already does, rather than failing.
Author
Owner
{
  "outcome": "complete",
  "summary": "Roll-call track: --roll-call at the post seam (sentinel-for-absence + _json_arg for the value, per-entry validation, agent-sorted in the retry-guard normalization), a shared test fixture helper, 19 suite call sites and 10 producer call sites updated, wait-discipline.md §1c as the normative how-to-build-one, qa-playbook.md §8 per-domain roll-call, and develop/SKILL.md 3.1 $PO_ORDINAL corrected to highest-ordinal+1. Ordered flag-day (accept -> populate -> require) verified green at each step. roll-call.sh 38/38; nine helper suites, supervisor-flow, lint-conventions, test-lint-conventions, audit-doc-structure and test-plugin-gates (17/17) all rc=0. One line was added to phase-outcome-read-all.sh outside the declared boundary — see dependencies note and the terminal PO finding.",
  "commit_refs": [
    "740e823",
    "ed051ef",
    "4e2ca40"
  ],
  "journal_ref": "2557",
  "dependencies_satisfied": [
    "WU-45-3-1"
  ],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-45-3-2 skill=develop --> ```json { "outcome": "complete", "summary": "Roll-call track: --roll-call at the post seam (sentinel-for-absence + _json_arg for the value, per-entry validation, agent-sorted in the retry-guard normalization), a shared test fixture helper, 19 suite call sites and 10 producer call sites updated, wait-discipline.md §1c as the normative how-to-build-one, qa-playbook.md §8 per-domain roll-call, and develop/SKILL.md 3.1 $PO_ORDINAL corrected to highest-ordinal+1. Ordered flag-day (accept -> populate -> require) verified green at each step. roll-call.sh 38/38; nine helper suites, supervisor-flow, lint-conventions, test-lint-conventions, audit-doc-structure and test-plugin-gates (17/17) all rc=0. One line was added to phase-outcome-read-all.sh outside the declared boundary — see dependencies note and the terminal PO finding.", "commit_refs": [ "740e823", "ed051ef", "4e2ca40" ], "journal_ref": "2557", "dependencies_satisfied": [ "WU-45-3-1" ], "dependencies_violated": [] } ```
Author
Owner

Development Journal — WU-45-3-3 (read track: _po_annulment + the two read shapes)

2026-08-31T15:05:00Z DISCOVERY: set -e is OFF inside the command substitution of an assignment — so every helper reached as var="$(func …)" must own each internal rc

type: discovery
timestamp: 2026-08-31T15:05:00Z
where: plugin/skills/_shared/procedures/bin/_lib.sh
tags: [portability-baseline, errexit, rc-ownership, jq]

Context: _po_annulment's dangling-target check had a real jq bug ([$targets[]] | index(.ref)
re-binds . to the array, so .ref indexes an array and jq aborts at runtime with exit 5). I
expected set -euo pipefail to turn that into a loud death. It did not: the read completed, exited
0, and reported no annulments.

Pattern: measured on bash 5.3.9 —

set -euo pipefail
probe() { case "$-" in *e*) echo "errexit ON";; *) echo "errexit OFF";; esac; }
probe            # errexit ON
x="$(probe)"     # errexit OFF   <-- inside the substitution
y="$(false; echo continued)"; echo "y=[$y]"   # y=[continued], script survives

errexit is not in effect inside the command substitution on the right-hand side of an assignment.
Every consumer-facing entry point in this tier is reached exactly that way — records="$(_po_parse …)", scan="$(_typed_scan …)", and now annul="$(_po_annulment …)" — so a mid-function failure in
any of them is silent unless the function checks it explicitly. Only the final status of the
substitution propagates, which is why _die (an exit) still works while a bare failing assignment
does not.

Why it matters: this is the fail-closed rule's blind spot, and it is the exact shape the SREQ
warns about. A jq that dies mid-program hands back an empty string, which _po_annulment would
have returned as {} — read downstream as "nothing is annulled", silently resurrecting records an
operator deliberately annulled. The existing tier already survives this only because _po_parse and
_typed_scan happen to write || _die / || return on every internal step; nothing states why
that is load-bearing rather than stylistic. Every jq in _po_annulment now carries its own
|| _die "$EXIT_ENV" …, with a comment saying this is the reason. This doubles as the
own-your-rc rule: jq's 1–5 exit range overlaps EXIT_COLLISION(3)/EXIT_IMMUTABLE(4)/
EXIT_ADAPTER(5), so a leaked jq 5 would have been read as a broken adapter install.

Related, outside this WU's boundary (not changed): _po_foreign_gate's
foreign="$(jq -r … <<<"$parsed")" has no || _die. It is called without a substitution, so
errexit does apply there — but that means a jq failure kills the caller with jq's exit 5, which
collides with EXIT_ADAPTER. Same class as the #47 _gl_api | jq '.[0]' misroute. Low risk today
(the program is total over _po_parse-validated input) but worth a one-line || _die "$EXIT_ENV"
when someone owns that function.

2026-08-31T15:20:00Z DISCOVERY: supersede-read.sh's Gate row 5 fixture could not produce the state it asserted

type: discovery
timestamp: 2026-08-31T15:20:00Z
where: plugin/skills/_shared/procedures/test/supersede-read.sh
tags: [test-fixture, immutability, mtime, flaky]

Context: 51 of 54 assertions went green; the three Gate-5 reds (edited supersede →
EXIT_IMMUTABLE) would not move.

Pattern: the fixture planted the supersede at po16_ms + 1000 — one second in the future of
the wall clock — then appended to it ~120 ms later. plant pins mtime to the filename's ms and
local-fs derives created_ms from the filename and edited_ms from mtime, so the append produced a
negative delta: measured {"created_ms":1788189770473,"edited_ms":1788189769592,"delta":-881}.
The normative signal is strictly greater-than in three places
(phase-outcome-supersede.v1.md:93 and :112, local-fs/bin/scan_comments.sh:5), so no correct
implementation could fire. smoke.sh:52-54 uses the same sleep 0.05; printf 'tamper' >> idiom
successfully because it tampers with a helper-posted comment, whose created_ms is the real
clock.

Why it matters: the assertion was not merely red, it was latently flaky — a box slow enough
to spend >1 s between the post and the append would have passed it for the wrong reason. I reported
the diagnosis and the one-line fix ($((po16_ms-1000))) to the Test Writer rather than editing
their file or weakening the check; they applied it and the suite is 54/54. The general form: when a
fixture pins a synthetic timestamp AND then relies on the real clock for a second event, the two
clocks must be ordered deliberately, not left to whichever the copied idiom assumed.

2026-08-31T15:30:00Z DISCOVERY: annulled_count is only useful as a ledger-wide total

type: discovery
timestamp: 2026-08-31T15:30:00Z
where: plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh
claude_md_gap: false
tags: [schema-ambiguity, read-shapes]

Context: phase-outcome.v1.md §Read shapes says "every surviving outcome carries an additive
annulled_count" and, separately, that it exists "so a consumer can know that filtered history
exists
without opting into it". It does not say what is counted, and no assertion in the suite pins
it.

Pattern: implemented as the number of items the default projection removed across the whole
ledger
— annulled outcomes plus annulled findings — identical on every surviving outcome.

Why it matters: the per-outcome reading (count only this outcome's annulled findings) defeats
the stated rationale. The dominant human touchpoint is read_latest, which returns a single
surviving outcome; if an entire outcome had been annulled out from under it, a per-outcome count
would read 0 and the annulment would be exactly as invisible as the schema says it must never be.
The ledger-wide total is the only reading under which read_latest alone can reveal that filtering
happened. Flagged to the lead as an interpretation, since the schema is another unit's file.

2026-08-31T15:34:00Z COMPLETE

type: complete
variant: development
timestamp: 2026-08-31T15:34:00Z
iterations: 4

Hardest part: diagnosing why a jq runtime error inside _po_annulment produced a clean exit 0
and an empty annulment map instead of a loud failure — the answer (errexit is suspended inside an
assignment's command substitution) is invisible at the call site and made a genuine bug look like a
correctly-empty result.
If I did this again: write || _die on every helper-internal var="$(jq …)" from the first
draft rather than trusting set -e, and check a red assertion's fixture against the clock it
depends on before assuming the implementation is at fault.

<!-- dev-journal:v1 wu=WU-45-3-3 skill=develop --> # Development Journal — WU-45-3-3 (read track: `_po_annulment` + the two read shapes) ## 2026-08-31T15:05:00Z DISCOVERY: `set -e` is OFF inside the command substitution of an assignment — so every helper reached as `var="$(func …)"` must own each internal rc ```yaml type: discovery timestamp: 2026-08-31T15:05:00Z where: plugin/skills/_shared/procedures/bin/_lib.sh tags: [portability-baseline, errexit, rc-ownership, jq] ``` **Context:** `_po_annulment`'s dangling-target check had a real jq bug (`[$targets[]] | index(.ref)` re-binds `.` to the array, so `.ref` indexes an array and jq aborts at runtime with exit 5). I expected `set -euo pipefail` to turn that into a loud death. It did not: the read completed, exited **0**, and reported *no annulments*. **Pattern:** measured on bash 5.3.9 — ``` set -euo pipefail probe() { case "$-" in *e*) echo "errexit ON";; *) echo "errexit OFF";; esac; } probe # errexit ON x="$(probe)" # errexit OFF <-- inside the substitution y="$(false; echo continued)"; echo "y=[$y]" # y=[continued], script survives ``` `errexit` is not in effect inside the command substitution on the right-hand side of an assignment. Every consumer-facing entry point in this tier is reached exactly that way — `records="$(_po_parse …)"`, `scan="$(_typed_scan …)"`, and now `annul="$(_po_annulment …)"` — so a mid-function failure in any of them is silent unless the function checks it explicitly. Only the *final* status of the substitution propagates, which is why `_die` (an `exit`) still works while a bare failing assignment does not. **Why it matters:** this is the fail-closed rule's blind spot, and it is the exact shape the SREQ warns about. A jq that dies mid-program hands back an **empty string**, which `_po_annulment` would have returned as `{}` — read downstream as *"nothing is annulled"*, silently resurrecting records an operator deliberately annulled. The existing tier already survives this only because `_po_parse` and `_typed_scan` happen to write `|| _die` / `|| return` on every internal step; nothing states *why* that is load-bearing rather than stylistic. Every jq in `_po_annulment` now carries its own `|| _die "$EXIT_ENV" …`, with a comment saying this is the reason. This doubles as the own-your-rc rule: jq's 1–5 exit range overlaps `EXIT_COLLISION`(3)/`EXIT_IMMUTABLE`(4)/ `EXIT_ADAPTER`(5), so a leaked jq 5 would have been read as a broken adapter install. **Related, outside this WU's boundary (not changed):** `_po_foreign_gate`'s `foreign="$(jq -r … <<<"$parsed")"` has no `|| _die`. It is called *without* a substitution, so errexit does apply there — but that means a jq failure kills the caller with **jq's** exit 5, which collides with `EXIT_ADAPTER`. Same class as the #47 `_gl_api | jq '.[0]'` misroute. Low risk today (the program is total over `_po_parse`-validated input) but worth a one-line `|| _die "$EXIT_ENV"` when someone owns that function. ## 2026-08-31T15:20:00Z DISCOVERY: `supersede-read.sh`'s Gate row 5 fixture could not produce the state it asserted ```yaml type: discovery timestamp: 2026-08-31T15:20:00Z where: plugin/skills/_shared/procedures/test/supersede-read.sh tags: [test-fixture, immutability, mtime, flaky] ``` **Context:** 51 of 54 assertions went green; the three Gate-5 reds (edited supersede → `EXIT_IMMUTABLE`) would not move. **Pattern:** the fixture planted the supersede at `po16_ms + 1000` — one second in the **future** of the wall clock — then appended to it ~120 ms later. `plant` pins mtime to the filename's ms and local-fs derives `created_ms` from the filename and `edited_ms` from mtime, so the append produced a **negative** delta: measured `{"created_ms":1788189770473,"edited_ms":1788189769592,"delta":-881}`. The normative signal is strictly greater-than in three places (`phase-outcome-supersede.v1.md:93` and `:112`, `local-fs/bin/scan_comments.sh:5`), so no correct implementation could fire. `smoke.sh:52-54` uses the same `sleep 0.05; printf 'tamper' >>` idiom successfully because it tampers with a **helper-posted** comment, whose `created_ms` is the real clock. **Why it matters:** the assertion was not merely red, it was **latently flaky** — a box slow enough to spend >1 s between the post and the append would have passed it for the wrong reason. I reported the diagnosis and the one-line fix (`$((po16_ms-1000))`) to the Test Writer rather than editing their file or weakening the check; they applied it and the suite is 54/54. The general form: when a fixture pins a synthetic timestamp AND then relies on the real clock for a second event, the two clocks must be ordered deliberately, not left to whichever the copied idiom assumed. ## 2026-08-31T15:30:00Z DISCOVERY: `annulled_count` is only useful as a ledger-wide total ```yaml type: discovery timestamp: 2026-08-31T15:30:00Z where: plugin/skills/_shared/procedures/bin/phase-outcome-read-all.sh claude_md_gap: false tags: [schema-ambiguity, read-shapes] ``` **Context:** `phase-outcome.v1.md` §Read shapes says "every surviving outcome carries an additive `annulled_count`" and, separately, that it exists "so a consumer can *know that filtered history exists* without opting into it". It does not say what is counted, and no assertion in the suite pins it. **Pattern:** implemented as the number of items the default projection removed **across the whole ledger** — annulled outcomes plus annulled findings — identical on every surviving outcome. **Why it matters:** the per-outcome reading (count only *this* outcome's annulled findings) defeats the stated rationale. The dominant human touchpoint is `read_latest`, which returns a **single** surviving outcome; if an entire outcome had been annulled out from under it, a per-outcome count would read `0` and the annulment would be exactly as invisible as the schema says it must never be. The ledger-wide total is the only reading under which `read_latest` alone can reveal that filtering happened. Flagged to the lead as an interpretation, since the schema is another unit's file. ## 2026-08-31T15:34:00Z COMPLETE ```yaml type: complete variant: development timestamp: 2026-08-31T15:34:00Z iterations: 4 ``` **Hardest part:** diagnosing why a jq runtime error inside `_po_annulment` produced a clean exit 0 and an empty annulment map instead of a loud failure — the answer (errexit is suspended inside an assignment's command substitution) is invisible at the call site and made a genuine bug look like a correctly-empty result. **If I did this again:** write `|| _die` on every helper-internal `var="$(jq …)"` from the first draft rather than trusting `set -e`, and check a red assertion's *fixture* against the clock it depends on before assuming the implementation is at fault.
Author
Owner
{
  "outcome": "complete",
  "summary": "Read track: _po_annulment in _lib.sh (exact-token header ref matching, form-aware numeric issue segment, latest-wins per target by (created_ms,comment_id), four-way fail-closed partition plus the EXIT_IMMUTABLE edit refusal), and phase-outcome-read-all.sh gaining the filtered default shape (annulled outcomes absent, annulled_findings[], annulled_count), --include-annulled history mode with superseded:{}|null, and the ordinal-density check moved onto the raw ledger. supersede-read.sh 54/54; ten other helper suites, supervisor-flow, lint-conventions, test-lint-conventions, audit-doc-structure and test-plugin-gates (17/17) all rc=0. read-latest.sh deliberately untouched — it inherits annulment by wrapping read_all. Verified empirically that post still mints from the raw ledger (annulled PO-2 still yields PO-3, default read sparse [1,3]).",
  "commit_refs": [
    "c7d7e15",
    "9c346ec",
    "678f4ce"
  ],
  "journal_ref": "2560",
  "dependencies_satisfied": [
    "WU-45-3-1",
    "WU-45-3-2"
  ],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-45-3-3 skill=develop --> ```json { "outcome": "complete", "summary": "Read track: _po_annulment in _lib.sh (exact-token header ref matching, form-aware numeric issue segment, latest-wins per target by (created_ms,comment_id), four-way fail-closed partition plus the EXIT_IMMUTABLE edit refusal), and phase-outcome-read-all.sh gaining the filtered default shape (annulled outcomes absent, annulled_findings[], annulled_count), --include-annulled history mode with superseded:{}|null, and the ordinal-density check moved onto the raw ledger. supersede-read.sh 54/54; ten other helper suites, supervisor-flow, lint-conventions, test-lint-conventions, audit-doc-structure and test-plugin-gates (17/17) all rc=0. read-latest.sh deliberately untouched — it inherits annulment by wrapping read_all. Verified empirically that post still mints from the raw ledger (annulled PO-2 still yields PO-3, default read sparse [1,3]).", "commit_refs": [ "c7d7e15", "9c346ec", "678f4ce" ], "journal_ref": "2560", "dependencies_satisfied": [ "WU-45-3-1", "WU-45-3-2" ], "dependencies_violated": [] } ```
Author
Owner

Development Journal — WU-45-3-4 (phase-outcome-supersede.sh, the write door)

[2026-08-31T16:17:37Z] DISCOVERY: resolve the target against --include-annulled, never the default read

type: discovery
timestamp: 2026-08-31T16:17:37Z
where: plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh
tags: [supersede, read-shapes, fail-open-risk]

Context: deciding which read door the write helper should use to answer "does this target exist?".
Pattern: phase-outcome-read-all.sh --issue N --include-annulled, then exact jq string equality of
--ref against .[].id (outcome targets) and .[].findings[].id (finding targets).
Why it matters: the two read modes have different output shapes, and the DEFAULT one deletes
annulled outcomes from the array entirely. Resolving against it would make an already-annulled
outcome absent, so the helper would answer EXIT_FINDING (6, "target cannot be resolved") for
exactly the two cases the schema requires it to ACCEPT — re-annulling after a withdrawn, and
recording a better reason for an annulment that already stands. The failure would have looked like a
correct refusal, which is the worst shape for a bug: the caller is told its target does not exist
when the truth is that it exists and is annulled. The history door is also what makes
already_annulled computable at all — it is .superseded != null on the resolved hit.

[2026-08-31T16:17:37Z] DISCOVERY: three harness bugs in supersede-flow.sh, all in the READER half of the test

type: discovery
timestamp: 2026-08-31T16:17:37Z
where: plugin/skills/_shared/procedures/test/supersede-flow.sh
tags: [test-harness, jq, assertion-integrity]

Context: the helper passed 76 of 89 assertions; the remaining 13 were traced before any
implementation change was considered.
Pattern: all 13 came from three defects in the suite's own readers, none from the helper:

  1. body_field() called jq -Rs without -r, so every body value came back JSON-quoted
    (["PO-701-1"] vs [PO-701-1]). It also silently broke the four "header/body identity" checks,
    which compare header_attr (raw) against body_field (quoted) — those could never be equal
    for any implementation.
  2. jq -r '.already_annulled // "MISSING"' maps a boolean false to "MISSING": jq's //
    treats false exactly like null. The two already_annulled=false assertions were therefore
    unsatisfiable against a JSON boolean.
  3. AC-9 posted TWO outcomes and annulled only the newer, then asserted read_latest is null
    but the older outcome is still in force, so read_latest correctly returns it. The null
    expectation was copy-pasted from AC-10, where the fixture genuinely has a single outcome.
    Why it matters: #2 is the one with teeth. The green-making "fix" was to emit the string
    "false" in the receipt, which would have passed all four already_annulled assertions and shipped
    a wrong wire format — a machine-read receipt field typed as a string instead of a bool — to satisfy a
    reader bug. The assertions were left red and reported to the Test Writer instead; they fixed all
    three (commit 11de472) and the suite went 89/89. Verification of the diagnosis was done on a
    throwaway copy of the suite carrying only those three edits, so the claim "the helper is complete"
    was proven before the harness was touched by anyone.
    Not in CLAUDE.md: the existing learning "when the claim is about the ABSENCE of output, assert on
    the raw capture, not a jq parse of it" has a sibling this run found: // is not an
    absence test, it is a falsiness test, so it silently swallows a legitimate false. Any assertion
    of the form jq -r '.flag // "MISSING"' over a boolean field is wrong for one of its two values.

[2026-08-31T16:17:37Z] DISCOVERY: a foreign --ref is a USAGE error, not an unresolvable target

type: discovery
timestamp: 2026-08-31T16:17:37Z
where: plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh
tags: [exit-codes, fail-closed, foreign-gate]

Context: --issue 45 --ref PO-46-2 is exercised by no test, so the exit code was mine to choose
between EXIT_USAGE (2) and EXIT_FINDING (6).
Pattern: refused at EXIT_USAGE, from the arguments alone, before the ledger is read at all.
Why it matters: the schema's own distinction is that EXIT_FINDING is for the state-dependent
case ("state-dependent, so it is not a usage error"), and a ref naming another issue is decidable
with no state whatsoever. The stakes make it worth getting right rather than leaving to the target
lookup: a foreign supersede record that actually lands is a hard EXIT_ENV refusal on EVERY
subsequent read of that issue (_po_annulment's foreign gate), so one mistyped ref would take the
issue's reads offline until an operator deleted the comment. Comparing numerically on both sides
(10#$seg) mirrors _po_foreign_gate, so --issue 070 and a ref segment of 70 agree.

[2026-08-31T16:17:37Z] DISCOVERY: _validate_producer's diagnostic is hardcoded to --skill

type: discovery
timestamp: 2026-08-31T16:17:37Z
where: plugin/skills/_shared/procedures/bin/_lib.sh:171
claude_md_gap: false
tags: [helper-tier, diagnostics, reuse]

Context: the briefing asked me to reuse _validate_producer for --by's producer case.
Pattern: _validate_producer both decides and diagnoses, and its diagnostic string names
'--skill $s' literally. Reusing it verbatim from this helper would tell an operator who mistyped
--by to fix their --skill flag. I kept the reuse for the decision — it runs in a subshell, so its
_die exits only that subshell — and emit a --by-accurate refusal myself. The producer enum stays
in exactly one place ($_VALID_PRODUCERS), which was the point of the reuse.
Why it matters: this will recur. _validate_producer is now called from two flags with different
names, and the next caller will be /dev:resolve --supersede (a later wave) with a third. If a third
site appears, the clean fix is a flag-name parameter on the validator rather than three subshell
wrappers — worth raising in the meta lane, not worth doing inside this WU's boundary.

[2026-08-31T16:17:37Z] COMPLETE

type: complete
variant: development
timestamp: 2026-08-31T16:17:37Z
iterations: 4

Hardest part: telling apart the two reasons an assertion can be red — my helper being wrong, and
the harness being unable to observe a correct helper — for thirteen failures at once, without
reaching for the change that would have turned four of them green by shipping a string where the
contract pins a boolean.
If I did this again: I would run the pinned receipt shape through the suite's own reader helpers
(body_field, the // "MISSING" idiom) on a hand-written fixture before writing the helper. Both
of the reader bugs are visible in thirty seconds that way, and reporting them at minute one rather
than minute forty would have let the Test Writer fix them fully in parallel with the implementation.

<!-- dev-journal:v1 wu=WU-45-3-4 skill=develop --> # Development Journal — WU-45-3-4 (phase-outcome-supersede.sh, the write door) ## [2026-08-31T16:17:37Z] DISCOVERY: resolve the target against --include-annulled, never the default read ```yaml type: discovery timestamp: 2026-08-31T16:17:37Z where: plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh tags: [supersede, read-shapes, fail-open-risk] ``` **Context:** deciding which read door the write helper should use to answer "does this target exist?". **Pattern:** `phase-outcome-read-all.sh --issue N --include-annulled`, then exact jq string equality of `--ref` against `.[].id` (outcome targets) and `.[].findings[].id` (finding targets). **Why it matters:** the two read modes have different output shapes, and the DEFAULT one deletes annulled outcomes from the array entirely. Resolving against it would make an already-annulled outcome *absent*, so the helper would answer `EXIT_FINDING` (6, "target cannot be resolved") for exactly the two cases the schema requires it to ACCEPT — re-annulling after a `withdrawn`, and recording a better reason for an annulment that already stands. The failure would have looked like a correct refusal, which is the worst shape for a bug: the caller is told its target does not exist when the truth is that it exists and is annulled. The history door is also what makes `already_annulled` computable at all — it is `.superseded != null` on the resolved hit. ## [2026-08-31T16:17:37Z] DISCOVERY: three harness bugs in supersede-flow.sh, all in the READER half of the test ```yaml type: discovery timestamp: 2026-08-31T16:17:37Z where: plugin/skills/_shared/procedures/test/supersede-flow.sh tags: [test-harness, jq, assertion-integrity] ``` **Context:** the helper passed 76 of 89 assertions; the remaining 13 were traced before any implementation change was considered. **Pattern:** all 13 came from three defects in the suite's own readers, none from the helper: 1. `body_field()` called `jq -Rs` without `-r`, so every body value came back JSON-quoted (`["PO-701-1"]` vs `[PO-701-1]`). It also silently broke the four "header/body identity" checks, which compare `header_attr` (raw) against `body_field` (quoted) — those could never be equal for any implementation. 2. `jq -r '.already_annulled // "MISSING"'` maps a boolean `false` to `"MISSING"`: jq's `//` treats `false` exactly like `null`. The two `already_annulled=false` assertions were therefore unsatisfiable against a JSON boolean. 3. AC-9 posted TWO outcomes and annulled only the newer, then asserted `read_latest` is `null` — but the older outcome is still in force, so `read_latest` correctly returns it. The `null` expectation was copy-pasted from AC-10, where the fixture genuinely has a single outcome. **Why it matters:** #2 is the one with teeth. The green-making "fix" was to emit the *string* `"false"` in the receipt, which would have passed all four `already_annulled` assertions and shipped a wrong wire format — a machine-read receipt field typed as a string instead of a bool — to satisfy a reader bug. The assertions were left red and reported to the Test Writer instead; they fixed all three (commit `11de472`) and the suite went 89/89. Verification of the diagnosis was done on a throwaway copy of the suite carrying only those three edits, so the claim "the helper is complete" was proven before the harness was touched by anyone. **Not in CLAUDE.md:** the existing learning "when the claim is about the ABSENCE of output, assert on the raw capture, not a `jq` parse of it" has a sibling this run found: `//` is not an absence test, it is a *falsiness* test, so it silently swallows a legitimate `false`. Any assertion of the form `jq -r '.flag // "MISSING"'` over a boolean field is wrong for one of its two values. ## [2026-08-31T16:17:37Z] DISCOVERY: a foreign --ref is a USAGE error, not an unresolvable target ```yaml type: discovery timestamp: 2026-08-31T16:17:37Z where: plugin/skills/_shared/procedures/bin/phase-outcome-supersede.sh tags: [exit-codes, fail-closed, foreign-gate] ``` **Context:** `--issue 45 --ref PO-46-2` is exercised by no test, so the exit code was mine to choose between `EXIT_USAGE` (2) and `EXIT_FINDING` (6). **Pattern:** refused at `EXIT_USAGE`, from the arguments alone, before the ledger is read at all. **Why it matters:** the schema's own distinction is that `EXIT_FINDING` is for the *state-dependent* case ("state-dependent, so it is not a usage error"), and a ref naming another issue is decidable with no state whatsoever. The stakes make it worth getting right rather than leaving to the target lookup: a foreign supersede record that actually lands is a hard `EXIT_ENV` refusal on EVERY subsequent read of that issue (`_po_annulment`'s foreign gate), so one mistyped ref would take the issue's reads offline until an operator deleted the comment. Comparing numerically on both sides (`10#$seg`) mirrors `_po_foreign_gate`, so `--issue 070` and a ref segment of `70` agree. ## [2026-08-31T16:17:37Z] DISCOVERY: _validate_producer's diagnostic is hardcoded to --skill ```yaml type: discovery timestamp: 2026-08-31T16:17:37Z where: plugin/skills/_shared/procedures/bin/_lib.sh:171 claude_md_gap: false tags: [helper-tier, diagnostics, reuse] ``` **Context:** the briefing asked me to reuse `_validate_producer` for `--by`'s producer case. **Pattern:** `_validate_producer` both *decides* and *diagnoses*, and its diagnostic string names `'--skill $s'` literally. Reusing it verbatim from this helper would tell an operator who mistyped `--by` to fix their `--skill` flag. I kept the reuse for the decision — it runs in a subshell, so its `_die` exits only that subshell — and emit a `--by`-accurate refusal myself. The producer enum stays in exactly one place (`$_VALID_PRODUCERS`), which was the point of the reuse. **Why it matters:** this will recur. `_validate_producer` is now called from two flags with different names, and the next caller will be `/dev:resolve --supersede` (a later wave) with a third. If a third site appears, the clean fix is a flag-name parameter on the validator rather than three subshell wrappers — worth raising in the meta lane, not worth doing inside this WU's boundary. ## [2026-08-31T16:17:37Z] COMPLETE ```yaml type: complete variant: development timestamp: 2026-08-31T16:17:37Z iterations: 4 ``` **Hardest part:** telling apart the two reasons an assertion can be red — my helper being wrong, and the harness being unable to observe a correct helper — for thirteen failures at once, without reaching for the change that would have turned four of them green by shipping a string where the contract pins a boolean. **If I did this again:** I would run the pinned receipt shape through the suite's own reader helpers (`body_field`, the `// "MISSING"` idiom) on a hand-written fixture *before* writing the helper. Both of the reader bugs are visible in thirty seconds that way, and reporting them at minute one rather than minute forty would have let the Test Writer fix them fully in parallel with the implementation.
Author
Owner
{
  "outcome": "complete",
  "summary": "Write track: phase-outcome-supersede.sh (209 lines, mode 100755) — the operator/skill write door. Resolves --ref through phase-outcome-read-all.sh --include-annulled with exact jq string equality against outcome ids and findings[].id, so an already-annulled target is ACCEPTED (already_annulled: true) instead of wrongly refused; mandatory non-empty --reason and valid --by (producer enum plus the reserved literal operator) with equal rigour on status=withdrawn; the post is the last statement in the file, so every refusal posts nothing structurally rather than by convention. Exit mapping 2/4/6/7 with no new code minted; a foreign --ref is refused at EXIT_USAGE before any read. supersede-flow.sh 89/89; eleven other suites, supervisor-flow, the three lint/doc gates and test-plugin-gates (17/17) all rc=0. Boundary clean: the commit adds exactly one file.",
  "commit_refs": [
    "40ddb95",
    "11de472",
    "f28d70f"
  ],
  "journal_ref": "2563",
  "dependencies_satisfied": [
    "WU-45-3-1",
    "WU-45-3-2",
    "WU-45-3-3"
  ],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-45-3-4 skill=develop --> ```json { "outcome": "complete", "summary": "Write track: phase-outcome-supersede.sh (209 lines, mode 100755) — the operator/skill write door. Resolves --ref through phase-outcome-read-all.sh --include-annulled with exact jq string equality against outcome ids and findings[].id, so an already-annulled target is ACCEPTED (already_annulled: true) instead of wrongly refused; mandatory non-empty --reason and valid --by (producer enum plus the reserved literal operator) with equal rigour on status=withdrawn; the post is the last statement in the file, so every refusal posts nothing structurally rather than by convention. Exit mapping 2/4/6/7 with no new code minted; a foreign --ref is refused at EXIT_USAGE before any read. supersede-flow.sh 89/89; eleven other suites, supervisor-flow, the three lint/doc gates and test-plugin-gates (17/17) all rc=0. Boundary clean: the commit adds exactly one file.", "commit_refs": [ "40ddb95", "11de472", "f28d70f" ], "journal_ref": "2563", "dependencies_satisfied": [ "WU-45-3-1", "WU-45-3-2", "WU-45-3-3" ], "dependencies_violated": [] } ```
Author
Owner

Development Journal — WU-45-3-5

Operator surface (/dev:resolve --supersede), procedure docs, and the two mechanical gates
(AC-3, AC-16) for feature 45-phase-outcome-supersede.

[2026-08-31T18:40] DISCOVERY: the AC-16 scan cannot be a plain grep — the suite writes the wrong formula down five times on purpose

type: discovery
timestamp: 2026-08-31T18:40
where: scripts/lint-conventions.sh
tags: [ac-16, mechanical-gate, false-positive]

What: a naive "flag len(...)+1 in the shipped tree" scan fails the AC-16 positive control on
today's clean tree. Five shipped sites carry the arithmetic as a counterexample:
schemas/README.md:48, procedures/phase-outcome.md:117, schemas/phase-outcome.v1.md:169,
develop/SKILL.md:91, local-fs/SKILL.md:41. Four of the five are files this work unit may not
touch, so an allow: anchor (the linter's usual escape hatch) was not available.

Why it matters: the rule's whole point is that the suite teaches the wrong formula in order to
forbid it. A gate that cannot tell a prohibition from a recipe would force the suite to stop writing
the prohibition down.

Resolution: three conjunct conditions instead of one pattern — the unit must (a) name the read
helper (phase-outcome-read-all / read_all), (b) carry the arithmetic, and (c) carry no
negation marker (not / never / wrong / unaffected / …). Every one of the five counterexamples
fails (a) or (c), most fail both. develop/SKILL.md:91 is the instructive one: it fails (a) because
the prohibition talks about "read output" in English and never names the helper, and it fails (c) on
"never" — but it sits two lines below a paragraph that does name the helper, which is why the
prose unit had to be the paragraph and not a fixed line window.

[2026-08-31T18:52] STRUGGLE: awk -v eats backslashes out of a regex passed as a variable

type: struggle
timestamp: 2026-08-31T18:52
where: scripts/lint-conventions.sh
attempts: 2
resolved: true
tags: [awk, portability]

Problem: the AC-16 arithmetic pattern was written (\([^)]*\))?…\+….
Actual: six awk: warning: escape sequence '\(' treated as plain '(' on every run, and the
pattern silently changed meaning (*+ where *\+ was intended). The scan still went green, which is
the dangerous part — a mis-parsed regex that happens to match is a false green waiting to happen.
Solution: bracket expressions — [(], [)], [+]. lint-conventions.sh already documents this
constraint on PRIM_RE ("No backslashes in the pattern: it is passed to awk via -v"); the note now
sits on these patterns too.
Key insight: awk warnings on stderr are part of a gate's output. A gate that prints warnings and
exits 0 has not been read.

[2026-08-31T19:05] DISCOVERY: the canonical producer reference taught a post call that exits 2

type: discovery
timestamp: 2026-08-31T19:05
where: plugin/skills/_shared/procedures/phase-outcome.md
tags: [blocking, roll-call, stale-doc]

What: the operations table's post(...) usage string carried no --roll-call. Since the
roll-call track flipped the flag to required, that exact command exits 2 and posts nothing. This file
is the canonical reference every producer skill is pointed at ("NEVER inline adapter mechanics — run
the script"), so the one document a stuck producer reads was handing it a failing call.

Fixed in this unit: the usage string now carries --roll-call '[]'|@F, with a paragraph under
the table stating that absence is refused and [] is the explicit "spawned none".

Why it survived the roll-call wave: that wave's scope was the ~12 producer call sites — real
invocations, enumerable by grep for the script name. A usage string in a reference table is not a
call site and does not run, so nothing failed when it went stale.

[2026-08-31T19:12] DISCOVERY: without the accept-only restriction, a decision on an annulled finding reports the ledger as CORRUPT

type: discovery
timestamp: 2026-08-31T19:12
where: plugin/skills/resolve/SKILL.md
tags: [annulment, resolve, step-7b]

What: Step 7b's first sub-step resolves decision.finding_ref against po.findings[] and, when
absent, FAILs with "which is not present in PO-… (the Phase Outcome is immutable, so a dangling ref
is a corruption signal, not a normal state)". An annulled finding is absent from findings[]
it has moved to annulled_findings[] — so resolving such a decision defer-to-issue would have
reported a perfectly healthy ledger as corrupt, and told the operator to go looking for tampering.

Why it does not bite: Step 5's accept-only restriction (this unit) refuses defer-to-issue
before Step 7b can run, and accept never triggers Step 7b at all. The path is closed. I still
recorded the distinction at Step 7b — a reader arriving there should not have to reconstruct why it
is unreachable, and the "corruption signal" wording is exactly the kind of confident diagnosis that
gets acted on.

[2026-08-31T19:18] DISCOVERY: a stale len(existing)+1 in the ordinal-collision row contradicted the raw-ledger rule

type: discovery
timestamp: 2026-08-31T19:18
where: plugin/skills/_shared/procedures/phase-outcome.md
tags: [ac-16, stale-doc]

What: the Failure-modes row for an ordinal collision explained a deleted PO as "deletion derives
n = len(existing)+1 to a value already in use". post does not derive that way — it mints highest
parsed ordinal + 1 — so the row named the wrong mechanism, in the same file where this unit was
adding "post always reads the RAW ledger … highest ordinal + 1". Two contradictory statements about
minting, one file. Rewrote the parenthetical to the real mechanism (deleting the highest-ordinal
record lowers the maximum, so the next post re-mints an ordinal whose derived Decision IDs already
exist); the row's conclusion and recovery were already right.

Out-of-boundary sibling, NOT fixed: plugin/skills/local-fs/SKILL.md:41 states "PO-ordinal
derivation (n = len(existing)+1) is a count, not an order, and is unaffected." Same wrong formula,
asserted as fact rather than as a counterexample, in an adapter doc this unit does not own. The
sentence's actual point (filename order does not affect ordinal derivation) survives the correction,
so it is a one-clause fix for whoever owns that file next.

[2026-08-31T19:22] DISCOVERY: _po_foreign_gate's unguarded jq would have been reported as a broken adapter install

type: discovery
timestamp: 2026-08-31T19:22
where: plugin/skills/_shared/procedures/bin/_lib.sh
tags: [exit-codes, misroute]

What (assigned by the lead, confirmed here): foreign="$(jq -r … <<<"$parsed")" had no || _die.
The function is called without a command-substitution wrapper at its call sites, so errexit does
apply — and a jq failure would kill the caller with jq's own exit 5, which is EXIT_ADAPTER in
this tier's shared vocabulary. A failed foreign-issue gate would have been diagnosed as a broken
adapter install. Same class as the _gl_api | jq '.[0]' misroute recorded on #47. One line added.

<!-- dev-journal:v1 wu=WU-45-3-5 skill=develop --> # Development Journal — WU-45-3-5 Operator surface (`/dev:resolve --supersede`), procedure docs, and the two mechanical gates (AC-3, AC-16) for feature 45-phase-outcome-supersede. ## [2026-08-31T18:40] DISCOVERY: the AC-16 scan cannot be a plain grep — the suite writes the wrong formula down five times on purpose ```yaml type: discovery timestamp: 2026-08-31T18:40 where: scripts/lint-conventions.sh tags: [ac-16, mechanical-gate, false-positive] ``` **What:** a naive "flag `len(...)+1` in the shipped tree" scan fails the AC-16 positive control on today's clean tree. Five shipped sites carry the arithmetic **as a counterexample**: `schemas/README.md:48`, `procedures/phase-outcome.md:117`, `schemas/phase-outcome.v1.md:169`, `develop/SKILL.md:91`, `local-fs/SKILL.md:41`. Four of the five are files this work unit may not touch, so an `allow:` anchor (the linter's usual escape hatch) was not available. **Why it matters:** the rule's whole point is that the suite *teaches* the wrong formula in order to forbid it. A gate that cannot tell a prohibition from a recipe would force the suite to stop writing the prohibition down. **Resolution:** three conjunct conditions instead of one pattern — the unit must (a) name the read helper (`phase-outcome-read-all` / `read_all`), (b) carry the arithmetic, and (c) carry **no** negation marker (`not` / `never` / `wrong` / `unaffected` / …). Every one of the five counterexamples fails (a) or (c), most fail both. `develop/SKILL.md:91` is the instructive one: it fails (a) because the prohibition talks about "read output" in English and never names the helper, and it fails (c) on "never" — but it sits **two lines below** a paragraph that *does* name the helper, which is why the prose unit had to be the paragraph and not a fixed line window. ## [2026-08-31T18:52] STRUGGLE: awk -v eats backslashes out of a regex passed as a variable ```yaml type: struggle timestamp: 2026-08-31T18:52 where: scripts/lint-conventions.sh attempts: 2 resolved: true tags: [awk, portability] ``` **Problem:** the AC-16 arithmetic pattern was written `(\([^)]*\))?…\+…`. **Actual:** six `awk: warning: escape sequence '\(' treated as plain '('` on every run, and the pattern silently changed meaning (`*+` where `*\+` was intended). The scan still went green, which is the dangerous part — a mis-parsed regex that happens to match is a false green waiting to happen. **Solution:** bracket expressions — `[(]`, `[)]`, `[+]`. `lint-conventions.sh` already documents this constraint on `PRIM_RE` ("No backslashes in the pattern: it is passed to awk via -v"); the note now sits on these patterns too. **Key insight:** awk warnings on stderr are part of a gate's output. A gate that prints warnings and exits 0 has not been read. ## [2026-08-31T19:05] DISCOVERY: the canonical producer reference taught a `post` call that exits 2 ```yaml type: discovery timestamp: 2026-08-31T19:05 where: plugin/skills/_shared/procedures/phase-outcome.md tags: [blocking, roll-call, stale-doc] ``` **What:** the operations table's `post(...)` usage string carried no `--roll-call`. Since the roll-call track flipped the flag to required, that exact command exits 2 and posts nothing. This file is the canonical reference every producer skill is pointed at ("NEVER inline adapter mechanics — run the script"), so the one document a stuck producer reads was handing it a failing call. **Fixed** in this unit: the usage string now carries `--roll-call '[]'|@F`, with a paragraph under the table stating that absence is refused and `[]` is the explicit "spawned none". **Why it survived the roll-call wave:** that wave's scope was the ~12 producer *call sites* — real invocations, enumerable by grep for the script name. A usage string in a reference table is not a call site and does not run, so nothing failed when it went stale. ## [2026-08-31T19:12] DISCOVERY: without the accept-only restriction, a decision on an annulled finding reports the ledger as CORRUPT ```yaml type: discovery timestamp: 2026-08-31T19:12 where: plugin/skills/resolve/SKILL.md tags: [annulment, resolve, step-7b] ``` **What:** Step 7b's first sub-step resolves `decision.finding_ref` against `po.findings[]` and, when absent, FAILs with "which is not present in PO-… (the Phase Outcome is immutable, so a dangling ref is a corruption signal, not a normal state)". An annulled finding **is** absent from `findings[]` — it has moved to `annulled_findings[]` — so resolving such a decision `defer-to-issue` would have reported a perfectly healthy ledger as corrupt, and told the operator to go looking for tampering. **Why it does not bite:** Step 5's `accept`-only restriction (this unit) refuses `defer-to-issue` before Step 7b can run, and `accept` never triggers Step 7b at all. The path is closed. I still recorded the distinction *at* Step 7b — a reader arriving there should not have to reconstruct why it is unreachable, and the "corruption signal" wording is exactly the kind of confident diagnosis that gets acted on. ## [2026-08-31T19:18] DISCOVERY: a stale `len(existing)+1` in the ordinal-collision row contradicted the raw-ledger rule ```yaml type: discovery timestamp: 2026-08-31T19:18 where: plugin/skills/_shared/procedures/phase-outcome.md tags: [ac-16, stale-doc] ``` **What:** the Failure-modes row for an ordinal collision explained a deleted PO as "deletion derives `n = len(existing)+1` to a value already in use". `post` does not derive that way — it mints highest parsed ordinal + 1 — so the row named the wrong mechanism, in the same file where this unit was adding "`post` always reads the RAW ledger … highest ordinal + 1". Two contradictory statements about minting, one file. Rewrote the parenthetical to the real mechanism (deleting the highest-ordinal record lowers the maximum, so the next post re-mints an ordinal whose derived Decision IDs already exist); the row's conclusion and recovery were already right. **Out-of-boundary sibling, NOT fixed:** `plugin/skills/local-fs/SKILL.md:41` states "PO-ordinal derivation (`n = len(existing)+1`) is a count, not an order, and is unaffected." Same wrong formula, asserted as fact rather than as a counterexample, in an adapter doc this unit does not own. The sentence's actual point (filename order does not affect ordinal derivation) survives the correction, so it is a one-clause fix for whoever owns that file next. ## [2026-08-31T19:22] DISCOVERY: `_po_foreign_gate`'s unguarded jq would have been reported as a broken adapter install ```yaml type: discovery timestamp: 2026-08-31T19:22 where: plugin/skills/_shared/procedures/bin/_lib.sh tags: [exit-codes, misroute] ``` **What (assigned by the lead, confirmed here):** `foreign="$(jq -r … <<<"$parsed")"` had no `|| _die`. The function is called *without* a command-substitution wrapper at its call sites, so errexit does apply — and a jq failure would kill the caller with **jq's own exit 5**, which is `EXIT_ADAPTER` in this tier's shared vocabulary. A failed foreign-issue gate would have been diagnosed as a broken adapter install. Same class as the `_gl_api | jq '.[0]'` misroute recorded on #47. One line added.
Author
Owner
{
  "outcome": "complete",
  "summary": "Operator surface, procedure docs and mechanical gates: /dev:resolve gains --supersede (structured mode, interactive Step I3.5/I4/I5, a full Supersede-mode section) plus the normative accept-only restriction on a decision whose finding_ref is annulled; phase-outcome.md gains the supersede op, both read shapes, the raw-ledger rule and — BLOCKING FIX — a post usage string that no longer omits --roll-call; decisions-settled-check.md records that the gate inherits the exclusion while a finding-level annulment does NOT unblock; phase-outcome.v1.md pins annulled_count as a ledger-wide total; _lib.sh gains one guarded jq in _po_foreign_gate. lint-conventions.sh gains the AC-3 one-honourer scan and the AC-16 no-ordinal-from-filtered-output scan, the latter fence-aware in both directions by reusing tier-1 extraction and judging prose by paragraph unit. test-lint-conventions 34/34; twelve helper suites, supervisor-flow, lint, audit-doc and test-plugin-gates (17/17) all rc=0.",
  "commit_refs": [
    "e22eaef",
    "bc7b033"
  ],
  "journal_ref": "2566",
  "dependencies_satisfied": [
    "WU-45-3-1",
    "WU-45-3-2",
    "WU-45-3-3",
    "WU-45-3-4"
  ],
  "dependencies_violated": []
}
<!-- work-unit-outcome:v1 id=WU-45-3-5 skill=develop --> ```json { "outcome": "complete", "summary": "Operator surface, procedure docs and mechanical gates: /dev:resolve gains --supersede (structured mode, interactive Step I3.5/I4/I5, a full Supersede-mode section) plus the normative accept-only restriction on a decision whose finding_ref is annulled; phase-outcome.md gains the supersede op, both read shapes, the raw-ledger rule and — BLOCKING FIX — a post usage string that no longer omits --roll-call; decisions-settled-check.md records that the gate inherits the exclusion while a finding-level annulment does NOT unblock; phase-outcome.v1.md pins annulled_count as a ledger-wide total; _lib.sh gains one guarded jq in _po_foreign_gate. lint-conventions.sh gains the AC-3 one-honourer scan and the AC-16 no-ordinal-from-filtered-output scan, the latter fence-aware in both directions by reusing tier-1 extraction and judging prose by paragraph unit. test-lint-conventions 34/34; twelve helper suites, supervisor-flow, lint, audit-doc and test-plugin-gates (17/17) all rc=0.", "commit_refs": [ "e22eaef", "bc7b033" ], "journal_ref": "2566", "dependencies_satisfied": [ "WU-45-3-1", "WU-45-3-2", "WU-45-3-3", "WU-45-3-4" ], "dependencies_violated": [] } ```
Author
Owner

{
"phase": "develop",
"learnings": [
{"id": "L-1", "scope": "project", "status": "applied",
"summary": "set -e is OFF inside the command substitution of an assignment, so a helper reached as var="$(func …)" must own every internal rc or a mid-function failure reads as a benign empty answer.",
"evidence": "DISCOVERY, WU-45-3-3 journal: a real jq bug in _po_annulment's dangling check made the read report NO annulments and exit 0, silently resurrecting deliberately-annulled records.",
"source_ref": "comment:2560", "applied_to": "CLAUDE.md"},
{"id": "L-2", "scope": "project", "status": "applied",
"summary": "An absence-shaped default hides a real signal: jq's // is a falsiness test (a genuine boolean false reads as MISSING) and .key | length is 0 for a key that does not exist, so the diagnostic points at the producer while the bug is in the reader.",
"evidence": "DISCOVERY, WU-45-3-4 journal: two already_annulled assertions unsatisfiable against a JSON boolean; plus the lead's own journal read, where a wrong key name rendered five populated journals as length 0.",
"source_ref": "comment:2563", "applied_to": "CLAUDE.md"},
{"id": "L-3", "scope": "project", "status": "applied",
"summary": "find here is bfs, not GNU findutils: it rejects GNU's relative -newermt '-N minutes' with a hard error, which a suppressed stderr turns into a silent empty result. Use -mmin -N.",
"evidence": "Lead watchdog on this run reported 'no worktree file written in 12 minutes' while files were actively being written; bfs 4.1.1 printed 'Invalid timestamp' into /dev/null.",
"source_ref": "comment:2560", "applied_to": "CLAUDE.md"},
{"id": "L-4", "scope": "project", "status": "applied",
"summary": "A fixture that pins a synthetic mtime and then relies on the real clock for a second event must order the two deliberately — a future-pinned created_ms inverts edited_ms > created_ms and the tamper-evidence gate silently never fires.",
"evidence": "DISCOVERY, WU-45-3-3 journal: supersede-read.sh Gate row 5 measured created_ms=1788189770473, edited_ms=1788189769592, delta=-881; no correct implementation could have tripped it.",
"source_ref": "comment:2560", "applied_to": "CLAUDE.md"},
{"id": "L-5", "scope": "project", "status": "applied",
"summary": "awk -v strips backslashes from a regex passed as a variable, warns on stderr and still exits 0 — use bracket expressions; and more generally, a gate that prints warnings and exits 0 has not been read.",
"evidence": "STRUGGLE, WU-45-3-5 journal: six 'escape sequence treated as plain' warnings silently turned *\+ into *+ while the AC-16 scan still exited 0.",
"source_ref": "comment:2566", "applied_to": "CLAUDE.md"}
]
}

<!-- learning:v1 issue=45 skill=develop po=PO-45-3 --> { "phase": "develop", "learnings": [ {"id": "L-1", "scope": "project", "status": "applied", "summary": "`set -e` is OFF inside the command substitution of an assignment, so a helper reached as var=\"$(func …)\" must own every internal rc or a mid-function failure reads as a benign empty answer.", "evidence": "DISCOVERY, WU-45-3-3 journal: a real jq bug in _po_annulment's dangling check made the read report NO annulments and exit 0, silently resurrecting deliberately-annulled records.", "source_ref": "comment:2560", "applied_to": "CLAUDE.md"}, {"id": "L-2", "scope": "project", "status": "applied", "summary": "An absence-shaped default hides a real signal: jq's `//` is a falsiness test (a genuine boolean false reads as MISSING) and `.key | length` is 0 for a key that does not exist, so the diagnostic points at the producer while the bug is in the reader.", "evidence": "DISCOVERY, WU-45-3-4 journal: two already_annulled assertions unsatisfiable against a JSON boolean; plus the lead's own journal read, where a wrong key name rendered five populated journals as length 0.", "source_ref": "comment:2563", "applied_to": "CLAUDE.md"}, {"id": "L-3", "scope": "project", "status": "applied", "summary": "`find` here is bfs, not GNU findutils: it rejects GNU's relative -newermt '-N minutes' with a hard error, which a suppressed stderr turns into a silent empty result. Use -mmin -N.", "evidence": "Lead watchdog on this run reported 'no worktree file written in 12 minutes' while files were actively being written; bfs 4.1.1 printed 'Invalid timestamp' into /dev/null.", "source_ref": "comment:2560", "applied_to": "CLAUDE.md"}, {"id": "L-4", "scope": "project", "status": "applied", "summary": "A fixture that pins a synthetic mtime and then relies on the real clock for a second event must order the two deliberately — a future-pinned created_ms inverts edited_ms > created_ms and the tamper-evidence gate silently never fires.", "evidence": "DISCOVERY, WU-45-3-3 journal: supersede-read.sh Gate row 5 measured created_ms=1788189770473, edited_ms=1788189769592, delta=-881; no correct implementation could have tripped it.", "source_ref": "comment:2560", "applied_to": "CLAUDE.md"}, {"id": "L-5", "scope": "project", "status": "applied", "summary": "`awk -v` strips backslashes from a regex passed as a variable, warns on stderr and still exits 0 — use bracket expressions; and more generally, a gate that prints warnings and exits 0 has not been read.", "evidence": "STRUGGLE, WU-45-3-5 journal: six 'escape sequence treated as plain' warnings silently turned *\\+ into *+ while the AC-16 scan still exited 0.", "source_ref": "comment:2566", "applied_to": "CLAUDE.md"} ] }
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "wu-plan",
      "ref": "2499",
      "summary": "5 work units in 5 waves, strictly sequential (single shared worktree)"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2522",
      "summary": "WU-45-3-1: supersede record schema, roll_call field, forge-contract §2.1/§7/§8/§9"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2559",
      "summary": "WU-45-3-2: --roll-call at the post seam, call sites, suite flag-day, $PO_ORDINAL fix"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2562",
      "summary": "WU-45-3-3: _po_annulment and the filtered/history read shapes"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2565",
      "summary": "WU-45-3-4: phase-outcome-supersede.sh, the write door"
    },
    {
      "kind": "work-unit-outcome",
      "ref": "2568",
      "summary": "WU-45-3-5: operator surface, procedure docs, mechanical gates"
    },
    {
      "kind": "dev-journal",
      "ref": "2521",
      "summary": "WU-45-3-1 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "2557",
      "summary": "WU-45-3-2 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "2560",
      "summary": "WU-45-3-3 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "2563",
      "summary": "WU-45-3-4 development journal"
    },
    {
      "kind": "dev-journal",
      "ref": "2566",
      "summary": "WU-45-3-5 development journal"
    },
    {
      "kind": "learning",
      "ref": "comment:2569",
      "summary": "5 learnings (5 applied to CLAUDE.md, 0 unhomed)"
    }
  ],
  "findings": [
    {
      "category": "pre-existing",
      "severity": "medium",
      "summary": "scripts/test-run-resource-claims.sh — one of CLAUDE.md's four declared verification commands — cannot pass on any clean checkout",
      "reasoning": "scenario_cr70_cr57_comment_misstates_journal_spelling reads REAL_JOURNAL=$REPO_ROOT/.devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md and returns 1 when absent. .devwork/ is gitignored, so that journal exists in neither this worktree nor the main checkout; only the tracked fixture scripts/fixtures/dispatch-journal.fixture.md survives, and the scenario deliberately bypasses it. MEASURED, not inferred: identical single failure at the pre-work base 549edaf in a throwaway worktree, and this feature's commits touch nothing under scripts/ until WU-45-3-5 (which added only the two new scans). It does not block release — release.sh's four gates are lint-conventions, audit-doc-structure, plugin validate and tag --dry-run, none of which is a harness — but QA will meet this red and must not read it as a regression. Fix is either to commit a fixture copy of the journal or to make the scenario report undetermined the way scenario_cr54_root_guard already does.",
      "proposed_action": "defer-to-issue",
      "target": "43",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "none",
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-45-3-1"
    },
    {
      "category": "out-of-scope",
      "severity": "medium",
      "summary": "read-all.sh's allow-list projection silently drops every 'additive' optional body field until that map is edited, contradicting the schema's own versioning promise",
      "reasoning": "phase-outcome.v1.md §Versioning states optional top-level fields are additive and 'every reader projects the keys it knows'. In practice phase-outcome-read-all.sh builds output with an explicit map({...}), so a body key nobody names is invisible to every consumer. suite paid this tax on #34 and roll_call paid it again here — WU-45-3-2 had to reach outside its declared file boundary for a one-line projection add, which is how it was found. Either merge unknown body keys through, or state the two-file tax in the schema so the next additive field budgets for it.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-45-3-2"
    },
    {
      "category": "pre-existing",
      "severity": "low",
      "summary": "_validate_producer's diagnostic hardcodes '--skill' but the validator now serves --by as well",
      "reasoning": "_lib.sh:171 tells the caller to fix their '--skill' flag. phase-outcome-supersede.sh validates --by against the same enum, so a naive reuse would misdirect an operator to a flag its command does not have. LATENT ONLY, not live: WU-45-3-4 kept the reuse for the decision inside a subshell (so its _die exits only the subshell) and emits a --by-accurate refusal itself, keeping $_VALID_PRODUCERS the single enum authority. The lead considered and DECLINED a flag-name parameter this round: /dev:resolve --supersede invokes the helper rather than the validator, so no third caller materialises, and refactoring two verified helpers on the final wave to remove a non-user-visible duplication is scope this feature should not take. If a third differently-named flag ever appears, parameterise it rather than adding a third subshell wrapper.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-45-3-3"
    },
    {
      "category": "pre-existing",
      "severity": "low",
      "summary": "plugin/skills/local-fs/SKILL.md:41 states the wrong ordinal formula (n = len(existing)+1) as fact",
      "reasoning": "post mints highest-parsed-ordinal + 1, never a count — the correction this feature made in develop/SKILL.md, phase-outcome.md and phase-outcome.v1.md. The local-fs adapter doc still asserts the count form as fact rather than as a counterexample. Its actual point (filename order does not affect ordinal derivation) survives the correction, so this is a one-clause fix. Deliberately NOT flagged by the new AC-16 scan: the sentence carries a negation marker ('unaffected'), which is exactly the discriminator that keeps the scan's positive control green against the five places this repo writes the wrong formula down on purpose. Out of every wave's file boundary this round.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-45-3-4"
    },
    {
      "category": "pre-existing",
      "severity": "low",
      "summary": "The supervise harness fixture posts a Phase Outcome under an invalid producer name, and the failure is swallowed by '>/dev/null 2>&1 || true'",
      "reasoning": "plugin/skills/supervise/test/fixtures/bin/claude:133 posts with --skill supervise-stub, which is not in _VALID_PRODUCERS, so the call has always exited 2 — invisibly, because the line discards status. WU-45-3-2 added --roll-call so the call is correct the day the producer name is fixed, but the real defect is the swallowed status: this is the rc-from-the-command rule applied to a fixture. Two further flag-day suite call sites carry the same shape. A fixture whose failure cannot be observed is not exercising what the suite believes it is.",
      "proposed_action": "defer-to-issue",
      "target": null,
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "defer-to-issue",
      "id": "F-PO-45-3-5"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "AC-8's 'a header/body mismatch is refused' clause is not reachable through the pinned CLI, so it is verified by proxy rather than directly",
      "reasoning": "phase-outcome-supersede.sh derives the header's ref/by and the body's target/by from the same two flags, so no conforming caller can request a mismatch. The refusal IS implemented, as a defensive internal invariant carrying a comment that it is not CLI-reachable today and exists so a future flag cannot introduce drift silently. supersede-flow.sh asserts the positive invariant instead — header ref == body target and header by == body by on two independently posted records, one skill-posted and one operator-posted. QA should know this clause of AC-8 is covered by a black-box proxy, not by a negative test, and that making it directly testable would mean contorting the CLI.",
      "proposed_action": "accept",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-45-3-6"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "AC-9's second half — an operator superseding from a genuinely fresh session — is the declared human-uat lane and is not verified in this phase",
      "reasoning": "Planned at technical-plan and recorded as Scenario 9's lane in the test-plan deliverable (comment 2197), not a deferral taken here. The helper-level half IS automated in supersede-flow.sh: the helper carries no session state, and the suite drives it as a brand-new process against a ledger it did not build, with --by operator. What remains for a human is dogfooding /dev:resolve --supersede against this repo's own tracker from a separate session, which is a UAT-gate activity by construction.",
      "proposed_action": "accept",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "core",
      "requires_product_decision": false,
      "applied_disposition": "accept",
      "id": "F-PO-45-3-7"
    }
  ],
  "pending_decisions": [],
  "roll_call": [
    {
      "agent": "WU-45-3-1 Implementer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-2 Test Writer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-2 Implementer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-3 Test Writer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-3 Implementer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-4 Test Writer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-4 Implementer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-5 Test Writer",
      "disposition": "reported"
    },
    {
      "agent": "WU-45-3-5 Implementer",
      "disposition": "reported"
    }
  ],
  "suite": {
    "source": "git",
    "sha": "06c73fbfe2d772821a500b27ef93c0b5bfa7b10a",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-45-3 skill=develop --> ```json { "next_state": "qa", "produced": [ { "kind": "wu-plan", "ref": "2499", "summary": "5 work units in 5 waves, strictly sequential (single shared worktree)" }, { "kind": "work-unit-outcome", "ref": "2522", "summary": "WU-45-3-1: supersede record schema, roll_call field, forge-contract §2.1/§7/§8/§9" }, { "kind": "work-unit-outcome", "ref": "2559", "summary": "WU-45-3-2: --roll-call at the post seam, call sites, suite flag-day, $PO_ORDINAL fix" }, { "kind": "work-unit-outcome", "ref": "2562", "summary": "WU-45-3-3: _po_annulment and the filtered/history read shapes" }, { "kind": "work-unit-outcome", "ref": "2565", "summary": "WU-45-3-4: phase-outcome-supersede.sh, the write door" }, { "kind": "work-unit-outcome", "ref": "2568", "summary": "WU-45-3-5: operator surface, procedure docs, mechanical gates" }, { "kind": "dev-journal", "ref": "2521", "summary": "WU-45-3-1 development journal" }, { "kind": "dev-journal", "ref": "2557", "summary": "WU-45-3-2 development journal" }, { "kind": "dev-journal", "ref": "2560", "summary": "WU-45-3-3 development journal" }, { "kind": "dev-journal", "ref": "2563", "summary": "WU-45-3-4 development journal" }, { "kind": "dev-journal", "ref": "2566", "summary": "WU-45-3-5 development journal" }, { "kind": "learning", "ref": "comment:2569", "summary": "5 learnings (5 applied to CLAUDE.md, 0 unhomed)" } ], "findings": [ { "category": "pre-existing", "severity": "medium", "summary": "scripts/test-run-resource-claims.sh — one of CLAUDE.md's four declared verification commands — cannot pass on any clean checkout", "reasoning": "scenario_cr70_cr57_comment_misstates_journal_spelling reads REAL_JOURNAL=$REPO_ROOT/.devwork/feature-qa-intra-run-lane-ownership/dispatch-journal.md and returns 1 when absent. .devwork/ is gitignored, so that journal exists in neither this worktree nor the main checkout; only the tracked fixture scripts/fixtures/dispatch-journal.fixture.md survives, and the scenario deliberately bypasses it. MEASURED, not inferred: identical single failure at the pre-work base 549edaf in a throwaway worktree, and this feature's commits touch nothing under scripts/ until WU-45-3-5 (which added only the two new scans). It does not block release — release.sh's four gates are lint-conventions, audit-doc-structure, plugin validate and tag --dry-run, none of which is a harness — but QA will meet this red and must not read it as a regression. Fix is either to commit a fixture copy of the journal or to make the scenario report undetermined the way scenario_cr54_root_guard already does.", "proposed_action": "defer-to-issue", "target": "43", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "none", "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-45-3-1" }, { "category": "out-of-scope", "severity": "medium", "summary": "read-all.sh's allow-list projection silently drops every 'additive' optional body field until that map is edited, contradicting the schema's own versioning promise", "reasoning": "phase-outcome.v1.md §Versioning states optional top-level fields are additive and 'every reader projects the keys it knows'. In practice phase-outcome-read-all.sh builds output with an explicit map({...}), so a body key nobody names is invisible to every consumer. suite paid this tax on #34 and roll_call paid it again here — WU-45-3-2 had to reach outside its declared file boundary for a one-line projection add, which is how it was found. Either merge unknown body keys through, or state the two-file tax in the schema so the next additive field budgets for it.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-45-3-2" }, { "category": "pre-existing", "severity": "low", "summary": "_validate_producer's diagnostic hardcodes '--skill' but the validator now serves --by as well", "reasoning": "_lib.sh:171 tells the caller to fix their '--skill' flag. phase-outcome-supersede.sh validates --by against the same enum, so a naive reuse would misdirect an operator to a flag its command does not have. LATENT ONLY, not live: WU-45-3-4 kept the reuse for the decision inside a subshell (so its _die exits only the subshell) and emits a --by-accurate refusal itself, keeping $_VALID_PRODUCERS the single enum authority. The lead considered and DECLINED a flag-name parameter this round: /dev:resolve --supersede invokes the helper rather than the validator, so no third caller materialises, and refactoring two verified helpers on the final wave to remove a non-user-visible duplication is scope this feature should not take. If a third differently-named flag ever appears, parameterise it rather than adding a third subshell wrapper.", "proposed_action": "accept", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-45-3-3" }, { "category": "pre-existing", "severity": "low", "summary": "plugin/skills/local-fs/SKILL.md:41 states the wrong ordinal formula (n = len(existing)+1) as fact", "reasoning": "post mints highest-parsed-ordinal + 1, never a count — the correction this feature made in develop/SKILL.md, phase-outcome.md and phase-outcome.v1.md. The local-fs adapter doc still asserts the count form as fact rather than as a counterexample. Its actual point (filename order does not affect ordinal derivation) survives the correction, so this is a one-clause fix. Deliberately NOT flagged by the new AC-16 scan: the sentence carries a negation marker ('unaffected'), which is exactly the discriminator that keeps the scan's positive control green against the five places this repo writes the wrong formula down on purpose. Out of every wave's file boundary this round.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-45-3-4" }, { "category": "pre-existing", "severity": "low", "summary": "The supervise harness fixture posts a Phase Outcome under an invalid producer name, and the failure is swallowed by '>/dev/null 2>&1 || true'", "reasoning": "plugin/skills/supervise/test/fixtures/bin/claude:133 posts with --skill supervise-stub, which is not in _VALID_PRODUCERS, so the call has always exited 2 — invisibly, because the line discards status. WU-45-3-2 added --roll-call so the call is correct the day the producer name is fixed, but the real defect is the swallowed status: this is the rc-from-the-command rule applied to a fixture. Two further flag-day suite call sites carry the same shape. A fixture whose failure cannot be observed is not exercising what the suite believes it is.", "proposed_action": "defer-to-issue", "target": null, "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "defer-to-issue", "id": "F-PO-45-3-5" }, { "category": "in-scope-deferrable", "severity": "info", "summary": "AC-8's 'a header/body mismatch is refused' clause is not reachable through the pinned CLI, so it is verified by proxy rather than directly", "reasoning": "phase-outcome-supersede.sh derives the header's ref/by and the body's target/by from the same two flags, so no conforming caller can request a mismatch. The refusal IS implemented, as a defensive internal invariant carrying a comment that it is not CLI-reachable today and exists so a future flag cannot introduce drift silently. supersede-flow.sh asserts the positive invariant instead — header ref == body target and header by == body by on two independently posted records, one skill-posted and one operator-posted. QA should know this clause of AC-8 is covered by a black-box proxy, not by a negative test, and that making it directly testable would mean contorting the CLI.", "proposed_action": "accept", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-45-3-6" }, { "category": "in-scope-deferrable", "severity": "info", "summary": "AC-9's second half — an operator superseding from a genuinely fresh session — is the declared human-uat lane and is not verified in this phase", "reasoning": "Planned at technical-plan and recorded as Scenario 9's lane in the test-plan deliverable (comment 2197), not a deferral taken here. The helper-level half IS automated in supersede-flow.sh: the helper carries no session state, and the suite drives it as a brand-new process against a ledger it did not build, with --by operator. What remains for a human is dogfooding /dev:resolve --supersede against this repo's own tracker from a separate session, which is a UAT-gate activity by construction.", "proposed_action": "accept", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "core", "requires_product_decision": false, "applied_disposition": "accept", "id": "F-PO-45-3-7" } ], "pending_decisions": [], "roll_call": [ { "agent": "WU-45-3-1 Implementer", "disposition": "reported" }, { "agent": "WU-45-3-2 Test Writer", "disposition": "reported" }, { "agent": "WU-45-3-2 Implementer", "disposition": "reported" }, { "agent": "WU-45-3-3 Test Writer", "disposition": "reported" }, { "agent": "WU-45-3-3 Implementer", "disposition": "reported" }, { "agent": "WU-45-3-4 Test Writer", "disposition": "reported" }, { "agent": "WU-45-3-4 Implementer", "disposition": "reported" }, { "agent": "WU-45-3-5 Test Writer", "disposition": "reported" }, { "agent": "WU-45-3-5 Implementer", "disposition": "reported" } ], "suite": { "source": "git", "sha": "06c73fbfe2d772821a500b27ef93c0b5bfa7b10a", "dirty": false } } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "Project declares api_invocation: mode: none — there is no API surface to exercise.",
  "findings": [],
  "not_applicable_reason": "no API — markdown+shell skill suite"
}

<!-- qa-report:v1 issue=45 skill=qa domain=api phase=validate --> ```json { "outcome": "skipped", "summary": "Project declares api_invocation: mode: none — there is no API surface to exercise.", "findings": [], "not_applicable_reason": "no API — markdown+shell skill suite" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "Project declares api_invocation: mode: none — no endpoints, so no auth, IDOR, injection or rate-limit surface to probe.",
  "findings": [],
  "not_applicable_reason": "no API — markdown+shell skill suite"
}

<!-- qa-report:v1 issue=45 skill=qa domain=security-api phase=validate --> ```json { "outcome": "skipped", "summary": "Project declares api_invocation: mode: none — no endpoints, so no auth, IDOR, injection or rate-limit surface to probe.", "findings": [], "not_applicable_reason": "no API — markdown+shell skill suite" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "No application or browser surface to drive end-to-end.",
  "findings": [],
  "not_applicable_reason": "markdown+shell skill suite — no application or browser surface to drive end-to-end; validation is the shell test harness plus dogfooding"
}

<!-- qa-report:v1 issue=45 skill=qa domain=e2e phase=validate --> ```json { "outcome": "skipped", "summary": "No application or browser surface to drive end-to-end.", "findings": [], "not_applicable_reason": "markdown+shell skill suite — no application or browser surface to drive end-to-end; validation is the shell test harness plus dogfooding" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "No rendered UI to evaluate.",
  "findings": [],
  "not_applicable_reason": "no rendered UI — the deliverables are markdown skill text and shell helpers"
}

<!-- qa-report:v1 issue=45 skill=qa domain=a11y phase=validate --> ```json { "outcome": "skipped", "summary": "No rendered UI to evaluate.", "findings": [], "not_applicable_reason": "no rendered UI — the deliverables are markdown skill text and shell helpers" } ```
Author
Owner
{
  "outcome": "skipped",
  "summary": "No browser surface to attack.",
  "findings": [],
  "not_applicable_reason": "no browser surface — nothing is served or rendered"
}

<!-- qa-report:v1 issue=45 skill=qa domain=security-browser phase=validate --> ```json { "outcome": "skipped", "summary": "No browser surface to attack.", "findings": [], "not_applicable_reason": "no browser surface — nothing is served or rendered" } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "4 in-scope-blocking of 19 verified findings over 2858 new lines. CR-1 (high) inverts latest-wins on every remote adapter and its covering assertion cannot fail; CR-2/CR-3/CR-4 are fail-closed and rc-ownership gaps in changed lines. 0 dropped below the precision floor; 0 carried forward (round 1).",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail",
      "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion."
    },
    {
      "id": "CR-2",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically",
      "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline."
    },
    {
      "id": "CR-3",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER",
      "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits."
    },
    {
      "id": "CR-4",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "the read gate never validates `by`, so an anonymous hand-posted supersede is honoured",
      "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone."
    },
    {
      "id": "CR-5",
      "category": "pre-existing",
      "severity": "medium",
      "summary": "retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only",
      "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental"
    },
    {
      "id": "CR-6",
      "category": "pre-existing",
      "severity": "low",
      "summary": "any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede",
      "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.",
      "proposed_action": "accept",
      "fix_cost": "substantial",
      "adjacent_to_blocking": false,
      "feature_value": "none"
    },
    {
      "id": "CR-7",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected",
      "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-8",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-3's mechanical scan is code-only, narrower than the AC's stated claim",
      "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-9",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence",
      "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental"
    },
    {
      "id": "CR-10",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible",
      "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental"
    },
    {
      "id": "CR-11",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "--roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr",
      "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-12",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes",
      "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-13",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "a duplicated header attribute resolves silently to the first occurrence",
      "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-14",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id",
      "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-15",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "supersede.sh asserts on the raw ledger capture but not on the raw post capture",
      "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-16",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "`// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"",
      "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-17",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "the json-fence capture regex now exists as three independent copies, two of them new",
      "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-18",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated",
      "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental"
    },
    {
      "id": "CR-19",
      "category": "out-of-scope",
      "severity": "low",
      "summary": "phase-outcome-post.sh takes the post rc from a pipeline ending in jq",
      "reasoning": "Line 224: cid=\"$(printf '%s' \"$body\" | _prim post_comment --issue \"$issue\" | jq -r '.comment_id')\". rc comes from jq, not post_comment, so a failed post's own exit vocabulary (e.g. EXIT_QUERY 9) is discarded and the caller sees EXIT_ENV \"post_comment returned no comment_id\". Direct violation of the baseline's rc-from-the-command rule. git blame says fff5b70, an ancestor of the base — PRE-EXISTING and untouched by this feature, hence out-of-scope. Surfaced because this diff's own new write door (phase-outcome-supersede.sh:199-203) establishes the correct pattern three files away, which makes the contrast actionable now.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "none"
    }
  ],
  "artifacts": {
    "report_path": ".devwork/feature-45-phase-outcome-supersede/code-report.md",
    "journal_path": ".devwork/feature-45-phase-outcome-supersede/code-journal.md",
    "dispatch_journal": ".devwork/feature-45-phase-outcome-supersede/dispatch-journal.md"
  }
}

<!-- qa-report:v1 issue=45 skill=qa domain=code phase=validate --> ```json { "outcome": "issues-found", "summary": "4 in-scope-blocking of 19 verified findings over 2858 new lines. CR-1 (high) inverts latest-wins on every remote adapter and its covering assertion cannot fail; CR-2/CR-3/CR-4 are fail-closed and rc-ownership gaps in changed lines. 0 dropped below the precision floor; 0 carried forward (round 1).", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "high", "summary": "latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail", "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion." }, { "id": "CR-2", "category": "in-scope-blocking", "severity": "medium", "summary": "a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically", "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline." }, { "id": "CR-3", "category": "in-scope-blocking", "severity": "medium", "summary": "retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER", "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits." }, { "id": "CR-4", "category": "in-scope-blocking", "severity": "medium", "summary": "the read gate never validates `by`, so an anonymous hand-posted supersede is honoured", "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone." }, { "id": "CR-5", "category": "pre-existing", "severity": "medium", "summary": "retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only", "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental" }, { "id": "CR-6", "category": "pre-existing", "severity": "low", "summary": "any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede", "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.", "proposed_action": "accept", "fix_cost": "substantial", "adjacent_to_blocking": false, "feature_value": "none" }, { "id": "CR-7", "category": "in-scope-deferrable", "severity": "low", "summary": "the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected", "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-8", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-3's mechanical scan is code-only, narrower than the AC's stated claim", "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-9", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence", "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental" }, { "id": "CR-10", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible", "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental" }, { "id": "CR-11", "category": "in-scope-deferrable", "severity": "low", "summary": "--roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr", "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-12", "category": "in-scope-deferrable", "severity": "low", "summary": "roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes", "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-13", "category": "in-scope-deferrable", "severity": "low", "summary": "a duplicated header attribute resolves silently to the first occurrence", "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-14", "category": "in-scope-deferrable", "severity": "low", "summary": "annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id", "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-15", "category": "in-scope-deferrable", "severity": "low", "summary": "supersede.sh asserts on the raw ledger capture but not on the raw post capture", "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-16", "category": "in-scope-deferrable", "severity": "low", "summary": "`// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"", "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-17", "category": "in-scope-deferrable", "severity": "low", "summary": "the json-fence capture regex now exists as three independent copies, two of them new", "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-18", "category": "in-scope-deferrable", "severity": "low", "summary": "dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated", "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental" }, { "id": "CR-19", "category": "out-of-scope", "severity": "low", "summary": "phase-outcome-post.sh takes the post rc from a pipeline ending in jq", "reasoning": "Line 224: cid=\"$(printf '%s' \"$body\" | _prim post_comment --issue \"$issue\" | jq -r '.comment_id')\". rc comes from jq, not post_comment, so a failed post's own exit vocabulary (e.g. EXIT_QUERY 9) is discarded and the caller sees EXIT_ENV \"post_comment returned no comment_id\". Direct violation of the baseline's rc-from-the-command rule. git blame says fff5b70, an ancestor of the base — PRE-EXISTING and untouched by this feature, hence out-of-scope. Surfaced because this diff's own new write door (phase-outcome-supersede.sh:199-203) establishes the correct pattern three files away, which makes the contrast actionable now.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "none" } ], "artifacts": { "report_path": ".devwork/feature-45-phase-outcome-supersede/code-report.md", "journal_path": ".devwork/feature-45-phase-outcome-supersede/code-journal.md", "dispatch_journal": ".devwork/feature-45-phase-outcome-supersede/dispatch-journal.md" } } ```
Author
Owner
{
  "next_state": "qa",
  "produced": [
    {
      "kind": "qa-report",
      "ref": "comment:2578",
      "summary": "code domain, phase=validate — 19 findings, 4 in-scope-blocking"
    },
    {
      "kind": "qa-report",
      "ref": "comment:2573",
      "summary": "api — skipped (api_invocation: mode: none)"
    },
    {
      "kind": "qa-report",
      "ref": "comment:2574",
      "summary": "security-api — skipped (api_invocation: mode: none)"
    },
    {
      "kind": "qa-report",
      "ref": "comment:2575",
      "summary": "e2e — skipped (qa_domains.not_applicable)"
    },
    {
      "kind": "qa-report",
      "ref": "comment:2576",
      "summary": "a11y — skipped (qa_domains.not_applicable)"
    },
    {
      "kind": "qa-report",
      "ref": "comment:2577",
      "summary": "security-browser — skipped (qa_domains.not_applicable)"
    }
  ],
  "findings": [
    {
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "[code CR-1] latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail",
      "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion.",
      "requires_product_decision": false,
      "id": "F-PO-45-4-1"
    },
    {
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "[code CR-2] a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically",
      "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline.",
      "requires_product_decision": false,
      "id": "F-PO-45-4-2"
    },
    {
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "[code CR-3] retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER",
      "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits.",
      "requires_product_decision": false,
      "id": "F-PO-45-4-3"
    },
    {
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "[code CR-4] the read gate never validates `by`, so an anonymous hand-posted supersede is honoured",
      "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone.",
      "requires_product_decision": false,
      "id": "F-PO-45-4-4"
    },
    {
      "category": "pre-existing",
      "severity": "medium",
      "summary": "[code CR-5] retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only",
      "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-5"
    },
    {
      "category": "pre-existing",
      "severity": "low",
      "summary": "[code CR-6] any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede",
      "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.",
      "proposed_action": "accept",
      "fix_cost": "substantial",
      "adjacent_to_blocking": false,
      "feature_value": "none",
      "requires_product_decision": true,
      "id": "F-PO-45-4-6"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-7] the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected",
      "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-7"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-8] AC-3's mechanical scan is code-only, narrower than the AC's stated claim",
      "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-8"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-9] AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence",
      "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-9"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-10] AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible",
      "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-10"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-11] --roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr",
      "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-11"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-12] roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes",
      "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-12"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-13] a duplicated header attribute resolves silently to the first occurrence",
      "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-13"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-14] annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id",
      "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-14"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-15] supersede.sh asserts on the raw ledger capture but not on the raw post capture",
      "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-15"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-16] `// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"",
      "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-16"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-17] the json-fence capture regex now exists as three independent copies, two of them new",
      "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-17"
    },
    {
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "[code CR-18] dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated",
      "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-18"
    },
    {
      "category": "out-of-scope",
      "severity": "low",
      "summary": "[code CR-19] phase-outcome-post.sh takes the post rc from a pipeline ending in jq",
      "reasoning": "Line 224: cid=\"$(printf '%s' \"$body\" | _prim post_comment --issue \"$issue\" | jq -r '.comment_id')\". rc comes from jq, not post_comment, so a failed post's own exit vocabulary (e.g. EXIT_QUERY 9) is discarded and the caller sees EXIT_ENV \"post_comment returned no comment_id\". Direct violation of the baseline's rc-from-the-command rule. git blame says fff5b70, an ancestor of the base — PRE-EXISTING and untouched by this feature, hence out-of-scope. Surfaced because this diff's own new write door (phase-outcome-supersede.sh:199-203) establishes the correct pattern three files away, which makes the contrast actionable now.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now",
      "id": "F-PO-45-4-19"
    }
  ],
  "pending_decisions": [
    {
      "type": "scope-disposition",
      "blocking": false,
      "question": "Accept the malformed-supersede denial-of-service surface? Any account that can comment on an issue can post one malformed phase-outcome-supersede:v1 record (edited-after-post, unparseable body, or unknown status) and make phase-outcome-read-all.sh hard-refuse for every consumer of that issue until a human deletes the comment.",
      "options": [
        "accept",
        "defer-to-issue",
        "fix-now"
      ],
      "recommended": "accept",
      "reasoning": "Three reasons, and the operator should still sign them off rather than the run doing it. (1) It is the tamper-evidence tradeoff this repo ALREADY accepts for phase-outcome:v1 itself — the feature replicates a house pattern rather than introducing a failure class. (2) The alternative is worse: failing OPEN here would let a hostile record silently un-annul a real correction, which is the exact outcome the fail-closed design exists to prevent. (3) A real fix needs an adapter primitive that fetches a comment's true author so `by` can be verified — the SREQ deliberately added no such primitive, and adding one is a scope change, not a bug fix. This is marked product-decidable precisely because accepting a security posture is not a call an autonomous run should make on its own authority.",
      "finding_ref": "F-PO-45-4-6",
      "id": "D-PO-45-4-1"
    }
  ],
  "roll_call": [
    {
      "agent": "code-dep-verify-r1",
      "disposition": "reported"
    },
    {
      "agent": "code-bug-hunter-r1",
      "disposition": "reported"
    },
    {
      "agent": "code-static-sec-r1",
      "disposition": "reported"
    },
    {
      "agent": "code-spec-check-r1",
      "disposition": "reported"
    }
  ],
  "suite": {
    "source": "git",
    "sha": "06c73fbfe2d772821a500b27ef93c0b5bfa7b10a",
    "dirty": false
  }
}
<!-- phase-outcome:v1 id=PO-45-4 skill=qa --> ```json { "next_state": "qa", "produced": [ { "kind": "qa-report", "ref": "comment:2578", "summary": "code domain, phase=validate — 19 findings, 4 in-scope-blocking" }, { "kind": "qa-report", "ref": "comment:2573", "summary": "api — skipped (api_invocation: mode: none)" }, { "kind": "qa-report", "ref": "comment:2574", "summary": "security-api — skipped (api_invocation: mode: none)" }, { "kind": "qa-report", "ref": "comment:2575", "summary": "e2e — skipped (qa_domains.not_applicable)" }, { "kind": "qa-report", "ref": "comment:2576", "summary": "a11y — skipped (qa_domains.not_applicable)" }, { "kind": "qa-report", "ref": "comment:2577", "summary": "security-browser — skipped (qa_domains.not_applicable)" } ], "findings": [ { "category": "in-scope-blocking", "severity": "high", "summary": "[code CR-1] latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail", "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion.", "requires_product_decision": false, "id": "F-PO-45-4-1" }, { "category": "in-scope-blocking", "severity": "medium", "summary": "[code CR-2] a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically", "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline.", "requires_product_decision": false, "id": "F-PO-45-4-2" }, { "category": "in-scope-blocking", "severity": "medium", "summary": "[code CR-3] retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER", "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits.", "requires_product_decision": false, "id": "F-PO-45-4-3" }, { "category": "in-scope-blocking", "severity": "medium", "summary": "[code CR-4] the read gate never validates `by`, so an anonymous hand-posted supersede is honoured", "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone.", "requires_product_decision": false, "id": "F-PO-45-4-4" }, { "category": "pre-existing", "severity": "medium", "summary": "[code CR-5] retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only", "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-5" }, { "category": "pre-existing", "severity": "low", "summary": "[code CR-6] any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede", "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.", "proposed_action": "accept", "fix_cost": "substantial", "adjacent_to_blocking": false, "feature_value": "none", "requires_product_decision": true, "id": "F-PO-45-4-6" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-7] the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected", "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-7" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-8] AC-3's mechanical scan is code-only, narrower than the AC's stated claim", "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-8" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-9] AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence", "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-9" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-10] AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible", "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-10" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-11] --roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr", "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-11" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-12] roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes", "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-12" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-13] a duplicated header attribute resolves silently to the first occurrence", "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-13" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-14] annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id", "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-14" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-15] supersede.sh asserts on the raw ledger capture but not on the raw post capture", "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-15" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-16] `// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"", "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-16" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-17] the json-fence capture regex now exists as three independent copies, two of them new", "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-17" }, { "category": "in-scope-deferrable", "severity": "low", "summary": "[code CR-18] dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated", "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-18" }, { "category": "out-of-scope", "severity": "low", "summary": "[code CR-19] phase-outcome-post.sh takes the post rc from a pipeline ending in jq", "reasoning": "Line 224: cid=\"$(printf '%s' \"$body\" | _prim post_comment --issue \"$issue\" | jq -r '.comment_id')\". rc comes from jq, not post_comment, so a failed post's own exit vocabulary (e.g. EXIT_QUERY 9) is discarded and the caller sees EXIT_ENV \"post_comment returned no comment_id\". Direct violation of the baseline's rc-from-the-command rule. git blame says fff5b70, an ancestor of the base — PRE-EXISTING and untouched by this feature, hence out-of-scope. Surfaced because this diff's own new write door (phase-outcome-supersede.sh:199-203) establishes the correct pattern three files away, which makes the contrast actionable now.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now", "id": "F-PO-45-4-19" } ], "pending_decisions": [ { "type": "scope-disposition", "blocking": false, "question": "Accept the malformed-supersede denial-of-service surface? Any account that can comment on an issue can post one malformed phase-outcome-supersede:v1 record (edited-after-post, unparseable body, or unknown status) and make phase-outcome-read-all.sh hard-refuse for every consumer of that issue until a human deletes the comment.", "options": [ "accept", "defer-to-issue", "fix-now" ], "recommended": "accept", "reasoning": "Three reasons, and the operator should still sign them off rather than the run doing it. (1) It is the tamper-evidence tradeoff this repo ALREADY accepts for phase-outcome:v1 itself — the feature replicates a house pattern rather than introducing a failure class. (2) The alternative is worse: failing OPEN here would let a hostile record silently un-annul a real correction, which is the exact outcome the fail-closed design exists to prevent. (3) A real fix needs an adapter primitive that fetches a comment's true author so `by` can be verified — the SREQ deliberately added no such primitive, and adding one is a scope change, not a bug fix. This is marked product-decidable precisely because accepting a security posture is not a call an autonomous run should make on its own authority.", "finding_ref": "F-PO-45-4-6", "id": "D-PO-45-4-1" } ], "roll_call": [ { "agent": "code-dep-verify-r1", "disposition": "reported" }, { "agent": "code-bug-hunter-r1", "disposition": "reported" }, { "agent": "code-static-sec-r1", "disposition": "reported" }, { "agent": "code-spec-check-r1", "disposition": "reported" } ], "suite": { "source": "git", "sha": "06c73fbfe2d772821a500b27ef93c0b5bfa7b10a", "dirty": false } } ```
Author
Owner
{
  "status": "resolved",
  "chosen_option": "accept",
  "rationale": "Answered by the operator at the QA round-1 gate on 2026-09-01, choosing the recommended option after being shown all three. The reasoning it rests on is checkable in three places, not on the choice itself. (1) Precedent: phase-outcome:v1 already carries the identical tamper-evidence tradeoff — an edited record refuses the read — so this feature replicates a house pattern rather than introducing a failure class; phase-outcome-supersede.v1.md documents the same fail-closed table for the new kind. (2) The alternative is measurably worse: failing OPEN on an untrustworthy supersede record would let a hostile or malformed record silently un-annul a real correction, which is the exact outcome the fail-closed gates exist to prevent — so the DoS surface is the cost of the property, not a defect beside it. (3) A real fix is out of scope by an earlier recorded decision, not by preference: verifying `by` against the comment true author needs an adapter primitive to fetch it, and the SREQ deliberately added none (phase-outcome-supersede.v1.md, Attribution: \"this suite deliberately adds no adapter primitive to fetch that\"). Adding one is a scope change to the feature. Recorded as accepted with no side effect; the surface is unchanged and now documented on the record.",
  "rejected_alternative": "defer-to-issue — rejected because a ticket for it would sit unactioned until the suite gains author verification, which is the unactionable-sibling-issue failure this project has already recorded; the limit is better carried as a documented, signed-off property than as an open ticket nobody can close."
}

<!-- decision-resolution:v1 ref=D-PO-45-4-1 --> ```json { "status": "resolved", "chosen_option": "accept", "rationale": "Answered by the operator at the QA round-1 gate on 2026-09-01, choosing the recommended option after being shown all three. The reasoning it rests on is checkable in three places, not on the choice itself. (1) Precedent: phase-outcome:v1 already carries the identical tamper-evidence tradeoff — an edited record refuses the read — so this feature replicates a house pattern rather than introducing a failure class; phase-outcome-supersede.v1.md documents the same fail-closed table for the new kind. (2) The alternative is measurably worse: failing OPEN on an untrustworthy supersede record would let a hostile or malformed record silently un-annul a real correction, which is the exact outcome the fail-closed gates exist to prevent — so the DoS surface is the cost of the property, not a defect beside it. (3) A real fix is out of scope by an earlier recorded decision, not by preference: verifying `by` against the comment true author needs an adapter primitive to fetch it, and the SREQ deliberately added none (phase-outcome-supersede.v1.md, Attribution: \"this suite deliberately adds no adapter primitive to fetch that\"). Adding one is a scope change to the feature. Recorded as accepted with no side effect; the surface is unchanged and now documented on the record.", "rejected_alternative": "defer-to-issue — rejected because a ticket for it would sit unactioned until the suite gains author verification, which is the unactionable-sibling-issue failure this project has already recorded; the limit is better carried as a documented, signed-off property than as an open ticket nobody can close." } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "v2 — supersedes comment 2578. CR-19 WITHDRAWN as a false positive on measured evidence (pipefail is in force at post.sh:224, so the primitive rc propagates intact). 18 findings stand, 4 in-scope-blocking. Re-posted by the DRIVER on the domain agents behalf: all four stage-A actors had already been released when the measurement was taken, and latest-wins is the supersession mechanism (validate-workflow.md Step 4).",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail",
      "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion.",
      "requires_product_decision": false
    },
    {
      "id": "CR-2",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically",
      "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline.",
      "requires_product_decision": false
    },
    {
      "id": "CR-3",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER",
      "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits.",
      "requires_product_decision": false
    },
    {
      "id": "CR-4",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "the read gate never validates `by`, so an anonymous hand-posted supersede is honoured",
      "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone.",
      "requires_product_decision": false
    },
    {
      "id": "CR-5",
      "category": "pre-existing",
      "severity": "medium",
      "summary": "retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only",
      "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-6",
      "category": "pre-existing",
      "severity": "low",
      "summary": "any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede",
      "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.",
      "proposed_action": "accept",
      "fix_cost": "substantial",
      "adjacent_to_blocking": false,
      "feature_value": "none",
      "requires_product_decision": true
    },
    {
      "id": "CR-7",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected",
      "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-8",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-3's mechanical scan is code-only, narrower than the AC's stated claim",
      "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-9",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence",
      "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-10",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible",
      "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-11",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "--roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr",
      "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-12",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes",
      "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-13",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "a duplicated header attribute resolves silently to the first occurrence",
      "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-14",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id",
      "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-15",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "supersede.sh asserts on the raw ledger capture but not on the raw post capture",
      "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-16",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "`// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"",
      "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-17",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "the json-fence capture regex now exists as three independent copies, two of them new",
      "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-18",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated",
      "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-19",
      "category": "false-positive",
      "severity": "info",
      "summary": "WITHDRAWN by the driver on measured evidence — phase-outcome-post.sh:224 does NOT lose the primitive rc",
      "reasoning": "Published in the first version of this report as an out-of-scope rc-from-a-pipeline violation. It is not one. _lib.sh:11 is `set -euo pipefail` and phase-outcome-post.sh:8 sources _lib.sh, so pipefail is in force at line 224. MEASURED on the decisive case (first stage fails emitting nothing, jq succeeds on empty input): WITH pipefail the script dies at rc=9 and the guard is never reached; WITHOUT pipefail the guard is reached with cid=[] and rc=0. The primitive exit vocabulary therefore PROPAGATES INTACT, which is the correct behaviour — the finding only holds in a world without pipefail, and that is not this one. The Bug Hunter reported it with an explicit scope caveat and could not run git diff; the lead confirmed the scope and failed to check the mechanism, so this is a filtering-pass error, not a reporting error. Withdrawn rather than silently dropped: the record says which of the two happened. CR-3 is NOT affected — those are simple assignments, not pipelines, so pipefail is irrelevant and a failing $(jq …) there still exits with jq's own rc, colliding with EXIT_ADAPTER.",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    }
  ],
  "artifacts": {
    "report_path": ".devwork/feature-45-phase-outcome-supersede/code-report.md",
    "journal_path": ".devwork/feature-45-phase-outcome-supersede/code-journal.md",
    "supersedes": "comment:2578",
    "reposted_by": "driver (qa round lead) — the domain agents had exited; validate-workflow.md Step 4 assigns the re-post to the driver in exactly this case"
  }
}

<!-- qa-report:v1 issue=45 skill=qa domain=code phase=validate --> ```json { "outcome": "issues-found", "summary": "v2 — supersedes comment 2578. CR-19 WITHDRAWN as a false positive on measured evidence (pipefail is in force at post.sh:224, so the primitive rc propagates intact). 18 findings stand, 4 in-scope-blocking. Re-posted by the DRIVER on the domain agents behalf: all four stage-A actors had already been released when the measurement was taken, and latest-wins is the supersession mechanism (validate-workflow.md Step 4).", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "high", "summary": "latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail", "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion.", "requires_product_decision": false }, { "id": "CR-2", "category": "in-scope-blocking", "severity": "medium", "summary": "a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically", "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline.", "requires_product_decision": false }, { "id": "CR-3", "category": "in-scope-blocking", "severity": "medium", "summary": "retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER", "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits.", "requires_product_decision": false }, { "id": "CR-4", "category": "in-scope-blocking", "severity": "medium", "summary": "the read gate never validates `by`, so an anonymous hand-posted supersede is honoured", "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone.", "requires_product_decision": false }, { "id": "CR-5", "category": "pre-existing", "severity": "medium", "summary": "retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only", "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-6", "category": "pre-existing", "severity": "low", "summary": "any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede", "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.", "proposed_action": "accept", "fix_cost": "substantial", "adjacent_to_blocking": false, "feature_value": "none", "requires_product_decision": true }, { "id": "CR-7", "category": "in-scope-deferrable", "severity": "low", "summary": "the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected", "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-8", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-3's mechanical scan is code-only, narrower than the AC's stated claim", "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-9", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence", "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-10", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible", "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-11", "category": "in-scope-deferrable", "severity": "low", "summary": "--roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr", "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-12", "category": "in-scope-deferrable", "severity": "low", "summary": "roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes", "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-13", "category": "in-scope-deferrable", "severity": "low", "summary": "a duplicated header attribute resolves silently to the first occurrence", "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-14", "category": "in-scope-deferrable", "severity": "low", "summary": "annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id", "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-15", "category": "in-scope-deferrable", "severity": "low", "summary": "supersede.sh asserts on the raw ledger capture but not on the raw post capture", "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-16", "category": "in-scope-deferrable", "severity": "low", "summary": "`// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"", "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-17", "category": "in-scope-deferrable", "severity": "low", "summary": "the json-fence capture regex now exists as three independent copies, two of them new", "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-18", "category": "in-scope-deferrable", "severity": "low", "summary": "dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated", "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-19", "category": "false-positive", "severity": "info", "summary": "WITHDRAWN by the driver on measured evidence — phase-outcome-post.sh:224 does NOT lose the primitive rc", "reasoning": "Published in the first version of this report as an out-of-scope rc-from-a-pipeline violation. It is not one. _lib.sh:11 is `set -euo pipefail` and phase-outcome-post.sh:8 sources _lib.sh, so pipefail is in force at line 224. MEASURED on the decisive case (first stage fails emitting nothing, jq succeeds on empty input): WITH pipefail the script dies at rc=9 and the guard is never reached; WITHOUT pipefail the guard is reached with cid=[] and rc=0. The primitive exit vocabulary therefore PROPAGATES INTACT, which is the correct behaviour — the finding only holds in a world without pipefail, and that is not this one. The Bug Hunter reported it with an explicit scope caveat and could not run git diff; the lead confirmed the scope and failed to check the mechanism, so this is a filtering-pass error, not a reporting error. Withdrawn rather than silently dropped: the record says which of the two happened. CR-3 is NOT affected — those are simple assignments, not pipelines, so pipefail is irrelevant and a failing $(jq …) there still exits with jq's own rc, colliding with EXIT_ADAPTER.", "requires_product_decision": false, "applied_disposition": "accept" } ], "artifacts": { "report_path": ".devwork/feature-45-phase-outcome-supersede/code-report.md", "journal_path": ".devwork/feature-45-phase-outcome-supersede/code-journal.md", "supersedes": "comment:2578", "reposted_by": "driver (qa round lead) — the domain agents had exited; validate-workflow.md Step 4 assigns the re-post to the driver in exactly this case" } } ```
Author
Owner
{
  "outcome": "issues-found",
  "summary": "v3 — supersedes 2581 (which superseded 2578). CR-19 REINSTATED at medium: the lead withdrawal in v2 was wrong. pipefail returns the RIGHTMOST non-zero status, so a primitive that fails AND writes non-JSON has its rc replaced by jq 5 = EXIT_ADAPTER, misrouting EXIT_QUERY=9 — the identical class as the repo #47 learning. 19 findings, 4 in-scope-blocking, 0 false positives.",
  "findings": [
    {
      "id": "CR-1",
      "category": "in-scope-blocking",
      "severity": "high",
      "summary": "latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail",
      "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion.",
      "requires_product_decision": false
    },
    {
      "id": "CR-2",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically",
      "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline.",
      "requires_product_decision": false
    },
    {
      "id": "CR-3",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER",
      "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits.",
      "requires_product_decision": false
    },
    {
      "id": "CR-4",
      "category": "in-scope-blocking",
      "severity": "medium",
      "summary": "the read gate never validates `by`, so an anonymous hand-posted supersede is honoured",
      "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone.",
      "requires_product_decision": false
    },
    {
      "id": "CR-5",
      "category": "pre-existing",
      "severity": "medium",
      "summary": "retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only",
      "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-6",
      "category": "pre-existing",
      "severity": "low",
      "summary": "any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede",
      "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.",
      "proposed_action": "accept",
      "fix_cost": "substantial",
      "adjacent_to_blocking": false,
      "feature_value": "none",
      "requires_product_decision": true
    },
    {
      "id": "CR-7",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected",
      "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-8",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-3's mechanical scan is code-only, narrower than the AC's stated claim",
      "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-9",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence",
      "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-10",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible",
      "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-11",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "--roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr",
      "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-12",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes",
      "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-13",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "a duplicated header attribute resolves silently to the first occurrence",
      "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-14",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id",
      "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-15",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "supersede.sh asserts on the raw ledger capture but not on the raw post capture",
      "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-16",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "`// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"",
      "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-17",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "the json-fence capture regex now exists as three independent copies, two of them new",
      "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-18",
      "category": "in-scope-deferrable",
      "severity": "low",
      "summary": "dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated",
      "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.",
      "proposed_action": "defer-to-issue",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    },
    {
      "id": "CR-19",
      "category": "pre-existing",
      "severity": "medium",
      "summary": "phase-outcome-post.sh:224 MISROUTES a failed post: under pipefail a non-JSON adapter failure reports jq's 5, which is EXIT_ADAPTER",
      "reasoning": "REINSTATED after being wrongly withdrawn by the lead. History matters here because the mechanism does. (a) The Bug Hunter said the primitive rc is always lost — too broad. (b) The lead measured ONE case, generalised from it, and withdrew the finding — wrong. (c) qa-testwriter-r1, writing a stub reproducing the EXACT line-224 shape, found the real mechanism, and the lead then re-measured and confirmed it. pipefail returns the RIGHTMOST non-zero status, so there are two cases: primitive fails emitting NOTHING and jq exits 0 on empty input, giving rc=9, the primitive vocabulary intact (this is the case the lead measured); primitive fails AND writes non-JSON, so jq ALSO fails with a parse error, and the rightmost non-zero is JQ'S 5. Measured: `{ printf \"not json\"; exit 9; } | jq -r .comment_id` under `set -euo pipefail` exits 5. And EXIT_ADAPTER=5 while EXIT_QUERY=9 (_lib.sh:15-21) — so a forge that could not be asked is reported as an adapter that could not be resolved. This is not merely a lost code, it is a MISROUTED one, and it is the identical class as the repo own recorded learning from #47 (`_gl_api … | jq` made a primitive exit with jq 5, colliding with EXIT_ADAPTER, so a correctly-detected auth failure was misrouted as a broken install). That the same defect recurs at post.sh:224 is what makes it worth fixing rather than noting. Severity raised low -> medium on that basis. Scope unchanged: git blame fff5b70, an ancestor of the base, so pre-existing and out of this feature file boundaries.",
      "proposed_action": "fix-now",
      "fix_cost": "trivial",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "fix-now"
    }
  ],
  "artifacts": {
    "report_path": ".devwork/feature-45-phase-outcome-supersede/code-report.md",
    "journal_path": ".devwork/feature-45-phase-outcome-supersede/code-journal.md",
    "supersedes": "comment:2581 (itself superseding comment:2578)",
    "reposted_by": "driver — correcting its OWN v2 withdrawal on evidence produced by qa-testwriter-r1 and then re-measured by the lead"
  }
}

<!-- qa-report:v1 issue=45 skill=qa domain=code phase=validate --> ```json { "outcome": "issues-found", "summary": "v3 — supersedes 2581 (which superseded 2578). CR-19 REINSTATED at medium: the lead withdrawal in v2 was wrong. pipefail returns the RIGHTMOST non-zero status, so a primitive that fails AND writes non-JSON has its rc replaced by jq 5 = EXIT_ADAPTER, misrouting EXIT_QUERY=9 — the identical class as the repo #47 learning. 19 findings, 4 in-scope-blocking, 0 false positives.", "findings": [ { "id": "CR-1", "category": "in-scope-blocking", "severity": "high", "summary": "latest-wins tiebreak compares comment_id LEXICALLY, and the test covering it cannot fail", "reasoning": "_lib.sh:542 uses max_by([.created_ms, .comment_id]). All three remote adapters emit comment_id as a STRING (`(.id|tostring)`) and tea-cli's iso2ms strips fractional seconds (`sub(\"\\\\.[0-9]+\";\"\")`), so created_ms is second-granular and ties are routine for a scripted annul-then-withdraw. jq then compares the ids lexically, where \"99\" > \"100\". The adapters' OWN sort is `sort_by(.created_ms, (.comment_id|tonumber))` — the new code deviates from the suite's ordering convention. MEASURED. The covering assertion (supersede-read.sh:368-369) plants ids 9021 vs 9020: equal length, so lexical and numeric agree and the test passes with the bug present. Fix note: a bare tonumber is not portable — local-fs comment_id is `{seq}-{ms}`, so the tiebreak needs a guarded numeric conversion.", "requires_product_decision": false }, { "id": "CR-2", "category": "in-scope-blocking", "severity": "medium", "summary": "a finding whose `id` is a truthy non-string crashes the entire issue read, undiagnostically", "reasoning": "phase-outcome-read-all.sh:85,106,115,119 use $annul[(.id // \"\")]. `//` is a falsiness test, so a hand-posted finding with \"id\": 5 keeps the number and $annul[5] is a jq runtime error; the projection dies and the read fails EXIT_ENV \"could not project the Phase Outcome ledger\" — naming no comment. _po_annulment guards this exact case one file away (_lib.sh:525, `select(type == \"string\")`), so the two changed sites disagree. This delivers the very outcome the dangling-supersede design explicitly rejects: one hand-posted comment takes an issue's reads offline.", "requires_product_decision": false }, { "id": "CR-3", "category": "in-scope-blocking", "severity": "medium", "summary": "retry-guard normalization assignments own no rc; jq's exit 5 escapes and collides with EXIT_ADAPTER", "reasoning": "phase-outcome-post.sh:165 (latest_norm) and :174 (incoming_norm) carry no `|| _die`. A simple assignment's status IS the command substitution's status, so under set -e a jq failure kills post.sh with jq's own code. jq's 1-5 range overlaps the reserved vocabulary and 5 is EXIT_ADAPTER, so a malformed landed record is misreported as a broken adapter install. Reachable: a hand-posted record with \"roll_call\": [\"rev-security\"] makes normrc's sort_by(.agent) error. This is CLAUDE.md's own recorded learning class, and _po_annulment's comment calls the same `|| _die` \"load-bearing, not belt-and-braces\". Line 174 is ed051ef (this feature); line 165 is 0515649 (pre-existing) with the identical gap. The `[[ -n \"$latest_norm\" ]]` guard at 177 suggests an empty-string fallthrough that set -e never permits.", "requires_product_decision": false }, { "id": "CR-4", "category": "in-scope-blocking", "severity": "medium", "summary": "the read gate never validates `by`, so an anonymous hand-posted supersede is honoured", "reasoning": "_PO_SUP_PARSE_JQ's `missing` list (_lib.sh:463-471) validates ref form, body fence, status presence, status enum and reason non-emptiness — and never `by`: not the header attribute (absent captures as \"\"), not the body echo, not the enum. `<!-- phase-outcome-supersede:v1 ref=PO-45-1 -->` with body {\"status\":\"active\",\"reason\":\"x\"} is honoured and annulled_findings[] then carries by: \"\". The asymmetry is the argument: `reason` is body-only and required and IS checked at read time; `by` is body-echoed and required and is not. May be fixed at the schema's read-side gating table and the gate together rather than in code alone.", "requires_product_decision": false }, { "id": "CR-5", "category": "pre-existing", "severity": "medium", "summary": "retry-guard finding_ref normalization is one-sided: stripref runs on the landed record only", "reasoning": "latest_norm applies stripref; incoming_norm applies none. Two directions. (a) Guard miss: the schema explicitly permits passing finding_ref already in F-… form, so a retry after a timeout normalizes the landed side to F#1 while the incoming side stays F-PO-45-2-1 — payloads differ, guard misses, duplicate outcome minted. (b) False absorption: stripref also mangles a CROSS-PO ref, so landed F-PO-45-1-1 becomes F#1 and can equal an incoming placeholder F#1 meaning this PO's own finding 1 — a genuinely new post absorbed as a retry, its decision never recorded. stripref is 0515649, pre-existing; this feature extended the same comparison with roll_call and got the symmetry right there.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-6", "category": "pre-existing", "severity": "low", "summary": "any issue commenter can take an issue's Phase-Outcome reads offline with one malformed supersede", "reasoning": "The write-side bar for annulment is \"can comment on this issue\" — the same bar as posting a Phase Outcome. Combined with the deliberate fail-closed read gates, one comment (edited-after-post, unparseable body, or unknown status) makes read-all hard-refuse for every consumer until a human deletes it. Raised by the Static Security Reviewer, which also judged it consistent with the tamper-evidence tradeoff the repo already accepts for phase-outcome:v1 — the feature replicates a house pattern rather than introducing a failure class, but does widen the surface to a second kind. Fail-OPEN would be strictly worse: it would let a hostile record silently un-annul real corrections. Kept because the precision floor forbids dropping a security-relevant finding at any severity.", "proposed_action": "accept", "fix_cost": "substantial", "adjacent_to_blocking": false, "feature_value": "none", "requires_product_decision": true }, { "id": "CR-7", "category": "in-scope-deferrable", "severity": "low", "summary": "the AC-8 header/body self-check guard is unexercised in both directions, so it could be vacuous undetected", "reasoning": "grep -rn 'header and body disagree' over the whole tree returns ONLY phase-outcome-supersede.sh:193. No suite exercises the guard green or red. Break the self-check jq so $selfcheck is always empty and every post still succeeds with supersede-flow.sh green; break it the other way and the first post fails loudly — so only the dangerous direction is undetectable. This is NOT a restatement of F-PO-45-3-6, which records that the CRITERION is proxied; this records that the GUARD has no observation at all.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-8", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-3's mechanical scan is code-only, narrower than the AC's stated claim", "reasoning": "in_bin() (lint-conventions.sh:223) matches ^[^:]*/bin/[^:]*\\.sh:[0-9]+: so skill markdown and procedure docs are entirely outside the scan, while AC-3 says \"no consumer outside the read helper references the supersede kind\". Argued closed in practice by the pre-existing PRIM_RE routing rule, which already forbids skill markdown from calling scan_comments directly. Not a live hole; the scan's coverage is narrower than its claim.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-9", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16's negation exclusion is tested per PARAGRAPH while the arithmetic is matched per sentence", "reasoning": "PO_ORD_NEG (lint-conventions.sh:514) is evaluated as `low ~ ng` over the whole joined paragraph in flush(). `not` and `instead` are among the commonest words in English, so a paragraph that genuinely prescribes the wrong recipe and happens to contain either anywhere is silently exempted and the scan reports clean. The clause is doing necessary work — five places in this repo write the wrong formula down on purpose — so the question is the SCOPE, not the existence. Raised independently by code-spec-check-r1; the lead had parked the same observation (lead-notes.md L-1) before dispatch and did not feed it to any reviewer.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-10", "category": "in-scope-deferrable", "severity": "low", "summary": "AC-16 scan: non-shell fence CONTENT is judged as prose, and a wrapped statement inside a shell fence is invisible", "reasoning": "$scratch holds only bash|sh|zsh|shell fences (line 133). In pass (b) a ```json fence's markers flush and skip but its content lines are appended into prose paragraph units, so a contiguous json example naming the read helper on one line and carrying `length + 1` on another joins into one unit and false-positives. Conversely pass (a) judges each shell-fence line alone by design, so a fenced recipe wrapped with a backslash continuation is missed — the same wrapped-phrase failure class the rule's own comment cites from CLAUDE.md, reproduced inside the fence dimension. Distinct from CR-9: different mechanism, same scan.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-11", "category": "in-scope-deferrable", "severity": "low", "summary": "--roll-call accepts multi-document JSON; an object slips the type gate and fails late with raw jq stderr", "reasoning": "MEASURED by the lead: printf '{} []' | jq -e 'type==\"array\"' exits 0, because -e reflects the LAST document. jq -c '.' on '[] []' emits two lines, so roll_call_j becomes multi-line, the per-entry and duplicate checks run per-document and pass, and the failure surfaces only at --argjson rc as \"Invalid JSON text\" with jq's exit 2 — coincidentally EXIT_USAGE, but with no helper diagnostic, from a seam whose whole purpose is early named refusals.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-12", "category": "in-scope-deferrable", "severity": "low", "summary": "roll-call agent uniqueness is string-identity only, so the likeliest real duplicate passes", "reasoning": "phase-outcome-post.sh:83-93. \"rev-a\" vs \"rev-a \" (trailing space) and \"Rev-A\" vs \"rev-a\" pass the duplicate gate as distinct agents, and a whitespace-only agent \" \" passes the non-empty check. The schema's \"unique within one roll-call\" is enforceable only up to string identity, and copy-paste with stray whitespace is exactly how a real duplicate arises.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-13", "category": "in-scope-deferrable", "severity": "low", "summary": "a duplicated header attribute resolves silently to the first occurrence", "reasoning": "_lib.sh:446-447. The anchored-header regex permits repeated keys and capture() returns the first match, so `ref=PO-45-1 ref=PO-45-2` is anchored, parses clean, and annuls PO-45-1 while a human reading the comment may believe PO-45-2 was named. Not producible by the write door; hand-posted only. Fail-open on an ambiguous identity claim, inside a function whose design brief is never to honour an untrustworthy record silently.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-14", "category": "in-scope-deferrable", "severity": "low", "summary": "annulment metadata is flat-merged into the finding and can overwrite its own reason/by/comment_id", "reasoning": "phase-outcome-read-all.sh:120 does `. + {reason: $s.reason, by: $s.by, comment_id: $s.comment_id}`. Finding is an open-vocabulary object, so a finding legitimately carrying its own reason/by/comment_id has it silently replaced in annulled_findings[]. A namespaced sub-object would be collision-free. The flat shape matches the schema's wording, so this may be a schema-level choice — the collision is silent either way.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-15", "category": "in-scope-deferrable", "severity": "low", "summary": "supersede.sh asserts on the raw ledger capture but not on the raw post capture", "reasoning": "phase-outcome-supersede.sh:199-203. The read path gets the assert-on-the-raw-capture treatment at line 130; post_out does not. An empty successful post_comment yields cid=\"\" via jq-on-empty-input (exit 0, no output) and reports \"returned no comment_id\" when the true observation is \"returned nothing\". Caught either way, but it collapses \"said nothing\" and \"said JSON without the field\" — the exact distinction CLAUDE.md's WU-47-3-7 learning says to preserve.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-16", "category": "in-scope-deferrable", "severity": "low", "summary": "`// null` on the roll_call projection launders a malformed literal false into \"pre-feature record\"", "reasoning": "phase-outcome-read-all.sh:95-96. (.body.roll_call // null) maps a literal false to null, indistinguishable from a genuine record posted before the field existed. Reader tolerance of ABSENCE is mandated by the schema; silently converting a malformed false into that same answer is a different statement. The repo's own \"// is a falsiness test, not an absence test\" learning is cited elsewhere in this very diff. Practical impact minimal.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-17", "category": "in-scope-deferrable", "severity": "low", "summary": "the json-fence capture regex now exists as three independent copies, two of them new", "reasoning": "_lib.sh:283 (_PO_PARSE_JQ), _lib.sh:448 (_PO_SUP_PARSE_JQ), phase-outcome-supersede.sh:182 (selfcheck) each carry \"(?ms)^```json\\r?\\n(?<j>.*?)\\r?\\n```\" — the expression that already needed a correctness fix once (#57 QA CR-2, line-anchored opener). The next fence-rule fix has three sites and no gate that diffs them. Duplicate-over-reuse; by contrast the foreign-gate near-duplicate inside _po_annulment is documented and justified.", "proposed_action": "defer-to-issue", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-18", "category": "in-scope-deferrable", "severity": "low", "summary": "dangling supersede records enter the resolved map, so \"inert\" is enforced downstream rather than where it is stated", "reasoning": "_lib.sh:540-545. The dangling partition only warns; the final group_by/max_by/select(active) includes dangling refs, so the returned map contains entries for targets that do not exist on this issue. Every current consumer indexes by real ledger ids, so danglings are inert by accident of usage — but the function's contract comment says it returns only currently-annulled targets, and a future consumer that iterates keys or counts size (a cheaper annulled_count, say) would count inert records.", "proposed_action": "defer-to-issue", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" }, { "id": "CR-19", "category": "pre-existing", "severity": "medium", "summary": "phase-outcome-post.sh:224 MISROUTES a failed post: under pipefail a non-JSON adapter failure reports jq's 5, which is EXIT_ADAPTER", "reasoning": "REINSTATED after being wrongly withdrawn by the lead. History matters here because the mechanism does. (a) The Bug Hunter said the primitive rc is always lost — too broad. (b) The lead measured ONE case, generalised from it, and withdrew the finding — wrong. (c) qa-testwriter-r1, writing a stub reproducing the EXACT line-224 shape, found the real mechanism, and the lead then re-measured and confirmed it. pipefail returns the RIGHTMOST non-zero status, so there are two cases: primitive fails emitting NOTHING and jq exits 0 on empty input, giving rc=9, the primitive vocabulary intact (this is the case the lead measured); primitive fails AND writes non-JSON, so jq ALSO fails with a parse error, and the rightmost non-zero is JQ'S 5. Measured: `{ printf \"not json\"; exit 9; } | jq -r .comment_id` under `set -euo pipefail` exits 5. And EXIT_ADAPTER=5 while EXIT_QUERY=9 (_lib.sh:15-21) — so a forge that could not be asked is reported as an adapter that could not be resolved. This is not merely a lost code, it is a MISROUTED one, and it is the identical class as the repo own recorded learning from #47 (`_gl_api … | jq` made a primitive exit with jq 5, colliding with EXIT_ADAPTER, so a correctly-detected auth failure was misrouted as a broken install). That the same defect recurs at post.sh:224 is what makes it worth fixing rather than noting. Severity raised low -> medium on that basis. Scope unchanged: git blame fff5b70, an ancestor of the base, so pre-existing and out of this feature file boundaries.", "proposed_action": "fix-now", "fix_cost": "trivial", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "fix-now" } ], "artifacts": { "report_path": ".devwork/feature-45-phase-outcome-supersede/code-report.md", "journal_path": ".devwork/feature-45-phase-outcome-supersede/code-journal.md", "supersedes": "comment:2581 (itself superseding comment:2578)", "reposted_by": "driver — correcting its OWN v2 withdrawal on evidence produced by qa-testwriter-r1 and then re-measured by the lead" } } ```
Author
Owner
{
  "outcome": "clean",
  "summary": "18 red markers across 4 suites, all verified RED by the DRIVER under sole ownership (rc=0, 0 XPASS on every suite). 16 of 19 findings carry a red marker; CR-7 is a deliberate green coverage test, CR-17 has no test (pure refactor), CR-6 was accepted. One new finding, CR-20, from the driver quiesce check.",
  "findings": [
    {
      "id": "CR-17",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "no regression test is possible for CR-17 — it is a pure refactor with no behavioural difference to assert",
      "reasoning": "Recorded as a phase=tests finding rather than left as a silent gap, which is what this phase of the report is for. CR-17 is the json-fence capture regex existing as three copies instead of one; the defect is duplicate-over-reuse and drift risk, not behaviour, so there is no observable difference for a test to pin. The test writer confirmed this independently and declined to manufacture a test — the right call: a test written to satisfy a per-finding quota is a test that cannot fail. Coverage for CR-17 is the fix itself plus review.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    },
    {
      "id": "CR-19-note",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "CR-19 is verified on an isolated stub of line 224, NOT end-to-end against the real adapter",
      "reasoning": "Disclosed by the test writer and worth carrying forward rather than burying in a suite comment. The real local-fs adapter cannot be made to fail with non-JSON stdout without editing production code, which the tests stage is forbidden from doing. The scenario therefore extracts the exact construct and drives it with a stub. That is a fair proxy and the mechanism is proven, but the round should not claim end-to-end coverage of the live path. The same technique — extracting the real lines by stable text anchor rather than a fixed line range or a hand-copy — is used for CR-15 and CR-7, and it is the right pattern: a hand-copied construct drifts from the code it claims to test.",
      "proposed_action": "accept",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    },
    {
      "id": "CR-13-note",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "CR-13's red test encodes ONE remediation (refuse a duplicated header attribute) that the finding did not prescribe",
      "reasoning": "Flagged by the test writer, and it matters because a red test defines what fixed means. CR-13 says a duplicated ref= attribute resolves silently to the first occurrence and calls that fail-open on an ambiguous identity claim; it does not say whether the fix is to refuse the record or to resolve it deterministically and say so. The test asserts refusal. If the fixer prefers the other remediation, the TEST must be changed deliberately and the change recorded — not quietly worked around. Contrast CR-14, where the test writer avoided this by asserting the finding own reason is discoverable ANYWHERE in the projection rather than pinning a nested shape.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    }
  ],
  "artifacts": {
    "test_files": [
      "plugin/skills/_shared/procedures/test/supersede-read.sh",
      "plugin/skills/_shared/procedures/test/roll-call.sh",
      "plugin/skills/_shared/procedures/test/supersede-flow.sh",
      "scripts/test-lint-conventions.sh"
    ],
    "test_commit": null,
    "test_marker": {
      "runner": "shell harness (bash) — scripts/test-*.sh and plugin/skills/_shared/procedures/test/*.sh",
      "write": "red_scenario \"<ID>\" <scenario_fn>",
      "promote": "swap `red_scenario \"<ID>\" <fn>` for `run_scenario <fn>` — never a bare call"
    },
    "driver_verification": "supersede-read rc=0/7 red/0 xpass; roll-call rc=0/6/0; supersede-flow rc=0/1/0; test-lint-conventions rc=0/4/0. lint-conventions rc=0. test-run-resource-claims rc=1 — exactly one failure, scenario_cr70_cr57_comment_misstates_journal_spelling, the known pre-existing red (F-PO-45-3-1). test-plugin-gates NOT run: it measures HEAD, not the working tree, and HEAD has not moved since develop terminal PO recorded it green — it is owed at the sweep, after the fixes are committed."
  }
}

<!-- qa-report:v1 issue=45 skill=qa domain=code phase=tests --> ```json { "outcome": "clean", "summary": "18 red markers across 4 suites, all verified RED by the DRIVER under sole ownership (rc=0, 0 XPASS on every suite). 16 of 19 findings carry a red marker; CR-7 is a deliberate green coverage test, CR-17 has no test (pure refactor), CR-6 was accepted. One new finding, CR-20, from the driver quiesce check.", "findings": [ { "id": "CR-17", "category": "in-scope-deferrable", "severity": "info", "summary": "no regression test is possible for CR-17 — it is a pure refactor with no behavioural difference to assert", "reasoning": "Recorded as a phase=tests finding rather than left as a silent gap, which is what this phase of the report is for. CR-17 is the json-fence capture regex existing as three copies instead of one; the defect is duplicate-over-reuse and drift risk, not behaviour, so there is no observable difference for a test to pin. The test writer confirmed this independently and declined to manufacture a test — the right call: a test written to satisfy a per-finding quota is a test that cannot fail. Coverage for CR-17 is the fix itself plus review.", "proposed_action": "accept", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept" }, { "id": "CR-19-note", "category": "in-scope-deferrable", "severity": "info", "summary": "CR-19 is verified on an isolated stub of line 224, NOT end-to-end against the real adapter", "reasoning": "Disclosed by the test writer and worth carrying forward rather than burying in a suite comment. The real local-fs adapter cannot be made to fail with non-JSON stdout without editing production code, which the tests stage is forbidden from doing. The scenario therefore extracts the exact construct and drives it with a stub. That is a fair proxy and the mechanism is proven, but the round should not claim end-to-end coverage of the live path. The same technique — extracting the real lines by stable text anchor rather than a fixed line range or a hand-copy — is used for CR-15 and CR-7, and it is the right pattern: a hand-copied construct drifts from the code it claims to test.", "proposed_action": "accept", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept" }, { "id": "CR-13-note", "category": "in-scope-deferrable", "severity": "info", "summary": "CR-13's red test encodes ONE remediation (refuse a duplicated header attribute) that the finding did not prescribe", "reasoning": "Flagged by the test writer, and it matters because a red test defines what fixed means. CR-13 says a duplicated ref= attribute resolves silently to the first occurrence and calls that fail-open on an ambiguous identity claim; it does not say whether the fix is to refuse the record or to resolve it deterministically and say so. The test asserts refusal. If the fixer prefers the other remediation, the TEST must be changed deliberately and the change recorded — not quietly worked around. Contrast CR-14, where the test writer avoided this by asserting the finding own reason is discoverable ANYWHERE in the projection rather than pinning a nested shape.", "proposed_action": "accept", "fix_cost": "trivial", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept" } ], "artifacts": { "test_files": [ "plugin/skills/_shared/procedures/test/supersede-read.sh", "plugin/skills/_shared/procedures/test/roll-call.sh", "plugin/skills/_shared/procedures/test/supersede-flow.sh", "scripts/test-lint-conventions.sh" ], "test_commit": null, "test_marker": { "runner": "shell harness (bash) — scripts/test-*.sh and plugin/skills/_shared/procedures/test/*.sh", "write": "red_scenario \"<ID>\" <scenario_fn>", "promote": "swap `red_scenario \"<ID>\" <fn>` for `run_scenario <fn>` — never a bare call" }, "driver_verification": "supersede-read rc=0/7 red/0 xpass; roll-call rc=0/6/0; supersede-flow rc=0/1/0; test-lint-conventions rc=0/4/0. lint-conventions rc=0. test-run-resource-claims rc=1 — exactly one failure, scenario_cr70_cr57_comment_misstates_journal_spelling, the known pre-existing red (F-PO-45-3-1). test-plugin-gates NOT run: it measures HEAD, not the working tree, and HEAD has not moved since develop terminal PO recorded it green — it is owed at the sweep, after the fixes are committed." } } ```
Author
Owner
{
  "outcome": "clean",
  "summary": "v2 — supersedes 2583, adding artifacts.test_commit, which v1 left null. 18 red markers across 4 suites, all verified RED by the DRIVER under sole ownership (rc=0, 0 XPASS on every suite). 16 of 19 findings carry a red marker; CR-7 is a deliberate green coverage test, CR-17 has no test (pure refactor), CR-6 was accepted. One new finding, CR-20, from the driver quiesce check.",
  "findings": [
    {
      "id": "CR-17",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "no regression test is possible for CR-17 — it is a pure refactor with no behavioural difference to assert",
      "reasoning": "Recorded as a phase=tests finding rather than left as a silent gap, which is what this phase of the report is for. CR-17 is the json-fence capture regex existing as three copies instead of one; the defect is duplicate-over-reuse and drift risk, not behaviour, so there is no observable difference for a test to pin. The test writer confirmed this independently and declined to manufacture a test — the right call: a test written to satisfy a per-finding quota is a test that cannot fail. Coverage for CR-17 is the fix itself plus review.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    },
    {
      "id": "CR-19-note",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "CR-19 is verified on an isolated stub of line 224, NOT end-to-end against the real adapter",
      "reasoning": "Disclosed by the test writer and worth carrying forward rather than burying in a suite comment. The real local-fs adapter cannot be made to fail with non-JSON stdout without editing production code, which the tests stage is forbidden from doing. The scenario therefore extracts the exact construct and drives it with a stub. That is a fair proxy and the mechanism is proven, but the round should not claim end-to-end coverage of the live path. The same technique — extracting the real lines by stable text anchor rather than a fixed line range or a hand-copy — is used for CR-15 and CR-7, and it is the right pattern: a hand-copied construct drifts from the code it claims to test.",
      "proposed_action": "accept",
      "fix_cost": "small",
      "adjacent_to_blocking": false,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    },
    {
      "id": "CR-13-note",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "CR-13's red test encodes ONE remediation (refuse a duplicated header attribute) that the finding did not prescribe",
      "reasoning": "Flagged by the test writer, and it matters because a red test defines what fixed means. CR-13 says a duplicated ref= attribute resolves silently to the first occurrence and calls that fail-open on an ambiguous identity claim; it does not say whether the fix is to refuse the record or to resolve it deterministically and say so. The test asserts refusal. If the fixer prefers the other remediation, the TEST must be changed deliberately and the change recorded — not quietly worked around. Contrast CR-14, where the test writer avoided this by asserting the finding own reason is discoverable ANYWHERE in the projection rather than pinning a nested shape.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    }
  ],
  "artifacts": {
    "test_files": [
      "plugin/skills/_shared/procedures/test/supersede-read.sh",
      "plugin/skills/_shared/procedures/test/roll-call.sh",
      "plugin/skills/_shared/procedures/test/supersede-flow.sh",
      "scripts/test-lint-conventions.sh"
    ],
    "test_commit": "85f32b405bbb686596c55ef043c41d669f923101",
    "test_marker": {
      "runner": "shell harness (bash) — scripts/test-*.sh and plugin/skills/_shared/procedures/test/*.sh",
      "write": "red_scenario \"<ID>\" <scenario_fn>",
      "promote": "swap `red_scenario \"<ID>\" <fn>` for `run_scenario <fn>` — never a bare call"
    },
    "driver_verification": "supersede-read rc=0/7 red/0 xpass; roll-call rc=0/6/0; supersede-flow rc=0/1/0; test-lint-conventions rc=0/4/0. lint-conventions rc=0. test-run-resource-claims rc=1 — exactly one failure, scenario_cr70_cr57_comment_misstates_journal_spelling, the known pre-existing red (F-PO-45-3-1). test-plugin-gates NOT run: it measures HEAD, not the working tree, and HEAD has not moved since develop terminal PO recorded it green — it is owed at the sweep, after the fixes are committed.",
    "note": "test_commit was null on the first post. qa-report.v1.md makes it the BASELINE the fix stage assertion-constraint check compares against, and says that without it the check must report itself not-performed rather than fall back to a working-tree diff — the fallback that produced a false accusation on a real run. Filled before the fixer was dispatched, so the check is well-founded."
  }
}

<!-- qa-report:v1 issue=45 skill=qa domain=code phase=tests --> ```json { "outcome": "clean", "summary": "v2 — supersedes 2583, adding artifacts.test_commit, which v1 left null. 18 red markers across 4 suites, all verified RED by the DRIVER under sole ownership (rc=0, 0 XPASS on every suite). 16 of 19 findings carry a red marker; CR-7 is a deliberate green coverage test, CR-17 has no test (pure refactor), CR-6 was accepted. One new finding, CR-20, from the driver quiesce check.", "findings": [ { "id": "CR-17", "category": "in-scope-deferrable", "severity": "info", "summary": "no regression test is possible for CR-17 — it is a pure refactor with no behavioural difference to assert", "reasoning": "Recorded as a phase=tests finding rather than left as a silent gap, which is what this phase of the report is for. CR-17 is the json-fence capture regex existing as three copies instead of one; the defect is duplicate-over-reuse and drift risk, not behaviour, so there is no observable difference for a test to pin. The test writer confirmed this independently and declined to manufacture a test — the right call: a test written to satisfy a per-finding quota is a test that cannot fail. Coverage for CR-17 is the fix itself plus review.", "proposed_action": "accept", "fix_cost": "trivial", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept" }, { "id": "CR-19-note", "category": "in-scope-deferrable", "severity": "info", "summary": "CR-19 is verified on an isolated stub of line 224, NOT end-to-end against the real adapter", "reasoning": "Disclosed by the test writer and worth carrying forward rather than burying in a suite comment. The real local-fs adapter cannot be made to fail with non-JSON stdout without editing production code, which the tests stage is forbidden from doing. The scenario therefore extracts the exact construct and drives it with a stub. That is a fair proxy and the mechanism is proven, but the round should not claim end-to-end coverage of the live path. The same technique — extracting the real lines by stable text anchor rather than a fixed line range or a hand-copy — is used for CR-15 and CR-7, and it is the right pattern: a hand-copied construct drifts from the code it claims to test.", "proposed_action": "accept", "fix_cost": "small", "adjacent_to_blocking": false, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept" }, { "id": "CR-13-note", "category": "in-scope-deferrable", "severity": "info", "summary": "CR-13's red test encodes ONE remediation (refuse a duplicated header attribute) that the finding did not prescribe", "reasoning": "Flagged by the test writer, and it matters because a red test defines what fixed means. CR-13 says a duplicated ref= attribute resolves silently to the first occurrence and calls that fail-open on an ambiguous identity claim; it does not say whether the fix is to refuse the record or to resolve it deterministically and say so. The test asserts refusal. If the fixer prefers the other remediation, the TEST must be changed deliberately and the change recorded — not quietly worked around. Contrast CR-14, where the test writer avoided this by asserting the finding own reason is discoverable ANYWHERE in the projection rather than pinning a nested shape.", "proposed_action": "accept", "fix_cost": "trivial", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept" } ], "artifacts": { "test_files": [ "plugin/skills/_shared/procedures/test/supersede-read.sh", "plugin/skills/_shared/procedures/test/roll-call.sh", "plugin/skills/_shared/procedures/test/supersede-flow.sh", "scripts/test-lint-conventions.sh" ], "test_commit": "85f32b405bbb686596c55ef043c41d669f923101", "test_marker": { "runner": "shell harness (bash) — scripts/test-*.sh and plugin/skills/_shared/procedures/test/*.sh", "write": "red_scenario \"<ID>\" <scenario_fn>", "promote": "swap `red_scenario \"<ID>\" <fn>` for `run_scenario <fn>` — never a bare call" }, "driver_verification": "supersede-read rc=0/7 red/0 xpass; roll-call rc=0/6/0; supersede-flow rc=0/1/0; test-lint-conventions rc=0/4/0. lint-conventions rc=0. test-run-resource-claims rc=1 — exactly one failure, scenario_cr70_cr57_comment_misstates_journal_spelling, the known pre-existing red (F-PO-45-3-1). test-plugin-gates NOT run: it measures HEAD, not the working tree, and HEAD has not moved since develop terminal PO recorded it green — it is owed at the sweep, after the fixes are committed.", "note": "test_commit was null on the first post. qa-report.v1.md makes it the BASELINE the fix stage assertion-constraint check compares against, and says that without it the check must report itself not-performed rather than fall back to a working-tree diff — the fallback that produced a false accusation on a real run. Filled before the fixer was dispatched, so the check is well-founded." } } ```
Author
Owner

Linked: this issue is sibling #514 (recorded by the devwork pipeline).

Linked: this issue is **sibling** #514 (recorded by the devwork pipeline).
Author
Owner
{
  "outcome": "clean",
  "summary": "All 18 findings fixed, all 18 markers promoted to run_scenario, 0 remaining. Driver-verified at HEAD 2ecdd76 under sole ownership: 9 suites rc=0 with 24 PASS:scenario_cr* lines and zero RED/XPASS/FAIL; lint-conventions clean; test-plugin-gates 17/17 at HEAD; test-run-resource-claims rc=1 with exactly the one known pre-existing failure.",
  "findings": [
    {
      "id": "CR-11-test",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "CR-11's red test could never have gone green as written — its grep read stdin, not the capture",
      "reasoning": "Found and fixed by the fixer, and recorded because it is a near-miss the round should not lose. The assertion was `hit -- '--roll-call' <file>`; `hit` takes its pattern from $1, so it received `--`, grep read that as its own end-of-options marker, took `--roll-call` as the pattern, and with no file argument left read STDIN instead of the capture file. The scenario would have reported RED whatever the helper printed, and would have gone on reporting RED after a correct fix — a test that cannot respond to the code it guards. Now `hit '[-][-]roll-call' <file>`, bracket expressions, which is this project's documented spelling for the same escaping class that bit `awk -v` in CLAUDE.md §Learnings.",
      "proposed_action": "accept",
      "fix_cost": "trivial",
      "adjacent_to_blocking": true,
      "feature_value": "incidental",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    },
    {
      "id": "CR-19-test",
      "category": "in-scope-deferrable",
      "severity": "info",
      "summary": "CR-19's red test asserted on a hand-copied stub, so it was decoupled from the code it claimed to guard",
      "reasoning": "Disclosed by the test writer as a limitation, recorded by the driver as a phase=tests finding, and eliminated by the fixer rather than worked around — the sequence is worth keeping. As written the scenario ran a hand-copy of line 224 inside `bash -c`: no change to phase-outcome-post.sh could turn it green and no regression there could turn it red. It now extracts the real construct by stable text anchors (`^post_out=` through `post_comment returned no comment_id`) and drives it with a primitive that fails at 9 while writing non-JSON. The claim under test is unchanged.",
      "proposed_action": "accept",
      "fix_cost": "small",
      "adjacent_to_blocking": true,
      "feature_value": "core",
      "requires_product_decision": false,
      "applied_disposition": "accept"
    }
  ],
  "artifacts": {
    "fix_commits": [
      "caaa816",
      "2ecdd76"
    ],
    "test_commit": "85f32b4",
    "assertion_changes": "FOUR, each deliberate and named by the fixer: (1) the AC-10(b) tie-break fixture re-planted 9021 -> 10021 (the second half of CR-1, driver-required); (2) CR-11's broken grep pattern; (3) CR-19's hand-copied stub replaced by a real-construct extraction; (4) CR-7's harness now sources the real _lib.sh instead of restating exit-code constants. Verified by diff against test_commit 85f32b4: no assertion was weakened, and every change makes a test MORE able to fail.",
    "driver_verification": "9 suites rc=0 / 24 PASS:scenario_cr* / 0 RED / 0 XPASS / 0 FAIL; lint-conventions rc=0 clean; test-plugin-gates rc=0 17-of-17 at HEAD 2ecdd76; test-run-resource-claims rc=1, one pre-existing failure (scenario_cr70_cr57_comment_misstates_journal_spelling)."
  }
}

<!-- qa-report:v1 issue=45 skill=qa domain=code phase=fix --> ```json { "outcome": "clean", "summary": "All 18 findings fixed, all 18 markers promoted to run_scenario, 0 remaining. Driver-verified at HEAD 2ecdd76 under sole ownership: 9 suites rc=0 with 24 PASS:scenario_cr* lines and zero RED/XPASS/FAIL; lint-conventions clean; test-plugin-gates 17/17 at HEAD; test-run-resource-claims rc=1 with exactly the one known pre-existing failure.", "findings": [ { "id": "CR-11-test", "category": "in-scope-deferrable", "severity": "info", "summary": "CR-11's red test could never have gone green as written — its grep read stdin, not the capture", "reasoning": "Found and fixed by the fixer, and recorded because it is a near-miss the round should not lose. The assertion was `hit -- '--roll-call' <file>`; `hit` takes its pattern from $1, so it received `--`, grep read that as its own end-of-options marker, took `--roll-call` as the pattern, and with no file argument left read STDIN instead of the capture file. The scenario would have reported RED whatever the helper printed, and would have gone on reporting RED after a correct fix — a test that cannot respond to the code it guards. Now `hit '[-][-]roll-call' <file>`, bracket expressions, which is this project's documented spelling for the same escaping class that bit `awk -v` in CLAUDE.md §Learnings.", "proposed_action": "accept", "fix_cost": "trivial", "adjacent_to_blocking": true, "feature_value": "incidental", "requires_product_decision": false, "applied_disposition": "accept" }, { "id": "CR-19-test", "category": "in-scope-deferrable", "severity": "info", "summary": "CR-19's red test asserted on a hand-copied stub, so it was decoupled from the code it claimed to guard", "reasoning": "Disclosed by the test writer as a limitation, recorded by the driver as a phase=tests finding, and eliminated by the fixer rather than worked around — the sequence is worth keeping. As written the scenario ran a hand-copy of line 224 inside `bash -c`: no change to phase-outcome-post.sh could turn it green and no regression there could turn it red. It now extracts the real construct by stable text anchors (`^post_out=` through `post_comment returned no comment_id`) and drives it with a primitive that fails at 9 while writing non-JSON. The claim under test is unchanged.", "proposed_action": "accept", "fix_cost": "small", "adjacent_to_blocking": true, "feature_value": "core", "requires_product_decision": false, "applied_disposition": "accept" } ], "artifacts": { "fix_commits": [ "caaa816", "2ecdd76" ], "test_commit": "85f32b4", "assertion_changes": "FOUR, each deliberate and named by the fixer: (1) the AC-10(b) tie-break fixture re-planted 9021 -> 10021 (the second half of CR-1, driver-required); (2) CR-11's broken grep pattern; (3) CR-19's hand-copied stub replaced by a real-construct extraction; (4) CR-7's harness now sources the real _lib.sh instead of restating exit-code constants. Verified by diff against test_commit 85f32b4: no assertion was weakened, and every change makes a test MORE able to fail.", "driver_verification": "9 suites rc=0 / 24 PASS:scenario_cr* / 0 RED / 0 XPASS / 0 FAIL; lint-conventions rc=0 clean; test-plugin-gates rc=0 17-of-17 at HEAD 2ecdd76; test-run-resource-claims rc=1, one pre-existing failure (scenario_cr70_cr57_comment_misstates_journal_spelling)." } } ```
Sign in to join this conversation.
No description provided.