Finish TEN-WP-0006-T03: persist guardrails in both stores

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 02:14:31 +02:00
parent 33ceb882ee
commit f631224ab5
7 changed files with 701 additions and 3 deletions

View file

@ -168,7 +168,7 @@ folded into this diff.
```task
id: TEN-WP-0006-T03
status: todo
status: done
priority: high
state_hub_task_id: "f1c93573-6322-4505-b738-7d66d67e60a8"
```
@ -191,6 +191,46 @@ Done when the store-conformance suite (already parametrised over both backends)
covers guardrails, so the durable store cannot diverge from the reference
semantics.
Done 2026-08-16: `TenantStore` gains `guardrail_overrides`, `guardrail_changes`,
and `set_guardrail_override`, implemented in both backends.
`tests/test_guardrail_store_conformance.py` parametrises 35 tests over
in-memory and SQLite. 201 tests total, all passing.
Deviations and decisions, both deliberate:
- **No backfill migration, because grouping defaults are resolved rather than
materialised.** The task anticipated backfilling existing tenants with their
grouping-derived defaults. Storing them would have been worse: it duplicates
a value that already has a single source, and it makes changing a default a
data migration instead of a config edit. Since a tenant with no override
resolves through the same function whether it predates guardrails or not,
"existing tenants must not silently gain a looser limit than a fresh tenant
of the same grouping" holds by construction, not by a migration step that
could be skipped. The forward-only, idempotent part is the two new tables,
created by the same `CREATE TABLE IF NOT EXISTS` script fresh and existing
databases both take — no second schema definition to drift.
- **A guardrail change bumps the tenant version.** It reuses the lifecycle
ETag, so a guardrail write invalidates a stale reader's `If-Match` exactly
as a metadata edit does, rather than introducing a second, separately
versioned resource that a caller could race against the first.
Retired tenants, following the TEN-WP-0005 reduce-privilege precedent:
- Guardrails stay **readable**; it is the values that clamp, not the endpoint.
- A retired tenant's effective limits clamp to the floor, provenance
`lifecycle`.
- **Tightening is allowed while retired, loosening is refused.** The check
compares the before/after value computed *as if the tenant were active*
because the clamp makes every candidate look inert today, and the real
effect of a loosening override lands the moment the tenant is reactivated.
That deferred loosening is the thing worth refusing. Clearing an override is
evaluated the same way, so clearing a tightening override on a retired
tenant is refused too, since fall-through would loosen.
Stored amounts are `TEXT NOT NULL` with an explicit `"unlimited"` token, never
NULL. A NULL that meant unlimited would make an open ceiling the result of
absence — the one thing the contract forbids.
## T04 - Expose guardrail read and write APIs
```task