feat: implement onboarding journeys

This commit is contained in:
2026-06-15 23:24:59 +02:00
parent 660ce24995
commit 5d7685dc8d
15 changed files with 1605 additions and 17 deletions

View File

@@ -22,6 +22,7 @@ from user_engine.domain import (
FamilyInvitation,
IdentityFactor,
Membership,
OnboardingJourney,
OutboxEvent,
PreparedAccount,
ProfileScope,
@@ -29,6 +30,7 @@ from user_engine.domain import (
RegistrationSession,
TenantAccount,
User,
WelcomeProtocol,
)
SCHEMA_VERSION = "0001_initial"
@@ -62,6 +64,8 @@ class InMemoryUserEngineStore:
active_access_contexts: dict[
tuple[str, str], ActiveAccessContext
] = field(default_factory=dict)
welcome_protocols: dict[str, WelcomeProtocol] = field(default_factory=dict)
onboarding_journeys: dict[str, OnboardingJourney] = field(default_factory=dict)
profile_values: dict[
tuple[str, str, ProfileScope, str | None], ProfileValue
] = field(default_factory=dict)
@@ -234,6 +238,45 @@ class InMemoryUserEngineStore:
if context.tenant == tenant
)
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
self.welcome_protocols[protocol.protocol_id] = protocol
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
return self.welcome_protocols.get(protocol_id)
def welcome_protocols_for_tenant(
self, tenant: str
) -> tuple[WelcomeProtocol, ...]:
return tuple(
protocol
for protocol in self.welcome_protocols.values()
if protocol.tenant == tenant
)
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
self.onboarding_journeys[journey.journey_id] = journey
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
return self.onboarding_journeys.get(journey_id)
def onboarding_journeys_for_user(
self, user_id: str, *, tenant: str | None = None
) -> tuple[OnboardingJourney, ...]:
return tuple(
journey
for journey in self.onboarding_journeys.values()
if journey.user_id == user_id and (tenant is None or journey.tenant == tenant)
)
def onboarding_journeys_for_tenant(
self, tenant: str
) -> tuple[OnboardingJourney, ...]:
return tuple(
journey
for journey in self.onboarding_journeys.values()
if journey.tenant == tenant
)
def save_profile_value(self, value: ProfileValue) -> None:
self.profile_values[
(value.user_id, value.attribute_key, value.scope, value.scope_id)
@@ -296,6 +339,8 @@ class InMemoryUserEngineStore:
"prepared_accounts": len(self.prepared_accounts),
"access_profiles": len(self.access_profiles),
"active_access_contexts": len(self.active_access_contexts),
"welcome_protocols": len(self.welcome_protocols),
"onboarding_journeys": len(self.onboarding_journeys),
"profile_values": len(self.profile_values),
"audit_records": len(self.audit_records),
"pending_outbox_events": len(self.outbox_events),
@@ -317,6 +362,8 @@ class InMemoryUserEngineStore:
"prepared_accounts": copy.deepcopy(self.prepared_accounts),
"access_profiles": copy.deepcopy(self.access_profiles),
"active_access_contexts": copy.deepcopy(self.active_access_contexts),
"welcome_protocols": copy.deepcopy(self.welcome_protocols),
"onboarding_journeys": copy.deepcopy(self.onboarding_journeys),
"profile_values": copy.deepcopy(self.profile_values),
"audit_records": copy.deepcopy(self.audit_records),
"outbox_events": copy.deepcopy(self.outbox_events),
@@ -349,6 +396,10 @@ class InMemoryUserEngineStore:
self.active_access_contexts = snapshot[
"active_access_contexts"
] # type: ignore[assignment]
self.welcome_protocols = snapshot["welcome_protocols"] # type: ignore[assignment]
self.onboarding_journeys = snapshot[
"onboarding_journeys"
] # 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

@@ -32,6 +32,12 @@ from user_engine.domain.models import (
ManagementMode,
Membership,
Mutability,
OnboardingJourney,
OnboardingJourneyStatus,
OnboardingStep,
OnboardingStepStatus,
OnboardingTask,
OnboardingTriggerType,
OutboxEvent,
PrincipalType,
PreparedAccount,
@@ -45,9 +51,12 @@ from user_engine.domain.models import (
RegistrationSession,
RegistrationStatus,
Sensitivity,
SubsystemHandoff,
TenantAccount,
User,
Visibility,
WelcomeProtocol,
WelcomeProtocolStep,
new_id,
utc_now,
)
@@ -84,6 +93,12 @@ __all__ = [
"ManagementMode",
"Membership",
"Mutability",
"OnboardingJourney",
"OnboardingJourneyStatus",
"OnboardingStep",
"OnboardingStepStatus",
"OnboardingTask",
"OnboardingTriggerType",
"OutboxEvent",
"PrincipalType",
"PreparedAccount",
@@ -97,9 +112,12 @@ __all__ = [
"RegistrationSession",
"RegistrationStatus",
"Sensitivity",
"SubsystemHandoff",
"TenantAccount",
"User",
"Visibility",
"WelcomeProtocol",
"WelcomeProtocolStep",
"new_id",
"utc_now",
]

View File

@@ -101,6 +101,32 @@ class AccessScopeType(StrEnum):
GROUP = "group"
class OnboardingTriggerType(StrEnum):
REGISTRATION = "registration"
PREPARED_ACCOUNT = "prepared_account"
INVITATION = "invitation"
ACCESS_PROFILE = "access_profile"
MANUAL = "manual"
class OnboardingJourneyStatus(StrEnum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
COMPLETED = "completed"
SKIPPED = "skipped"
FAILED = "failed"
class OnboardingStepStatus(StrEnum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
COMPLETED = "completed"
SKIPPED = "skipped"
FAILED = "failed"
class ProfileScope(StrEnum):
GLOBAL = "global"
TENANT = "tenant"
@@ -375,6 +401,91 @@ class AccessControlFact:
source_system: str = "user-engine"
@dataclass(frozen=True)
class OnboardingTask:
subsystem: str
task_kind: str
task_id: str = field(default_factory=lambda: new_id("otask"))
external_ref: str | None = None
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
@dataclass(frozen=True)
class SubsystemHandoff:
subsystem: str
handoff_id: str = field(default_factory=lambda: new_id("hnd"))
callback_ref: str | None = None
lifecycle_gap: str | None = None
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
@dataclass(frozen=True)
class WelcomeProtocolStep:
step_key: str
title: str
subsystem: str
task_kind: str = "welcome"
task_ref: str | None = None
callback_ref: str | None = None
support_ref: str | None = None
requires_subsystem_callback: bool = False
@dataclass(frozen=True)
class WelcomeProtocol:
tenant: str
name: str
trigger_type: OnboardingTriggerType
steps: tuple[WelcomeProtocolStep, ...]
protocol_id: str = field(default_factory=lambda: new_id("wpro"))
journey_key: str | None = None
registration_status: RegistrationStatus | None = None
prepared_journey: str | None = None
application_id: str | None = None
realm_id: str | None = None
service_id: str | None = None
role: str | None = None
hat: str | None = None
required_factor_types: tuple[IdentityFactorType, ...] = ()
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class OnboardingStep:
step_key: str
title: str
subsystem: str
step_id: str = field(default_factory=lambda: new_id("ostep"))
status: OnboardingStepStatus = OnboardingStepStatus.PENDING
task: OnboardingTask | None = None
handoff: SubsystemHandoff | None = None
support_ref: str | None = None
lifecycle_gap: str | None = None
updated_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class OnboardingJourney:
tenant: str
user_id: str
protocol_id: str
trigger_type: OnboardingTriggerType
steps: tuple[OnboardingStep, ...]
journey_id: str = field(default_factory=lambda: new_id("ojrn"))
status: OnboardingJourneyStatus = OnboardingJourneyStatus.PENDING
source_id: str | None = None
source_event_type: str | None = None
journey_key: str | None = None
active_step_key: str | None = None
correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
completed_at: datetime | None = None
failed_at: datetime | None = None
skipped_at: datetime | None = None
@dataclass(frozen=True)
class FamilyMemberSpec:
primary_email: str

View File

@@ -28,12 +28,14 @@ from user_engine.domain import (
FamilyInvitation,
IdentityFactor,
Membership,
OnboardingJourney,
OutboxEvent,
PreparedAccount,
ProfileValue,
RegistrationSession,
TenantAccount,
User,
WelcomeProtocol,
)
@@ -185,6 +187,33 @@ class UserEngineStore(Protocol):
) -> tuple[ActiveAccessContext, ...]:
"""Return active access contexts for a tenant."""
def save_welcome_protocol(self, protocol: WelcomeProtocol) -> None:
"""Create or replace a welcome protocol template."""
def welcome_protocol(self, protocol_id: str) -> WelcomeProtocol | None:
"""Return a welcome protocol template by id."""
def welcome_protocols_for_tenant(
self, tenant: str
) -> tuple[WelcomeProtocol, ...]:
"""Return welcome protocol templates for a tenant."""
def save_onboarding_journey(self, journey: OnboardingJourney) -> None:
"""Create or replace an onboarding journey."""
def onboarding_journey(self, journey_id: str) -> OnboardingJourney | None:
"""Return an onboarding journey by id."""
def onboarding_journeys_for_user(
self, user_id: str, *, tenant: str | None = None
) -> tuple[OnboardingJourney, ...]:
"""Return onboarding journeys for a user."""
def onboarding_journeys_for_tenant(
self, tenant: str
) -> tuple[OnboardingJourney, ...]:
"""Return onboarding journeys for a tenant."""
def save_profile_value(self, value: ProfileValue) -> None:
"""Create or replace a profile value."""
@@ -260,6 +289,41 @@ class AccessControlFactExporter(Protocol):
"""Return an adapter-neutral access-control fact manifest."""
class OnboardingNotificationPort(Protocol):
"""Notify a delivery system about onboarding journey state."""
def notify(self, journey: OnboardingJourney) -> Mapping[str, Any]:
"""Return adapter metadata for a notification request."""
class OnboardingTaskPort(Protocol):
"""Create or link external lifecycle tasks for onboarding steps."""
def link_task(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
"""Return task-link metadata for one onboarding step."""
class SupportContentPort(Protocol):
"""Resolve support or help content references for onboarding."""
def content_ref(self, protocol: WelcomeProtocol, step_key: str) -> str | None:
"""Return an adapter-owned content reference for a protocol step."""
class SubsystemWelcomePort(Protocol):
"""Call a protected subsystem welcome callback."""
def start(self, journey: OnboardingJourney, step_key: str) -> Mapping[str, Any]:
"""Return callback metadata for a subsystem welcome step."""
class LifecycleTaskLinkPort(Protocol):
"""Link onboarding journeys to external lifecycle task systems."""
def link(self, journey: OnboardingJourney) -> Mapping[str, Any]:
"""Return lifecycle task references for an onboarding journey."""
class EventOutbox(Protocol):
"""Persist and publish durable domain events."""

View File

@@ -35,6 +35,12 @@ from user_engine.domain import (
InvitationStatus,
Membership,
Mutability,
OnboardingJourney,
OnboardingJourneyStatus,
OnboardingStep,
OnboardingStepStatus,
OnboardingTask,
OnboardingTriggerType,
OutboxEvent,
PreparedAccount,
PreparedAccountStatus,
@@ -47,9 +53,12 @@ from user_engine.domain import (
RegistrationSession,
RegistrationStatus,
Sensitivity,
SubsystemHandoff,
TenantAccount,
User,
Visibility,
WelcomeProtocol,
WelcomeProtocolStep,
new_id,
utc_now,
)
@@ -142,6 +151,7 @@ class IdentityContext:
gaps: tuple[str, ...] = ()
active_access_context: ActiveAccessContext | None = None
access_control_facts: tuple[AccessControlFact, ...] = ()
onboarding_journeys: tuple[OnboardingJourney, ...] = ()
@dataclass(frozen=True)
@@ -167,6 +177,22 @@ class AccessProfileDiagnostics:
issues: tuple[str, ...]
@dataclass(frozen=True)
class OnboardingJourneyStart:
journey: OnboardingJourney
protocol: WelcomeProtocol
@dataclass(frozen=True)
class OnboardingDiagnostics:
tenant: str
protocol_count: int
journey_count: int
statuses: Mapping[str, int]
blocked_steps: tuple[str, ...]
lifecycle_gaps: tuple[str, ...]
@dataclass(frozen=True)
class FamilyMemberInvitation:
user: User
@@ -549,6 +575,16 @@ class UserEngineService:
),
},
)
self._auto_start_onboarding_journeys(
actor,
user.user_id,
self._matching_welcome_protocols_for_registration(completed),
trigger_type=OnboardingTriggerType.REGISTRATION,
source_id=session.registration_id,
source_event_type="registration.completed",
correlation_id=correlation_id,
decision_id=decision.decision_id,
)
identity_context = self.identity_context(
actor,
user_id=user.user_id,
@@ -979,6 +1015,16 @@ class UserEngineService:
"journey": journey,
},
)
self._auto_start_onboarding_journeys(
actor,
user.user_id,
self._matching_welcome_protocols_for_prepared_account(claimed),
trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT,
source_id=prepared.prepared_account_id,
source_event_type="prepared_account.claimed",
correlation_id=correlation_id,
decision_id=decision.decision_id,
)
return PreparedAccountClaim(
prepared_account=claimed,
registration=session,
@@ -1254,6 +1300,350 @@ class UserEngineService:
issues=tuple(issues),
)
def register_welcome_protocol(
self,
actor: Actor,
protocol: WelcomeProtocol,
*,
correlation_id: str | None = None,
) -> WelcomeProtocol:
tenant_context = self.resolve_tenant_context(actor, protocol.tenant)
self._validate_welcome_protocol(protocol, tenant_context.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="welcome_protocol.write",
resource_type="user-engine:welcome-protocol",
resource_id=protocol.protocol_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
context={
"trigger_type": protocol.trigger_type.value,
"journey_key": protocol.journey_key,
"step_count": len(protocol.steps),
},
)
updated = replace(protocol, tenant=tenant_context.tenant, updated_at=utc_now())
with self.store.transaction():
self.store.save_welcome_protocol(updated)
self._record_mutation(
actor,
action="welcome_protocol.write",
subject=updated.protocol_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="welcome_protocol.registered",
aggregate_id=updated.protocol_id,
payload={
"protocol_id": updated.protocol_id,
"trigger_type": updated.trigger_type,
"journey_key": updated.journey_key,
"step_count": len(updated.steps),
},
)
return updated
def list_welcome_protocols(
self,
actor: Actor,
*,
tenant: str,
correlation_id: str | None = None,
) -> tuple[WelcomeProtocol, ...]:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="welcome_protocol.read",
resource_type="user-engine:welcome-protocol",
resource_id=tenant_context.tenant,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
return self.store.welcome_protocols_for_tenant(tenant_context.tenant)
def start_onboarding_journey(
self,
actor: Actor,
user_id: str,
protocol_id: str,
*,
trigger_type: OnboardingTriggerType = OnboardingTriggerType.MANUAL,
source_id: str | None = None,
source_event_type: str | None = None,
correlation_id: str | None = None,
) -> OnboardingJourneyStart:
protocol = self._require_welcome_protocol(protocol_id)
tenant_context = self.resolve_tenant_context(actor, protocol.tenant)
user = self._require_user(user_id)
if self._onboarding_journey_exists(user.user_id, protocol.protocol_id, source_id):
raise ConflictError("onboarding journey already exists for source")
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="onboarding_journey.start",
resource_type="user-engine:onboarding-journey",
resource_id=protocol.protocol_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=user.user_id,
context={
"protocol_id": protocol.protocol_id,
"trigger_type": trigger_type.value,
"source_event_type": source_event_type,
},
)
journey = self._build_onboarding_journey(
protocol,
user.user_id,
trigger_type=trigger_type,
source_id=source_id,
source_event_type=source_event_type,
correlation_id=correlation_id,
)
with self.store.transaction():
self.store.save_onboarding_journey(journey)
self._record_onboarding_journey_started(
actor,
journey,
decision_id=decision.decision_id,
correlation_id=correlation_id,
)
return OnboardingJourneyStart(journey=journey, protocol=protocol)
def start_onboarding_for_registration(
self,
actor: Actor,
registration_id: str,
*,
protocol_id: str | None = None,
correlation_id: str | None = None,
) -> tuple[OnboardingJourneyStart, ...]:
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 onboarding")
protocols = (
(self._require_welcome_protocol(protocol_id),)
if protocol_id is not None
else self._matching_welcome_protocols_for_registration(session)
)
starts = []
for protocol in protocols:
starts.append(
self.start_onboarding_journey(
actor,
session.user_id,
protocol.protocol_id,
trigger_type=OnboardingTriggerType.REGISTRATION,
source_id=session.registration_id,
source_event_type="registration.completed",
correlation_id=correlation_id,
)
)
return tuple(starts)
def start_onboarding_for_prepared_account(
self,
actor: Actor,
prepared_account_id: str,
*,
protocol_id: str | None = None,
correlation_id: str | None = None,
) -> tuple[OnboardingJourneyStart, ...]:
prepared = self._require_prepared_account(prepared_account_id)
if (
prepared.status != PreparedAccountStatus.CLAIMED
or prepared.claimed_by_user_id is None
):
raise ValidationError("prepared account must be claimed before onboarding")
protocols = (
(self._require_welcome_protocol(protocol_id),)
if protocol_id is not None
else self._matching_welcome_protocols_for_prepared_account(prepared)
)
starts = []
for protocol in protocols:
starts.append(
self.start_onboarding_journey(
actor,
prepared.claimed_by_user_id,
protocol.protocol_id,
trigger_type=OnboardingTriggerType.PREPARED_ACCOUNT,
source_id=prepared.prepared_account_id,
source_event_type="prepared_account.claimed",
correlation_id=correlation_id,
)
)
return tuple(starts)
def progress_onboarding_step(
self,
actor: Actor,
journey_id: str,
step_key: str,
*,
correlation_id: str | None = None,
) -> OnboardingJourney:
return self._transition_onboarding_step(
actor,
journey_id,
step_key,
status=OnboardingStepStatus.IN_PROGRESS,
action="onboarding_step.progress",
event_type="onboarding_step.progressed",
correlation_id=correlation_id,
)
def complete_onboarding_step(
self,
actor: Actor,
journey_id: str,
step_key: str,
*,
correlation_id: str | None = None,
) -> OnboardingJourney:
return self._transition_onboarding_step(
actor,
journey_id,
step_key,
status=OnboardingStepStatus.COMPLETED,
action="onboarding_step.complete",
event_type="onboarding_step.completed",
correlation_id=correlation_id,
)
def skip_onboarding_step(
self,
actor: Actor,
journey_id: str,
step_key: str,
*,
correlation_id: str | None = None,
) -> OnboardingJourney:
return self._transition_onboarding_step(
actor,
journey_id,
step_key,
status=OnboardingStepStatus.SKIPPED,
action="onboarding_step.skip",
event_type="onboarding_step.skipped",
correlation_id=correlation_id,
)
def fail_onboarding_step(
self,
actor: Actor,
journey_id: str,
step_key: str,
*,
lifecycle_gap: str | None = None,
correlation_id: str | None = None,
) -> OnboardingJourney:
return self._transition_onboarding_step(
actor,
journey_id,
step_key,
status=OnboardingStepStatus.FAILED,
action="onboarding_step.fail",
event_type="onboarding_step.failed",
lifecycle_gap=lifecycle_gap,
correlation_id=correlation_id,
)
def resume_onboarding_journey(
self,
actor: Actor,
journey_id: str,
*,
callback_refs: Mapping[str, str] | None = None,
correlation_id: str | None = None,
) -> OnboardingJourney:
journey = self._require_onboarding_journey(journey_id)
tenant_context = self.resolve_tenant_context(actor, journey.tenant)
if journey.status not in (
OnboardingJourneyStatus.BLOCKED,
OnboardingJourneyStatus.FAILED,
OnboardingJourneyStatus.PENDING,
):
raise ValidationError("only pending, blocked, or failed journeys can resume")
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="onboarding_journey.resume",
resource_type="user-engine:onboarding-journey",
resource_id=journey.journey_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=journey.user_id,
)
updated_steps = _resume_onboarding_steps(journey.steps, callback_refs or {})
updated = replace(
journey,
steps=updated_steps,
status=_journey_status_from_steps(updated_steps),
active_step_key=_active_onboarding_step_key(updated_steps),
updated_at=utc_now(),
failed_at=None,
)
with self.store.transaction():
self.store.save_onboarding_journey(updated)
self._record_mutation(
actor,
action="onboarding_journey.resume",
subject=updated.journey_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="onboarding_journey.resumed",
aggregate_id=updated.user_id,
payload={
"journey_id": updated.journey_id,
"user_id": updated.user_id,
"status": updated.status,
"active_step_key": updated.active_step_key,
},
)
return updated
def onboarding_diagnostics(
self,
actor: Actor,
*,
tenant: str,
correlation_id: str | None = None,
) -> OnboardingDiagnostics:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="onboarding.diagnostics.read",
resource_type="user-engine:onboarding",
resource_id=tenant_context.tenant,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
protocols = self.store.welcome_protocols_for_tenant(tenant_context.tenant)
journeys = self.store.onboarding_journeys_for_tenant(tenant_context.tenant)
statuses: dict[str, int] = {}
blocked_steps = []
lifecycle_gaps = []
for journey in journeys:
statuses[journey.status.value] = statuses.get(journey.status.value, 0) + 1
for step in journey.steps:
if step.status == OnboardingStepStatus.BLOCKED:
blocked_steps.append(f"{journey.journey_id}:{step.step_key}")
if step.lifecycle_gap:
lifecycle_gaps.append(step.lifecycle_gap)
return OnboardingDiagnostics(
tenant=tenant_context.tenant,
protocol_count=len(protocols),
journey_count=len(journeys),
statuses=statuses,
blocked_steps=tuple(blocked_steps),
lifecycle_gaps=tuple(sorted(set(lifecycle_gaps))),
)
def me(
self,
claims: Mapping[str, Any],
@@ -1803,6 +2193,9 @@ class UserEngineService:
memberships=memberships,
active_context=active_access_context,
)
onboarding_journeys = self.store.onboarding_journeys_for_user(
user.user_id, tenant=tenant_context.tenant
)
entity_refs = self._identity_entity_refs(
actor,
user,
@@ -1844,6 +2237,7 @@ class UserEngineService:
gaps=gaps,
active_access_context=active_access_context,
access_control_facts=access_control_facts,
onboarding_journeys=onboarding_journeys,
)
def onboard_family_dataspace(
@@ -2702,6 +3096,241 @@ class UserEngineService:
)
)
def _require_welcome_protocol(self, protocol_id: str) -> WelcomeProtocol:
protocol = self.store.welcome_protocol(protocol_id)
if protocol is None:
raise NotFoundError("welcome protocol not found")
return protocol
def _require_onboarding_journey(self, journey_id: str) -> OnboardingJourney:
journey = self.store.onboarding_journey(journey_id)
if journey is None:
raise NotFoundError("onboarding journey not found")
return journey
def _validate_welcome_protocol(
self, protocol: WelcomeProtocol, tenant: str
) -> None:
if protocol.tenant != tenant:
raise ValidationError("welcome protocol tenant must match context")
if not protocol.name.strip():
raise ValidationError("welcome protocol name is required")
if not protocol.steps:
raise ValidationError("welcome protocol requires at least one step")
step_keys = [step.step_key for step in protocol.steps]
if len(step_keys) != len(set(step_keys)):
raise ValidationError("welcome protocol step keys must be unique")
for step in protocol.steps:
if not step.step_key.strip() or not step.title.strip():
raise ValidationError("welcome protocol steps require key and title")
if not step.subsystem.strip():
raise ValidationError("welcome protocol steps require subsystem")
def _build_onboarding_journey(
self,
protocol: WelcomeProtocol,
user_id: str,
*,
trigger_type: OnboardingTriggerType,
source_id: str | None,
source_event_type: str | None,
correlation_id: str,
) -> OnboardingJourney:
if trigger_type != OnboardingTriggerType.MANUAL and (
protocol.trigger_type != trigger_type
):
raise ValidationError("welcome protocol trigger does not match journey")
steps = tuple(_onboarding_step_from_template(step) for step in protocol.steps)
steps = _activate_initial_onboarding_step(steps)
status = _journey_status_from_steps(steps)
return OnboardingJourney(
tenant=protocol.tenant,
user_id=user_id,
protocol_id=protocol.protocol_id,
trigger_type=trigger_type,
steps=steps,
status=status,
source_id=source_id,
source_event_type=source_event_type,
journey_key=protocol.journey_key or protocol.prepared_journey,
active_step_key=_active_onboarding_step_key(steps),
correlation_id=correlation_id,
)
def _auto_start_onboarding_journeys(
self,
actor: Actor,
user_id: str,
protocols: tuple[WelcomeProtocol, ...],
*,
trigger_type: OnboardingTriggerType,
source_id: str,
source_event_type: str,
correlation_id: str,
decision_id: str | None,
) -> tuple[OnboardingJourney, ...]:
journeys = []
for protocol in protocols:
if self._onboarding_journey_exists(
user_id,
protocol.protocol_id,
source_id,
):
continue
journey = self._build_onboarding_journey(
protocol,
user_id,
trigger_type=trigger_type,
source_id=source_id,
source_event_type=source_event_type,
correlation_id=correlation_id,
)
self.store.save_onboarding_journey(journey)
self._record_onboarding_journey_started(
actor,
journey,
decision_id=decision_id,
correlation_id=correlation_id,
)
journeys.append(journey)
return tuple(journeys)
def _record_onboarding_journey_started(
self,
actor: Actor,
journey: OnboardingJourney,
*,
decision_id: str | None,
correlation_id: str,
) -> None:
self._record_mutation(
actor,
action="onboarding_journey.start",
subject=journey.journey_id,
tenant=journey.tenant,
correlation_id=correlation_id,
decision_id=decision_id,
event_type="onboarding_journey.started",
aggregate_id=journey.user_id,
payload={
"journey_id": journey.journey_id,
"user_id": journey.user_id,
"protocol_id": journey.protocol_id,
"trigger_type": journey.trigger_type,
"status": journey.status,
"source_id": journey.source_id,
"source_event_type": journey.source_event_type,
"active_step_key": journey.active_step_key,
"lifecycle_gaps": tuple(
step.lifecycle_gap for step in journey.steps if step.lifecycle_gap
),
},
)
def _matching_welcome_protocols_for_registration(
self, session: RegistrationSession
) -> tuple[WelcomeProtocol, ...]:
factors = self.store.factors_for_registration(session.registration_id)
factor_types = {factor.factor_type for factor in factors}
return tuple(
protocol
for protocol in self.store.welcome_protocols_for_tenant(session.tenant)
if protocol.trigger_type == OnboardingTriggerType.REGISTRATION
and (
protocol.registration_status is None
or protocol.registration_status == session.status
)
and set(protocol.required_factor_types).issubset(factor_types)
)
def _matching_welcome_protocols_for_prepared_account(
self, prepared: PreparedAccount
) -> tuple[WelcomeProtocol, ...]:
prepared_journeys = {
entitlement.onboarding_journey
for entitlement in prepared.entitlements
if entitlement.onboarding_journey
}
return tuple(
protocol
for protocol in self.store.welcome_protocols_for_tenant(prepared.tenant)
if protocol.trigger_type == OnboardingTriggerType.PREPARED_ACCOUNT
and (
not protocol.prepared_journey
or protocol.prepared_journey in prepared_journeys
)
)
def _onboarding_journey_exists(
self,
user_id: str,
protocol_id: str,
source_id: str | None,
) -> bool:
return any(
journey.protocol_id == protocol_id and journey.source_id == source_id
for journey in self.store.onboarding_journeys_for_user(user_id)
)
def _transition_onboarding_step(
self,
actor: Actor,
journey_id: str,
step_key: str,
*,
status: OnboardingStepStatus,
action: str,
event_type: str,
lifecycle_gap: str | None = None,
correlation_id: str | None,
) -> OnboardingJourney:
journey = self._require_onboarding_journey(journey_id)
tenant_context = self.resolve_tenant_context(actor, journey.tenant)
if journey.status in (
OnboardingJourneyStatus.COMPLETED,
OnboardingJourneyStatus.SKIPPED,
):
raise ValidationError("completed onboarding journeys cannot change")
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action=action,
resource_type="user-engine:onboarding-step",
resource_id=f"{journey.journey_id}:{step_key}",
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=journey.user_id,
)
updated_steps = _replace_onboarding_step_status(
journey.steps,
step_key,
status=status,
lifecycle_gap=lifecycle_gap,
)
updated = _replace_onboarding_journey_steps(journey, updated_steps)
with self.store.transaction():
self.store.save_onboarding_journey(updated)
self._record_mutation(
actor,
action=action,
subject=updated.journey_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type=event_type,
aggregate_id=updated.user_id,
payload={
"journey_id": updated.journey_id,
"user_id": updated.user_id,
"step_key": step_key,
"step_status": status,
"journey_status": updated.status,
"active_step_key": updated.active_step_key,
"lifecycle_gap": lifecycle_gap,
},
)
return updated
def _require_access_profile(self, access_profile_id: str) -> AccessProfile:
profile = self.store.access_profile(access_profile_id)
if profile is None:
@@ -3869,6 +4498,201 @@ def _active_group_ids(
return tuple(sorted(group_ids))
def _onboarding_step_from_template(template: WelcomeProtocolStep) -> OnboardingStep:
lifecycle_gap = (
f"subsystem-callback-missing:{template.subsystem}:{template.step_key}"
if template.requires_subsystem_callback and not template.callback_ref
else None
)
initial_status = (
OnboardingStepStatus.BLOCKED
if lifecycle_gap is not None
else OnboardingStepStatus.PENDING
)
task = OnboardingTask(
subsystem=template.subsystem,
task_kind=template.task_kind,
external_ref=template.task_ref,
status=initial_status,
)
handoff = SubsystemHandoff(
subsystem=template.subsystem,
callback_ref=template.callback_ref,
lifecycle_gap=lifecycle_gap,
status=initial_status,
)
return OnboardingStep(
step_key=template.step_key,
title=template.title,
subsystem=template.subsystem,
status=initial_status,
task=task,
handoff=handoff,
support_ref=template.support_ref,
lifecycle_gap=lifecycle_gap,
)
def _activate_initial_onboarding_step(
steps: tuple[OnboardingStep, ...]
) -> tuple[OnboardingStep, ...]:
if any(step.status == OnboardingStepStatus.BLOCKED for step in steps):
return steps
for index, step in enumerate(steps):
if step.status == OnboardingStepStatus.PENDING:
return (
*steps[:index],
_onboarding_step_with_status(step, OnboardingStepStatus.IN_PROGRESS),
*steps[index + 1 :],
)
return steps
def _replace_onboarding_step_status(
steps: tuple[OnboardingStep, ...],
step_key: str,
*,
status: OnboardingStepStatus,
lifecycle_gap: str | None,
) -> tuple[OnboardingStep, ...]:
updated = []
found = False
for step in steps:
if step.step_key != step_key:
updated.append(step)
continue
found = True
updated.append(
_onboarding_step_with_status(
step,
status,
lifecycle_gap=lifecycle_gap,
)
)
if not found:
raise NotFoundError("onboarding step not found")
return _activate_next_onboarding_step(tuple(updated))
def _activate_next_onboarding_step(
steps: tuple[OnboardingStep, ...]
) -> tuple[OnboardingStep, ...]:
if any(
step.status
in (OnboardingStepStatus.IN_PROGRESS, OnboardingStepStatus.BLOCKED)
for step in steps
):
return steps
for index, step in enumerate(steps):
if step.status == OnboardingStepStatus.PENDING:
return (
*steps[:index],
_onboarding_step_with_status(step, OnboardingStepStatus.IN_PROGRESS),
*steps[index + 1 :],
)
return steps
def _resume_onboarding_steps(
steps: tuple[OnboardingStep, ...], callback_refs: Mapping[str, str]
) -> tuple[OnboardingStep, ...]:
updated = []
for step in steps:
if step.status not in (
OnboardingStepStatus.BLOCKED,
OnboardingStepStatus.FAILED,
):
updated.append(step)
continue
callback_ref = callback_refs.get(step.step_key)
handoff = step.handoff
if handoff is not None and callback_ref is not None:
handoff = replace(
handoff,
callback_ref=callback_ref,
lifecycle_gap=None,
status=OnboardingStepStatus.IN_PROGRESS,
)
updated.append(
_onboarding_step_with_status(
step,
OnboardingStepStatus.IN_PROGRESS,
lifecycle_gap=None,
handoff=handoff,
)
)
return tuple(updated)
def _onboarding_step_with_status(
step: OnboardingStep,
status: OnboardingStepStatus,
*,
lifecycle_gap: str | None = None,
handoff: SubsystemHandoff | None = None,
) -> OnboardingStep:
task = replace(step.task, status=status) if step.task is not None else None
resolved_handoff = handoff if handoff is not None else step.handoff
if resolved_handoff is not None:
resolved_handoff = replace(
resolved_handoff,
status=status,
lifecycle_gap=lifecycle_gap,
)
return replace(
step,
status=status,
task=task,
handoff=resolved_handoff,
lifecycle_gap=lifecycle_gap,
updated_at=utc_now(),
)
def _replace_onboarding_journey_steps(
journey: OnboardingJourney, steps: tuple[OnboardingStep, ...]
) -> OnboardingJourney:
now = utc_now()
status = _journey_status_from_steps(steps)
return replace(
journey,
steps=steps,
status=status,
active_step_key=_active_onboarding_step_key(steps),
updated_at=now,
completed_at=now if status == OnboardingJourneyStatus.COMPLETED else None,
failed_at=now if status == OnboardingJourneyStatus.FAILED else journey.failed_at,
skipped_at=now if status == OnboardingJourneyStatus.SKIPPED else None,
)
def _journey_status_from_steps(
steps: tuple[OnboardingStep, ...]
) -> OnboardingJourneyStatus:
if any(step.status == OnboardingStepStatus.FAILED for step in steps):
return OnboardingJourneyStatus.FAILED
if any(step.status == OnboardingStepStatus.BLOCKED for step in steps):
return OnboardingJourneyStatus.BLOCKED
if all(step.status == OnboardingStepStatus.SKIPPED for step in steps):
return OnboardingJourneyStatus.SKIPPED
if all(
step.status in (OnboardingStepStatus.COMPLETED, OnboardingStepStatus.SKIPPED)
for step in steps
):
return OnboardingJourneyStatus.COMPLETED
if any(step.status == OnboardingStepStatus.IN_PROGRESS for step in steps):
return OnboardingJourneyStatus.IN_PROGRESS
return OnboardingJourneyStatus.PENDING
def _active_onboarding_step_key(steps: tuple[OnboardingStep, ...]) -> str | None:
for status in (OnboardingStepStatus.IN_PROGRESS, OnboardingStepStatus.BLOCKED):
for step in steps:
if step.status == status:
return step.step_key
return None
def _scope_concept(scope_type: str) -> str:
concepts = {
"team": "Team",