generated from coulomb/repo-seed
Finalize user-engine contracts and operability
This commit is contained in:
@@ -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.
|
||||
|
||||
49
docs/contracts.md
Normal file
49
docs/contracts.md
Normal 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
37
docs/final-assessment.md
Normal 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
39
docs/operability.md
Normal 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
37
docs/release.md
Normal 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
32
docs/ui-contracts.md
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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})),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
88
tests/test_finalization_contracts.py
Normal file
88
tests/test_finalization_contracts.py
Normal 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()
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user