9.7 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.
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"
}
}
}
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.