tenant-engine/docs/tenant-guardrail-policy.md
tegwick 0d1435c2d2 Finish TEN-WP-0006-T01: specify guardrail model and boundary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:07:42 +02:00

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-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.

{
  "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.