Skip to content

Spec: Typed Owner Identity #197

Description

@George-RD

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:

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

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

  3. D-007 invariant (grant is only authority): Revoking the grant revokes all authority, regardless of principal_id value. No new authority sources introduced.

  4. 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).

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

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions