DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path phases/phase-47-credit-substrate-foundation/phase-47-cr-credit-substrate-foundation-v0_1.md

Phase 47 — Credit Substrate Foundation — Change Request

Version. 0.1 Date. 2026-05-07 CR identifier. CR-2026-062 (verify against the registry at Step 0; advance to next available number if 062 is taken) Phase. 47 — Credit Schema Foundation + Grant Substrate + Account Lifecycle Columns Repos affected. DUNIN7/loomworks-engine (substrate only) Tag at completion. phase-47-credit-substrate-foundation on loomworks-engine


1. Purpose

Phase 47 builds the substrate that supports paid Loomworks. It creates a credit schema co-located in the engine database, the FORAY-attested flow + balance machinery for per-credit-type accounting, the grant + email-registry mechanism for issuance-time abuse boundary, the account lifecycle columns (active / exhausted / suspended / deleted) on the person table, and the integration seam through which the converse pipeline routes models and records consumption. It also adds two endpoints: POST /claim/grant for grant claim with email verification, and POST /admin/grants for Operator-curated grant issuance.

Phase 47 is substrate only. The public credit-request form, SMTP email delivery, the evaluator-driven suspension/deletion scan, and the Companion's intelligence around grant decisions and exhaustion presentation are all Phase 48. The Phase 47 alpha path for grant delivery is manual: the Operator hits POST /admin/grants, copies the returned claim URL, and emails it to the recipient by hand.

This CR is drawn from loomworks-phase-47-scoping-note-v0_3.md, which itself implements loomworks-invitation-credit-referral-scoping-note-v0_7.md. All seventeen scoping decisions D1–D17 are settled; this CR is execution.


2. Scope

2.1 In scope

  1. Migration 0062. Single Alembic migration creating the credit schema, five credit.* tables, the update_balance_on_flow function, the trg_balance_update trigger, five new columns on the person table, system_config seeds, and oracle-rate seeds.
  1. **Five credit.* tables.** foray_action_flows, asset_balances, credit_grant, email_grant_registry, oracle_rate_config.
  1. Trigger function credit.update_balance_on_flow(). UPSERT pattern, atomic with each flow insert. Increments to_party balance; decrements from_party balance.
  1. Email hashing functions. Two-form hashing: lowercase-trim form and aggressively normalized form (Gmail +plus-addressing stripped, Gmail dots-in-local-part stripped).
  1. Email eligibility check. Returns ELIGIBLE_NEW | ELIGIBLE_COOLED | INELIGIBLE_RECENT | INELIGIBLE_DELETED based on email_grant_registry lookup.
  1. Balance and oracle functions. Single-row PK lookup; multi-credit-type resolution (opus > sonnet > haiku); per-credit-type token-to-credit conversion.
  1. Flow write functions. Credit issuance flow; per-turn multi-flow consumption recording; lifecycle FORAY flow writes (status transitions, balance zeroing on deletion); referral credit stub for Phase 48 wiring.
  1. Grant functions. issue_grant (eligibility + 64-char opaque token + registry registration + grant row, all transactional). claim_grant (token verify + expiry check + email match + flow write + status updates, all transactional with SELECT … FOR UPDATE on the grant row).
  1. Account lifecycle functions. suspend_account, reactivate_account, delete_account. Direct callable via the seam in Phase 47; evaluator-triggered in Phase 48.
  1. Integration seam. Eight functions exposed via an abstract interface plus an in-process Python implementation backed by direct database calls.
  1. Model routing in converse pipeline. Classifier always uses converse_classifier_model from config. Responder uses the model identified by the resolved credit type (system-key path) or converse_default_responder_model (own-key path).
  1. Consumption recording hook. Background task fired after system-key converse turns. Writes the multi-flow FORAY transaction; trigger updates balances.
  1. Key resolution tier 3. Multi-credit-type resolution + model selection + account_status check. Distinct error classes for CreditExhaustedError, AccountSuspendedError, AccountDeletedError.
  1. Signup endpoint modification. Replaces freeform-code path with claim-token path. Validate token via seam, verify email match, create person record (or attach grant to existing person), record referred_by if grant_kind = 'referrer_initiated', write credit issuance flow. All in one transaction.
  1. POST /claim/grant endpoint. Public endpoint. Token + email verification flow.
  1. POST /admin/grants endpoint. Operator-only. Body: recipient_email, asset_id, amount, optional campaign_ref, optional grant_kind. Returns: claim_token + claim URL.

2.2 Out of scope (Phase 48+)

2.3 Out of scope (future)


3. Prerequisites

3.1 Baseline

If the live baseline diverges (test count off by more than the routine ±2 noise, Alembic head different, working tree dirty), CC stops at Step 0 and reports before proceeding.

3.2 Step 0 — pre-flight against the live codebase

CC reads the actual codebase before writing any code. The CR specifies architectural decisions firmly; substrate names that have not been verified against ground truth are marked [CC verifies] throughout this CR. If pre-flight finds divergence that changes the architecture (not just naming), CC halts and produces an amendment proposal.

