DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-48-credit-completion-and-operator-sign-in/phase-48-cr-credit-completion-and-operator-sign-in-v0_1.md

Phase 48 — Credit Completion and Operator Sign-In — Change Request

Version. 0.1 Date. 2026-05-07 CR identifier. CR-2026-063 (verify against the registry at Step 0; advance to next available number if 063 is taken) Phase. 48 — Credit Substrate Completion + Production Operator Sign-In Repos affected. DUNIN7/loomworks-engine (substrate) and DUNIN7/loomworks (Operator Layer frontend) Tag at completion. phase-48-credit-completion-and-operator-signin on both repos Source documents. loomworks-phase-48-scoping-note-v0_2.md (authoritative scope) and phase-48-step-0-findings-v0_1.md (verified live-codebase state, absorbed into v0.2). Drafting handoff: loomworks-phase-48-cr-drafting-handoff-v0_1.md.


1. Purpose

Phase 48 closes the Phase 47 credit-substrate carry-forward along the substrate-vs-Companion-intelligence seam. Phase 47 shipped the credit schema, the seam, the persons lifecycle columns, and the two foundational endpoints (POST /claim/grant, POST /admin/grants). Phase 48 ships the mechanical-and-frontend completion: a balance-read endpoint, the Phase-31 _call_llm routing extension, an Engagement bootstrap for the Credit Management and Accounting Engagements, SMTP plumbing for transactional email, two periodic evaluators (suspension/deletion lifecycle and reconciliation), the conversion-detection wiring for referral credits, and the Operator Layer frontend pieces — including a new production passkey sign-in surface that the Phase 46 dev-auth page does not currently provide.

Phase 48 is substrate completion + frontend completion + plumbing. Phase 49 ships the Companion intelligence on top: voice for the three-choice exhaustion presentation, decision logic for Companion-as-Authority grants, the public credit-request form, near-exhaustion warning voice, and Memory assertion seeding for the bootstrapped Engagements. The phase boundary preserves the discipline established by Phase 47: substrate arithmetic and infrastructure happen mechanically; Companion voice and decisions are governed and deferred to a subsequent phase with its own scope.

This CR is execution against loomworks-phase-48-scoping-note-v0_2.md. All decisions enumerated in §4 below are settled; this CR specifies how, not whether.


2. Scope

2.1 In scope

  1. GET /me/credits endpoint. Returns the current Operator's per-credit-type balances and resolved tier. Backs the frontend balance display and tier badge. Build step 1.
  1. Phase-31 _call_llm routing extension. Threads model= through the seed-conversation _call_llm path and conditionally fires schedule_consumption_recording for system-key turns, closing the routing seam left open by Phase 47 (which routed only the Phase 42 converse pipeline). Build step 2.
  1. Engagement bootstrap — Credit Management + Accounting. Two system-administrative Engagements created via the ensure_administrative_engagement precedent at engagement/bootstrap.py:88. Minimal seeds in Phase 48: purpose statement, four-room structure, system principal as Operator. Memory assertions for Companion-as-Authority decisions deferred to Phase 49. Build step 3.
  1. SMTP plumbing. Configurable SMTP backend abstraction; default standard SMTP relay. Connection, send, retry logic. Template loading mechanism. Wired into deletion-warning emails (build step 5) and into POST /admin/grants to programmatically send the claim email (replacing the manual copy-paste from Phase 47 alpha workflow). Build step 4.
  1. Suspension/deletion evaluator. Periodic background task cloned from the Phase 44 evaluator template at notifications/evaluator.py:544-611. Two scans on configured cadence: deletion-warning scan (suspended persons within 24h of expires_at) and deletion-execution scan (suspended persons whose expires_at has passed). Near-exhaustion explicitly excluded (Phase 49). Build step 5.
  1. Reconciliation evaluator. Independent clone of the Phase 44 evaluator template. Walks credit.foray_action_flows since the last successful reconciliation, identifies unrecorded consumption (system-key turns whose fire-and-forget hook failed), creates correction proposals that surface in the Accounting Engagement Manifestation room. Operator-approval-driven; the evaluator proposes, the Operator confirms through the existing engagement pipeline. Build step 6.
  1. Conversion detection wiring. Single-purpose application-level hook on set_api_key (api_keys/store.py:70-126), an observer module that subscribes to it, and the wiring that fires seam.write_referral_credit_flow when a referred person (referred_by IS NOT NULL) saves their own API key. Idempotent — at most one conversion credit per referee. Build step 7.
  1. Operator Layer frontend — production passkey sign-in. New (sixth) frontend surface that did not exist in Phase 46 (which shipped only a dev-auth page). Full WebAuthn passkey sign-in flow with embedded reactivation surface at the post-verify/pre-cookie gate. Org-SSO/passwordless explicitly out of scope. Build step 8.
  1. Operator Layer frontend — five integration surfaces. Claim landing page (consumes the claim URL from POST /admin/grants), balance display (header chip), tier badge (chrome), reactivation flow (embedded in the sign-in flow per item 8), admin grants UI (Operator-only page replacing manual curl invocation). Build step 9.
  1. Migration 0063. Single Alembic migration adding the credit.evaluator_state table for evaluator persistence and adding new system_config keys for SMTP configuration, evaluator cadences, and per-evaluator state defaults. [CC verifies that 0063 is the next available number; renumber if intervening migrations have landed.]

2.2 Out of scope (Phase 49)

Items the CR explicitly does not implement. Each is named in the scoping handoff §7 as scope-boundary work that drafting must not cross:

2.3 Out of scope (future)


3. Step 0 — pre-flight, archival, and halt protocol

3.1 Pre-flight discipline

This CR specifies architectural decisions firmly; substrate field names, function signatures, table names, model identifiers, and route paths that have not been verified against the live codebase from this Claude.ai drafting context are marked with [CC verifies]. The CR's job is to communicate what to build and in what order. CC's job is to verify names against the actual substrate before executing each step.

The Phase 47 precedent established that this discipline catches naming-only divergences (resolved by re-deriving from ground truth) without conflating them with architectural divergences (which mandate halt-and-amend). Phase 48 preserves it.

3.2 Pre-flight items

