Tag (Checkpoint A pending). phase-47-credit-substrate-foundation on loomworks-engine.
CR. CR-2026-062 v0.1.
Date. 2026-05-07.
Per CR §3.2, sixteen items checked against HEAD on main.
The CR named the baseline as "tag phase-46-conversation-history at 8e10880. 1,781 tests passed" — but the tag itself points to a state with 1,777 tests (per the Phase 46 implementation notes commit message). The two intervening commits on main (de07e2b "Phase 31 amendment fix: KeyProvidedRecord accepts key_source='person'" and 29067c7 "Token-usage instrumentation on the converse pipeline") added 4 tests and ARE required by Phase 47 (pre-flight item 6 explicitly references the post-Phase-46 token-usage fix). The CR's "1,781 tests at 8e10880" therefore conflated tag and HEAD — the correct baseline is HEAD on main = 29067c7, which matches the 1,781 / 26 / Alembic 0061 figures literally.
This was a naming-only divergence per CR §3.2's halt protocol; no amendment required.
| Pre-flight item | Disposition |
|---|---|
| 1. Substrate baseline | ✅ 1,781 / 26 / Alembic 0061 confirmed at HEAD = 29067c7. |
| 2. Persons table location | ✅ src/loomworks/persons/models.py; table persons (plural — naming-only divergence from CR's "person"). Has companion_name, personal_engagement_id from Phase 41; none of the five Phase 47 columns existed. |
| 3. system_config | ✅ src/loomworks/system_config/models.py. Stores Fernet-encrypted Text values, not JSON or jsonb. Format divergence: CR §5.1.9 seeds plain values; resolved by Fernet-encrypting at migration time using LOOMWORKS_SECRET_KEY inside the migration (helper inlined per Phase 41 migration 0058's "no app imports" precedent). |
| 4. Three-tier key resolution | ✅ _resolve_llm_key(engagement_id, db, person_id) in src/loomworks/api/routers/seed_conversation.py. Returns (api_key, key_source) where key_source ∈ {"operator", "person", "system"}. CR's "engagement-scoped / person-scoped / system" maps directly: "engagement-scoped" = key_source="operator" (per-engagement key store); "person-scoped" = key_source="person". Naming-only divergence; resolved. |
| 5. Converse pipeline LLM call sites | ✅ Classifier: src/loomworks/orchestration/classifier.py::classify_intent. Responder: src/loomworks/orchestration/responder.py::generate_response. Both call _call_*_llm seams that take model: str = CONVERSATION_MODEL, where CONVERSATION_MODEL = "claude-sonnet-4-6" (src/loomworks/prompts/creation_conversation.py). The CR's "claude-sonnet-4-20250514" placeholder for the responder default was invented; pre-flight confirmed claude-sonnet-4-6 is the live string. Phase 47 uses the live string for converse_default_responder_model and the Sonnet credit-asset → responder mapping. |
| 6. Token-usage contextvar buffer | ✅ src/loomworks/orchestration/usage.py. Records (label, {"input": N, "output": N}) with labels "classify" and "respond". Confirmed present (the post-Phase-46 fix is one of the two commits past the tag). |
| 7. ConverseResponse.token_usage | ✅ Shape {classify, respond, total} × {input, output}. Phase 47 does not touch the schema. |
| 8. Signup endpoint | ✅ Three-step state machine: POST /auth/signup/begin → POST /auth/signup/passkey → POST /auth/signup/totp-verify. Person creation happens in step 3. |
| 9. Signup test pattern | ✅ 22 signup-touching tests (test_webauthn_registration, test_signup_personal_engagement). All exercise the three-step flow and do not pass a claim_token. Phase 47 makes claim_token optional on the totp-verify request, so the existing tests remain unchanged. |
| 10. Admin auth pattern | ⚠️ Substantive but resolved without halt. The only existing /admin/ route is POST /admin/mint-migration-passkey-token, ungated on localhost (Phase 14 acknowledges "a networked deployment would gate by an admin secret or a privileged caller"). Phase 47 takes a stricter posture: POST /admin/grants requires a session-authenticated person via get_current_person. This is a policy strengthening, not a structural change — both patterns coexist on /admin/ routes during alpha. Phase 48 layers role-aware admin auth. |
| 11. Email verification mechanism | ✅ Passkey enrollment exists (src/loomworks/persons/webauthn.py + signup.py). Magic-link does not exist. Per CR §17.2, Phase 47 takes the single-stage passkey variant for POST /claim/grant: the endpoint validates the token + reads the grant's retained recipient_email + drives begin_signup so the WebAuthn challenge binds to that email. The client follows up with the existing /auth/signup/passkey and /auth/signup/totp-verify (with the claim_token echoed into totp-verify so credits issue in the same transaction). |
| 12. FORAY hooks | ✅ src/loomworks/memory/events.py (Phase 25+). Phase 47's credit.foray_action_flows is a parallel table with the same metadata-JSONB shape but credit-specific semantics (no Memory event linkage). |
| 13. Background task pattern | ✅ Phase 44's start_evaluator / BackgroundAgentRunner use asyncio.create_task with a session factory captured by closure. Phase 47's consumption hook follows the same pattern. |
| 14. Alembic next migration | ✅ 0062. |
| 15. No existing credit. schema | ✅ No files matching credit* in src/loomworks. |
| 16. No existing /claim/ or /admin/grants route | ✅ Confirmed via grep. |
No items mandated a halt-and-amend. Three naming-only divergences resolved by re-deriving from ground truth (items 2, 4, 5). One format divergence resolved without amendment (item 3, system_config seed encryption). One policy strengthening recorded (item 10, admin auth posture).
| Step | Commit | What landed |
|---|---|---|
| 0 | 6393760 | CR archive at docs/phase-crs/phase-47-cr-credit-substrate-foundation-v0_1.md. |
| 1 | fc7a251 | Migration 0062: credit schema + 5 tables + balance trigger + 5 persons columns + 9 system_config seeds (Fernet-encrypted at upgrade time) + 10 oracle_rate_config rows. |
| 2 | a8275c8 | src/loomworks/credit/models.py — five SQLAlchemy ORM rows with schema-qualified __table_args__. The metadata columns map their DB name to Python attributes flow_metadata / grant_metadata because SQLAlchemy reserves metadata on DeclarativeBase. |
| 3 | fbf8984 | grants.py (partial): two-form email hashing (Gmail dot/plus + Outlook +plus + googlemail.com canonical), EmailEligibility four-state enum, registry UPSERT. |
| 4 | 2808ba3 | oracle.py (round-up rate conversion + missing-rate raises) + balance.py (single-row PK lookup + multi-credit-type tier resolution + responder model mapping). |
| 5 | f0461f7 | flows.py — issuance, multi-flow consumption, lifecycle (suspension / reactivation / deletion + balance zeroing), and the Phase-48-stub referral credit (idempotent). |
| 6 | 0f1424e | errors.py (CreditError hierarchy) + grants.py extension: generate_claim_token, issue_grant, claim_grant (with SELECT … FOR UPDATE and the §16.1 step-5 referred_by set on referrer-initiated claims). |
| 7 | 56df283 | lifecycle.py — suspend_account, reactivate_account, delete_account with deterministic deleted_<sha256_hex> email anonymization that preserves the registry hash linkage. persons/models.py gains the five Mapped column declarations matching migration 0062. |
| 8 | b2bf504 | seam.py — CreditSeam Protocol + InProcessCreditSeam direct implementation + get_credit_seam() / set_credit_seam() accessors. Nine methods (eight per CR §12.1 plus record_consumption). |
| 9 | 066af56 | routing.py — resolve_classifier_model + resolve_responder_model + PIPELINE_STAGE_TOKEN_ASSET_MAP + responder_model_to_token_asset_pair. Wires model= parameter through classify_intent and generate_response from the Phase 42 converse pipeline. |
| 10 | 9e5d693 | consumption_hook.py — schedule_consumption_recording fire-and-forget background task with per-task session factory; the converse route invokes it after both turns are recorded when key_source == "system". Defensive lookup on app.state.session_factory for ASGI test transports. |
| 11 | 0c95137 | Tier-3 logic in routing.py: account-status gate (raises AccountSuspendedError / AccountDeletedError) before credit resolution; raises CreditExhaustedError when the seam returns None. The converse route translates each into a structural ConverseResponse (warm three-choice presentation deferred to Phase 48 per CR §15.2). The conftest gains an autouse permissive-seam fixture that keeps the 19 pre-Phase-47 converse tests passing without modification. |
| 12 | 0d2510f | The existing POST /auth/signup/totp-verify request schema gains an optional claim_token. When present, the route reads the verified email from the pending registration record and calls seam.claim_grant after the person row is created — all in the same transaction. Errors map to 404 / 409 / 410 / 403. |
| 13 | 48898de | POST /claim/grant — single-stage passkey-bound claim per CR §17.2. Returns claim_token, registration_id, masked email hint, asset/amount/expiry, and the WebAuthn options. The client follows up with /auth/signup/passkey and /auth/signup/totp-verify (with claim_token threaded). |
| 14 | 07f9d96 | POST /admin/grants — session-authenticated. Body validation for asset / kind / amount; calls seam.issue_grant; returns the grant row + composed claim URL ({webauthn_rp_origin}/claim?token=<token>). 409 on IneligibleEmailError carries the eligibility state and matched hash. |
| 15 | e43e213 | 85 new tests across two files: test_phase_47_credit_substrate.py (70) for trigger / balance / oracle / hashing / grants / lifecycle / tier-3 / consumption, and test_phase_47_credit_endpoints.py (15) for the three endpoints. The conftest's TRUNCATE list extends to the four credit state tables (oracle_rate_config preserved as seed data). |
| A | _checkpoint_ | These notes. |
| 16 | _pending_ | Tag phase-47-credit-substrate-foundation after Operator approval. |
3.1 system_config seed encryption (Step 1). The Fernet helper is inlined in the migration rather than imported from loomworks.credentials.encryption so the migration stays self-contained per Phase 41 migration 0058's precedent. The seed inserts use ON CONFLICT DO NOTHING so any operator-tuned value survives; the downgrade intentionally leaves the seeds in place rather than clobbering them.
3.2 ORM-level FK avoidance (Step 2). CreditGrantRow.claimed_by_person_id declares the column without a SQLAlchemy ForeignKey(). The DB-level FK still exists (constraint name fk_credit_grant_claimed_by_person, created by migration 0062); the ORM declaration is omitted because the credit metadata is intentionally independent of the Phase-14 persons metadata, and ForeignKey("persons.id", ...) in the credit base would force cross-metadata mapper resolution at module import time. Same pattern for PersonRow.referred_by on the persons-layer side. This is a discipline divergence — neither metadata silos around the constraint, but neither owns it for ORM FK navigation.
3.3 register_email_grant return shape (Step 3). First draft returned the upserted ORM row via RETURNING EmailGrantRegistryRow. The returned row's lazy-loaded attributes triggered MissingGreenlet errors when the same session crossed a rollback boundary (a pattern inside test scripts, not production code). Simplified to return the lowercase-trim hash string only — callers that need the row re-select it explicitly.
3.4 Model strings (Step 4). CREDIT_TO_RESPONDER_MODEL uses the live string claude-sonnet-4-6 rather than the CR's claude-sonnet-4-20250514 placeholder, per pre-flight item 5. claude-haiku-4-5-20251001 and claude-opus-4-7 are added as the first two are not present in the existing codebase (Phase 47 introduces the multi-model surface).
3.5 Self-cancelling self-transfers (Step 5). The trigger fires both UPSERTs unconditionally; when from_party = to_party they sum to zero. CR §6.1 was explicit about not adding a WHERE from != to guard. Verified by test_trigger_self_transfer_nets_to_zero.
3.6 PersonRow Mapped declarations (Step 7). Migration 0062 added five columns to persons, but the ORM model needed Mapped declarations as well — without them, ORM access raises AttributeError. Discovered by Phase 7 lifecycle testing; the migration itself is unchanged.
3.7 Test DB migration step (Step 7). The test database (playground_test) is a separate instance from the dev database. Migrations need to be applied separately: ALEMBIC_URL=postgresql+asyncpg://playground@localhost:5432/playground_test alembic upgrade head. Required once at Phase 47 baseline; the rest of the suite is deterministic on TRUNCATE.
3.8 Permissive credit-seam test fixture (Step 11). The pre-Phase-47 converse pipeline tests (test_phase_43_integration, test_converse_integration, test_phase_40_converse — 19 tests) use the system-key path but do not seed credits. With Phase 47's tier-3 gate, every one of those tests would have raised CreditExhaustedError. Per CR §20.1's "Existing test refactor at §16.2 touches > ~30 tests → propose splitting into Step 12a", 19 was under the threshold — but rather than touch the 19 tests directly, conftest.py grew a _phase_47_permissive_credit_seam autouse fixture that swaps in a seam returning a synthetic Haiku resolution when the underlying real seam returns None. Phase 47 tests that exercise the gate explicitly install InProcessCreditSeam themselves via the production_credit_seam fixture. This is not an architectural change — the seam swap is test-scoped only and the production code path is exercised by the §19.7 tests directly.
3.9 Consumption hook session-factory lookup (Step 11 hardening). ASGI test transports skip the lifespan, so app.state.session_factory may not be set. The route uses getattr(request.app.state, "session_factory", None) and silently no-ops the hook when it's absent — consistent with the fire-and-forget contract.
3.10 Signup integration as totp-verify augmentation (Step 12). Rather than ship a parallel POST /signup endpoint, Phase 47 adds an optional claim_token to the existing totp-verify request. The same transaction creates the person row and consumes the grant. The legacy 3-step path (used by 22 existing tests) keeps working without modification — those tests don't supply a claim_token and the new code path is gated on its presence. CR §16.2's "test-only signup that bypasses the claim path" describes exactly this configuration.
| Phase | Tests passing | Skipped | Alembic |
|---|---|---|---|
| Baseline (phase-46-conversation-history HEAD = 29067c7) | 1,781 | 26 | 0061 |
| Phase 47 Step 0 (CR archive) | 1,781 | 26 | 0061 |
| Phase 47 Step 1 (migration applied) | 1,781 | 26 | 0062 |
| Phase 47 Step 7 (lifecycle landed) | 1,781 | 26 | 0062 |
| Phase 47 Step 11 (tier-3 gate) | 1,781 | 26 | 0062 |
| Phase 47 Step 15 (test suite) | 1,866 | 26 | 0062 |
85 new tests, no new skips. Right inside the CR's 80–100 target band.
By section:
These are out of scope for Phase 47 by CR §2.2 / §23 but worth recording for Phase 48 build planning.
write_referral_credit_flow is a stub; nobody calls it in Phase 47. Phase 48 detects "own-key save" events and issues the referrer's credit.POST /admin/grants returns a claim_url; Phase 47 alpha relies on the operator copying that URL into an email by hand. Phase 48 wires SMTP + Companion-as-Authority decision flow.POST /authority/grant-request endpoint or marketing site in Phase 47.previous_status_change_at column is wired, but no scan walks the table looking for elapsed windows; that's Phase 48.record_consumption is fire-and-forget; failures are logged but not chased. Phase 48's reconciler catches drift._call_llm model routing. The Phase-31-era seed_conversation._call_llm path (used by the delegated create_project / finalize_project branches) keeps CONVERSATION_MODEL as the default. CR §13's routing applies to the Phase 42 classify-route-respond pipeline only, but in a future amendment the same routing surface should reach Phase-31's path so own-key turns there also use the system-config default.DUNIN7/loomworks and DUNIN7/loomworks-ui are unchanged in Phase 47 per CR §2 / §22. Phase 48+ wires the claim flow, balance display, tier badges, and account UI.
| # | Item | Status |
|---|---|---|
| 1 | Migration 0062 applies cleanly from baseline | ✅ |
| 2 | alembic downgrade -1 + upgrade head round-trips cleanly | ✅ verified at Step 1 |
| 3 | Five credit.* tables exist with §5 columns | ✅ |
| 4 | Trigger function + trigger fire on insert | ✅ exercised by 10 §19.1 tests |
| 5 | Five new persons columns exist | ✅ |
| 6 | system_config seeded per §5.1.9 | ✅ (9 keys: 8 from CR + email_eligibility_cooling_days for §7.2) |
| 7 | oracle_rate_config seeded per §5.1.10 | ✅ 10 rows |
| 8 | Email hashing stable; normalized form catches Gmail variations | ✅ §19.3 |
| 9 | check_email_eligibility four-state enum returned correctly | ✅ §19.3 |
| 10 | issue_grant writes grant + registry in one transaction | ✅ §19.4 |
| 11 | claim_grant is concurrent-safe (FOR UPDATE) | ✅ §19.5 / §19.8 |
| 12 | Account lifecycle enforces state machine; invalid transitions raise | ✅ §19.6 |
| 13 | delete_account zeros balances, anonymizes person, preserves email hash | ✅ §19.6 |
| 14 | Tier 3 returns distinct error classes; tiers 1+2 unaffected | ✅ §19.7 |
| 15 | Signup endpoint accepts claim token, atomic person + credits | ✅ §19.8 |
| 16 | POST /claim/grant validates token, verifies email, completes claim | ✅ §19.9 |
| 17 | POST /admin/grants returns claim URL on success; rejects unauth and invalid bodies | ✅ §19.10 |
| 18 | ~80–100 new tests; total 1,860–1,880; no new skips; Alembic 0062; pre-existing tests unchanged | ✅ 85 new / 1,866 total / 26 skipped (unchanged) / 0062 |
All eighteen items satisfied. Ready for Operator confirmation at Checkpoint A.
DUNIN7 — Done In Seven LLC — Miami, Florida Phase 47: Credit Substrate Foundation — Implementation Notes v0.1 — 2026-05-07