Closes the guardrail/quota concern reserved since repo creation. Notably
ADR-0013 specifies trial tenants default to a zero spend budget; no such
default exists today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pin tenant-engine to the CI-built digest from 7e68cc8, record rollback
digests, and close T05 after live create/update/retire/replay/reactivate
evidence against a disposable production tenant. Hands the 0.1.0 contract
to USER-WP-0021.
Production still serves the TEN-WP-0004 image, which has no lifecycle
routes. Recover the live railiance01 objects into deploy/ so rollback
does not depend on a cluster annotation, and add the fleet CI image
workflow so the lifecycle image is built from a forge revision.
Add an explicit tenant lifecycle (active/retired), allow-listed mutable
metadata, record versioning, and lifecycle timestamps to the tenant authority.
- domain: TenantLifecycle, with_metadata/retire/reactivate, immutability and
transition invariants. Identifier stays immutable -- it is the IAM Profile
`tenant` claim key-cape mints into tokens.
- store: mutate_tenant() commits idempotency replay, version CAS, mutation,
and audit event together; durable receipts survive restart. Retired tenants
refuse new grants and plan changes but keep their history.
- sqlite: forward-only idempotent migration; existing rows default to active
at version 1. Reads now take the write lock -- the concurrent-writer test
caught unguarded reads on the shared connection observing mid-transaction
state as a spurious tenant_not_found.
- api: GET/PATCH /tenants/{id}, POST retire|reactivate. Idempotency-Key and
If-Match required, distinct flex-auth actions per operation, stable error
schema, redacted 503s.
- docs/tenant-lifecycle-api.md: consumer contract for user-engine.
Implemented against SQLite, not PostgreSQL as the workplan assumed --
TEN-WP-0004 shipped SQLite on a PVC as the production store.
124 tests pass (was 66); no breaking change to existing endpoints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Maintainer decision, 2026-07-29: adopts TRSL V1C1 as this repo's
preliminary governing license, per target-revenue's
workplans/TREV-WP-0008-governance-and-pilot-rollout.md T05. Full
specialist legal review is deferred until out of beta (target-revenue
SCOPE.md §1). No Phase is yet declared for this repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found via a real cross-service check while implementing key-cape's
KEY-WP-0005-T02: key-cape's Go adapter called GET /tenants/tenant:coulomb/roles
and got a genuine 404 for a tenant that existed. External callers
(key-cape, flex-auth) only ever have a tenant's profile identifier, never
tenant-engine's internal tenant_id (caller-chosen at creation, otherwise
opaque). Every existing test happened to use identical strings for both
fields, so this was invisible until a real, independent second caller
exercised the documented contract.
InMemoryTenantStore gained a _by_identifier index and a _resolve() helper
every method calls first; create_tenant now also rejects a duplicate
identifier under a different internal id (an oversight the same fix
surfaced). 5 new tests, including the exact HTTP-level scenario with colon
characters in the URL path. 65 total, all 60 pre-existing tests unaffected.
Re-verified end-to-end for real: fresh flex-auth + tenant-engine + key-cape's
actual adapter code, over real HTTP -- roles=[IAM] ok=true resolving by
identifier.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
flex_auth.py: CheckRequest + FlexAuthCheckClient against flex-auth's real
POST /v1/check contract (schemas/check_request.schema.json,
decision_envelope.schema.json, read directly from the flex-auth repo, not
guessed). Fail-closed by construction: only effect=="allow" authorizes;
every other effect, non-200, malformed body, or transport failure resolves
to deny, nothing raises past is_allowed().
authz.FlexAuthWriteAuthorizer implements the existing WriteAuthorizer
Protocol. Action -> resource-type mapping coordinated with FLEX-WP-0008's
planned vocabulary (both repos reference the same table).
DefaultDenyWriteAuthorizer stays the fallback when no flex-auth URL is
configured.
config.py: Settings.from_env(), mirroring qonto-assistant's pattern.
docs/flex-auth-integration.md documents the contract, fail-closed rule,
and current real state (denies everything until FLEX-WP-0008 lands).
60 tests passing. Verified live twice over real HTTP between separate
processes (not just MockTransport): a deny-returning flex-auth double
produces 403 from POST /tenants, an allow-returning one produces 201.
Also registered (not implemented) the two workplans this depends on for a
complete picture: flex-auth/FLEX-WP-0008 (protected-system registration --
what makes allow reachable) and key-cape/KEY-WP-0005 (discovered key-cape
emits none of iam-profile_v0.3.md's core claims yet, not just missing
tenant_roles -- a bigger, security-sensitive gap flagged rather than
quietly worked around).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 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>
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>
statehub register + repo-seed template scaffold (CLAUDE.md, .claude/rules/,
registry/). INTENT.md and SCOPE.md rewritten from the generated stub to
match net-kingdom's ratified tenant-engine-boundary-contract_v0.1.md
(Purpose, Responsibility Boundary, Non-Goals). topic_slug corrected from
the auto-assigned custodian default to netkingdom, matching key-cape and
user-engine.
TEN-WP-0001 (bootstrap) complete: files reviewed/refined, stack decided
(Python 3.12 + FastAPI, matching qonto-assistant's convention), first real
workplan seeded.
TEN-WP-0002 drafted: service skeleton, domain model (tenant/grouping/
capability-role/plan-grant), storage layer, and the three boundary-contract
API surfaces (cache-read for key-cape, live-lookup for flex-auth with an
explicit fail-closed requirement, write API behind a WriteAuthorizer seam
since real flex-auth integration is a declared non-goal for this pass).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>