tenant-engine/docs/tenant-guardrail-policy.md
tegwick bdf9f5f643 Finish TEN-WP-0010: mutable grouping, contract corrected, handoffs sent
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:53:10 +02:00

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

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