diff --git a/CLAUDE.md b/CLAUDE.md
index 5af3b55..c0a15a8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,4 +32,4 @@ The oracle cases (vignette §9, `O-1…O-4`) are hand-computed and must never be
## Current phase (update this line when it changes)
-Stage 0 complete; PR #8 merged to `main`. Register batch 3 ratified (ASSAY-DEC-22…27, closing concept §6.6–6.11) — walkthrough and findings ledger now canonical, G6 a standing invariant. Stage 1 built (issue #10): research note `01-knowledge.md` (DEC-16 floor); SPEC-05 implemented (`src/{seam,deltas,encoding,lint,knowledge}.ts` + `components/{refusalBanner,s1Table}.ts`); all four Stage-1 exits demonstrated on Meridian. Stage 2 built: research note `docs/research/02-compile.md` (DEC-11 gate — decides the **sparse channel representation** from the Stage-0 dense-world numbers + ATP 2-01.3 MCOO doctrine, retiring dense `Channel.cells`); SPEC-06 specified via spec-kit (`specs/006-compile/`) and **implemented** (`src/compile.ts` + `components/channelTrace.ts`; `fixtures/vignette-config.json`; sparse-channel schema regen; 107 tests, typecheck clean; compile demo moment renders in the gallery). All three Stage-2 exits demonstrated on Meridian (K12 contested ⇒ compile refuses `contested_knowledge`, resolve⇒recompile; byte-identical stamp; every channel traces back to named knowledge via `compiled_into` + `source`). The sparse-channel schema change is recorded as a flagged register candidate (concept §6, item 12), built under seam open item 2's delegated authority pending batch ratification. Stage 3 scorer built: research note `docs/research/03-score-plan.md` (DEC-11 gate — **decides the four-stop verdict mapping as signed margin bands, signs-only**: robust iff m_lo>0, marginal iff m_lo=0, tight iff m_lo<0≤m_hi, violated iff m_hi<0 — the unique O-3-satisfying rule with no interior cut; also settles `(cell,time)` route evaluation over a time-expanded graph, closing note 02 §3's forward flag, and reserves ε-dominance for SPEC-08 plan comparison); SPEC-07 specified via spec-kit (`specs/007-score/`) and **implemented** (`src/{interval,materialise,metrics,score}.ts` + `components/s2Matrix.ts`; `fixtures/plans.json` canned handful; no schema change — verdict/score/plan shapes already exist; 139 tests, typecheck clean; the honest-matrix demo moment renders in the gallery). The correctness leg holds on Meridian: oracle O-1 (`[9,13]`), O-2 (robust, margin `[15,19]`), O-3 (four-stop sweep changes only at band edges 9/13) reproduced exactly, and O-4 propagation-honesty (G6) under property-based testing (`fast-check`). SPEC-07 asserts no new register decision (the mapping is decided in the research note under the latitude vignette §9/O-3 delegated to Stage-3). **Next: SPEC-08 — handful generator + banded non-dominated organisation (sacrificial scope, delivery plan §3), over the same `03-score-plan.md` note; then Stage 4 SPEC-09 relaxation.**
+Stage 0 complete; PR #8 merged to `main`. Register batch 3 ratified (ASSAY-DEC-22…27, closing concept §6.6–6.11) — walkthrough and findings ledger now canonical, G6 a standing invariant. Stage 1 built (issue #10): research note `01-knowledge.md` (DEC-16 floor); SPEC-05 implemented (`src/{seam,deltas,encoding,lint,knowledge}.ts` + `components/{refusalBanner,s1Table}.ts`); all four Stage-1 exits demonstrated on Meridian. Stage 2 built: research note `docs/research/02-compile.md` (DEC-11 gate — decides the **sparse channel representation** from the Stage-0 dense-world numbers + ATP 2-01.3 MCOO doctrine, retiring dense `Channel.cells`); SPEC-06 specified via spec-kit (`specs/006-compile/`) and **implemented** (`src/compile.ts` + `components/channelTrace.ts`; `fixtures/vignette-config.json`; sparse-channel schema regen; 107 tests, typecheck clean; compile demo moment renders in the gallery). All three Stage-2 exits demonstrated on Meridian (K12 contested ⇒ compile refuses `contested_knowledge`, resolve⇒recompile; byte-identical stamp; every channel traces back to named knowledge via `compiled_into` + `source`). The sparse-channel schema change is recorded as a flagged register candidate (concept §6, item 12), built under seam open item 2's delegated authority pending batch ratification. Stage 3 scorer built: research note `docs/research/03-score-plan.md` (DEC-11 gate — **decides the four-stop verdict mapping as signed margin bands, signs-only**: robust iff m_lo>0, marginal iff m_lo=0, tight iff m_lo<0≤m_hi, violated iff m_hi<0 — the unique O-3-satisfying rule with no interior cut; also settles `(cell,time)` route evaluation over a time-expanded graph, closing note 02 §3's forward flag, and reserves ε-dominance for SPEC-08 plan comparison); SPEC-07 specified via spec-kit (`specs/007-score/`) and **implemented** (`src/{interval,materialise,metrics,score}.ts` + `components/s2Matrix.ts`; `fixtures/plans.json` canned handful; no schema change — verdict/score/plan shapes already exist; 139 tests, typecheck clean; the honest-matrix demo moment renders in the gallery). The correctness leg holds on Meridian: oracle O-1 (`[9,13]`), O-2 (robust, margin `[15,19]`), O-3 (four-stop sweep changes only at band edges 9/13) reproduced exactly, and O-4 propagation-honesty (G6) under property-based testing (`fast-check`). SPEC-07 asserts no new register decision (the mapping is decided in the research note under the latitude vignette §9/O-3 delegated to Stage-3). Stage 3 handful built: SPEC-08 specified via spec-kit (`specs/008-handful/`) and **implemented** (`src/{dominance,generate,handful}.ts` + `components/handfulStrip.ts`; new seam types; no schema change) over the same `03-score-plan.md` note (§5 — the DEC-11 gate, deciding **banded ε-non-domination** and Meridian's **four generation axes**). `/plan/handful` = generate (seeded fan-out over approach × suppression × causeway × extraction) → score (reuses the SPEC-07 scorer) → organise (ε-non-domination on the conservative interval order, ε defaults to 0, diversity cap by axis Hamming — never a scalar, DEC-19). Both Stage-3 handful exits demonstrated on Meridian: `/plan/handful` returns **4 genuinely distinct plans** for the base world (each pair mutually non-dominated; the survivors split cleanly along the two trade axes — approach trades C1↔C2, suppression trades C3↔C4), and **same stamp + seed ⇒ identical handful** (G1); 163 tests, typecheck clean; the honest-matrix demo moment now renders the *generated* handful plus a derived `distinct_because` strip (organised, not authored). SPEC-08 asserts no new register decision (the concept §6.2 handful-axes open question is closed by the research note §5.2 under Stage-3 latitude). **Next: Stage 4 SPEC-09 — relaxation / least-worst (`/relax`, `sacrificed` populated, invariant G4), over research note `04-relaxation.md` (DEC-11 gate — to author); then the Stage 4–6 depth fan-out.**
diff --git a/docs/assay-delivery-plan.md b/docs/assay-delivery-plan.md
index 843a2d4..a92a4e3 100644
--- a/docs/assay-delivery-plan.md
+++ b/docs/assay-delivery-plan.md
@@ -63,6 +63,8 @@ Stage column maps to `assay-build-plan.md`. Research note is the DEC-11 prerequi
| **SPEC-15** | Spine-complete gate harness — asserts content-addressing, stamp determinism, and invariants G2–G5 end-to-end on Meridian; re-asserts the vignette §9 oracle cases and the O-4 propagation-honesty property (candidate G6) | gate | SPEC-05…13 | — |
| **SPEC-16** | Surfaces S1–S4 as config-declared bundles + five narrative scripts + banded-honesty polish pass | 7 | SPEC-14, SPEC-15, services | `07-narratives.md` |
+> **Status (2026-07-13, spine through Stage 3):** SPEC-01…04 (Stage 0) and SPEC-05/06/07/08 (Stages 1–3) built and merged/branch-open. The spine `δ` (SPEC-05 → SPEC-06 → SPEC-07 → SPEC-08) is complete: the scorer stands and the honest generator organises a computed handful over it. SPEC-08's *sacrificial-scope* latitude was spent — the canned fallback (`fixtures/plans.json`) is retained for the SPEC-07 suite; the gallery's honest matrix now runs the generated handful. **Next: the depth fan-out `ε` (SPEC-09…13) opens on the landed scorer.**
+
## 3. Dependency graph & critical path
```mermaid
diff --git a/docs/assets/gallery/index.html b/docs/assets/gallery/index.html
index 03a4c35..11455b8 100644
--- a/docs/assets/gallery/index.html
+++ b/docs/assets/gallery/index.html
@@ -526,17 +526,48 @@
Compile — knowledge → CompiledWor
| K12a | assessed · moderateassessment, not factowner: J-2 red cell |
-Score — the honest matrix (SPEC-07)
-
The Stage-3 demo moment, run by the actual scorer over the canned Meridian handful against the base world. Verdicts are a four-stop scale — robust / marginal / tight / violated, one colour language, no decimals anywhere; the banded margin rides on hover, never as a headline number (G2). Each commitment's verdict propagates its metric by interval arithmetic — worst-and-best-case on pure bands, no midpoint (DEC-15) — and is validated by the vignette §9 oracle cases (O-1–O-3 exact, O-4 containment). Pick a verdict, walk it to the assessment and owner it rests on: every cell was written with a scored_from edge back to the world, whose channels trace to named knowledge above. score stamp c5e11b4a027f1053… · every verdict carries a scored_from edge to the world (G3) · margins on hover, no decimals (G2)
+Score & organise — the honest matrix (SPEC-07 · SPEC-08)
+The Stage-3 demo moment, run end-to-end by the actual machinery: /plan/handful (SPEC-08) fans out over Meridian's four axes — approach, suppression, causeway, extraction — scores every candidate with the SPEC-07 scorer, and organises by banded non-domination into the genuinely distinct plans below. No author picked the set: a plan is kept only when no other conservatively beats it on every commitment (overlapping bands are honestly incomparable and both survive), and dropped only when strictly beaten everywhere — never on a scalar total (DEC-19). Each plan's reason is derived from the organiser, not captioned:
+
+-
+
+ P-SwSoCcXe
+ sweep-first · stand-off · contest · pull-early
+
+ sweep-first · stand-off · contest · pull-early — leads the handful on C1, C3; non-dominated (no plan beats it on every commitment).
+
+-
+
+ P-SeFfCcXe
+ strait-early · fires-forward · contest · pull-early
+
+ strait-early · fires-forward · contest · pull-early — leads the handful on C2, C4; non-dominated (no plan beats it on every commitment).
+
+-
+
+ P-SeSoCcXe
+ strait-early · stand-off · contest · pull-early
+
+ strait-early · stand-off · contest · pull-early — leads the handful on C2, C3; non-dominated (no plan beats it on every commitment).
+
+-
+
+ P-SwFfCcXe
+ sweep-first · fires-forward · contest · pull-early
+
+ sweep-first · fires-forward · contest · pull-early — leads the handful on C1, C4; non-dominated (no plan beats it on every commitment).
+
+
+The same handful, as the S2 planner matrix. Verdicts are a four-stop scale — robust / marginal / tight / violated, one colour language, no decimals anywhere; the banded margin rides on hover, never a headline number (G2). Each verdict propagates its metric by interval arithmetic — worst-and-best-case on pure bands, no midpoint (DEC-15) — validated by the vignette §9 oracle cases (O-1–O-3 exact, O-4 containment). Pick a verdict, walk it to the assessment and owner it rests on: every cell was written with a scored_from edge back to the world, whose channels trace to named knowledge above. handful stamp 99ca45289c4582da… · 4 genuinely distinct plans, organised not authored · same stamp + seed ⇒ identical handful (G1) · every verdict carries a scored_from edge to the world (G3)
| plan \ commitment |
C1 | C2 | C3 | C4 | C5 | C6 |
- | P1 · Strait-early / battery-suppression |
- robust
+ |
| P-SwSoCcXe · sweep-first · stand-off · contest · pull-early |
+ robust
|
- robust
+ robust
|
marginal
|
@@ -544,11 +575,35 @@ Score — the honest matrix (SPEC-07)
|
marginal
|
- robust
- |
| P2 · Sweep-first / fires-forward |
+ robust
+ |
| P-SeFfCcXe · strait-early · fires-forward · contest · pull-early |
robust
|
+ robust
+ |
+ violated
+ |
+ robust
+ |
+ marginal
+ |
+ robust
+ |
| P-SeSoCcXe · strait-early · stand-off · contest · pull-early |
robust
+ |
+ robust
+ |
+ marginal
+ |
+ tight
+ |
+ marginal
+ |
+ robust
+ |
| P-SwFfCcXe · sweep-first · fires-forward · contest · pull-early |
+ robust
+ |
+ robust
|
violated
|
@@ -556,7 +611,7 @@ Score — the honest matrix (SPEC-07)
|
marginal
|
- robust
+ robust
|
Generated from fixtures/ by npm run gallery · identifiers frozen per assay-vignette.md §8 · compile per research note docs/research/02-compile.md · score per docs/research/03-score-plan.md
diff --git a/scripts/build-gallery.ts b/scripts/build-gallery.ts
index 1062baa..7d88d67 100644
--- a/scripts/build-gallery.ts
+++ b/scripts/build-gallery.ts
@@ -17,11 +17,13 @@ import { s1Table, type S1Row } from '../src/components/s1Table.js';
import { channelTrace } from '../src/components/channelTrace.js';
import { refusalBanner } from '../src/components/refusalBanner.js';
import { s2Matrix, type S2Cell } from '../src/components/s2Matrix.js';
+import { handfulStrip, type HandfulStripRow } from '../src/components/handfulStrip.js';
import { checkEncoding } from '../src/encoding.js';
import { confidenceLint } from '../src/lint.js';
import { KnowledgeService } from '../src/knowledge.js';
import { CompileService } from '../src/compile.js';
import { ScoreService } from '../src/score.js';
+import { HandfulService } from '../src/handful.js';
import { isRefusal } from '../src/seam.js';
import type {
Commitment,
@@ -41,9 +43,6 @@ const coas = JSON.parse(
const commitments = JSON.parse(
readFileSync(new URL('../fixtures/commitments.json', import.meta.url), 'utf8'),
) as Commitment[];
-const plans = JSON.parse(
- readFileSync(new URL('../fixtures/plans.json', import.meta.url), 'utf8'),
-) as Plan[];
const vignetteConfig = JSON.parse(
readFileSync(new URL('../fixtures/vignette-config.json', import.meta.url), 'utf8'),
) as VignetteConfig;
@@ -119,33 +118,38 @@ const stampLine =
? `stamp ${compiled.stamp.slice(0, 16)}… · ${world.consumed.length} knowledge objects consumed · sparse channels (no dense cell stored)`
: '';
-// SPEC-07 — the Stage-3 demo moment, "the honest matrix", run by the ACTUAL
-// scorer over the canned Meridian handful (P1/P2) against the resolved base
-// world. Four-stop chips, margin bands on hover, no decimals; every verdict was
-// written with a `scored_from` edge back to the world it rests on (G3).
+// SPEC-07/08 — the Stage-3 demo moment, "the honest matrix", now run over the
+// GENERATED handful (SPEC-08) rather than a hand-authored one. /plan/handful
+// fans out over Meridian's four axes, scores each candidate with the ACTUAL
+// scorer, and organises by banded non-domination into 3–5 genuinely distinct
+// plans — no author picked the set. The S2 matrix renders the survivors as
+// four-stop chips (margins on hover, no decimals); the handful strip shows the
+// organiser's derived reason each plan is in the set.
for (const c of commitments) await svc.store.put(c as unknown as Record);
-const planRefs = new Map();
-for (const p of plans) planRefs.set(p.logical_id, await svc.store.put(p as unknown as Record));
const scorer = new ScoreService({ store: svc.store, trace: svc.trace, config: vignetteConfig, commitments });
+const handfulSvc = new HandfulService({ store: svc.store, scorer, config: vignetteConfig, commitments });
const matrixRows: S2Cell[] = [];
-let scoreStamp = '';
+const stripRows: HandfulStripRow[] = [];
+let handfulStamp = '';
if (!isRefusal(compiled)) {
- for (const p of plans) {
- const scored = await scorer.score({
- plan: planRefs.get(p.logical_id)!,
- world: compiled.world,
- scenario: 'BASE',
- engine_version: '0.1.0',
+ const h = await handfulSvc.handful({ world: compiled.world, seed: 1, engine_version: '0.1.0' });
+ if (!isRefusal(h)) {
+ handfulStamp = h.stamp;
+ h.plans.forEach((planRef, i) => {
+ const plan = svc.store.get(planRef.content_hash) as Plan;
+ stripRows.push({ plan: planRef, name: plan.name, distinct_because: h.organisation.distinct_because[i]! });
});
- if (!isRefusal(scored)) {
- matrixRows.push({ plan: `${p.logical_id} · ${p.name}`, verdicts: scored.verdicts });
- scoreStamp = scored.stamp;
+ for (const planRef of h.plans) {
+ const scored = await scorer.score({ plan: planRef, world: compiled.world, scenario: 'BASE', engine_version: '0.1.0' });
+ const plan = svc.store.get(planRef.content_hash) as Plan;
+ if (!isRefusal(scored)) matrixRows.push({ plan: `${plan.logical_id} · ${plan.name}`, verdicts: scored.verdicts });
}
}
}
const s2 = matrixRows.length > 0 ? s2Matrix(['C1', 'C2', 'C3', 'C4', 'C5', 'C6'], matrixRows) : '';
-const scoreStampLine = scoreStamp
- ? `score stamp ${scoreStamp.slice(0, 16)}… · every verdict carries a scored_from edge to the world (G3) · margins on hover, no decimals (G2)`
+const strip = stripRows.length > 0 ? handfulStrip(stripRows) : '';
+const scoreStampLine = handfulStamp
+ ? `handful stamp ${handfulStamp.slice(0, 16)}… · ${matrixRows.length} genuinely distinct plans, organised not authored · same stamp + seed ⇒ identical handful (G1) · every verdict carries a scored_from edge to the world (G3)`
: '';
const html = `
@@ -170,8 +174,10 @@ ${rows}
${compileRefusalHtml}
Resolve the contest, and the recompile builds the world. Channels are stored sparse — a per-channel default plus named region overrides (the MCOO idiom; no dense 1.2M-cell surface is ever stored or hashed). Every channel value walks back to the named knowledge it came from, with its owner (G3). ${stampLine}
${channelHtml}
-Score — the honest matrix (SPEC-07)
-The Stage-3 demo moment, run by the actual scorer over the canned Meridian handful against the base world. Verdicts are a four-stop scale — robust / marginal / tight / violated, one colour language, no decimals anywhere; the banded margin rides on hover, never as a headline number (G2). Each commitment's verdict propagates its metric by interval arithmetic — worst-and-best-case on pure bands, no midpoint (DEC-15) — and is validated by the vignette §9 oracle cases (O-1–O-3 exact, O-4 containment). Pick a verdict, walk it to the assessment and owner it rests on: every cell was written with a scored_from edge back to the world, whose channels trace to named knowledge above. ${scoreStampLine}
+Score & organise — the honest matrix (SPEC-07 · SPEC-08)
+The Stage-3 demo moment, run end-to-end by the actual machinery: /plan/handful (SPEC-08) fans out over Meridian's four axes — approach, suppression, causeway, extraction — scores every candidate with the SPEC-07 scorer, and organises by banded non-domination into the genuinely distinct plans below. No author picked the set: a plan is kept only when no other conservatively beats it on every commitment (overlapping bands are honestly incomparable and both survive), and dropped only when strictly beaten everywhere — never on a scalar total (DEC-19). Each plan's reason is derived from the organiser, not captioned:
+${strip}
+The same handful, as the S2 planner matrix. Verdicts are a four-stop scale — robust / marginal / tight / violated, one colour language, no decimals anywhere; the banded margin rides on hover, never a headline number (G2). Each verdict propagates its metric by interval arithmetic — worst-and-best-case on pure bands, no midpoint (DEC-15) — validated by the vignette §9 oracle cases (O-1–O-3 exact, O-4 containment). Pick a verdict, walk it to the assessment and owner it rests on: every cell was written with a scored_from edge back to the world, whose channels trace to named knowledge above. ${scoreStampLine}
${s2}
Generated from fixtures/ by npm run gallery · identifiers frozen per assay-vignette.md §8 · compile per research note docs/research/02-compile.md · score per docs/research/03-score-plan.md
diff --git a/specs/008-handful/checklists/requirements.md b/specs/008-handful/checklists/requirements.md
new file mode 100644
index 0000000..52a1553
--- /dev/null
+++ b/specs/008-handful/checklists/requirements.md
@@ -0,0 +1,37 @@
+# Requirements checklist — SPEC-08 (Handful)
+
+Traceability from spec requirements to acceptance. Every box is a committed test or a rendered surface.
+
+## The exit (build plan Stage 3 · the reason SPEC-08 exists)
+
+- [x] `/plan/handful` over the Meridian base world returns **3–5 plans**, each resolving to a stored `Plan` (FR-001/002, SC-001) — `tests/handful.test.ts`
+- [x] Every pair in the handful is mutually **non-dominated** on the C1–C6 margin vector (FR-004, SC-001)
+- [x] The handful shows more than one distinct verdict on at least two commitments — genuinely different profiles (US1-3)
+- [x] **Same (world, seed) ⇒ identical** stamp, plans, scores, organisation (FR-008, SC-002)
+- [x] A different seed changes the stamp (seed participates in the stamp) (FR-008, SC-002)
+
+## The organiser (the honesty core)
+
+- [x] Drops a strictly-worse-everywhere plan; keeps a genuine trade-off; irreflexive + asymmetric (FR-004/005, SC-003) — `tests/dominance.test.ts`
+- [x] `violated` (no margin) treated as the conservatively worst value on its criterion (FR-006)
+- [x] Monotone in ε — increasing ε never shrinks the non-dominated set (FR-004, SC-003)
+- [x] No scalar total / weighted sum / midpoint anywhere in the organiser (FR-005, DEC-19/15)
+- [x] The diversity cap trims by axis spread (seed-tie-broken), never by a scalar; below `count` returns all, no padding (FR-007) — `count=3` cap test
+
+## Generation (honest fan-out)
+
+- [x] 16 candidates, each a valid `Plan` with all five force elements (FR-002, SC-004) — `tests/generate.test.ts`
+- [x] The candidate set spans both settings of all four axes (US4-2)
+- [x] Deterministic in `(config, seed)` — same seed ⇒ identical candidate list (FR-002, SC-004)
+
+## Trace, surface & discipline
+
+- [x] Every handful member opens a complete trace chain verdict → world → knowledge (G3, SC-005)
+- [x] `distinct_because` is derived and non-empty per plan, aligned to `plans` (FR-010, SC-006)
+- [x] The gallery renders the generated handful: S2 four-stop chips (margin-on-hover, no decimals) + the handful strip (SC-006)
+- [x] `unknown_ref` refuses first-class when the world cannot be resolved; persists nothing (FR-011)
+- [x] No `knowledge_overrides` on the handful; no `/relax` call (FR-012)
+
+## Gate
+
+- [x] `npm run typecheck` clean; `npm test` green (SPEC-07 baseline + SPEC-08 suite); no schema regen (SC-007)
diff --git a/specs/008-handful/contracts/handful-service.md b/specs/008-handful/contracts/handful-service.md
new file mode 100644
index 0000000..67cff0c
--- /dev/null
+++ b/specs/008-handful/contracts/handful-service.md
@@ -0,0 +1,42 @@
+# Contract — Handful service (`POST /plan/handful`, seam §6)
+
+Generates a strategy-biased fan-out (seeded), scores via §5, and organises by banded non-domination into 3–5 genuinely distinct plans. Sacrificial scope (delivery plan §3): a canned handful satisfying this contract keeps every downstream consumer honest — but SPEC-08 implements the honest generator. All declines are first-class `Refusal`s (seam §1).
+
+## Request
+
+```
+POST /plan/handful
+{
+ world: Ref, // a CompiledWorld to plan against; its stamp seeds determinism
+ seed: number, // orders the enumeration and breaks diversity-cap ties
+ count?: number, // clamped to [3,5]; default 5
+ engine_version: string
+}
+```
+
+Scenario is derived from the world (`world.scenario ?? BASE`) — the world already carries any excursion (DEC-10); the handful is scenario-blind like the scorer.
+
+## Response
+
+```
+→ { plans: Ref[], scores: PlanScore[], organisation: { distinct_because: string[] }, stamp } // success
+| Refusal { refused: true, reason, offending: Ref[], explanation } // decline
+```
+
+## Behavioural contract
+
+1. **Generate** — a seeded fan-out over the four Meridian axes (research note §5.2): approach, suppression, causeway, extraction. 2⁴ = 16 candidate `Plan`s, each a valid DEC-20 plan with routes for the five force elements. Deterministic in `(config, seed)`.
+2. **Score** — each candidate through the SPEC-07 `ScoreService` (reused). The criteria vector is the per-commitment **margin band** (satisfied ⟺ margin ≥ 0 — one uniform maximisation order).
+3. **Organise** — banded ε-non-domination on the conservative interval order (research note §5): keep a plan unless another is no worse on every commitment and strictly separated-above (by ε) on at least one. `ε` defaults to 0 (scale-free); monotone — a larger ε never shrinks the set. No scalar total, weighted sum, or midpoint decides membership (DEC-19, DEC-15).
+4. **Cap** — when the frontier exceeds `count`, trim by deterministic axis-diversity selection (seed-tie-broken), never by a scalar. When below `count`, return all (no padding).
+5. **`distinct_because`** — one derived reason per returned plan (aligned to `plans`): the commitments it leads on + its axis signature. Never a hand-authored caption.
+6. **Trace** — every returned plan is stored and content-addressed; its verdicts carry the scorer's `scored_from` edges, so every handful member opens a complete backward chain to named knowledge (G3).
+7. **Determinism** — `stamp = hash(world.stamp, seed, count, engine_version)`; same inputs ⇒ byte-identical stamp and identical ordered plans/scores/organisation (G1). Never hashes materialised cells.
+8. **Scope** — runs no `knowledge_overrides` perturbation (stays scorer-only, seam §6 open question 3) and calls no `/relax` (the commitment set stays whole — relaxation is SPEC-09).
+
+## Refusals (all first-class, persist nothing)
+
+| reason | when |
+|---|---|
+| `unknown_ref` | `world` names no live object |
+| _(scorer refusal)_ | if scoring a candidate refuses (e.g. `stamp_mismatch`), that refusal is returned verbatim |
diff --git a/specs/008-handful/data-model.md b/specs/008-handful/data-model.md
new file mode 100644
index 0000000..05e426a
--- /dev/null
+++ b/specs/008-handful/data-model.md
@@ -0,0 +1,37 @@
+# Phase 1 — Data Model (SPEC-08)
+
+Domain entities are generated from LinkML (`src/generated/types.ts`, slice D2) and must not be hand-edited. **SPEC-08 changes no schema** — every stored shape it needs already exists (D2, SPEC-06, SPEC-07). This file maps the spec's entities to the existing LinkML shapes and defines the handful's service types (movement, not stored objects).
+
+## Existing LinkML types reused (source of truth — do not hand-edit)
+
+| Entity | Shape | Notes |
+|---|---|---|
+| `Plan` / `ElementPlan` / `RouteLeg` | timed routes for the five force elements (DEC-20) | the generated candidates; stored & content-addressed |
+| `PlanScore` | `{plan, scenario, world_stamp, criterion, score, engine_version}` | banded criteria; returned in `scores` |
+| `CommitmentVerdict` | `{plan, commitment, scenario, world_stamp, verdict, margin?, engine_version}` | `margin` is the oriented criterion the organiser reads |
+| `Band` | `{lo, hi, unit}` | pure closed interval (DEC-15); compared by the conservative interval order |
+| `CompiledWorld` | sparse channels + `stamp` + `scenario?` | the world the handful is planned against; `stamp` seeds determinism |
+| `TraceEdge` (`scored_from`) | written by the scorer per verdict → world | every handful member opens a backward chain (G3) |
+
+No enum or class edit is required; `npm run gen` is **not** run in this slice.
+
+## Service types added (`src/seam.ts` — movement, not stored objects)
+
+| Type | Shape | Notes |
+|---|---|---|
+| `HandfulRequest` | `{world: Ref, seed: number, count?: number, engine_version: string}` | `count` clamped to `[3,5]` (default 5); scenario is derived from the world (seam §6 request shape) |
+| `HandfulOrganisation` | `{distinct_because: string[]}` | derived reasons, index-aligned to `plans` |
+| `HandfulSuccess` | `{plans: Ref[], scores: PlanScore[], organisation, stamp}` | the seam §6 return shape |
+| `HandfulResult` | `HandfulSuccess \| Refusal` | `unknown_ref` is the only handful-specific refusal reason (already in `RefusalReason`) |
+
+## Internal shapes (not exported at the seam)
+
+| Type | Shape | Notes |
+|---|---|---|
+| `AxisSignature` | `{approach, suppression, causeway, extraction}` | `src/generate.ts`: the four Meridian axes (research note §5.2); drives generation + the diversity cap |
+| `Criterion` / `CriteriaVector` | `Band \| null` / `Criterion[]` | `src/dominance.ts`: a plan's per-commitment margin vector; `null` = `violated` (worst) |
+| `Candidate` | `{sig, planRef, vector, scores}` | `src/handful.ts`: a scored candidate before organisation |
+
+## Determinism
+
+`stamp = SHA-256(canonicalJson({world: world.stamp, seed, count, engine_version}))` — over inputs, never over materialised cells (seam §1). The generator is a pure function of `(config, seed)`; the organiser and diversity cap are pure functions of the scored vectors + seed. Same inputs ⇒ byte-identical stamp and identical ordered `plans`/`scores`/`organisation` (G1).
diff --git a/specs/008-handful/plan.md b/specs/008-handful/plan.md
new file mode 100644
index 0000000..956180b
--- /dev/null
+++ b/specs/008-handful/plan.md
@@ -0,0 +1,57 @@
+# Implementation Plan: Handful — generate → score → organise (SPEC-08)
+
+**Branch**: `claude/next-spec-tkiz9e` · **Spec dir**: `specs/008-handful/` · **Date**: 2026-07-13
+**Spec**: `specs/008-handful/spec.md` · **Research note (DEC-11 gate)**: `docs/research/03-score-plan.md` §5 (present)
+
+## Summary
+
+Build the handful unit (delivery plan §δ, the last of the spine): `/plan/handful` = generate → score → organise. A seeded, strategy-biased **generator** fans out over the vignette's four axes into candidate `Plan`s; the SPEC-07 **scorer** (reused, not re-implemented) turns each into a C1–C6 margin vector; a banded ε-non-domination **organiser** reduces them to 3–5 genuinely distinct plans, capped by seed-driven axis diversity. Deterministic over `(world stamp, seed, count, engine)`; every member stored and traced. No schema change — `Plan`, `PlanScore`, `CommitmentVerdict.margin`, `TraceEdge` all exist. The demo moment ("the honest matrix") now renders the *generated* handful plus a `distinct_because` strip, replacing the canned P1/P2.
+
+## Technical Context
+
+**Language**: TypeScript (ESM, `type: module`), Node ≥ 22, in-browser-capable (no Node-only APIs in `src/`).
+**Testing**: vitest (existing). No new dependency — the generator is deterministic fan-out (no PRNG library; the seed drives ordering/tie-breaks over a fixed enumeration).
+**Reuse (do not re-implement)**: `src/{store,trace,canonical,validate,seam}.ts`, `src/score.ts` (`ScoreService` — the scorer is called, never re-built), `src/generated/types.ts`, `src/components/s2Matrix.ts` (reused unchanged), `src/interval.ts` (band helpers for the organiser). The margin vector comes straight off the scorer's `CommitmentVerdict.margin`.
+**New source**: `src/dominance.ts` (banded ε-non-domination + diversity cap), `src/generate.ts` (seeded axis fan-out → candidate Plans), `src/handful.ts` (the `HandfulService`), `src/components/handfulStrip.ts` (the `distinct_because` view). New seam types in `src/seam.ts`. No new fixtures — candidates are generated, not authored; `fixtures/plans.json` (the canned handful) stays as a documented fallback and for the SPEC-07 suite.
+**Constraints**: banded honesty at the seam (G2); no midpoint/scalar-total/weighted-sum anywhere in the organiser (DEC-15, DEC-19); determinism over inputs, never over cells (G1); first-class refusals (seam §1); frozen identifiers — generated plans stay in the `P*` family, clearly marked generator scaffolding (vignette §8).
+
+## Constitution Check
+
+- **Register-first (DEC-2)**: SPEC-08 introduces no new decision. The distinctness rule (banded ε-non-domination) and the four generation axes are *decided in the research note* (`03-score-plan.md` §5), under the concept §6.2 open question that the note explicitly closes for this domain. No peer document asserts a new decision; the sparse-channel candidate (concept §6 item 12) remains the only open one and is unaffected.
+- **Research-first (DEC-11)**: `docs/research/03-score-plan.md` §5 present before implementation — gate satisfied (the same note that gated SPEC-07 reserved §5 for SPEC-08).
+- **Banded honesty (G2)**: the organiser reads margin *bands* and compares them by the conservative interval order — it never collapses a band to a point. Verdicts still cross the seam only as the four-stop scale; `distinct_because` names commitments and axes, never a decimal.
+- **Frozen identifiers**: C1–C6, R1/R2/R3, FE-* rendered as-is; generated plans use `P*` ids encoding their axis signature, clearly marked demonstrator scaffolding (as the canned P1/P2 were).
+- **Batch propagation**: the current-phase line (CLAUDE.md), the build/delivery peers, and the gallery sweep in the same change.
+
+## Project Structure (this slice)
+
+```
+docs/research/03-score-plan.md # DEC-11 gate (done — §5 is SPEC-08's)
+specs/008-handful/{spec,plan,tasks,data-model,research,quickstart}.md
+specs/008-handful/contracts/handful-service.md
+specs/008-handful/checklists/requirements.md
+src/dominance.ts # conservative interval order; εDominates; nonDominated; diversityCap
+src/generate.ts # AxisSignature; generateCandidates(config, seed) -> Plan[]
+src/handful.ts # HandfulService: /plan/handful (generate -> score -> organise)
+src/components/handfulStrip.ts
+tests/{dominance,generate,handful}.test.ts
+```
+
+## Design decisions (from the research note §5)
+
+1. **Distinctness = banded ε-non-domination on the conservative interval order** (note §5). Maximisation on the scorer's margin bands (satisfied ⟺ margin ≥ 0 gives uniform "higher is better" across all comparators). `A ε-dominates B` iff `∀i: B.lo_i ≤ A.hi_i` (A no worse) and `∃j: A.lo_j ≥ B.hi_j + ε` (A strictly separated-above). `ε` defaults to 0 — scale-free, since criteria have incommensurate units and no absolute ε is honest. Two plans with overlapping bands are incomparable and both survive. This is DEC-15 in the organiser: overlap = honestly-indistinguishable = keep both.
+2. **The margin vector, not the raw metric, is the criteria vector.** The scorer already orients each comparator to a signed margin (`at_most`/`at_least`/`never`/`by_step` → margin ≥ 0 iff satisfied). Reading margins gives one uniform maximisation order and needs no per-criterion orientation table. A `violated` verdict (no margin) maps to the conservatively-worst sentinel on that criterion.
+3. **Generation is deterministic fan-out over four binary axes** (note §5.2), 2⁴ = 16 candidates via documented route templates; the seed orders the enumeration and breaks ties in the diversity cap — no PRNG, so no hidden non-determinism. Route geometry is fixture-authoring latitude *within* the constraints (vignette §8): the axis→route mapping is documented in `generate.ts`.
+4. **The cap is diversity, never score** (note §5, spec FR-007): when > `count` plans are non-dominated, greedily select for maximal axis-signature spread, seed as tie-break. Capping by any scalar would smuggle a weighting past DEC-19.
+5. **ε stays the organiser's knob, out of verdicts** (note §3/§5): SPEC-07 kept ε out of the four-stop scale; SPEC-08 is where ε lives, and it defaults to 0 so the honest baseline is pure conservative non-domination.
+
+## Complexity Tracking
+
+Scope is bounded to exactly the contract §6 movement: generate (fixed 16-candidate fan-out), score (reuse SPEC-07), organise (non-domination + diversity cap). No optimiser, no PRNG dependency, no `/relax` awareness (SPEC-09), no scenario strip (SPEC-10), no `knowledge_overrides` on the handful (seam §6 open question 3 — stays scorer-only). The only new surface is `handfulStrip`; the S2 matrix is reused unchanged.
+
+## Phase Gates
+
+- Research complete (DEC-11): ✅ `03-score-plan.md` §5.
+- Spec approved: this slice.
+- Tests-first: the organiser's non-domination laws (constructed vectors) and the determinism assertions are written and confirmed failing before `src/handful.ts` behaviour (constitution quality gate 2).
+- Green: typecheck + full suite; the Meridian base handful is 3–5 distinct, computed not hand-set; no schema regen in this slice.
diff --git a/specs/008-handful/quickstart.md b/specs/008-handful/quickstart.md
new file mode 100644
index 0000000..86b9b03
--- /dev/null
+++ b/specs/008-handful/quickstart.md
@@ -0,0 +1,45 @@
+# Quickstart — SPEC-08 handful
+
+## What this slice adds
+
+`POST /plan/handful` — the last unit of the spine. Generate a strategy-biased fan-out over Meridian's four axes, score each candidate with the SPEC-07 scorer, and organise by banded non-domination into 3–5 genuinely distinct plans.
+
+## Run it
+
+```
+npm install
+npm run typecheck # clean — no schema change, no gen
+npm test # vitest — includes dominance / generate / handful suites
+npm run gallery # regenerates the honest-matrix demo over the GENERATED handful
+```
+
+## The three steps (src)
+
+| File | Role |
+|---|---|
+| `src/generate.ts` | seeded fan-out over the four axes → 16 candidate `Plan`s (`generateCandidates(config, seed)`) |
+| `src/dominance.ts` | banded ε-non-domination on the conservative interval order (`nonDominated`, `dominates`, `distinct`) |
+| `src/handful.ts` | `HandfulService.handful(req)` — generate → score (reuse `ScoreService`) → organise → cap; deterministic stamp |
+| `src/components/handfulStrip.ts` | renders the derived `distinct_because` per plan above the S2 matrix |
+
+## Minimal use
+
+```ts
+const scorer = new ScoreService({ store, trace, config, commitments });
+const handfuls = new HandfulService({ store, scorer, config, commitments });
+const h = await handfuls.handful({ world: baseWorldRef, seed: 1, engine_version: '0.1.0' });
+// h.plans — 3–5 Refs, each a stored Plan
+// h.scores — banded PlanScores for the survivors
+// h.organisation.distinct_because — one derived reason per plan
+// h.stamp — same (world stamp, seed, count, engine) ⇒ identical handful
+```
+
+## The exit (build plan Stage 3)
+
+- `/plan/handful` returns **3–5 genuinely distinct plans** for the Meridian baseline (every pair mutually non-dominated).
+- **Same stamp + seed ⇒ identical handful** (G1).
+- Every verdict opens a complete backward trace chain (G3), reused from the scorer.
+
+## Demo moment
+
+*The honest matrix, organised not authored:* the S2 matrix now renders the generated handful, and the strip above it names — from the organiser, not a caption — why each plan is one of the few. On Meridian the survivors split cleanly along the two trade axes: approach trades C1 (port) against C2 (strait); suppression trades C3 (civil harm) against C4 (threat).
diff --git a/specs/008-handful/research.md b/specs/008-handful/research.md
new file mode 100644
index 0000000..c5fbda4
--- /dev/null
+++ b/specs/008-handful/research.md
@@ -0,0 +1,21 @@
+# Phase 0 — Research (SPEC-08)
+
+**DEC-11 gate**: `docs/research/03-score-plan.md` §5 — present before implementation. The Stage-3 note reserved §5 for SPEC-08 when it gated SPEC-07; no separate note is authored (the handful strategy folds into the score-plan note, as the note itself states).
+
+## Decisions taken from the note (not re-derived here)
+
+1. **Distinctness = banded ε-non-domination** (note §5). Two plans are distinct in banded space when neither ε-dominates the other across the criteria vector of banded scores, using the conservative interval order (`A ⪯ B` iff `A.hi ≤ B.lo`). Overlapping bands are honestly incomparable and both survive.
+2. **ε is the organiser's knob, not the verdict's** (note §3/§5). SPEC-07 kept ε out of the four-stop scale; SPEC-08 is where it lives. It defaults to 0 — scale-free, since the criteria carry incommensurate units (steps, band-hours, boolean, district cells) and no absolute ε is honest.
+3. **The four generation axes are Meridian's own** (note §5.2): approach (strait-early vs sweep-first), suppression posture (fires-forward vs stand-off), causeway (contest vs bypass), extraction coupling (pull-out early vs mission-tail). REMIT's time/exposure/robustness/completeness axes do not transfer unexamined (concept §6.2) — the note closes that open question for this domain.
+4. **The generator is sacrificial scope** (delivery plan §3). A canned handful over the honest scorer already demonstrates four theses; the honest generator turns "3–5 genuinely distinct plans" into a computed exit rather than a hand-set one. This is why the slice is late on the spine and low-risk.
+
+## Choices this slice makes within that latitude
+
+- **Criteria vector = the scorer's margin bands**, not the raw metric scores. SPEC-07 already orients every comparator to a signed margin (satisfied ⟺ margin ≥ 0), giving one uniform "higher is better" order and removing the need for a per-criterion orientation table. A `violated` verdict (no margin) is the conservatively worst value on its criterion.
+- **The domination gate is strict-above** (`A.lo > B.hi + ε`) rather than touching (`≥`), so equal/overlapping bands never count as "strictly better" — this is what makes the relation irreflexive (no plan dominates itself) and keeps DEC-15's "no honest interior point" honest.
+- **The diversity cap is by axis Hamming spread, seed-tie-broken** — never by a scalar score, which would smuggle a weighting past DEC-19.
+- **No PRNG.** The generator is a fixed 2⁴ enumeration; the seed only orders output and breaks cap ties (a deterministic FNV-1a key). This keeps "same stamp + seed ⇒ identical handful" true by construction, with no hidden non-determinism.
+
+## Sources
+
+As `03-score-plan.md` §5: Laumanns et al. (2002) ε-dominance; Deb et al. (2002) NSGA-II diversity; JP 5-0 COA comparison as commander's judgement (why the cap is not a scalar). ASSAY register DEC-4/10/15/19/20; seam contract §6; vignette §6 (C1–C6, force elements)/§8 (frozen identifiers); concept §6.2.
diff --git a/specs/008-handful/spec.md b/specs/008-handful/spec.md
new file mode 100644
index 0000000..b4a9912
--- /dev/null
+++ b/specs/008-handful/spec.md
@@ -0,0 +1,148 @@
+# Feature Specification: Handful — generate → score → organise into a banded non-dominated handful (SPEC-08)
+
+**Feature Branch**: `claude/next-spec-tkiz9e` (spec dir `008-handful`)
+
+**Created**: 2026-07-13
+
+**Status**: Draft
+
+**Stage**: Build-plan Stage 3 · **Depends on**: SPEC-07 (scorer — `ScoreService`, `CommitmentVerdict.margin`, deterministic stamp), SPEC-06 (compile — CompiledWorld, VignetteConfig geometry), D3 (seam contract §6) · **Research note**: `docs/research/03-score-plan.md` §5 (DEC-11 gate — present; defines banded ε-non-domination and Meridian's four generation axes)
+
+**Input**: `/plan/handful` (`{world, seed, count?} → {plans: Ref[], scores: PlanScore[], organisation: {distinct_because}, stamp}`) — the last unit of the spine (delivery plan §δ). It **generates** candidate plans by a seeded, strategy-biased fan-out over the vignette's four domain axes, **scores** each via the SPEC-07 scorer, and **organises** them by banded ε-non-domination into 3–5 genuinely distinct plans. It is *sacrificial scope* (delivery plan §3): the generator can slip to the gate because a canned handful over the honest scorer already demonstrates four theses — but the honest generator is what makes "3–5 genuinely distinct plans for the Meridian baseline" a computed exit rather than a hand-set one.
+
+## Honesty stance *(the point of this slice)*
+
+The handful is where the demonstrator could most easily cheat: pick a flattering three, call them "the options", and bury the trade. SPEC-08 refuses that by construction.
+
+1. **The organiser never invents precision.** Distinctness is banded ε-non-domination on the scorer's **margin bands** — the conservative interval order (`A ⪯ B` iff `A.hi ≤ B.lo`, research note §5). Two plans whose criteria bands *overlap* are honestly incomparable and both survive; a plan is dropped only when another is conservatively at-or-above it on **every** commitment and strictly separated-above on at least one. No scalar total, no weighted sum, no midpoint (DEC-15, DEC-19) ever decides membership.
+2. **The generator is honest fan-out, not cherry-picking.** Candidates come from the domain's own four axes (research note §5.2 — approach, suppression posture, causeway, extraction coupling), enumerated deterministically. The handful is what the organiser leaves standing, not what an author chose to show.
+3. **Every member is fully traced.** Each plan is content-addressed and stored; each verdict carries the `scored_from` edge the scorer wrote (G3). A handful member with no backward chain is a defect.
+4. **`distinct_because` is derived, not decorative.** Each returned reason names *why* the organiser kept that plan — the commitments it leads on and its axis signature — so the "why is this in the set" question has a computed answer, not a caption.
+
+## User Scenarios & Testing *(mandatory)*
+
+The scenarios play on the Meridian base world (the resolved compile of SPEC-06/07 — K1–K9 + resolved K12a). The handful over the base world is the Stage-3 user-observable; the depth stages (4–6) re-score into the same S2 matrix.
+
+### User Story 1 — 3–5 genuinely distinct plans for the Meridian baseline (Priority: P1) 🎯 exit
+
+`/plan/handful` over the Meridian base world returns **between three and five plans, each genuinely distinct in banded space** — none ε-dominates another across the C1–C6 margin vector. The organiser produced the set; no author hand-picked it.
+
+**Why this priority**: This is the Stage-3 handful exit (build plan §Stage 3, coverage matrix G1 row): "`/plan/handful` returns 3–5 genuinely distinct plans for the Meridian baseline." It is the whole reason the slice exists.
+
+**Independent Test**: Generate over the base world with a fixed seed; assert `3 ≤ plans.length ≤ 5`, that every pair is mutually non-dominated (neither ε-dominates the other on the margin vector), and that each returned plan resolves to a stored `Plan` with a distinct route geometry.
+
+**Acceptance Scenarios**:
+
+1. **Given** the Meridian base world and a fixed seed, **When** `/plan/handful` is called, **Then** it returns 3–5 plan refs, each resolving to a stored `Plan`.
+2. **Given** the returned handful, **When** every pair is checked, **Then** neither member of any pair ε-dominates the other on the C1–C6 margin vector (banded non-domination, research note §5).
+3. **Given** the returned handful, **When** each plan is scored, **Then** the members occupy genuinely different criteria profiles — at least two commitments on which the handful shows more than one distinct verdict.
+
+---
+
+### User Story 2 — Same stamp + seed ⇒ identical handful (Priority: P1)
+
+Calling `/plan/handful` twice with the same world stamp and the same seed returns a byte-identical stamp, the same plan refs in the same order, and the same `scores`/`organisation`. The handful is a pure function of `(world stamp, seed, count, engine version)`.
+
+**Why this priority**: The Stage-3 exit names it explicitly (coverage matrix G1: "same stamp + seed ⇒ identical handful") and every downstream demo (sensitivity/discrimination/robustness) re-runs the handful; a non-deterministic handful makes those loops theatre (DEC-4). P1 alongside US1 as the two together *are* the exit.
+
+**Independent Test**: Call twice with identical `(world, seed)`; assert identical `stamp`, identical `plans` (same order, same content hashes), identical `scores` and `distinct_because`. Change only the seed; assert the stamp changes (the seed participates in the stamp).
+
+**Acceptance Scenarios**:
+
+1. **Given** a fixed world and seed, **When** `/plan/handful` is called twice, **Then** both calls return the same stamp, the same ordered plan refs, and the same organisation.
+2. **Given** the same world and a different seed, **When** called, **Then** the stamp differs (the seed is part of the stamped input) and the handful may differ.
+3. **Given** the same seed and a world recompiled to a different stamp, **When** called, **Then** the handful stamp differs (the world stamp is part of the stamped input).
+
+---
+
+### User Story 3 — The organiser drops only conservatively-dominated plans (Priority: P1)
+
+The banded ε-non-domination organiser keeps every plan that any other does not conservatively beat on all criteria. A plan is excluded **only** when another is at-or-above it on every commitment margin (`A ⪯ B` fails nowhere) and strictly separated-above (by ε) on at least one. Overlapping bands are incomparable and both survive.
+
+**Why this priority**: This is the honesty core (stance §1). An organiser that dropped a plan on a scalar total, a midpoint tie-break, or a weighted score would be the false-precision machine ASSAY refuses (DEC-15, DEC-19). It is testable independently of the generator on constructed criteria vectors, so it is P1 and specified against hand-built cases.
+
+**Independent Test**: On constructed banded criteria vectors: assert a strictly-worse-everywhere plan is dominated and dropped; assert two plans with overlapping bands on any criterion are both kept; assert `ε` widening only ever *keeps more* plans (monotone in ε); assert the relation is irreflexive (no plan dominates itself) and asymmetric.
+
+**Acceptance Scenarios**:
+
+1. **Given** plan A whose every C1–C6 margin band is conservatively above plan B's (`B.hi ≤ A.lo` on all, strict on one), **When** organised, **Then** B is dominated and excluded, A retained.
+2. **Given** plans A and B whose margin bands overlap on at least one commitment, **When** organised, **Then** neither dominates the other and both are candidates for the handful.
+3. **Given** a fixed candidate set, **When** ε is increased, **Then** the non-dominated set never shrinks (ε is the organiser's distinctness knob, research note §5) — more plans read as distinct, never fewer.
+4. **Given** any plan, **When** compared to itself, **Then** it does not dominate itself (irreflexive); and if A dominates B then B does not dominate A (asymmetric).
+
+---
+
+### User Story 4 — Generation is honest fan-out over the domain's four axes (Priority: P2)
+
+Candidate plans are produced by a **strategy-biased, seeded** fan-out across the Meridian axes (research note §5.2): **approach** (strait-early vs sweep-first), **suppression posture** (fires-forward vs stand-off), **causeway** (contest vs bypass), **extraction coupling** (pull-out early vs mission-tail). Each candidate is a valid `Plan` (routes for the five force elements, DEC-20); the fan-out is deterministic and reproducible.
+
+**Why this priority**: The four axes are the vignette's own (concept §6.2 gate — REMIT's axes "do not transfer unexamined"), decided in the research note; the generator is the machine that turns them into candidates. P2 because the exit (US1/US2) constrains the *output* of generation, and the axes are the honest *means*; a different valid fan-out that still yields 3–5 distinct plans would satisfy the exit.
+
+**Independent Test**: Generate the candidate set for a fixed seed; assert every candidate validates as a `Plan` against the generated types, that the four axes each vary across the set (both settings of each axis appear), and that generation is deterministic (same seed ⇒ identical candidate list).
+
+**Acceptance Scenarios**:
+
+1. **Given** a fixed seed, **When** the candidate set is generated, **Then** every candidate is a valid `Plan` with routes for the five Meridian force elements (FE-BROOM, FE-PACKHORSE, FE-ANVIL, FE-FALCON, FE-KINGFISHER).
+2. **Given** the candidate set, **When** its axis signatures are read, **Then** both settings of each of the four axes appear (the fan-out spans the axes, not one corner).
+3. **Given** the same seed, **When** candidates are generated twice, **Then** the two candidate lists are identical (deterministic generation).
+
+---
+
+### User Story 5 — The honest matrix over the generated handful, with `distinct_because` (Priority: P2)
+
+The S2 planner matrix now renders the **generated** handful (replacing the canned P1/P2), each plan a row of four-stop chips (margin on hover, no decimals), alongside a `distinct_because` line per plan naming why the organiser kept it. This is the Stage-3 demo moment made honest end-to-end: *pick a verdict, walk it to the assessment and owner it rests on* — and now, *see why this plan is one of the handful.*
+
+**Why this priority**: It is the user-observable and the surface the depth stages re-score into. P2 because it composes US1–US4's correctness onto the gallery; the S2 matrix component already exists (SPEC-07) and is reused unchanged, fed by the generated handful.
+
+**Independent Test**: Run `/plan/handful` in the gallery build over the resolved base world; render the S2 matrix over the returned plans and a `handfulStrip` over the `organisation`; assert four-stop chips with margin-on-hover and no decimals, and a non-empty `distinct_because` per plan.
+
+**Acceptance Scenarios**:
+
+1. **Given** the generated handful, **When** the S2 matrix renders, **Then** every cell is a four-stop chip, margin appears only on hover, and no verdict cell shows a decimal (G2, ui-design §2).
+2. **Given** the returned `organisation`, **When** the handful strip renders, **Then** each plan carries a `distinct_because` reason naming the commitments it leads on and its axis signature.
+3. **Given** the gallery build, **When** it runs, **Then** the honest-matrix demo moment is driven by the actual `/plan/handful`, not by hand-authored fixture rows.
+
+---
+
+### Edge Cases
+
+- **Fewer than three non-dominated candidates**: if the honest organiser leaves fewer than three plans standing, that is a *signal*, not something to pad — the service returns what stands and the test surfaces it (for the engineered Meridian base world the axes are designed to yield ≥ 3; this case guards against silent padding).
+- **More non-dominated candidates than `count`**: the service caps at `count` (default 5) by a deterministic, seed-driven **diversity** selection over axis signatures — never by a scalar score (that would smuggle a weighting in through the cap). The drop is logged in `distinct_because` framing, never silent.
+- **`count` out of the 3–5 band**: a `count` below 3 or above 5 is clamped to the contract band (seam §6), not honoured blindly.
+- **A severed / unreachable objective in a candidate** (a route crossing a zero-mobility region): the scorer already returns `violated` with no margin; the organiser treats an absent margin as the worst possible on that criterion (a `violated` plan is conservatively dominated there), never as a gap to skip.
+- **World refusal / unknown world ref**: `/plan/handful` refuses `unknown_ref` (first-class, seam §1) when the world cannot be resolved; it never scores against a phantom world.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: `/plan/handful` MUST accept `{world: Ref, seed: integer, count?: 3..5}` and return `{plans: Ref[], scores: PlanScore[], organisation: {distinct_because: string[]}, stamp}` or a first-class `Refusal` (seam §6). `count` MUST be clamped to `[3,5]` (default 5).
+- **FR-002**: Generation MUST be a seeded, strategy-biased fan-out over the four Meridian axes (research note §5.2). Every candidate MUST be a valid `Plan` (DEC-20) with routes for the five force elements, and generation MUST be deterministic in `(world, seed)`.
+- **FR-003**: Each candidate MUST be scored by the SPEC-07 scorer against the given world (reusing `ScoreService`, no re-implementation); the criteria vector for organisation is the per-commitment **margin bands** (satisfied ⟺ margin ≥ 0 — uniform orientation, higher is better).
+- **FR-004**: Organisation MUST be banded ε-non-domination on the conservative interval order (research note §5): A ε-dominates B iff A is no worse than B on every criterion (`B.lo ≤ A.hi`) and strictly separated-above by ε on at least one (`A.lo ≥ B.hi + ε`). The handful is the ε-non-dominated set. `ε` defaults to 0 (scale-free conservative non-domination) and MUST be monotone: increasing ε never shrinks the set.
+- **FR-005**: The domination relation MUST be irreflexive (no plan dominates itself) and asymmetric (A dom B ⇒ ¬(B dom A)); it MUST NOT use any scalar total, weighted sum, or band midpoint (DEC-15, DEC-19).
+- **FR-006**: A `violated` verdict (no margin — an unreachable objective) MUST be treated as the conservatively worst value on that criterion, so a plan failing a `must` is dominated there, never skipped.
+- **FR-007**: When the non-dominated set exceeds `count`, the service MUST cap it by a deterministic, seed-driven diversity selection over axis signatures — never by a scalar score. When it is below `count`, the service returns all non-dominated plans (no padding).
+- **FR-008**: The handful MUST be deterministic: same `(world stamp, seed, count, engine version)` ⇒ byte-identical stamp and identical ordered `plans`/`scores`/`organisation` (G1). The stamp is a hash over those inputs, never over materialised cells.
+- **FR-009**: Every returned plan MUST be content-addressed and stored (so its ref resolves), and MUST carry the scorer's `scored_from` edges on its verdicts, so every handful member opens a complete backward trace chain to named knowledge (G3).
+- **FR-010**: `distinct_because` MUST be **derived** from the organisation (the commitments each plan leads on + its axis signature), one entry aligned to each returned plan; it MUST NOT be a hand-authored caption.
+- **FR-011**: `/plan/handful` MUST refuse `unknown_ref` (first-class) when the world ref cannot be resolved, and persist nothing on refusal.
+- **FR-012**: `/plan/handful` MUST NOT compute a `knowledge_overrides` perturbation (that stays scorer-only — seam §6 open question 3), and MUST NOT call `/relax` (the commitment set stays whole; relaxation is SPEC-09).
+
+### Key Entities
+
+- **Plan / ElementPlan / RouteLeg** (existing LinkML, DEC-20) — the generated candidates; timed routes for the five force elements. Stored and content-addressed.
+- **PlanScore** (existing LinkML) — the banded criteria per plan; returned in `scores`.
+- **CommitmentVerdict.margin** (existing LinkML, SPEC-07) — the oriented banded criterion the organiser reads; never crosses the seam as a scalar.
+- **HandfulRequest / HandfulSuccess / HandfulResult** (service types, `src/seam.ts` — not stored) — the seam §6 movement shapes; `organisation.distinct_because` is a derived view, not a stored object.
+- **AxisSignature** (internal, not at the seam) — the four-axis label of a candidate; drives generation and the diversity cap.
+
+## Success Criteria *(mandatory)*
+
+- **SC-001**: `/plan/handful` over the Meridian base world returns 3–5 plans, every pair mutually non-dominated on the C1–C6 margin vector.
+- **SC-002**: Same `(world, seed)` ⇒ byte-identical stamp and identical ordered `plans`/`scores`/`organisation`; changing the seed changes the stamp.
+- **SC-003**: The organiser drops a strictly-worse-everywhere plan, keeps overlapping-band plans, is monotone in ε, and is irreflexive + asymmetric — proven on constructed criteria vectors.
+- **SC-004**: Generation is a deterministic fan-out spanning both settings of all four Meridian axes; every candidate validates as a `Plan`.
+- **SC-005**: Every handful member resolves to a stored `Plan` and every verdict opens a complete backward trace chain (G3).
+- **SC-006**: `distinct_because` is derived and non-empty per plan; the S2 matrix + handful strip render in the gallery from the actual `/plan/handful` with four-stop chips, margins on hover, and no decimals (G2).
+- **SC-007**: `npm run typecheck` clean; `npm test` green (SPEC-07 baseline plus the SPEC-08 suite); no schema change (`npm run gen` not run in this slice).
diff --git a/specs/008-handful/tasks.md b/specs/008-handful/tasks.md
new file mode 100644
index 0000000..fecf092
--- /dev/null
+++ b/specs/008-handful/tasks.md
@@ -0,0 +1,70 @@
+---
+description: "Task list for SPEC-08 — Handful (generate → score → organise)"
+---
+
+# Tasks: Handful — generate → score → organise into a banded non-dominated handful (SPEC-08)
+
+**Input**: Design documents from `specs/008-handful/`
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/handful-service.md, quickstart.md; research note `docs/research/03-score-plan.md` §5 (DEC-11 gate — present)
+**Tests**: REQUIRED — the organiser's non-domination laws and the determinism assertions are written against the seam §6 shape and **confirmed failing before implementation** (TDD; constitution quality gate 2). The Meridian handful is a *computed* product; tests assert its shape (3–5, mutually non-dominated, deterministic), never hand-set membership.
+**Organization**: the organiser (pure, testable in isolation) and the generator first, then the service that composes them, then the surface. Stage-3 handful exit is the acceptance.
+
+## Format: `[ID] [P?] [Story] Description`
+- **[P]**: parallelizable (different file, no dependency on an incomplete task)
+- **[Story]**: US1 (3–5 distinct, P1) · US2 (determinism, P1) · US3 (organiser laws, P1) · US4 (fan-out, P2) · US5 (matrix + strip, P2)
+
+## Path conventions
+Single project: `src/`, `tests/`. Reuse `src/{store,trace,canonical,validate,seam,score,interval}.ts`, `src/generated/types.ts`, `src/components/s2Matrix.ts`. **No schema change** — `Plan`/`PlanScore`/`CommitmentVerdict`/`Band`/`TraceEdge` already exist; do **not** run `npm run gen`.
+
+---
+
+## Phase 1: Setup
+
+- [x] T001 Confirm the SPEC-07 baseline is green: `npm install`, `npm run typecheck`, `npm test`. Record any failing baseline before Stage-3 code.
+- [x] T002 Confirm the pieces SPEC-08 reuses exist: `ScoreService` (`src/score.ts`) with `CommitmentVerdict.margin`; `Plan`/`PlanScore` in `src/generated/types.ts`; `scored_from` in `TraceEdgeType`; the sparse world shapes SPEC-06 produced. No new dependency (the generator is deterministic fan-out — no PRNG library).
+
+## Phase 2: US3 — the organiser laws (P1, the honesty core, pure)
+
+**Goal**: banded ε-non-domination on the conservative interval order, proven on constructed vectors before any pipeline exists.
+**Independent test**: constructed margin vectors → dominated/kept/monotone/irreflexive/asymmetric.
+
+- [x] T003 Write `tests/dominance.test.ts` FIRST and confirm failing: `noWorse`/`strictlyBetter` on the conservative order incl. `null` (violated) as worst; `dominates` drops strictly-worse-everywhere, keeps trade-offs, is irreflexive + asymmetric, throws on misaligned lengths; `nonDominated` monotone in ε.
+- [x] T004 Implement `src/dominance.ts`: `Criterion`/`CriteriaVector`, `noWorse`, `strictlyBetter(a,b,ε=0)`, `dominates(a,b,ε=0)`, `distinct`, `nonDominated(vectors,ε=0)`. Maximisation on margins; strict-above gate (`a.lo > b.hi + ε`) so equal/overlap is never "strictly better" (DEC-15). Make T003 pass.
+
+## Phase 3: US4 — the seeded fan-out generator (P2)
+
+**Goal**: deterministic candidates over Meridian's four axes, each a valid `Plan`.
+**Independent test**: 16 valid plans, span all axes, deterministic in `(config, seed)`.
+
+- [x] T005 Write `tests/generate.test.ts` FIRST and confirm failing: 16 candidates, each `validateInstance('Plan', p) === []` with the five force elements and a `P-*` id; both settings of all four axes appear; same seed ⇒ identical list; id encodes the signature.
+- [x] T006 Implement `src/generate.ts`: `AxisSignature`, `AXES`, `signatureId`/`signatureName`, per-force-element route builders (documented axis→route mapping — reach cells avoid banded-mobility regions so reaches stay clean; C4 exercises the banded threat channel), `planFor`, `allSignatures`, `generateCandidates(config, seed)`. Make T005 pass.
+
+## Phase 4: US1 + US2 — the handful service (P1)
+
+**Goal**: `/plan/handful` = generate → score → organise → cap, deterministic, over the Meridian base world.
+**Independent test**: base world → 3–5 mutually non-dominated plans; same (world, seed) ⇒ identical; count clamps; unknown world refuses.
+
+- [x] T007 Add handful service types to `src/seam.ts`: `HandfulRequest`, `HandfulOrganisation`, `HandfulSuccess`, `HandfulResult` (per data-model.md). Types only; reuse `Refusal`/`Ref`.
+- [x] T008 Write `tests/handful.test.ts` FIRST and confirm failing (rig mirrors `score.test.ts`: resolved base world): 3–5 stored plans; every pair `distinct`; ≥2 commitments vary; same (world, seed) ⇒ identical stamp/plans/scores/organisation; different seed ⇒ different stamp; `distinct_because` derived & aligned; verdict→world→knowledge chain (G3); `count` clamp; `unknown_ref` persists nothing.
+- [x] T009 Implement `src/handful.ts` `HandfulService`: resolve world (refuse `unknown_ref`), clamp `count` to `[3,5]`, generate → store each candidate → score via `ScoreService` → build margin vectors → `nonDominated` → diversity cap (axis Hamming, seed FNV-1a tie-break) → derive `distinct_because` → stamp over `(world.stamp, seed, count, engine)`. Make T008 pass.
+
+## Phase 5: US5 — the surface (P2)
+
+**Goal**: the honest-matrix demo moment, now over the generated handful.
+
+- [x] T010 [P] Implement `src/components/handfulStrip.ts`: one row per plan — id, axis-signature name, derived `distinct_because`. Arranges projections only; depends on generated types + `Ref`.
+- [x] T011 Wire `scripts/build-gallery.ts`: replace the canned P1/P2 scoring with `HandfulService.handful` over the resolved base world; render the strip above the reused `s2Matrix`; update the section copy to name SPEC-08 (organised, not authored). Regenerate `docs/assets/gallery/index.html`.
+
+## Phase 6: Batch propagation & gate
+
+- [x] T012 Sweep the canonical peers in the same change: CLAUDE.md current-phase line; `assay-build-plan.md` / `assay-delivery-plan.md` Stage-3 status; the gallery. Note the concept §6.2 handful-axes open question is closed by research note §5.2 (no new register candidate — the note holds the decision under Stage-3 latitude).
+- [x] T013 Gate: `npm run typecheck` clean; `npm test` green (SPEC-07 baseline + SPEC-08 suites); `npm run gallery` renders the generated handful. No schema regen.
+
+---
+
+## Dependencies & parallelism
+
+- **T003→T004** (organiser) and **T005→T006** (generator) are independent tracks — run in parallel.
+- **T007** (types) unblocks **T008→T009** (service), which depends on T004 + T006.
+- **T010** is parallel to the service; **T011** needs T009 + T010.
+- **T012/T013** are the closing sweep and gate.
diff --git a/src/components/handfulStrip.ts b/src/components/handfulStrip.ts
new file mode 100644
index 0000000..5039672
--- /dev/null
+++ b/src/components/handfulStrip.ts
@@ -0,0 +1,43 @@
+/**
+ * SPEC-08 — the handful organisation strip (seam §6; ui-design §S2).
+ *
+ * Renders the organiser's answer to "why is this plan one of the handful?" — one
+ * row per plan with its axis signature and the DERIVED `distinct_because` reason
+ * the service returned (the commitments it leads on; never a hand-authored
+ * caption). It arranges projections only (constitution I): no service call, no
+ * state, depends only on the returned `plans`/`organisation`. It sits above the
+ * SPEC-07 S2 matrix, which renders the same handful as four-stop chips — the
+ * strip explains membership, the matrix shows the verdicts.
+ */
+import type { Ref } from '../store.js';
+
+const esc = (s: string): string =>
+ s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
+export interface HandfulStripRow {
+ plan: Ref;
+ name?: string; // the plan's human name (axis signature), if to hand
+ distinct_because: string;
+}
+
+/**
+ * @param rows one per handful member, aligned to the service's `plans` order.
+ */
+export function handfulStrip(rows: HandfulStripRow[]): string {
+ const items = rows
+ .map((r) => {
+ const id = esc(r.plan.logical_id);
+ const name = r.name ? `${esc(r.name)}` : '';
+ return `
+
+ ${id}
+ ${name}
+
+ ${esc(r.distinct_because)}
+ `;
+ })
+ .join('\n');
+ return ``;
+}
diff --git a/src/dominance.ts b/src/dominance.ts
new file mode 100644
index 0000000..939064b
--- /dev/null
+++ b/src/dominance.ts
@@ -0,0 +1,87 @@
+/**
+ * SPEC-08 — banded ε-non-domination, the handful's organiser (research note
+ * `03-score-plan.md` §5).
+ *
+ * Two plans are DISTINCT in banded space when neither ε-dominates the other
+ * across the criteria vector of banded scores. The criteria are the scorer's
+ * per-commitment MARGIN bands (satisfied ⟺ margin ≥ 0 — SPEC-07's signed margin
+ * gives one uniform "higher is better" order across every comparator), so the
+ * organiser needs no per-criterion orientation table.
+ *
+ * The order is the CONSERVATIVE interval order (note §5): `A ⪯ B` iff
+ * `A.hi ≤ B.lo` (A entirely at/below B). For maximisation this reads:
+ * - A is NO WORSE than B on a criterion ⟺ B is not entirely above A ⟺ B.lo ≤ A.hi
+ * - A is STRICTLY BETTER than B by ε ⟺ A is separated above B ⟺ A.lo > B.hi + ε
+ * and `A ε-dominates B` iff A is no worse on EVERY criterion and strictly better
+ * on AT LEAST ONE. Bands that overlap are honestly incomparable and BOTH survive
+ * — that is DEC-15 in the organiser: overlap means indistinguishable, so keep both.
+ * No scalar total, no weighted sum, no midpoint ever decides membership (DEC-19).
+ *
+ * ε is the organiser's distinctness knob and defaults to 0 (scale-free — the
+ * criteria carry incommensurate units, so no absolute ε is honest). Increasing ε
+ * only ever KEEPS MORE plans (fewer dominations), never fewer.
+ */
+import type { Band } from './generated/types.js';
+
+/** One criterion of a plan's vector: a margin band, or `null` for a `violated`
+ * verdict (an unreachable objective — conservatively the worst possible here). */
+export type Criterion = Band | null;
+export type CriteriaVector = Criterion[];
+
+/**
+ * A is NO WORSE than B on this criterion (maximisation): B is not conservatively
+ * above A. `null` (violated) is the worst value: any finite margin is above it.
+ */
+export function noWorse(a: Criterion, b: Criterion): boolean {
+ if (a === null) return b === null; // worst is no-worse only than the worst
+ if (b === null) return true; // a finite margin is at least as good as unreachable
+ return b.lo <= a.hi; // B does not sit entirely above A
+}
+
+/**
+ * A is STRICTLY BETTER than B on this criterion, by more than ε: A is separated
+ * conservatively above B. A finite margin strictly beats `violated`; equal or
+ * overlapping bands are NOT strictly better (no honest interior point — DEC-15).
+ */
+export function strictlyBetter(a: Criterion, b: Criterion, eps = 0): boolean {
+ if (a === null) return false; // the worst is never strictly better
+ if (b === null) return true; // reachable strictly beats unreachable
+ return a.lo > b.hi + eps; // A entirely above B by more than ε
+}
+
+/**
+ * A ε-dominates B: no worse on every criterion, strictly better on at least one.
+ * Irreflexive (a vector never dominates itself — no criterion is strictly above
+ * itself) and asymmetric (the strict criterion that lets A beat B forbids B beating A).
+ * Vectors must be the same length and aligned to the same criterion order.
+ */
+export function dominates(a: CriteriaVector, b: CriteriaVector, eps = 0): boolean {
+ if (a.length !== b.length) {
+ throw new Error(`dominance: criteria vectors of differing length (${a.length} vs ${b.length}) — misaligned commitment order`);
+ }
+ let strictSomewhere = false;
+ for (let i = 0; i < a.length; i++) {
+ if (!noWorse(a[i]!, b[i]!)) return false; // worse on some criterion ⇒ no domination
+ if (strictlyBetter(a[i]!, b[i]!, eps)) strictSomewhere = true;
+ }
+ return strictSomewhere;
+}
+
+/** Two plans are DISTINCT iff neither ε-dominates the other (research note §5). */
+export function distinct(a: CriteriaVector, b: CriteriaVector, eps = 0): boolean {
+ return !dominates(a, b, eps) && !dominates(b, a, eps);
+}
+
+/**
+ * Indices of the ε-non-dominated vectors — those no other vector ε-dominates.
+ * This is the honest frontier; it is never padded and never trimmed by a scalar.
+ * Monotone in ε: a larger ε yields a superset (fewer dominations).
+ */
+export function nonDominated(vectors: CriteriaVector[], eps = 0): number[] {
+ const keep: number[] = [];
+ for (let i = 0; i < vectors.length; i++) {
+ const dominated = vectors.some((other, j) => j !== i && dominates(other, vectors[i]!, eps));
+ if (!dominated) keep.push(i);
+ }
+ return keep;
+}
diff --git a/src/generate.ts b/src/generate.ts
new file mode 100644
index 0000000..1a1ab49
--- /dev/null
+++ b/src/generate.ts
@@ -0,0 +1,179 @@
+/**
+ * SPEC-08 — the seeded, strategy-biased plan generator (research note
+ * `03-score-plan.md` §5.2).
+ *
+ * Candidates are a deterministic fan-out over the vignette's OWN four axes —
+ * REMIT's time/exposure/robustness/completeness axes "do not transfer
+ * unexamined" (concept §6.2), so these are read off Meridian's C1–C6 and force
+ * elements:
+ *
+ * 1. approach — strait-early vs sweep-first (trades C2 strait ↔ C1 port)
+ * 2. suppression — fires-forward vs stand-off (trades C4 threat ↔ C3 civil)
+ * 3. causeway — contest intact vs bypass by sea
+ * 4. extraction — KINGFISHER pull-out early vs mission-tail
+ *
+ * Each axis is binary, so the fan-out is 2⁴ = 16 candidate `Plan`s (DEC-20:
+ * timed routes for the five force elements, nothing richer). The generator is a
+ * PURE function of `(config, seed)` — no PRNG, no wall clock — so the same seed
+ * yields the same candidates; the seed only orders the enumeration and breaks
+ * ties in the organiser's diversity cap (`src/handful.ts`). It scores nothing and
+ * organises nothing: it hands honest candidates to the scorer, and the organiser
+ * decides which survive. Route geometry is fixture-authoring latitude within the
+ * vignette's constraints (§8); the axis→route mapping is documented cell-by-cell
+ * below so a reviewer can walk any leg back to the axis that set it.
+ */
+import type { ElementPlan, Plan, RouteLeg, VignetteConfig } from './generated/types.js';
+
+export type Approach = 'strait_early' | 'sweep_first';
+export type Suppression = 'fires_forward' | 'stand_off';
+export type Causeway = 'contest' | 'bypass';
+export type Extraction = 'pull_early' | 'mission_tail';
+
+export interface AxisSignature {
+ approach: Approach;
+ suppression: Suppression;
+ causeway: Causeway;
+ extraction: Extraction;
+}
+
+/** The four binary axes, in canonical enumeration order. */
+export const AXES = {
+ approach: ['strait_early', 'sweep_first'] as Approach[],
+ suppression: ['fires_forward', 'stand_off'] as Suppression[],
+ causeway: ['contest', 'bypass'] as Causeway[],
+ extraction: ['pull_early', 'mission_tail'] as Extraction[],
+};
+
+/** Two-letter axis codes → the `P*` logical id (vignette §8 frozen family). */
+const CODE = {
+ strait_early: 'Se', sweep_first: 'Sw',
+ fires_forward: 'Ff', stand_off: 'So',
+ contest: 'Cc', bypass: 'Cb',
+ pull_early: 'Xe', mission_tail: 'Xt',
+} as const;
+
+const PHRASE = {
+ strait_early: 'strait-early', sweep_first: 'sweep-first',
+ fires_forward: 'fires-forward', stand_off: 'stand-off',
+ contest: 'contest', bypass: 'bypass',
+ pull_early: 'pull-early', mission_tail: 'mission-tail',
+} as const;
+
+export const signatureId = (s: AxisSignature): string =>
+ `P-${CODE[s.approach]}${CODE[s.suppression]}${CODE[s.causeway]}${CODE[s.extraction]}`;
+
+export const signatureName = (s: AxisSignature): string =>
+ `${PHRASE[s.approach]} · ${PHRASE[s.suppression]} · ${PHRASE[s.causeway]} · ${PHRASE[s.extraction]}`;
+
+const leg = (x: number, y: number, enter: number, exit: number): RouteLeg => ({
+ x, y, enter_step: enter, exit_step: exit,
+});
+
+/**
+ * FE-BROOM sweeps the strait — the C2 clock. `strait_early` rushes a direct pass
+ * (fast C2); `sweep_first` does a thorough approach pass then the strait (C2 later
+ * but clean, the thesis-C robustness posture). All cells avoid the banded-mobility
+ * approach/causeway regions, so C2 is a clean point band.
+ */
+function broom(a: Approach): ElementPlan {
+ const route = a === 'strait_early'
+ ? [leg(30, 50, 0, 3), leg(26, 25, 3, 20)] // strait_open = 0 + 3 + 17 = 20
+ : [leg(30, 50, 0, 2), leg(24, 26, 2, 9), leg(26, 25, 9, 26)]; // = 0 + 2 + 7 + 17 = 26
+ return { force_element: 'FE-BROOM', route };
+}
+
+/**
+ * FE-PACKHORSE is the relief payload — the C1 clock. `approach` trades it against
+ * C2: rushing the strait (strait_early) sends PACKHORSE in behind an unfinished
+ * sweep, so it berths later (higher C1); sweep-first lets it run clean and berth
+ * earlier. `causeway` sets the route: `contest` takes the direct Ledger crossing,
+ * `bypass` the longer sea route (12 steps slower), so contest strictly beats
+ * bypass on C1, all else equal. Cells avoid the banded-mobility regions, so the
+ * reach is a clean point band (the banded channels are exercised on C4 below).
+ */
+function packhorse(a: Approach, c: Causeway): ElementPlan {
+ const berth = a === 'strait_early' ? 36 : 24; // strait_early berths later (higher C1)
+ const route = c === 'contest'
+ ? [leg(30, 50, 0, 4), leg(26, 32, 4, berth)] // direct crossing: port_open = berth
+ : [leg(30, 50, 0, 4), leg(44, 44, 4, 16), leg(26, 32, 16, berth + 12)]; // by sea, +12
+ return { force_element: 'FE-PACKHORSE', route };
+}
+
+/**
+ * FE-ANVIL's threat exposure is C4 — the INVERSE of the suppression choice:
+ * `fires_forward` means FALCON has suppressed the threat, so ANVIL transits a
+ * clean cell (26,32) (zero threat ⇒ C4 robust); `stand_off` leaves the threat up,
+ * so ANVIL must cross the FAC waters cell (32,22) (threat [2,6] ⇒ C4 exposed/tight).
+ */
+function anvil(s: Suppression): ElementPlan {
+ const route = s === 'fires_forward'
+ ? [leg(30, 50, 0, 8), leg(26, 32, 8, 10)] // clean cell — exposure [0,0]
+ : [leg(30, 50, 0, 7), leg(32, 22, 7, 8)]; // FAC waters — one step in threat [2,6]
+ return { force_element: 'FE-ANVIL', route };
+}
+
+/**
+ * FE-FALCON's fires decide C3: `fires_forward` puts a leg into the populated port
+ * district cell (26,32) (a fire in the district ⇒ C3 violated — civil harm);
+ * `stand_off` keeps FALCON at the Carrick battery standoff (48,15) outside the
+ * district (no fires ⇒ C3 marginal). This is the C3↔C4 trade the suppression axis
+ * carries: fires forward buys ANVIL safety at a civil-harm cost, and vice versa.
+ */
+function falcon(s: Suppression): ElementPlan {
+ const route = s === 'fires_forward'
+ ? [leg(30, 50, 0, 4), leg(26, 32, 4, 8)] // inside port_district ⇒ C3 fire
+ : [leg(30, 50, 0, 4), leg(48, 15, 4, 10)]; // battery standoff, outside district
+ return { force_element: 'FE-FALCON', route };
+}
+
+/**
+ * DET KINGFISHER's extraction is C6: `pull_early` extracts by step 38 (C6 robust
+ * with room); `mission_tail` keeps it in until step 46 (C6 robust but tight). Pull
+ * early is strictly better on C6, all else equal — mission-tail's collection value
+ * (K11) is not a scored criterion here, so on the honest matrix it is dominated.
+ */
+function kingfisher(x: Extraction): ElementPlan {
+ const route = x === 'pull_early'
+ ? [leg(48, 15, 0, 4), leg(30, 50, 4, 38)] // extraction_step = 0 + 4 + 34 = 38
+ : [leg(48, 15, 0, 8), leg(30, 50, 8, 46)]; // = 0 + 8 + 38 = 46
+ return { force_element: 'FE-KINGFISHER', route };
+}
+
+/** Build one candidate `Plan` from an axis signature (DEC-20 shape). */
+export function planFor(sig: AxisSignature, seed: number): Plan {
+ return {
+ logical_id: signatureId(sig),
+ version: 1,
+ name: signatureName(sig),
+ seed,
+ generator: 'spec-08 axis fan-out (approach × suppression × causeway × extraction)',
+ elements: [
+ broom(sig.approach),
+ packhorse(sig.approach, sig.causeway),
+ anvil(sig.suppression),
+ falcon(sig.suppression),
+ kingfisher(sig.extraction),
+ ],
+ };
+}
+
+/** All 16 axis signatures, in canonical enumeration order. */
+export function allSignatures(): AxisSignature[] {
+ const out: AxisSignature[] = [];
+ for (const approach of AXES.approach)
+ for (const suppression of AXES.suppression)
+ for (const causeway of AXES.causeway)
+ for (const extraction of AXES.extraction)
+ out.push({ approach, suppression, causeway, extraction });
+ return out;
+}
+
+/**
+ * The strategy-biased fan-out: one candidate `Plan` per axis signature. The seed
+ * is stamped onto each plan and (in the organiser) orders the diversity cap; the
+ * candidate SET is seed-independent — the organiser, not the generator, decides
+ * the handful, so generation stays honest fan-out rather than cherry-picking.
+ */
+export function generateCandidates(_config: VignetteConfig, seed: number): Plan[] {
+ return allSignatures().map((sig) => planFor(sig, seed));
+}
diff --git a/src/handful.ts b/src/handful.ts
new file mode 100644
index 0000000..e0f8f4b
--- /dev/null
+++ b/src/handful.ts
@@ -0,0 +1,218 @@
+/**
+ * SPEC-08 — the handful service (seam contract §6; delivery plan §δ, the last
+ * unit of the spine).
+ *
+ * POST /plan/handful {world, seed, count?}
+ * → {plans: Ref[], scores: PlanScore[], organisation: {distinct_because}, stamp}
+ *
+ * Three honest steps and nothing more:
+ * 1. GENERATE — a seeded, strategy-biased fan-out over the vignette's four axes
+ * (`src/generate.ts`), 16 candidate `Plan`s. The generator cherry-picks
+ * nothing; it hands every candidate forward.
+ * 2. SCORE — each candidate through the SPEC-07 scorer (`ScoreService`, REUSED,
+ * never re-implemented). The criteria vector is the per-commitment MARGIN
+ * band (satisfied ⟺ margin ≥ 0 — one uniform "higher is better" order).
+ * 3. ORGANISE — banded ε-non-domination (`src/dominance.ts`) keeps the plans no
+ * other conservatively beats on every commitment; a diversity cap trims to
+ * `count` by axis spread, NEVER by a scalar score (that would smuggle a
+ * weighting past DEC-19). `distinct_because` is derived from the survivors.
+ *
+ * Deterministic over `(world stamp, seed, count, engine)`: same inputs ⇒
+ * byte-identical stamp and identical ordered plans/scores/organisation (G1). It
+ * runs no `knowledge_overrides` perturbation (that stays scorer-only, seam §6)
+ * and calls no `/relax` (the commitment set stays whole — relaxation is SPEC-09).
+ */
+import type { Commitment, CompiledWorld, PlanScore } from './generated/types.js';
+import { ObjectStore, type Ref } from './store.js';
+import { contentHash } from './canonical.js';
+import { ScoreService } from './score.js';
+import { isRefusal, type HandfulRequest, type HandfulResult, type Refusal } from './seam.js';
+import { allSignatures, planFor, type AxisSignature } from './generate.js';
+import { type Criterion, type CriteriaVector, nonDominated, noWorse, strictlyBetter } from './dominance.js';
+import type { VignetteConfig } from './generated/types.js';
+
+const COUNT_MIN = 3;
+const COUNT_MAX = 5;
+const DEFAULT_SCENARIO = 'BASE';
+
+/** One axis word per axis, for `distinct_because` (matches generate.ts phrasing). */
+const AXIS_PHRASE: Record = {
+ strait_early: 'strait-early', sweep_first: 'sweep-first',
+ fires_forward: 'fires-forward', stand_off: 'stand-off',
+ contest: 'contest', bypass: 'bypass',
+ pull_early: 'pull-early', mission_tail: 'mission-tail',
+};
+
+interface Candidate {
+ sig: AxisSignature;
+ planRef: Ref;
+ vector: CriteriaVector; // per-commitment margin band (or null = violated)
+ scores: PlanScore[];
+}
+
+export class HandfulService {
+ #store: ObjectStore;
+ #scorer: ScoreService;
+ #config: VignetteConfig;
+ #commitmentIds: string[]; // canonical criterion order (by logical_id)
+
+ constructor(opts: {
+ store: ObjectStore;
+ scorer: ScoreService;
+ config: VignetteConfig;
+ commitments: Commitment[];
+ }) {
+ this.#store = opts.store;
+ this.#scorer = opts.scorer;
+ this.#config = opts.config;
+ this.#commitmentIds = [...opts.commitments].map((c) => c.logical_id).sort(cmp);
+ }
+
+ /** POST /plan/handful — pure over its stamped inputs; refuses first-class. */
+ async handful(req: HandfulRequest): Promise {
+ const world = this.#resolveWorld(req.world);
+ if (!world) {
+ return refusal('unknown_ref', [req.world], `${req.world.logical_id}: no such world to plan against.`);
+ }
+ const count = clamp(req.count ?? COUNT_MAX, COUNT_MIN, COUNT_MAX);
+ const scenario = world.scenario ?? DEFAULT_SCENARIO;
+
+ // 1–2. Generate the fan-out, then score every candidate (store first so the
+ // scorer can resolve each plan ref; storage is content-addressed/idempotent).
+ // The signature travels WITH each plan — no id round-trip.
+ const candidates: Candidate[] = [];
+ for (const sig of allSignatures()) {
+ const plan = planFor(sig, req.seed);
+ const planRef = await this.#store.put(plan as unknown as Record);
+ const scored = await this.#scorer.score({
+ plan: planRef,
+ world: req.world,
+ scenario,
+ engine_version: req.engine_version,
+ });
+ if (isRefusal(scored)) return scored; // a world that refuses scoring refuses the handful
+ const byCommitment = new Map(scored.verdicts.map((v) => [v.commitment, v]));
+ const vector: CriteriaVector = this.#commitmentIds.map(
+ (id) => (byCommitment.get(id)?.margin ?? null) as Criterion,
+ );
+ candidates.push({ sig, planRef, vector, scores: scored.scores });
+ }
+
+ // 3. Organise: the ε-non-dominated frontier (ε = 0, scale-free), then a
+ // deterministic diversity cap to `count`.
+ const frontier = nonDominated(candidates.map((c) => c.vector)).map((i) => candidates[i]!);
+ const selected = this.#cap(frontier, count, req.seed);
+
+ // Derive `distinct_because` from the survivors: the commitments each leads on
+ // (strictly better than every OTHER survivor) plus its axis signature.
+ const distinct_because = selected.map((c) => this.#reason(c, selected));
+
+ // Deterministic stamp over inputs only (never over materialised cells — G1).
+ const stamp = await contentHash({
+ world: world.stamp,
+ seed: req.seed,
+ count,
+ engine_version: req.engine_version,
+ });
+
+ return {
+ plans: selected.map((c) => c.planRef),
+ scores: selected.flatMap((c) => c.scores),
+ organisation: { distinct_because },
+ stamp,
+ };
+ }
+
+ #resolveWorld(ref: Ref): CompiledWorld | undefined {
+ if (ref.content_hash) return this.#store.get(ref.content_hash) as CompiledWorld | undefined;
+ const versions = this.#store.versions(ref.logical_id);
+ const hash = versions.length > 0 ? versions[versions.length - 1]!.content_hash : undefined;
+ return hash ? (this.#store.get(hash) as CompiledWorld) : undefined;
+ }
+
+ /**
+ * Cap the frontier to `count` by axis DIVERSITY (never a scalar). Greedy: seed
+ * the pick order deterministically, take the first, then repeatedly add the
+ * candidate whose signature is farthest (max Hamming distance over the four
+ * axes) from those already chosen; ties break on the seed key. When the frontier
+ * already fits, return it in the deterministic seed order (no padding).
+ */
+ #cap(frontier: Candidate[], count: number, seed: number): Candidate[] {
+ const ordered = [...frontier].sort(
+ (a, b) => seedKey(seed, a.planRef.logical_id) - seedKey(seed, b.planRef.logical_id)
+ || cmp(a.planRef.logical_id, b.planRef.logical_id),
+ );
+ if (ordered.length <= count) return ordered;
+ const chosen: Candidate[] = [ordered[0]!];
+ while (chosen.length < count) {
+ let best: Candidate | undefined;
+ let bestDist = -1;
+ for (const cand of ordered) {
+ if (chosen.includes(cand)) continue;
+ const dist = Math.min(...chosen.map((c) => hamming(c.sig, cand.sig)));
+ if (dist > bestDist) {
+ bestDist = dist;
+ best = cand;
+ }
+ }
+ chosen.push(best!);
+ }
+ // Present the chosen set in the same deterministic seed order.
+ return ordered.filter((c) => chosen.includes(c));
+ }
+
+ /**
+ * Why the organiser kept this plan: the commitments it LEADS on (at least as
+ * good as every other survivor and strictly better than at least one) plus its
+ * axis signature. Derived from the frontier — never a hand-authored caption.
+ */
+ #reason(c: Candidate, handful: Candidate[]): string {
+ const others = handful.filter((o) => o !== c);
+ const leads = this.#commitmentIds.filter((_, i) => {
+ const a = c.vector[i]!;
+ const noOneBeatsMe = others.every((o) => noWorse(a, o.vector[i]!));
+ const iBeatSomeone = others.some((o) => strictlyBetter(a, o.vector[i]!));
+ return noOneBeatsMe && iBeatSomeone;
+ });
+ const axes = signatureWords(c.sig).join(' · ');
+ if (leads.length > 0) {
+ return `${axes} — leads the handful on ${leads.join(', ')}; non-dominated (no plan beats it on every commitment).`;
+ }
+ return `${axes} — a distinct trade-off; non-dominated (no plan beats it on every commitment).`;
+ }
+}
+
+// ————— helpers —————
+
+const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
+const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, Math.round(n)));
+
+const signatureWords = (s: AxisSignature): string[] => [
+ AXIS_PHRASE[s.approach]!, AXIS_PHRASE[s.suppression]!, AXIS_PHRASE[s.causeway]!, AXIS_PHRASE[s.extraction]!,
+];
+
+/** Axis Hamming distance: how many of the four axes two signatures differ on. */
+function hamming(a: AxisSignature, b: AxisSignature): number {
+ return (a.approach !== b.approach ? 1 : 0)
+ + (a.suppression !== b.suppression ? 1 : 0)
+ + (a.causeway !== b.causeway ? 1 : 0)
+ + (a.extraction !== b.extraction ? 1 : 0);
+}
+
+/** Deterministic seed-keyed order (FNV-1a over `${seed}:${id}`) — no PRNG, no clock. */
+function seedKey(seed: number, id: string): number {
+ let h = 0x811c9dc5;
+ const s = `${seed}:${id}`;
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 0x01000193) >>> 0;
+ }
+ return h;
+}
+
+const refusal = (reason: Refusal['reason'], offending: Ref[], explanation: string): Refusal => ({
+ refused: true,
+ reason,
+ offending,
+ explanation,
+});
diff --git a/src/seam.ts b/src/seam.ts
index 1013686..01e0761 100644
--- a/src/seam.ts
+++ b/src/seam.ts
@@ -79,3 +79,30 @@ export interface ScoreSuccess {
}
export type ScoreResult = ScoreSuccess | Refusal;
+
+/**
+ * SPEC-08 — handful movement types (seam §6). `/plan/handful` generates a
+ * strategy-biased fan-out, scores each via §5, and organises by banded
+ * non-domination into 3–5 genuinely distinct plans. `count` is clamped to the
+ * contract band [3,5]. `organisation.distinct_because` is a DERIVED view (one
+ * reason per returned plan, aligned to `plans`), never a hand-authored caption.
+ */
+export interface HandfulRequest {
+ world: Ref;
+ seed: number;
+ count?: number; // clamped to [3,5]; default 5
+ engine_version: string;
+}
+
+export interface HandfulOrganisation {
+ distinct_because: string[]; // aligned index-for-index with `plans`
+}
+
+export interface HandfulSuccess {
+ plans: Ref[];
+ scores: PlanScore[];
+ organisation: HandfulOrganisation;
+ stamp: string;
+}
+
+export type HandfulResult = HandfulSuccess | Refusal;
diff --git a/tests/dominance.test.ts b/tests/dominance.test.ts
new file mode 100644
index 0000000..a927950
--- /dev/null
+++ b/tests/dominance.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest';
+import type { Band } from '../src/generated/types.js';
+import {
+ type CriteriaVector,
+ distinct,
+ dominates,
+ nonDominated,
+ noWorse,
+ strictlyBetter,
+} from '../src/dominance.js';
+
+// Margin bands are a maximisation criterion (satisfied ⟺ margin ≥ 0), so higher
+// is better. Unit is irrelevant to the order (same criterion index ⇒ same unit).
+const b = (lo: number, hi: number): Band => ({ lo, hi, unit: 'step' });
+
+describe('SPEC-08 — banded ε-non-domination (research note 03 §5)', () => {
+ describe('the conservative interval order', () => {
+ it('noWorse: A is no worse than B unless B sits entirely above A', () => {
+ expect(noWorse(b(5, 6), b(1, 2))).toBe(true); // A above B
+ expect(noWorse(b(1, 2), b(5, 6))).toBe(false); // B entirely above A
+ expect(noWorse(b(1, 4), b(3, 6))).toBe(true); // overlap ⇒ no worse (incomparable)
+ expect(noWorse(b(2, 2), b(2, 2))).toBe(true); // equal ⇒ no worse
+ });
+
+ it('strictlyBetter: A must be separated conservatively above B (by more than ε)', () => {
+ expect(strictlyBetter(b(5, 6), b(1, 2))).toBe(true); // A entirely above B
+ expect(strictlyBetter(b(2, 2), b(2, 2))).toBe(false); // equal is NOT strictly better
+ expect(strictlyBetter(b(1, 4), b(3, 6))).toBe(false); // overlap is NOT strictly better
+ expect(strictlyBetter(b(5, 6), b(1, 2), 10)).toBe(false); // ε too large to clear
+ });
+
+ it('violated (null) is the worst possible value on a criterion', () => {
+ expect(noWorse(b(-9, -9), null)).toBe(true); // any finite margin ≥ unreachable
+ expect(noWorse(null, b(-9, -9))).toBe(false); // unreachable < any finite margin
+ expect(strictlyBetter(b(-9, -9), null)).toBe(true); // reachable strictly beats unreachable
+ expect(strictlyBetter(null, b(1, 1))).toBe(false); // the worst never strictly better
+ expect(noWorse(null, null)).toBe(true); // worst is no-worse only than the worst
+ expect(strictlyBetter(null, null)).toBe(false);
+ });
+ });
+
+ describe('domination', () => {
+ it('drops a plan strictly worse on every criterion (US3-1)', () => {
+ const a: CriteriaVector = [b(5, 6), b(5, 6), b(5, 6)];
+ const worse: CriteriaVector = [b(1, 2), b(1, 2), b(1, 2)];
+ expect(dominates(a, worse)).toBe(true);
+ expect(dominates(worse, a)).toBe(false); // asymmetric
+ expect(nonDominated([a, worse])).toEqual([0]); // only A survives
+ });
+
+ it('keeps both plans of a genuine trade-off (US3-2)', () => {
+ const a: CriteriaVector = [b(5, 6), b(1, 2)]; // wins criterion 0
+ const c: CriteriaVector = [b(1, 2), b(5, 6)]; // wins criterion 1
+ expect(dominates(a, c)).toBe(false);
+ expect(dominates(c, a)).toBe(false);
+ expect(distinct(a, c)).toBe(true);
+ expect(nonDominated([a, c]).sort()).toEqual([0, 1]);
+ });
+
+ it('is irreflexive — no plan dominates itself (US3-4)', () => {
+ expect(dominates([b(2, 2), b(3, 3)], [b(2, 2), b(3, 3)])).toBe(false);
+ expect(dominates([b(1, 4)], [b(1, 4)])).toBe(false);
+ });
+
+ it('a no-worse-everywhere-and-strictly-better-once plan dominates', () => {
+ const a: CriteriaVector = [b(5, 6), b(3, 4)]; // equal-ish on 1, strictly better on 0
+ const c: CriteriaVector = [b(1, 2), b(3, 4)];
+ expect(dominates(a, c)).toBe(true);
+ expect(dominates(c, a)).toBe(false);
+ });
+
+ it('throws on misaligned criteria vectors', () => {
+ expect(() => dominates([b(1, 1)], [b(1, 1), b(2, 2)])).toThrow(/differing length/);
+ });
+ });
+
+ describe('ε is the distinctness knob — increasing it never shrinks the set', () => {
+ it('is monotone in ε (US3-3)', () => {
+ // A dominates B at ε=0 (separated by 1); raising ε past the gap frees B.
+ const vectors: CriteriaVector[] = [
+ [b(5, 6), b(1, 2)],
+ [b(3, 4), b(1, 2)],
+ ];
+ const at0 = nonDominated(vectors, 0);
+ const at1 = nonDominated(vectors, 1); // gap is 5-4 = 1, not > 1 ⇒ no domination
+ const at5 = nonDominated(vectors, 5);
+ expect(at0).toEqual([0]); // B dominated at ε=0
+ expect(at1.length).toBeGreaterThanOrEqual(at0.length); // never shrinks
+ expect(at5.sort()).toEqual([0, 1]); // both distinct at a large ε
+ });
+ });
+});
diff --git a/tests/generate.test.ts b/tests/generate.test.ts
new file mode 100644
index 0000000..b031d9a
--- /dev/null
+++ b/tests/generate.test.ts
@@ -0,0 +1,55 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import type { VignetteConfig } from '../src/generated/types.js';
+import { validateInstance } from '../src/validate.js';
+import { AXES, allSignatures, generateCandidates, signatureId } from '../src/generate.js';
+
+const config = JSON.parse(
+ readFileSync(new URL('../fixtures/vignette-config.json', import.meta.url), 'utf8'),
+) as VignetteConfig;
+
+const FORCE_ELEMENTS = ['FE-BROOM', 'FE-PACKHORSE', 'FE-ANVIL', 'FE-FALCON', 'FE-KINGFISHER'];
+
+describe('SPEC-08 — the seeded axis fan-out generator (research note 03 §5.2)', () => {
+ it('produces one valid Plan per axis signature (2⁴ = 16) with all five force elements (US4-1)', () => {
+ const plans = generateCandidates(config, 1);
+ expect(plans).toHaveLength(16);
+ for (const p of plans) {
+ expect(validateInstance('Plan', p)).toEqual([]); // validates against the generated LinkML type
+ const elements = p.elements.map((e) => e.force_element).sort();
+ expect(elements).toEqual([...FORCE_ELEMENTS].sort());
+ expect(p.logical_id.startsWith('P-')).toBe(true); // P* frozen family (vignette §8)
+ expect(p.generator).toMatch(/axis fan-out/);
+ }
+ const ids = new Set(plans.map((p) => p.logical_id));
+ expect(ids.size).toBe(16); // every signature is a distinct plan
+ });
+
+ it('spans both settings of all four axes across the candidate set (US4-2)', () => {
+ const sigs = allSignatures();
+ for (const axis of Object.keys(AXES) as (keyof typeof AXES)[]) {
+ const settings = new Set(sigs.map((s) => s[axis]));
+ expect(settings.size).toBe(2); // both settings of each axis appear
+ }
+ });
+
+ it('is deterministic in (config, seed) — same seed ⇒ identical candidate list (US4-3)', () => {
+ const a = generateCandidates(config, 7);
+ const b = generateCandidates(config, 7);
+ expect(JSON.stringify(a)).toBe(JSON.stringify(b));
+ });
+
+ it('stamps the seed onto every candidate; the candidate SET is seed-independent', () => {
+ const a = generateCandidates(config, 1);
+ const b = generateCandidates(config, 2);
+ expect(a.every((p) => p.seed === 1)).toBe(true);
+ expect(b.every((p) => p.seed === 2)).toBe(true);
+ // Same signatures, only the stamped seed differs (the organiser uses the seed).
+ expect(a.map((p) => p.logical_id)).toEqual(b.map((p) => p.logical_id));
+ });
+
+ it('the logical id encodes the axis signature (self-documenting P* id)', () => {
+ expect(signatureId({ approach: 'strait_early', suppression: 'fires_forward', causeway: 'contest', extraction: 'pull_early' }))
+ .toBe('P-SeFfCcXe');
+ });
+});
diff --git a/tests/handful.test.ts b/tests/handful.test.ts
new file mode 100644
index 0000000..8505b3c
--- /dev/null
+++ b/tests/handful.test.ts
@@ -0,0 +1,179 @@
+import { readFileSync } from 'node:fs';
+import { beforeEach, describe, expect, it } from 'vitest';
+import type {
+ Commitment,
+ CommitmentVerdict,
+ KnowledgeObject,
+ Plan,
+ ScenarioCOA,
+ VignetteConfig,
+} from '../src/generated/types.js';
+import { KnowledgeService } from '../src/knowledge.js';
+import { CompileService } from '../src/compile.js';
+import { ScoreService } from '../src/score.js';
+import { HandfulService } from '../src/handful.js';
+import { isRefusal, type HandfulResult } from '../src/seam.js';
+import { type Criterion, type CriteriaVector, distinct } from '../src/dominance.js';
+import type { Ref } from '../src/store.js';
+
+const load = (name: string): T[] =>
+ JSON.parse(readFileSync(new URL(`../fixtures/${name}.json`, import.meta.url), 'utf8')) as T[];
+
+const knowledge = load('knowledge');
+const coas = load('coas');
+const commitments = load('commitments');
+const config = JSON.parse(
+ readFileSync(new URL('../fixtures/vignette-config.json', import.meta.url), 'utf8'),
+) as VignetteConfig;
+
+const byId = new Map(knowledge.map((k) => [k.logical_id, k]));
+const K = (id: string): KnowledgeObject => structuredClone(byId.get(id)!);
+const answered = (id: string): KnowledgeObject => ({ ...K(id), status: 'answered' });
+const ref = (id: string): Ref => ({ logical_id: id, content_hash: '' });
+const BASE = ['K1', 'K2', 'K3', 'K4', 'K6', 'K7', 'K8', 'K9'];
+const ENGINE = '0.1.0';
+const cids = commitments.map((c) => c.logical_id).sort();
+
+interface Rig {
+ svc: KnowledgeService;
+ scorer: ScoreService;
+ handfuls: HandfulService;
+ worldRef: Ref;
+}
+
+async function setup(): Promise {
+ const svc = new KnowledgeService();
+ for (const id of BASE) await svc.create(answered(id));
+ await svc.create(K('K12a'));
+ await svc.create(K('K12b'));
+ svc.contest('K12a', 'K12b');
+ svc.resolve('K12a', 'defector debrief corroborated');
+ for (const coa of coas) await svc.store.put(coa as unknown as Record);
+ for (const c of commitments) await svc.store.put(c as unknown as Record);
+
+ const compiler = new CompileService({ knowledge: svc });
+ const compiled = await compiler.compile({ knowledge: [...BASE, 'K12a'].map(ref), config, engine_version: ENGINE });
+ if (isRefusal(compiled)) throw new Error(`compile refused: ${compiled.reason}`);
+
+ const scorer = new ScoreService({ store: svc.store, trace: svc.trace, config, commitments });
+ const handfuls = new HandfulService({ store: svc.store, scorer, config, commitments });
+ return { svc, scorer, handfuls, worldRef: compiled.world };
+}
+
+const ok = (r: HandfulResult) => {
+ if (isRefusal(r)) throw new Error(`expected success, got refusal ${r.reason}`);
+ return r;
+};
+
+/** Re-score a returned plan to recover its C1–C6 margin vector for domination checks. */
+async function vectorOf(rig: Rig, planRef: Ref): Promise {
+ const scored = await rig.scorer.score({ plan: planRef, world: rig.worldRef, scenario: 'BASE', engine_version: ENGINE });
+ if (isRefusal(scored)) throw new Error('re-score refused');
+ const byC = new Map(scored.verdicts.map((v: CommitmentVerdict) => [v.commitment, v]));
+ return cids.map((id) => (byC.get(id)?.margin ?? null) as Criterion);
+}
+
+describe('SPEC-08 — /plan/handful over the Meridian base world', () => {
+ let rig: Rig;
+ beforeEach(async () => {
+ rig = await setup();
+ });
+
+ it('returns 3–5 plans, each resolving to a stored Plan (US1-1, SC-001)', async () => {
+ const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ expect(h.plans.length).toBeGreaterThanOrEqual(3);
+ expect(h.plans.length).toBeLessThanOrEqual(5);
+ for (const p of h.plans) {
+ const plan = rig.svc.store.get(p.content_hash) as Plan | undefined;
+ expect(plan).toBeDefined();
+ expect(plan!.logical_id).toBe(p.logical_id);
+ }
+ });
+
+ it('every pair in the handful is mutually non-dominated (US1-2, SC-001)', async () => {
+ const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ const vectors = await Promise.all(h.plans.map((p) => vectorOf(rig, p)));
+ for (let i = 0; i < vectors.length; i++) {
+ for (let j = i + 1; j < vectors.length; j++) {
+ expect(distinct(vectors[i]!, vectors[j]!)).toBe(true); // neither ε-dominates the other
+ }
+ }
+ });
+
+ it('the handful shows more than one distinct verdict on at least two commitments (US1-3)', async () => {
+ const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ const perCommitment: Map> = new Map(cids.map((id) => [id, new Set()]));
+ for (const p of h.plans) {
+ const scored = await rig.scorer.score({ plan: p, world: rig.worldRef, scenario: 'BASE', engine_version: ENGINE });
+ if (isRefusal(scored)) throw new Error('score refused');
+ for (const v of scored.verdicts) perCommitment.get(v.commitment)!.add(v.verdict);
+ }
+ const varying = [...perCommitment.values()].filter((s) => s.size > 1).length;
+ expect(varying).toBeGreaterThanOrEqual(2);
+ });
+
+ it('same (world, seed) ⇒ identical stamp, plans, scores, organisation (US2-1, SC-002)', async () => {
+ const a = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ const b = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ expect(a.stamp).toBe(b.stamp);
+ expect(a.plans).toEqual(b.plans);
+ expect(a.scores).toEqual(b.scores);
+ expect(a.organisation).toEqual(b.organisation);
+ });
+
+ it('a different seed changes the stamp (US2-2, SC-002)', async () => {
+ const a = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ const b = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 2, engine_version: ENGINE }));
+ expect(a.stamp).not.toBe(b.stamp);
+ });
+
+ it('derives a non-empty distinct_because per plan, aligned to plans (FR-010, SC-006)', async () => {
+ const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ expect(h.organisation.distinct_because).toHaveLength(h.plans.length);
+ for (const reason of h.organisation.distinct_because) {
+ expect(reason.length).toBeGreaterThan(0);
+ expect(reason).toMatch(/non-dominated/);
+ }
+ });
+
+ it('every handful member opens a complete trace chain verdict → world → knowledge (G3, SC-005)', async () => {
+ const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, engine_version: ENGINE }));
+ const worldHash = rig.worldRef.content_hash;
+ for (const p of h.plans) {
+ const scored = await rig.scorer.score({ plan: p, world: rig.worldRef, scenario: 'BASE', engine_version: ENGINE });
+ if (isRefusal(scored)) throw new Error('score refused');
+ // Each verdict was written with a scored_from edge to the world it rests on…
+ const verdictHash = (await rig.svc.store.put(scored.verdicts[0]! as unknown as Record)).content_hash;
+ const scoredFrom = rig.svc.trace.edges.filter(
+ (e) => e.edge_type === 'scored_from' && e.from_hash === verdictHash && e.to_hash === worldHash,
+ );
+ expect(scoredFrom.length).toBeGreaterThan(0);
+ }
+ // …and the world carries incoming compiled_into edges from named knowledge, so
+ // the chain verdict → world → knowledge terminates at named owners (G3).
+ const compiledInto = rig.svc.trace.edges.filter((e) => e.edge_type === 'compiled_into' && e.to_hash === worldHash);
+ expect(compiledInto.length).toBeGreaterThan(0);
+ });
+
+ describe('count clamping (FR-001)', () => {
+ it('count = 3 returns exactly 3 by diversity cap (SC-001)', async () => {
+ const h = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, count: 3, engine_version: ENGINE }));
+ expect(h.plans).toHaveLength(3);
+ });
+
+ it('count below 3 is clamped up to 3, count above 5 is clamped down to 5', async () => {
+ const low = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, count: 1, engine_version: ENGINE }));
+ expect(low.plans.length).toBeGreaterThanOrEqual(3);
+ const high = ok(await rig.handfuls.handful({ world: rig.worldRef, seed: 1, count: 9, engine_version: ENGINE }));
+ expect(high.plans.length).toBeLessThanOrEqual(5);
+ });
+ });
+
+ it('refuses unknown_ref when the world cannot be resolved, persisting nothing (FR-011)', async () => {
+ const before = rig.svc.store.size;
+ const r = await rig.handfuls.handful({ world: { logical_id: 'NO-SUCH-WORLD', content_hash: '' }, seed: 1, engine_version: ENGINE });
+ expect(isRefusal(r)).toBe(true);
+ if (isRefusal(r)) expect(r.reason).toBe('unknown_ref');
+ expect(rig.svc.store.size).toBe(before); // nothing generated/stored on refusal
+ });
+});