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>
This commit is contained in:
tegwick 2026-07-23 22:01:23 +02:00
parent 7eb21c05b8
commit 0770ce82d9
11 changed files with 774 additions and 3 deletions

170
src/tenant_engine/domain.py Normal file
View file

@ -0,0 +1,170 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import datetime
from enum import Enum
from typing import Literal
# ADR-0013: onboarding-risk / entity-shape grouping. Orthogonal to
# capability role (below) -- neither axis constrains the other except
# through guardrail policy, which is a reserved, unimplemented concern.
GROUPINGS = frozenset(
{
"trial",
"friendly",
"single",
"small",
"medium",
"large",
"enterprise",
"consumer",
"family",
"community",
"association",
"agentic",
}
)
# tenant:platform and tenant:coulomb predate the grouping taxonomy and stay
# reserved, ungrouped identifiers (ADR-0013's Tenant Claim rationale).
RESERVED_IDENTIFIERS = frozenset({"tenant:platform", "tenant:coulomb"})
class InvalidTenantIdentifierError(ValueError):
"""A tenant identifier does not match `tenant:<grouping>:<name>` or a reserved form."""
class InvalidGrantError(ValueError):
"""A role grant violates a domain invariant (ADR-0014)."""
class CapabilityRole(str, Enum):
"""ADR-0014: non-exclusive capability roles a tenant may hold."""
PLTF = "PLTF"
IAM = "IAM"
VEN = "VEN"
CUS = "CUS"
GrantReason = Literal["plan_assignment", "manual_grant", "platform_default"]
def parse_tenant_identifier(identifier: str) -> tuple[str | None, str]:
"""Return (grouping, name). grouping is None only for reserved identifiers."""
if identifier in RESERVED_IDENTIFIERS:
return None, identifier.split(":", 1)[1]
parts = identifier.split(":")
if len(parts) != 3 or parts[0] != "tenant":
raise InvalidTenantIdentifierError(
f"Malformed tenant identifier: {identifier!r} (expected tenant:<grouping>:<name>)"
)
_, grouping, name = parts
if grouping not in GROUPINGS:
raise InvalidTenantIdentifierError(f"Unknown tenant grouping: {grouping!r}")
if not name:
raise InvalidTenantIdentifierError("Tenant name segment is empty")
return grouping, name
@dataclass(frozen=True, slots=True)
class Tenant:
tenant_id: str
identifier: str
grouping: str | None
@classmethod
def create(cls, *, tenant_id: str, identifier: str) -> "Tenant":
grouping, _name = parse_tenant_identifier(identifier)
return cls(tenant_id=tenant_id, identifier=identifier, grouping=grouping)
@property
def is_reserved(self) -> bool:
return self.grouping is None
@dataclass(frozen=True, slots=True)
class RoleGrant:
"""Audited role grant record -- the Tenant Role & Plan Grant Contract shape.
Append-only: revocation produces a new record via `revoke()`, it never
deletes the original.
"""
grant_id: str
tenant_id: str
role: CapabilityRole
grant_reason: GrantReason
plan_id: str | None
granted_by: str
granted_at: datetime
correlation_id: str
revoked_at: datetime | None = None
@property
def active(self) -> bool:
return self.revoked_at is None
def revoke(self, *, at: datetime) -> "RoleGrant":
if self.revoked_at is not None:
raise InvalidGrantError(f"Grant {self.grant_id!r} is already revoked")
return replace(self, revoked_at=at)
@dataclass(frozen=True, slots=True)
class PlanAssignment:
"""A tenant's current plan, referenced by id only -- never resolved or
duplicated locally. Plan term definitions belong to adaptive-pricing.
"""
tenant_id: str
plan_id: str
assigned_at: datetime
def create_role_grant(
*,
tenant: Tenant,
grant_id: str,
role: CapabilityRole,
grant_reason: GrantReason,
plan_id: str | None,
granted_by: str,
correlation_id: str,
granted_at: datetime,
) -> RoleGrant:
"""Construct a RoleGrant, enforcing ADR-0014's domain invariants.
- `plan_assignment` grants always require a `plan_id`.
- `platform_default` grants never carry a `plan_id`, and are only valid
for `trial`-grouped tenants (ADR-0014: trial may hold any role,
unrestricted, for showcase purposes) or the reserved, ungrouped
`tenant:platform`/`tenant:coulomb` tenants (their baseline roles were
never purchased either).
- `manual_grant` carries no grouping restriction and an optional
`plan_id`.
"""
if grant_reason == "plan_assignment" and plan_id is None:
raise InvalidGrantError("plan_assignment grants require a plan_id")
if grant_reason == "platform_default":
if plan_id is not None:
raise InvalidGrantError("platform_default grants must not carry a plan_id")
if tenant.grouping not in (None, "trial"):
raise InvalidGrantError(
"platform_default is only valid for trial-grouped or reserved tenants, "
f"got grouping={tenant.grouping!r}"
)
return RoleGrant(
grant_id=grant_id,
tenant_id=tenant.tenant_id,
role=role,
grant_reason=grant_reason,
plan_id=plan_id,
granted_by=granted_by,
granted_at=granted_at,
correlation_id=correlation_id,
)