decision
ADR-0001: Entitlement service shape
ADR-0001 (Accepted, 2026-05-08): Entitlement service shape.
Status: Accepted
Date: 2026-05-08
Deciders: Seth (Lead Architect), Blair (CEO sign-off pending)
Context
Per SPEC §6 and §8.4, MemberIntel must enforce tier-based model routing
server-side as a single source of truth. The cost review (2026-05-08)
identified that “Haiku-only for Free” is an invariant that must not be
defeatable by an admin tool, debug surface, or internal job. The service
must mint a typed model handle so that callers cannot pick the model.
Decision
Single module at src/memberintel/entitlement/. Public function
check_and_consume(tier, operation) -> ModelHandle. The handle is an
opaque dataclass that carries the model id internally; callers cannot
read .model_id (CI guard rule enforces). The handle’s bound operation
must match the operation arg in llm.call or the call raises
HandleOperationMismatch.
At kickoff: in-process state only. monthly_cap is declared in the
TIERS table but check_and_consume does not decrement any counter.
Slice 1 swaps in a Redis hot path + Postgres usage_counters durable
table behind the same function signature — no caller change.
V1.5 trial-state fields (trial_started_at, trial_ends_at,
card_required_at) are reserved in the eventual usage_counters schema
so the V1.5 trial flow is additive, not a rewrite.
Consequences
Positive:
- Single source of truth for tier-routing decisions.
- Opaque handle prevents the “string-typed model” hole the cost review identified.
- V1.5 trial state slot reserved early.
Negative / costs:
- Slightly more friction for debugging (can’t
print(handle.model_id)in app code). - Tests of evals must read
.model_id; the eval suite is the trusted boundary documenting that.
Mitigations:
__repr__deliberately omits_model_idso log lines don’t leak it.- The eval suite exercises the routing matrix explicitly.
Alternatives considered
- String-typed model passed through layers — rejected because the cost review flagged this as the exact hole that lets Free leak to Sonnet.
- Skip the handle, pass tier+operation everywhere — rejected because the tier-to-model mapping is then duplicated at every call site.