--- id: TEN-WP-0002 type: workplan title: "Service skeleton, domain model, and the three boundary-contract APIs" domain: infotech repo: tenant-engine status: finished owner: codex topic_slug: netkingdom created: "2026-07-23" updated: "2026-07-23" state_hub_workstream_id: "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 ```task 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 ```task 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::` or reserved `tenant:platform`/`tenant:coulomb`), grouping (ADR-0013 enum, nullable for the two reserved identifiers). - `CapabilityRole`: enum `PLTF`/`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 an `adaptive-pricing` plan 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 with `grant_reason: platform_default` and no `plan_id` (ADR-0014) — this must be representable, not blocked by a plan-required constraint; - non-`trial` roles other than `platform_default` require a `plan_id` when `grant_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 ```task 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) ```task 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 ```task 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 ```task 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 ```task 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`-backed `WriteAuthorizer` (replaces the default-deny stub) — also where a real `actor` identity would come from instead of a request-body field. - `key-cape` wiring 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`'s `KEY-WP-0004` reaches that point. - Persistence beyond in-memory (`TenantStore` is already a swappable seam for this).