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
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.
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.credit.* tables.** foray_action_flows, asset_balances, credit_grant, email_grant_registry, oracle_rate_config.credit.update_balance_on_flow(). UPSERT pattern, atomic with each flow insert. Increments to_party balance; decrements from_party balance.+plus-addressing stripped, Gmail dots-in-local-part stripped).ELIGIBLE_NEW | ELIGIBLE_COOLED | INELIGIBLE_RECENT | INELIGIBLE_DELETED based on email_grant_registry lookup.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).suspend_account, reactivate_account, delete_account. Direct callable via the seam in Phase 47; evaluator-triggered in Phase 48.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).account_status check. Distinct error classes for CreditExhaustedError, AccountSuspendedError, AccountDeletedError.referred_by if grant_kind = 'referrer_initiated', write credit issuance flow. All in one transaction.POST /claim/grant endpoint. Public endpoint. Token + email verification flow.POST /admin/grants endpoint. Operator-only. Body: recipient_email, asset_id, amount, optional campaign_ref, optional grant_kind. Returns: claim_token + claim URL.POST /authority/grant-request or similar) and the marketing website that hosts it.DUNIN7/loomworks-engine, tag phase-46-conversation-history at 8e10880. 1,781 tests passed, 26 skipped, Alembic head 0061.main.DUNIN7/loomworks) and the Workshop (DUNIN7/loomworks-ui) are unchanged by Phase 47.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.
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:
phase-46-conversation-history checked out. pytest -q reports 1,781 passed, 26 skipped. alembic heads reports 0061.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.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.[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.[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.[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.ConverseResponse.token_usage field. [CC verifies schema location.] Frontend already reads this; Phase 47 does not change its shape.[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.[CC verifies how existing signup tests authenticate / mock person creation.] New tests in Phase 47 follow the same 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.[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.)[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.[CC verifies how Phase 44's background tasks are registered in the FastAPI lifespan; Phase 47's consumption-recording hook follows the same pattern.][CC verifies that 0062 is next; renumber if intervening migrations have landed.] Migration is written manually; do not autogenerate.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./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.
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.
| 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 |
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.
credit schema. CREATE SCHEMA credit;credit.foray_action_flows. Columns:id UUID PRIMARY KEY (default gen_random_uuid())transaction_id UUID NOT NULL (groups multi-flow transactions)asset_id VARCHAR(64) NOT NULLquantity BIGINT NOT NULL (signed; positive in inflow direction, negative in outflow — convention is quantity is the absolute amount and from_party/to_party direction defines sign behavior in the trigger)from_party VARCHAR(128) NOT NULL (UUID-as-string for persons; literal strings like 'dunin7', 'anthropic' for institutional parties)to_party VARCHAR(128) NOT NULLtimestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()metadata JSONB NOT NULL DEFAULT '{}'::jsonb(transaction_id), on (from_party, asset_id, timestamp), on (to_party, asset_id, timestamp).credit.asset_balances. Columns:party_id VARCHAR(128) NOT NULLasset_id VARCHAR(64) NOT NULLbalance BIGINT NOT NULL DEFAULT 0last_flow_id UUID NOT NULL (FK to credit.foray_action_flows.id, no cascade)updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()PRIMARY KEY (party_id, asset_id)credit.credit_grant. Columns per credit scoping v0.7 §7.4:id UUID PRIMARY KEY (default gen_random_uuid())claim_token VARCHAR(64) NOT NULL UNIQUErecipient_email VARCHAR (nullable — nulled after claim; cleartext retained until then)recipient_email_hash VARCHAR(64) NOT NULL (SHA-256 hex of lowercase-trim form)grant_kind VARCHAR NOT NULL (one of 'form_initiated', 'operator_curated', 'referrer_initiated')asset_id VARCHAR(64) NOT NULL (one of 'loomworks_credit_haiku', 'loomworks_credit_sonnet', 'loomworks_credit_opus')credit_amount BIGINT NOT NULLinitiated_by VARCHAR(128) (nullable; person_id of Operator or referrer; null for form_initiated)campaign_ref VARCHAR (nullable)metadata JSONB NOT NULL DEFAULT '{}'::jsonbstatus VARCHAR NOT NULL DEFAULT 'pending_claim' (one of 'pending_claim', 'claimed', 'expired', 'revoked')expires_at TIMESTAMPTZ NOT NULLclaimed_at TIMESTAMPTZ (nullable)claimed_by_person_id UUID (nullable; FK to person.id, no cascade)created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()(recipient_email_hash), on (status, expires_at), on (initiated_by).credit.email_grant_registry. Columns per credit scoping v0.7 §7.3:email_hash VARCHAR(64) PRIMARY KEY (SHA-256 hex of lowercase-trim form)email_normalized_hash VARCHAR(64) NOT NULL (SHA-256 hex of aggressively normalized form)first_grant_at TIMESTAMPTZ NOT NULL DEFAULT NOW()last_grant_at TIMESTAMPTZ NOT NULL DEFAULT NOW()total_grants_issued INTEGER NOT NULL DEFAULT 1last_grant_status VARCHAR NOT NULL (one of 'pending_claim', 'claimed', 'expired', 'deleted')last_status_at TIMESTAMPTZ NOT NULL DEFAULT NOW()(email_normalized_hash).credit.oracle_rate_config. Columns:id UUID PRIMARY KEY (default gen_random_uuid())credit_asset_id VARCHAR(64) NOT NULLprovider_token_asset_id VARCHAR(64) NOT NULLrate_per_million BIGINT NOT NULL (credits charged per 1M provider tokens)effective_at TIMESTAMPTZ NOT NULL DEFAULT NOW()superseded_at TIMESTAMPTZ (nullable)(credit_asset_id, provider_token_asset_id, effective_at).person. Each as a separate op.add_column call:referred_by UUID — nullable, FK to person.id, no cascade.license_tier VARCHAR NOT NULL DEFAULT 'trial'.account_status VARCHAR NOT NULL DEFAULT 'active' (allowed values: 'active', 'exhausted', 'suspended', 'deleted'; check constraint).expires_at TIMESTAMPTZ — nullable.previous_status_change_at TIMESTAMPTZ — nullable.system_config. Insert the following keys if absent (use ON CONFLICT DO NOTHING on the existing system_config PK; do not overwrite):converse_classifier_model → claude-haiku-4-5-20251001converse_default_responder_model → claude-sonnet-4-20250514 (verify exact current model identifier strings against [CC verifies] whatever the live converse pipeline uses today; do not invent strings)referrer_rate_limit_per_period → 5referrer_rate_limit_period_days → 30default_suspension_period_days → 21default_grant_amount_per_credit_type → JSON: {"loomworks_credit_haiku": 10000, "loomworks_credit_sonnet": 10000, "loomworks_credit_opus": 5000} (scoping note D1 sets 10,000 for haiku/sonnet; opus given a deliberately smaller default per the v0.6 §3.5 advisory pattern; CC verifies whether system_config values are stored as text-encoded JSON or as a typed jsonb column; format accordingly)referral_credit_amount → 10000default_grant_expiry_days → 30 (claim-link validity per credit scoping v0.7 §7.4)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.
alembic upgrade head cleanly, the existing 1,781 tests pass without modification, and alembic downgrade -1 followed by alembic upgrade head round-trips cleanly.__table_args__ = {'schema': 'credit'} and the migration uses op.create_table(..., schema='credit').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();
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.
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.
(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.
Two functions, both pure (no DB access), in src/loomworks/credit/grants.py [CC verifies the module path against any pre-existing organization]:
hash_email_lowercase_trim(email: str) -> str — lowercases, strips leading/trailing whitespace, returns SHA-256 hex of the result.hash_email_normalized(email: str) -> str — applies the lowercase-trim transform, then aggressively normalizes:gmail.com, googlemail.com): strip everything from the first + in the local part to the @; strip all . in the local part; replace googlemail.com → gmail.com.outlook.com, hotmail.com, live.com): strip everything from the first + in the local part to the @. Do not strip dots; Outlook treats them as significant.
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.
Function: check_email_eligibility(email: str) -> EmailEligibility.
Returns one of four values (enum):
ELIGIBLE_NEW — neither hash matches any registry row.ELIGIBLE_COOLED — at least one hash matches, the matching row's last_grant_at is older than the cooling threshold (system_config.email_eligibility_cooling_days, default 180), and last_grant_status != 'deleted'.INELIGIBLE_RECENT — at least one hash matches, the matching row's last_grant_at is within the cooling threshold, and last_grant_status != 'deleted'.INELIGIBLE_DELETED — at least one hash matches and last_grant_status = 'deleted'. The Authority can override (Operator-curated grants can bypass), but the default decision is to refuse.
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).
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:
total_grants_issued, update last_grant_at = NOW(), update last_grant_status = status (typically 'pending_claim'), update last_status_at = NOW().first_grant_at = NOW(), last_grant_at = NOW(), total_grants_issued = 1, the supplied last_grant_status, last_status_at = NOW(), both hash columns populated.
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.
In src/loomworks/credit/balance.py [CC verifies module path] and src/loomworks/credit/oracle.py:
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)."""
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.
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.
In src/loomworks/credit/flows.py. All functions write to credit.foray_action_flows; the trigger handles balance updates.
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."""
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'}."""
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.
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."""
In src/loomworks/credit/grants.py. All functions are transactional.
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.
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.
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.
In src/loomworks/credit/lifecycle.py. All functions write a FORAY flow (§9.3) and update person columns in the same transaction.
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).
"""
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).
"""
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.
In src/loomworks/credit/seam.py. Defines an abstract interface and an in-process implementation.
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).
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.
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.
The responder LLM call site:
system_config.converse_default_responder_model.seam.resolve_credit_model(person_id). The same resolution call also identifies which credit type to debit.
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:
pipeline_stage = 'classify' → (anthropic_haiku_4_input, anthropic_haiku_4_output) token-asset pair, regardless of credit type.pipeline_stage = 'respond' → token-asset pair derived from the responder model:(anthropic_haiku_4_input, anthropic_haiku_4_output).(anthropic_sonnet_4_input, anthropic_sonnet_4_output).(anthropic_opus_4_input, anthropic_opus_4_output).
The mapping lives in a single constant (PIPELINE_STAGE_TOKEN_ASSET_MAP or similar) so adding a new model is a single edit.
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.
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.
Follows Phase 44's pattern [CC verifies the precise lifespan registration mechanism].
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.
Modify the existing tier-3 path in the key resolution function.
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,
# )
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 only — CreditExhaustedError returns a structured error payload; the warm message + three-choice presentation is Phase 48.
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).
Modify the existing signup handler to accept a claim token and require email verification.
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).
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.]
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.
POST /claim/grant — public endpoint.
The claim is two-stage because email verification cannot happen in a single round-trip:
Stage 1 — token presentation. POST /claim/grant/initiate
{ claim_token }{ claim_id, requires_verification: 'magic_link' | 'passkey', recipient_email_hint }.recipient_email_hint is the email with most characters masked (a@e.com) — enough for the recipient to recognize it's the right one without leaking PII.recipient_email (Phase 47 alpha: this is the substrate for the magic link; SMTP delivery is Phase 48). The magic link contains a verification_token distinct from claim_token.
Stage 2 — verification + claim. POST /claim/grant/finalize
{ claim_id, verification_token, person_creation_fields }verification_token against the magic-link record confirms email ownership; signup proceeds.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.
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.
POST /admin/grants — Operator-only.
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": {...} }
Reuses the existing admin-auth pattern [CC verifies — pre-flight item 10]. If no pattern exists, CC halts at Step 0.
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.
Approximate count and breakdown — the actual numbers will land in implementation notes. Target: ~80–100 new tests, no new skips.
INSERT, including bulk inserts.last_flow_id correctly reflects most recent flow in the row.updated_at advances on every update.@gmail.com, dots-only local part).record_consumption does not surface to the user (logged only).Expected post-CR substrate total: 1,860–1,880 tests passed, 26 skipped, Alembic 0062.
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) |
CC halts and surfaces an amendment if:
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.
Eighteen items. All must pass for Checkpoint A.
phase-46-conversation-history baseline.alembic downgrade -1 and alembic upgrade head round-trip cleanly.credit.* tables exist with the columns specified in §5.credit.update_balance_on_flow() function and trg_balance_update trigger exist and fire on INSERT INTO credit.foray_action_flows.person columns exist with correct types and defaults.system_config keys seeded per §5.1.9.credit.oracle_rate_config seeded per §5.1.10.check_email_eligibility returns the correct enum for each registry state.issue_grant writes grant row and registry row in one transaction; both roll back together on error.claim_grant is concurrent-safe (FOR UPDATE).delete_account zeros balances, anonymizes person, preserves email hash in registry.POST /claim/grant validates token, verifies email, completes claim.POST /admin/grants returns claim URL on success; rejects unauthenticated callers; rejects invalid bodies.DUNIN7/loomworks-engine: ~1,860–1,880 tests passing, 26 skipped. Alembic 0062. New schema credit with five tables. Five new person columns. New module src/loomworks/credit/ with sub-modules models.py, oracle.py, balance.py, flows.py, grants.py, lifecycle.py, seam.py, errors.py. New API modules src/loomworks/api/claim.py (or wherever the live API tree lives), src/loomworks/api/admin_grants.py. Modified converse pipeline (model routing). Modified key resolution (tier 3). Modified signup endpoint. Tag: phase-47-credit-substrate-foundation.DUNIN7/loomworks (Operator Layer): unchanged.DUNIN7/loomworks-ui (Workshop): unchanged.(Restated from §2.2 and §2.3 for reading-at-Checkpoint-A clarity.)
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