diff --git a/website/SPEC.md b/website/SPEC.md
index 981b34d..da19955 100644
--- a/website/SPEC.md
+++ b/website/SPEC.md
@@ -17,8 +17,9 @@ Automated observation results are rendered separately with an explicit denominat
Everything renders on one sheet — no tabs, no view flips (they contradicted the one-pager paradigm and were removed):
-- **Section II, The Machinery — territory cards.** The ten program territories render as cards side by side (CSS columns, 2-up): *The Perimeter* (identity wall, charter, front door), *The Loop* (the SDLC — every change funnels through here), *The Sirens* (detect → respond → learn), *The People* (join → recertify → leave), *The Custody* (classify → back up → prove restore → dispose), *The Counterparties* (vendor lifecycle + cloud-inherited CC6.4), *The Risk*, *The Product* (PI, dimmed when out of scope), *The Subjects* (privacy, dimmed when out of scope), *The Clock*. Each card lists its stations one line each, with procedure pins right-aligned as status-colored chips (green verified, amber installed, red failing, grey not installed). Nothing is explained in visible text — hovering opens the same CSS-only modal used by the criteria matrix: a station shows `name · criteria` plus its description; a pin shows `id · category · status`, the procedure name, the detection command, and serves/install/last-checked. A `:has()` rule suppresses the station modal while a pin inside it is hovered. Machinery-less stations read "procedural."
-- **Section III, The Criteria — the card matrix.** All 61 criteria as a grid of small cards, each carrying: the ID (`CC6.1`, status-colored, with a matching left border), a **two-word essence** ("Access Security", "Change Management", "Breach Notification"…), the category tag (`security`), and a **nature icon** in the corner — ⚙︎ technical (evidence is system state; 29 criteria) vs ¶ document-only (evidence is human-authored paper — policies, registers, minutes, attestations; 32 criteria). The nature comes from a `nature: technical|document` frontmatter field in each criterion file; machinery pins get the same treatment in their modal header (paper/cadence procedures are ¶). Hovering opens a CSS-only modal (no JS) containing the verbatim TSP §100 criterion text and nothing more, headed by `id · category · status`; clicking goes to the evidence page (`/criteria/{id}`). Out-of-scope criteria render dimmed/italic. The two-word labels live in `LABELS` in `app/src/render.rs`, and a third test — `labels_cover_all_criteria` — asserts every criterion has exactly one label of exactly two words.
+- **Section II, The Gap — next-action ledger.** Every in-scope criterion appears exactly once in one of three action buckets: *Build or fix now* (`not_started`/`failing`), *Prove it operates* (`implemented`/`in_progress`), or *Review over time* (`verified`). Each bucket explains why the items are there, the next action, and links every criterion to its evidence page. A separate Type II time-gap notice states that only a CPA-agreed examination period and auditor testing can close the observation-period gap.
+- **Section III, The Machinery — territory cards.** The ten program territories render as cards side by side (CSS columns, 2-up): *The Perimeter* (identity wall, charter, front door), *The Loop* (the SDLC — every change funnels through here), *The Sirens* (detect → respond → learn), *The People* (join → recertify → leave), *The Custody* (classify → back up → prove restore → dispose), *The Counterparties* (vendor lifecycle + cloud-inherited CC6.4), *The Risk*, *The Product* (PI, dimmed when out of scope), *The Subjects* (privacy, dimmed when out of scope), *The Clock*. Each card lists its stations one line each, with procedure pins right-aligned as status-colored chips (green verified, amber installed, red failing, grey not installed). Nothing is explained in visible text — hovering opens the same CSS-only modal used by the criteria matrix: a station shows `name · criteria` plus its description; a pin shows `id · category · status`, the procedure name, the detection command, and serves/install/last-checked. A `:has()` rule suppresses the station modal while a pin inside it is hovered. Machinery-less stations read "procedural."
+- **Section IV, The Criteria — the card matrix.** All 61 criteria as a grid of small cards, each carrying: the ID (`CC6.1`, status-colored, with a matching left border), a **two-word essence** ("Access Security", "Change Management", "Breach Notification"…), the category tag (`security`), and a **nature icon** in the corner — ⚙︎ technical (evidence is system state; 29 criteria) vs ¶ document-only (evidence is human-authored paper — policies, registers, minutes, attestations; 32 criteria). The nature comes from a `nature: technical|document` frontmatter field in each criterion file; machinery pins get the same treatment in their modal header (paper/cadence procedures are ¶). Hovering opens a CSS-only modal (no JS) containing the verbatim TSP §100 criterion text and nothing more, headed by `id · category · status`; clicking goes to the evidence page (`/criteria/{id}`). Out-of-scope criteria render dimmed/italic. The two-word labels live in `LABELS` in `app/src/render.rs`, and a third test — `labels_cover_all_criteria` — asserts every criterion has exactly one label of exactly two words.
**Coverage invariants, enforced by `cargo test`:** `map_covers_all_criteria` expands every station's criteria tag (including ranges) and asserts all 61 criteria in `criteria/` appear across the cards; `every_procedure_is_pinned_once` asserts the pinned set exactly matches the procedure IDs defined in `procedures/PROCEDURES.md` (34 today), each pinned once. Adding a criterion file or procedure without placing it on the map fails the build. The station→pin mapping is `MAP` in `app/src/render.rs`; unpinned DB procedures fall into a visible "Unpinned" card rather than disappearing.
@@ -105,9 +106,10 @@ Responsive: below 900px the sheet goes single-column (cards stack, header stacks
1. **Criterion maturity gauge** — fixed semicircular arc, 0–100%, needle at weighted in-scope evidence maturity. The formula and credits are printed beside it. Below the needle: the comparable trend from `gauge_history`; a metric-version migration discards incompatible legacy history.
2. **Evidence summary and category chips** — verified/implemented/not-started/failing criterion counts; applicable automated pass/fail/unknown counts with n/a separated; Security, Availability, Confidentiality, PI, and Privacy weighted sub-scores; out-of-scope categories greyed with "not in scope".
-3. **The Machinery** — the ten territory cards (see above).
-4. **The Criteria** — the 61-cell checkbox matrix (see above), mirroring [CHECKLIST.md](../CHECKLIST.md) content via hover.
-5. **Footer** — last verify run time, count of `unknown` checks ("blind spots"), link to `/db`.
+3. **The Gap** — explicit build/fix, prove-operation, and review-over-time buckets plus the Type II period gap.
+4. **The Machinery** — the ten territory cards (see above).
+5. **The Criteria** — the 61-cell checkbox matrix (see above), mirroring [CHECKLIST.md](../CHECKLIST.md) content via hover.
+6. **Footer** — last verify run time, count of `unknown` checks ("blind spots"), link to `/db`.
Honest-rendering rules: a stale verify run (>48h) banners the whole page ("state is stale — monitor may be dead"); `unknown` never displays as pass and not-applicable never inflates the applicable denominator; the gauge is always shown with its computation date, formula, and denominator. A perfect maturity score only means every in-scope criterion received full credit under this internal evidence rubric. It is never an auditor's opinion, a prediction of examination outcome, or a substitute for either a Type I CPA evaluation or a Type II observation period.
diff --git a/website/app/src/render.rs b/website/app/src/render.rs
index eb6df3d..51e149f 100644
--- a/website/app/src/render.rs
+++ b/website/app/src/render.rs
@@ -268,6 +268,92 @@ fn evidence_cards(m: &Model) -> String {
s
}
+fn gap_items(criteria: &[&Crit]) -> String {
+ if criteria.is_empty() {
+ return r#"none in this bucket"#.into();
+ }
+ let mut s = String::from(r#"
"#);
+ for criterion in criteria {
+ let _ = write!(
+ s,
+ r#"
{id}{label}"#,
+ id = esc(&criterion.id),
+ label = esc(label_for(&criterion.id)),
+ );
+ }
+ s.push_str("
");
+ s
+}
+
+fn gap_ledger(m: &Model) -> String {
+ let mut build_or_fix = Vec::new();
+ let mut prove_operating = Vec::new();
+ let mut observe_over_time = Vec::new();
+ for criterion in m.criteria.iter().filter(|criterion| criterion.in_scope) {
+ match criterion.status.as_str() {
+ "verified" => observe_over_time.push(criterion),
+ "implemented" | "in_progress" => prove_operating.push(criterion),
+ _ => build_or_fix.push(criterion),
+ }
+ }
+ for bucket in [&mut build_or_fix, &mut prove_operating, &mut observe_over_time] {
+ bucket.sort_by(|a, b| a.id.cmp(&b.id));
+ }
+ let present_work = build_or_fix.len() + prove_operating.len();
+ let build_grammar = if build_or_fix.len() == 1 { "criterion needs" } else { "criteria need" };
+ let prove_grammar = if prove_operating.len() == 1 { "criterion has" } else { "criteria have" };
+ let prove_need = if prove_operating.len() == 1 { "needs" } else { "need" };
+ let mut s = String::new();
+ let _ = write!(
+ s,
+ r#"II. The Gap {present_work} need action now · {observing} at review-over-time stage
{present_work} of {total} in-scope criteria still need present-period work. {build} {build_grammar} a control built or fixed; {prove} {prove_grammar} design evidence but still {prove_need} proof of operation. The remaining {observing} have point-in-time evidence and can accumulate operating history.
"#,
+ observing = observe_over_time.len(),
+ total = m.criterion_summary.in_scope,
+ build = build_or_fix.len(),
+ prove = prove_operating.len(),
+ build_grammar = build_grammar,
+ prove_grammar = prove_grammar,
+ prove_need = prove_need,
+ );
+ for (class, eyebrow, title, count, description, next, criteria) in [
+ (
+ "work",
+ "MORE WORK",
+ "Build or fix now",
+ build_or_fix.len(),
+ "The control is absent, incomplete, or failing. Implement or correct it before claiming evidence maturity.",
+ "make the control real, attach evidence, rerun",
+ build_or_fix.as_slice(),
+ ),
+ (
+ "prove",
+ "MORE WORK",
+ "Prove it operates",
+ prove_operating.len(),
+ "The design exists, but current evidence only establishes implementation. Execute the control and retain a dated result.",
+ "capture an operating sample",
+ prove_operating.as_slice(),
+ ),
+ (
+ "time",
+ "TIME / REVIEW",
+ "Review over time",
+ observe_over_time.len(),
+ "Point-in-time evidence exists. Keep the control operating and retain periodic samples through the auditor-agreed window.",
+ "maintain cadence; the CPA selects samples",
+ observe_over_time.as_slice(),
+ ),
+ ] {
+ let _ = write!(
+ s,
+ r#"
{eyebrow}
{title}{count} criteria
{description}
Next: {next}
{items}"#,
+ items = gap_items(criteria),
+ );
+ }
+ s.push_str(r#"
Type II time gap: not evaluated. Shadow can retain repeated evidence, but only a CPA-agreed examination start/end date and auditor testing can close the observation-period gap. A green automated check never starts or completes that clock.
"#);
+ s
+}
+
// ---------- the workflow map: the SDLC as territory, procedures as pins ----------
enum MapItem {
@@ -567,7 +653,7 @@ fn machinery_cards(m: &Model) -> String {
let mut s = String::new();
let _ = write!(
s,
- r#"II. The Machinery the program as territory · {done} of {} procedures verified · hover anything
"#,
+ r#"
III. The Machinery the program as territory · {done} of {} procedures verified · hover anything
"#,
m.procedures.len()
);
@@ -713,7 +799,7 @@ fn criteria_matrix(m: &Model) -> String {
let mut s = String::new();
let _ = write!(
s,
- r#"
III. The Criteria TSP §100 · {in_scope} in scope · {verified} verified · {failing} failing · ⚙︎ technical / ¶ document-only — hover for the verbatim criterion
"#
+ r#"
IV. The Criteria TSP §100 · {in_scope} in scope · {verified} verified · {failing} failing · ⚙︎ technical / ¶ document-only — hover for the verbatim criterion
"#
);
for c in &crits {
let cls = if !c.in_scope {
@@ -801,6 +887,16 @@ h1 .org{font-style:italic;font-weight:400;color:var(--faint)}
.provenance{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px 16px;margin-top:12px;padding:12px;border:1px solid var(--rule);font-family:"IBM Plex Mono",monospace;font-size:9.5px;line-height:1.5;color:var(--faint)}
.provenance span{overflow-wrap:anywhere}.provenance strong{color:var(--ink);text-transform:uppercase;letter-spacing:.06em}.provenance a{color:var(--deep)}
.integrity-note{font-family:"IBM Plex Mono",monospace;font-size:9px;line-height:1.5;color:var(--faint);margin-top:6px}
+.gap-summary{font-size:16px;line-height:1.55;margin:18px 0 14px;max-width:900px}.gap-summary strong{color:var(--deep)}
+.gap-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
+.gap-card{border:1px solid var(--rule);border-top:4px solid;padding:16px 16px 14px;background:rgba(33,28,20,.015)}
+.gap-card.work{border-top-color:var(--red)}.gap-card.prove{border-top-color:var(--amber)}.gap-card.time{border-top-color:var(--green)}
+.gap-eyebrow{font-family:"IBM Plex Mono",monospace;font-size:9px;letter-spacing:.16em;font-weight:600;color:var(--faint)}
+.gap-card h3{font-size:21px;margin:5px 0 8px}.gap-card h3 span{display:block;font-family:"IBM Plex Mono",monospace;font-size:10px;font-weight:400;color:var(--faint);margin-top:3px}
+.gap-card p{font-size:13.5px;line-height:1.55;min-height:84px}.gap-next{border-top:1px solid var(--rule);padding-top:8px;margin-top:9px;font-family:"IBM Plex Mono",monospace;font-size:9.5px;line-height:1.45;color:var(--faint)}
+.gap-items{display:flex;flex-wrap:wrap;gap:5px;margin-top:12px}.gap-item{display:inline-flex;gap:5px;border:1px solid var(--rule);padding:4px 6px;color:var(--ink);text-decoration:none;font-size:11px;line-height:1.2}.gap-item:hover{border-color:var(--ink);background:rgba(33,28,20,.03)}
+.gap-id{font-family:"IBM Plex Mono",monospace;font-size:9px;color:var(--faint)}.gap-empty{display:block;margin-top:12px;font-family:"IBM Plex Mono",monospace;font-size:9px;color:var(--faint)}
+.period-gap{margin-top:14px;padding:12px 14px;border:1px solid var(--ink);font-family:"IBM Plex Mono",monospace;font-size:10.5px;line-height:1.6;color:var(--faint)}.period-gap strong{color:var(--ink)}
.chip{display:grid;grid-template-columns:1fr auto;grid-template-rows:auto auto;padding:12px 4px;border-bottom:1px solid var(--rule)}
.chip-name{font-size:19px;font-weight:600}
.chip-score{font-family:"IBM Plex Mono",monospace;font-size:19px;grid-row:span 2;align-self:center}
@@ -894,6 +990,7 @@ a.back{font-family:"IBM Plex Mono",monospace;font-size:11px;letter-spacing:.14em
h1{font-size:30px}
.instrument{grid-template-columns:1fr;gap:26px;padding:24px 0 4px}
.provenance{grid-template-columns:1fr}
+ .gap-grid{grid-template-columns:1fr}.gap-card p{min-height:0}
.reading .big{font-size:46px}
.cards{column-count:1}
.st{flex-wrap:wrap}
@@ -1003,7 +1100,8 @@ pub fn index(m: &Model) -> String {
);
s.push_str("");
- // II + III
+ // II + III + IV
+ s.push_str(&gap_ledger(m));
s.push_str(&machinery_cards(m));
s.push_str(&criteria_matrix(m));
@@ -1309,4 +1407,44 @@ mod tests {
assert!(html.contains("1 n/a"));
assert!(!html.contains("would you pass an examination today"));
}
+
+ #[test]
+ fn gap_ledger_partitions_every_in_scope_criterion_by_next_action() {
+ let criterion = |id: &str, status: &str, in_scope: bool| Crit {
+ id: id.into(),
+ family: "CC1".into(),
+ category: "security".into(),
+ text: "criterion".into(),
+ weight: 1,
+ in_scope,
+ status: status.into(),
+ credit: 0.0,
+ nature: "document".into(),
+ failing: vec![],
+ };
+ let model = Model {
+ org: "example/repo".into(),
+ gauge: Gauge { value: 0.0, cap: None, cap_reason: None, ts: None, history: vec![], stale_hours: None },
+ observations: ObservationSummary { total: 0, pass: 0, fail: 0, unknown: 0, not_applicable: 0 },
+ criterion_summary: CriterionSummary { in_scope: 4, verified: 1, implemented: 1, failing: 1, not_started: 1 },
+ provenance: Provenance { repository: "example/repo".into(), commit: None, run_id: None, workflow: None, generator: None, report_signature: None },
+ criteria: vec![
+ criterion("CC1.1", "not_started", true),
+ criterion("CC1.2", "failing", true),
+ criterion("CC1.3", "implemented", true),
+ criterion("CC1.4", "verified", true),
+ criterion("P1.1", "not_started", false),
+ ],
+ procedures: vec![],
+ unknown_checks: 0,
+ };
+
+ let html = gap_ledger(&model);
+ assert!(html.contains("3 of 4 in-scope criteria still need present-period work"));
+ assert!(html.contains("2 criteria need a control built or fixed"));
+ assert!(html.contains("1 criterion has design evidence but still needs proof of operation"));
+ assert!(html.contains("Type II time gap: not evaluated"));
+ assert_eq!(html.matches("class=\"gap-item\"").count(), 4);
+ assert!(!html.contains("P1.1"), "out-of-scope criteria must not appear as gaps");
+ }
}