tenant-engine/workplans/TEN-WP-0002-domain-model-and-scaffold.md
tegwick adb74d2443 TEN-WP-0002 T04-T07: cache-read, live-lookup (fail-closed), write API, close
- 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>
2026-07-23 22:24:09 +02:00

281 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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:<grouping>:<name>` 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 T01T06 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.** T01T06 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).