feat: implement prepared account claims

This commit is contained in:
2026-06-15 22:37:31 +02:00
parent a36a25898e
commit 97cd03b551
14 changed files with 1376 additions and 13 deletions

View File

@@ -11,7 +11,8 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
`docs/evidence-gap-examples.md`, `docs/family-dataspace-onboarding.md`,
`docs/netkingdom-registration-onboarding-vision.md`,
`docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.md`,
`docs/registration-identity-and-factor-model.md`, `docs/scenarios.md`,
`docs/registration-identity-and-factor-model.md`,
`docs/prepared-accounts-and-entitlement-claims.md`, `docs/scenarios.md`,
`docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
for implementation boundaries, contracts, canon mappings, examples, and release

View File

@@ -59,6 +59,7 @@ truth.
Implementation and planning work is tracked in `workplans/USER-WP-0001`
through `USER-WP-0015`. `USER-WP-0010` implements the first headless
registration and factor-evidence slice; `USER-WP-0011` through `USER-WP-0015`
remain proposed future workplans for prepared accounts, hats/access profiles,
onboarding journeys, optional UI, and security conformance.
registration and factor-evidence slice. `USER-WP-0011` implements prepared
accounts and entitlement claims. `USER-WP-0012` through `USER-WP-0015` remain
proposed future workplans for hats/access profiles, onboarding journeys,
optional UI, and security conformance.

View File

@@ -9,6 +9,9 @@ HTTP or RPC adapters should preserve these operation names:
- `start_registration`, `attach_registration_factor`, `complete_registration`,
`abandon_registration`, `expire_registration`, `resume_registration`,
`registration_diagnostics`
- `prepare_account`, `update_prepared_account`, `list_prepared_accounts`,
`revoke_prepared_account`, `expire_prepared_account`,
`claim_prepared_account`
- `me`, `create_user`, `set_account_status`, `link_identity`
- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`,
`tenant_diagnostics`
@@ -40,6 +43,25 @@ user-engine does not verify factors itself, issue credentials, perform MFA,
run eID proofing, or issue tokens. Those remain external IAM/proofing adapter
responsibilities.
## Prepared Account Contract
Prepared accounts are pending user-domain facts for people who have not yet
registered or have not yet claimed their prepared rights. They can carry
required factor matches, entitlement intent, preparer metadata, expiry, and
claim lifecycle state, but they do not create credentials.
`claim_prepared_account` requires a completed registration session and
unexpired verified `IdentityFactor` records that satisfy every prepared factor
requirement. A successful claim marks the package claimed and converts
prepared entitlements into user-engine-owned facts: tenant account state,
memberships, catalog validated profile values, application bindings, and
onboarding-request events.
Expired, revoked, claimed, mismatching, ambiguous, duplicate, or
approval-required packages fail closed. Denied claim decisions are audited
without outbox events. Mutation outbox payloads include ids, counts, statuses,
factor types, and journey names, but not normalized factor values.
## Identity Context Contract
`identity_context` is the first canon-facing read model for NetKingdom

View File

@@ -235,6 +235,10 @@ once.
## Recommended Workplans
As of 2026-06-15, `USER-WP-0010` and `USER-WP-0011` are implemented as
headless user-engine slices. The later workplans remain recommended follow-on
work.
| Workplan | Title | Purpose |
| --- | --- | --- |
| USER-WP-0010 | Registration Identity And Factor Model | Add registration sessions, NetKingdom ID semantics, factor evidence, and verification adapter boundaries. |

View File

@@ -0,0 +1,117 @@
# Prepared Accounts And Entitlement Claims
Status: implemented headless slice
Date: 2026-06-15
Related workplan: USER-WP-0011
## Purpose
Prepared accounts let a tenant admin, operator, family owner, service owner, or
upstream system prepare user-domain intent before a person registers. The
package can name expected factor matches, tenant account state, memberships,
profile defaults, application bindings, and onboarding journey hints.
Prepared accounts are not credentials. A package is claimable only after a
completed registration presents matching verified factor evidence.
## Domain Model
`PreparedAccount` stores pending account intent:
- tenant
- required factor matches
- prepared entitlements
- status: `pending`, `claimed`, `revoked`, or `expired`
- preparer subject
- optional display name and primary email hints
- optional expiry
- claim metadata and lifecycle timestamps
`PreparedFactorRequirement` stores the factor type and normalized value to
match against verified registration factors. The model also carries optional
source-system and evidence references.
`PreparedEntitlement` stores the activation intent. Supported kinds are:
- `tenant_account`
- `membership`
- `profile_value`
- `application_binding`
- `onboarding_journey`
Entitlements may be marked `requires_approval`. Those packages fail closed in
the current claim facade until an explicit approval workflow is added.
## Public Facade
`UserEngineService` exposes:
- `prepare_account(...)`
- `update_prepared_account(...)`
- `list_prepared_accounts(...)`
- `revoke_prepared_account(...)`
- `expire_prepared_account(...)`
- `claim_prepared_account(...)`
Create, update, list, revoke, expire, and claim operations all pass through the
authorization port. The service depends on `UserEngineStore` protocol methods,
not the in-memory adapter internals.
## Claim Rules
Claims are only evaluated for completed registration sessions with a resolved
canonical user. A prepared account matches when every required factor is
present as unexpired verified `IdentityFactor` evidence on the registration.
The claim facade fails closed when:
- the caller names a missing, revoked, expired, claimed, or mismatching package;
- no prepared account matches the registration factors;
- multiple pending prepared accounts match the same verified factors;
- any entitlement in the package requires manual approval;
- entitlement activation references an invalid profile attribute or
unregistered application.
Factor requirements must include non-empty normalized values. Duplicate
pending packages with the same tenant and factor-signature are blocked during
create/update. Expired packages are ignored by duplicate checks and cannot be
claimed.
## Activation
Successful claim converts prepared entitlements into user-engine-owned facts:
- `TenantAccount` for tenant access state;
- `Membership` for scoped role facts;
- `ProfileValue` for catalog-validated profile defaults;
- `ApplicationBinding` for registered protected-system mappings;
- `prepared_account.onboarding_requested` outbox events for journey starts.
The prepared account is then marked `claimed` with the claiming user and
registration id.
## Audit, Outbox, And Redaction
Prepared-account mutations emit audit and outbox records:
- `prepared_account.created`
- `prepared_account.updated`
- `prepared_account.revoked`
- `prepared_account.expired`
- `prepared_account.claimed`
- `prepared_account.onboarding_requested`
Denied claim decisions are audited without outbox events. Outbox payloads use
ids, counts, factor types, statuses, and journey names. They deliberately avoid
normalized factor values such as email addresses, phone numbers, postal
addresses, and eID payloads.
## Current Limits
- Prepared accounts do not issue credentials, invitations, MFA challenges, or
tokens.
- Approval-required entitlement packages are blocked until a later workplan
adds explicit approval decisions.
- Final authorization policy and ACL evaluation remains outside user-engine;
user-engine only activates owned facts for policy systems to consume.
- Journey orchestration beyond outbox requests is left to USER-WP-0013.

