Phase Outcome ledger: a non-conforming comment is counted as a PO — silent ordinal corruption + opaque read failure #54

Closed
opened 2026-08-22 12:54:40 +00:00 by jbr870 · 1 comment
Owner

Observation

A comment that merely contains phase-outcome:v1 in its first line is absorbed into an issue's
Phase Outcome ledger with no shape validation, and everything downstream then misbehaves — one of
them silently and permanently.

Reproduced for real on #50 (2026-08-22). A UAT closure note was posted with the header
<!-- phase-outcome:v1 scenario=13 --> — no id=, no skill=, no JSON fence. Consequences, in
order of severity:

  1. Silent, permanent ordinal corruption (the dangerous one). phase-outcome-post.sh step 1
    computes n = length(scan) + 1. With the note counted, #50 had 5 real POs and a scan length of
    6, so the next Phase Outcome would have been minted as PO-50-7 — skipping PO-50-6. The
    step-3 collision check cannot catch this (it only checks that the computed id is absent), and
    the resulting gap then trips phase-outcome-read-all.sh's own density check forever after, on
    every subsequent read. Nothing would have pointed at the cause.
  2. Opaque hard failure on read. phase-outcome-read-all.sh --issue 50 exited 5 with a bare jq
    error — jq: error (at <stdin>:38): null (null) only strings can be parsed — naming no comment
    id, no header, and no diagnosis. The parser's hdr("id") returns null for a header with no
    id=, and null | split("-") is what raises it. Everything that funnels through read_all
    dies with it, including /dev:resolve's decision discovery, which is how it surfaced.
  3. Contract §8 immutability could not be honoured on the repair. The only way out was to edit
    the offending comment. That is fine here (it is not a Phase Outcome), but the helpers offer no
    way to say so — the immutability check keys on the scan, so the very thing that made the
    comment visible also made it look protected.

Recurrence

First occurrence, but mechanical: it needs no unusual input, only a comment whose first line
contains the schema token. Every typed header in the contract has the same substring-match
exposure (scan_comments filters on first line starts with <!-- AND contains PATTERN), so this
is a family, not a one-off — decision-resolution:v1, work-unit-outcome:v1 and the rest are
equally reachable. The agent-authored-header path makes it a live risk, not a theoretical one: the
malformed header here was written by an agent (me) reaching for the nearest-looking schema name for
a note that had no schema.

Hypothesis

Three cheap, independent fixes — the first is the one that matters:

  1. phase-outcome-post.sh must derive the ordinal from parsed ids, not from length. Take
    max(id ordinal) + 1 over comments that actually parse as Phase Outcomes, and fail loudly on
    any scanned comment that does not parse. A record-writer must never count something it cannot
    read.
  2. phase-outcome-read-all.sh should reject with a diagnosis, not a jq stack error — name the
    comment id and the missing field ("comment 1067: header has no id=; not a Phase Outcome").
    Ambiguous by construction, so it must be loud: this is exactly the shape the CLAUDE.md baseline
    warns about (a gate whose failure output identifies nothing).
  3. Tighten the header match to the anchored form the schemas actually specify
    (<!-- {schema} id=… skill=… -->) rather than a substring, so a non-conforming comment is never
    collected in the first place. Whether that belongs in scan_comments (adapter tier, affects all
    schemas) or in each skill-facing reader is the design call.

Suggested lane

Full pipeline — helper behaviour across the adapter tier and the skill-facing tier, and it
touches how every typed comment is matched. Closely related to #34 (run-record integrity); found
while confirming #34's UAT-F33.

Filed 2026-08-22 from #50 UAT.

## Observation A comment that merely *contains* `phase-outcome:v1` in its first line is absorbed into an issue's Phase Outcome ledger with no shape validation, and everything downstream then misbehaves — one of them silently and permanently. Reproduced for real on **#50** (2026-08-22). A UAT closure note was posted with the header `<!-- phase-outcome:v1 scenario=13 -->` — no `id=`, no `skill=`, no JSON fence. Consequences, in order of severity: 1. **Silent, permanent ordinal corruption (the dangerous one).** `phase-outcome-post.sh` step 1 computes `n = length(scan) + 1`. With the note counted, #50 had 5 real POs and a scan length of 6, so the next Phase Outcome would have been minted as **`PO-50-7`** — skipping `PO-50-6`. The step-3 collision check cannot catch this (it only checks that the *computed* id is absent), and the resulting gap then trips `phase-outcome-read-all.sh`'s own density check forever after, on every subsequent read. Nothing would have pointed at the cause. 2. **Opaque hard failure on read.** `phase-outcome-read-all.sh --issue 50` exited 5 with a bare jq error — `jq: error (at <stdin>:38): null (null) only strings can be parsed` — naming no comment id, no header, and no diagnosis. The parser's `hdr("id")` returns `null` for a header with no `id=`, and `null | split("-")` is what raises it. Everything that funnels through `read_all` dies with it, including `/dev:resolve`'s decision discovery, which is how it surfaced. 3. **Contract §8 immutability could not be honoured on the repair.** The only way out was to edit the offending comment. That is fine here (it is not a Phase Outcome), but the helpers offer no way to say so — the immutability check keys on the *scan*, so the very thing that made the comment visible also made it look protected. ## Recurrence First occurrence, but mechanical: it needs no unusual input, only a comment whose first line contains the schema token. Every typed header in the contract has the same substring-match exposure (`scan_comments` filters on `first line starts with <!--` AND `contains PATTERN`), so this is a family, not a one-off — `decision-resolution:v1`, `work-unit-outcome:v1` and the rest are equally reachable. The agent-authored-header path makes it a live risk, not a theoretical one: the malformed header here was written by an agent (me) reaching for the nearest-looking schema name for a note that had no schema. ## Hypothesis Three cheap, independent fixes — the first is the one that matters: 1. **`phase-outcome-post.sh` must derive the ordinal from parsed ids, not from `length`.** Take `max(id ordinal) + 1` over comments that actually parse as Phase Outcomes, and fail loudly on any scanned comment that does not parse. A record-writer must never count something it cannot read. 2. **`phase-outcome-read-all.sh` should reject with a diagnosis, not a jq stack error** — name the comment id and the missing field ("comment 1067: header has no `id=`; not a Phase Outcome"). Ambiguous by construction, so it must be loud: this is exactly the shape the CLAUDE.md baseline warns about (a gate whose failure output identifies nothing). 3. **Tighten the header match** to the anchored form the schemas actually specify (`<!-- {schema} id=… skill=… -->`) rather than a substring, so a non-conforming comment is never collected in the first place. Whether that belongs in `scan_comments` (adapter tier, affects all schemas) or in each skill-facing reader is the design call. ## Suggested lane **Full pipeline** — helper behaviour across the adapter tier and the skill-facing tier, and it touches how every typed comment is matched. Closely related to #34 (run-record integrity); found while confirming #34's UAT-F33. *Filed 2026-08-22 from #50 UAT.*
Author
Owner
{"status":"active","into_issue":"57","source_kind":"sibling-stub","absorbed_finding_refs":[],"rationale":"the counting axis of run-record integrity: anchored matching, parsed-ids ordinals, diagnostic refusals all land in #57"}
<!-- promoted-into:v1 issue=54 into_issue=57 --> ```json {"status":"active","into_issue":"57","source_kind":"sibling-stub","absorbed_finding_refs":[],"rationale":"the counting axis of run-record integrity: anchored matching, parsed-ids ordinals, diagnostic refusals all land in #57"} ```
Sign in to join this conversation.
No description provided.