Commit cadec30c7d

cadec30c7d7c510c0eb48b6abc45913e3f4fc93e

parent: 3d273a7bf8

Unsigned

cmc <hello@cleberg.net> · 2026-08-19 23:58 UTC

Collapse branch protection and rulesets into one evidence row

Classic branch protection and repository rulesets are two implementations
of the same control (CC8.1 / A.8.32), but were reported as independent
signals: a repo whose default branch is covered by an active ruleset still
emitted a branch_protection/disabled row and counted as a change-control
gap the ruleset already addresses.

buildEvidenceRows now keeps one row per (framework, control, repo) for the
pair, preferring the mechanism actually in force. Both snapshots are still
recorded — the collapse is query-time, like the mapping join itself.

Closes #27

Layout: unified · split

docs/architecture.md +7 −1
@@ -227,7 +227,8 @@ flowchart TB
227 L --> J{{"JOIN control_mappings<br/>on resource + status"}} 227 L --> J{{"JOIN control_mappings<br/>on resource + status"}}
228 CM[("control_mappings")] --> J 228 CM[("control_mappings")] --> J
229 J --> F["filter by framework<br/>(soc2 | iso27001 | all)"] 229 J --> F["filter by framework<br/>(soc2 | iso27001 | all)"]
230 F --> E["evidence rows<br/>control · posture · rationale"] 230 F --> C["collapse branch_protection<br/>+ repository_ruleset to one row"]
231 C --> E["evidence rows<br/>control · posture · rationale"]
231 E --> CSV["renderCsv"] 232 E --> CSV["renderCsv"]
232 E --> PDF["renderPdf"] 233 E --> PDF["renderPdf"]
233``` 234```
@@ -235,6 +236,11 @@ flowchart TB
235Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply 236Unmapped `(resource, status)` pairs (e.g. `unavailable`, raw `push`) simply
236produce no rows — no evidence in either direction. 237produce no rows — no evidence in either direction.
237 238
239Classic branch protection and repository rulesets both attest the same control,
240so the two are collapsed to one row per (framework, control, repo) — enabled
241wins over disabled — rather than letting an unused mechanism report a gap the
242other one covers.
243
238## Boundaries & isolation 244## Boundaries & isolation
239 245
240- **Multi-tenant scoping.** Every session route is scoped to the session's 246- **Multi-tenant scoping.** Every session route is scoped to the session's
docs/framework-mapping.md +7
@@ -151,6 +151,13 @@ the next poll settles the state. See [`extractFact`](../src/webhook.ts).
151change control in a Git workflow. Enabled → **positive**; disabled → 151change control in a Git workflow. Enabled → **positive**; disabled →
152**negative** ("direct pushes possible" is a concrete change-control gap). 152**negative** ("direct pushes possible" is a concrete change-control gap).
153 153
154**One row per repo, not two.** The two are alternative implementations of the
155same control, so the evidence query collapses them to a single row per
156(framework, control, repo), keeping whichever mechanism is actually in force —
157a repo covered by an active ruleset is not a change-control gap merely because
158classic protection is off. Both snapshots are still recorded; the collapse
159happens at query time in [`collapseChangeControl`](../src/exporter.ts).
160
154**Fit assessment: strong, with the scope stated in the evidence itself.** Both 161**Fit assessment: strong, with the scope stated in the evidence itself.** Both
155frameworks name "change management" explicitly, and branch protection is the 162frameworks name "change management" explicitly, and branch protection is the
156canonical GitHub-native implementation of it. What the evidence attests is that 163canonical GitHub-native implementation of it. What the evidence attests is that
src/exporter.ts +30 −2
@@ -57,12 +57,40 @@ export async function buildEvidenceRows(
57 l.resource NOT IN ('org_member', 'team_member') 57 l.resource NOT IN ('org_member', 'team_member')
58 OR l.captured_at = (SELECT t FROM access_latest) 58 OR l.captured_at = (SELECT t FROM access_latest)
59 ) 59 )
60 ORDER BY cm.framework, cm.control_id, l.repo`, 60 -- l.resource last so the change-control collapse below sees
61 -- branch_protection before repository_ruleset deterministically.
62 ORDER BY cm.framework, cm.control_id, l.repo, l.resource`,
61 ) 63 )
62 .bind(installationId, framework) 64 .bind(installationId, framework)
63 .all<EvidenceRow>(); 65 .all<EvidenceRow>();
64 66
65 return results; 67 return collapseChangeControl(results);
68}
69
70// Classic branch protection and repository rulesets are two implementations of
71// the same control (CC8.1 / A.8.32), and a repo can have either, both, or
72// neither. Reported separately, a repo whose default branch is covered by an
73// active ruleset still emitted a `branch_protection` / `disabled` row and
74// counted as a change-control gap that isn't one. Collapse the pair to one row
75// per (framework, control, repo), keeping the mechanism actually in force:
76// enabled beats disabled, and classic protection wins an otherwise-equal tie
77// because its rationale describes the repo's state without naming a mechanism
78// the reader may not use.
79const CHANGE_CONTROL_RESOURCES = new Set(["branch_protection", "repository_ruleset"]);
80
81function collapseChangeControl(rows: EvidenceRow[]): EvidenceRow[] {
82 const groupKey = (row: EvidenceRow) => `${row.framework}|${row.control_id}|${row.repo}`;
83 const winners = new Map<string, EvidenceRow>();
84
85 for (const row of rows) {
86 if (!CHANGE_CONTROL_RESOURCES.has(row.resource)) continue;
87 const incumbent = winners.get(groupKey(row));
88 if (!incumbent || (row.posture === "positive" && incumbent.posture !== "positive")) {
89 winners.set(groupKey(row), row);
90 }
91 }
92
93 return rows.filter((row) => !CHANGE_CONTROL_RESOURCES.has(row.resource) || winners.get(groupKey(row)) === row);
66} 94}
67 95
68const CSV_COLUMNS: Array<keyof EvidenceRow> = [ 96const CSV_COLUMNS: Array<keyof EvidenceRow> = [