From 3fc5e173d8ce3c5b26c44311d26e2a0d9bf2011c Mon Sep 17 00:00:00 2001 From: Dor Kalev Date: Thu, 6 Aug 2026 10:01:57 +0300 Subject: [PATCH] Add actionable gap ledger --- website/SPEC.md | 12 ++-- website/app/src/render.rs | 144 +++++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 8 deletions(-) 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"); + } }