14 KiB
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-authis 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:
- it is never a default;
- it is never the result of absence, silence, or a parse failure;
- it can only arrive by explicit declaration — an override, a plan-derived limit, or the reserved profile below;
- 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_changedevent 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.mdrecords for payment processing. Until one is named innet-kingdomcanon,flex-authcan 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-0013trialdefault 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.
{
"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=<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}
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:
{"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.