Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ described above).
| `domain-routing-form` | No hardcoded routing form (`domain:<id>` or `skills/<id>/`) for a domain the body does not itself own | body, both populations |
| `phase-0-section` | A `## Phase 0` heading is present | body, skills only |
| `error-handling-section` | A `## Error Handling` heading is present — **the heading only**; what it should cover is author-judged and unenforced (see [Adding a skill](#adding-a-skill)) | body, skills only |
| `hard-failure-block` | The house hard-failure block is present and well-formed: a `Phase N (skill)` headline, then a subject line whose key is one of `Milestone`, `Story`, `Goal`, then `Expected`, `Found`, and an `Action` line carrying the invariant `Pipeline cannot continue. ` prefix | body, skills only |

### claim-lint

Expand Down
23 changes: 21 additions & 2 deletions docs/design/02-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,12 +275,31 @@ parsed by the conductor to halt.

```
HARD FAILURE in Phase {N} ({skill}):
- Story: #{story}
- {Subject}: {identifier}
- Expected: {what should exist}
- Found: {what was actually found}
- Action: Pipeline cannot continue. Fix and re-run.
- Action: Pipeline cannot continue. {remedy}
```

`{Subject}` is an open slot, not a fixed key. It is one of **`Milestone`**,
**`Story`** or **`Goal`** — broadest to narrowest — and only `Story` carries
the `#` sigil, because only a Story is a forge issue. The vocabulary is
exported as `HARD_FAILURE_SUBJECT_KINDS` from `packages/core`, rendered by
`renderHardFailure`, and enforced on every skill body by `skill-lint`'s
`hard-failure-block` rule.

> **This slot was specified as a fixed `- Story: #{story}` until #316.** That
> was unsatisfiable for two shipped skills: `goal-create` runs *before any
> Story exists*, so it had nothing to name there and emitted `- Goal: <id>`;
> `story-create` emitted `- Milestone: <milestone>` for the same reason one
> rung up. Both passed review because the rule meant to police the block
> matched `(?:- .+\n?)+` and never looked at the key. Restated per gate ruling
> **G-a on #47**.

`{remedy}` is the trailing imperative only. The rest of the `Action:` line is
invariant: every copy in the tree preserves `Pipeline cannot continue. ` and
varies only what follows it.

Worked example:

```
Expand Down
193 changes: 193 additions & 0 deletions docs/evidence/316-20260923T131945Z.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
---
issue: 316
repo: dev-pmallapp/iAI
commit: b0616b913dbd466d59cc0cfbbf6b2b5b14c2dee5
branch: task/316-hard-failure-block-emitter
run: 2026-09-23T13:19:45Z
verdict: PASS
---

# Evidence: #316 — The hard-failure block emitter

| Field | Value |
|-------|-------|
| Verdict | **PASS** |
| Task | S2.3.6. Build target **7** of `docs/design/stories/47.md`. Anchors `CLAIM-47.6` |
| Chain | **963 tests / 949 pass / 0 fail / 14 skip** · lint **0 across 98 files** · typecheck clean · skill-lint **4 + 12, 0 errors, 18 rules** · claim-lint **115 files, 0 errors, 1 warning** · both verifiers PASS at **six** · purity **9 pass**, binding sweep **5 pass** |
| `expect()` | **3324 measured at `b0616b9`; 3326 once this artifact lands** — #299, and the delta is the evidence-artifact corpus (`evidence-upsert.test.ts:33-50`), not a rounding. claim-lint likewise goes **115 → 116 files** |
| Baseline | **917 tests / 903 pass / 3243 `expect()`** · lint 0 across **95** files · claim-lint **115 files** at `fcd91c7` |
| Delta | **+46 tests, +81 `expect()`, +3 lint files, +1 skill-lint rule** |
| Mutations | **12 run, 12 killed.** 11 on the first pass; **M5 survived and found a fixture that was passing for the wrong reason** |
| Cases | `docs/test-plans/47-plan.md` case **8 PARTIALLY DISCHARGED** per gate ruling **G-b**, with the carry recorded below |
| Gate rulings | **G-a**, **G-b**, **G-c** — ruled on #47 before any code was written |

> **The commit above is this artifact's PARENT.** An evidence artifact cannot
> carry the hash of the commit that contains it. The figures were measured on
> `b0616b9`; the two `expect()` calls and one claim-lint file this file itself
> adds are stated rather than measured, per #299.

## The task began by finding its own issue body wrong

#316 said, of the hard-failure block: *"No module emits it, no lint rule
requires it, and nothing parses it."* **Two of those three were false against
the tree**, and the second one mattered enough to have wasted the task.

| #316 said | Measured |
|---|---|
| *"No module emits it"* | `packages/core/src/guards/risk-mandate.ts:48-56` emitted one — a private `hardFailure()`, the fifth copy in the tree and the only one in TypeScript |
| *"no lint rule requires it"* | `test/skill-lint.test.ts:1369` **required** the block in every real skill body, and `:1442-1459` asserted `- Expected:` and `- Found: none` inside it |
| *"seven hand-copies"* | **Ten copies exist; five are in scope.** Four prose bodies + `risk-mandate.ts`. Out of scope: four illustrative copies in design docs and one **immutable** copy in `docs/evidence/19-*.md` |

**The consequence was not cosmetic.** #316's first Done-when bullet proposed
reconciling the four prose bodies to *cite* the block rather than restate it.
`skill-lint.test.ts:1362` loops the whole real corpus, so that change turns all
four red at once — and those assertions encode **CLAIM-41.5 and case 12 of
`docs/test-plans/41-plan.md`**, a shipped plan from a **closed Story** with
committed evidence at `docs/evidence/44-*.md:19`. Weakening them would have
retro-invalidated a discharged claim.

The bullet's *second* branch was taken instead — the bodies keep restating the
block — and ruling **G-a** is the recorded reason. **The issue body was
corrected before work started**, not after.

> **A premise stated in an issue body is not a measurement, and this one had
> been read by everybody who queued the task.** The only thing that caught it
> was resolving each clause against the tree before believing it. #304's class,
> at the level of the work order rather than the citation.

## What shipped

**`packages/core/src/guards/hard-failure.ts`** — the spec, once.
`HARD_FAILURE_SUBJECT_KINDS`, `HARD_FAILURE_ACTION_PREFIX`,
`HARD_FAILURE_HEADLINE_PREFIX`, `HARD_FAILURE_FIELD_ORDER`, and
`renderHardFailure`. Pure: no fs, no process, no clock.

**One spec, two enforcement directions.** The renderer produces conformant
blocks; skill-lint's new **`hard-failure-block`** rule validates prose against
**the same exported constants**. That is the part worth copying: a renderer and
a validator that agree only by coincidence are two contracts, which is the
shape this task existed to remove.

**`risk-mandate.ts`'s private copy is deleted and rewired**, and
`packages/core/test/risk-mandate.test.ts` **was not touched**. It pins the
output string verbatim, line for line, so leaving it alone is what makes it a
regression guard rather than a co-conspirator. It stayed green throughout;
mutation **M12** confirms it is load-bearing.

**`checkEvidenceMandate`** (`evidence-mandate.ts`) is the reader the doctrine at
`references/evidence-artifacts.md:140-144` never had — *"a `status:resolved`
with no artifact behind it is a claim, not a result"* — and the refusal says so
in its own Action line.

## The ruling that made the task shippable at all

**Build target 4's claim-to-artifact resolver cannot be built as the Design
specifies**, and case 8 depends on it. Measured over the 54 artifacts:

- **21 carry no `| Claims |` row at all**
- the other **33** use at least **five mutually incompatible notations** —
`…`, `–` with an abbreviated endpoint, `..`, spaced `–`, and partial-coverage
qualifiers — plus the retired `ISC-*` namespace
- **#287 has two artifacts**, so the mapping is not 1:1
- **`docs/evidence/` is immutable** (NEVER-194.6), so **the gaps can never be
backfilled and the notations can never be normalised**

Which leaves exactly one sound direction:

> **SOUND** — a claim id appearing in **zero** artifacts is certainly uncovered.
> **UNSOUND** — a claim id appearing somewhere is therefore covered.

**`CLAIM-47.6` refuses, and a refusal needs only the sound direction.** So the
refusal ships as a pure function over a **supplied** resolution and **exports no
resolver**. Raised as **#341**; case 8 recorded **partially discharged** under
**G-b**.

**The carry:** build target 4 owns the positive direction, and #341 owns the
ruling on whether it is reconstructed from the Design/plan side instead of from
the artifacts.

**The seam is asserted statically**, because behaviourally it is invisible: a
future edit making `evidence-mandate.ts` read the corpus itself would return
identical verdicts for every input the suite supplies, while silently adopting
the unsound direction. The test reads the module's own import list and requires
it to mention no `node:fs`, no `node:path`, no `readdirSync`, no `readFileSync`
and no `Bun.file`, with the extraction asserted non-empty first so a failed read
cannot pass vacuously. **#322's M11 is the precedent** — a behaviourally
equivalent mutation can only be killed statically.

## The mutation round: 12 run, 12 killed

| | Mutation | Result |
|---|---|---|
| M1 | `Story` loses its `#` sigil | KILLED |
| M2 | blank fields accepted | KILLED — 8 fail |
| M3 | newline injection accepted | KILLED |
| M4 | vocabulary order changed | KILLED |
| **M5** | **Action invariant prefix no longer compared** | **SURVIVED, then killed — see below** |
| M6 | subject vocabulary no longer checked | KILLED |
| M7 | only the FIRST block validated | KILLED |
| M8 | rule reads the stripped body, not the raw one | KILLED — 36 fail **and** `skill-lint` exit 1 |
| M9 | blank `Expected` accepted | KILLED |
| M10 | constant refusal: every task blocked | KILLED |
| M11 | refusal stops recording absence as `none` | KILLED |
| M12 | rewired `risk-mandate` drifts from the pinned string | KILLED |

### M5 is the whole return on the round

M5 deleted the Action line's invariant check outright —
`startsWith(HARD_FAILURE_ACTION_PREFIX)` became `startsWith("- Action:")` — and
**the suite stayed green.**

The negative fixture written specifically to pin that check *did* fail the
mutant, but **for the wrong reason**. Its Action line, `- Action: Fix and
re-run.`, is **shorter than the 36-character prefix**, so the surviving
`.slice(PREFIX.length)` ran off the end of the string and yielded `""` — which
the *blank-remainder* branch rejected regardless of whether the prefix was ever
compared.

> **A length accident was standing in for a content comparison, and every
> assertion in the test still read as though the comparison were pinned.**

The added fixture miscases one word (`Pipeline cannot CONTINUE.`) and keeps the
line long enough that the slice yields a non-blank remainder either way, so the
**only** thing that can reject it is a real comparison against the invariant.
The test now also asserts the line is longer than the prefix and its remainder
non-blank, **so the accident cannot come back silently.**

This is the class recorded in `docs/evidence/33-*.md` and re-hit in #315: **two
assertions that differ only in a way the code never reads are one assertion.**
Third occurrence in this lineage, and the second time it was found by a
mutation rather than by review.

## What this task does not close

- **Case 8's positive direction** — carried to build target 4 under **G-b**,
blocked on **#341**. It is not claimed here.
- **`CLAIM-47.6` is anchored, not fully discharged.** The refusal is proven; the
resolution that feeds it has no production caller yet. `task-verify` (#50) is
the first, and it must supply the resolution rather than compute it here.
- **#330 is still unpaid.** No `gh` argv this repository builds has ever been
executed, and this task added none.
- **The four prose bodies were deliberately not reconciled** — G-a, and the
reason is recorded in `hard-failure.ts`'s header where the next person to
"finish the job" will read it.

## Verification chain, as run at `b0616b9`

```
bun install
bun test # 963 tests / 949 pass / 0 fail / 14 skip / 3324 expect()
bun run lint # 0 violations across 98 files
bun run typecheck # clean
bun run skill-lint # 4 SKILL.md + 12 contract files, 0 errors, 0 warnings
bun run claim-lint # 115 files, 0 errors, 1 warning
IAI_PURITY_CHILD=1 bun test packages/core/test/purity.test.ts # 9 pass
IAI_BINDING_SWEEP_CHILD=1 bun test packages/core/test/binding-purity.test.ts # 5 pass
bash scripts/verify-required-checks.sh # PASS, six contexts
bash scripts/verify-workflow-hygiene.sh # PASS
```

**The 1 claim-lint warning is expected and must stay a warning** —
`docs/design/stories/9.md` predates the metadata convention (ruling G1b of #41).

**There is no `seam` job on `story/47`**, so this PR reports **7 checks, not 8**.
94 changes: 94 additions & 0 deletions packages/core/src/guards/evidence-mandate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { decide, type Decision } from "../decision";
import { renderHardFailure } from "./hard-failure";

// CLAIM-47.6's refusal: task-verify does not resolve a task whose anchored
// CLAIM-{story}.{n} has no artifact on disk.
//
// The doctrine is references/evidence-artifacts.md:140-144 — "A
// `status:resolved` with no artifact behind it is a claim, not a result." It
// had no reader (Problem 6 of docs/design/stories/47.md); this is the reader.
//
// ============================================================================
// THIS MODULE RESOLVES NOTHING, AND THAT IS THE DESIGN, NOT AN OMISSION
// ============================================================================
//
// It takes an ALREADY-DETERMINED resolution and decides what to do about it.
// It does not enumerate docs/evidence/, does not parse a Claims row, and does
// not decide whether a claim is covered. The seam is the one
// risk-mandate.ts:58-60 already documents for the same reason: "`story`
// identifies the Story for the block message only; this function reads nothing
// about it."
//
// The reason is specific and is recorded in #341. Build target 4's
// claim-to-artifact resolver CANNOT be built as the Design specifies, because
// the corpus will not support it: of the 54 artifacts in docs/evidence/, 21
// carry no `| Claims |` row at all and the other 33 use at least five mutually
// incompatible notations, plus a retired `ISC-*` namespace. docs/evidence/ is
// immutable (NEVER-194.6), so neither the gaps nor the notations can ever be
// normalised.
//
// Only ONE DIRECTION is sound over such a corpus:
//
// SOUND — a claim id appearing in ZERO artifacts is certainly uncovered.
// UNSOUND — a claim id appearing somewhere is therefore covered. This needs
// range expansion across five notations and cannot be had.
//
// A refusal needs only the sound direction, which is why CLAIM-47.6 can ship
// while build target 4 is blocked. Gate ruling G-b on #47 records case 8 as
// PARTIALLY discharged on exactly this basis, carrying the positive direction
// to build target 4.
//
// So: if a future change makes this module read the corpus itself, it has
// silently adopted the unsound direction and pre-empted a ruling that has not
// been made. Supply the resolution; do not compute it here.

// What the caller determined about the anchored claim's artifact.
//
// `absent` is deliberately NOT "not found" — the distinction matters. A caller
// that could not look (a read failed, a directory was unreadable) has NOT
// established absence, and must not report `absent`, because the refusal below
// is indistinguishable from the one earned by a genuinely missing artifact.
// Fail the read loudly instead; do not launder it into a verdict.
export type EvidenceResolution =
| { readonly kind: "present"; readonly artifact: string }
| { readonly kind: "absent" };

export interface EvidenceMandateInput {
// The task issue being verified, for the block's subject line.
readonly task: string | number;
// The Story the task belongs to, named in the refusal's prose.
readonly story: string | number;
// The claim the task is anchored to, e.g. "CLAIM-47.6".
readonly claim: string;
readonly resolution: EvidenceResolution;
}

// Phase 7 is task-verify (docs/design/03-workflow.md:21).
const TASK_VERIFY_PHASE = 7;
const TASK_VERIFY_SKILL = "task-verify";

export function checkEvidenceMandate(input: EvidenceMandateInput): Decision {
if (input.resolution.kind === "present") {
return decide(
"allow",
`Task #${String(input.task)} is anchored to ${input.claim}, and ` +
`${input.resolution.artifact} is on disk behind it`,
);
}

return decide(
"block",
renderHardFailure({
phase: TASK_VERIFY_PHASE,
skill: TASK_VERIFY_SKILL,
subject: { kind: "Story", value: input.story },
expected: `an evidence artifact on disk for ${input.claim}, anchored by task #${String(input.task)}`,
found: "none",
// The doctrine, said in the block rather than only in the reference, so
// the person who reads the refusal reads the reason with it.
remedy:
"A status:resolved with no artifact behind it is a claim, not a result. " +
"Run the verification, write the artifact, and re-run.",
}),
);
}
Loading
Loading