CC confirms each of these against the live codebase before Step 1 begins. Scope: /Users/dunin7/loomworks-engine for items 1–17; /Users/dunin7/loomworks for items 18–22.

  1. Substrate baseline. [CC verifies] baseline tag phase-47-credit-substrate-foundation at 284c9e5; main HEAD at e927ecf; 1,866 tests passed, 26 skipped, Alembic 0062. If main has advanced since 2026-05-07, CC reports the current numbers and the CR's expected post-CR figures shift accordingly.
  1. persons table lifecycle columns. [CC verifies] that referred_by, account_status, license_tier, expires_at, previous_status_change_at exist on persons per Phase 47 migration 0062. Build step 5 reads account_status and expires_at; build step 7 reads referred_by.
  1. **credit.* schema and seam.** [CC verifies] that the five credit.* tables exist (foray_action_flows, asset_balances, credit_grant, email_grant_registry, oracle_rate_config); CreditSeam Protocol + InProcessCreditSeam exist at src/loomworks/credit/seam.py; nine seam methods are exposed (the Phase 47 eight plus record_consumption).
  1. set_api_key location and signature. [CC verifies] that set_api_key lives at src/loomworks/api_keys/store.py:70-126 per the Step 0 findings absorption (scoping v0.2 §3.1). Verify the function's signature, the route handler that calls it, and the absence of any existing emit/hook surface.
  1. Phase 44 evaluator template. [CC verifies] that the periodic-loop template exists at src/loomworks/notifications/evaluator.py:544-611 per the Step 0 findings absorption (scoping v0.2 §3.5). Verify the template's structure: asyncio.create_task registration in lifespan, sleep loop with cadence, body that calls a configurable scan function. The two Phase 48 evaluators clone the template body inline rather than abstracting a shared PeriodicTask (queued as deferred work, not Phase 48).
  1. ensure_administrative_engagement precedent. [CC verifies] that the function exists at src/loomworks/engagement/bootstrap.py:88 (scoping v0.2 §4.1). Verify (a) signature, (b) whether the precedent is startup-driven idempotent or migration-driven, and (c) whether it is reusable for two purpose-specific Engagements (Credit Management, Accounting) or whether a closest-fit reuse is needed. If the precedent is startup-driven idempotent and reusable, build step 3 calls it. If migration-driven, build step 3 routes through Migration 0063. If close but not exact, CC implements the closest-fit reuse and reports the divergence at step 3 review (per scoping handoff §5.1: "don't over-engineer; mirror the precedent's pattern even if it isn't a perfect generalization").
  1. _resolve_llm_key and three-tier resolution. [CC verifies] that _resolve_llm_key(engagement_id, db, person_id) exists at src/loomworks/api/routers/seed_conversation.py and returns (api_key, key_source) where key_source ∈ {"operator", "person", "system"} (per Phase 47 impl notes §1, item 4).
  1. Phase 31 _call_llm location and call site. [CC verifies] that _call_llm exists in the seed_conversation module, that converse_route binds (api_key, key_source) at line 499, and that _call_llm's only call site at line 527 can thread model= and a conditional schedule_consumption_recording call (per scoping v0.2 §3.7). Verify the function's current signature and the model parameter source (whether CONVERSATION_MODEL is a module-level constant or already config-driven).
  1. Existing system_config keys. [CC verifies] that the Phase 47 seeded keys exist (converse_classifier_model, converse_default_responder_model, referrer_rate_limit_per_period, referrer_rate_limit_period_days, default_suspension_period_days, default_grant_amount_per_credit_type, referral_credit_amount, default_grant_expiry_days, email_eligibility_cooling_days). Migration 0063 adds new keys (§5) without modifying these.
  1. system_config storage format. [CC verifies] that system_config stores Fernet-encrypted text per Phase 47 impl notes §3.1. Migration 0063 follows the same pattern: inline Fernet encryption helper, ON CONFLICT DO NOTHING on inserts, downgrade preserves seeded values.
  1. Existing /admin/ auth pattern. [CC verifies] that POST /admin/grants uses session-authenticated get_current_person per Phase 47 impl notes (the policy strengthening). Build step 9's admin grants UI presumes the same pattern. The new admin-grants UI page calls the existing endpoint; no new admin-auth substrate is needed.
  1. Existing claim flow. [CC verifies] that POST /claim/grant (single-stage passkey-bound per Phase 47 impl notes §3.10) and the augmented /auth/signup/totp-verify with optional claim_token exist and behave as documented. Build step 9's claim landing page consumes these.
  1. Engine routes for sign-in. [CC verifies] whether sign-in routes exist in the engine (separately from /auth/signup/). Candidate names extrapolated from the signup flow are /auth/signin/begin and /auth/signin/passkey; CR text uses these names but defers to live names. Halt-and-amend trigger: if the engine has no sign-in routes (only signup), build step 8 expands meaningfully; CC reports a finding at Step 0 review and the Operator decides whether to amend the CR or absorb the additional work into step 8 with an estimate revision. Per scoping handoff §8: "if reading sign-in endpoints reveals that the engine has no* sign-in path (only signup), the build-step-8 work is meaningfully larger than v0.2 estimated. Surface as a finding for v0.3 scoping consideration rather than absorbing silently."
  1. TOTP / recovery routes' post-verify gate. [CC verifies] that the post-verify/pre-cookie detection point identified by Step 0 in TOTP and recovery routes (scoping v0.2 §3.8) exists in the engine sign-in path (or in whatever path build step 8 lands as the production sign-in flow). Build step 8 inserts the suspended-status check at this gate.
  1. Phase 44 background-task lifespan registration. [CC verifies] how Phase 44's evaluator is registered in the FastAPI lifespan and how BackgroundAgentRunner (Phase 3 / Phase 44) cancels on shutdown. Build steps 5 and 6 follow the same pattern.
  1. credit.foray_action_flows query patterns for reconciliation. [CC verifies] how to identify "unrecorded consumption" — flows with metadata->>'reason' = 'consumption' whose corresponding token-usage signal does not have a matching flow row. The reconciliation evaluator (build step 6) walks this set since the last last_reconciliation_at. CR §11 specifies the query shape; CC verifies the actual metadata keys against the Phase 47 implementation.
  1. Alembic next migration number. [CC verifies] that 0063 is next; renumber if intervening migrations have landed.
  1. Operator Layer baseline. [CC verifies] baseline state in DUNIN7/loomworks: 63 vitest tests, 18 files, 7 prerendered routes, eslint/tsc/build clean per current-status manifest v0.33. Phase 46 dev-auth page is the only auth surface; build step 8 replaces or augments it.
  1. Operator Layer brand and design tokens. [CC verifies] the brand-guide / logo-system / design-md state. Build steps 8 and 9 follow the existing token system; the new sign-in surface uses the vertical-lockup ceremonial style for the landing/sign-in entry, transitioning to horizontal-lockup chrome on the post-auth dashboard (per current-status manifest §1).
  1. Operator Layer auth state machine. [CC verifies] how the Phase 46 dev-auth page establishes the session cookie. Build step 8's production passkey flow either replaces the dev-auth path or routes around it (the dev-auth path can remain for local development; a routing flag selects which surface is presented in production).
  1. Engine CORS / cookie configuration. [CC verifies] that the engine's CORS and cookie-issuance settings are compatible with the Operator Layer's domain. Build step 8 may need a CORS amendment if the production sign-in flow originates from a different origin than the existing dev-auth surface.
  1. webauthn_rp_origin system_config. [CC verifies] the value of webauthn_rp_origin (the same key used by Phase 47's claim URL composition: {webauthn_rp_origin}/claim?token=<token>). Build step 8's production passkey flow must use the same RP origin.

Pre-flight halt protocol. If any of items 2–22 diverge from this CR's assumptions in a way that changes the architecture (a structural assumption proves false, a required mechanism doesn't exist, two incompatible pieces meet), CC halts at Step 0 and produces a blocker report. The Operator decides whether to amend the CR or proceed with CC's recommended adjustments. Do not proceed through divergence.

Naming-only divergences (a function exists at a different path; a column has a slightly different name; a route is named differently) are resolved by CC re-deriving the name from ground truth and continuing — that is the entire point of the [CC verifies] discipline.

The single highest-probability halt trigger in Phase 48 is item 13 (engine sign-in endpoints absent). The scoping handoff §8 explicitly flags this as a halt-and-surface signal at draft time; pre-flight resolves it definitively by reading the live route table.

3.3 CR archival

At Step 0, before Step 1 begins, archive this CR (and any subsequent amendments) to:


docs/phase-crs/phase-48-cr-credit-completion-and-operator-sign-in-v0_1.md

in loomworks-engine. The frontend repo does not get an archival copy; the engine repo holds all phase CRs.


4. Settled decisions (consumed from scoping v0.2)

All decisions enumerated below are settled by loomworks-phase-48-scoping-note-v0_2.md. This CR is execution.

| ID | Decision | Phase 48 implementation | |----|----------|------------------------| | P48-D1 | Substrate-vs-Companion-intelligence split | Phase 48 ships substrate completion + frontend completion + SMTP plumbing; Phase 49 ships Companion intelligence (voice, decisions, public form) | | P48-D2 | Conversion-credit asset_id policy: default-mirror | Build step 7 reads referee's most recent claimed credit_grant.asset_id and writes the referrer credit at that asset; Operator override path deferred to Phase 49 | | P48-D3 | Maker-referrer accumulation: accepted | Build step 7 issues conversion credits regardless of whether the referrer is on system or own key; documented as deliberate | | P48-D4 | Asymmetric Opus reward (10,000 referrer credit when referee got 5,000): accepted | system_config.referral_credit_amount = 10000 (Phase 47 seed) reads as-is; build step 7 does not adjust | | P48-D5 | SMTP plumbing only (no Companion-fired grants in Phase 48) | Build step 4 ships the SMTP layer; build step 5 wires deletion-warning emails through it; POST /admin/grants programmatically sends the claim email through it | | P48-D6 | Suspension-expiry warning + deletion-execution scans only; near-exhaustion deferred | Build step 5 implements two scans; near-exhaustion explicitly excluded | | P48-D7 | Reconciliation evaluator: time-based scheduled, daily default, config-driven cadence | Build step 6 reads system_config.reconciliation_cadence_minutes (default 1440); proposals surface in Accounting Engagement Manifestation room | | P48-D8 | Item 7 routing extension folded into Phase 48 (not sibling amendment) | Build step 2 | | P48-D9 | Workshop frontend: unchanged | No changes to DUNIN7/loomworks-ui | | P48-D10 | Engagement bootstrap (Credit Management + Accounting) using ensure_administrative_engagement precedent | Build step 3; minimal seeds; Memory assertions deferred to Phase 49 | | P48-D11 | Event surface for own-key save: single-purpose application-level hook on set_api_key | Build step 7 builds the hook before attaching the observer | | P48-D12 | Production sign-in: passkey-only | Build step 8; org-SSO/passwordless explicitly out of scope | | P48-D13 | Reactivation surface placement: embedded in sign-in flow at post-verify/pre-cookie gate | Build step 8; not a separate route or settings page | | P48-D14 | SMTP provider: configurable backend, default standard SMTP relay | Build step 4 ships an interface + default implementation; provider-specific backends (SES, Postmark) future | | P48-D15 | Reconciliation cadence default: 1,440 minutes (daily) | system_config.reconciliation_cadence_minutes = 1440 seeded in Migration 0063 | | P48-D16 | Suspension period default: 21 days | Already seeded in Phase 47 (default_suspension_period_days = 21); Phase 48 reads it | | P48-D17 | Admin grants UI: included in Phase 48 | Build step 9 |


5. Migration 0063 — schema and config additions

Single migration. Manually written, not autogenerated. Schema-qualified table name. Forward (upgrade()) and reverse (downgrade()) both implemented.

5.1 New table — credit.evaluator_state

Persistent state for the two evaluators in build steps 5 and 6. One row per evaluator.

| Column | Type | Constraint | |---|---|---| | evaluator_name | VARCHAR(64) | PRIMARY KEY | | last_run_started_at | TIMESTAMPTZ | NULL | | last_run_completed_at | TIMESTAMPTZ | NULL | | last_run_status | VARCHAR(16) | NULL — one of 'success', 'partial', 'failed' | | last_run_summary | TEXT | NULL — human-readable run summary | | last_reconciliation_through | TIMESTAMPTZ | NULL — for reconciliation evaluator only: walk-from boundary for the next run | | created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | | updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() |

The migration seeds two rows at upgrade time (ON CONFLICT DO NOTHING):

Why a table rather than system_config rows. system_config stores Fernet-encrypted single values; multi-column persistent state with timestamp comparisons is cleaner in a typed table. The table is intentionally narrow — one row per evaluator, no row-fan-out — so cost is low. Future evaluators add a row, not a table.