View File

@@ -106,9 +106,10 @@ payloads.
Diagnostics report counts by status and total verified factors. They do not
return factor values.
## Current Limits
## Follow-On Boundaries
- Prepared account claiming is intentionally left to USER-WP-0011.
- Prepared account claiming is implemented by USER-WP-0011 and documented in
`docs/prepared-accounts-and-entitlement-claims.md`.
- Hats, realms, services, assets, and access profiles are left to
USER-WP-0012.
- Welcome protocols and onboarding journeys are left to USER-WP-0013.

View File

@@ -21,6 +21,7 @@ from user_engine.domain import (
IdentityFactor,
Membership,
OutboxEvent,
PreparedAccount,
ProfileScope,
ProfileValue,
RegistrationSession,
@@ -54,6 +55,7 @@ class InMemoryUserEngineStore:
default_factory=dict
)
identity_factors: dict[str, IdentityFactor] = field(default_factory=dict)
prepared_accounts: dict[str, PreparedAccount] = field(default_factory=dict)
profile_values: dict[
tuple[str, str, ProfileScope, str | None], ProfileValue
] = field(default_factory=dict)
@@ -181,6 +183,21 @@ class InMemoryUserEngineStore:
if factor.user_id == user_id
)
def save_prepared_account(self, account: PreparedAccount) -> None:
self.prepared_accounts[account.prepared_account_id] = account
def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None:
return self.prepared_accounts.get(prepared_account_id)
def prepared_accounts_for_tenant(
self, tenant: str
) -> tuple[PreparedAccount, ...]:
return tuple(
account
for account in self.prepared_accounts.values()
if account.tenant == tenant
)
def save_profile_value(self, value: ProfileValue) -> None:
self.profile_values[
(value.user_id, value.attribute_key, value.scope, value.scope_id)
@@ -240,6 +257,7 @@ class InMemoryUserEngineStore:
"family_invitations": len(self.family_invitations),
"registration_sessions": len(self.registration_sessions),
"identity_factors": len(self.identity_factors),
"prepared_accounts": len(self.prepared_accounts),
"profile_values": len(self.profile_values),
"audit_records": len(self.audit_records),
"pending_outbox_events": len(self.outbox_events),
@@ -258,6 +276,7 @@ class InMemoryUserEngineStore:
"family_invitations": copy.deepcopy(self.family_invitations),
"registration_sessions": copy.deepcopy(self.registration_sessions),
"identity_factors": copy.deepcopy(self.identity_factors),
"prepared_accounts": copy.deepcopy(self.prepared_accounts),
"profile_values": copy.deepcopy(self.profile_values),
"audit_records": copy.deepcopy(self.audit_records),
"outbox_events": copy.deepcopy(self.outbox_events),
@@ -285,6 +304,7 @@ class InMemoryUserEngineStore:
"registration_sessions"
] # type: ignore[assignment]
self.identity_factors = snapshot["identity_factors"] # type: ignore[assignment]
self.prepared_accounts = snapshot["prepared_accounts"] # type: ignore[assignment]
self.profile_values = snapshot["profile_values"] # type: ignore[assignment]
self.audit_records = [*snapshot_audit_records, *denied_audit_records]
self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment]

View File