Sixteen pre-flight items:

  1. Substrate baseline confirmed. Tag phase-46-conversation-history checked out. pytest -q reports 1,781 passed, 26 skipped. alembic heads reports 0061.
  1. person table location and existing columns. [CC verifies model module path; expected something like src/loomworks/types/person.py or src/loomworks/models/person.py.] Confirm: companion_name TEXT NOT NULL DEFAULT 'Companion' (Phase 41), personal_engagement_id FK (Phase 41), no existing referred_by, license_tier, account_status, expires_at, or previous_status_change_at columns.
  1. system_config table and current key set. [CC verifies module + column names.] Confirm absence of 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 keys.
  1. Three-tier key resolution function. [CC verifies the function name and module path; expected to return a key + identifies which tier resolved.] Tier 1 = engagement-scoped key; tier 2 = person-scoped key; tier 3 = system key. Phase 47 modifies the tier-3 path to add credit-type resolution + account-status check.
  1. Converse pipeline LLM call sites. [CC verifies the file/function where the classifier is invoked and where the responder is invoked.] These are the two sites where the model identifier is currently selected. Phase 47 modifies both to read from config / credit resolution.
  1. Token usage contextvar buffer. [CC verifies module path and the structure of the captured records — pipeline_stage label, input/output token counts.] Confirmed present after the post-Phase-46 token-usage fix. Phase 47 extends the post-turn handler to map (pipeline_stage, selected_credit_type)asset_id pair when writing flows; the buffer itself is unchanged.
  1. ConverseResponse.token_usage field. [CC verifies schema location.] Frontend already reads this; Phase 47 does not change its shape.
  1. Signup endpoint location and flow. [CC verifies route path and handler module.] Phase 47 replaces the (currently freeform / open-trial / however it is today) signup path with a claim-token-based path. The pre-flight identifies the existing handler and any auth dependencies attached to it.
  1. Self-service signup test pattern. [CC verifies how existing signup tests authenticate / mock person creation.] New tests in Phase 47 follow the same pattern.
  1. Admin auth pattern. [CC verifies how admin-only endpoints are gated today.] Phase 47's POST /admin/grants reuses whatever pattern is current. If no admin-auth pattern exists yet, CC halts and the CR amends to specify one.
  1. Email verification mechanism. [CC verifies whether magic-link or passkey enrollment exists today.] Phase 47 binds claim email-verification to one of these. If neither exists, CC halts and reports — the claim endpoint cannot ship without an email-binding mechanism. (Reasonable amendment paths: magic-link-only minimum, deferred to Phase 48; or passkey-enrollment-only.)
  1. Existing FORAY attestation hooks. [CC verifies module path and how Memory events are currently attested.] Phase 47's FORAY flow writes to credit.foray_action_flows follow the same attestation pattern.
  1. Background task / fire-and-forget pattern. [CC verifies how Phase 44's background tasks are registered in the FastAPI lifespan; Phase 47's consumption-recording hook follows the same pattern.]
  1. Alembic next migration number. [CC verifies that 0062 is next; renumber if intervening migrations have landed.] Migration is written manually; do not autogenerate.
  1. **No existing credit.* schema.** [CC verifies via \dn and \dt credit.* that no credit schema exists in any test database fixture or fresh-build path.] If one exists, CC halts and reports.
  1. No existing /claim/ or /admin/grants route. [CC verifies via the route table that these paths are unclaimed.]

Pre-flight halt protocol. If any of items 2–16 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 (the function exists at a different path; the column has a slightly different name) are resolved by CC re-deriving the name from ground truth and continuing — that is the entire point of the [CC verifies] discipline.

3.3 CR archival

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


docs/phase-crs/phase-47-cr-credit-substrate-foundation-v0_1.md

in the substrate repo.


4. Settled decisions (consumed from credit scoping v0.7 + Phase 47 scoping v0.3)

| ID | Decision | Phase 47 implementation | |----|----------|------------------------| | D1 | Trial credit amount: 10,000 per grant (admin-settable per grant) | credit_amount column on credit.credit_grant; admin endpoint sets per-grant value; default seeded as default_grant_amount_per_credit_type system_config | | D2 | Conversion credit to referrer: 10,000 | system_config.referral_credit_amount; flow-write function exists but unwired in Phase 47 | | D3 | Oracle rates per-credit-type, Anthropic-mirrored | Migration 0062 seeds credit.oracle_rate_config | | D4 | Grant token format: opaque 64-char | Generation function in seam; stored on credit.credit_grant.claim_token | | D5 | ~~Signup gate config flag~~ — obsolete | Trial path is grant-claim only; no require_invitation_code flag | | D6 | Three-choice exhaustion (add-key / suspend / delete) | Structural account_status states; choice presentation is Phase 48 | | D7 | Integration seam: in-process | Direct DB calls behind an abstract interface | | D8 | Referrer rate limit: 5 per 30 days | Seeded as system_config policy; enforcement is Phase 48 | | D9 | License tier pricing | person.license_tier column; gating is Phase 48+ | | D10 | Credit data location: engine DB, credit schema | Single database, schema-qualified | | D11 | Migration management: single Alembic chain | Migration 0062 in the existing chain | | D12 | Consumption recording: fire-and-forget | Background task after system-key turns | | D13 | Email hashing: SHA-256 of (a) lowercase-trim and (b) normalized | Both columns on credit.email_grant_registry | | D14 | Suspension period: 21 days | Default expires_at offset for suspended status; admin-adjustable via system_config | | D15 | Deletion warning: 24 hours | Schema supports it (previous_status_change_at); evaluator scan is Phase 48 | | D16 | Account state machine: active / exhausted / suspended / deleted | account_status column with allowed values | | D17 | Companion authority for form-initiated grants | Phase 48 — substrate doesn't constrain decision logic |


5. Migration 0062 — credit schema and person additions

Single migration. Manually written, not autogenerated. Schema-qualified table names throughout. Forward (upgrade()) and reverse (downgrade()) both implemented. The reverse drops the credit schema with CASCADE and removes the five person columns.

