Problem Statement
OpenSpine's owner identity is fragmented across three AppState scalars (owner_user_id: i64, owner_principal_id: Ulid, owner_identity_id: Ulid) and four TaskGrant.user value shapes (stringified principal_id, stringified user_id, literal "owner", literal "kernel"). This fragmentation makes identity-aware audit queries difficult, sources ApprovalRecord.approved_by from static config (not verified surface), and leaves no typed actor dimension on AuditEvent.
Consequences:
- No single source of truth for owner identity
- ApprovalRecord.approved_by is raw config, not verified principal (bug: doesn't record actual approver)
- AuditEvent has no actor link, breaking identity-aware responsibility queries
- Grant MAC-covered user field accepts non-Ulid shapes (type-unsafe)
- Future tenancy/multi-principal models will reopen this spec if not designed for extensibility now
Solution
Introduce a typed PrincipalId(Ulid) newtype and single OwnerPrincipal aggregate to consolidate identity. Retype TaskGrant.user (and RootAuthority.user, SelectionToken.actor) to PrincipalId. Source ApprovalRecord.approved_by from the verified OwnerSurfaceRef::principal_id() instead of config. Add an optional audit actor dimension to AuditEvent. All changes preserve existing grant MACs and invariants (D-006, D-007, AD-146, AD-148).
Key design choices:
- PrincipalId(Ulid) newtype: Serializes identically to Ulid.to_string(), preserving grant MACs for existing sealed grants holding a stringified Ulid. Removes non-Ulid shapes by construction.
- OwnerPrincipal aggregate: Bundles principal_id, identity_id, and Telegram binding (private field, accessible only via channel-binding accessor). Minted once at bootstrap. Replaces three scalars with one source of truth. Carries zero authority fields (D-006).
- Verified surface sourcing: ApprovalRecord.approved_by now set from OwnerSurfaceRef::principal_id() at approval time (pipeline/approval.rs, pipeline/plan_approval.rs), not static config. Historical rows not backfilled (immutability).
- Optional audit actor: AuditEvent gains
actor: Option<PrincipalId>, populated from verified surface on owner-authored events (approval, review, escalation), None for system/worker events.
- MAC preservation: RootAuthority::canonical_bytes keeps "user" as mandatory string with identical bytes; PrincipalId serde form matches Ulid.to_string(), so existing MACs remain valid.
User Stories
Auditor: Trace approval lineage
As an auditor reviewing compliance, I want to query which principal approved each action, with confidence it was verified at approval time (not a static config value).
Acceptance: ApprovalRecord.approved_by is a PrincipalId sourced from verified OwnerSurfaceRef. Historical records (pre-change) are immutable; audit queries join approval + grant to recover context.
Bell: Integrate principal identity into per-tenant instances
As Bell (instance-per-tenant product) integrating with OpenSpine, I want owner identity typed so I can later extend (tenant, principal, contact) without reopening the spec.
Acceptance: Design is additive. PrincipalId is channel-neutral; OwnerPrincipal carries no multi-tenant semantics. Future tenancy models can extend without requiring spec changes.
Lyra runtime: Audit identity dimension
As the runtime's audit system, I want to log the principal that authored each owner action (approval, escalation), enabling identity-aware compliance queries.
Acceptance: AuditEvent carries optional typed actor dimension. Queries can filter/aggregate by actor to answer "which actions did the owner approve this week?".
Implementation Decisions
D-001: TaskGrant.user representation
Decision: Retype TaskGrant.user from String to PrincipalId(Ulid) newtype.
Why: Preserves MAC bytes (PrincipalId serde = Ulid.to_string()); removes non-Ulid shapes by construction; kills ~35 construction sites setting user = format!("{}", some_value) or string literals.
Affected: TaskGrant.user, RootAuthority.user (grant_chain.rs), SelectionToken.actor, ~35 mint sites (compose.rs, approval callbacks, worker commissioning).
D-002: Owner-identity aggregate
Decision: Single OwnerPrincipal aggregate (principal_id: PrincipalId, identity_id: Ulid, telegram_binding: i64 via accessor).
Why: One source of truth replacing three scalars. Telegram i64 hidden behind channel-binding accessor (no generic kernel code reads raw i64). Preserves D-006 (identity carries no authority).
Constructor: bootstrap_owner_principal(telegram_id: i64, principal_id: Ulid, identity_id: Ulid) called once at kernel startup. Read-only after bootstrap.
D-003: Audit actor dimension
Decision: Optional typed PrincipalId actor on AuditEvent (not full OwnerSurfaceRef).
Why: Channel-neutral; channel-specific detail stays in grant/review rows. Minimal audit footprint (most events have actor=None).
Population: Some(principal_id) on owner-authored (approval, review, escalation), None on system/worker events.
D-004: ApprovalRecord.approved_by migration
Decision: Retype to PrincipalId; source from verified OwnerSurfaceRef::principal_id() at two callback sites; no backfill of historical rows.
Why: Fixes bug where approvals record config value, not verified principal. Immutability preserves digest-bound evidence integrity. Forward-looking only.
Callbacks: pipeline/approval.rs::handle_draft_approval_callback, pipeline/plan_approval.rs::handle_plan_approval_callback.
D-005: MAC byte preservation
Decision: RootAuthority::canonical_bytes generates identical bytes before/after PrincipalId retype.
Why: Existing sealed grants (holding stringified Ulid in user field) pass MAC verification without modification. New grants set owner PrincipalId correctly; literals "owner"/"kernel" are type-invalid.
Reseal path (if legacy literals exist in store): Detect via query, backfill to canonical owner_principal_id, re-mint grant as valid descendant in chain.
Testing Decisions
Authority-sensitive verification (per conventions.md)
Because this change affects identity resolution, task grant composition, approval recording, and audit:
-
MAC invariant test: Before/after PrincipalId serialization produces identical bytes to Ulid.to_string(); a grant sealed with user="01ARZ3..." verifies identically after TaskGrant.user is retyped to PrincipalId.
-
D-006 invariant (identity ≠ authority): OwnerPrincipal has zero authority fields; PrincipalId type has no authority-granting constructors; composed grant with owner PrincipalId set correctly passes gate() with identical authority.
-
D-007 invariant (grant is only authority): Revoking the grant revokes all authority, regardless of principal_id value. No new authority sources introduced.
-
AD-146 invariant (single owner principal v1): Exactly one OwnerPrincipal minted at bootstrap with principal_id set; all grants in v1 carry the same owner PrincipalId; no code path creates a second principal (v1 is single-owner; tenancy out-of-scope).
-
AD-148 invariant (kernel-owned MAC-covered fields): Mutating principal_id in a sealed grant invalidates its MAC; thread_id (if populated) also MAC-covered.
-
Approval principal sourcing: ApprovalRecord.approved_by set from OwnerSurfaceRef::principal_id(), not state.owner_user_id; approval matching works with PrincipalId comparisons; gate receives PrincipalId.
-
E2E regression: Minimal task with typed identity sent through gate with approval; gate accepts grant, approvals match on PrincipalId, audit log records actor.
Out of Scope
- Tenancy implementation: Bell v1 is instance-per-tenant; tenancy design follows separate decision. This design is additive and leaves room.
- OAuth or multi-principal model: v1 has one bootstrapped owner. Future tenancy/OAuth decisions are orthogonal.
- Channel-specific detail: Surface-specific payload stays in OwnerSurfaceRef accessors, not exposed generically.
- WhatsApp connector: Currently out of scope; AD-148 thread binding waits for channel-agnostic enablement.
- Relationship-keyed disclosure: AD-146 design, separate change.
- Standing rule authority: AD-010 design, separate change.
Further Notes
Invariants preserved:
- ✅ D-006: Identity carries no authority (OwnerPrincipal has zero authority fields)
- ✅ D-007: Task grant is only authority (no new authority sources)
- ✅ AD-146: Single owner principal v1, additive for future tenancy
- ✅ AD-148: Kernel-owned MAC-covered fields (PrincipalId on user, thread_id dormant)
Downstream cutover (post-implementation):
- WorkerIdentity.owner: String → PrincipalId
- OperationAuthorization.owner_principal_id: (already typed, no change)
- SQL queries reading json_extract(grant, '$.user'): String → PrincipalId comparisons
Migration paths:
- Grant MACs: No change; existing sealed grants verify without reseal (PrincipalId serde matches Ulid.to_string())
- Legacy literals (if persisted): Query for non-Ulid user values; backfill to canonical owner_principal_id; re-mint as valid descendants
- ApprovalRecord historical rows: No backfill (immutable, digest-bound evidence); forward-looking queries join with grant
Lane context: Wayfinder map #182; design ticket #188 (closed).
🤖 Typed owner identity spec (inline, self-contained)
Problem Statement
OpenSpine's owner identity is fragmented across three AppState scalars (owner_user_id: i64, owner_principal_id: Ulid, owner_identity_id: Ulid) and four TaskGrant.user value shapes (stringified principal_id, stringified user_id, literal "owner", literal "kernel"). This fragmentation makes identity-aware audit queries difficult, sources ApprovalRecord.approved_by from static config (not verified surface), and leaves no typed actor dimension on AuditEvent.
Consequences:
Solution
Introduce a typed
PrincipalId(Ulid)newtype and singleOwnerPrincipalaggregate to consolidate identity. Retype TaskGrant.user (and RootAuthority.user, SelectionToken.actor) to PrincipalId. Source ApprovalRecord.approved_by from the verified OwnerSurfaceRef::principal_id() instead of config. Add an optional audit actor dimension to AuditEvent. All changes preserve existing grant MACs and invariants (D-006, D-007, AD-146, AD-148).Key design choices:
actor: Option<PrincipalId>, populated from verified surface on owner-authored events (approval, review, escalation), None for system/worker events.User Stories
Auditor: Trace approval lineage
Acceptance: ApprovalRecord.approved_by is a PrincipalId sourced from verified OwnerSurfaceRef. Historical records (pre-change) are immutable; audit queries join approval + grant to recover context.
Bell: Integrate principal identity into per-tenant instances
Acceptance: Design is additive. PrincipalId is channel-neutral; OwnerPrincipal carries no multi-tenant semantics. Future tenancy models can extend without requiring spec changes.
Lyra runtime: Audit identity dimension
Acceptance: AuditEvent carries optional typed actor dimension. Queries can filter/aggregate by actor to answer "which actions did the owner approve this week?".
Implementation Decisions
D-001: TaskGrant.user representation
Decision: Retype TaskGrant.user from String to PrincipalId(Ulid) newtype.
Why: Preserves MAC bytes (PrincipalId serde = Ulid.to_string()); removes non-Ulid shapes by construction; kills ~35 construction sites setting user = format!("{}", some_value) or string literals.
Affected: TaskGrant.user, RootAuthority.user (grant_chain.rs), SelectionToken.actor, ~35 mint sites (compose.rs, approval callbacks, worker commissioning).
D-002: Owner-identity aggregate
Decision: Single OwnerPrincipal aggregate (principal_id: PrincipalId, identity_id: Ulid, telegram_binding: i64 via accessor).
Why: One source of truth replacing three scalars. Telegram i64 hidden behind channel-binding accessor (no generic kernel code reads raw i64). Preserves D-006 (identity carries no authority).
Constructor: bootstrap_owner_principal(telegram_id: i64, principal_id: Ulid, identity_id: Ulid) called once at kernel startup. Read-only after bootstrap.
D-003: Audit actor dimension
Decision: Optional typed PrincipalId actor on AuditEvent (not full OwnerSurfaceRef).
Why: Channel-neutral; channel-specific detail stays in grant/review rows. Minimal audit footprint (most events have actor=None).
Population: Some(principal_id) on owner-authored (approval, review, escalation), None on system/worker events.
D-004: ApprovalRecord.approved_by migration
Decision: Retype to PrincipalId; source from verified OwnerSurfaceRef::principal_id() at two callback sites; no backfill of historical rows.
Why: Fixes bug where approvals record config value, not verified principal. Immutability preserves digest-bound evidence integrity. Forward-looking only.
Callbacks: pipeline/approval.rs::handle_draft_approval_callback, pipeline/plan_approval.rs::handle_plan_approval_callback.
D-005: MAC byte preservation
Decision: RootAuthority::canonical_bytes generates identical bytes before/after PrincipalId retype.
Why: Existing sealed grants (holding stringified Ulid in user field) pass MAC verification without modification. New grants set owner PrincipalId correctly; literals "owner"/"kernel" are type-invalid.
Reseal path (if legacy literals exist in store): Detect via query, backfill to canonical owner_principal_id, re-mint grant as valid descendant in chain.
Testing Decisions
Authority-sensitive verification (per conventions.md)
Because this change affects identity resolution, task grant composition, approval recording, and audit:
MAC invariant test: Before/after PrincipalId serialization produces identical bytes to Ulid.to_string(); a grant sealed with user="01ARZ3..." verifies identically after TaskGrant.user is retyped to PrincipalId.
D-006 invariant (identity ≠ authority): OwnerPrincipal has zero authority fields; PrincipalId type has no authority-granting constructors; composed grant with owner PrincipalId set correctly passes gate() with identical authority.
D-007 invariant (grant is only authority): Revoking the grant revokes all authority, regardless of principal_id value. No new authority sources introduced.
AD-146 invariant (single owner principal v1): Exactly one OwnerPrincipal minted at bootstrap with principal_id set; all grants in v1 carry the same owner PrincipalId; no code path creates a second principal (v1 is single-owner; tenancy out-of-scope).
AD-148 invariant (kernel-owned MAC-covered fields): Mutating principal_id in a sealed grant invalidates its MAC; thread_id (if populated) also MAC-covered.
Approval principal sourcing: ApprovalRecord.approved_by set from OwnerSurfaceRef::principal_id(), not state.owner_user_id; approval matching works with PrincipalId comparisons; gate receives PrincipalId.
E2E regression: Minimal task with typed identity sent through gate with approval; gate accepts grant, approvals match on PrincipalId, audit log records actor.
Out of Scope
Further Notes
Invariants preserved:
Downstream cutover (post-implementation):
Migration paths:
Lane context: Wayfinder map #182; design ticket #188 (closed).
🤖 Typed owner identity spec (inline, self-contained)