Finalize user-engine contracts and operability

This commit is contained in:
2026-05-22 21:45:30 +02:00
parent b81a9e05da
commit ce2d620f4e
12 changed files with 392 additions and 15 deletions

View File

@@ -6,6 +6,7 @@ Headless multi-application, multi-tenant user management engine.
make test make test
``` ```
See `docs/development.md`, `docs/configuration.md`, `docs/examples.md`, and See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
`docs/scenarios.md` for implementation boundaries, local usage examples, and `docs/examples.md`, `docs/scenarios.md`, `docs/operability.md`,
scenario coverage. `docs/release.md`, `docs/ui-contracts.md`, and `docs/final-assessment.md`
for implementation boundaries, contracts, examples, and release readiness.

49
docs/contracts.md Normal file
View File

@@ -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.

37
docs/final-assessment.md Normal file
View File

@@ -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.

39
docs/operability.md Normal file
View File

@@ -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.

37
docs/release.md Normal file
View File

@@ -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.

32
docs/ui-contracts.md Normal file
View File

@@ -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.

View File

@@ -1,9 +1,16 @@
[project] [project]
name = "user-engine" name = "user-engine"
version = "0.0.0" version = "0.1.0"
description = "Headless user-domain and profile engine." description = "Headless user-domain and profile engine."
requires-python = ">=3.12" requires-python = ">=3.12"
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.user-engine] [tool.user-engine]
package = "user_engine" package = "user_engine"
workplan = "USER-WP-0001" workplan = "USER-WP-0006"

View File

@@ -1,13 +1,14 @@
"""Headless user-domain and profile engine.""" """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 from user_engine.service import PLATFORM_TENANT, UserEngineService
__all__ = [ __all__ = [
"CacheStatus",
"ClaimsEnrichmentProjectionCache", "ClaimsEnrichmentProjectionCache",
"PLATFORM_TENANT", "PLATFORM_TENANT",
"UserEngineService", "UserEngineService",
"__version__", "__version__",
] ]
__version__ = "0.0.0" __version__ = "0.1.0"

View File

@@ -8,6 +8,14 @@ from user_engine.domain import Actor, ProjectionType
from user_engine.service import Projection, UserEngineService from user_engine.service import Projection, UserEngineService
@dataclass
class CacheStatus:
entries: int
tenants: tuple[str, ...]
applications: tuple[str, ...]
users: tuple[str, ...]
@dataclass @dataclass
class ClaimsEnrichmentProjectionCache: class ClaimsEnrichmentProjectionCache:
"""Cache claims-enrichment projections for external token adapters. """Cache claims-enrichment projections for external token adapters.
@@ -47,3 +55,11 @@ class ClaimsEnrichmentProjectionCache:
for key in tuple(self._cache): for key in tuple(self._cache):
if key[2] == user_id: if key[2] == user_id:
del self._cache[key] 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})),
)

View File

@@ -97,6 +97,21 @@ class TenantDiagnostics:
memberships: tuple[Membership, ...] 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: class UserEngineService:
"""Headless service API for isolated user and profile management.""" """Headless service API for isolated user and profile management."""
@@ -667,6 +682,61 @@ class UserEngineService:
def outbox_events(self) -> tuple[OutboxEvent, ...]: def outbox_events(self) -> tuple[OutboxEvent, ...]:
return tuple(self.store.outbox_events) 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: def _session(self, actor: Actor, user: User, account: Account) -> UserSession:
identities = tuple( identities = tuple(
identity identity

View File

@@ -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()

View File

@@ -4,7 +4,7 @@ type: workplan
title: "User Engine Implementation Assessment And Polish" title: "User Engine Implementation Assessment And Polish"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: active status: finished
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: medium planning_priority: medium
@@ -28,7 +28,7 @@ operability, packaging, and UI handoff readiness.
```task ```task
id: USER-WP-0006-T1 id: USER-WP-0006-T1
status: todo status: done
priority: high priority: high
state_hub_task_id: "e0b5621a-4935-45a6-bbd0-41476b3d3317" state_hub_task_id: "e0b5621a-4935-45a6-bbd0-41476b3d3317"
``` ```
@@ -38,7 +38,7 @@ interface guidance. Record gaps, accepted deviations, and follow-up work.
```task ```task
id: USER-WP-0006-T2 id: USER-WP-0006-T2
status: todo status: done
priority: high priority: high
state_hub_task_id: "467eebf2-3a16-45b0-a5bd-28b5c1b634b1" state_hub_task_id: "467eebf2-3a16-45b0-a5bd-28b5c1b634b1"
``` ```
@@ -48,7 +48,7 @@ UI, or deployment responsibilities.
```task ```task
id: USER-WP-0006-T3 id: USER-WP-0006-T3
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "3b7efcaf-041d-4262-973d-6cfd2a011b47" state_hub_task_id: "3b7efcaf-041d-4262-973d-6cfd2a011b47"
``` ```
@@ -58,7 +58,7 @@ responses, audit event shapes, and migration contracts.
```task ```task
id: USER-WP-0006-T4 id: USER-WP-0006-T4
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "a0bc5469-6c6f-429a-a58e-2f6c6489f5c3" state_hub_task_id: "a0bc5469-6c6f-429a-a58e-2f6c6489f5c3"
``` ```
@@ -68,7 +68,7 @@ outbox drain diagnostics, cache status, and runbooks.
```task ```task
id: USER-WP-0006-T5 id: USER-WP-0006-T5
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "996ffd8d-5006-4393-8214-be8072ebae8e" state_hub_task_id: "996ffd8d-5006-4393-8214-be8072ebae8e"
``` ```
@@ -79,7 +79,7 @@ integration.
```task ```task
id: USER-WP-0006-T6 id: USER-WP-0006-T6
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "bc1ca685-be53-4f70-8717-7d7226c81944" state_hub_task_id: "bc1ca685-be53-4f70-8717-7d7226c81944"
``` ```
@@ -89,7 +89,7 @@ requirements, migration policy, and compatibility guarantees.
```task ```task
id: USER-WP-0006-T7 id: USER-WP-0006-T7
status: todo status: done
priority: low priority: low
state_hub_task_id: "7c4fa8b5-f6c4-434a-85e6-0048449fbdc8" state_hub_task_id: "7c4fa8b5-f6c4-434a-85e6-0048449fbdc8"
``` ```