From 42bbf5d2dca856e1283755974e9b0f11461222c4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 20 Aug 2026 07:43:51 +0200 Subject: [PATCH] Adaptive check cadence: the interval is earned, not assigned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling 2026-08-20. Severity no longer sets the review interval. A check that comes back clean climbs one rung — instant, 1h, 8h, 24h, 48h, 96h, 7d, 14d, 1mo, 1q — and anything wrong drops straight back to instant. A quarter is the ceiling. The operator may defer an instant finding to a stated date; that is the only other way off the bottom rung. The rung is the point: it says how stable the estate has been on that matter, which is information severity does not carry. Volatile things get attention automatically; quiet things stop consuming it; neither judgement has to be made by a person who might be busy. Escalation trigger 5 rebased onto the ladder — fourteen days at the bottom rung, whether that is failing checks or no checks. Co-Authored-By: Claude Opus 5 --- REGISTER.md | 31 ++--- docs/method/escalation.md | 35 ++---- docs/method/review.md | 118 ++++++++++++++---- findings/README.md | 19 ++- ...-F-0001-flex-auth-unauthenticated-check.md | 6 +- .../RISK-F-0002-ops-warden-sign-ungated.md | 6 +- ...ops-warden-read-boundary-ungraded-lanes.md | 47 ++++++- ...004-tenant-engine-unfiltered-event-read.md | 6 +- ...-F-0005-audit-core-unfiltered-read-path.md | 6 +- ...ISK-F-0006-apps-pg-no-backup-configured.md | 6 +- .../RISK-F-0007-unverified-tenant-boundary.md | 6 +- ...008-audit-retention-legal-basis-assumed.md | 6 +- ...-set-covers-a-third-of-high-risk-lanes.md} | 86 ++++++++++++- .../__pycache__/register_lib.cpython-312.pyc | Bin 2876 -> 6271 bytes tools/register_check.py | 97 +++++++------- tools/register_index.py | 36 +++--- tools/register_lib.py | 66 +++++++++- 17 files changed, 429 insertions(+), 148 deletions(-) rename findings/{RISK-F-0004-agent-boundary-policy-covers-a-third-of-high-risk-lanes.md => RISK-F-0009-openbao-deny-set-covers-a-third-of-high-risk-lanes.md} (55%) diff --git a/REGISTER.md b/REGISTER.md index 9d69772..24d37ee 100644 --- a/REGISTER.md +++ b/REGISTER.md @@ -2,21 +2,21 @@ Generated by `tools/register_index.py` from `findings/`. Do not edit by hand. Last built 2026-08-20. -6 open of 9 findings; 3 notes below the floor. +8 live of 9 findings; 3 notes below the floor. ## Findings -| ID | Finding | System | Severity | Disclosure | Escalation | Fix owner | Status | Review by | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| [RISK-F-0008](findings/RISK-F-0008-audit-retention-legal-basis-assumed.md) | The legal basis for retaining audit facts against an erasure request has been assumed, never established | audit-core | medium | public | **required** (t2, partially-answered) | risk-nexus | open | 2026-11-17 | -| [RISK-F-0007](findings/RISK-F-0007-unverified-tenant-boundary.md) | No consumer's tenant boundary is verified anywhere | estate | **high** | embargoed | **answered** (t4, assigned) | per-consumer, on request | accepted | 2026-09-18 | -| [RISK-F-0006](findings/RISK-F-0006-apps-pg-no-backup-configured.md) | apps-pg has no backup configured at all: R0 means no recovery | railiance-platform | **high** | embargoed | **answered** (t3, approved) | railiance-platform | open | 2026-09-18 | -| [RISK-F-0005](findings/RISK-F-0005-audit-core-unfiltered-read-path.md) | audit-core read path applies no tenant filter; the bound is deployment, not code | audit-core | medium | embargoed | none | audit-core | open | 2026-11-17 | -| [RISK-F-0004](findings/RISK-F-0004-agent-boundary-policy-covers-a-third-of-high-risk-lanes.md) | agent-high-risk-boundary denies 6 of 17 high-risk lanes; the direct bao path is unprotected for the rest | railiance-platform | unset | unset | unset | railiance-platform | open | — | -| [RISK-F-0004](findings/RISK-F-0004-tenant-engine-unfiltered-event-read.md) | tenant-engine events() returns the entire event log unfiltered | tenant-engine | **high** | embargoed | none | tenant-engine | open | 2026-09-18 | -| [RISK-F-0003](findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md) | ops-warden agent read-boundary does not fire on ungraded catalog lanes | ops-warden | **high** | embargoed | none | ops-warden | mitigated | 2026-09-18 | -| [RISK-F-0002](findings/RISK-F-0002-ops-warden-sign-ungated.md) | ops-warden signs SSH certificates with no authorization decision, and its unblock is now unsafe | ops-warden | medium | embargoed | **withdrawn** (t6, withdrawn-hazard-window-closed) | ops-warden | open | 2026-11-17 | -| [RISK-F-0001](findings/RISK-F-0001-flex-auth-unauthenticated-check.md) | flex-auth /v1/check authenticates no caller | flex-auth | **high** | public | **withdrawn** (t1, withdrawn-before-sending) | flex-auth | fixed | 2026-08-26 | +| ID | Finding | System | Severity | Disclosure | Escalation | Fix owner | Status | Cadence | Next check | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| [RISK-F-0009](findings/RISK-F-0009-openbao-deny-set-covers-a-third-of-high-risk-lanes.md) | agent-high-risk-boundary denies 6 of 17 high-risk lanes; the direct bao path is unprotected for the rest | railiance-platform | **high** | embargoed | none | railiance-platform | open | instant (0) | **due** | +| [RISK-F-0008](findings/RISK-F-0008-audit-retention-legal-basis-assumed.md) | The legal basis for retaining audit facts against an erasure request has been assumed, never established | audit-core | medium | public | **required** (t2, partially-answered) | risk-nexus | open | instant (0) | **due** | +| [RISK-F-0007](findings/RISK-F-0007-unverified-tenant-boundary.md) | No consumer's tenant boundary is verified anywhere | estate | **high** | embargoed | **answered** (t4, assigned) | per-consumer, on request | accepted | instant (0) | **due** | +| [RISK-F-0006](findings/RISK-F-0006-apps-pg-no-backup-configured.md) | apps-pg has no backup configured at all: R0 means no recovery | railiance-platform | **high** | embargoed | **answered** (t3, approved) | railiance-platform | open | instant (0) | **due** | +| [RISK-F-0005](findings/RISK-F-0005-audit-core-unfiltered-read-path.md) | audit-core read path applies no tenant filter; the bound is deployment, not code | audit-core | medium | embargoed | none | audit-core | open | instant (0) | **due** | +| [RISK-F-0004](findings/RISK-F-0004-tenant-engine-unfiltered-event-read.md) | tenant-engine events() returns the entire event log unfiltered | tenant-engine | **high** | embargoed | none | tenant-engine | open | instant (0) | **due** | +| [RISK-F-0003](findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md) | ops-warden agent read-boundary does not fire on ungraded catalog lanes | ops-warden | medium | embargoed | none | ops-warden | mitigated | instant (0) | **due** | +| [RISK-F-0002](findings/RISK-F-0002-ops-warden-sign-ungated.md) | ops-warden signs SSH certificates with no authorization decision, and its unblock is now unsafe | ops-warden | medium | embargoed | **withdrawn** (t6, withdrawn-hazard-window-closed) | ops-warden | open | instant (0) | **due** | +| [RISK-F-0001](findings/RISK-F-0001-flex-auth-unauthenticated-check.md) | flex-auth /v1/check authenticates no caller | flex-auth | **high** | public | **withdrawn** (t1, withdrawn-before-sending) | flex-auth | fixed | instant (0) | **due** | ## Constraints @@ -32,11 +32,12 @@ Held from publication with a stated condition. A hold with no moving condition i | Finding | Since | Lifts when | Re-decided | | --- | --- | --- | --- | +| RISK-F-0009 | 2026-08-20 | the deny set covers every high-risk lane with a KV path, verified against live OpenBao | — | | RISK-F-0007 | 2026-08-19 | a verification exists for at least one consumer boundary | 2026-09-18 | | RISK-F-0006 | 2026-08-19 | a backup exists and a restore has been demonstrated once | 2026-09-18 | | RISK-F-0005 | 2026-08-19 | AUDIT-WP-0008-T04 lands a tenant filter in the read path | 2026-11-17 | | RISK-F-0004 | 2026-08-19 | the read path filters by tenant in code | 2026-09-18 | -| RISK-F-0003 | 2026-08-19 | the five exec_capable lanes graded under WARDEN-WP-0032-T05 | 2026-09-18 | +| RISK-F-0003 | 2026-08-19 | RISK-F-0009 resolved — the OpenBao deny set covers every high-risk lane with a KV path | 2026-09-18 | | RISK-F-0002 | 2026-08-19 | FLEX-WP-0015-T02 shipped and ops-warden policy.enabled true in production | 2026-11-17 | ## Notes (below the floor) @@ -52,5 +53,7 @@ Seen, deliberately not findings. Not graded, not reviewed, not published. ## How to read this Severity is `docs/method/severity.md`; disclosure `docs/method/disclosure.md`; -escalation `docs/method/escalation.md`; review dates `docs/method/review.md`. +escalation `docs/method/escalation.md`; the check cadence `docs/method/review.md`. +Cadence is the ladder rung and the count of consecutive clean checks — a finding at `1q (9)` +has held still for a long time; one at `instant (0)` moved recently. Anything wrong resets it. A constraint may be graded higher than the finding that carries it — read both. diff --git a/docs/method/escalation.md b/docs/method/escalation.md index 4c3315b..aef32ae 100644 --- a/docs/method/escalation.md +++ b/docs/method/escalation.md @@ -103,33 +103,22 @@ the operator is a *stuck* disagreement, because only the operator can assign work across repos that will not take it. One exchange, then escalate — not three, and not zero. -### 5. Stalled remediation — adopted, bounded by interval +### 5. Stalled remediation — adopted, bounded by the bottom rung -Escalate when a finding's fix has not moved for **twice its review interval** -(`docs/method/review.md`): 14 days for `critical`, 60 for `high`, 180 for -`medium`. `low` findings do not escalate on staleness; they age out through -review. +Escalate when a finding has sat at the **`instant` rung of the cadence ladder +for more than fourteen days** (`docs/method/review.md`). -"Not moved" means the fix owner's tracking record has not changed state and no -new evidence has arrived. A deliberate, stated deferral by the owner is -movement. Silence is not. +The bottom rung means one of two things: every check keeps finding something +wrong, or no check is happening. Fourteen days of either is a stall, and the +escalation does not have to know which — the operator will. -### 6. Ordering hazard across repos — added +This replaces the original severity-keyed interval. It is strictly better: a +`medium` finding that keeps resetting is stalling visibly, and the old rule +would have waited 180 days to say so. -Escalate once when remediating finding A in the wrong order relative to -finding B would create a **false attestation** — a control that starts -claiming a check happened when it did not. - -This trigger is not in `INTENT.md`'s list. It is added because `RISK-F-0002` -produced the case: enabling the `ops-warden` signing gate against a forgeable -`flex-auth` would convert an absent control into a signed audit record -asserting an authorization that was never made. Ordering hazards are cheap to -avoid before the fact and expensive to detect afterwards, because the evidence -is what lies. - -The escalation is a single acknowledgement of the ordering, not a standing -supervision request. Once the operator has the constraint, the register carries -it. +`low` findings are not exempt. Under the ladder, severity does not set cadence +at all, so there is no rung a `low` finding sits on that a `critical` one does +not. ## What does not escalate diff --git a/docs/method/review.md b/docs/method/review.md index 0f11755..22d7ca1 100644 --- a/docs/method/review.md +++ b/docs/method/review.md @@ -14,20 +14,75 @@ review_interval: 180d `INTENT.md`: a finding that has sat untouched past its review date is itself a finding. Silence is not resolution. This document makes that operable. -## Intervals +## The cadence ladder -Set from the headline severity at grading time, and reset at each review. +Operator ruling, 2026-08-20. **Intervals are not set by severity. They are +earned by outcomes.** -| Severity | Interval | Stale (2×, escalation trigger 5) | -| --- | --- | --- | -| `critical` | 7 days | 14 days | -| `high` | 30 days | 60 days | -| `medium` | 90 days | 180 days | -| `low` | 180 days | — (ages out through review) | +A finding is checked, and the check comes back clean or it does not. Clean +climbs one rung. Anything wrong drops straight back to the bottom. -A finding graded `critical` is being looked at weekly or it is not being -managed. If weekly is unbearable, the honest response is to re-grade it, not -to let the date slide. +| Rung | Wait before the next check | +| --- | --- | +| `instant` | now, and again immediately until a check comes back clean | +| `1h` | one hour | +| `8h` | eight hours | +| `24h` | one day | +| `48h` | two days | +| `96h` | four days | +| `7d` | one week | +| `14d` | two weeks | +| `1mo` | one month | +| `1q` | one quarter — **the ceiling; nothing is ever checked less often than this** | + +Two rules and one escape: + +- **Climb on clean.** One rung per clean check, never two. +- **Reset on anything wrong.** Not a slide down one rung — straight to + `instant`. A matter that has just moved has no track record, whatever it had + before. +- **The operator may defer.** An `instant` finding can be deferred to a stated + future date by explicit operator decision, recorded as `deferred_to`. That is + the only way out of the bottom rung other than a clean check, and it is a + decision with a name on it rather than a check quietly not happening. + +### The rung is the signal + +This is the point of the design, not a side effect. **The cadence a finding +sits on is a statement about how stable the estate has been on that matter.** + +`RISK-F-0002` at `1q (9)` says the signing gate has been examined ten times +over three months and nothing has moved. `RISK-F-0002` at `instant (0)` says +something changed within the day. The number carries information that no +severity does — severity says how bad it would be, the rung says how settled +it is — and the two are independent. A `low` finding that keeps resetting is +telling you something a `critical` one at the ceiling is not. + +It is also self-correcting in the direction that matters. Anything volatile +gets attention often, automatically, without anyone deciding it deserves it; +anything genuinely quiet stops consuming attention, without anyone deciding to +stop looking. Neither of those judgements has to be made by a person who might +be wrong or busy. + +### What "clean" means + +A check is **clean** when nothing about the assessment moved: the grade still +holds, every stated blocker is still true, the fix state is unchanged, the +disclosure state is still right, and no new fact has arrived. + +A check is **not clean** when any of those moved — including when they moved in +a good direction. `RISK-F-0001` being fixed is not a clean check; it is a large +change, and the next check comes immediately. Good news resets the ladder +exactly like bad news, because the ladder measures stillness, not health. + +### Starting position + +Every finding starts at `instant`. A register with no check history has no +grounds to wait, and the first clean check is what buys the first hour. + +The whole register sat at `instant (0)` on 2026-08-20, which is correct and +temporary: everything in it had been graded, re-graded or ruled on within the +preceding day. ## What a review is @@ -58,18 +113,19 @@ and `review_by` is pushed by one interval. A review that changes nothing still writes the line — "checked, nothing moved" is the evidence that the silence was observed rather than accidental. -## When a review is missed +## When a check is missed Overdue is not a status change on the finding. It is a fact about *this repo*, and it surfaces in three places: -- `make check` reports it (`tools/register_check.py`), always, unconditionally. -- `REGISTER.md` shows the finding as overdue with the day count. -- At **twice** the interval, escalation trigger 5 fires for `critical`, - `high` and `medium`. That is the point at which the register's own silence - reaches the operator. +- `make check` lists it under "Checks due", with how late it is and which rung + it is on. +- `REGISTER.md` shows the next check as **due**. +- A finding sitting at the bottom rung for more than fourteen days with no + movement fires escalation trigger 5. Bottom rung means it keeps failing or + keeps being skipped; fourteen days of that is a stall whichever it is. -The register does not auto-escalate severity for lateness, and it does not +The register does not auto-escalate severity for lateness and does not auto-close anything. Both would be the register lying about its own state to make a number look better. @@ -90,13 +146,17 @@ obligation is visible rather than discovered on the day. ## Front-matter this adds ```yaml -last_reviewed: "2026-08-19" -review_by: "2026-08-26" +last_checked: "2026-08-20T05:40:00Z" +next_check: "2026-08-20T06:40:00Z" +cadence: 1h +clean_streak: 1 production_rescore: true +deferred_to: "" # only by explicit operator decision ``` -`review_by` is what the nag reads. It is a date, never a duration, so that -nothing has to recompute an interval to know whether a finding is late. +`next_check` is what the nag reads, and it is an absolute moment rather than a +duration, so nothing has to recompute an interval to know whether a check is +late. The rungs run in hours as well as days, so it carries a time. ## Closing a finding @@ -106,10 +166,20 @@ A finding leaves `open` for exactly one of: something concrete rather than been told. Publication follows if the disclosure state was `embargoed`. - `accepted` — the estate is deliberately carrying it. Requires who accepted - it, why, and a re-review date. `accepted` is not `closed`; it keeps a review - interval forever. + it, why, and what ends the acceptance. `accepted` is not closed: it stays on + the ladder forever, and it climbs like anything else. +- `mitigated` — the live gap is closed but the finding is not. `RISK-F-0003` is + the case: the boundary now fires, and the omission that let it not fire is + still there. Stays watched. - `withdrawn` — the finding was wrong, or the defect never existed. Say which. +**Any status the tooling does not recognise keeps the finding watched, and the +unrecognised word is reported.** `RISK-F-0003` arrived as `mitigated` on +2026-08-20, before that word existed here, and dropped silently out of +`make check` — a finding vanishing from the nag because someone used an +unfamiliar word is precisely the failure this register exists to prevent. The +tooling now fails loud instead of quiet. + There is no `stale`, no `wontfix` and no silent expiry. A finding that nobody will fix and nobody will accept stays `open` and keeps arriving in the nag, because that is the true state. diff --git a/findings/README.md b/findings/README.md index 5b556d0..bfcfd1f 100644 --- a/findings/README.md +++ b/findings/README.md @@ -1,7 +1,14 @@ # Filing a finding One file per finding: `findings/RISK-F-NNNN-.md`, YAML front-matter, -then prose. Next id is one past the highest here. +then prose. + +**Ids are allocated by `risk-nexus`.** Take the next id past the highest you +can see and file — that is the right thing to do — but if two reporters take +the same one, the earlier commit keeps it and the newcomer is renumbered here, +with the original id recorded as `filed_as`. This happened on 2026-08-20 +(`RISK-F-0009`, filed as `RISK-F-0004`), which is why it is written down. Do +not renumber your own finding after filing; the register does it and tells you. ## What the reporter fills in @@ -24,8 +31,14 @@ related: [RISK-F-0001] # optional `severity`, `severity_at_production`, `impact`, `likelihood`, `fidelity_modifier`, `production_rescore`, `disclosure`, `embargo_*`, -`escalation*`, `constraint*`, `last_reviewed`, `review_by`, `graded_by`, -`ruling`. +`escalation*`, `constraint*`, `last_checked`, `next_check`, `cadence`, +`clean_streak`, `graded_by`, `ruling`. + +`cadence` is the check-frequency rung, and it is earned rather than assigned: +a clean check climbs one rung (`instant` → `1h` → `8h` → `24h` → `48h` → `96h` +→ `7d` → `14d` → `1mo` → `1q`), and anything moving drops it straight back to +`instant`. So the rung on your finding is a public statement about how settled +the matter has been — which is why it is not yours to set. Setting them yourself is not an error to be corrected — it is a boundary this repo would rather keep. The reporter says what is true; this repo says how bad diff --git a/findings/RISK-F-0001-flex-auth-unauthenticated-check.md b/findings/RISK-F-0001-flex-auth-unauthenticated-check.md index e0e0a90..157947a 100644 --- a/findings/RISK-F-0001-flex-auth-unauthenticated-check.md +++ b/findings/RISK-F-0001-flex-auth-unauthenticated-check.md @@ -28,8 +28,10 @@ escalation: withdrawn escalation_trigger: 1 escalation_status: withdrawn-before-sending date_fixed: "2026-08-19" -last_reviewed: "2026-08-19" -review_by: "2026-08-26" +last_checked: "2026-08-19T21:30:00Z" +next_check: "2026-08-19T21:30:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19 --- diff --git a/findings/RISK-F-0002-ops-warden-sign-ungated.md b/findings/RISK-F-0002-ops-warden-sign-ungated.md index a4d98ed..5fe3034 100644 --- a/findings/RISK-F-0002-ops-warden-sign-ungated.md +++ b/findings/RISK-F-0002-ops-warden-sign-ungated.md @@ -29,8 +29,10 @@ embargo_review: "2026-11-17" escalation: withdrawn escalation_trigger: 6 escalation_status: withdrawn-hazard-window-closed -last_reviewed: "2026-08-19" -review_by: "2026-11-17" +last_checked: "2026-08-19T21:30:00Z" +next_check: "2026-08-19T21:30:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19 --- diff --git a/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md b/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md index e3c550f..08bc9c9 100644 --- a/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md +++ b/findings/RISK-F-0003-ops-warden-read-boundary-ungraded-lanes.md @@ -10,21 +10,24 @@ date_reported: "2026-08-19" system: ops-warden environment: production fix_owner: ops-warden -fix_tracking: WARDEN-WP-0032-T05 +fix_tracking: WARDEN-WP-0032-T05 (done) / T06 (structural) # Graded by risk-nexus 2026-08-19 — docs/rulings/2026-08-19-first-grading.md -severity: high -severity_at_production: high +severity: medium +severity_at_production: medium +severity_superseded: "high (2026-08-19) — the CLI-layer gap is mitigated" impact: I4 likelihood: L2 fidelity_modifier: false production_rescore: false disclosure: embargoed -embargo_condition: "the five exec_capable lanes graded under WARDEN-WP-0032-T05" +embargo_condition: "RISK-F-0009 resolved — the OpenBao deny set covers every high-risk lane with a KV path" embargo_since: "2026-08-19" embargo_review: "2026-09-18" escalation: none -last_reviewed: "2026-08-19" -review_by: "2026-09-18" +last_checked: "2026-08-20T05:40:00Z" +next_check: "2026-08-20T05:40:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19 --- @@ -190,3 +193,35 @@ Reasoning: `docs/rulings/2026-08-19-first-grading.md`. Open at review: OpenBao policy coverage of the five paths; whether `WARDEN-WP-0032-T05` has landed; whether any compliance evidence cites `ADR-0004` as implemented. + +## Check — 2026-08-20: mitigated on one layer, `high` → `medium` + +`ops-warden` reports all 17 high-risk lanes now exiting 7 on +`warden access --fetch` with `WARDEN_AGENT_ID` set, verified under +`WARDEN-WP-0032-T05`/`T06`. The 14 ungraded lanes are graded; the boundary +fires. The live gap this finding described is closed on the CLI layer. + +**Not clean, so the cadence stays at `instant`.** Something moved, and under +the ladder that resets the clock rather than earning a longer one. The next +check is the one that can start climbing. + +**`high` → `medium`.** What remains is the omission shape rather than the +instance: whether a future lane can still be added without a grade. That is +`WARDEN-WP-0032-T06`, and the durable form of the operator's maturity-context +rule is `zone-engine`'s (`RISK-N-0004`). Not `low`, because "graded by hand +once" is not the same as "cannot be ungraded again". + +**The embargo condition changed rather than being met.** The original condition +— the five `exec_capable` lanes graded — is satisfied. But `RISK-F-0009` shows +the *second* layer, the OpenBao deny set that protects the direct `bao kv get` +path, covers 6 of 17 high-risk lanes. Publishing this finding now would name +lanes that are still reachable by the path this control exists to close. The +condition is therefore re-pointed at `RISK-F-0009`. + +That is the disclosure rule working across two findings rather than one: what +matters is whether the text shortens a path, not whether this particular +finding's own fix has landed. + +**Verification limit, recorded rather than assumed:** the mitigation is +`ops-warden`'s report and this register has not probed it. Their operator token +is expired, which is also why `RISK-F-0009` rests on a file comparison. diff --git a/findings/RISK-F-0004-tenant-engine-unfiltered-event-read.md b/findings/RISK-F-0004-tenant-engine-unfiltered-event-read.md index 5539f97..65feb2d 100644 --- a/findings/RISK-F-0004-tenant-engine-unfiltered-event-read.md +++ b/findings/RISK-F-0004-tenant-engine-unfiltered-event-read.md @@ -25,8 +25,10 @@ embargo_condition: "the read path filters by tenant in code" embargo_since: "2026-08-19" embargo_review: "2026-09-18" escalation: none -last_reviewed: "2026-08-19" -review_by: "2026-09-18" +last_checked: "2026-08-19T21:20:00Z" +next_check: "2026-08-19T21:20:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19-B --- diff --git a/findings/RISK-F-0005-audit-core-unfiltered-read-path.md b/findings/RISK-F-0005-audit-core-unfiltered-read-path.md index 01d9f6b..880395e 100644 --- a/findings/RISK-F-0005-audit-core-unfiltered-read-path.md +++ b/findings/RISK-F-0005-audit-core-unfiltered-read-path.md @@ -25,8 +25,10 @@ embargo_condition: "AUDIT-WP-0008-T04 lands a tenant filter in the read path" embargo_since: "2026-08-19" embargo_review: "2026-11-17" escalation: none -last_reviewed: "2026-08-19" -review_by: "2026-11-17" +last_checked: "2026-08-19T21:20:00Z" +next_check: "2026-08-19T21:20:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19-B --- diff --git a/findings/RISK-F-0006-apps-pg-no-backup-configured.md b/findings/RISK-F-0006-apps-pg-no-backup-configured.md index 88763d3..64e3193 100644 --- a/findings/RISK-F-0006-apps-pg-no-backup-configured.md +++ b/findings/RISK-F-0006-apps-pg-no-backup-configured.md @@ -31,8 +31,10 @@ escalation_answered: "2026-08-19" escalation_answered_by: the-custodian escalation_act: approve decision: "spend for apps-pg backup storage approved; no ceiling stated" -last_reviewed: "2026-08-19" -review_by: "2026-09-18" +last_checked: "2026-08-19T23:05:00Z" +next_check: "2026-08-19T23:05:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19-B --- diff --git a/findings/RISK-F-0007-unverified-tenant-boundary.md b/findings/RISK-F-0007-unverified-tenant-boundary.md index 065e7d7..3338ccf 100644 --- a/findings/RISK-F-0007-unverified-tenant-boundary.md +++ b/findings/RISK-F-0007-unverified-tenant-boundary.md @@ -34,8 +34,10 @@ accepted_by: the-custodian accepted_on: "2026-08-19" accepted_until: "production transition (hard expiry, not a date)" decision: "pragmatic default before production — carried unverified; verification of a named consumer boundary on request" -last_reviewed: "2026-08-19" -review_by: "2026-09-18" +last_checked: "2026-08-19T23:05:00Z" +next_check: "2026-08-19T23:05:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19-B --- diff --git a/findings/RISK-F-0008-audit-retention-legal-basis-assumed.md b/findings/RISK-F-0008-audit-retention-legal-basis-assumed.md index 09d85ac..66e9be3 100644 --- a/findings/RISK-F-0008-audit-retention-legal-basis-assumed.md +++ b/findings/RISK-F-0008-audit-retention-legal-basis-assumed.md @@ -32,8 +32,10 @@ escalation_act: rule decision: "identity in audit records: opaque subject ids preferred, agent identifiers where possible, operator credentials only where necessary, policy decisions tracked to the responsible party; zone-level privacy guarantees may raise the floor" outstanding: "a defensible retention period per category (waits on audit-core's co-residency horizon), and the trigger list for buying an external answer" determination: RISK-REG-0001 -last_reviewed: "2026-08-19" -review_by: "2026-11-17" +last_checked: "2026-08-20T05:25:00Z" +next_check: "2026-08-20T05:25:00Z" # due now: the ladder starts at instant +cadence: instant +clean_streak: 0 graded_by: risk-nexus ruling: RISK-RULING-2026-08-19-C --- diff --git a/findings/RISK-F-0004-agent-boundary-policy-covers-a-third-of-high-risk-lanes.md b/findings/RISK-F-0009-openbao-deny-set-covers-a-third-of-high-risk-lanes.md similarity index 55% rename from findings/RISK-F-0004-agent-boundary-policy-covers-a-third-of-high-risk-lanes.md rename to findings/RISK-F-0009-openbao-deny-set-covers-a-third-of-high-risk-lanes.md index 2ea2c51..2d3cb96 100644 --- a/findings/RISK-F-0004-agent-boundary-policy-covers-a-third-of-high-risk-lanes.md +++ b/findings/RISK-F-0009-openbao-deny-set-covers-a-third-of-high-risk-lanes.md @@ -1,5 +1,5 @@ --- -id: RISK-F-0004 +id: RISK-F-0009 type: finding title: "agent-high-risk-boundary denies 6 of 17 high-risk lanes; the direct bao path is unprotected for the rest" status: open @@ -11,12 +11,29 @@ system: railiance-platform environment: production fix_owner: railiance-platform fix_tracking: unset -severity: unset -disclosure: unset -escalation: unset +filed_as: "RISK-F-0004 by ops-warden; renumbered by risk-nexus 2026-08-20 (id collision)" +answers: RISK-F-0003 +related: [RISK-F-0003] +# Graded by risk-nexus 2026-08-20 — docs/rulings/2026-08-20-openbao-layer.md +severity: high +severity_at_production: high +impact: I4 +likelihood: L2 +fidelity_modifier: false +production_rescore: false +disclosure: embargoed +embargo_condition: "the deny set covers every high-risk lane with a KV path, verified against live OpenBao" +embargo_since: "2026-08-20" +escalation: none +last_checked: "2026-08-20T05:40:00Z" +next_check: "2026-08-20T05:40:00Z" +cadence: instant +clean_streak: 0 +graded_by: risk-nexus +ruling: RISK-RULING-2026-08-20 --- -# RISK-F-0004 — the OpenBao half of the agent read-boundary covers a third of the lanes +# RISK-F-0009 — the OpenBao half of the agent read-boundary covers a third of the lanes ## What is true @@ -101,3 +118,62 @@ this from an audit into an invariant. - ops-warden `ADR-0004`, `ADR-0007` - `ops-warden/wiki/playbooks/agent-read-boundary.md` - `railiance-platform/openbao/policies/agent-high-risk-boundary.hcl` + +## Register ruling — 2026-08-20 + +**Renumbered from `RISK-F-0004`.** `ops-warden` filed it as `RISK-F-0004`, +which this register had already assigned to `tenant-engine`'s unfiltered event +read hours earlier. Earlier id keeps it; the newcomer moves. Nothing about the +finding changes, and the filing contract now says how ids are allocated so the +next reporter does not hit this. + +`high` (`I4` × `L2`), embargoed, no escalation. + +**`I4` on the worst uncovered lane.** Eight high-risk lanes have a concrete KV +path the policy does not deny. Most are `I3`-shaped inside their own system; +`scaleway-bootstrap` and `agent-harness-forgejo-deploy` are not — cloud account +bootstrap credentials and a forge deploy key propagate past the system that +holds them. + +**`L2`.** It needs an agent token carrying a `workload-kv-read-*` policy, which +the estate does grant. The register holds `ops-warden`'s two stated limits +rather than resolving them in either direction: the comparison is static against +the policy *file* because their operator token is expired (`bao token lookup` → +403), so **the deployed policy may differ**; and whether any agent token +currently carries `agent-high-risk-boundary` together with a +`workload-kv-read-*` policy is not established. + +**No fidelity modifier, same call as `RISK-F-0003`.** `ADR-0004` describes two +independent layers and one of them covers a third of the lanes, so the +documentation overstates the control — but the modifier is scoped to +attestations produced at runtime, not to documentation drift. Noted twice now +in the same system; if it recurs a third time the modifier's scope is what +should be re-examined, not the finding. + +**No escalation.** `railiance-platform` owns the policy, `ops-warden` owns the +catalog, and they have not disagreed about anything — trigger 4 needs a failed +routing exchange, not merely two repos. `ops-warden`'s suggestion that the deny +set be generated from the catalog rather than maintained twice is routed as a +suggestion, which is what it was offered as. + +**The pre-existing point is the important one.** Four of the eight uncovered +lanes were already graded `high` before the 2026-08-19 regrade. The divergence +is not a consequence of that work; the regrade only made the two lists +comparable enough to see it. A register that took credit for finding a fresh +defect here would be misreading its own evidence. + +**Disclosure: embargoed**, and it changes `RISK-F-0003`'s embargo too. This +names eight live KV paths that nothing denies. + +**One expired credential is blocking verification** of both this finding and +`RISK-F-0003`'s mitigation. That is not a finding — nothing is wrong with the +estate because a token expired — but it is why two gradings here rest on file +comparison rather than a probe, and the register says so rather than implying +otherwise. + +## Reviews + +- **2026-08-20** — renumbered, graded. Cadence starts at `instant`: nothing + about this has been checked twice yet. Open at the next check: does the + deployed policy match the file; do any agent tokens carry both policies; has + `railiance-platform` taken the catalog-generated deny set. diff --git a/tools/__pycache__/register_lib.cpython-312.pyc b/tools/__pycache__/register_lib.cpython-312.pyc index 131e5ddb3883c9287a9e1ab46e69911db1489c54..fb0cee584bde9171958315cac33e1bcf4370bddb 100644 GIT binary patch literal 6271 zcmbstS!^4}b$7Wu#Fa#ymVC&jE=#mcT9&QamSs7LL??Y(fdOjKsYS&5WMt~|T6 zEJkYRG-zeOh3zD$>DGniB0wxyMGQ0_32Gn-5Fjo3Lxw^nZ2S>4`KbQfss)<-v~QNz z>H<=9fSs9r^XAQ)H*elCe=aIAQ&7Hs=VudrMHKZ7`Nn%23p83Q-;k(TEAs zA;!bdq^}F=JgnO4J$kh@cnl0B=qHNPbG5Z#dPt}LC(5<(bpK%nG*Pj6Y%7?D6F9_1 zIg0WWLQk*uipD8q_$}=zhSmtJ1(`f0s6Z%*0#Q#XI*80~v7R!-0gvp&{>>{PI?Ll2 zg9^9BDq1Yo;90iCD_MtEx-DMWI=u32@haBgRc?#7V;x@Aws_TxJ2m^*wJqN6b$ENW z#j9C|w|85-eOkP6v!_C+=%H> zR3ht6a+*8PAyf;eS%vJV4%%wuK=sh>M2ArWw7XCXYJ_$-I)e5?y9e4PXlqa_YKC?% zI*M%2?nB4W0cfq$Iv;3tdYviC))t+0`*|!N>lhZpvOUDhvVg5v;87qvZXFY`RrU+; zKOT_CfGmnZ$ze7R18^)5P9p_^f@B3EDyk$#uurgxV^$eQWWU4e5@kQ3^NWP#bMX;cl1qo)TR26erxFUOfe!+K9G5UBUgnfdIQS@Vh zD*{pqrUJ4bVSXxX(vubQyzb$y;qz{%8$bpLHX4y6Ld*KS{jgsQ35TadEcyJ0aX`9g z2Tmi>VO7+Jvy%4)1EY?~X~j6j%diQAiF%MvA_go*t#GAx_a(UI1lzm@07@|i!mv(Q zR+uCHRk9SUtoOs=ND6bpziJ{ly4GX<)oQhO3}7v4@8pmCtFMtBa~!RLj*^Ki;-@7= zcjOpaB{mOwA#ruDige`Css}o&|ADejJRq91c%FgI2Juk1GB2zNg1Mp0P}wW%1{3V2 zY>W#NdBcenb$9hRUENNvtLsIlThW6S3Rf0@$1oof(n7`Rp3FEB9`~xl_(w4JFnrQJ zs6blHhdwMyHlMzJdYRjs$buIKt1V0(7CNSXF11uo?&t* zh$eNK?HcF-+r|vrDl7OH0JXfD0%5pbm-!$*2_r|zXcbg3>Iql&ZhIW1%IlL{!~9^9 zYf7+9n>i%sq;mKgbhhSD*29om^1LFN_zaoMR*Ov3LM>VG0|xlcheY;Jvb&15^@jzk zFDUR~t9qr{t&vHq2rwe^L)I|4jn+wCl2w~IaocWP%( zl5-|lXIe&ppDVfhN}R7&M)}i<)^~-c)8=Xl?BRI3b(HsA%$O_ds3^-~$dOh=THVD0 zrLL1!I6W35LB`Jj`40FpHxsyxmnOO033m79bIWPgw<=FF)P&Z;0E`qwFEZKl0AE3G zbzzV$iy>8OPkO?TrlGBWO(FI-If`!*iUr#7bRX%EK6~!3f^N++v$|PUhFBzBCJM8? zd^?ll;tDN+p9xU2`Wb3Qj|@a0GkUlYMpslHk;m*Oz^s_S2#dsrL|MDtZjP3=Tf^cy zPqa!7P0@QNdD(9>Dh0$bd1bKViW#gMmL+1h74ro?7!jNpix{`56Qs#tKvvjkJ`_|6 zBz{cr21Op(3<^sqDXdC@_A1O+2&j*WXqwnKnU{#TtZGT0DNE+zL4bC_Ck3G*#@2G9 z^IGTp@wv_vcQDpN{?A-H6FuYt5pW^18Y_^u4-l1DScp$xG`G$SM8|JYNVmv7 z%ve7+(1DnOnWZ42(7U4CP_O%({jB|fBedCLLR3tz5!E3sA$#){7?;8X5IzHN&68?wAv*CtT^x#$=7-BX)^9 zonTLY_YWOa)I&?;jP93pv8)d|xsp5G7=W}kYO6b+%_bx`MK_hd^@x5;tvQhsshxIyhB!QJ+6mo58io4T5W;*NFW-N73pLj#xl>Xsvo=73`PvZJMU8m$K9} z^*h}ZjVocAjn0ahakStc14Jw3?+ZG{1qgp8ac8tz4gEAHogHzLNyLW#jrw?7P|fiEyTkxH>ZPPjSk|MU?f_#k)w?6sSFXj7^D8k zCkBT9G@eyH{4zj6!W9UkCHx9mXM|Kl;qx8@6QxkA0q9619wQMz$&HR{ z9oIWoETwY=piZ#O_nhy)aQ}q|v#EyDOJ$wOp7RN>Z;3+*7HxJhxzL~w)LCmVAYjd# z3?jA-C2Rcg32;kWxDm|EfZD;>7S2OMSxvpylitd8Eas9@Q02U_(ek)Ox}D?ErCD){ zy-bv@=Yagd{xb#)OeTrpHx$IcHOWXwL;Z*ECubhl?T2WtTC%jU2MnR^GL0{2R@rI~ zBqH2Uww69(^R7F48z)HYwxMX&`+>X4Qgc(&2AeT@bbtqwYUQoqJ0a)8YAXjqkQ{2$ zNr-DBfF)Va4|hAjz8mCdAPl)B5iS^9aPW~ZL{19ri5d>Jw6wRhxNJp=IUuET7@wdp zNLEZ_5kedXU>-5(lR+NFOavB%;8W2>WS{EC1xWG4&}aZ)EE!dFl8luiU@8=lL|7T} zT*VCGX=Wzyq*s~L+<8bbAu$Wfn1sj|;FBIh1<|Jw4Eo)&d%N%KUNSbWRM*@q{8iyx zBw^}*(D;FU!TuP3^7_ZGfAYr1Z#=N44)g>3NoGdXovI~c?GsDsjn}WezHF&WTI%N8 z7VAD}T4;Jy`)Kf?{d49E?lbNS%V(B^r7mR|NSFrFbIX^EHS>)RW|MUtx#`VGOY?)u z4{8=_9!VcPpRzeqmb1&2!K7tyX=r$PXe2o_vOL5mhxn9bG+`QraAI>XK{OOTdTkOq z>)cJwGQdElY!z0>*}*L>#6&Kg$f0TSy0kyg<1h?j5h_I&7N_tf@{38e!8pzk7^!&p zB$7%H<5+a#)U{J2`nubC@6?@B_s-lo12I{GJ-FEt=5|XG*8e9X&AZzC{_{5@y)T_& zb~`UPhkA#5oo)bQdkr9vpcr2! z)m5kz9b}uEpwU7kJIIAVLQv*64Xe{_j7lKgXE6zdqJ<=)Ca)6%(5C6LDBzPbxKVQu zeBLVrGLi+A!AjZ7oMAaT#A$|rhd0(Sj(-ZjwpzvL^&-*d^(tnscPs)s6}(+I`s;~fN6e9qPXk}LO~qrSr^1v$B;^H>rfDW>860q@Xx*K_HVpCtBbCaWfh zY7~?XDpjXXjEt(>suF-S)FV@)T5=UzTGS{MV&Ra8B0-@O-vTIclhWHzKh@DR{dY!3 zv;Q(tbp1C}tNH=j=#YnF|TNn>Nm*tBePB#n+`V_VYL7GuFO z+&qxv_QXsproy?YYsE4BUyE8}W~F2>R`k?NmD^&LmD1Kn-ehS{ta!~p)3&F@l)-Yd z@FsJ+@K#}b@EuEnwXWz3<~rtDNvsm*mx!X)ZV58a_02tdqwiW@94{5tCD{5E{SO1> z$qz1Z`_vWl6GxW{YZGjpmR1gwpE#7}>uDv&F)`!QYHGiIxx{{_Dc&$ozg>|kvB!#5 zAYCt0r9pP;q}NO|-MnUE=(F^inW6WrnT>SGS_MOQu9;l0U(nLTv3gTaaiGOHU(^%286~h|SRA-c8yWs5dBZ+|+ z4GvJt^Vsp6VqD_H7kRkc-LYPO~zs*d{AeA*7s;2Yd9 zMiV>EhN6Rb^Vc_;rSPGk(42CO@{O>? zpKEWD8zz9klri3Psn$_1raC;t#NThgGra*uQ9#r=8X<<ZzyrawZRmJ+CDxBS7Vw~n{fE%gR{tbWxgf?gI9`HK?Lb^#r0fv zSh9{vkoA~MZ44)WB_?4nUyPrCNtmA^F(=4B64WAa!tvGKAuQmx$Ty$VtU8TBTZBQ#RE?U@R*WJ;t8 zgWiyyo4`5`F6iPhKPc3S!0ZvoWTzyYl^i2)4P~wCrR-qQ z$`1|XN|}M-f=CvM139a7`C6`+y_(Bfd7)p-4`i%N=|a}ZWeWZ^8i(>1BERClP5lm? ztc{V0Dm~4L5S9Oh`v{?C^j*amZzw9(HbV&SUE`ar=Z>#-cFnW*`A_OfTW%Q{t0`j{ eB}H;*POk@>aI$VF_;fv@Vv2vnFusKSi~Ip(E9|ZS diff --git a/tools/register_check.py b/tools/register_check.py index 4f16a5c..707d068 100644 --- a/tools/register_check.py +++ b/tools/register_check.py @@ -11,21 +11,11 @@ import datetime as dt import register_lib as lib -TODAY = dt.date.today() - - -def date(value): - if isinstance(value, dt.date): - return value - if isinstance(value, str) and value not in ("", "unset"): - return dt.date.fromisoformat(value) - return None +NOW = lib.now() def main() -> int: - # "accepted" is deliberately carried, not closed: it keeps its review - # interval and its production re-score, so the nag must keep watching it. - fs = [f for f in lib.findings() if f.get("status") in ("open", "accepted")] + fs = [f for f in lib.findings() if lib.watched(f.get("status"))] lines: list[str] = [] def section(title: str, rows: list[str], quiet: str) -> None: @@ -36,44 +26,67 @@ def main() -> int: lines.append(f" {quiet}") lines.append("") + unknown = [ + f"{f['id']} — status '{f.get('status')}' is not one of {', '.join(lib.KNOWN_STATUSES)}; watched anyway" + for f in fs + if f.get("status") not in lib.KNOWN_STATUSES + ] + if unknown: + section("Unrecognised status", unknown, "none") + ungraded = [ f"{f['id']} — {', '.join(k for k in lib.GRADED_FIELDS if str(f.get(k, 'unset')) == 'unset')}" for f in fs if any(str(f.get(k, "unset")) == "unset" for k in lib.GRADED_FIELDS) ] - section("Ungraded", ungraded, "none — every open finding carries a grade") + section("Ungraded", ungraded, "none — every watched finding carries a grade") - overdue = [] + due, deferred = [], [] for f in fs: - due = date(f.get("review_by")) - if due and TODAY > due: - overdue.append(f"{f['id']} — review was due {due} ({(TODAY - due).days}d ago)") - section("Overdue review", overdue, "none") - - stale = [] - for f in fs: - seen = date(f.get("last_reviewed")) - interval = lib.REVIEW_INTERVAL_DAYS.get(f.get("severity")) - if not seen or not interval or f.get("severity") == "low": - continue - limit = seen + dt.timedelta(days=interval * lib.STALE_MULTIPLIER) - if TODAY > limit: - stale.append( - f"{f['id']} ({f.get('severity')}) — untouched since {seen}; " - f"escalation trigger 5 fires (limit was {limit})" + if f.get("deferred_to"): + until = lib.moment(f["deferred_to"]) + if until and NOW < until: + deferred.append(f"{f['id']} — deferred by the operator until {f['deferred_to']}") + continue + when = lib.moment(f.get("next_check")) + if when is None: + due.append(f"{f['id']} — no next_check set") + elif NOW >= when: + late = NOW - when + hours = int(late.total_seconds() // 3600) + rung = f.get("cadence", "instant") + due.append( + f"{f['id']} ({f.get('severity')}) — due {f['next_check']}" + f"{f', {hours}h late' if hours else ''}, cadence {rung}" ) - section("Stalled — escalation trigger 5", stale, "none") + section("Checks due", due, "none") + if deferred: + section("Deferred by the operator", deferred, "none") - embargo = [] - for f in fs: - if f.get("disclosure") != "embargoed": - continue - due = date(f.get("embargo_review")) - if due and TODAY > due: - embargo.append( - f"{f['id']} — embargo not re-decided since {due}; lifts when: {f.get('embargo_condition')}" - ) - section("Embargo overdue for re-decision", embargo, "none") + # The rung is the signal: how long this matter has held still. + stability = [] + for f in sorted(fs, key=lambda f: lib.rung_index(f.get("cadence", "instant"))): + rung = f.get("cadence", "instant") + streak = f.get("clean_streak", 0) + note = " — at the ceiling" if rung == lib.TOP_RUNG else "" + stability.append(f"{f['id']}: {rung} ({streak} clean in a row){note}") + section("Stability — where each finding sits on the ladder", stability, "nothing watched") + + bottom = [ + f"{f['id']} ({f.get('severity')}) — still at the bottom rung since {f.get('last_checked')}" + for f in fs + if lib.rung_index(f.get("cadence", "instant")) == 0 + and (m := lib.moment(f.get("last_checked"))) + and NOW - m > dt.timedelta(days=14) + ] + section("Stalled — escalation trigger 5", bottom, "none") + + embargo = [ + f"{f['id']} — lifts when: {f.get('embargo_condition')}" + for f in fs + if f.get("disclosure") == "embargoed" + ] + section("Embargoed", embargo, "none") esc = [ f"{f['id']} — trigger {f.get('escalation_trigger')}, {f.get('escalation_status')}" @@ -89,7 +102,7 @@ def main() -> int: ] section("Owed at the production transition", rescore, "none — no finding is graded lower for build mode") - print(f"Register check — {TODAY}\n{len(fs)} open or accepted finding(s)\n") + print(f"Register check — {NOW:%Y-%m-%d %H:%MZ}\n{len(fs)} live finding(s)\n") print("\n".join(lines).rstrip()) return 0 diff --git a/tools/register_index.py b/tools/register_index.py index 11895e4..6d9fdec 100644 --- a/tools/register_index.py +++ b/tools/register_index.py @@ -22,14 +22,19 @@ def date(value) -> dt.date | None: return None -def review_cell(f: dict) -> str: - due = date(f.get("review_by")) - if due is None: +def check_cell(f: dict) -> str: + when = lib.moment(f.get("next_check")) + if when is None: return "—" - late = (TODAY - due).days - if late > 0: - return f"**{due} (overdue {late}d)**" - return str(due) + if lib.now() >= when: + return "**due**" + return when.strftime("%Y-%m-%d %H:%MZ") + + +def cadence_cell(f: dict) -> str: + rung = f.get("cadence", "instant") + streak = f.get("clean_streak", 0) + return f"{rung} ({streak})" def sev_cell(f: dict) -> str: @@ -50,23 +55,23 @@ def esc_cell(f: dict) -> str: def render() -> str: fs = lib.findings() ns = lib.notes() - open_fs = [f for f in fs if f.get("status") == "open"] + open_fs = [f for f in fs if lib.watched(f.get("status"))] out = [ "# Register", "", f"Generated by `tools/register_index.py` from `findings/`. Do not edit by hand. Last built {TODAY}.", "", - f"{len(open_fs)} open of {len(fs)} findings; {len(ns)} notes below the floor.", + f"{len(open_fs)} live of {len(fs)} findings; {len(ns)} notes below the floor.", "", "## Findings", "", - "| ID | Finding | System | Severity | Disclosure | Escalation | Fix owner | Status | Review by |", - "| --- | --- | --- | --- | --- | --- | --- | --- | --- |", + "| ID | Finding | System | Severity | Disclosure | Escalation | Fix owner | Status | Cadence | Next check |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", ] for f in fs: out.append( - "| [{id}]({path}) | {title} | {system} | {sev} | {disc} | {esc} | {owner} | {status} | {review} |".format( + "| [{id}]({path}) | {title} | {system} | {sev} | {disc} | {esc} | {owner} | {status} | {cadence} | {check} |".format( id=f["id"], path=f["_path"].relative_to(lib.REPO), title=str(f.get("title", "")).strip('"'), @@ -76,7 +81,8 @@ def render() -> str: esc=esc_cell(f), owner=f.get("fix_owner", "—"), status=f.get("status", "—"), - review=review_cell(f), + cadence=cadence_cell(f), + check=check_cell(f), ) ) @@ -112,7 +118,9 @@ def render() -> str: out += ["", "## How to read this", "", "Severity is `docs/method/severity.md`; disclosure `docs/method/disclosure.md`;", - "escalation `docs/method/escalation.md`; review dates `docs/method/review.md`.", + "escalation `docs/method/escalation.md`; the check cadence `docs/method/review.md`.", + "Cadence is the ladder rung and the count of consecutive clean checks — a finding at `1q (9)`", + "has held still for a long time; one at `instant (0)` moved recently. Anything wrong resets it.", "A constraint may be graded higher than the finding that carries it — read both.", ""] return "\n".join(out) diff --git a/tools/register_lib.py b/tools/register_lib.py index e17b641..7c435d3 100644 --- a/tools/register_lib.py +++ b/tools/register_lib.py @@ -4,6 +4,7 @@ The finding files are the source of truth. Nothing here writes to them. """ from __future__ import annotations +import datetime as dt import pathlib import yaml @@ -12,9 +13,53 @@ FINDINGS = REPO / "findings" NOTES = REPO / "notes" SEVERITIES = ["critical", "high", "medium", "low"] -REVIEW_INTERVAL_DAYS = {"critical": 7, "high": 30, "medium": 90, "low": 180} -STALE_MULTIPLIER = 2 -GRADED_FIELDS = ["severity", "disclosure", "escalation", "review_by", "last_reviewed"] +GRADED_FIELDS = ["severity", "disclosure", "escalation", "next_check", "cadence"] +# A finding leaves the watch list only by being genuinely finished. Anything +# else — including a word the register has not seen before — stays watched, and +# the unknown word is reported. A finding must never fall out of the nag because +# somebody used a status the tooling did not recognise. +CLOSED_STATUSES = ("fixed", "withdrawn") +KNOWN_STATUSES = ("open", "accepted", "mitigated") + CLOSED_STATUSES + + +def watched(status: str | None) -> bool: + return status not in CLOSED_STATUSES + +# The adaptive cadence ladder (operator ruling, 2026-08-20). A clean check +# climbs one rung; anything wrong drops straight back to `instant`. The rung a +# finding sits on is itself the signal: how stable this matter has been. +CADENCE = [ + ("instant", dt.timedelta(0)), + ("1h", dt.timedelta(hours=1)), + ("8h", dt.timedelta(hours=8)), + ("24h", dt.timedelta(hours=24)), + ("48h", dt.timedelta(hours=48)), + ("96h", dt.timedelta(hours=96)), + ("7d", dt.timedelta(days=7)), + ("14d", dt.timedelta(days=14)), + ("1mo", dt.timedelta(days=30)), + ("1q", dt.timedelta(days=90)), +] +CADENCE_NAMES = [name for name, _ in CADENCE] +TOP_RUNG = CADENCE_NAMES[-1] + + +def rung_index(name: str) -> int: + return CADENCE_NAMES.index(name) if name in CADENCE_NAMES else 0 + + +def interval(name: str) -> dt.timedelta: + return CADENCE[rung_index(name)][1] + + +def climb(name: str) -> str: + """One clean check: up one rung, never past the quarter.""" + return CADENCE_NAMES[min(rung_index(name) + 1, len(CADENCE) - 1)] + + +def reset() -> str: + """Anything wrong: back to the bottom.""" + return CADENCE_NAMES[0] def load(path: pathlib.Path) -> dict: @@ -38,5 +83,20 @@ def notes() -> list[dict]: return sorted((load(p) for p in NOTES.glob("RISK-N-*.md")), key=lambda n: n["id"], reverse=True) +def moment(value) -> dt.datetime | None: + """Parse a date or datetime front-matter value as UTC.""" + if isinstance(value, dt.datetime): + return value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc) + if isinstance(value, dt.date): + return dt.datetime.combine(value, dt.time(0, 0), tzinfo=dt.timezone.utc) + if isinstance(value, str) and value not in ("", "unset"): + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(dt.timezone.utc) + return None + + +def now() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + + def sev_rank(sev: str) -> int: return SEVERITIES.index(sev) if sev in SEVERITIES else len(SEVERITIES)