# 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. ### Grouping can change (TEN-WP-0010) A tenant's grouping is **mutable**, through `POST /tenants/{id}/grouping` (action `tenant.grouping.set`). Reclassifying a tenant moves every grouping-derived ceiling with it — a `trial` tenant reclassified to `medium` goes from a zero spend budget to €1 000. Two consequences for anyone reading guardrails: - **A ceiling can change with no guardrail write against it.** The audit record is the `tenant_grouping_changed` event on the tenant, not an entry in the guardrail change log. That is deliberate: a guardrail trail containing entries no guardrail write produced would be worse than one that is complete but needs two event types read together. - **Precedence is unchanged.** A per-tenant override still wins over the new grouping default. Reclassification moves the grouping layer only. Grouping is read from the tenant record, never parsed from the identifier's middle segment — that segment is historical, and the two are expected to diverge after a reclassification. ### 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" } } } ``` --- ## Endpoints ### `GET /tenants/{tenant_id}/guardrails?actor=` Every registered key, resolved, with provenance — the shape above. `actor` is required: the read is authorized, so there is no anonymous caller. ### `PUT /tenants/{tenant_id}/guardrails/{limit_key}` ```http PUT /tenants/t-1/guardrails/spend.monthly Idempotency-Key: 4f1c… If-Match: "1" {"limit": {"kind": "spend", "amount": "9000", "currency": "EUR", "period": "P1M"}, "actor": "ops", "reason": "raised for pilot", "correlation_id": "corr-1"} ``` `amount` is a **string** so the `unlimited` sentinel and an integer share one field, and so a spend amount in minor units never round-trips through a float. ### `DELETE /tenants/{tenant_id}/guardrails/{limit_key}` Same headers; body is `{"actor", "reason", "correlation_id"}`. Clears the override so resolution falls back to the plan, then the grouping default. Both mutations return the new effective limit plus `ETag`, `change_id`, and `Idempotent-Replay: true|false`: ```json {"tenant_id": "t-1", "limit_key": "spend.monthly", "version": 2, "change_id": "9f2c…", "cleared": false, "effective": {"kind": "spend", "amount": 9000, "currency": "EUR", "period": "P1M", "provenance": "override"}} ``` A guardrail change **bumps the tenant's version**, so it invalidates the record ETag exactly as a metadata edit does. There is no separately versioned guardrail resource to race against the tenant record. `change_id` is derived from `(tenant_id, limit_key, Idempotency-Key)`, not random, so a genuine retry replays the original audit record rather than minting a second one for a mutation that happened once. --- ## Authorization Two **distinct** actions, so policy can give `flex-auth` the read without giving anything the write: | Action | Resource type | |---|---| | `tenant.guardrail.read` | `guardrail` | | `tenant.guardrail.set` | `guardrail` | These extend package `tenant-engine.write-api.mutate` alongside the existing seven actions. **Both are new and require a policy-package change in `flex-auth` before this surface functions in production** — until then every check correctly resolves to deny. Authorization runs **before** the store is touched on every guardrail route, so an unauthorized caller cannot use status codes or timing to probe which tenants exist or which limit keys are registered. A denied read of a real tenant and a denied read of a nonexistent one are byte-identical. --- ## Errors | Status | `error_code` | Cause | |---|---|---| | 400 | `idempotency_key_required` | `Idempotency-Key` header missing | | 400 | `invalid_if_match` | `If-Match` is `*` or not a version ETag | | 400 | `invalid_limit` | malformed limit — negative amount, spend without currency, wrong shape | | 403 | `write_denied` | flex-auth denied the action | | 404 | `tenant_not_found` | unknown tenant | | 404 | `unknown_limit_key` | key is not in the registry | | 409 | `version_conflict` | stale `If-Match` — re-read and retry | | 409 | `idempotency_key_conflict` | key reused for a different request | | 409 | `guardrail_loosening_denied` | change would raise a retired tenant's ceiling | | 422 | *(schema)* | unknown body field, or empty `reason`/`correlation_id` | | 428 | `if_match_required` | `If-Match` header missing | | 503 | `tenant_authority_unavailable` | store or authority unavailable | | — | `guardrail_registry_invalid` | startup validation failed — a grouping has no default | `guardrail_registry_invalid` is a **startup** failure, not a response: the service refuses to come up rather than serving an incomplete registry. Errors are redacted: never a policy internal, a store path, or a registry dump in `detail`. --- ## What is not wired yet The **plan-derived layer has no feed.** Precedence layer 2 exists, resolves, and is tested, but nothing populates it: `adaptive-pricing` owns plan terms and does not yet expose plan-derived ceilings. Until it does, a tenant's limits come from its override or its grouping default. Inventing a plan→limit mapping here would duplicate terms this repo does not own.