5.2 New system_config keys (Fernet-encrypted at upgrade time)

Insert with ON CONFLICT DO NOTHING; do not overwrite operator-tuned values.

| Key | Default value | Purpose | |---|---|---| | smtp_provider_backend | "smtp_relay" | Selects the SMTP provider implementation (build step 4). Future values: "ses", "postmark", etc. | | smtp_relay_host | "" (empty — operator must set before SMTP works) | SMTP server hostname | | smtp_relay_port | "587" | SMTP server port | | smtp_relay_username | "" | SMTP auth username | | smtp_relay_password | "" | SMTP auth password | | smtp_relay_use_tls | "true" | TLS enabled | | smtp_from_address | "" (empty — operator must set before SMTP works) | From: header for outbound mail | | smtp_from_name | "Loomworks" | From: display name | | suspension_evaluator_cadence_minutes | "1440" | Build step 5 cadence (daily by default) | | reconciliation_cadence_minutes | "1440" | Build step 6 cadence (daily by default; D15) | | deletion_warning_lead_hours | "24" | Build step 5: hours before expires_at to fire deletion-warning email | | evaluator_runs_enabled | "true" | Master switch for both evaluators (operator can disable for maintenance windows) |

The migration helper inlines the Fernet encryption per Phase 41 / Phase 47 precedent (no app imports in the migration); downgrade leaves the seeded rows in place rather than clobbering them.

5.3 No changes to existing tables

Migration 0063 does not alter any existing table. The persons lifecycle columns (Phase 47), the credit.* tables (Phase 47), and system_config (existing) are untouched.

5.4 Bootstrap-driven Engagement creation — note