@@ -29,6 +29,11 @@ from user_engine.domain.models import (
Mutability,
OutboxEvent,
PrincipalType,
PreparedAccount,
PreparedAccountStatus,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
ProfileScope,
ProfileValue,
ProjectionType,
@@ -71,6 +76,11 @@ __all__ = [
"Mutability",
"OutboxEvent",
"PrincipalType",
"PreparedAccount",
"PreparedAccountStatus",
"PreparedEntitlement",
"PreparedEntitlementKind",
"PreparedFactorRequirement",
"ProfileScope",
"ProfileValue",
"ProjectionType",

View File

@@ -69,6 +69,13 @@ class RegistrationStatus(StrEnum):
REJECTED = "rejected"
class PreparedAccountStatus(StrEnum):
PENDING = "pending"
CLAIMED = "claimed"
REVOKED = "revoked"
EXPIRED = "expired"
class IdentityFactorType(StrEnum):
EMAIL = "email"
PHONE = "phone"
@@ -78,6 +85,14 @@ class IdentityFactorType(StrEnum):
SSO = "sso"
class PreparedEntitlementKind(StrEnum):
TENANT_ACCOUNT = "tenant_account"
MEMBERSHIP = "membership"
PROFILE_VALUE = "profile_value"
APPLICATION_BINDING = "application_binding"
ONBOARDING_JOURNEY = "onboarding_journey"
class ProfileScope(StrEnum):
GLOBAL = "global"
TENANT = "tenant"
@@ -379,6 +394,54 @@ class RegistrationSession:
rejected_at: datetime | None = None
@dataclass(frozen=True)
class PreparedFactorRequirement:
factor_type: IdentityFactorType
normalized_value: str
source_system: str | None = None
evidence_refs: tuple[CanonEntityReference, ...] = ()
@dataclass(frozen=True)
class PreparedEntitlement:
kind: PreparedEntitlementKind
tenant: str
entitlement_id: str = field(default_factory=lambda: new_id("pent"))
scope_type: str | None = None
scope_id: str | None = None
role: str | None = None
attribute_key: str | None = None
value: Any = None
profile_scope: ProfileScope = ProfileScope.GLOBAL
profile_scope_id: str | None = None
tenant_account_status: AccountStatus = AccountStatus.ACTIVE
application_binding: ApplicationBinding | None = None
onboarding_journey: str | None = None
requires_approval: bool = False
evidence_refs: tuple[CanonEntityReference, ...] = ()
@dataclass(frozen=True)
class PreparedAccount:
tenant: str
required_factor_matches: tuple[PreparedFactorRequirement, ...]
entitlements: tuple[PreparedEntitlement, ...]
prepared_account_id: str = field(default_factory=lambda: new_id("pacct"))
status: PreparedAccountStatus = PreparedAccountStatus.PENDING
display_name: str | None = None
primary_email: str | None = None
prepared_by_subject: str | None = None
correlation_id: str | None = None
expires_at: datetime | None = None
claimed_by_user_id: str | None = None
claimed_registration_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
claimed_at: datetime | None = None
revoked_at: datetime | None = None
expired_at: datetime | None = None
@dataclass(frozen=True)
class AuthorizationRequest:
actor: Actor

View File

@@ -26,6 +26,7 @@ from user_engine.domain import (
IdentityFactor,
Membership,
OutboxEvent,
PreparedAccount,
ProfileValue,
RegistrationSession,
TenantAccount,
@@ -148,6 +149,17 @@ class UserEngineStore(Protocol):
def factors_for_user(self, user_id: str) -> tuple[IdentityFactor, ...]:
"""Return verified factors attached to a user."""
def save_prepared_account(self, account: PreparedAccount) -> None:
"""Create or replace a prepared account package."""
def prepared_account(self, prepared_account_id: str) -> PreparedAccount | None:
"""Return a prepared account package by id."""
def prepared_accounts_for_tenant(
self, tenant: str
) -> tuple[PreparedAccount, ...]:
"""Return prepared account packages for a tenant."""
def save_profile_value(self, value: ProfileValue) -> None:
"""Create or replace a profile value."""

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import datetime
from typing import Any, Iterable, Mapping
from user_engine.domain import (
@@ -30,6 +31,11 @@ from user_engine.domain import (
Membership,
Mutability,
OutboxEvent,
PreparedAccount,
PreparedAccountStatus,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
ProfileScope,
ProfileValue,
ProjectionType,
@@ -65,6 +71,7 @@ _TERMINAL_REGISTRATION_STATUSES = {
RegistrationStatus.EXPIRED,
RegistrationStatus.REJECTED,
}
_UNSET = object()
@dataclass(frozen=True)
@@ -169,6 +176,18 @@ class RegistrationCompletion:
identity_context: IdentityContext
@dataclass(frozen=True)
class PreparedAccountClaim:
prepared_account: PreparedAccount
registration: RegistrationSession
user: User
memberships: tuple[Membership, ...]
tenant_accounts: tuple[TenantAccount, ...]
profile_values: tuple[ProfileValue, ...]
application_bindings: tuple[ApplicationBinding, ...]
onboarding_journeys: tuple[str, ...]
@dataclass(frozen=True)
class TenantDiagnostics:
tenant: str
@@ -612,6 +631,334 @@ class UserEngineService:
pending_session_count=pending_count,
)
def prepare_account(
self,
actor: Actor,
*,
tenant: str,
required_factor_matches: Iterable[PreparedFactorRequirement],
entitlements: Iterable[PreparedEntitlement],
display_name: str | None = None,
primary_email: str | None = None,
expires_at: datetime | None = None,
correlation_id: str | None = None,
) -> PreparedAccount:
tenant_context = self.resolve_tenant_context(actor, tenant)
required = tuple(required_factor_matches)
prepared_entitlements = tuple(entitlements)
self._validate_prepared_factor_requirements(required)
if not prepared_entitlements:
raise ValidationError("prepared account requires at least one entitlement")
self._validate_prepared_entitlements(
prepared_entitlements, tenant_context.tenant
)
self._ensure_no_duplicate_prepared_account(
tenant_context.tenant,
required,
excluding_prepared_account_id=None,
)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="prepared_account.create",
resource_type="user-engine:prepared-account",
resource_id="new",
tenant=tenant_context.tenant,
correlation_id=correlation_id,
context={
"factor_types": tuple(
sorted({item.factor_type.value for item in required})
),
"entitlement_count": len(prepared_entitlements),
},
)
prepared = PreparedAccount(
tenant=tenant_context.tenant,
required_factor_matches=required,
entitlements=prepared_entitlements,
display_name=display_name,
primary_email=primary_email,
prepared_by_subject=actor.subject,
correlation_id=correlation_id,
expires_at=expires_at,
)
with self.store.transaction():
self.store.save_prepared_account(prepared)
self._record_mutation(
actor,
action="prepared_account.create",
subject=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="prepared_account.created",
aggregate_id=prepared.prepared_account_id,
payload={
"prepared_account_id": prepared.prepared_account_id,
"factor_types": tuple(
sorted(
{
item.factor_type.value
for item in prepared.required_factor_matches
}
)
),
"entitlement_count": len(prepared.entitlements),
"expires_at": prepared.expires_at.isoformat()
if prepared.expires_at
else None,
},
)
return prepared
def update_prepared_account(
self,
actor: Actor,
prepared_account_id: str,
*,
required_factor_matches: Iterable[PreparedFactorRequirement] | None = None,
entitlements: Iterable[PreparedEntitlement] | None = None,
expires_at: datetime | None | object = _UNSET,
correlation_id: str | None = None,
) -> PreparedAccount:
prepared = self._require_prepared_account(prepared_account_id)
self._ensure_prepared_account_pending(prepared)
tenant_context = self.resolve_tenant_context(actor, prepared.tenant)
required = (
tuple(required_factor_matches)
if required_factor_matches is not None
else prepared.required_factor_matches
)
prepared_entitlements = (
tuple(entitlements) if entitlements is not None else prepared.entitlements
)
self._validate_prepared_factor_requirements(required)
if not prepared_entitlements:
raise ValidationError("prepared account requires at least one entitlement")
self._validate_prepared_entitlements(
prepared_entitlements, tenant_context.tenant
)
self._ensure_no_duplicate_prepared_account(
tenant_context.tenant,
required,
excluding_prepared_account_id=prepared.prepared_account_id,
)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="prepared_account.update",
resource_type="user-engine:prepared-account",
resource_id=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
updated = replace(
prepared,
required_factor_matches=required,
entitlements=prepared_entitlements,
expires_at=prepared.expires_at if expires_at is _UNSET else expires_at,
updated_at=utc_now(),
)
with self.store.transaction():
self.store.save_prepared_account(updated)
self._record_mutation(
actor,
action="prepared_account.update",
subject=updated.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="prepared_account.updated",
aggregate_id=updated.prepared_account_id,
payload={
"prepared_account_id": updated.prepared_account_id,
"factor_types": tuple(
sorted(
{
item.factor_type.value
for item in updated.required_factor_matches
}
)
),
"entitlement_count": len(updated.entitlements),
"expires_at": updated.expires_at.isoformat()
if updated.expires_at
else None,
},
)
return updated
def list_prepared_accounts(
self,
actor: Actor,
*,
tenant: str,
correlation_id: str | None = None,
) -> tuple[PreparedAccount, ...]:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="prepared_account.read",
resource_type="user-engine:prepared-account",
resource_id=tenant_context.tenant,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
return self.store.prepared_accounts_for_tenant(tenant_context.tenant)
def revoke_prepared_account(
self,
actor: Actor,
prepared_account_id: str,
*,
correlation_id: str | None = None,
) -> PreparedAccount:
return self._close_prepared_account(
actor,
prepared_account_id,
status=PreparedAccountStatus.REVOKED,
action="prepared_account.revoke",
event_type="prepared_account.revoked",
correlation_id=correlation_id,
)
def expire_prepared_account(
self,
actor: Actor,
prepared_account_id: str,
*,
correlation_id: str | None = None,
) -> PreparedAccount:
return self._close_prepared_account(
actor,
prepared_account_id,
status=PreparedAccountStatus.EXPIRED,
action="prepared_account.expire",
event_type="prepared_account.expired",
correlation_id=correlation_id,
)
def claim_prepared_account(
self,
actor: Actor,
registration_id: str,
*,
prepared_account_id: str | None = None,
correlation_id: str | None = None,
) -> PreparedAccountClaim:
session = self._require_registration_session(registration_id)
if session.status != RegistrationStatus.COMPLETED or session.user_id is None:
raise ValidationError("registration must be completed before claim")
tenant_context = self.resolve_tenant_context(actor, session.tenant)
correlation_id = correlation_id or new_id("corr")
factors = self.store.factors_for_registration(session.registration_id)
prepared = self._resolve_prepared_account_claim(
actor,
session,
factors,
prepared_account_id=prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
if any(entitlement.requires_approval for entitlement in prepared.entitlements):
self._record_audit_only(
actor,
action="prepared_account.claim",
subject=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
summary="prepared account claim denied: approval required",
)
raise AuthorizationDenied("prepared account claim requires approval")
decision = self._authorize(
actor,
action="prepared_account.claim",
resource_type="user-engine:prepared-account",
resource_id=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=session.user_id,
context={
"registration_id": session.registration_id,
"entitlement_count": len(prepared.entitlements),
},
)
user = self._require_user(session.user_id)
(
tenant_accounts,
memberships,
profile_values,
application_bindings,
onboarding_journeys,
) = self._prepared_entitlement_records(
prepared,
user.user_id,
correlation_id=correlation_id,
)
claimed_at = utc_now()
claimed = replace(
prepared,
status=PreparedAccountStatus.CLAIMED,
claimed_by_user_id=user.user_id,
claimed_registration_id=session.registration_id,
claimed_at=claimed_at,
updated_at=claimed_at,
)
with self.store.transaction():
for account in tenant_accounts:
self.store.save_tenant_account(account)
for membership in memberships:
self.store.save_membership(membership)
for value in profile_values:
self.store.save_profile_value(value)
for binding in application_bindings:
self.store.save_binding(binding)
self.store.save_prepared_account(claimed)
self._record_mutation(
actor,
action="prepared_account.claim",
subject=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="prepared_account.claimed",
aggregate_id=user.user_id,
payload={
"prepared_account_id": prepared.prepared_account_id,
"registration_id": session.registration_id,
"user_id": user.user_id,
"entitlement_count": len(prepared.entitlements),
},
)
for journey in onboarding_journeys:
self._record_mutation(
actor,
action="prepared_account.onboarding.start",
subject=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="prepared_account.onboarding_requested",
aggregate_id=user.user_id,
payload={
"prepared_account_id": prepared.prepared_account_id,
"registration_id": session.registration_id,
"user_id": user.user_id,
"journey": journey,
},
)
return PreparedAccountClaim(
prepared_account=claimed,
registration=session,
user=user,
memberships=memberships,
tenant_accounts=tenant_accounts,
profile_values=profile_values,
application_bindings=application_bindings,
onboarding_journeys=onboarding_journeys,
)
def me(
self,
claims: Mapping[str, Any],
@@ -1749,6 +2096,54 @@ class UserEngineService:
)
return updated
def _close_prepared_account(
self,
actor: Actor,
prepared_account_id: str,
*,
status: PreparedAccountStatus,
action: str,
event_type: str,
correlation_id: str | None,
) -> PreparedAccount:
prepared = self._require_prepared_account(prepared_account_id)
self._ensure_prepared_account_pending(prepared)
tenant_context = self.resolve_tenant_context(actor, prepared.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action=action,
resource_type="user-engine:prepared-account",
resource_id=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
now = utc_now()
updated = replace(
prepared,
status=status,
updated_at=now,
revoked_at=now if status == PreparedAccountStatus.REVOKED else None,
expired_at=now if status == PreparedAccountStatus.EXPIRED else None,
)
with self.store.transaction():
self.store.save_prepared_account(updated)
self._record_mutation(
actor,
action=action,
subject=prepared.prepared_account_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type=event_type,
aggregate_id=prepared.prepared_account_id,
payload={
"prepared_account_id": prepared.prepared_account_id,
"status": updated.status,
},
)
return updated
def _require_registration_session(
self, registration_id: str
) -> RegistrationSession:
@@ -1770,6 +2165,229 @@ class UserEngineService:
raise ValidationError("factor verifier adapter is required")
return self.factor_verifier.normalize(verification)
def _require_prepared_account(self, prepared_account_id: str) -> PreparedAccount:
prepared = self.store.prepared_account(prepared_account_id)
if prepared is None:
raise NotFoundError("prepared account not found")
return prepared
def _ensure_prepared_account_pending(self, prepared: PreparedAccount) -> None:
if prepared.status != PreparedAccountStatus.PENDING:
raise ValidationError("prepared account is not pending")
def _ensure_no_duplicate_prepared_account(
self,
tenant: str,
requirements: tuple[PreparedFactorRequirement, ...],
*,
excluding_prepared_account_id: str | None,
) -> None:
signature = _prepared_factor_signature(requirements)
for prepared in self.store.prepared_accounts_for_tenant(tenant):
if prepared.prepared_account_id == excluding_prepared_account_id:
continue
if prepared.status != PreparedAccountStatus.PENDING:
continue
if _prepared_account_expired(prepared):
continue
if _prepared_factor_signature(prepared.required_factor_matches) == signature:
raise ConflictError("matching prepared account already exists")
def _validate_prepared_factor_requirements(
self, requirements: tuple[PreparedFactorRequirement, ...]
) -> None:
if not requirements:
raise ValidationError("prepared account requires at least one factor match")
for requirement in requirements:
if not requirement.normalized_value.strip():
raise ValidationError(
"prepared account factor match requires normalized value"
)
def _validate_prepared_entitlements(
self, entitlements: tuple[PreparedEntitlement, ...], tenant: str
) -> None:
for entitlement in entitlements:
if entitlement.tenant != tenant:
raise ValidationError("prepared entitlement tenant must match account")
if entitlement.kind == PreparedEntitlementKind.TENANT_ACCOUNT:
continue
if entitlement.kind == PreparedEntitlementKind.MEMBERSHIP:
if not entitlement.scope_type or not entitlement.scope_id:
raise ValidationError("membership entitlement requires scope")
if not entitlement.role:
raise ValidationError("membership entitlement requires role")
elif entitlement.kind == PreparedEntitlementKind.PROFILE_VALUE:
if not entitlement.attribute_key:
raise ValidationError("profile entitlement requires attribute_key")
elif entitlement.kind == PreparedEntitlementKind.APPLICATION_BINDING:
if entitlement.application_binding is None:
raise ValidationError(
"application binding entitlement requires binding"
)
elif entitlement.kind == PreparedEntitlementKind.ONBOARDING_JOURNEY:
if not entitlement.onboarding_journey:
raise ValidationError(
"onboarding journey entitlement requires journey"
)
else:
raise ValidationError(f"unsupported entitlement kind: {entitlement.kind}")
def _resolve_prepared_account_claim(
self,
actor: Actor,
session: RegistrationSession,
factors: tuple[IdentityFactor, ...],
*,
prepared_account_id: str | None,
tenant: str,
correlation_id: str,
) -> PreparedAccount:
candidates = (
(self._require_prepared_account(prepared_account_id),)
if prepared_account_id is not None
else self.store.prepared_accounts_for_tenant(tenant)
)
matches = tuple(
prepared
for prepared in candidates
if prepared.tenant == tenant
and prepared.status == PreparedAccountStatus.PENDING
and not _prepared_account_expired(prepared)
and _prepared_account_matches_factors(prepared, factors)
)
if prepared_account_id is not None and not matches:
self._record_audit_only(
actor,
action="prepared_account.claim",
subject=prepared_account_id,
tenant=tenant,
correlation_id=correlation_id,
summary="prepared account claim denied: factor mismatch or closed",
)
raise ValidationError("prepared account claim requirements are not met")
if not matches:
self._record_audit_only(
actor,
action="prepared_account.claim",
subject=session.registration_id,
tenant=tenant,
correlation_id=correlation_id,
summary="prepared account claim denied: no match",
)
raise NotFoundError("no matching prepared account found")
if len(matches) > 1:
self._record_audit_only(
actor,
action="prepared_account.claim",
subject=session.registration_id,
tenant=tenant,
correlation_id=correlation_id,
summary="prepared account claim denied: ambiguous match",
)
raise ConflictError("multiple prepared accounts match registration")
return matches[0]
def _prepared_entitlement_records(
self,
prepared: PreparedAccount,
user_id: str,
*,
correlation_id: str,
) -> tuple[
tuple[TenantAccount, ...],
tuple[Membership, ...],
tuple[ProfileValue, ...],
tuple[ApplicationBinding, ...],
tuple[str, ...],
]:
tenant_accounts: list[TenantAccount] = []
memberships: list[Membership] = []
profile_values: list[ProfileValue] = []
application_bindings: list[ApplicationBinding] = []
onboarding_journeys: list[str] = []
for entitlement in prepared.entitlements:
if entitlement.kind == PreparedEntitlementKind.TENANT_ACCOUNT:
tenant_accounts.append(
TenantAccount(
user_id=user_id,
tenant=entitlement.tenant,
status=entitlement.tenant_account_status,
)
)
elif entitlement.kind == PreparedEntitlementKind.MEMBERSHIP:
memberships.append(
Membership(
membership_id=new_id("mem"),
user_id=user_id,
tenant=entitlement.tenant,
scope_type=entitlement.scope_type or "tenant",
scope_id=entitlement.scope_id or entitlement.tenant,
kind=entitlement.role or "member",
source_system="prepared-account",
freshness_version=correlation_id,
)
)
elif entitlement.kind == PreparedEntitlementKind.PROFILE_VALUE:
definition = self._require_attribute(entitlement.attribute_key or "")
self._validate_profile_scope(
definition,
entitlement.profile_scope,
entitlement.profile_scope_id,
)
self._validate_value(definition, entitlement.value)
profile_values.append(
ProfileValue(
user_id=user_id,
attribute_key=entitlement.attribute_key or "",
value=entitlement.value,
scope=entitlement.profile_scope,
scope_id=entitlement.profile_scope_id,
source=f"prepared-account:{prepared.prepared_account_id}",
)
)
elif entitlement.kind == PreparedEntitlementKind.APPLICATION_BINDING:
if entitlement.application_binding is not None:
if self.store.application(
entitlement.application_binding.application_id
) is None:
raise ValidationError(
"application binding entitlement requires registered application"
)
application_bindings.append(entitlement.application_binding)
elif entitlement.kind == PreparedEntitlementKind.ONBOARDING_JOURNEY:
if entitlement.onboarding_journey:
onboarding_journeys.append(entitlement.onboarding_journey)
return (
tuple(tenant_accounts),
tuple(memberships),
tuple(profile_values),
tuple(application_bindings),
tuple(onboarding_journeys),
)
def _record_audit_only(
self,
actor: Actor,
*,
action: str,
subject: str,
tenant: str,
correlation_id: str,
summary: str,
) -> None:
self.store.append_audit(
AuditRecord(
audit_id=new_id("aud"),
actor=actor,
action=action,
subject=subject,
tenant=tenant,
correlation_id=correlation_id,
summary=summary,
)
)
def _ensure_actor_session(
self, actor: Actor, correlation_id: str
) -> UserSession:
@@ -2595,6 +3213,43 @@ def _registration_primary_email(
return _optional_claim(actor, "email")
def _prepared_factor_signature(
requirements: Iterable[PreparedFactorRequirement],
) -> tuple[tuple[str, str], ...]:
return tuple(
sorted(
(
requirement.factor_type.value,
requirement.normalized_value.casefold(),
)
for requirement in requirements
)
)
def _prepared_account_expired(prepared: PreparedAccount) -> bool:
return prepared.expires_at is not None and prepared.expires_at <= utc_now()
def _prepared_account_matches_factors(
prepared: PreparedAccount, factors: Iterable[IdentityFactor]
) -> bool:
now = utc_now()
factor_values = {
(factor.factor_type, factor.normalized_value.casefold())
for factor in factors
if factor.expires_at is None or factor.expires_at > now
}
return all(
(
requirement.factor_type,
requirement.normalized_value.casefold(),
)
in factor_values
for requirement in prepared.required_factor_matches
)
def _scope_concept(scope_type: str) -> str:
concepts = {
"team": "Team",

View File

@@ -163,6 +163,7 @@ class _ProtocolOnlyStore:
"identity_factors",
"memberships",
"outbox_events",
"prepared_accounts",
"profile_values",
"registration_sessions",
"tenant_accounts",

View File

@@ -0,0 +1,420 @@
from datetime import timedelta
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
AccountStatus,
CanonEntityReference,
Catalog,
CatalogLifecycle,
FactorVerification,
IdentityFactorType,
PreparedAccount,
PreparedAccountStatus,
PreparedEntitlement,
PreparedEntitlementKind,
PreparedFactorRequirement,
ProfileScope,
utc_now,
)
from user_engine.errors import AuthorizationDenied, ConflictError, ValidationError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
sample_application,
sample_application_binding,
sample_catalog,
)
class PreparedAccountTests(unittest.TestCase):
def test_claim_prepared_account_activates_entitlements(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
prepared = _prepare_demo_account(service, preparer)
registration = _complete_registration(service, applicant)
claim = service.claim_prepared_account(
applicant,
registration.session.registration_id,
correlation_id="corr-claim",
)
self.assertEqual(claim.prepared_account.status, PreparedAccountStatus.CLAIMED)
self.assertEqual(claim.prepared_account.claimed_by_user_id, claim.user.user_id)
self.assertEqual(claim.tenant_accounts[0].status, AccountStatus.ACTIVE)
self.assertEqual(claim.memberships[0].scope_id, "team:demo")
self.assertEqual(claim.memberships[0].kind, "member")
self.assertEqual(claim.profile_values[0].attribute_key, "demo.display_density")
self.assertEqual(claim.profile_values[0].value, "compact")
self.assertEqual(claim.onboarding_journeys, ("welcome-demo",))
self.assertEqual(
store.prepared_account(prepared.prepared_account_id).status,
PreparedAccountStatus.CLAIMED,
)
self.assertIn(
"prepared_account.onboarding_requested",
[event.event_type for event in service.outbox_events()],
)
self.assertNotIn(
"sample.user@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_claim_requires_matching_verified_factor(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
prepared = _prepare_demo_account(
service,
preparer,
email="different@example.test",
)
registration = _complete_registration(service, applicant)
before_outbox = len(service.outbox_events())
with self.assertRaises(ValidationError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim-mismatch",
)
self.assertEqual(
store.prepared_account(prepared.prepared_account_id).status,
PreparedAccountStatus.PENDING,
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(len(service.outbox_events()), before_outbox)
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: factor mismatch or closed",
)
def test_claim_ignores_expired_factor_evidence(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
prepared = _prepare_demo_account(service, preparer)
registration = _complete_registration(
service,
applicant,
factor_expires_at=utc_now() - timedelta(days=1),
)
with self.assertRaises(ValidationError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim-expired-factor",
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: factor mismatch or closed",
)
def test_ambiguous_prepared_account_matches_fail_closed(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
requirement = _email_requirement()
entitlements = (_membership_entitlement(),)
store.save_prepared_account(
PreparedAccount(
tenant="tenant:coulomb",
required_factor_matches=(requirement,),
entitlements=entitlements,
prepared_by_subject="fixture",
)
)
store.save_prepared_account(
PreparedAccount(
tenant="tenant:coulomb",
required_factor_matches=(requirement,),
entitlements=entitlements,
prepared_by_subject="fixture",
)
)
registration = _complete_registration(service, applicant)
with self.assertRaises(ConflictError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
correlation_id="corr-ambiguous",
)
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: ambiguous match",
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
def test_approval_required_entitlement_blocks_claim(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
prepared = service.prepare_account(
preparer,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement(),),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="team",
scope_id="team:ops",
role="admin",
requires_approval=True,
),
),
correlation_id="corr-prepare-privileged",
)
registration = _complete_registration(service, applicant)
with self.assertRaises(AuthorizationDenied):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id="corr-claim-privileged",
)
self.assertEqual(
store.prepared_account(prepared.prepared_account_id).status,
PreparedAccountStatus.PENDING,
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: approval required",
)
def test_revoked_and_expired_prepared_accounts_cannot_be_claimed(self):
service, store = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
applicant = _actor(subject="new-user", email="sample.user@example.test")
_bootstrap_catalog(service, preparer)
revoked = _prepare_demo_account(service, preparer)
service.revoke_prepared_account(
preparer,
revoked.prepared_account_id,
correlation_id="corr-revoke",
)
expired = _prepare_demo_account(service, preparer)
service.expire_prepared_account(
preparer,
expired.prepared_account_id,
correlation_id="corr-expire",
)
registration = _complete_registration(service, applicant)
for prepared in (revoked, expired):
with self.assertRaises(ValidationError):
service.claim_prepared_account(
applicant,
registration.session.registration_id,
prepared_account_id=prepared.prepared_account_id,
correlation_id=f"corr-claim-{prepared.prepared_account_id}",
)
self.assertEqual(store.memberships_for_user(registration.user.user_id), ())
self.assertEqual(
service.audit_records()[-1].summary,
"prepared account claim denied: factor mismatch or closed",
)
def test_duplicate_pending_prepared_accounts_are_rejected(self):
service, _ = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
_bootstrap_catalog(service, preparer)
_prepare_demo_account(service, preparer)
with self.assertRaises(ConflictError):
_prepare_demo_account(service, preparer)
def test_weak_factor_requirements_are_rejected(self):
service, _ = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
with self.assertRaises(ValidationError):
service.prepare_account(
preparer,
tenant="tenant:coulomb",
required_factor_matches=(
PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value=" ",
),
),
entitlements=(_membership_entitlement(),),
correlation_id="corr-weak-factor",
)
def test_revoke_and_list_prepared_accounts(self):
service, _ = _service()
preparer = _actor(subject="tenant-admin", roles=("tenant-admin",))
prepared = _prepare_demo_account(service, preparer)
listed = service.list_prepared_accounts(
preparer,
tenant="tenant:coulomb",
correlation_id="corr-list",
)
revoked = service.revoke_prepared_account(
preparer,
prepared.prepared_account_id,
correlation_id="corr-revoke",
)
self.assertEqual(listed[0].prepared_account_id, prepared.prepared_account_id)
self.assertEqual(revoked.status, PreparedAccountStatus.REVOKED)
def _service():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
return service, store
def _actor(
*,
subject: str = "sample-user",
roles: tuple[str, ...] = ("user",),
email: str = "sample.user@example.test",
):
claims = human_actor_claims(subject=subject, tenant="tenant:coulomb")
claims["roles"] = list(roles)
claims["email"] = email
claims["preferred_username"] = subject
return FixtureIdentityClaimsAdapter().normalize(claims)
def _bootstrap_catalog(service: UserEngineService, actor):
service.register_application(
actor,
sample_application(),
binding=sample_application_binding(),
correlation_id="corr-app",
)
service.publish_catalog(
actor,
Catalog(
catalog_id="demo-prepared-profile",
namespace=sample_catalog().namespace,
version=sample_catalog().version,
owning_application_id=sample_catalog().owning_application_id,
lifecycle=CatalogLifecycle.ACTIVE,
attributes=sample_catalog().attributes,
),
correlation_id="corr-catalog",
)
def _complete_registration(
service: UserEngineService,
actor,
*,
factor_expires_at=None,
):
session = service.start_registration(actor, correlation_id="corr-start")
service.attach_registration_factor(
actor,
session.registration_id,
FactorVerification(
factor_type=IdentityFactorType.EMAIL,
normalized_value="sample.user@example.test",
display_value="sample.user@example.test",
source_system="fixture-email",
evidence_refs=(
CanonEntityReference(
concept="Evidence Source",
identifier="email-proof",
source_system="fixture-email",
),
),
expires_at=factor_expires_at,
),
correlation_id="corr-factor",
)
return service.complete_registration(
actor,
session.registration_id,
correlation_id="corr-complete",
)
def _prepare_demo_account(
service: UserEngineService,
preparer,
*,
email: str = "sample.user@example.test",
):
return service.prepare_account(
preparer,
tenant="tenant:coulomb",
required_factor_matches=(_email_requirement(email=email),),
entitlements=(
PreparedEntitlement(
kind=PreparedEntitlementKind.TENANT_ACCOUNT,
tenant="tenant:coulomb",
tenant_account_status=AccountStatus.ACTIVE,
),
_membership_entitlement(),
PreparedEntitlement(
kind=PreparedEntitlementKind.PROFILE_VALUE,
tenant="tenant:coulomb",
attribute_key="demo.display_density",
value="compact",
profile_scope=ProfileScope.APPLICATION,
profile_scope_id="app.demo",
),
PreparedEntitlement(
kind=PreparedEntitlementKind.ONBOARDING_JOURNEY,
tenant="tenant:coulomb",
onboarding_journey="welcome-demo",
),
),
display_name="Prepared User",
primary_email=email,
correlation_id="corr-prepare",
)
def _email_requirement(
*,
email: str = "sample.user@example.test",
) -> PreparedFactorRequirement:
return PreparedFactorRequirement(
factor_type=IdentityFactorType.EMAIL,
normalized_value=email,
source_system="fixture-email",
)
def _membership_entitlement() -> PreparedEntitlement:
return PreparedEntitlement(
kind=PreparedEntitlementKind.MEMBERSHIP,
tenant="tenant:coulomb",
scope_type="team",
scope_id="team:demo",
role="member",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Prepared Accounts And Entitlement Claims"
domain: netkingdom
repo: user-engine
status: proposed
status: finished
owner: codex
topic_slug: netkingdom
planning_priority: high
@@ -45,7 +45,7 @@ history.
```task
id: USER-WP-0011-T1
status: todo
status: done
priority: high
state_hub_task_id: "11508f77-170b-4b22-bfdc-115a69bfe4db"
```
@@ -56,7 +56,7 @@ audit metadata.
```task
id: USER-WP-0011-T2
status: todo
status: done
priority: high
state_hub_task_id: "86ca36d4-721b-48fe-8c0c-c6a1e6740d2f"
```
@@ -66,7 +66,7 @@ accounts, guarded by the authorization port.
```task
id: USER-WP-0011-T3
status: todo
status: done
priority: high
state_hub_task_id: "fe5a08e8-1101-4cec-b02f-b2eee8928604"
```
@@ -76,7 +76,7 @@ prepared account requirements and produce explicit claim decisions.
```task
id: USER-WP-0011-T4
status: todo
status: done
priority: high
state_hub_task_id: "8aef6d9e-5e76-4e44-bf81-58049b22a25c"
```
@@ -87,7 +87,7 @@ onboarding journey starts.
```task
id: USER-WP-0011-T5
status: todo
status: done
priority: medium
state_hub_task_id: "527519a1-48ed-45fc-a6fc-739986ae6303"
```
@@ -97,7 +97,7 @@ matches, expired packages, privileged roles, and manual approval requirements.
```task
id: USER-WP-0011-T6
status: todo
status: done
priority: medium
state_hub_task_id: "9530c8d6-82af-4635-8af8-aa79c54be94d"
```
@@ -122,3 +122,39 @@ activation, denial, expiry, and revocation.
- Prepared entitlement activation facade.
- Claim matching rules and tests.
- Documentation for account preparation boundaries.
## Implementation Notes
Implemented on 2026-06-15:
- Added `PreparedAccountStatus`, `PreparedEntitlementKind`,
`PreparedFactorRequirement`, `PreparedEntitlement`, and `PreparedAccount`
domain models.
- Added prepared-account persistence to `UserEngineStore` and
`InMemoryUserEngineStore`, including transaction rollback snapshots and
adapter-neutral record counts.
- Added `UserEngineService` prepared-account facade methods:
`prepare_account`, `update_prepared_account`, `list_prepared_accounts`,
`revoke_prepared_account`, `expire_prepared_account`, and
`claim_prepared_account`.
- Added factor-match claim resolution for completed registrations, explicit
claim decisions, duplicate pending package checks, expiry handling,
weak-factor rejection, ambiguous-match rejection, expired-factor rejection,
and approval-required fail-closed behavior.
- Added entitlement activation into tenant accounts, memberships, catalog
validated profile values, application bindings, and onboarding-request
outbox events.
- Added audit/outbox behavior for preparation, update, claim, onboarding
request, expiry, and revocation while keeping normalized factor values out
of event payloads.
- Added `docs/prepared-accounts-and-entitlement-claims.md`, public contract
updates, and scenario tests for successful claim, mismatch, ambiguity,
approval-required denial, list, and revoke behavior.
Verification:
```text
make test
Ran 55 tests in 0.362s
OK
```