tenant-engine/workplans/TEN-WP-0002-domain-model-and-scaffold.md
tegwick 0770ce82d9 TEN-WP-0002 T01-T03: service skeleton, domain model, storage layer
Python 3.12 + FastAPI, pyproject.toml + Makefile mirroring
qonto-assistant's exactly. src/tenant_engine/{domain,store,app,main}.py:

- domain.py: Tenant, CapabilityRole (PLTF/IAM/VEN/CUS), RoleGrant,
  PlanAssignment, create_role_grant() enforcing ADR-0014's invariants.
  Refinement made while implementing: platform_default grants are valid
  for trial-grouped tenants OR the reserved tenant:platform/tenant:coulomb
  tenants (their baseline roles were never purchased either) -- the task
  spec only named the trial case.
- store.py: TenantStore Protocol + InMemoryTenantStore, every mutation
  emits a DomainEvent per the boundary contract's Audit Correlation
  Contract.
- app.py/main.py: FastAPI factory + /health endpoint, verified live on
  127.0.0.1:8090.

29 tests passing, including non-exclusive role coexistence (CUS+VEN
simultaneously) and append-only revoke semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 22:01:23 +02:00

215 lines
9 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: ready
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: todo
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.
## Task: Live-lookup API (for flex-auth) — fail closed
```task
id: TEN-WP-0002-T05
status: todo
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 + `[]`.
## Task: Write API — grant, revoke, assign-plan
```task
id: TEN-WP-0002-T06
status: todo
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.
## Task: Closure review
```task
id: TEN-WP-0002-T07
status: todo
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`.