From 0d1435c2d2df865c7a2f8a508b2e20e5deddf699 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 16 Aug 2026 02:07:42 +0200 Subject: [PATCH] Finish TEN-WP-0006-T01: specify guardrail model and boundary Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 6 + docs/tenant-guardrail-policy.md | 249 ++++++++++++++++++ .../TEN-WP-0006-guardrail-quota-policy.md | 45 +++- 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 docs/tenant-guardrail-policy.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index f7480d1..ff39c7c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -14,6 +14,7 @@ | workplan | TEN-WP-0003 | finished | — | workplans/TEN-WP-0003-flex-auth-write-authorizer.md | | workplan | TEN-WP-0004 | finished | — | workplans/TEN-WP-0004-production-runtime.md | | workplan | TEN-WP-0005 | finished | — | workplans/TEN-WP-0005-tenant-update-and-retirement-api.md | +| workplan | TEN-WP-0006 | ready | — | workplans/TEN-WP-0006-guardrail-quota-policy.md | | task | ADHOC-2026-07-24-T01 | done | — | workplans/ADHOC-2026-07-24.md | | task | TEN-WP-0001-T01 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md | | task | TEN-WP-0001-T02 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md | @@ -37,3 +38,8 @@ | task | TEN-WP-0005-T03 | done | — | workplans/TEN-WP-0005-tenant-update-and-retirement-api.md | | task | TEN-WP-0005-T04 | done | — | workplans/TEN-WP-0005-tenant-update-and-retirement-api.md | | task | TEN-WP-0005-T05 | done | — | workplans/TEN-WP-0005-tenant-update-and-retirement-api.md | +| task | TEN-WP-0006-T01 | todo | — | workplans/TEN-WP-0006-guardrail-quota-policy.md | +| task | TEN-WP-0006-T02 | todo | — | workplans/TEN-WP-0006-guardrail-quota-policy.md | +| task | TEN-WP-0006-T03 | todo | — | workplans/TEN-WP-0006-guardrail-quota-policy.md | +| task | TEN-WP-0006-T04 | todo | — | workplans/TEN-WP-0006-guardrail-quota-policy.md | +| task | TEN-WP-0006-T05 | todo | — | workplans/TEN-WP-0006-guardrail-quota-policy.md | diff --git a/docs/tenant-guardrail-policy.md b/docs/tenant-guardrail-policy.md new file mode 100644 index 0000000..7af8294 --- /dev/null +++ b/docs/tenant-guardrail-policy.md @@ -0,0 +1,249 @@ +# Tenant guardrail policy (TEN-WP-0006) + +Consumer contract for tenant guardrails: the **ceilings** a tenant is subject +to. Primary consumer: `flex-auth`, which joins a tenant's limits with observed +consumption to reach a decision. + +`tenant-engine` owns *policy* — what a tenant is permitted to spend and how +many entities or actions it may hold. It does **not**: + +- **meter.** Consumption counters are produced by whoever meters the resource. + This service never observes spend and never counts actions. +- **bill.** Money movement belongs to no repo yet identified; plan *terms* + belong to `adaptive-pricing`. +- **decide.** `flex-auth` is the PDP. A guardrail read is an input to a + decision, never the decision itself. Never invert this. + +**A guardrail is a safety ceiling, not an entitlement.** It answers "how far +may this tenant go before something must stop it", not "what has this tenant +bought". Entitlement is `adaptive-pricing`'s question. + +--- + +## Limit kinds + +| Kind | Meaning | Unit | +|---|---|---| +| `spend` | money the tenant may commit over a period | integer minor units + ISO-4217 currency | +| `entity_count` | how many of a thing the tenant may hold at once | integer, no period | +| `action_count` | how many times the tenant may do a thing per period | integer + period | + +Amounts are **integer minor units** (cents), never floats — a budget compared +with a float is a budget that rounds the wrong way at the boundary. + +**Rate limiting is out of scope.** A per-second request ceiling is a gateway +concern with entirely different latency and storage characteristics; it does +not fall out of this model for free. `action_count` is a business-period +quota (per month, per day), not a traffic shaper. + +### Limit keys and the registry + +A limit is addressed by a dotted **limit key**. Keys are declared in an +explicit **registry**; a key that is not registered does not resolve. + +| Prefix | Kind | Example | +|---|---|---| +| `spend.` | `spend` | `spend.monthly` | +| `entity.` | `entity_count` | `entity.workspace` | +| `action.` | `action_count` | `action.export.monthly` | + +`spend.monthly` is the only key seeded by this workplan. Entity and action +keys are registered as consuming services arrive, each naming its owner in +the registry entry — `tenant-engine` stores the ceiling and never learns what +a `workspace` is. + +Reading an **unregistered** key is an error (`unknown_limit_key`), not a zero +and not an unlimited. Both of those would be lies: zero would break every +caller whose key is merely misspelled, and unlimited would fail open. + +The registry is validated at startup: every registered key must supply a +default for **every** grouping in `GROUPINGS`. A registry that does not is a +startup failure, so an unmapped grouping cannot reach production. + +--- + +## Resolution + +Every tenant resolves to exactly **one** effective value per registered limit +key. Resolution is a total, side-effect-free function of +`(grouping, plan_id, overrides, lifecycle)`. + +Precedence is evaluated **per key**, highest first — not per set. A plan that +supplies `spend.monthly` does not thereby wipe out a grouping-derived +`entity.workspace`. + +| # | Layer | Provenance | Source | +|---|---|---|---| +| 1 | per-tenant override | `override` | explicit, audited, authorized write | +| 2 | plan-derived | `plan` | derived from the assigned `adaptive-pricing` plan id | +| 3 | grouping default | `grouping` | the table below (ADR-0013) | +| 4 | fail-closed floor | `fail_closed` | the registry's declared floor | + +Layer 4 exists so resolution is total. It is reached only when the registry +is internally inconsistent — a grouping added to `GROUPINGS` with no default +declared. It resolves to the most restrictive value the key admits, and its +provenance is `fail_closed` so the condition is visible in a read rather than +mistaken for policy. + +**There is no "unset means unlimited".** Absence at every layer yields the +floor, never an open ceiling. + +### Unlimited + +`unlimited` exists as an **explicit sentinel**, subject to four rules: + +1. it is never a default; +2. it is never the result of absence, silence, or a parse failure; +3. it can only arrive by explicit declaration — an override, a plan-derived + limit, or the reserved profile below; +4. setting it is audited like any other limit change. + +The prohibition is on inferring an open ceiling, not on choosing one. + +### Lifecycle clamp + +The clamp is applied **after** precedence resolution and may only **reduce**: + +| Lifecycle | Effect | +|---|---| +| `active` | resolved value stands | +| `retired` | every limit clamps to the floor; provenance becomes `lifecycle` | + +This follows the TEN-WP-0005 precedent — operations that only reduce privilege +stay available while retired, loosening ones do not. A retired tenant's +guardrails remain **readable**; it is the values that clamp, not the endpoint. + +--- + +## Grouping defaults + +Monthly spend budget per grouping (ADR-0013), in minor units of the +deployment's canonical currency (`GUARDRAIL_CURRENCY`, default `EUR`). + +| Grouping | `spend.monthly` | Rationale | +|---|---|---| +| `trial` | **0** | ADR-0013 mandate — a trial tenant commits nothing | +| `friendly` | 0 | comped relationship; spend is the platform's, not the tenant's | +| `consumer` | 2 000 (€20) | single natural person | +| `single` | 5 000 (€50) | one-person business | +| `family` | 5 000 (€50) | household, consumer-shaped | +| `community` | 5 000 (€50) | volunteer-run, low commercial exposure | +| `agentic` | 10 000 (€100) | autonomous spender — deliberately tight | +| `small` | 25 000 (€250) | | +| `association` | 25 000 (€250) | small-business-shaped, member-funded | +| `medium` | 100 000 (€1 000) | | +| `large` | 500 000 (€5 000) | | +| `enterprise` | 2 000 000 (€20 000) | | + +> **Assumption flagged for product sign-off.** Only the `trial` = 0 row is +> canon (ADR-0013). The rest are conservative opening ceilings chosen so that +> no grouping starts unbounded. They are guardrails, not prices — raising one +> is an override or a plan-derived limit, both audited. Adjusting the table is +> a config change, not a code change. + +`agentic` sits below `small` on purpose: an autonomous agent can exhaust a +budget far faster than a human operator can notice, so its default ceiling is +set for the blast radius, not the buying power. + +### Currency + +Spend limits carry their currency explicitly. Because precedence resolves +per key and the highest layer wins outright, two currencies never combine +within one resolution — so `tenant-engine` never converts currencies, and has +no exchange rate anywhere in it. A plan-derived limit in a currency other +than the resolved read's is simply the value that won. + +--- + +## Reserved identifiers + +`tenant:platform` and `tenant:coulomb` are ungrouped (`grouping is None`), so +layer 3 cannot apply to them. They do not fall through to the floor either — +falling through would clamp the platform's own identity to zero and take the +platform down with it. + +They resolve to an explicit **`reserved` profile**, provenance `reserved`, +slotted at layer 3 in place of the grouping default: + +| Key kind | Reserved value | Why | +|---|---|---| +| `spend.*` | **0** | infrastructure identities are not billable spenders; platform cost is not metered per tenant | +| `entity.*`, `action.*` | `unlimited` (explicit) | the platform tenant operates the platform | + +This is the sentinel's rule 3 — an explicit declaration, not an inference. + +--- + +## Consumption + +**Consumption counters do not live in this repo.** Decided under T01. + +`tenant-engine` is a low-write policy authority with strong audit and +compare-and-swap semantics. Consumption is high-frequency telemetry with +opposite durability, latency, and retention needs. Storing it here would +drag metering into a service whose `SCOPE.md` explicitly disowns it, and the +audit trail that makes a limit *change* reconstructible is pure overhead on a +counter that ticks continuously. + +So the guardrail read returns **limits and provenance only**. A PDP joins +them with consumption obtained from the meter. + +> **Open boundary question.** No repo currently owns metering — the same gap +> `SCOPE.md` records for payment processing. Until one is named in +> `net-kingdom` canon, `flex-auth` can enforce presence-and-ceiling semantics +> (is there a limit? is it zero?) but not consumption-relative ones (has the +> tenant used it up?). A zero budget is fully enforceable today without any +> meter, which is what makes the ADR-0013 `trial` default land immediately. + +--- + +## Audit + +A limit change is a privilege change, and is audited exactly like a +`RoleGrant`: append-only, carrying actor, reason, and correlation id. A +guardrail's history must be as reconstructible as a role's — "who raised this +tenant's ceiling, when, and why" is an answerable question. + +--- + +## What a read returns + +Effective limits **with provenance** — which layer each value came from — +so a consumer can tell a deliberate enterprise ceiling from a floor reached +by accident. + +```json +{ + "tenant_id": "t-1", + "identifier": "tenant:trial:binky", + "lifecycle": "active", + "limits": { + "spend.monthly": { + "kind": "spend", + "value": 0, + "currency": "EUR", + "period": "P1M", + "provenance": "grouping" + } + } +} +``` + +Endpoint shapes, headers, error codes, and the flex-auth actions that gate +them are specified in T04 and documented here on completion. + +--- + +## Errors + +| `error_code` | Cause | +|---|---| +| `unknown_limit_key` | key is not in the registry | +| `guardrail_registry_invalid` | startup validation failed — a grouping has no default | + +Lifecycle, authorization, concurrency, and idempotency errors are unchanged +from `tenant-lifecycle-api.md`; guardrail writes use the same mutation +contract (`Idempotency-Key`, `If-Match`, actor, reason, correlation id). + +Errors are redacted: never a policy internal, a store path, or a registry +dump in `detail`. diff --git a/workplans/TEN-WP-0006-guardrail-quota-policy.md b/workplans/TEN-WP-0006-guardrail-quota-policy.md index 08e12c8..c17d9fc 100644 --- a/workplans/TEN-WP-0006-guardrail-quota-policy.md +++ b/workplans/TEN-WP-0006-guardrail-quota-policy.md @@ -12,6 +12,7 @@ updated: "2026-08-16" depends_on: - TEN-WP-0005 unblocks: [] +state_hub_workstream_id: "bddb6699-cb53-472a-9757-a3ed0eb0ce27" --- # TEN-WP-0006 - Guardrail and quota policy @@ -46,8 +47,9 @@ them: ```task id: TEN-WP-0006-T01 -status: todo +status: done priority: high +state_hub_task_id: "b0693ec3-b3a7-48fb-9f4f-baeedb488e9d" ``` Write the guardrail domain contract before any code. Decide and document: @@ -73,12 +75,50 @@ 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). +Done 2026-08-16: `docs/tenant-guardrail-policy.md`. Decisions worth carrying: + +- **Limit kinds:** `spend` (integer minor units + ISO-4217, never floats), + `entity_count`, `action_count`. Rate limiting stays out — it is a gateway + concern with different latency and storage needs, not a free by-product. +- **Registry, not open keys.** A limit key must be registered to resolve; an + unregistered key errors (`unknown_limit_key`) rather than resolving to zero + or unlimited. Zero would break a caller who merely misspelled a key; + unlimited would fail open. Startup validation requires a default for every + grouping, so an unmapped grouping cannot reach production. +- **Precedence, per key:** override → plan → grouping → fail-closed floor. Per + key, not per set, so a plan supplying `spend.monthly` does not wipe out a + grouping-derived `entity.*`. The floor is only reachable if the registry is + internally inconsistent, and reports provenance `fail_closed` so that state + is visible rather than mistaken for policy. +- **`unlimited` is an explicit sentinel**, never a default and never the result + of absence. The rule is "no unset means unlimited" — that bans *inferring* an + open ceiling, not deliberately declaring one. +- **Reserved identifiers** get a `reserved` profile at layer 3 rather than + falling through: spend 0 (infrastructure identities are not billable + spenders), counts explicitly unlimited. Falling through would clamp the + platform's own identity to zero and take the platform down with it. +- **Lifecycle clamp** applies after resolution and may only reduce; `retired` + clamps every limit to the floor while leaving reads working. +- **Consumption does not live in this repo.** tenant-engine is a low-write + policy authority with audit and CAS semantics; consumption is high-frequency + telemetry with opposite needs, and holding it here would drag metering into a + service `SCOPE.md` disowns it from. Reads return limits plus provenance only. + +Two things flagged rather than settled: the non-`trial` grouping ceilings are +conservative opening values needing product sign-off (only `trial` = 0 is +canon), and **no repo owns metering** — the same gap `SCOPE.md` records for +payment processing. Until canon names one, `flex-auth` can enforce +ceiling-presence semantics but not consumption-relative ones. A zero budget is +fully enforceable with no meter at all, which is why the ADR-0013 `trial` +default lands immediately. + ## T02 - Implement the guardrail domain model ```task id: TEN-WP-0006-T02 status: todo priority: high +state_hub_task_id: "1b28d836-11d9-4ef3-967b-05bfa74c304a" ``` Add a `guardrail/` module (the namespace `architecture.md` reserves) holding @@ -100,6 +140,7 @@ plans, and conflicting overrides. id: TEN-WP-0006-T03 status: todo priority: high +state_hub_task_id: "f1c93573-6322-4505-b738-7d66d67e60a8" ``` Extend the `TenantStore` Protocol and both implementations (`InMemoryTenantStore` @@ -126,6 +167,7 @@ semantics. id: TEN-WP-0006-T04 status: todo priority: high +state_hub_task_id: "4a256517-2773-4679-ad57-2909f22ac8a4" ``` Add the API surface, authorized through the existing flex-auth `WriteAuthorizer` @@ -157,6 +199,7 @@ provider-neutral, and the OpenAPI document makes the semantics unambiguous. id: TEN-WP-0006-T05 status: todo priority: medium +state_hub_task_id: "92036fa3-9031-4b42-a67e-93196e236e08" ``` Cover: every grouping's default, `trial` resolving to zero spend, precedence