5.1 Migration sections (in upgrade order)

  1. Create credit schema. CREATE SCHEMA credit;
  1. Create credit.foray_action_flows. Columns:
  1. Create credit.asset_balances. Columns:
  1. Create credit.credit_grant. Columns per credit scoping v0.7 §7.4:
  1. Create credit.email_grant_registry. Columns per credit scoping v0.7 §7.3:
  1. Create credit.oracle_rate_config. Columns:
  1. Create the trigger function and trigger. Exact PL/pgSQL per §6 below.
  1. Add five columns to person. Each as a separate op.add_column call:
  1. Seed system_config. Insert the following keys if absent (use ON CONFLICT DO NOTHING on the existing system_config PK; do not overwrite):
  1. Seed credit.oracle_rate_config. Per credit scoping v0.6 §3.3:

| credit_asset_id | provider_token_asset_id | rate_per_million | |---|---|---| | loomworks_credit_haiku | anthropic_haiku_4_input | 100 | | loomworks_credit_haiku | anthropic_haiku_4_output | 500 | | loomworks_credit_sonnet | anthropic_haiku_4_input | 100 | | loomworks_credit_sonnet | anthropic_haiku_4_output | 500 | | loomworks_credit_sonnet | anthropic_sonnet_4_input | 300 | | loomworks_credit_sonnet | anthropic_sonnet_4_output | 1500 | | loomworks_credit_opus | anthropic_haiku_4_input | 100 | | loomworks_credit_opus | anthropic_haiku_4_output | 500 | | loomworks_credit_opus | anthropic_opus_4_input | 1500 | | loomworks_credit_opus | anthropic_opus_4_output | 7500 |

The classifier-tokens-debited-against-the-spending-credit pattern (v0.6 §4.3) requires every credit type to have a rate row for the Haiku token assets — that's the per-turn classifier cost. Sonnet credits also need the Sonnet token-asset rates (responder); Opus credits also need Opus rates. Missing rate rows are a runtime error.

  1. Migration verification. After applying, the migration runs alembic upgrade head cleanly, the existing 1,781 tests pass without modification, and alembic downgrade -1 followed by alembic upgrade head round-trips cleanly.

5.2 Migration discipline


6. The trigger function and trigger

Exact PL/pgSQL per credit scoping v0.7 §5.2 / v0.6 §5.2:


CREATE OR REPLACE FUNCTION credit.update_balance_on_flow()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO credit.asset_balances (party_id, asset_id, balance, last_flow_id, updated_at)
  VALUES (NEW.to_party, NEW.asset_id, NEW.quantity, NEW.id, NOW())
  ON CONFLICT (party_id, asset_id)
  DO UPDATE SET
    balance = credit.asset_balances.balance + NEW.quantity,
    last_flow_id = NEW.id,
    updated_at = NOW();

  INSERT INTO credit.asset_balances (party_id, asset_id, balance, last_flow_id, updated_at)
  VALUES (NEW.from_party, NEW.asset_id, -NEW.quantity, NEW.id, NOW())
  ON CONFLICT (party_id, asset_id)
  DO UPDATE SET
    balance = credit.asset_balances.balance - NEW.quantity,
    last_flow_id = NEW.id,
    updated_at = NOW();

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_balance_update
AFTER INSERT ON credit.foray_action_flows
FOR EACH ROW EXECUTE FUNCTION credit.update_balance_on_flow();

6.1 Self-transfer behavior

If from_party = to_party (which should not occur in normal flow construction but might in tests), the two UPSERTs net to zero — the second UPSERT increments the row by -quantity after the first incremented by +quantity. Result: balance unchanged. This is correct behavior and is exercised by a unit test. Do not add WHERE NEW.from_party != NEW.to_party guards; the arithmetic is self-cancelling and the test asserts that.

6.2 Institutional-party rows

Flows like from = 'dunin7', to = 'person_alice' create an asset_balances row for 'dunin7' with negative balance. This is intentional — 'dunin7''s aggregate liability is exactly that negative balance. Tests exercise this.

6.3 Mandatory test coverage for the trigger function

(Step 15 §18.1.) Insert a flow with two real parties → two balance rows updated atomically. Insert a flow from 'dunin7' to a person → both rows created (institutional + person). Insert a self-transfer → balance unchanged. Insert two flows in a single transaction → all balance updates committed atomically; no partial state visible mid-transaction. Insert a flow with an asset_id no balance row exists for → row created at exactly the flow's signed quantity. Insert a flow with a pre-existing balance row → row updated, not duplicated.


7. Email hashing and registry

7.1 Hashing

Two functions, both pure (no DB access), in src/loomworks/credit/grants.py [CC verifies the module path against any pre-existing organization]:

The list of providers is documented in code comments. Adding new normalization rules is a future amendment; conservatism is the principle. Halt-and-amend trigger: if testing surfaces a normalization rule that breaks legitimate distinct users (a corporate domain treats + as significant), CC halts and requests an amendment narrowing the rule.

7.2 Eligibility check

Function: check_email_eligibility(email: str) -> EmailEligibility.

Returns one of four values (enum):

The cooling threshold is read from system_config.email_eligibility_cooling_days, which Phase 47 seeds at 180 and which the Operator can tune via direct DB update for alpha (no admin endpoint in this CR).

7.3 Registry registration

Function: register_email_grant(email: str, status: str) — called from issue_grant after the grant row writes. UPSERTs the credit.email_grant_registry row keyed by email_hash:

7.4 Registry status updates

claim_grant updates last_grant_status = 'claimed'. The Phase 48 evaluator updates 'expired' and 'deleted'. delete_account writes 'deleted' for the deleted person's email.


8. Balance and oracle functions

In src/loomworks/credit/balance.py [CC verifies module path] and src/loomworks/credit/oracle.py:

8.1 Balance query


def check_credit_balance(person_id: UUID, asset_id: str) -> int:
    """Single-row PK lookup against credit.asset_balances.
    Returns 0 if no row exists (not None — zero balance and missing row are equivalent)."""

8.2 Multi-credit-type resolution


