From ce2d620f4e6cefb7e4ff1dcbf2a845049b4b8155 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 22 May 2026 21:45:30 +0200 Subject: [PATCH] Finalize user-engine contracts and operability --- README.md | 7 +- docs/contracts.md | 49 +++++++++++ docs/final-assessment.md | 37 ++++++++ docs/operability.md | 39 ++++++++ docs/release.md | 37 ++++++++ docs/ui-contracts.md | 32 +++++++ pyproject.toml | 11 ++- src/user_engine/__init__.py | 5 +- src/user_engine/projections.py | 16 ++++ src/user_engine/service.py | 70 +++++++++++++++ tests/test_finalization_contracts.py | 88 +++++++++++++++++++ workplans/USER-WP-0006-finalization-polish.md | 16 ++-- 12 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 docs/contracts.md create mode 100644 docs/final-assessment.md create mode 100644 docs/operability.md create mode 100644 docs/release.md create mode 100644 docs/ui-contracts.md create mode 100644 tests/test_finalization_contracts.py diff --git a/README.md b/README.md index b9507a1..6a285c7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Headless multi-application, multi-tenant user management engine. make test ``` -See `docs/development.md`, `docs/configuration.md`, `docs/examples.md`, and -`docs/scenarios.md` for implementation boundaries, local usage examples, and -scenario coverage. +See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`, +`docs/examples.md`, `docs/scenarios.md`, `docs/operability.md`, +`docs/release.md`, `docs/ui-contracts.md`, and `docs/final-assessment.md` +for implementation boundaries, contracts, examples, and release readiness. diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 0000000..d22d416 --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,49 @@ +# Public Contracts + +## Headless Service Surface + +`UserEngineService` is the stable in-process API for the current MVP. Future +HTTP or RPC adapters should preserve these operation names: + +- `health`, `readiness`, `operability_snapshot`, `outbox_diagnostics` +- `me`, `create_user`, `set_account_status`, `link_identity` +- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`, + `tenant_diagnostics` +- `register_application`, `publish_catalog` +- `set_profile_value`, `effective_profile`, `projection` +- `audit_records`, `outbox_events` + +## Error Taxonomy + +- `ValidationError`: caller supplied an invalid shape, state transition, or + catalog/profile value. +- `AuthorizationDenied`: the authorization port or tenant boundary denied the + operation. +- `NotFoundError`: a requested user, account, or active attribute is missing. +- `ConflictError`: uniqueness or ownership would be violated. + +## Catalog Contract + +Catalogs are active by namespace and owning application. Attribute keys must +use the namespace prefix. Active namespace ownership cannot move to another +application. Catalog updates cannot move versions backwards or downgrade +attribute sensitivity. + +## Projection Contract + +Application runtime, agent-context, and claims-enrichment projections require +an `application_id` and are filtered to that application's active catalogs. +Sensitive and secret values are redacted outside admin, audit, and +self-service projections. + +## Audit And Event Contract + +Every mutating service operation appends an audit record and outbox event with +the same correlation id and resolved tenant. Authorization denials are audited +without emitting outbox events. + +## Migration Contract + +The isolated store exposes `SCHEMA_VERSION = 0001_initial` and a `migrate` +hook. Database-backed stores must expose equivalent readiness semantics before +they are accepted by platform adapters. diff --git a/docs/final-assessment.md b/docs/final-assessment.md new file mode 100644 index 0000000..ec52810 --- /dev/null +++ b/docs/final-assessment.md @@ -0,0 +1,37 @@ +# Implementation Assessment + +## Implemented + +- Headless service API for users, accounts, identity links, applications, + catalogs, profiles, projections, audit records, and outbox events. +- Tenant context enforcement, tenant account state, memberships, tenant + profile precedence, tenant diagnostics, and cross-tenant denial. +- Multi-application catalog ownership, namespace collision protection, + semantic version checks, sensitivity downgrade prevention, app-filtered + projections, and claims-enrichment projection caching. +- Scenario fixtures and conformance-style tests for positive and negative + standalone, tenant, multi-app, redaction, audit, event, and cache paths. + +## Boundary Verification + +User-engine does not issue tokens, verify MFA, store credentials, act as the +policy decision point, own deployment, or provide a UI. It consumes verified +claims through an identity adapter, asks authorization through a port, emits +audit/outbox records, and exposes backend contracts for future UIs. + +## Accepted Deviations + +- The first persistence adapter is in-memory. It carries schema and migration + semantics but is not durable. +- The first API surface is in-process Python. HTTP/RPC transport adapters are + still future work. +- Metrics and cache diagnostics are local snapshots, not platform telemetry. + +## Follow-Up Work + +- Add a durable database adapter and migration tests. +- Add transport adapters with request/response contract tests. +- Add platform authorization, audit sink, secret provider, and outbox drain + adapters. +- Add release automation for SBOM, package build, static checks, and + deployment handoff. diff --git a/docs/operability.md b/docs/operability.md new file mode 100644 index 0000000..8634ab4 --- /dev/null +++ b/docs/operability.md @@ -0,0 +1,39 @@ +# Operability + +## Diagnostics + +Use `readiness()` for dependency checks and `operability_snapshot()` for +runtime counters and invariant checks. The snapshot currently reports store +readiness, audit correlation completeness, outbox diagnostic availability, and +counts for users, accounts, tenant accounts, memberships, applications, +catalogs, profile values, audit records, and pending outbox events. + +## Structured Logs + +Use `structured_log_context(correlation_id=..., tenant=..., actor=...)` as the +base log envelope. Adapters should add transport details around that envelope +without dropping correlation id or tenant. + +## Outbox Drain + +`outbox_diagnostics()` reports pending event count, event type counts, and the +oldest pending correlation id. A real outbox drain adapter should publish +events idempotently by `event_id`, retain `correlation_id`, and only mark +delivery after the sink acknowledges receipt. + +## Cache Status + +`ClaimsEnrichmentProjectionCache.status()` reports entry count and cached +tenant, application, and user keys. Token issuers must invalidate affected +users after profile, membership, or catalog changes before minting enriched +claims. + +## Runbook Checks + +1. Run `make test-conformance`. +2. Confirm `readiness().ready` is true. +3. Confirm `operability_snapshot().issues` is empty. +4. Confirm pending outbox events are either drained or expected for the local + environment. +5. Confirm production identity adapters reject local, expired, and + missing-tenant claims. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..e73f4f1 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,37 @@ +# Release And Compatibility + +## Version + +The current implementation is `0.1.0`: a headless MVP with standard-library +runtime behavior, local adapters, and conformance-style tests. Until `1.0.0`, +schema and service contracts may evolve, but changes should include migration +notes and scenario test updates. + +## Packaging + +The package uses a `src/` layout with setuptools metadata in `pyproject.toml`. +Build artifacts should be created from clean commits after `make test`, +`make test-scenarios`, `make test-integration`, and `make test-conformance` +pass. + +## Security And SBOM + +The current runtime has no third-party dependencies. Release automation should +still generate an SBOM for the Python package and run static/security scans +before publishing or deploying a platform adapter. + +## Migration Policy + +Persistence adapters must expose a schema version, readiness check, and +forward migration hook. Catalog updates must not move versions backwards or +downgrade sensitivity. + +## Compatibility Guarantees + +- Identity, authorization, secret, deployment, and UI ownership remain outside + user-engine. +- Application runtime projections require explicit application ids. +- Tenant-scoped operations require explicit tenant context once exposed over a + transport adapter. +- Outbox and audit correlation ids are part of the public integration + contract. diff --git a/docs/ui-contracts.md b/docs/ui-contracts.md new file mode 100644 index 0000000..d3559b6 --- /dev/null +++ b/docs/ui-contracts.md @@ -0,0 +1,32 @@ +# UI Handoff Contracts + +Future self-service and scope-admin UIs should consume user-engine through a +transport adapter that preserves the service shapes below. + +## Self-Service Account UI + +Required backend operations: + +- `me` to resolve the current actor, user, account, and identity links. +- `effective_profile` with the actor tenant and optional application id. +- `projection` with `SELF_SERVICE` for editable user-visible fields. +- `set_profile_value` for fields whose catalog mutability includes `USER`. +- `audit_records` or a filtered audit transport for recent user-visible + account activity. + +## Scope Admin UI + +Required backend operations: + +- `resolve_tenant_context` before all tenant-scoped screens. +- `set_tenant_account_status` for in-scope account state. +- `add_membership` for tenant/team membership changes. +- `projection` with `ADMIN` or a future admin transport projection. +- `tenant_diagnostics` for onboarding and support readiness checks. + +## Fixtures + +Use `user_engine.testing.scenarios` for human, tenant admin, platform +operator, delegated agent, invalid, expired, local issuer, and missing-tenant +fixtures. UIs should keep fixtures at the transport boundary and avoid +embedding identity-provider logic. diff --git a/pyproject.toml b/pyproject.toml index c8e3a50..140c32b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,16 @@ [project] name = "user-engine" -version = "0.0.0" +version = "0.1.0" description = "Headless user-domain and profile engine." requires-python = ">=3.12" +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + [tool.user-engine] package = "user_engine" -workplan = "USER-WP-0001" +workplan = "USER-WP-0006" diff --git a/src/user_engine/__init__.py b/src/user_engine/__init__.py index cbfeeae..1046f6a 100644 --- a/src/user_engine/__init__.py +++ b/src/user_engine/__init__.py @@ -1,13 +1,14 @@ """Headless user-domain and profile engine.""" -from user_engine.projections import ClaimsEnrichmentProjectionCache +from user_engine.projections import CacheStatus, ClaimsEnrichmentProjectionCache from user_engine.service import PLATFORM_TENANT, UserEngineService __all__ = [ + "CacheStatus", "ClaimsEnrichmentProjectionCache", "PLATFORM_TENANT", "UserEngineService", "__version__", ] -__version__ = "0.0.0" +__version__ = "0.1.0" diff --git a/src/user_engine/projections.py b/src/user_engine/projections.py index 6fa017f..84a396b 100644 --- a/src/user_engine/projections.py +++ b/src/user_engine/projections.py @@ -8,6 +8,14 @@ from user_engine.domain import Actor, ProjectionType from user_engine.service import Projection, UserEngineService +@dataclass +class CacheStatus: + entries: int + tenants: tuple[str, ...] + applications: tuple[str, ...] + users: tuple[str, ...] + + @dataclass class ClaimsEnrichmentProjectionCache: """Cache claims-enrichment projections for external token adapters. @@ -47,3 +55,11 @@ class ClaimsEnrichmentProjectionCache: for key in tuple(self._cache): if key[2] == user_id: del self._cache[key] + + def status(self) -> CacheStatus: + return CacheStatus( + entries=len(self._cache), + tenants=tuple(sorted({key[0] for key in self._cache})), + applications=tuple(sorted({key[1] for key in self._cache})), + users=tuple(sorted({key[2] for key in self._cache})), + ) diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 7a0a083..fe80cec 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -97,6 +97,21 @@ class TenantDiagnostics: memberships: tuple[Membership, ...] +@dataclass(frozen=True) +class OutboxDiagnostics: + pending_count: int + event_types: Mapping[str, int] + oldest_correlation_id: str | None + + +@dataclass(frozen=True) +class OperabilitySnapshot: + ready: bool + checks: Mapping[str, bool] + metrics: Mapping[str, int] + issues: tuple[str, ...] + + class UserEngineService: """Headless service API for isolated user and profile management.""" @@ -667,6 +682,61 @@ class UserEngineService: def outbox_events(self) -> tuple[OutboxEvent, ...]: return tuple(self.store.outbox_events) + def outbox_diagnostics(self) -> OutboxDiagnostics: + event_types: dict[str, int] = {} + for event in self.store.outbox_events: + event_types[event.event_type] = event_types.get(event.event_type, 0) + 1 + oldest = self.store.outbox_events[0].correlation_id if self.store.outbox_events else None + return OutboxDiagnostics( + pending_count=len(self.store.outbox_events), + event_types=event_types, + oldest_correlation_id=oldest, + ) + + def operability_snapshot(self) -> OperabilitySnapshot: + audit_correlation_ok = all( + bool(record.correlation_id) for record in self.store.audit_records + ) + checks = { + "ready": self.readiness().ready, + "audit_correlation": audit_correlation_ok, + "outbox_diagnostics": self.outbox_diagnostics().pending_count >= 0, + } + metrics = { + "users": len(self.store.users), + "accounts": len(self.store.accounts), + "tenant_accounts": len(self.store.tenant_accounts), + "memberships": len(self.store.memberships), + "applications": len(self.store.applications), + "catalogs": len(self.store.catalogs), + "profile_values": len(self.store.profile_values), + "audit_records": len(self.store.audit_records), + "pending_outbox_events": len(self.store.outbox_events), + } + issues = tuple(key for key, passed in checks.items() if not passed) + return OperabilitySnapshot( + ready=all(checks.values()), + checks=checks, + metrics=metrics, + issues=issues, + ) + + def structured_log_context( + self, + *, + correlation_id: str, + tenant: str, + actor: Actor | None = None, + ) -> Mapping[str, str]: + context = { + "correlation_id": correlation_id, + "tenant": tenant, + } + if actor is not None: + context["actor_issuer"] = actor.issuer + context["actor_subject"] = actor.subject + return context + def _session(self, actor: Actor, user: User, account: Account) -> UserSession: identities = tuple( identity diff --git a/tests/test_finalization_contracts.py b/tests/test_finalization_contracts.py new file mode 100644 index 0000000..3e3aa08 --- /dev/null +++ b/tests/test_finalization_contracts.py @@ -0,0 +1,88 @@ +import unittest + +from user_engine.adapters.local import ( + InMemoryUserEngineStore, + LocalAuthorizationCheckPort, +) +from user_engine.projections import ClaimsEnrichmentProjectionCache +from user_engine.service import UserEngineService +from user_engine.testing.fixtures import ( + FixtureIdentityClaimsAdapter, + human_actor_claims, + sample_application, + sample_application_binding, + sample_catalog, +) + + +class FinalizationContractTests(unittest.TestCase): + def test_operability_snapshot_and_outbox_diagnostics_are_consistent(self): + service = _service() + session = _bootstrap(service) + + snapshot = service.operability_snapshot() + outbox = service.outbox_diagnostics() + log_context = service.structured_log_context( + correlation_id="corr-log", + tenant="tenant:coulomb", + actor=session.actor, + ) + + self.assertTrue(snapshot.ready) + self.assertEqual(snapshot.issues, ()) + self.assertEqual( + snapshot.metrics["pending_outbox_events"], + outbox.pending_count, + ) + self.assertGreaterEqual(outbox.event_types["user.created"], 1) + self.assertEqual(log_context["correlation_id"], "corr-log") + self.assertEqual(log_context["actor_subject"], session.actor.subject) + + def test_claims_enrichment_cache_reports_status(self): + service = _service() + session = _bootstrap(service) + cache = ClaimsEnrichmentProjectionCache() + + cache.get( + service, + session.actor, + user_id=session.user.user_id, + tenant="tenant:coulomb", + application_id="app.demo", + correlation_id="corr-cache", + ) + status = cache.status() + cache.invalidate_user(session.user.user_id) + + self.assertEqual(status.entries, 1) + self.assertEqual(status.tenants, ("tenant:coulomb",)) + self.assertEqual(status.applications, ("app.demo",)) + self.assertEqual(cache.status().entries, 0) + + +def _service() -> UserEngineService: + return UserEngineService( + store=InMemoryUserEngineStore(), + identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=LocalAuthorizationCheckPort(), + ) + + +def _bootstrap(service: UserEngineService): + session = service.me(human_actor_claims(), correlation_id="corr-me") + service.register_application( + session.actor, + sample_application(), + binding=sample_application_binding(), + correlation_id="corr-app", + ) + service.publish_catalog( + session.actor, + sample_catalog(), + correlation_id="corr-catalog", + ) + return session + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/USER-WP-0006-finalization-polish.md b/workplans/USER-WP-0006-finalization-polish.md index 22fc7fb..1f53b89 100644 --- a/workplans/USER-WP-0006-finalization-polish.md +++ b/workplans/USER-WP-0006-finalization-polish.md @@ -4,7 +4,7 @@ type: workplan title: "User Engine Implementation Assessment And Polish" domain: netkingdom repo: user-engine -status: active +status: finished owner: codex topic_slug: netkingdom planning_priority: medium @@ -28,7 +28,7 @@ operability, packaging, and UI handoff readiness. ```task id: USER-WP-0006-T1 -status: todo +status: done priority: high state_hub_task_id: "e0b5621a-4935-45a6-bbd0-41476b3d3317" ``` @@ -38,7 +38,7 @@ interface guidance. Record gaps, accepted deviations, and follow-up work. ```task id: USER-WP-0006-T2 -status: todo +status: done priority: high state_hub_task_id: "467eebf2-3a16-45b0-a5bd-28b5c1b634b1" ``` @@ -48,7 +48,7 @@ UI, or deployment responsibilities. ```task id: USER-WP-0006-T3 -status: todo +status: done priority: medium state_hub_task_id: "3b7efcaf-041d-4262-973d-6cfd2a011b47" ``` @@ -58,7 +58,7 @@ responses, audit event shapes, and migration contracts. ```task id: USER-WP-0006-T4 -status: todo +status: done priority: medium state_hub_task_id: "a0bc5469-6c6f-429a-a58e-2f6c6489f5c3" ``` @@ -68,7 +68,7 @@ outbox drain diagnostics, cache status, and runbooks. ```task id: USER-WP-0006-T5 -status: todo +status: done priority: medium state_hub_task_id: "996ffd8d-5006-4393-8214-be8072ebae8e" ``` @@ -79,7 +79,7 @@ integration. ```task id: USER-WP-0006-T6 -status: todo +status: done priority: medium state_hub_task_id: "bc1ca685-be53-4f70-8717-7d7226c81944" ``` @@ -89,7 +89,7 @@ requirements, migration policy, and compatibility guarantees. ```task id: USER-WP-0006-T7 -status: todo +status: done priority: low state_hub_task_id: "7c4fa8b5-f6c4-434a-85e6-0048449fbdc8" ```