- authz.py: WriteAuthorizer Protocol + DefaultDenyWriteAuthorizer. Every
write endpoint calls it before touching the store; denial maps to
403 write_denied via an exception handler.
- app.py: GET /tenants/{id}/roles (cache-read, key-cape) and
GET /tenants/{id}/roles/live (live-lookup, flex-auth) share one handler
that fails closed (503) on StoreUnavailableError -- deliberately made
identical rather than giving cache-read weaker guarantees than the task
strictly required. POST /tenants, /roles/grant, /roles/revoke, /plan --
all four gated by the WriteAuthorizer seam, domain/store errors mapped to
400/404/409 after authorization passes.
- store.py: new StoreUnavailableError for the fail-closed test double.
43 tests passing: default-deny on every write endpoint, an
_AllowAllAuthorizer test double proving the seam actually gates (full
create->grant->read->revoke->read->assign-plan lifecycle over real HTTP),
and a _BrokenStore double proving outage never looks like "zero roles".
Verified live over real HTTP, not just TestClient.
TEN-WP-0002 closed: all 7 tasks done, boundary-contract ownership checked
against the implementation with no drift found. Follow-ups recorded in the
closure note (real flex-auth WriteAuthorizer, key-cape wiring, guardrail
policy design, Binky as first real tenant record, durable persistence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
| id | type | title | domain | repo | status | owner | topic_slug | created | updated | state_hub_workstream_id |
|---|---|---|---|---|---|---|---|---|---|---|
| TEN-WP-0002 | workplan | Service skeleton, domain model, and the three boundary-contract APIs | infotech | tenant-engine | finished | codex | netkingdom | 2026-07-23 | 2026-07-23 | f2fcafb4-edef-4e8e-9401-d781b4d0c585 |
Service skeleton, domain model, and the three boundary-contract APIs
First real implementation workplan. Builds the tenant-engine skeleton
against net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md:
tenant/grouping/capability-role/plan-grant domain model, an audited grant
trail, and the three API surfaces the contract defines — cache-read
(key-cape), live-lookup (flex-auth), and write (grant/revoke/plan
mutation). Guardrail/quota policy stays a reserved, unimplemented namespace
per ADR-0014 — not in scope here.
Depends on: net-kingdom canon — iam-profile_v0.3.md,
tenant-engine-boundary-contract_v0.1.md, ADR-0013, ADR-0014 (all
ratified). Non-goals: payment processing, pricing-model definitions
(adaptive-pricing's job), real flex-auth integration (the write/live
APIs get a policy-hook interface, not a working flex-auth client — that's
a follow-up once flex-auth has a reachable endpoint), guardrail/quota
enforcement.
Task: Service skeleton
id: TEN-WP-0002-T01
status: done
priority: high
state_hub_task_id: "d22b0ce8-3f25-4c8d-b54f-ba44c65159c1"
Python 3.12 + FastAPI, matching qonto-assistant's layout convention:
pyproject.toml, Makefile (install-dev, test, lint, run), package
layout separating domain/ (pure), store/ (persistence), api/ (FastAPI
routers), from day one — not refactored in later.
Done when: make test runs an empty/smoke suite; make run starts a bare
FastAPI app with a /health endpoint.
Done 2026-07-23: pyproject.toml + Makefile land, mirroring
qonto-assistant's exactly (install-dev/test/lint/run).
src/tenant_engine/{domain,store,app,main}.py scaffolded per the planned
layering (domain.py pure, store.py the persistence seam, app.py the
FastAPI factory). Verified live: python -m tenant_engine.main starts on
127.0.0.1:8090, GET /health returns
{"status": "ok", "service": "tenant-engine", "version": "0.1.0"}.
Task: Domain model — tenant, grouping, capability role, plan grant
id: TEN-WP-0002-T02
status: done
priority: high
state_hub_task_id: "cfceea30-f383-4781-a7e4-226653aafca9"
Pure domain types, no framework dependency:
Tenant: id, identifier (tenant:<grouping>:<name>or reservedtenant:platform/tenant:coulomb), grouping (ADR-0013 enum, nullable for the two reserved identifiers).CapabilityRole: enumPLTF/IAM/VEN/CUS.RoleGrant: tenant_id, role, grant_reason (plan_assignment/manual_grant/platform_default), plan_id (nullable), granted_by, granted_at, revoked_at (nullable), correlation_id — the audited record shape from the boundary contract's Tenant Role & Plan Grant Contract.PlanAssignment: tenant_id, plan_id (references anadaptive-pricingplan id — stored as an opaque string, never resolved or duplicated locally), assigned_at.
Validation rules encoded as domain invariants, not just API-layer checks:
- grouping must be one of ADR-0013's twelve values, or the tenant identifier
must be exactly
tenant:platform/tenant:coulomb(grouping-less); - a
trial-grouped tenant may hold any role withgrant_reason: platform_defaultand noplan_id(ADR-0014) — this must be representable, not blocked by a plan-required constraint; - non-
trialroles other thanplatform_defaultrequire aplan_idwhengrant_reason: plan_assignment; - revoking a grant sets
revoked_at, never deletes the record (audit trail).
Done when: unit tests cover valid/invalid grouping values, the trial/ no-plan-required exception, and grant/revoke as append-only operations.
Done 2026-07-23: domain.py implements Tenant, CapabilityRole,
RoleGrant, PlanAssignment, and create_role_grant() exactly as
specified. One refinement made while implementing, not pre-specified in the
task text: platform_default is valid for trial-grouped tenants or
the reserved, ungrouped tenant:platform/tenant:coulomb tenants (their
baseline roles were never purchased either) — the task only mentioned the
trial case. Also enforced platform_default grants must not carry a
plan_id (implied by "no plan_id" in the task text, made an explicit
invariant). tests/test_domain.py covers all listed cases plus the
reserved-tenant extension and double-revoke rejection. pytest →
29 passed (whole-repo total, includes T03's tests below).
Task: Storage layer
id: TEN-WP-0002-T03
status: done
priority: high
state_hub_task_id: "ade9374a-ed26-45da-a424-a0d21effe520"
In-memory store behind a TenantStore protocol/interface (mirrors
qonto_client.QontoClientProtocol's pattern in qonto-assistant), so a
real backend can be swapped in later without touching domain/ or api/.
Every mutation emits a domain event (tenant created, role granted, role
revoked, plan assigned) per the boundary contract's Audit Correlation
Contract — the event bus itself can be a simple in-process list for now.
Done when: unit tests cover create/read/grant/revoke/assign-plan through the store interface, plus event emission for each mutation.
Done 2026-07-23: store.py's TenantStore Protocol +
InMemoryTenantStore implement create/get/grant/revoke/active_roles/
assign_plan, each emitting a DomainEvent. tests/test_store.py covers
the full lifecycle, non-exclusive role coexistence (CUS + VEN
simultaneously, proving ADR-0014's non-exclusivity isn't just a comment),
revoke-keeps-record-but-clears-active-set, and asserts the exact event
sequence for a full mutation chain. python -m compileall src tests clean.
Task: Cache-read API (for key-cape)
id: TEN-WP-0002-T04
status: done
priority: high
state_hub_task_id: "425cab98-ab7f-47fb-a8b6-ec0f52d62cd5"
GET /tenants/{tenant_id}/roles — returns current, non-revoked capability
roles for a tenant. This is the endpoint key-cape calls at token-issuance
time to source the cached tenant_roles claim (IAM Profile v0.3). No
authorization gate of its own beyond service-to-service auth (out of scope
here — see Non-Goals); this endpoint's whole purpose is to be cheap and
fast, per the boundary contract's performance model.
Done when: integration test hits the endpoint against the in-memory store and returns the expected role set for a seeded tenant.
Done 2026-07-23: Implemented in app.py. Returns
{"tenant_id": ..., "roles": [...]}, sorted role-value strings; 404 for an
unknown tenant. Extended beyond the task's minimum: also fails closed (503)
on store unavailability (see T05) — there was no good reason for the two
read endpoints to behave differently on that axis, and keeping them
identical avoids a second, subtly-different error-handling path to drift
later. tests/test_api_reads.py covers the happy path and the 404 case;
verified live over real HTTP against the running service.
Task: Live-lookup API (for flex-auth) — fail closed
id: TEN-WP-0002-T05
status: done
priority: high
state_hub_task_id: "2a04ce4c-c967-4540-8873-70ac419378e6"
GET /tenants/{tenant_id}/roles/live — same data as the cache-read
endpoint, but explicitly documented and tested as the path flex-auth must
call before authorizing aal2-class actions. The distinction from T04 is
operational intent (freshness guarantee, called synchronously on a
privileged-decision path), not payload shape.
Per the boundary contract's performance model: fail closed, never open. If the store is unavailable, this endpoint must return an error response that a policy caller would treat as "deny", not a default-allow or an empty role list indistinguishable from "no roles granted".
Done when: a test simulates store unavailability and asserts the endpoint
signals failure distinctly from "zero roles", not silently as 200 + [].
Done 2026-07-23: New store.StoreUnavailableError; both read endpoints
convert it to 503 {"detail": "tenant_roles_unavailable"}. A _BrokenStore
test double (tests/test_api_reads.py) always raises it from
active_roles(), simulating an outage; the test asserts 503 and
explicitly asserts the response body is not
{"tenant_id": ..., "roles": []} — the exact ambiguity the task exists to
prevent. TenantNotFoundError stays a distinct 404, so "tenant doesn't
exist," "store is down," and "tenant exists with zero roles" are three
different, distinguishable responses, never collapsed into one shape.
Task: Write API — grant, revoke, assign-plan
id: TEN-WP-0002-T06
status: done
priority: medium
state_hub_task_id: "3a2d1ee7-6e66-4080-9345-32ba457acf5f"
POST /tenants (create), POST /tenants/{id}/roles/grant,
POST /tenants/{id}/roles/revoke, POST /tenants/{id}/plan — all mutating
endpoints from the boundary contract's Write API. Per the contract,
tenant-engine does not self-authorize these writes; flex-auth is meant
to gate them. Since a reachable flex-auth integration is explicitly a
non-goal here, implement a WriteAuthorizer protocol/interface point (one
class, default-deny stub) so every mutation already flows through a single
seam — swapping in a real flex-auth client later touches one file, not
every endpoint.
Done when: unit tests confirm every write endpoint calls the
WriteAuthorizer seam and is denied by the default-deny stub; a
test-only authorizer override proves the seam actually gates the mutation
when swapped.
Done 2026-07-23: authz.py's WriteAuthorizer Protocol +
DefaultDenyWriteAuthorizer; every write endpoint in app.py calls
authorizer.authorize(...) before touching the store, and a
WriteAuthorizationDeniedError exception handler maps denial to
403 {"error_code": "write_denied", ...}. tests/test_api_writes.py
asserts all four write endpoints are 403 under the default authorizer,
then swaps in an _AllowAllAuthorizer test double and exercises the full
create → grant → read → revoke → read → assign-plan lifecycle over real
HTTP requests (TestClient), confirming the seam actually gates rather
than just existing decoratively. Domain/store errors surfacing after
authorization passes get separate status codes: 400 invalid
identifier/grant, 404 unknown tenant, 409 duplicate tenant. Verified
live: unauthenticated POST /tenants over real HTTP returns 403 with the
expected body, matching the test suite. One known simplification, not
resolved here: actor is a request-body field rather than extracted from a
real auth context — there is no real auth context yet, since wiring one in
is exactly what a flex-auth-backed WriteAuthorizer will do; noted for
whoever picks up that follow-up.
Task: Closure review
id: TEN-WP-0002-T07
status: done
priority: low
state_hub_task_id: "eecf4bc8-d20b-4b18-986a-6518f2f74d7b"
Confirm T01–T06 done; run make test/make lint; verify the three API
surfaces match the boundary contract's Ownership Model and Source-of-Truth
Matrix with no drift. Note follow-ups: real flex-auth WriteAuthorizer
integration, key-cape wiring to actually call the cache-read endpoint at
issuance, guardrail/quota policy design (ADR-0014's reserved item), and
Binky Hedgehog GmbH as the first real tenant record once key-cape's
KEY-WP-0004 reaches that point. Run statehub fix-consistency.
Closed 2026-07-23. T01–T06 all done. PYTHONPATH=src pytest → 43 passed; python -m compileall src tests clean; ruff unavailable in this
workstation's shared venv (same environment gap noted in qonto-assistant's
own workplans) — compileall substituted, make lint untested against a
real ruff install. Verified live over real HTTP, not just TestClient:
/health 200, unauthenticated POST /tenants 403 with the expected error
body, GET on an unknown tenant 404.
Ownership check against the boundary contract: all three API surfaces
present (cache-read, live-lookup, write); every write routes through the
WriteAuthorizer seam, never self-authorized; every mutation emits a
DomainEvent; plan_id is stored and returned as an opaque string, never
resolved against adaptive-pricing locally; nothing here stores user data,
issues tokens, or makes an authorization decision. No drift found.
Follow-ups, not started:
- Real
flex-auth-backedWriteAuthorizer(replaces the default-deny stub) — also where a realactoridentity would come from instead of a request-body field. key-capewiring to actually call the cache-read endpoint at token issuance.- Guardrail/quota policy design (ADR-0014's reserved item — spend limits, entity/action counts).
- Binky Hedgehog GmbH as the first real tenant record, once
key-cape'sKEY-WP-0004reaches that point. - Persistence beyond in-memory (
TenantStoreis already a swappable seam for this).