def get_credit_balances(person_id: UUID) -> list[CreditBalance]:
    """Query credit.asset_balances for all rows where party_id = person_id and
    asset_id LIKE 'loomworks_credit_%'.
    Returns list of CreditBalance(asset_id, balance) ordered by tier:
    opus > sonnet > haiku. Empty list if no credit rows."""

def resolve_credit_model(person_id: UUID) -> CreditResolution | None:
    """Calls get_credit_balances. Returns highest-tier asset with balance > 0 as
    CreditResolution(asset_id, responder_model, balance). Returns None if all
    exhausted. The responder_model is derived from the asset_id:
      loomworks_credit_haiku  → 'claude-haiku-4-5-20251001'  (CC verifies exact model string)
      loomworks_credit_sonnet → 'claude-sonnet-4-20250514'   (CC verifies)
      loomworks_credit_opus   → 'claude-opus-4-6'            (CC verifies)
    The mapping is encoded as a constant dict in the module; the CR specifies the
    pairing, not the exact strings — CC reads strings from the same source the
    converse pipeline uses today."""

Test matrix (Step 15 §18.2): all combinations of {none, haiku-only, sonnet-only, opus-only, haiku+sonnet, haiku+opus, sonnet+opus, all-three} × {balance exactly zero, balance slightly negative, balance large positive}. Boundary: zero is not positive — a balance of exactly 0 does not satisfy > 0.

8.3 Oracle conversion


def convert_tokens_to_credit_debit(
    credit_asset_id: str,                # the credit type being spent
    provider_token_asset_id: str,        # e.g., "anthropic_sonnet_4_input"
    token_count: int,
) -> int:
    """Look up rate from credit.oracle_rate_config where
    credit_asset_id matches AND provider_token_asset_id matches AND superseded_at IS NULL.
    Compute (token_count * rate_per_million) / 1_000_000, rounded up to next integer.
    Raises if no rate row exists — that's a configuration error, not silent zero."""

The rounding-up behavior matters — partial credits never round to zero. Even one token of a metered model debits 1 credit minimum.


9. Flow write functions

In src/loomworks/credit/flows.py. All functions write to credit.foray_action_flows; the trigger handles balance updates.

9.1 Credit issuance


def write_issuance_flow(
    person_id: UUID,
    asset_id: str,
    amount: int,
    grant_id: UUID,
    metadata_extra: dict | None = None,
) -> UUID:  # returns transaction_id
    """Single flow: from='dunin7', to=person_id, asset_id, quantity=+amount.
    metadata: {'reason': 'grant_claim', 'grant_id': str(grant_id), **metadata_extra}.
    Trigger fires; person's balance row is created or incremented."""

9.2 Consumption recording (multi-flow per turn)


def write_consumption_flows(
    person_id: UUID,
    credit_asset_id: str,                # the credit type resolved for this turn
    classifier_input_tokens: int,
    classifier_output_tokens: int,
    responder_model_token_pair: tuple[str, str],  # (input_asset_id, output_asset_id)
    responder_input_tokens: int,
    responder_output_tokens: int,
    engagement_id: UUID,
    turn_event_id: UUID,
) -> UUID:  # returns transaction_id
    """Writes five flows in one transaction (single transaction_id):
      1. Classifier input: from=person, to='anthropic', asset_id=anthropic_haiku_4_input
      2. Classifier output: from=person, to='anthropic', asset_id=anthropic_haiku_4_output
      3. Responder input: from=person, to='anthropic', asset_id=responder_model_token_pair[0]
      4. Responder output: from=person, to='anthropic', asset_id=responder_model_token_pair[1]
      5. Credit debit: from=person, to='dunin7', asset_id=credit_asset_id,
         quantity = oracle.convert(credit_asset_id, anthropic_haiku_4_input, classifier_input)
                  + oracle.convert(credit_asset_id, anthropic_haiku_4_output, classifier_output)
                  + oracle.convert(credit_asset_id, responder_model_token_pair[0], responder_input)
                  + oracle.convert(credit_asset_id, responder_model_token_pair[1], responder_output)
    metadata on each flow: {'engagement_id', 'turn_event_id', 'pipeline_stage'}."""

9.3 Lifecycle flows


def write_suspension_flow(person_id: UUID, requested_by: UUID, expires_at: datetime) -> UUID
def write_reactivation_flow(person_id: UUID, auth_method: str) -> UUID
def write_deletion_flow(person_id: UUID, deletion_kind: str) -> UUID
def write_balance_zeroing_flows(person_id: UUID) -> UUID
    """Zeros out all loomworks_credit_* balances at deletion time.
    For each credit type with positive balance, write a flow:
      from=person, to='dunin7', asset_id=<credit type>, quantity=<remaining balance>.
    All in one transaction_id. Trigger drives balances to 0."""

The lifecycle flows use the synthetic asset loomworks_account_status per credit scoping v0.7 §4.6/§4.7.

9.4 Referral credit (stub)


def write_referral_credit_flow(referrer_person_id: UUID, asset_id: str, amount: int) -> UUID:
    """Flow: from='dunin7', to=referrer_person_id, asset_id=asset_id, quantity=+amount.
    Idempotency: the function checks for an existing referral flow for this referrer
    and returns the existing transaction_id without writing a duplicate.
    Phase 47 ships the function but does not call it — Phase 48 wires conversion
    detection (key-save → referrer credit). Tests exercise the function directly."""

10. Grant functions

In src/loomworks/credit/grants.py. All functions are transactional.

10.1 Grant issuance