The Engagement bootstrap (build step 3) creates the Credit Management Engagement and the Accounting Engagement. This work is intentionally not part of Migration 0063. Per scoping v0.2 §7.10 (tentative pending CR drafting), the bootstrap follows the ensure_administrative_engagement precedent which is startup-driven idempotent (per the function's name). Build step 3 confirms this against engagement/bootstrap.py:88 at execution time. If CC's verification reveals the precedent is migration-driven instead, build step 3 amends Migration 0063 with the Engagement INSERTs. [CC verifies the precedent's mechanism at step 3 entry; the CR specifies startup-driven as the primary path with migration-driven as the documented fallback.]


6. Build step 1 — GET /me/credits endpoint

What lands. A new authenticated endpoint that returns the current Operator's per-credit-type balances and resolved tier. Backs the frontend balance display (build step 9) and the tier badge (build step 9).

6.1 Step 0 inspection (per build step)

Before implementing, CC confirms:

6.2 Endpoint specification


GET /me/credits
Auth: session-authenticated person (existing pattern)

Response 200:
  {
    "person_id": "<uuid>",
    "balances": [
      { "asset_id": "loomworks_credit_opus", "balance": <integer> },
      { "asset_id": "loomworks_credit_sonnet", "balance": <integer> },
      { "asset_id": "loomworks_credit_haiku", "balance": <integer> }
    ],
    "resolved_tier": {
      "asset_id": "loomworks_credit_sonnet" | "loomworks_credit_haiku" | "loomworks_credit_opus" | null,
      "responder_model": "<live model string>" | null,
      "balance": <integer> | null
    } | null,
    "account_status": "active" | "exhausted" | "suspended" | "deleted",
    "license_tier": "trial" | "maker" | "professional" | "team",
    "expires_at": "<iso-8601>" | null
  }

Response 401: unauthenticated.
Response 403: account_status='deleted' (anonymized; the endpoint refuses to surface state for deleted accounts beyond the status itself).

The balances array always includes all three loomworks_credit_* asset_ids in tier order (opus, sonnet, haiku), with balance: 0 for asset_ids the person has no row for (so the frontend doesn't have to handle missing rows).

The resolved_tier is the result of seam.resolve_credit_model(person_id). null when all loomworks_credit_* balances are zero (the exhausted state, structurally represented even when account_status='active' because the lifecycle transition to 'exhausted' is mechanical, not Companion-driven).

6.3 New module / file

src/loomworks/api/me_credits.py [CC verifies the existing /me/ router file naming convention; the actual filename may differ. The handler may also live in an existing /me/ router rather than a new file.]

6.4 Implementation sketch


@router.get("/me/credits", response_model=MeCreditsResponse)
async def get_me_credits(
    person: Person = Depends(get_current_person),
    seam: CreditSeam = Depends(get_credit_seam),
    db: AsyncSession = Depends(get_db),
) -> MeCreditsResponse:
    if person.account_status == "deleted":
        raise HTTPException(status_code=403, detail="account_deleted")

    balances_raw = await seam.get_credit_balances(person.id)
    by_asset = {b.asset_id: b.balance for b in balances_raw}
    balances = [
        CreditBalance(asset_id=aid, balance=by_asset.get(aid, 0))
        for aid in ("loomworks_credit_opus", "loomworks_credit_sonnet", "loomworks_credit_haiku")
    ]

    resolution = await seam.resolve_credit_model(person.id)
    resolved_tier = (
        ResolvedTier(
            asset_id=resolution.asset_id,
            responder_model=resolution.responder_model,
            balance=resolution.balance,
        )
        if resolution is not None
        else None
    )

    return MeCreditsResponse(
        person_id=person.id,
        balances=balances,
        resolved_tier=resolved_tier,
        account_status=person.account_status,
        license_tier=person.license_tier,
        expires_at=person.expires_at,
    )

6.5 Halt threshold for build step 1

>5 tests touched outside the new endpoint's own tests; >2 existing endpoints' contracts changed. Either condition halts and surfaces.

6.6 Tests (~5)

Per §15.1.


7. Build step 2 — Phase-31 _call_llm routing extension

What lands. The Phase-31-era seed_conversation._call_llm path (the path used by delegated create_project / finalize_project branches) gains the same model-routing and consumption-recording behavior the Phase 42 converse pipeline received in Phase 47. After this step, both paths route system-key turns to the credit-resolved responder model and fire the consumption hook.

This is the smallest of the nine build steps per scoping v0.2 §3.7.

7.1 Step 0 inspection

Before implementing, CC confirms:

7.2 Changes

In the seed_conversation module:

  1. Thread key_source: str | None = None through _call_llm's signature.
  2. Compute the responder model:
  1. After the LLM call, if key_source == "system", call schedule_consumption_recording(person_id=..., asset_id=..., usage=..., db=...) per the Phase 47 fire-and-forget contract.
  2. The caller (converse_route or whichever Phase-31-era handler invokes _call_llm) updates to pass key_source from the existing _resolve_llm_key result.

7.3 Account-status gate

The seed_conversation path adopts the Phase 47 §15 account-status gate — calls from suspended or deleted accounts raise AccountSuspendedError / AccountDeletedError before reaching _call_llm. [CC verifies the gate insertion point; if the seed_conversation path doesn't currently have an analogous gate, build step 2 adds one mirroring the converse-route pattern.]

7.4 Halt threshold for build step 2

>5 tests touched in the seed_conversation suite; the change requires touching >30 tests across the codebase. Either condition halts and surfaces.

7.5 Tests (~7)

Per §15.2.


8. Build step 3 — Engagement bootstrap (Credit Management + Accounting)

What lands. Two system-administrative Loomworks Engagements created via the ensure_administrative_engagement precedent at engagement/bootstrap.py:88. Both Engagements receive minimal seeds in Phase 48 (purpose statement + four-room structure + system principal as Operator). Memory assertions for Companion-as-Authority decisions are deferred to Phase 49.

8.1 Step 0 inspection

Before implementing, CC confirms:

8.2 Engagement identifiers

Two deterministic UUIDs (mirrors the Phase-2 administrative-engagement pattern):

The deterministic-UUID approach makes existence checks trivial (query by UUID) and allows the bootstrap to be idempotent without persisting additional state.

8.3 Engagement seeds (minimal)

Each Engagement is created with a minimal seed. Per scoping v0.2 §4.3, Memory assertions land in Phase 49.

Credit Management seed (text):

> The Credit Management Engagement governs the issuance of credits and grants in the Loomworks credit system. It carries policy decisions about who receives credits, in what asset, and at what amount. The Companion-as-Authority on this Engagement reads Memory assertions (model profile knowledge, campaign data, referral policy) and proposes grant decisions for Operator approval. In Phase 48 this Engagement exists as a substrate object; the Companion intelligence and Memory contents are added in Phase 49.

Accounting seed (text):

> The Accounting Engagement maintains the Loomworks credit ledger and surfaces reconciliation proposals when the consumption-recording fire-and-forget path drifts from the source-of-truth flow log. Its Manifestation room receives correction proposals from the reconciliation evaluator (Phase 48 build step 6); the Operator approves or refuses each proposal through the existing engagement pipeline. The substrate accumulates the data needed to surface burn-rate observations, per-credit-type liability, and campaign performance — Companion-driven analyses on top of these surfaces are Phase 49+.

8.4 Operator (system principal)

Both Engagements are owned by a system principal (not a real human Operator). Per Phase 47 §3.10 and the existing dunin7 institutional party pattern, the system principal is 'dunin7' (the institutional from-party already used in credit.foray_action_flows). [CC verifies whether the existing administrative-engagement precedent assigns an Operator at all; if so, mirror the assignment; if not, leave the Operator slot per the precedent's pattern. Don't invent a new principal.]

8.5 Bootstrap implementation

In src/loomworks/credit/bootstrap.py [CC verifies the desired module path; this may live in src/loomworks/engagement/bootstrap.py if the precedent's location is conventional]:


CREDIT_MANAGEMENT_ENGAGEMENT_ID = UUID("00000000-0000-0000-0000-000000000048")
ACCOUNTING_ENGAGEMENT_ID = UUID("00000000-0000-0000-0000-000000000049")

CREDIT_MANAGEMENT_SEED_TEXT = """..."""  # per §8.3
ACCOUNTING_SEED_TEXT = """..."""  # per §8.3


async def ensure_credit_management_engagement(db: AsyncSession) -> Engagement:
    """Idempotent. Mirrors ensure_administrative_engagement at engagement/bootstrap.py:88.
    Checks for the Engagement by deterministic UUID; creates with the four-room
    structure and seed text if absent; returns the current state."""
    ...


async def ensure_accounting_engagement(db: AsyncSession) -> Engagement:
    """Idempotent. Same pattern."""
    ...


async def ensure_credit_engagements(db: AsyncSession) -> tuple[Engagement, Engagement]:
    """Convenience wrapper. Called from the FastAPI lifespan startup hook."""
    return (
        await ensure_credit_management_engagement(db),
        await ensure_accounting_engagement(db),
    )

The lifespan startup hook calls ensure_credit_engagements once at startup, after the database is reachable but before any HTTP request handlers are registered. [CC verifies the lifespan hook insertion point against the existing administrative-engagement bootstrap call.]

8.6 If the precedent is migration-driven

If pre-flight item 6 establishes that ensure_administrative_engagement is migration-driven (not startup-driven idempotent), build step 3 amends Migration 0063 to add the two Engagement INSERT statements at upgrade time, with ON CONFLICT DO NOTHING for idempotency on re-application. The startup-hook call is omitted in that path.

The CR's primary path (§8.5) reflects the most likely state per the precedent's name; the migration-driven fallback is named explicitly so CC can implement it without surfacing an architectural-divergence finding.

8.7 If the precedent doesn't fit at all

Per scoping handoff §5.1: "If the precedent doesn't quite fit (e.g., it creates a single administrative engagement with hardcoded structure that isn't reusable for two purpose-specific engagements), the CR specifies the closest-fit reuse and notes the divergence. Don't over-engineer; mirror the precedent's pattern even if it isn't a perfect generalization."

The closest-fit reuse path: copy the precedent's structure into a new module (src/loomworks/credit/bootstrap.py), generalize just enough to handle the two-Engagement case (factor out the per-Engagement parameters: id, seed text, name), do not generalize further. Document the divergence in implementation notes at Checkpoint A.

8.8 Halt threshold for build step 3

The implementation diverges from the ensure_administrative_engagement precedent in a way that is not a closest-fit reuse — i.e., a fundamentally different mechanism is required. CC halts and surfaces.

8.9 Tests (~15)

Per §15.3.


9. Build step 4 — SMTP plumbing

What lands. A configurable SMTP backend abstraction with a default standard-SMTP-relay implementation; connection, send, retry logic; a template loading mechanism. Wired into deletion-warning emails (build step 5) and into POST /admin/grants to programmatically send the claim email (replacing the manual copy-paste from the Phase 47 alpha workflow).

9.1 Step 0 inspection

Before implementing, CC confirms:

9.2 Module structure

New module tree at src/loomworks/email/:

| File | Purpose | |---|---| | __init__.py | Module init | | provider.py | EmailProvider Protocol + provider registry + get_email_provider() accessor | | smtp_relay.py | SmtpRelayProvider implementation (standard SMTP) | | templates.py | Template loading (read template files from src/loomworks/email/templates/) and rendering (Jinja2 or string-format — [CC determines]) | | service.py | send_email(to, template_name, context) high-level function. Loads template, renders, dispatches via provider, schedules retry on failure | | models.py | EmailSendAttempt ORM row (optional; see §9.6) | | templates/ | Directory of .txt and .html template files |

9.3 EmailProvider Protocol


class EmailProvider(Protocol):
    async def send(
        self,
        to: str,
        from_address: str,
        from_name: str,
        subject: str,
        body_text: str,
        body_html: str | None = None,
    ) -> EmailSendResult:
        ...

@dataclass
class EmailSendResult:
    success: bool
    provider_message_id: str | None
    error_message: str | None

The get_email_provider() accessor reads system_config.smtp_provider_backend and instantiates the matching implementation. Default is "smtp_relay"SmtpRelayProvider.

9.4 SmtpRelayProvider

Wraps aiosmtplib (or equivalent — [CC determines] based on the existing async stack). Reads smtp_relay_host, smtp_relay_port, smtp_relay_username, smtp_relay_password, smtp_relay_use_tls from system_config at instantiation.

Behavior on send failure: logs at error level with the exception, returns EmailSendResult(success=False, ...). Does not raise — the caller (service.send_email) decides whether to retry or schedule a follow-up attempt.

9.5 Templates

Templates live in src/loomworks/email/templates/ as .txt files (and optional .html for HTML email). Phase 48 ships the following templates (the names match the call sites):

Phase 48 ships exactly these two templates. Future templates (Companion-driven warm exhaustion messages, etc.) are Phase 49.

9.6 EmailSendAttempt ORM row (optional)

If pre-flight inspection or initial implementation reveals that observability into email sends is needed for the alpha (e.g., to debug "did the deletion warning send?"), Phase 48 may add an email_send_attempts table:

| Column | Type | |---|---| | id | UUID PK | | to | VARCHAR | | template_name | VARCHAR | | attempted_at | TIMESTAMPTZ | | success | BOOLEAN | | provider_message_id | VARCHAR (NULL) | | error_message | TEXT (NULL) |

If included, the table is added to Migration 0063 and a row is inserted for each send attempt. Default decision: include the table (the alpha benefits from observability). [CC may omit it if the existing logging infrastructure is judged sufficient; document the decision at Checkpoint A.]

9.7 Wiring into POST /admin/grants

Phase 47's POST /admin/grants returns { "claim_url": "..." } and the Operator manually emails the URL. Phase 48 augments the endpoint:

9.8 Wiring into deletion-warning (build step 5)

Build step 5's evaluator calls email_service.send_email(to=person.recovery_email, template_name="deletion_warning", context={...}) for each suspended person within the warning window. [CC verifies the recovery-email field on persons; if no recovery email is stored, the warning sends to the person's hashed email reverse-lookup is impossible — fall back to the Operator's email (via system_config.alpha_operator_notification_email if present, or skip with logged-warning if not).]

Since Phase 47's deletion lifecycle anonymizes persons.email on delete_account, the deletion-warning fires before anonymization (the person is still in account_status='suspended', not yet 'deleted'). The plaintext email is still on the row at warning time.

9.9 Halt threshold for build step 4

The selected SMTP library doesn't compose with the existing async stack. CC halts and surfaces with a recommendation.

9.10 Tests (~5)

Per §15.4. Tests use a fake provider that captures EmailSendResult records; no real SMTP calls in the test suite.


10. Build step 5 — Suspension/deletion evaluator

What lands. A periodic background task (cloned from the Phase 44 evaluator template at notifications/evaluator.py:544-611) that runs two scans on configurable cadence: a deletion-warning scan and a deletion-execution scan. The evaluator surfaces nothing to a Companion-driven UI; it acts directly on persons rows and sends emails through the SMTP layer (build step 4).

10.1 Step 0 inspection

Before implementing, CC confirms:

10.2 Module structure

New module: src/loomworks/credit/lifecycle_evaluator.py. Independent from the reconciliation evaluator (build step 6) per scoping v0.2 §3.5 (no shared PeriodicTask abstraction).

10.3 Evaluator structure

Cloned template body. Approximate structure:


async def lifecycle_evaluator_loop(session_factory, get_email_provider, get_seam):
    """Cloned from notifications/evaluator.py:544-611. Runs forever until cancelled."""
    while True:
        try:
            cadence_minutes = await read_system_config_int(
                "suspension_evaluator_cadence_minutes", default=1440
            )
            enabled = await read_system_config_bool("evaluator_runs_enabled", default=True)
            if enabled:
                await run_lifecycle_scan_cycle(session_factory, get_email_provider, get_seam)
            await asyncio.sleep(cadence_minutes * 60)
        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("lifecycle_evaluator unexpected error")
            await asyncio.sleep(60)  # back off on unexpected error


async def run_lifecycle_scan_cycle(session_factory, get_email_provider, get_seam):
    async with session_factory() as db:
        await mark_run_started(db, "suspension_deletion_evaluator")
        try:
            warning_count = await scan_deletion_warnings(db, get_email_provider)
            execution_count = await scan_deletion_executions(db, get_seam)
            await mark_run_completed(
                db,
                "suspension_deletion_evaluator",
                status="success",
                summary=f"warnings={warning_count} executions={execution_count}",
            )
        except Exception as e:
            await mark_run_completed(
                db,
                "suspension_deletion_evaluator",
                status="failed",
                summary=f"{type(e).__name__}: {e}",
            )
            raise

The mark_run_started / mark_run_completed helpers update credit.evaluator_state rows. Both helpers and read_system_config_* helpers are added in this step (or pulled from a shared module if pre-flight reveals one already exists).

10.4 Deletion-warning scan


async def scan_deletion_warnings(db, get_email_provider) -> int:
    """For each person with account_status='suspended' and expires_at within
    deletion_warning_lead_hours of NOW(), and where no warning has been sent in
    this window, send a deletion-warning email."""
    lead_hours = await read_system_config_int("deletion_warning_lead_hours", default=24)
    candidates = await db.execute(
        select(PersonRow).where(
            PersonRow.account_status == "suspended",
            PersonRow.expires_at.is_not(None),
            PersonRow.expires_at <= func.now() + timedelta(hours=lead_hours),
            PersonRow.expires_at > func.now(),
        )
    )
    sent = 0
    for person in candidates.scalars():
        if await deletion_warning_already_sent_in_window(db, person.id, lead_hours):
            continue
        await email_service.send_email(
            to=person.email,  # plaintext while still suspended; anonymized only on deletion
            template_name="deletion_warning",
            context={
                "operator_name": person.display_name or "there",
                "expires_at": person.expires_at,
                "reactivate_url": compose_reactivate_url(person.id),
            },
        )
        await record_warning_sent(db, person.id)  # uses email_send_attempts or equivalent
        sent += 1
    return sent

Deduplication. A warning is "already sent in this window" if email_send_attempts has a successful row for (to=person.email, template_name='deletion_warning') within the last lead_hours 2 (i.e., we don't re-send if a prior run already did, but we do re-send if the lead_hours config widens). [CC verifies the deduplication shape; alternative is a last_warning_sent_at column on persons, which is cleaner but adds schema. Default: use email_send_attempts if §9.6's table is included; otherwise add a persons.last_deletion_warning_sent_at column to Migration 0063.]*

10.5 Deletion-execution scan


async def scan_deletion_executions(db, get_seam) -> int:
    """For each person with account_status='suspended' and expires_at <= NOW(),
    call seam.delete_account(person_id, deletion_kind='suspension_expired')."""
    candidates = await db.execute(
        select(PersonRow).where(
            PersonRow.account_status == "suspended",
            PersonRow.expires_at.is_not(None),
            PersonRow.expires_at <= func.now(),
        )
    )
    deleted = 0
    seam = get_seam()
    for person in candidates.scalars():
        try:
            await seam.delete_account(person.id, deletion_kind="suspension_expired")
            deleted += 1
        except Exception:
            logger.exception("delete_account failed for person %s", person.id)
            # Continue with the next candidate; do not abort the scan.
    return deleted

Per Phase 47 §11, seam.delete_account zeros balances, anonymizes the person row, and writes a FORAY flow recording the deletion. The evaluator simply triggers it on the right rows at the right time.

10.6 compose_reactivate_url helper

Composes a URL the recipient can click to begin the reactivation flow (build step 8). The URL is {webauthn_rp_origin}/signin?reactivate=<person_id_signed_token>. [CC verifies the signing pattern; reuse Phase 47's claim-token signing if applicable.]

The reactivation flow itself is in the sign-in surface (build step 8), not at this URL — the URL just deep-links to the sign-in page with a hint that the person knows they need to reactivate.

10.7 Lifespan registration

The evaluator is registered in the FastAPI lifespan startup path:


@asynccontextmanager
async def lifespan(app: FastAPI):
    # ... existing startup work, including Phase 44 evaluator and Phase 48 §8 bootstrap
    lifecycle_task = asyncio.create_task(
        lifecycle_evaluator_loop(app.state.session_factory, get_email_provider, get_credit_seam)
    )
    try:
        yield
    finally:
        lifecycle_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await lifecycle_task

[CC verifies the existing lifespan structure; the registration must be additive to the Phase 44 evaluator registration without breaking it.]

10.8 Halt threshold for build step 5

Implementing the lifecycle evaluator requires materially modifying the Phase 44 evaluator template (not just cloning the periodic loop structure). CC halts and surfaces — the divergence may indicate the template is more abstract than the cloning approach assumed.

10.9 Tests (~15)

Per §15.5.


11. Build step 6 — Reconciliation evaluator

What lands. A periodic background task (independent clone of the Phase 44 evaluator template) that walks credit.foray_action_flows since the last successful reconciliation, identifies unrecorded consumption, and creates correction proposals that surface in the Accounting Engagement Manifestation room. Operator-driven approval through the existing engagement pipeline; the evaluator proposes, the Operator confirms.

11.1 Step 0 inspection

Before implementing, CC confirms:

11.2 What "drift" looks like

The Phase 47 consumption hook is fire-and-forget. When it fails, the LLM call still happened (the Operator received a response and tokens were consumed at Anthropic) but no flow was written to credit.foray_action_flows. The result: the operator's balance is higher than it should be by the amount of the unrecorded consumption.

Detection signals:

  1. Token-usage signal without matching flow. The Phase 47 token-usage contextvar buffer captures every LLM call's input/output tokens, labeled by pipeline stage and key_source. [CC verifies whether these signals are persisted anywhere — if not, the only detection signal is failed-hook log entries, which is weaker.]
  2. Failed-hook log entries. When the consumption hook fires and fails, it logs at error level. Log-scraping is fragile but available as a fallback.
  3. Aggregate mismatch. Compute expected total consumption from the token-usage signal source against actual total flows; identify per-person mismatches.

The reconciliation evaluator's primary mechanism is (1) token-usage-signal vs. flow comparison when the signals are persisted, falling back to (3) aggregate mismatch when (1) is not available. [CC determines the appropriate mechanism after verifying signal persistence; if neither (1) nor (3) is feasible, build step 6 produces a structural reconciliation evaluator that runs no-op for now and is wired to a real signal source in Phase 49 — and CC halts and surfaces this finding rather than silently shipping a no-op.]

11.3 Module structure

New module: src/loomworks/credit/reconciliation_evaluator.py.

11.4 Evaluator structure

Independent clone of the Phase 44 template, mirroring §10.3:


async def reconciliation_evaluator_loop(session_factory, ...):
    while True:
        try:
            cadence_minutes = await read_system_config_int(
                "reconciliation_cadence_minutes", default=1440
            )
            enabled = await read_system_config_bool("evaluator_runs_enabled", default=True)
            if enabled:
                await run_reconciliation_cycle(session_factory)
            await asyncio.sleep(cadence_minutes * 60)
        except asyncio.CancelledError:
            raise
        except Exception:
            logger.exception("reconciliation_evaluator unexpected error")
            await asyncio.sleep(60)


async def run_reconciliation_cycle(session_factory):
    async with session_factory() as db:
        await mark_run_started(db, "reconciliation_evaluator")
        try:
            state = await db.execute(
                select(EvaluatorStateRow).where(EvaluatorStateRow.evaluator_name == "reconciliation_evaluator")
            )
            row = state.scalar_one()
            walk_from = row.last_reconciliation_through or (datetime.utcnow() - timedelta(days=7))
            walk_to = datetime.utcnow()

            proposals = await detect_reconciliation_drift(db, walk_from, walk_to)
            for proposal in proposals:
                await surface_proposal_to_accounting_room(db, proposal)

            row.last_reconciliation_through = walk_to
            await db.commit()
            await mark_run_completed(
                db,
                "reconciliation_evaluator",
                status="success",
                summary=f"proposals={len(proposals)} window={walk_from.isoformat()}..{walk_to.isoformat()}",
            )
        except Exception as e:
            await mark_run_completed(
                db,
                "reconciliation_evaluator",
                status="failed",
                summary=f"{type(e).__name__}: {e}",
            )
            raise

11.5 detect_reconciliation_drift shape


@dataclass
class ReconciliationProposal:
    person_id: UUID
    asset_id: str
    expected_consumption: int  # what the token-usage signal source says happened
    recorded_consumption: int  # what credit.foray_action_flows recorded
    drift: int  # expected - recorded; positive means under-recorded
    window_from: datetime
    window_to: datetime
    rationale: str  # human-readable explanation


async def detect_reconciliation_drift(
    db: AsyncSession, walk_from: datetime, walk_to: datetime
) -> list[ReconciliationProposal]:
    """Walks the configured signal source within [walk_from, walk_to). Returns
    one proposal per (person, asset) pair where drift != 0."""
    ...

11.6 surface_proposal_to_accounting_room


async def surface_proposal_to_accounting_room(
    db: AsyncSession, proposal: ReconciliationProposal
) -> None:
    """Creates a Manifestation contribution in the Accounting Engagement that
    represents this proposal. The Operator sees it in the Accounting room and
    approves or refuses through the existing engagement pipeline."""
    ...

The exact API to create a Manifestation contribution is [CC verifies] against the existing pipeline. The proposal is rendered in plain operator vocabulary — not Companion voice. The Operator-approval-driven application of the proposal is not part of build step 6: when the Operator approves, the existing engagement pipeline writes a Rendering output, and a separate small handler (the proposal applier) writes the corrective FORAY flow. Phase 48 ships the proposal-creation half; the proposal-applier is structural work tracked at the same step but conceptually separate. [CC may split this into two sub-tasks in step 6 if the blast radius warrants it.]

11.7 No proposals when drift is below threshold

A system_config.reconciliation_drift_threshold = "1" key (added in Migration 0063 §5.2 — amend the table if missing) suppresses proposals smaller than the threshold. Default 1 means "always surface drift"; the alpha may want a higher threshold once volume is real. [CC adds this key to Migration 0063 if not already present.]

11.8 Lifespan registration

Same pattern as build step 5 (§10.7), registered as a separate task. Two evaluator tasks, both cancelled on shutdown.

11.9 Halt threshold for build step 6

The signal source for "expected consumption" is not available (per §11.2 fallback chain). CC halts and surfaces — a no-op reconciliation evaluator is not shipped silently.

Or: the existing pipeline contribution mechanism (Manifestation surface) is not callable from a background task without architectural changes. CC halts and surfaces.

11.10 Tests (~15)

Per §15.6.


12. Build step 7 — Conversion detection wiring (event-surface hook + observer)

What lands. A single-purpose application-level hook on set_api_key (api_keys/store.py:70-126), an observer module that subscribes to it, and the wiring that fires seam.write_referral_credit_flow when a referred person (referred_by IS NOT NULL) saves their own API key. Idempotent — at most one conversion credit per referee.

This is the build step that absorbs the Step 0 §3.2 BREAKS finding (no event surface for own-key save) per scoping v0.2 §3.1 / §5.7.

12.1 Step 0 inspection

Before implementing, CC confirms:

12.2 Hook design — single-purpose, application-level

Per scoping v0.2 §7.9, the hook is a single-purpose application-level hook, not a generic event bus. Reasoning: matches alpha-in-process pattern; avoids over-engineering; the only consumer for the foreseeable future is conversion detection itself; if a second consumer arrives (telemetry, audit, etc.), the hook can be generalized then.

Implementation shape (illustrative):


# In src/loomworks/api_keys/hooks.py (new module)

_on_api_key_saved_subscribers: list[OnApiKeySavedHandler] = []

OnApiKeySavedHandler = Callable[
    [UUID, str, AsyncSession],  # person_id, scope ("operator" | "engagement"), db
    Awaitable[None],
]


def register_on_api_key_saved(handler: OnApiKeySavedHandler) -> None:
    """Register a handler. Called at module-import time by the observer."""
    _on_api_key_saved_subscribers.append(handler)


async def emit_api_key_saved(person_id: UUID, scope: str, db: AsyncSession) -> None:
    """Called inside set_api_key after the key writes successfully but before
    set_api_key returns. Fires all registered handlers in sequence; handler
    failures log at error level but do not abort set_api_key."""
    for handler in _on_api_key_saved_subscribers:
        try:
            await handler(person_id, scope, db)
        except Exception:
            logger.exception("on_api_key_saved handler failed: %s", handler)

set_api_key (in api_keys/store.py) is amended to call emit_api_key_saved(person_id, scope, db) at the documented point. The amendment is small and additive; existing callers see unchanged behavior.

Why not the notifications event bus. Per scoping v0.2 §3.1: the notifications bus is "person-keyed" (intended for surfacing notifications to a specific person). Conversion detection isn't surfacing to the saving person; it's writing a credit to a different person (the referrer). Different shape; different lifecycle. Co-locating in the notifications module would silo around an irrelevant constraint.

12.3 Observer module

New module: src/loomworks/credit/conversion_observer.py.


# Module-level registration at import time

from loomworks.api_keys.hooks import register_on_api_key_saved


async def _on_api_key_saved_for_conversion(
    person_id: UUID, scope: str, db: AsyncSession
) -> None:
    """Conversion-detection observer.

    When a referred person saves their own API key (operator-scope), check
    whether they have an unclaimed referrer reward, and fire one if so.
    """
    if scope != "operator":
        # Engagement-scope keys don't trigger conversion (per scoping v0.7 / §3.1)
        return

    person = await db.get(PersonRow, person_id)
    if person is None:
        return
    if person.referred_by is None:
        return  # not referred — no conversion to detect

    # Idempotency: check whether a conversion credit has already been written
    # for this person to this referrer. The per-referee uniqueness constraint
    # is enforced via a flow metadata key.
    if await conversion_credit_already_written(db, person_id):
        return

    # Read the referee's most recent claimed grant to determine the asset
    # (default-mirror policy per P48-D2)
    grant = await get_most_recent_claimed_grant(db, person_id)
    if grant is None:
        # Edge case: person has referred_by set but no claimed grant — they
        # were created through some non-claim path and were attached to a
        # referrer via another mechanism. Skip; not a Phase 48 case.
        logger.warning(
            "conversion_observer: person %s has referred_by but no claimed grant",
            person_id,
        )
        return

    referral_amount = await read_system_config_int("referral_credit_amount", default=10000)

    seam = get_credit_seam()
    await seam.write_referral_credit_flow(
        referrer_id=person.referred_by,
        asset_id=grant.asset_id,
        amount=referral_amount,
        metadata={
            "reason": "conversion_detected",
            "converted_person_id": str(person_id),
            "source_grant_id": str(grant.id),
        },
    )


register_on_api_key_saved(_on_api_key_saved_for_conversion)

12.4 Idempotency check

conversion_credit_already_written(db, person_id) queries credit.foray_action_flows for any flow with metadata->>'reason' = 'conversion_detected' and metadata->>'converted_person_id' = '<person_id>'. If any row exists, the conversion has already been credited and the observer skips.

Why a metadata-based check rather than a column. The conversion is one specific flow among many in the same table; adding a column dedicated to "this is the conversion flow" is over-specific. The metadata key is queryable, indexable if needed, and matches the existing FORAY-metadata pattern.

[CC verifies that querying metadata->>'key' = 'value' performs adequately at alpha volume. If indexing is required, add a partial index in Migration 0063: CREATE INDEX idx_conversion_flows ON credit.foray_action_flows ((metadata->>'reason')) WHERE metadata->>'reason' = 'conversion_detected';. Default: don't add the index unless pre-flight or initial implementation reveals a performance concern.]

12.5 seam.write_referral_credit_flow — wiring the existing stub

Per Phase 47 implementation notes §5 carry-forward item 1: write_referral_credit_flow is a stub; nobody calls it in Phase 47. Phase 48 build step 7 is the first caller.

The seam method's existing signature (per Phase 47 §9 / impl notes step 5) is [CC verifies]. If the existing signature doesn't match the call shape in §12.3, build step 7 amends the seam — but the amendment must be backward-compatible (no-op for existing-test callers, since there are none in Phase 47).

12.6 Observer module import side effect

The observer registers on import. The module must be imported at engine startup so the registration happens. [CC verifies the existing module-import-at-startup pattern; if a discovery mechanism exists (pkgutil.walk_packages or explicit imports list), add loomworks.credit.conversion_observer; if not, add an explicit import in the lifespan startup hook.]

12.7 Halt threshold for build step 7

set_api_key cannot be amended additively without breaking >5 existing tests. CC halts and surfaces.

Or: the seam's write_referral_credit_flow requires a non-backward-compatible signature change. CC halts and surfaces.

12.8 Tests (~15)

Per §15.7.


13. Build step 8 — Operator Layer frontend — production passkey sign-in

What lands. A new (sixth) frontend surface in DUNIN7/loomworks that did not exist in Phase 46: a full WebAuthn passkey production sign-in flow with embedded reactivation surface at the post-verify/pre-cookie gate. Org-SSO/passwordless explicitly out of scope.

13.1 Step 0 inspection

Before implementing, CC confirms:

13.2 Sign-in surface — flow

The sign-in page is at /signin in the Operator Layer. The flow:

  1. Landing. The /signin page loads. Vertical-lockup ceremonial layout (consistent with the existing landing/welcome treatment per current-status manifest §1). Header lede: "Welcome back." Two-step UI shell, but only one step is visible at a time (per the design system standard from current-status manifest's "DUNIN7 standard: Step 1 passkey or org SSO").
  1. Step 1 — Identifier. A single email field. The user types their email, submits.
  1. Server call — sign-in begin. Frontend calls POST {engine}/auth/signin/begin with { email }. Server returns either:
  1. Step 2 — Passkey. Frontend invokes the WebAuthn API with the challenge. User presents their passkey (Touch ID, hardware key, etc.). Browser returns the credential.
  1. Server call — sign-in passkey. Frontend posts the credential to POST {engine}/auth/signin/passkey. Server validates the assertion and:
  1. Step 3 — TOTP (if enrolled). If the engine's response indicates TOTP is required (existing Phase 14 / Phase 47 path), frontend presents the TOTP input. Submission goes through /auth/signin/totp-verify (or whatever the live name is — [CC verifies]). Same account_status branching as step 5.

13.3 Reactivation surface

When the response from step 5 (or step 6) indicates account_status='suspended', frontend transitions to the reactivation surface in-place (no redirect — the session is not established yet, so a route change is risky).

The reactivation surface presents:

13.4 Post-verify gate insertion

The reactivation surface relies on the engine's post-verify/pre-cookie gate (pre-flight item 14). The engine's auth/signin/passkey (and auth/signin/totp-verify, if applicable) must:

  1. Verify the credential.
  2. Look up the persons row.
  3. Check account_status.
  4. Branch:

If the engine routes don't already check account_status at this gate, build step 8 amends them to do so. [CC verifies the gate's current state and amends if needed.]

13.5 Module structure (frontend)

New routes and components in DUNIN7/loomworks:

| Path | Type | |---|---| | app/signin/page.tsx (or equivalent — [CC verifies the routing convention]) | Sign-in page | | app/signin/components/IdentifierStep.tsx | Step 1 UI | | app/signin/components/PasskeyStep.tsx | Step 2 UI | | app/signin/components/TotpStep.tsx | Step 3 UI | | app/signin/components/ReactivationCard.tsx | Reactivation surface | | lib/auth/signin-flow.ts | Sign-in flow state machine and engine client calls | | lib/auth/webauthn-client.ts | WebAuthn helper (if not already present) | | lib/auth/account-status.ts | Account-status type and branching helpers |

13.6 Coexistence with Phase 46 dev-auth

The Phase 46 dev-auth page can remain for local development. A routing flag or env var (NEXT_PUBLIC_AUTH_MODE=production vs dev) selects which surface is presented. Production deployments use production; local dev defaults to dev but can be overridden. [CC verifies the existing env-var / build-flag pattern.]

13.7 Halt-and-amend trigger — engine sign-in routes absent

Per scoping handoff §8 and pre-flight item 13: if the engine has no sign-in routes (only signup), build step 8 expands to include adding them. The expansion is meaningful (server-side WebAuthn signature verification, session establishment, account-status gate) and pushes the build estimate up materially.

If pre-flight reveals this state, CC halts and surfaces a finding to the Operator. The Operator decides:

Default disposition: amend the CR, because the engine work is substrate, not Operator Layer, and conflating the two breaks the tag-cleanness expected at Checkpoint B.

13.8 Halt threshold for build step 8 (other)

Frontend tests touching pre-existing Phase 46 dev-auth surface that exceed >10 (the Phase 46 surface itself) — i.e., the dev-auth page can't be left isolated. CC halts and surfaces; the Operator decides whether to deprecate the dev-auth page entirely in Phase 48.

13.9 Tests (~15 frontend; possibly additional engine tests if §13.7 amend path)

Per §15.8.


14. Build step 9 — Operator Layer frontend — five integration surfaces

What lands. Five additional frontend surfaces in DUNIN7/loomworks, all of which assume an authenticated session (build step 8) and add UI:

  1. Claim landing page (consumes claim_url from POST /admin/grants or POST /claim/grant)
  2. Balance display (header chip)
  3. Tier badge (chrome)
  4. Reactivation flow (already in build step 8; this is just the chrome that links into it, e.g., a settings-page reactivation banner — possibly a no-op in Phase 48 since reactivation is post-verify-gated)
  5. Admin grants UI (Operator-only page replacing manual curl invocation of POST /admin/grants)

14.1 Step 0 inspection

Before implementing, CC confirms:

14.2 Surface 1 — Claim landing page

Path. /claim in DUNIN7/loomworks.

Flow.

  1. Page loads with ?token=<claim_token> query parameter (the URL the operator sent, composed by POST /admin/grants as {webauthn_rp_origin}/claim?token=<token>).
  2. Frontend extracts the token and calls POST {engine}/claim/grant with { claim_token }.
  3. Engine returns the single-stage passkey-bound claim payload per Phase 47 §17.2: { claim_token, registration_id, masked_email_hint, asset, amount, expires, webauthn_options }.
  4. Frontend presents a summary: "{masked_email_hint}, you've been granted {amount} {asset_id} credits. Set up your passkey to claim them."
  5. User invokes WebAuthn (via the existing helper from build step 8).
  6. Frontend posts the credential to /auth/signup/passkey, then proceeds to /auth/signup/totp-verify with the threaded claim_token per Phase 47 §16.
  7. On success, the session cookie is established; frontend redirects to the dashboard with a "Welcome — your credits are active" banner.

Module structure. New components under app/claim/ mirroring the build step 8 structure. The existing build step 8 helpers (webauthn-client.ts) are reused.

14.3 Surface 2 — Balance display (header chip)

A small UI element in the dashboard header showing the current Operator's resolved tier balance. Displays:

Data source. GET /me/credits (build step 1).

Refresh policy. Polled on dashboard load; no SSE updates in Phase 48 (real-time balance updates are future).

Empty state. When resolved_tier is null (all credits exhausted), the chip reads "No credits" and links to … nowhere in Phase 48 (the linked-to-flow is exhaustion choice presentation, which is Phase 49).

14.4 Surface 3 — Tier badge (chrome)

A small badge near the Operator's name/avatar showing the license_tier value: "Trial", "Maker", "Pro", "Team". Different visual treatment per tier. [CC verifies the available license_tier values from Phase 47; the tier-mapping for visual treatment is [CC determines] against the brand guide.]

Data source. GET /me/credits (which carries license_tier) or GET /me, whichever already exists.

14.5 Surface 4 — Reactivation flow (linking chrome)

Per §13.3, the reactivation surface is part of the sign-in flow itself. Build step 9 adds links into the sign-in flow from anywhere a suspended-but-still-cookied session somehow appears (which shouldn't happen if step 8's gate is correctly placed, but cross-tab session changes can produce this state).

Default implementation: No additional chrome in build step 9. The reactivation surface is reached only through the sign-in flow. If pre-flight or initial implementation reveals a session-state edge case where reactivation links should appear elsewhere (e.g., if a person's account is suspended during an active session), build step 9 adds a banner. [CC may decline to ship this surface in Phase 48 if the edge case is judged not present; document at Checkpoint B.]

14.6 Surface 5 — Admin grants UI

Path. /admin/grants in DUNIN7/loomworks.

Auth. Same session-authenticated person check as the existing engine POST /admin/grants (per pre-flight item 11). The Operator Layer's admin gate may also need to be implemented in this step — [CC verifies] whether an admin-route guard exists in the frontend (likely not, in alpha).

UI.

14.7 Module structure (frontend, surfaces 1–5)

New routes and components in DUNIN7/loomworks:

| Path | Type | |---|---| | app/claim/page.tsx | Surface 1 | | app/claim/components/ClaimSummary.tsx | Surface 1 sub-component | | app/(dashboard)/components/BalanceChip.tsx | Surface 2 | | app/(dashboard)/components/TierBadge.tsx | Surface 3 | | app/admin/grants/page.tsx | Surface 5 | | app/admin/grants/components/GrantForm.tsx | Surface 5 sub-component | | app/admin/grants/components/GrantResult.tsx | Surface 5 sub-component | | lib/api/me-credits.ts | Client for GET /me/credits (used by surfaces 2, 3) | | lib/api/admin-grants.ts | Client for POST /admin/grants (used by surface 5) | | lib/api/claim-grant.ts | Client for POST /claim/grant (used by surface 1) |

14.8 Halt threshold for build step 9

Pre-existing dashboard components require restructuring beyond the additive chrome of surfaces 2 and 3 (e.g., header layout has no slot for a chip and adding one breaks responsive layout). CC halts and surfaces.

Or: an existing endpoint that surface 5 needs (e.g., grant-list view) doesn't exist and adding it expands the substrate work beyond the build step's frontend scope. CC halts and surfaces; the Operator decides whether to add a substrate amendment or defer the missing piece.

14.9 Tests (~15)

Per §15.9.


15. Tests by build step

Approximate counts and coverage per step. Phase 48 target: ~75 backend + ~30 frontend = ~105 new tests, no new skips, per scoping v0.2 §9.

15.1 Build step 1 — GET /me/credits (~5 tests)

15.2 Build step 2 — Phase-31 routing extension (~7 tests)

15.3 Build step 3 — Engagement bootstrap (~15 tests)

15.4 Build step 4 — SMTP plumbing (~5 tests)

15.5 Build step 5 — Suspension/deletion evaluator (~15 tests)

15.6 Build step 6 — Reconciliation evaluator (~15 tests)

15.7 Build step 7 — Conversion detection (with hook + observer) (~15 tests)

15.8 Build step 8 — Production passkey sign-in (~15 frontend tests)

If §13.7 amend path is taken, additional engine-side tests (~10) for the new sign-in routes — these are not counted in the ~30 frontend total.

15.9 Build step 9 — Five integration surfaces (~15 frontend tests)

15.10 Test posture progression

Phase 48 follows the Phase 47 shape — green-with-permissive-fixture (early steps) to green-with-real-credit-seam (final steps) where applicable. Specifically:

Expected post-CR substrate total: ~1,941 backend tests passed, 26 skipped (no new skips), Alembic 0063. Expected post-CR Operator Layer total: ~93 vitest tests passed, eslint/tsc/build clean.


16. Order of operations

Eighteen steps. Auto-mode posture: Steps 0–16 auto-mode-proceed. Two checkpoints — Checkpoint A at the end of substrate work (Step 14), Checkpoint B at the end of frontend work (Step 16). Step 17 is the tag, executed only after Operator approval at Checkpoint B.

| Step | What | Posture | |------|------|---------| | 0 | Pre-flight per §3.2 (twenty-two items). Archive CR per §3.3. | Auto | | 1 | Migration 0063: credit.evaluator_state table, twelve system_config keys (Fernet-encrypted at upgrade), seeded evaluator-state rows. Verify upgrade/downgrade cycle clean. Existing 1,866 tests pass. | Auto | | 2 | Build step 1 — GET /me/credits. Tests per §15.1. | Auto | | 3 | Build step 2 — Phase-31 _call_llm routing extension. Tests per §15.2. | Auto | | 4 | Build step 3 — Engagement bootstrap (Credit Management + Accounting). Tests per §15.3. | Auto | | 5 | Build step 4 — SMTP plumbing. Tests per §15.4. | Auto | | 6 | Build step 5 — Suspension/deletion evaluator. Tests per §15.5. | Auto | | 7 | Build step 6 — Reconciliation evaluator. Tests per §15.6. | Auto | | 8 | Build step 7 — Conversion detection wiring (hook + observer). Tests per §15.7. | Auto | | 9 | Run full substrate test sweep. Verify ~1,941 passing, 26 skipped, Alembic 0063, all existing tests pass unchanged. | Auto | | 10 | _(reserved — buffer for amendments arising from steps 1–9)_ | Auto | | 11 | _(reserved — buffer)_ | Auto | | 12 | _(reserved — buffer)_ | Auto | | 13 | _(reserved — buffer)_ | Auto | | 14 | Substrate done. Implementation notes drafted at docs/phase-impl-notes/phase-48-implementation-notes-v0_1.md for the substrate half. | Auto | | A | Checkpoint A — Substrate evaluation. Operator confirms substrate work, test counts, no regressions. | Checkpoint | | 15 | Build step 8 — Production passkey sign-in (Operator Layer). If §13.7 amend path: substrate sign-in routes added first (this step expands). Tests per §15.8. | Auto | | 16 | Build step 9 — Five integration surfaces (Operator Layer). Tests per §15.9. Frontend test sweep: ~93 vitest passing, eslint/tsc/build clean. Implementation notes extended for the frontend half. | Auto | | B | Checkpoint B — Final. Operator confirms frontend work and the full phase. | Checkpoint | | 17 | Tag phase-48-credit-completion-and-operator-signin on both repos. Push tags. | Auto (post-checkpoint) |

Why two checkpoints (vs. Phase 47's one). Phase 47 was substrate-only; one checkpoint sufficed. Phase 48 spans both repos; the substrate half can be evaluated and (if necessary) corrected before the frontend half begins. This matches the precedent set by Phase 14 (person layer) and Phase 19 (organized manifestation), which both used two-checkpoint structures for cross-repo phases.

16.1 Halt conditions during Steps 1–16

CC halts and surfaces an amendment if:

16.2 Implementation notes

After Checkpoint A (and again after Checkpoint B), CC writes implementation notes at:


docs/phase-impl-notes/phase-48-implementation-notes-v0_1.md

Notes capture (per the Phase 47 precedent): pre-flight summary (twenty-two items, dispositions), build summary by step (commit-by-commit), determinations made during construction (the Phase 47 §3 / impl notes §3 style), test posture progression, carry-forward to Phase 49, acceptance-gate walkthrough.


17. Acceptance gate

Twenty items. All must pass for Checkpoint B.

Substrate (Checkpoint A):

  1. Migration 0063 applies cleanly from the phase-47-credit-substrate-foundation baseline.
  2. alembic downgrade -1 and alembic upgrade head round-trip cleanly.
  3. The credit.evaluator_state table exists with the columns specified in §5.1, with two seeded rows.
  4. Twelve new system_config keys exist (Fernet-encrypted), per §5.2.
  5. GET /me/credits returns the documented response shape for all six person states (active multi-credit, active single-credit, exhausted, suspended, deleted, no-credits).
  6. Phase-31 _call_llm routing extension threads model= and fires schedule_consumption_recording correctly for system-key turns; own-key turns unchanged.
  7. Credit Management Engagement and Accounting Engagement exist as Loomworks Engagement objects with four-room structure, deterministic UUIDs, system-principal Operator, and minimal seeds per §8.3.
  8. SMTP layer sends test emails through the configured provider (or fake provider in tests); failure modes log without raising.
  9. Suspension/deletion evaluator runs on configured cadence; deletion-warning scan and deletion-execution scan both work; deduplication prevents double-warn.
  10. Reconciliation evaluator runs on configured cadence; surfaces correction proposals into Accounting Engagement Manifestation room; idempotent across runs.
  11. Conversion detection: own-key save by referred person fires seam.write_referral_credit_flow; idempotent on re-save; engagement-scope keys do not trigger.
  12. Substrate test count: ~1,941 passing, 26 skipped (no new skips), Alembic 0063.

Frontend (Checkpoint B):

  1. Production passkey sign-in flow at /signin works end-to-end for active, exhausted, suspended, and deleted accounts.
  2. Reactivation surface presents in-place when sign-in encounters a suspended account; reactivation succeeds and redirects to dashboard.
  3. Claim landing page at /claim?token=X consumes the token, drives the passkey enrollment, and lands the claimed person on the dashboard with credits active.
  4. Balance chip on dashboard shows the current resolved tier and balance; updates on dashboard reload.
  5. Tier badge on dashboard chrome shows the current license_tier with appropriate visual treatment.
  6. Admin grants page at /admin/grants allows the Operator to issue grants, surfaces the claim URL, copies to clipboard, sends the email when send_email=true.
  7. Operator Layer test count: ~93 vitest passing, eslint/tsc/build clean.

Both repos:

  1. Both repos tagged phase-48-credit-completion-and-operator-signin after Checkpoint B approval. No regressions in either repo's existing test suites.

18. Carry-forward to Phase 49

Items intentionally not built in Phase 48, recorded for Phase 49 scoping. This list mirrors and refines the original eight-item carry-forward (Phase 47 → Phase 48); items that close in Phase 48 are removed from the carry-forward; items that remain or arise are listed.

  1. Companion exhaustion-choice voice and surface. Phase 47 ships structural CreditExhaustedError; Phase 48 ships the balance-display/tier-badge chrome. Phase 49 ships the warm Companion-voiced exhaustion message and the three-choice dialog (add own key → Maker / suspend → 21-day hold / delete → anonymize now).
  2. Companion-as-Authority decision logic for grants. Phase 49 builds the Companion behavior that reads Memory assertions (model profiles, campaign data, referral policy) and proposes grant decisions for Operator approval. The two Engagements ship in Phase 48; their Memory contents land in Phase 49.
  3. Public credit-request form. POST /authority/grant-request endpoint plus marketing-site coordination. External dependency (the marketing site itself).
  4. Near-exhaustion warning. Phase 49 builds the threshold + voice for "you have N credits remaining" warnings, integrated with the Companion's suitability-aware voice.
  5. Tier-drop voice. When sonnet credits exhaust and the user drops to haiku, the Companion acknowledges the transition. Phase 49 work.
  6. Model profile assertions in Credit Management Memory. The Companion's code-creation advice and exhaustion messaging both depend on these.
  7. Exhaustion-choice settings UI in Operator Layer. A settings page where the Operator can pre-decide their exhaustion choice (rather than being prompted at exhaustion). Phase 49 frontend.
  8. Operator override path for conversion-credit asset_id. Phase 48 ships default-mirror only; Phase 49 adds the Credit Management Memory-driven override.
  9. Org-SSO / passwordless production sign-in. Queued as future, not Phase 49. Tracked in loomworks-queued-directions-and-deferred-work-v0_2.md.
  10. Factored PeriodicTask abstraction. Currently each evaluator clones the Phase 44 template inline. A future refactor consolidates the three evaluators (Phase 44 plus the two Phase 48 evaluators) onto a shared abstraction. Tracked as deferred work, not Phase 49.

19. CC kickoff prompt — paste-ready


Read the Change Request document at the path I supply below. This is
CR-2026-063 v0.1, the Phase 48 Change Request. You are the executing
agent named in the CR.

CR path: ~/Downloads/phase-48-cr-credit-completion-and-operator-sign-in-v0_1.md

v0.1 drafts against the Phase 48 scoping note v0.2 (which absorbed
phase-48-step-0-findings-v0_1.md) and the drafting handoff v0.1.
The CR specifies architectural decisions firmly; substrate field
names, function signatures, table names, model identifiers, and
route paths that have not been verified are marked with [CC verifies].

Code baseline: tag phase-47-credit-substrate-foundation on
loomworks-engine. Substrate: 1,866 tests, 26 skipped, Alembic 0062.
Operator Layer: 63 vitest, 18 files, 7 prerendered routes,
eslint/tsc/build clean. Workshop: unchanged in Phase 48.

Run pre-flight (Step 0) per CR §3.2 (twenty-two items). The single
highest-probability halt trigger is item 13 (engine sign-in routes
absent). If hit, halt and surface — do not absorb silently.

Per CR §3.3: archive this CR to
docs/phase-crs/phase-48-cr-credit-completion-and-operator-sign-in-v0_1.md
in the engine repo at Step 0 before Step 1 begins.

Per CR §16, eighteen steps with two checkpoints:
  - Steps 0–14 substrate, then Checkpoint A.
  - Steps 15–16 frontend, then Checkpoint B (final).
  - Step 17 tags both repos.

Auto-mode posture: Steps 0–16 auto-mode-proceed; Checkpoints A and
B halt for Operator confirmation. Step 17 (tag) executes after
Operator approval at Checkpoint B.

Halt conditions per CR §16.1:
  - Architectural divergence at pre-flight (not just naming).
  - Required mechanism missing (pre-flight items 6, 13, 16).
  - Migration 0063 too large to land safely.
  - SMTP misroutes during testing → narrow the path.
  - Any build step's halt threshold trips (§§6.5, 7.4, 8.8, 9.9,
    10.8, 11.9, 12.7, 13.8/9, 14.8).
  - Existing test refactor at any step touches > ~30 tests.
  - §13.7 engine-sign-in-routes-absent finding exceeds the v0.2
    scoping estimate materially.

Pre-flight surprises that change the architecture stop execution at
Step 0 and drive a CR amendment; do not proceed through divergence.
Naming-only divergences are resolved by re-deriving from ground
truth and continuing.

Implementation notes after Checkpoint A (substrate half) and again
after Checkpoint B (frontend half):
docs/phase-impl-notes/phase-48-implementation-notes-v0_1.md

Tag at Step 17 (post-Checkpoint-B):
phase-48-credit-completion-and-operator-signin on both repos.

DUNIN7 — Done In Seven LLC — Miami, Florida Phase 48: Credit Completion and Operator Sign-In — CR v0.1 — 2026-05-07