Adaptive check cadence: the interval is earned, not assigned

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-20 07:43:51 +02:00
parent 8b204d0411
commit 42bbf5d2dc
17 changed files with 429 additions and 148 deletions

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -1,7 +1,14 @@
# Filing a finding
One file per finding: `findings/RISK-F-NNNN-<slug>.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

View file

@ -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
---

View file

@ -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
---

View file

@ -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.

View file

@ -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
---

View file

@ -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
---

View file

@ -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
---

View file

@ -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
---

View file

@ -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
---

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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)