def issue_grant(
    recipient_email: str,
    asset_id: str,                            # 'loomworks_credit_haiku' etc.
    amount: int,
    grant_kind: str,                          # 'form_initiated' | 'operator_curated' | 'referrer_initiated'
    initiated_by: UUID | None,
    campaign_ref: str | None,
    metadata: dict | None = None,
    override_eligibility: bool = False,        # Operator-curated grants can override INELIGIBLE_*
    expires_at: datetime | None = None,        # default: NOW() + default_grant_expiry_days
) -> Grant:
    """
    Transaction:
      1. eligibility = check_email_eligibility(recipient_email)
      2. If eligibility is INELIGIBLE_* and not override_eligibility → raise IneligibleEmailError
      3. Generate claim_token (64-char URL-safe random; secrets.token_urlsafe(48) gives ~64 chars)
      4. INSERT row into credit.credit_grant with status='pending_claim'
      5. register_email_grant(recipient_email, status='pending_claim')
      6. Return Grant(id, claim_token, recipient_email_hash, status, expires_at, ...)
    """

The eligibility-override semantics matter: an Operator-curated grant for a known repeat-recipient (a VIP attendee, a deliberate re-grant) sets override_eligibility=True. The INELIGIBLE_DELETED case should still be the rare exception even with override; the deletion was an explicit user choice.

10.2 Grant claim


def claim_grant(claim_token: str, person_id: UUID, verified_email: str) -> ClaimResult:
    """
    Transaction (with SELECT ... FOR UPDATE on the grant row):
      1. SELECT grant FROM credit.credit_grant WHERE claim_token = ? FOR UPDATE.
         If not found → raise InvalidClaimTokenError.
      2. If grant.status != 'pending_claim' → raise ClaimAlreadyConsumedError.
         (Race-safe: the FOR UPDATE held until this check + UPDATE means a second
         concurrent claim sees the row already updated.)
      3. If grant.expires_at <= NOW() → UPDATE status='expired'; raise ExpiredGrantError.
      4. If hash_email_lowercase_trim(verified_email) != grant.recipient_email_hash
            AND hash_email_normalized(verified_email) != hash_email_normalized(
              <reverse-lookup not possible — grant only has hash; see note>) →
         raise EmailMismatchError.
      5. write_issuance_flow(person_id, grant.asset_id, grant.credit_amount, grant.id)
      6. UPDATE credit.credit_grant SET status='claimed', claimed_at=NOW(),
         claimed_by_person_id=person_id, recipient_email=NULL.
      7. UPDATE credit.email_grant_registry SET last_grant_status='claimed',
         last_status_at=NOW() WHERE email_hash=grant.recipient_email_hash.
      8. Return ClaimResult(success, person_id, asset_id, amount, transaction_id).
    """

Note on email-match in step 4. The grant row stores recipient_email_hash (lowercase-trim). The match succeeds if hash_email_lowercase_trim(verified_email) == grant.recipient_email_hash. The normalized-form match is not done at claim time — claim is a precise check (the same email the grant was sent to). Normalization is for the eligibility registry, where we want to recognize aliasing. Mixing them at claim time would let claim_token leak to a Gmail+plus alias of the recipient grant a credit — that defeats the email-binding.

10.3 Token generation


def generate_claim_token() -> str:
    """64 URL-safe characters from secrets.token_urlsafe(48). Verified to fit in VARCHAR(64)."""

secrets.token_urlsafe(48) produces 64 characters of base64url-without-padding. Confirm under test that the output length is exactly 64.


11. Account lifecycle functions

In src/loomworks/credit/lifecycle.py. All functions write a FORAY flow (§9.3) and update person columns in the same transaction.

11.1 suspend_account


def suspend_account(person_id: UUID, requested_by: UUID) -> None:
    """
    Transaction:
      1. SELECT person FOR UPDATE.
      2. If account_status != 'active' and != 'exhausted' → raise InvalidStateTransitionError.
      3. expires_at = NOW() + default_suspension_period_days (from system_config; default 21).
      4. UPDATE person SET account_status='suspended', expires_at=expires_at,
         previous_status_change_at=NOW().
      5. write_suspension_flow(person_id, requested_by, expires_at).
    """

11.2 reactivate_account


def reactivate_account(person_id: UUID, auth_method: str) -> ReactivationResult:
    """
    Transaction:
      1. SELECT person FOR UPDATE.
      2. If account_status != 'suspended' → raise InvalidStateTransitionError.
      3. If expires_at is in the past → raise SuspensionExpiredError
         (the evaluator should have deleted them; if it hasn't yet, the door is
         still closed by policy).
      4. UPDATE person SET account_status='active', expires_at=NULL,
         previous_status_change_at=NOW().
      5. write_reactivation_flow(person_id, auth_method).
      6. Return ReactivationResult with the person's current credit balances
         (likely all zero — they were suspended because exhausted).
    """

11.3 delete_account


def delete_account(person_id: UUID, deletion_kind: str) -> None:
    """
    deletion_kind one of: 'user_initiated', 'suspension_expired', 'admin_initiated'.

    Transaction:
      1. SELECT person FOR UPDATE.
      2. If account_status == 'deleted' → no-op return.
      3. write_balance_zeroing_flows(person_id) — drives every credit balance to 0.
      4. write_deletion_flow(person_id, deletion_kind).
      5. UPDATE person SET account_status='deleted', expires_at=NULL,
         previous_status_change_at=NOW(),
         email = 'deleted_' || encode(sha256(email::bytea), 'hex') [CC verifies email
         column type and the engine's preferred anonymization style — replace email
         with a placeholder that preserves the hash deterministically].
         (Other identifying fields nulled per [CC verifies live person table fields
         that are PII — name, display_name, etc.])
      6. UPDATE credit.email_grant_registry SET last_grant_status='deleted',
         last_status_at=NOW() WHERE email_hash=<the lowercase-trim hash of the
         original email, computed in this transaction before the email is replaced>.
    """

