Plan guardrail and quota policy (TEN-WP-0006)

Closes the guardrail/quota concern reserved since repo creation. Notably
ADR-0013 specifies trial tenants default to a zero spend budget; no such
default exists today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 01:50:08 +02:00
parent 77bc207484
commit b8af02d74d

View file

@ -0,0 +1,186 @@
---
id: TEN-WP-0006
type: workplan
title: "Guardrail and quota policy for tenants"
domain: infotech
repo: tenant-engine
status: ready
owner: claude
topic_slug: tenant-guardrails
created: "2026-08-16"
updated: "2026-08-16"
depends_on:
- TEN-WP-0005
unblocks: []
---
# TEN-WP-0006 - Guardrail and quota policy
Implement the guardrail/quota concern that `SCOPE.md`, `INTENT.md`, and
`.claude/rules/architecture.md` have reserved since the repo was created but
never built. `tenant-engine` owns *policy* — what a tenant is permitted to
spend and how many entities/actions it may hold — not metering, not billing,
and not the enforcement decision itself.
The concrete hole this closes: ADR-0013 specifies that `trial`-grouped tenants
default to a **zero** spend budget. No such default exists today, so every
trial tenant currently carries no ceiling at all.
## Boundary constraints
These are not negotiable without a new ADR, and every task below is bound by
them:
- **`tenant-engine` is a data source, never a PDP.** It answers "what is this
tenant's limit" and "what is its recorded consumption". `flex-auth` decides
whether an action is allowed. Never invert this.
- **No metering.** Consumption counters are written *to* this service by
whoever meters; `tenant-engine` does not observe spend or count actions
itself.
- **No plan terms.** A guardrail may be *derived from* an `adaptive-pricing`
plan id, but plan definitions are never copied here.
- **Fail closed.** An unknown tenant, an unreadable store, or a missing
guardrail resolves to the most restrictive answer, never to "unlimited".
## T01 - Specify the guardrail model and its boundary
```task
id: TEN-WP-0006-T01
status: todo
priority: high
```
Write the guardrail domain contract before any code. Decide and document:
- The **limit kinds** in scope. Start with spend budget (a currency amount
over a period) and entity/action counts; leave rate limiting out unless it
falls out for free.
- **Precedence.** A tenant may inherit a limit from its grouping (ADR-0013),
from its assigned plan, and from an explicit per-tenant override. Define the
resolution order and make it total — every tenant resolves to exactly one
effective guardrail set, with no ambiguity and no "unset means unlimited".
- **The `trial` = zero-spend default**, and what the default is for each other
grouping. An unmapped grouping must resolve restrictively, not openly.
- **Reserved identifiers.** `tenant:platform` and `tenant:coulomb` are
ungrouped (see `domain.py`); state explicitly what they resolve to.
- Whether consumption lives in this repo at all. If it does, it is an opaque
counter written by an external meter, with the writer named in the contract.
Document the consumer-facing shape in `docs/tenant-guardrail-policy.md`,
matching the style of `docs/tenant-lifecycle-api.md`.
Done when the precedence rules are unambiguous, the fail-closed default is
explicit for every grouping, and the doc states plainly what this service does
*not* do (meter, bill, decide).
## T02 - Implement the guardrail domain model
```task
id: TEN-WP-0006-T02
status: todo
priority: high
```
Add a `guardrail/` module (the namespace `architecture.md` reserves) holding
pure domain types with no framework dependency, mirroring how `domain.py` is
structured: frozen dataclasses, validation that raises typed errors, and
resolution logic as a pure function over (grouping, plan_id, overrides).
Guardrail changes are **audited like role grants** — append-only, carrying
actor, reason, and correlation id. A limit change is a privilege change and
must be as reconstructible as a `RoleGrant`.
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.
## T03 - Persist guardrails in both stores
```task
id: TEN-WP-0006-T03
status: todo
priority: high
```
Extend the `TenantStore` Protocol and both implementations (`InMemoryTenantStore`
and the SQLite store) with guardrail persistence and its audit trail. Follow the
precedent set by `mutate_tenant()` in TEN-WP-0005: the mutation, the audit
event, and any idempotency receipt commit **in one transaction** — a crash must
never leave a changed limit with no audit record.
Add a forward-only, idempotent migration that backfills existing tenants with
their grouping-derived defaults. Existing tenants must not silently gain a
looser limit than a fresh tenant of the same grouping would get.
Interaction with lifecycle: decide and test what a `retired` tenant's
guardrails resolve to. Follow the TEN-WP-0005 precedent — operations that only
*reduce* privilege stay available while retired; loosening does not.
Done when the store-conformance suite (already parametrised over both backends)
covers guardrails, so the durable store cannot diverge from the reference
semantics.
## T04 - Expose guardrail read and write APIs
```task
id: TEN-WP-0006-T04
status: todo
priority: high
```
Add the API surface, authorized through the existing flex-auth `WriteAuthorizer`
with **distinct actions** so policy can permit reading a limit without
permitting a change to it — the same split TEN-WP-0005 used for
`tenant.update` / `tenant.retire` / `tenant.reactivate`:
- a read returning a tenant's effective guardrails plus their provenance
(which layer each limit came from), for `flex-auth` to consume;
- a write to set or clear a per-tenant override, requiring `Idempotency-Key`,
`If-Match`, actor, reason, and correlation id, consistent with the lifecycle
mutation contract.
Authorization runs **before** the store is touched, so an unauthorized caller
cannot probe which tenants exist. Errors are redacted: never reflect policy
internals or store paths in `detail`.
New flex-auth actions mean a policy-package change outside this repo. Name the
required actions explicitly in the task record so the flex-auth side is a
tracked handoff and not a surprise — this is exactly what stalled
TEN-WP-0005-T05.
Done when the routes are authorized, versioned, idempotent, correlated, and
provider-neutral, and the OpenAPI document makes the semantics unambiguous.
## T05 - Conformance and consumer handoff
```task
id: TEN-WP-0006-T05
status: todo
priority: medium
```
Cover: every grouping's default, `trial` resolving to zero spend, precedence
between grouping/plan/override, unmapped grouping failing closed, reserved
identifiers, guardrail reads on a retired tenant, stale-version conflict,
idempotent replay, cross-tenant authorization denial, store outage, and error
redaction. Prove the existing create/role/plan/lifecycle clients are unaffected.
Then write the consumer handoff for `flex-auth` — the guardrail read is only
useful once a PDP consults it — naming the API version, the authorization
actions, and the contract doc, in the table format TEN-WP-0005-T05 used.
**Do not** deploy to production as part of this workplan. Cluster rollout is a
separate, human-authorized step; see the note below.
Done when the full suite passes and the handoff is sent.
## Out of scope / explicitly deferred
- **Production rollout.** Image build and cluster apply need credentials and a
human authorization decision. Track separately.
- **The live flex-auth regression** (`flex-auth-tenant-engine` rolled back to
the four-action image, so `tenant.update` / `tenant.retire` /
`tenant.reactivate` currently deny with `unknown_action`). Unrelated to this
workplan, but it means any *new* actions from T04 land on a policy package
that is already behind. Resolve the regression before shipping T04's actions.
- **Metering and billing.** Not this repo, at any point.