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

9 KiB
Raw Blame History

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 ready 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 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

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: 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

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

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

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.