The anonymization step preserves the email hash linkage — the registry needs to keep recognizing this email on future grants. The plaintext email is destroyed; the hash remains as the abuse boundary's memory.


12. Integration seam

In src/loomworks/credit/seam.py. Defines an abstract interface and an in-process implementation.

12.1 Interface


class CreditSeam(Protocol):
    def issue_grant(self, ...) -> Grant: ...
    def claim_grant(self, claim_token: str, person_id: UUID, verified_email: str) -> ClaimResult: ...
    def check_email_eligibility(self, email: str) -> EmailEligibility: ...
    def check_credit_balance(self, person_id: UUID, asset_id: str) -> int: ...
    def resolve_credit_model(self, person_id: UUID) -> CreditResolution | None: ...
    def record_consumption(self, ...) -> UUID: ...
    def suspend_account(self, person_id: UUID, requested_by: UUID) -> None: ...
    def reactivate_account(self, person_id: UUID, auth_method: str) -> ReactivationResult: ...
    def delete_account(self, person_id: UUID, deletion_kind: str) -> None: ...

Eight functions per scoping note + record_consumption rolled in (the multi-flow helper from §9.2).

12.2 In-process implementation

InProcessCreditSeam — direct calls to the Step 3–11 functions. No HTTP, no serialization. Single dependency-injected instance, accessed via a getter get_credit_seam() that the converse pipeline, signup endpoint, and admin endpoint all use.

The interface abstraction is cheap and pays off at multi-instance deployment time when the Authority's seam becomes HTTP. The CR ships the interface; HTTP transport is future work.


13. Model routing in the converse pipeline

13.1 Classifier

The classifier LLM call site reads system_config.converse_classifier_model and uses it. No change to the call structure beyond the model identifier source.

13.2 Responder

The responder LLM call site:

13.3 Token usage labelling

The post-Phase-46 contextvar buffer captures (pipeline_stage, input_tokens, output_tokens) per LLM call. Phase 47's consumption-recording hook reads from the buffer and maps:

The mapping lives in a single constant (PIPELINE_STAGE_TOKEN_ASSET_MAP or similar) so adding a new model is a single edit.


14. Consumption recording hook

After each system-key converse turn completes (response sent, conversation_turn record committed), a fire-and-forget background task calls:


seam.record_consumption(
    person_id=...,
    credit_asset_id=<resolved at turn start>,
    classifier_input_tokens=...,
    classifier_output_tokens=...,
    responder_model_token_pair=...,
    responder_input_tokens=...,
    responder_output_tokens=...,
    engagement_id=...,
    turn_event_id=...,
)

which delegates to write_consumption_flows (§9.2). The trigger fires per flow insert; balances update.

14.1 Failure handling

record_consumption is fire-and-forget. Failures are logged at error level but do not surface to the user. The Phase 48 reconciliation evaluator catches drift; alpha tolerates a small amount of unrecorded consumption.

14.2 Background-task registration

Follows Phase 44's pattern [CC verifies the precise lifespan registration mechanism].

14.3 Own-key turns are not recorded

If the resolution at turn start was tier 1 or tier 2, no consumption is recorded — the user is paying their own Anthropic bill. Phase 47 may still emit FORAY observation events for own-key consumption (Phase 48+) but does not write to credit.foray_action_flows.


15. Key resolution tier 3

Modify the existing tier-3 path in the key resolution function.

15.1 New tier-3 logic


person = SELECT person WHERE id = person_id

# Account status gate
if person.account_status == 'suspended':
    raise AccountSuspendedError(expires_at=person.expires_at)
if person.account_status == 'deleted':
    raise AccountDeletedError()

# Credit-type resolution
resolution = seam.resolve_credit_model(person_id)
if resolution is None:
    raise CreditExhaustedError()

# Returns: KeyResolution(
#   key=system_key,
#   tier=3,
#   responder_model=resolution.responder_model,
#   credit_asset_id=resolution.asset_id,
# )

15.2 Error class hierarchy

Three new exception types in src/loomworks/credit/errors.py:


class CreditError(Exception): ...
class CreditExhaustedError(CreditError): ...
class AccountSuspendedError(CreditError):
    def __init__(self, expires_at: datetime): ...
class AccountDeletedError(CreditError): ...

These propagate up to the converse endpoint, which translates each to a distinct user-facing response. In Phase 47, the response surface is structural onlyCreditExhaustedError returns a structured error payload; the warm message + three-choice presentation is Phase 48.

15.3 Existing tier-1 / tier-2 paths

