From b8af02d74d7089f53d2dbf098be10950fedb0cea Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 16 Aug 2026 01:50:08 +0200 Subject: [PATCH] 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 --- .../TEN-WP-0006-guardrail-quota-policy.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 workplans/TEN-WP-0006-guardrail-quota-policy.md diff --git a/workplans/TEN-WP-0006-guardrail-quota-policy.md b/workplans/TEN-WP-0006-guardrail-quota-policy.md new file mode 100644 index 0000000..08e12c8 --- /dev/null +++ b/workplans/TEN-WP-0006-guardrail-quota-policy.md @@ -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.