Finish TEN-WP-0006-T02: implement guardrail domain model

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 02:10:38 +02:00
parent 0d1435c2d2
commit 33ceb882ee
6 changed files with 868 additions and 1 deletions

View file

@ -116,7 +116,7 @@ default lands immediately.
```task
id: TEN-WP-0006-T02
status: todo
status: done
priority: high
state_hub_task_id: "1b28d836-11d9-4ef3-967b-05bfa74c304a"
```
@ -134,6 +134,36 @@ Done when effective-guardrail resolution is a total, side-effect-free function
with unit tests covering every grouping, the reserved identifiers, missing
plans, and conflicting overrides.
Done 2026-08-16: `src/tenant_engine/guardrail/``model.py` (frozen
dataclasses, typed errors), `registry.py` (the key registry and grouping
defaults), `resolution.py` (the pure resolution function). 42 new tests, 166
total, all passing.
Design notes worth carrying:
- **Registry totality is enforced at construction.** `LimitDefinition` raises
`GuardrailRegistryInvalidError` if any grouping in `GROUPINGS` lacks a
default, so an unmapped grouping cannot reach production. The `fail_closed`
branch in `resolve_limit` is the second line of defence, not the first — its
test has to bypass the constructor to reach it.
- **A floor may never be `unlimited`,** also enforced at construction. A floor
that fails open is not a floor.
- **Currency is deliberately excluded from `compatible_with`.** Precedence
resolves per key and the winning layer takes the value whole, so two
currencies never combine — which is why there is no exchange rate anywhere
in this service, and why there must not be one.
- **The lifecycle clamp only relabels when it actually reduces.** A retired
`trial` tenant already at zero keeps provenance `grouping`; a retired
`large` tenant becomes `lifecycle`. Relabelling unconditionally would hide
where the value came from.
- **`bool` is rejected as an amount** — `True` is an `int` in Python and would
otherwise pass the non-negative check as a ceiling of 1.
Lint: the new files are clean. `make lint` still fails on **19 pre-existing
E501s** in files this workplan does not touch (`tests/test_store.py` and
friends) — that baseline predates TEN-WP-0006 and is left alone rather than
folded into this diff.
## T03 - Persist guardrails in both stores
```task