CR identifier. CR-2026-XXX (next number in sequence; CR drafter confirms against engine repo docs/phase-crs/ listing before archival).
Version. 0.1
Date. 2026-05-24
Status. Drafted; awaiting Operator approval. No pre-flight Step 0 yet — Step 0 runs at CR execution time.
Author. Claude Code (CR drafter) on direction from Marvin Percival (DUNIN7 Operator).
Phase number. 63 (Phase 62 CR-B closed and tagged phase-62-memory-events-cleanup at engine commit ead29a8; Phase 63 is the next available).
Companion documents.
loomworks-cr-c-credit-substrate-cleanup-design-v0_1.md — design document; this CR's substrate-design source of truth.phase-61-cr-audit-cleanup-v0_1.md + implementation notes — CR-A; patterns inherited.phase-62-cr-memory-events-cleanup-v0_1.md + implementation notes — CR-B; patterns inherited.loomworks-substrate-hygiene-scoping-note-v0_2.md — scoping note authorizing the three-sibling-CR shape.loomworks-foray-integration-locations-v0_1.md — Step 1 inventory.Baseline. loomworks-engine ead29a8 (CR-B merge). Alembic head 0072. Test count 2670 pytest passing at baseline.
CR-A (Phase 61 — audit cleanup) and CR-B (Phase 62 — memory_events Phase 25 wiring) have closed. The reserved-location pattern is validated; src/loomworks/foray/ is established and populated; per-call-site discipline scales 3 sites → 1 site cleanly.
CR-C is the third and largest sibling CR. It addresses the credit substrate:
credit.foray_action_flows table — append-only flow log with the FORAY-shaped name. 11 constructor sites in flows.py + 1 in proposal_applier.py write into it. 3 downstream reader sites (2 in dedicated reader modules + 1 inline in the writer) extract fields from a JSONB metadata column. Each row is half of a double-entry credit transaction with explicit from_party → to_party movement.credit.update_balance_on_flow PostgreSQL trigger — fires AFTER INSERT to atomically maintain the credit.asset_balances projection table. Has been in production since Phase 47.credit.asset_balances projection table — maintained by the trigger; has an FK to credit.foray_action_flows.id (intra-credit, same DeclarativeBase).CR-C transforms credit.foray_action_flows into credit.flows (clean Loomworks-natural name), extracts three typed columns from the JSONB (event_kind, turn_event_id, converted_person_id) to replace JSONB-extract queries in the readers, renames the JSONB column from metadata to extra_metadata to retire the SQLAlchemy reserved-name workaround, renames timestamp to occurred_at (matching CR-A and CR-B conventions), preserves the PostgreSQL trigger (it binds to the table OID and references column names CR-C does not change), preserves the intra-credit FK (transparent through RENAME TABLE), and adds _foray_reserved_emit at all 12 constructor sites per the per-call-site discipline.
CR-C closes Phase A of the development assessment. The FORAY-shape cleanup arc completes when CR-C ships.
The discovery trajectory: scoping note v0.1 → CC's Step 1 inventory → scoping note v0.2 (absorbed corrections) → CR-A close → CR-B close → this CR (CR-C).
Engine-only work. The complete set:
0073 doing in-place transformation: rename table; rename two columns; add three typed columns; backfill from JSONB; tighten event_kind to NOT NULL; create two new indexes. PostgreSQL trigger and intra-credit FK survive transparently.ForayActionFlowRow → CreditFlowRow in src/loomworks/credit/models.py. Column changes, FK target update, module docstring update.CreditFlowRow, pass the new typed-column kwargs (event_kind, turn_event_id, converted_person_id where applicable), keep residual fields in extra_metadata, fire _foray_reserved_emit per the per-call-site discipline.src/loomworks/credit/ and src/loomworks/api/app.py:301.turn_event_id could FK to memory; converted_person_id could FK to persons. Both would entangle credit metadata with other DeclarativeBases — design §8.7 explicitly avoids.event_kind restricting to the 9 known values. Adds migration friction every time a new event kind lands; today's trade favors flexibility.legacy_unknown historical rows. Production rows that fall outside the 9 patterns stay as historical noise.ForayClient wrapper is downstream work.If CC finds itself implementing any of the out-of-scope items, it has crossed a scope boundary — halt and surface per §10.
All decisions trace to the design document. CC consumes them; does not relitigate.
| Decision | Setting | Source |
|---|---|---|
| D1 | Table name. credit.flows (drop foray_action_ prefix; preserve "flow" vocabulary the codebase uses). |
Design §3.1 |
| D2 | In-place transform via RENAME. PostgreSQL trigger and intra-credit FK survive transparently; minimal DDL. | Design §3.6 + §4.1 + §8.1 |
| D3 | Preserve the PostgreSQL trigger. No DDL touches it. The trigger references column names CR-C does not change. | Design §3.6 + §8.2 + §8.3 |
| D4 | Three new typed columns: event_kind (NOT NULL), turn_event_id (NULL), converted_person_id (NULL). |
Design §3.2 |
| D5 | 9-value event_kind taxonomy. Explicit at every writer site; no derivation. legacy_unknown for historical rows that match no pattern. |
Design §3.3 + §8.4 |
| D6 | metadata column renamed to extra_metadata. Python attribute matches DB column; retires the SQLAlchemy reserved-name workaround. |
Design §3.2 + §8.6 |
| D7 | timestamp column renamed to occurred_at. Same convention as CR-A and CR-B. |
Design §3.2 |
| D8 | extra_metadata Python attribute (was flow_metadata). Test code keyword-argument updates throughout. |
Design §8.6 |
| D9 | 3 readers refactored to typed-column queries. Includes the inline reader inside write_referral_credit_flow (inventory said 2 readers; grep found 3). |
Design §2.6 + §5.3 |
| D10 | Two new indexes. (event_kind, converted_person_id) and (event_kind, turn_event_id). Both small (filtered kinds are minorities). |
Design §3.4 + §8.9 |
| D11 | No new FKs. Intra-credit last_flow_id FK survives the rename; no cross-base FKs added per CR-A precedent. |
Design §3.5 + §8.7 |
| D12 | Per-call-site reserved-emit, 12 emits per insert pattern. Validates the discipline at scale-up (3 → 1 → 12). | Design §5.5 + §8 (cross-cutting) |
| D13 | Reserved-slot count: 2 slots. Most likely consumption: test scope expansion within densely-referencing files (test_phase_47_credit_substrate.py has 21 construct sites; test_phase_48_conversion_detection.py has 29 references). |
Design §9.6 |
0073_credit_flows_cleanup.pySingle migration. Revision 0073, revises 0072.
Upgrade (in-place transform):
op.rename_table("foray_action_flows", "flows", schema="credit") — table rename; trigger + FK + indexes follow transparentlyop.alter_column("flows", "metadata", new_column_name="extra_metadata", schema="credit") — rename JSONB columnop.alter_column("flows", "timestamp", new_column_name="occurred_at", schema="credit") — rename timestamp columnop.add_column("flows", sa.Column("event_kind", sa.String(64), nullable=True), schema="credit") — nullable initially for backfillop.add_column("flows", sa.Column("turn_event_id", UUID(as_uuid=True), nullable=True), schema="credit")op.add_column("flows", sa.Column("converted_person_id", UUID(as_uuid=True), nullable=True), schema="credit")event_kind from JSONB:
sql
UPDATE credit.flows
SET event_kind = COALESCE(
extra_metadata->>'event',
CASE
WHEN extra_metadata->>'pipeline_stage' IN ('classify','respond') THEN 'consumption_token'
WHEN extra_metadata->>'pipeline_stage' = 'credit_debit' THEN 'consumption_credit'
WHEN extra_metadata ? 'grant_id' THEN 'issuance'
WHEN extra_metadata ? 'shape_event_id' THEN 'corrective'
ELSE 'legacy_unknown'
END
);turn_event_id from extra_metadata->>'turn_event_id'converted_person_id from extra_metadata->>'converted_person_id'op.alter_column("flows", "event_kind", nullable=False, schema="credit") — tighten to NOT NULL after backfillop.create_index("ix_credit_flows_kind_converted_person", "flows", ["event_kind", "converted_person_id"], schema="credit")op.create_index("ix_credit_flows_kind_turn_event", "flows", ["event_kind", "turn_event_id"], schema="credit")Downgrade:
Reverse: drop the two new indexes; drop the three new columns; rename occurred_at → timestamp; rename extra_metadata → metadata; rename table flows → foray_action_flows.
The downgrade loses the typed-column data but the original JSONB still carries the source values (the typed columns were extracted-not-moved). Production rollback path is fail-forward via a new migration.
The credit.update_balance_on_flow function and trg_balance_update trigger are NOT touched by the migration. Pre-flight (Step 0) confirms the trigger fires on inserts to credit.foray_action_flows; post-migration verification confirms the same trigger fires on inserts to credit.flows.
The fk_credit_asset_balances_last_flow constraint on credit.asset_balances.last_flow_id references the renamed table transparently. The constraint name does not change.
The 2 rows in dev DB (count verified at design time) preserve through the migration:
event_kind = 'legacy_unknown' (both rows have empty metadata in dev), turn_event_id = NULL, converted_person_id = NULLProduction rows: count and shape verified at Step 0 pre-flight.
src/loomworks/credit/models.py:
ForayActionFlowRow → CreditFlowRow__tablename__ = "flows"Mapped[...] columns (matching the migration shape)timestamp → occurred_at renameflow_metadata/"metadata" workaround; replace with extra_metadata: Mapped[dict] = mapped_column(JSONB(), nullable=False, server_default=text("'{}'::jsonb"))AssetBalanceRow.last_flow_id's ForeignKey from "credit.foray_action_flows.id" to "credit.flows.id"__all__ to export CreditFlowRowsrc/loomworks/credit/flows.py — 11 constructor site updatesEach ForayActionFlowRow(...) construct becomes CreditFlowRow(...). Each call adds:
event_kind=<value> per the §3.3 taxonomyturn_event_id=<uuid> for consumption flows (5 sites in write_consumption_flows)converted_person_id=<uuid> for referral_credit (1 site in write_referral_credit_flow)flow_metadata=... kwarg renames to extra_metadata=...; the dict shape is reduced (the fields that became typed columns are removed)Each db.add(...) call is followed by a _foray_reserved_emit call:
db.add(flow := CreditFlowRow(...))
# FORAY_RESERVED_LOCATION: credit.<event_kind>
_foray_reserved_emit(
f"credit.{event_kind}",
{
"flow_id": str(flow.id),
"transaction_id": str(transaction_id),
"asset_id": asset_id,
"quantity": quantity,
},
)
(The walrus assignment captures the row instance for the reserved-emit payload; if walrus syntax isn't appropriate in a given site, the row is constructed first then db.add(...) is called separately.)
Per-function specifics in design doc §5.2.
src/loomworks/credit/proposal_applier.py:399 — 1 constructor siteCreditFlowRow(...) with event_kind="corrective", extra_metadata carrying shape_event_id and applied_bydb.add(flow) callReader 1 — conversion_observer._conversion_credit_already_written (src/loomworks/credit/conversion_observer.py:75-85):
Replace JSONB-extract predicates with typed-column predicates:
select(CreditFlowRow.transaction_id).where(
CreditFlowRow.event_kind == "referral_credit",
CreditFlowRow.converted_person_id == converted_person_id,
)
Reader 2 — reconciliation_evaluator._sum_recorded_consumption_for_turn (src/loomworks/credit/reconciliation_evaluator.py:211-222):
Add explicit event_kind = 'consumption_credit' filter; replace flow_metadata["turn_event_id"].astext with typed-column predicate:
select(CreditFlowRow.quantity).where(
CreditFlowRow.event_kind == "consumption_credit",
CreditFlowRow.from_party == str(person_id),
CreditFlowRow.to_party == "dunin7",
CreditFlowRow.asset_id == credit_asset_id,
CreditFlowRow.turn_event_id == turn_event_id,
)
Reader 3 — write_referral_credit_flow inline (src/loomworks/credit/flows.py:399-423):
Same refactor pattern as Reader 1. Both branches of the conditional (Phase 48 vs Phase 47 fallback) use typed-column predicates.
Mechanical text updates in:
src/loomworks/credit/specialists.py:116, 162 — class name + table namesrc/loomworks/credit/bootstrap.py:276, 413 — table namesrc/loomworks/credit/conversion_observer.py:15, 72 — docstring referencessrc/loomworks/credit/reconciliation_evaluator.py:5, 32, 209 — docstring referencessrc/loomworks/api/app.py:301 — comment-onlyNo behavior changes; just text updates.
src/loomworks/credit/__init__.py — if it exports ForayActionFlowRow directly, update to CreditFlowRow. (Step 0 verifies.)
tests/conftest.pySingle line: credit.foray_action_flows → credit.flows in the TRUNCATE chain.
tests/test_phase_47_credit_substrate.py — densest test surface21 construct sites + 24 total references. Mechanical changes throughout:
ForayActionFlowRow → CreditFlowRowflow_metadata=... kwargs → extra_metadata=...event_kind=... kwarg to every construct (per the §3.3 taxonomy; tests must use the same value the production writer would use)turn_event_id=... to consumption-flow constructsconverted_person_id=... to referral-credit constructs (if any in this file)timestamp references → occurred_atEstimated 75 line changes.
tests/test_phase_48_reconciliation_evaluator.py1 raw-SQL INSERT helper update (line 166): rewrite the INSERT to use the new column names + add the new typed columns (event_kind, turn_event_id, converted_person_id as appropriate for the helper's purpose — likely 'consumption_credit' with turn_event_id populated). Plus any ORM-level references in the rest of the file (mostly mechanical column renames).
tests/test_phase_48_conversion_detection.py29 references — most are read-side ORM queries. Mechanical updates:
ForayActionFlowRow → CreditFlowRowflow_metadata["event"].astext) → typed-column predicates (event_kind)flow_metadata["converted_person_id"].astext → converted_person_idThe queries get cleaner. Some tests may have parametrized fixtures that need adapting.
Estimated 30 line changes.
tests/test_phase_50_conversion_credit_override.py17 references — similar shape to test_phase_48_conversion_detection. Same refactor pattern.
Estimated 20 line changes.
tests/test_phase_49_* (three files)Smaller surface (2 + 5 + 1 references). Mechanical updates: class rename, column renames, typed-column predicates where reader-side queries exist.
Estimated 15 line changes total.
tests/test_foray_reserved_emit_for_credit.py — 2-3 tests asserting the reserved-emit hook fires at credit flow writes with the correct namespaced kind and payload shape. Pattern matches CR-B's test_foray_reserved_emit_for_memory.py.
Estimated 50 lines.
~180-220 lines across 8 modified files + 1 new file. Mechanical but voluminous.
Unlike CR-A and CR-B, no test file has a phase-25-style filename framing that's invalidated by the cleanup. Test names like test_phase_47_credit_substrate.py describe their phase-origin which remains historically correct.
Before alembic upgrade head:
ead29a8 (the CR-B merge)0072sql
SELECT COUNT(*) FROM credit.foray_action_flows;
SELECT
COALESCE(metadata->>'event', metadata->>'pipeline_stage', 'unknown') AS kind,
COUNT(*)
FROM credit.foray_action_flows
GROUP BY 1 ORDER BY 2 DESC;
SELECT tgname FROM pg_trigger
WHERE tgrelid = 'credit.foray_action_flows'::regclass;credit.foray_action_flows.venv/bin/alembic upgrade head
DATABASE_URL='postgresql+asyncpg://playground@localhost:5432/playground_test' .venv/bin/alembic upgrade head
CR-A established the test-DB-needs-its-own-migration pattern; CR-B confirmed.
credit.flows exists with the expected schema (\d credit.flows)credit.foray_action_flows does NOT existtrg_balance_update still fires (test by inserting a synthetic flow row and confirming credit.asset_balances updates)fk_credit_asset_balances_last_flow still binds (\d credit.asset_balances)alembic downgrade -1 → alembic upgrade head. Same data preservation pattern as CR-A and CR-B.
alembic downgrade -1 then alembic upgrade head reappliesloomworks-engine main; confirm HEAD at ead29a80072.venv/bin/pytest -q; record count (~2670)phase-63-credit-substrate-cleanup from mainIf any check fails, CC halts and surfaces.
Write migrations/versions/0073_credit_flows_cleanup.py per §4.1. Do not run yet.
Apply on both dev + test DBs. Run §7.3 verifications. If anything fails (trigger doesn't fire, row count drops, schema mismatch), halt and surface.
Round-trip verify: alembic downgrade -1 → alembic upgrade head preserves all data.
Apply §5.1 changes to src/loomworks/credit/models.py. Verify imports across the credit module still resolve.
Apply §5.3 changes:
conversion_observer._conversion_credit_already_writtenreconciliation_evaluator._sum_recorded_consumption_for_turnwrite_referral_credit_flow inline idempotency checkRun focused tests on the reader files (tests/test_phase_48_conversion_detection.py, tests/test_phase_48_reconciliation_evaluator.py) to catch any reader-behavior subtleties early.
Apply §5.1 + §5.2 changes (the 11 sites in flows.py + the 1 site in proposal_applier.py). Each site gets:
CreditFlowRow(...) instead of ForayActionFlowRow(...)extra_metadata (typed-column fields extracted)_foray_reserved_emit(...) call immediately after db.add(...)12 sites total. Per-site review.
Apply §5.4 mechanical text updates across the credit module + src/loomworks/api/app.py:301.
Apply §6 changes to the 8 test files. Add tests/test_foray_reserved_emit_for_credit.py. Run full test suite.
Expected: ~2670 ± 5 passing tests (some test counts may shift slightly because the typed-column predicates may merge or split parametrized cases). The 2 pre-existing failures from CR-A and CR-B baselines remain.
If tests fail, CC halts and surfaces.
grep -rn "ForayActionFlow\|foray_action_flows" --include="*.py" --include="*.md" src/ tests/ docs/
Each hit should be either in the migration 0073 (appropriate historical reference) or already updated. Anything else gets corrected.
5-7 commits on branch phase-63-credit-substrate-cleanup:
CC reports back:
0073 applied on both DBs; trigger + FK verified to surviveForayActionFlow / foray_action_flows references in source or testsOperator reviews; either approves close or requests adjustments.
On Operator approval:
phase-63-credit-substrate-cleanup at branch HEADloomworks-record/substrate/cleanup/At Checkpoint A, all of the following must be true:
credit.flows table exists with the §3.2 schema (11 columns including the 3 new typed)credit.foray_action_flows does NOT existtrg_balance_update fires on inserts to credit.flows (verified empirically)fk_credit_asset_balances_last_flow binds to credit.flows.idix_credit_flows_kind_converted_person, ix_credit_flows_kind_turn_eventevent_kind backfilled (no NULL event_kind rows)CreditFlowRow exists; ForayActionFlowRow does not# FORAY_RESERVED_LOCATION: credit.<event_kind> marker + _foray_reserved_emit(...) callflow_metadata["..."].astext queries remain)tests/test_foray_reserved_emit_for_credit.py exists with 2-3 testsgrep -rn "ForayActionFlow\|foray_action_flows" src/ tests/ returns no hits0073 on both dev + test DBsRisk. A PostgreSQL version edge case unbinds the trigger from the renamed table.
Mitigation. Step 0 records the trigger binding via pg_trigger; Step 2 verifies same binding post-rename. If unbound, halt and recreate via the downgrade-path DDL.
Risk. Production rows have JSONB shapes the §4.1 step-7 backfill doesn't cover; rows land at event_kind = 'legacy_unknown' when they should have been one of the explicit values.
Mitigation. Step 0 runs the backfill query as SELECT first; surfaces the distribution before applying as UPDATE. If unexpected, extend the backfill query before Step 2.
event_kind filter)Risk. Reader 2's refactored query adds an explicit event_kind = 'consumption_credit' filter that wasn't in the original. Could subtly differ from the JSONB-extract version for edge cases.
Mitigation. Reader 2 originally filtered by (from_party, to_party, asset_id, turn_event_id). The new filter adds event_kind. For production data, the credit-debit flow is identified by from_party=<person>, to_party='dunin7', asset_id=<credit asset> AND event_kind='consumption_credit' after CR-C. The original implicit identification by metadata.turn_event_id will now use the typed column. The tests in test_phase_48_reconciliation_evaluator.py exercise this; passing tests confirm no behavior change.
Risk. 12 near-identical 5-line emit blocks across two files might feel like ceremony where CR-A's 3 sites felt natural.
Mitigation. If the per-call-site discipline feels wrong at this scale, halt and surface for v0.2 of the design. Don't centralize unilaterally — D12 was the explicit decision.
test_phase_47_credit_substrate.pyRisk. 21 construct sites in one test file may have parametrized patterns that don't map cleanly to the new typed-column kwargs.
Mitigation. Reserved slot 1 anticipates this. If a test pattern resists mechanical adaptation, halt and surface.
extra_metadata keyword rename breaks parametrized testsRisk. Tests may parametrize across flow_metadata kwargs explicitly; the rename to extra_metadata requires careful sweep.
Mitigation. grep -rn "flow_metadata=" tests/ enumerates every site. Mechanical sweep.
Per D13, slot 1 likely absorbed by test pattern complexity, slot 2 by reader behavior subtleties. If a third surprise emerges, halt and surface for v0.2.
CR-C is the multi-day CR. The execution span risks context loss between sessions. Each step's checkpoint should be a stable checkpoint (branch + tests passing) so resumption is clean.
CR-C closes Phase A of the development assessment. There is no CR-D. The carry-forward is to Phase B (Memory-environment cleanup) and Phase C (Memory work).
src/loomworks/foray/ is fully populated after CR-C (reserved_emit, anchor_priority, and any CR-C additions per §10.3)turn_event_id, converted_person_id are UUIDs that point at other tables); if Phase B introduces a unified identifier scheme, CR-C's typed columns are early adoptersWhen FORAY anchoring eventually opens:
audit.events, memory_events, and credit.flows are all clean Loomworks-shaped tables_foray_reserved_emit hooks at 16+ call sites (3 audit + 1 memory + 12 credit) become real attestation writes_ANCHOR_PRIORITY registry guides per-event-kind anchoring priorityAfter CR-C closes, a methodology consolidation pass should lift the patterns validated across the three-CR arc. Candidates:
This CR is drafted by Claude Code on 2026-05-24 from:
substrate/cleanup/loomworks-substrate-hygiene-scoping-note-v0_2.md)phase-61-*-v0_1.{md,html})phase-62-*-v0_1.{md,html})loomworks-cr-c-credit-substrate-cleanup-design-v0_1.md)ead29a8: src/loomworks/credit/{models,flows,proposal_applier,conversion_observer,reconciliation_evaluator,specialists,bootstrap}.py, migrations/versions/0062_phase_47_credit_substrate.py, 8 test files + tests/conftest.pyNo CR-execution work begins until the Operator approves both this CR and the companion design document.