Unchanged. No account-status check on own-key paths in Phase 47 (the account-status gate could be hoisted above tier resolution in a future amendment, but for alpha the account-status gate only blocks system-key consumption — own-key users are paying their own bills and the operational signal of "you're suspended" doesn't attach to that path yet).


16. Signup endpoint with claim token

Modify the existing signup handler to accept a claim token and require email verification.

16.1 New signup flow


POST /signup  (replaces existing)
body: { claim_token, verified_email, <existing person-creation fields like display_name>,
        auth_credential (passkey enrollment payload or magic-link verification token) }

Transaction:
  1. Validate auth_credential: verify the user has demonstrated ownership of
     verified_email via magic link or passkey. [CC verifies the existing
     verification mechanism's interface; if neither magic-link nor passkey is
     wired, halt at Step 0 — see pre-flight item 11.]
  2. Look up the grant: SELECT FROM credit.credit_grant WHERE claim_token = ?
     FOR UPDATE. (Hold the row through the transaction.)
  3. Validate grant: status == 'pending_claim', NOT expired, hash matches verified_email.
  4. Check whether a person record already exists for verified_email.
     - If yes: this is "attach grant to existing person" — proceed to step 5 with
       that person_id, do NOT create a new person.
     - If no: create person record (with companion_name='Companion' default,
       personal_engagement_id assignment per Phase 41 pattern).
  5. If grant.grant_kind == 'referrer_initiated' and grant.initiated_by IS NOT NULL:
       UPDATE person SET referred_by = grant.initiated_by.
  6. seam.claim_grant(claim_token, person_id, verified_email).
     (This writes the issuance flow, marks the grant claimed, updates registry.)
  7. Return signup response with person_id, session_token, etc. (existing shape).

16.2 Without a claim token

Phase 47 makes the trial path grant-claim-only. Signup without a claim token is rejected. However: the existing self-service signup path used by Phase 14 may have legacy semantics. [CC verifies how existing tests rely on signup; the CR's expectation is that all existing tests either provide a claim token (via a test fixture that issues a grant first) or use a separate test-only signup that bypasses the claim path. If the test refactor is non-trivial — touching > ~30 tests — CC halts and proposes splitting the test refactor into its own step.]

16.3 Existing-person attach

A grant issued to alice@example.com where Alice already has a person record (e.g., she was previously a Maker with her own key) attaches the credits to her existing account. She does not become a duplicate person. The signup flow detects this in step 4 above and routes accordingly.


17. Claim endpoint

POST /claim/grant — public endpoint.

17.1 Two-stage flow

The claim is two-stage because email verification cannot happen in a single round-trip:

Stage 1 — token presentation. POST /claim/grant/initiate

Stage 2 — verification + claim. POST /claim/grant/finalize

17.2 Phase 47 alpha simplification

If pre-flight item 11 establishes that magic-link infrastructure exists, the two-stage flow as written is the implementation.

If pre-flight item 11 establishes that only passkey enrollment exists, the flow becomes single-stage: passkey enrollment binds to recipient_email at registration time, and the claim endpoint is a single POST /claim/grant that validates the claim token and triggers passkey enrollment for the bound email.

CC selects the appropriate variant after pre-flight and reports the selection at Step 0 review.

17.3 Concurrent-claim safety

SELECT … FOR UPDATE on the grant row inside the claim transaction. A second concurrent claim on the same token sees status='claimed' once the first commits.


18. Admin grants endpoint

POST /admin/grants — Operator-only.

18.1 Request and response


Request body:
  {
    "recipient_email": "...",
    "asset_id": "loomworks_credit_haiku" | "loomworks_credit_sonnet" | "loomworks_credit_opus",
    "amount": <integer>,
    "campaign_ref": "..." | null,
    "grant_kind": "operator_curated" (default) | "form_initiated" | "referrer_initiated",
    "override_eligibility": <bool, default false>,
    "expires_in_days": <integer, default 30>
  }

Response 201:
  {
    "grant_id": "...",
    "claim_token": "...",
    "claim_url": "https://<base>/claim?token=<claim_token>",
    "recipient_email_hash": "...",
    "expires_at": "2026-..."
  }

Response 409 IneligibleEmailError (when override_eligibility=false and registry says no):
  { "error": "INELIGIBLE_RECENT" | "INELIGIBLE_DELETED",
    "registry_status": {...} }

18.2 Auth

Reuses the existing admin-auth pattern [CC verifies — pre-flight item 10]. If no pattern exists, CC halts at Step 0.

18.3 Manual delivery in Phase 47 alpha

The Operator runs POST /admin/grants, copies claim_url from the response body, and emails it to the recipient by hand. Phase 48 wires SMTP and a Companion-driven flow on top of the same endpoint.


19. Tests

Approximate count and breakdown — the actual numbers will land in implementation notes. Target: ~80–100 new tests, no new skips.

19.1 Trigger function (~10 tests)

19.2 Balance and oracle (~20 tests)

19.3 Email hashing and registry (~10 tests)

19.4 Grant issuance (~12 tests)

19.5 Grant claim (~12 tests)

19.6 Account lifecycle (~10 tests)

19.7 Key resolution tier 3 (~12 tests)

19.8 Signup with claim token (~10 tests)

19.9 Claim endpoint (~6 tests)

19.10 Admin grants endpoint (~5 tests)

19.11 Consumption recording end-to-end (~5 tests)

Expected post-CR substrate total: 1,860–1,880 tests passed, 26 skipped, Alembic 0062.


20. Order of operations

Sixteen steps. Auto-mode posture: Steps 0–14 auto-mode-proceed. One checkpoint (Checkpoint A) at the end of Step 15 for Operator confirmation. Step 16 is the tag, executed only after Operator approval at the checkpoint.

| Step | What | Posture | |------|------|---------| | 0 | Pre-flight per §3.2 (sixteen items). Archive CR per §3.3. | Auto | | 1 | Migration 0062: schema + five tables + trigger fn + trigger + person columns + system_config seeds + oracle rate seeds. Verify upgrade/downgrade cycle clean. Existing 1,781 tests pass. | Auto | | 2 | SQLAlchemy models for the five credit.* tables. Schema-qualified __table_args__. Models compile and round-trip a basic insert/select. | Auto | | 3 | Email hashing functions + eligibility check + registry registration (per §7). | Auto | | 4 | Balance query + multi-credit-type resolution + oracle conversion (per §8). | Auto | | 5 | Flow write functions: issuance, multi-flow consumption, lifecycle, balance-zeroing, referral stub (per §9). | Auto | | 6 | Grant functions: issue_grant, claim_grant, token generation (per §10). | Auto | | 7 | Account lifecycle functions: suspend_account, reactivate_account, delete_account (per §11). | Auto | | 8 | Integration seam: interface + in-process implementation (per §12). | Auto | | 9 | Model routing in converse pipeline: classifier from config, responder from credit resolution (per §13). | Auto | | 10 | Consumption recording hook: background task after system-key turns (per §14). | Auto | | 11 | Key resolution tier 3: account-status check + multi-credit-type resolution + distinct error classes (per §15). | Auto | | 12 | Signup endpoint: claim-token-based flow (per §16). | Auto | | 13 | Claim endpoint: POST /claim/grant with email verification (per §17). | Auto | | 14 | Admin grants endpoint: POST /admin/grants (per §18). | Auto | | 15 | Test suite: write tests per §19 (~80–100 new tests). Run full substrate test sweep. Verify ~1,860–1,880 passing, 26 skipped, Alembic 0062, all existing tests pass unchanged. | Auto | | A | Checkpoint A — Operator evaluation. Operator confirms substrate work, test counts, no regressions. Implementation notes drafted at docs/phase-impl-notes/phase-47-implementation-notes-v0_1.md. | Checkpoint | | 16 | Tag phase-47-credit-substrate-foundation on loomworks-engine. Push tag. | Auto (post-checkpoint) |

20.1 Halt conditions during Steps 1–15

CC halts and surfaces an amendment if:

20.2 Implementation notes

After Checkpoint A, before Step 16 tag, CC writes implementation notes to:


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

Notes capture: amendments produced (if any), naming-only divergences resolved at pre-flight, residues, and any test-count anomalies versus the §19 estimate.


21. Acceptance gate

Eighteen items. All must pass for Checkpoint A.

  1. Migration 0062 applies cleanly from phase-46-conversation-history baseline.
  2. alembic downgrade -1 and alembic upgrade head round-trip cleanly.
  3. The five credit.* tables exist with the columns specified in §5.
  4. credit.update_balance_on_flow() function and trg_balance_update trigger exist and fire on INSERT INTO credit.foray_action_flows.
  5. The five new person columns exist with correct types and defaults.
  6. system_config keys seeded per §5.1.9.
  7. credit.oracle_rate_config seeded per §5.1.10.
  8. Email hashing produces stable hashes; normalized form catches Gmail+plus and Gmail-dot variations.
  9. check_email_eligibility returns the correct enum for each registry state.
  10. issue_grant writes grant row and registry row in one transaction; both roll back together on error.
  11. claim_grant is concurrent-safe (FOR UPDATE).
  12. Account lifecycle transitions enforce the state machine; invalid transitions raise.
  13. delete_account zeros balances, anonymizes person, preserves email hash in registry.
  14. Key resolution tier 3 returns distinct error classes for exhausted / suspended / deleted; preserves tier 1 + 2 paths.
  15. Signup endpoint accepts a claim token and produces a person record with credits issued, all transactional.
  16. POST /claim/grant validates token, verifies email, completes claim.
  17. POST /admin/grants returns claim URL on success; rejects unauthenticated callers; rejects invalid bodies.
  18. Test suite: ~80–100 new tests; total ~1,860–1,880 passing; 26 skipped (no new skips); Alembic head 0062; all pre-existing tests unchanged.

22. Post-CR state


23. What this CR does not build

(Restated from §2.2 and §2.3 for reading-at-Checkpoint-A clarity.)


24. Kickoff prompt for the Claude Code session


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

CR path: ~/Downloads/phase-47-cr-credit-substrate-foundation-v0_1.md

Phase 47 builds the credit substrate: a `credit` schema co-located in
the engine database, FORAY-attested flow + balance machinery (with a
trigger that maintains balances atomically), the grant + email-registry
mechanism for issuance-time abuse boundary, account lifecycle columns
on the person table, the integration seam, model routing in the
converse pipeline, key resolution tier-3 modifications, claim-token-
based signup, and two new endpoints (POST /claim/grant and POST
/admin/grants).

Substrate-only phase. Sixteen steps, one checkpoint. Single migration
0062. ~80–100 new tests.

Code baseline: tag `phase-46-conversation-history` (substrate at
8e10880). 1,781 tests, 26 skipped, Alembic 0061. Working tree clean.

Three repos involved:
  - DUNIN7/loomworks-engine — substrate work, this CR.
  - DUNIN7/loomworks — UNCHANGED.
  - DUNIN7/loomworks-ui — UNCHANGED.

Run pre-flight (Step 0) per CR §3.2. Sixteen pre-flight items covering:
substrate baseline, person table, system_config, key resolution,
converse pipeline LLM call sites, token usage buffer, signup endpoint,
admin auth pattern, email verification mechanism (magic-link or
passkey — halt if neither), FORAY hooks, background task pattern,
Alembic numbering, no pre-existing credit schema, no claimed routes.

The CR uses [CC verifies] markers throughout for substrate names that
must be confirmed at pre-flight. The CR specifies architectural
decisions firmly; substrate locations are verified.

Per CR §3.3: archive this CR to
docs/phase-crs/phase-47-cr-credit-substrate-foundation-v0_1.md
at Step 0 before Step 1 begins.

Per CR §20, sixteen steps with one checkpoint. Auto-mode posture:
Steps 0–15 auto-mode-proceed; Checkpoint A halts for Operator
confirmation. Step 16 (tag) executes after Operator approval.

Halt conditions per CR §20.1:
  - Architectural divergence at pre-flight (not just naming).
  - Required mechanism missing (pre-flight items 10, 11).
  - Migration 0062 too large to land safely → propose 0062a + 0062b.
  - Email normalization rule misclassifies legitimate users.
  - Existing test refactor at §16.2 touches > ~30 tests → propose
    splitting into Step 12a.

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:
docs/phase-impl-notes/phase-47-implementation-notes-v0_1.md

Tag at Step 16 (post-checkpoint):
phase-47-credit-substrate-foundation on loomworks-engine.

DUNIN7 — Done In Seven LLC — Miami, Florida Phase 47: Credit Substrate Foundation — CR v0.1 — 2026-05-07