Finish TEN-WP-0006-T01: specify guardrail model and boundary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9f37b3cf6e
commit
0d1435c2d2
3 changed files with 299 additions and 1 deletions
|
|
@ -14,6 +14,7 @@
|
||||||
| workplan | TEN-WP-0003 | finished | — | workplans/TEN-WP-0003-flex-auth-write-authorizer.md |
|
| 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-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-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 | 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-T01 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md |
|
||||||
| task | TEN-WP-0001-T02 | 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-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-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-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 |
|
||||||
|
|
|
||||||
249
docs/tenant-guardrail-policy.md
Normal file
249
docs/tenant-guardrail-policy.md
Normal file
|
|
@ -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`.
|
||||||
|
|
@ -12,6 +12,7 @@ updated: "2026-08-16"
|
||||||
depends_on:
|
depends_on:
|
||||||
- TEN-WP-0005
|
- TEN-WP-0005
|
||||||
unblocks: []
|
unblocks: []
|
||||||
|
state_hub_workstream_id: "bddb6699-cb53-472a-9757-a3ed0eb0ce27"
|
||||||
---
|
---
|
||||||
|
|
||||||
# TEN-WP-0006 - Guardrail and quota policy
|
# TEN-WP-0006 - Guardrail and quota policy
|
||||||
|
|
@ -46,8 +47,9 @@ them:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0006-T01
|
id: TEN-WP-0006-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "b0693ec3-b3a7-48fb-9f4f-baeedb488e9d"
|
||||||
```
|
```
|
||||||
|
|
||||||
Write the guardrail domain contract before any code. Decide and document:
|
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
|
explicit for every grouping, and the doc states plainly what this service does
|
||||||
*not* do (meter, bill, decide).
|
*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
|
## T02 - Implement the guardrail domain model
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TEN-WP-0006-T02
|
id: TEN-WP-0006-T02
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "1b28d836-11d9-4ef3-967b-05bfa74c304a"
|
||||||
```
|
```
|
||||||
|
|
||||||
Add a `guardrail/` module (the namespace `architecture.md` reserves) holding
|
Add a `guardrail/` module (the namespace `architecture.md` reserves) holding
|
||||||
|
|
@ -100,6 +140,7 @@ plans, and conflicting overrides.
|
||||||
id: TEN-WP-0006-T03
|
id: TEN-WP-0006-T03
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "f1c93573-6322-4505-b738-7d66d67e60a8"
|
||||||
```
|
```
|
||||||
|
|
||||||
Extend the `TenantStore` Protocol and both implementations (`InMemoryTenantStore`
|
Extend the `TenantStore` Protocol and both implementations (`InMemoryTenantStore`
|
||||||
|
|
@ -126,6 +167,7 @@ semantics.
|
||||||
id: TEN-WP-0006-T04
|
id: TEN-WP-0006-T04
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "4a256517-2773-4679-ad57-2909f22ac8a4"
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the API surface, authorized through the existing flex-auth `WriteAuthorizer`
|
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
|
id: TEN-WP-0006-T05
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "92036fa3-9031-4b42-a67e-93196e236e08"
|
||||||
```
|
```
|
||||||
|
|
||||||
Cover: every grouping's default, `trial` resolving to zero spend, precedence
|
Cover: every grouping's default, `trial` resolving to zero spend, precedence
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue