DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path substrate/cleanup/loomworks-cr-c-credit-substrate-cleanup-design-v0_1.md

CR-C Credit Substrate Cleanup Design — v0.1

Version. 0.1

Date. 2026-05-24

Author. Claude Code, on direction from Marvin Percival (DUNIN7 Operator)

Companion documents.

Baseline. loomworks-engine ead29a8 (CR-B merge). Alembic head 0072. Test count 2670 pytest passing at baseline.


1. Purpose

CR-C — credit substrate cleanup — is the third and final sibling Change Request from the substrate hygiene scoping note v0.2. It transforms credit.foray_action_flows into a clean Loomworks-native credit.flows table, replaces the FORAY-shaped framing with operationally-meaningful typed columns extracted from JSONB, validates the reserved-emit pattern at twelve call sites (the scale-up test the pattern was waiting for), preserves the existing PostgreSQL trigger (which survives the rename without modification), and refactors the two downstream readers from JSONB-extract queries into typed-column queries.

CR-C closes Phase A of the development assessment. When CR-C ships, the FORAY-shape cleanup arc completes; the substrate is clean of speculative FORAY framing across all three tables.

The work is multi-day:

The PostgreSQL trigger — the first-time element in this CR series — turns out to be the least complicated piece, because PostgreSQL's ALTER TABLE ... RENAME operations preserve the trigger binding (the trigger is associated with the table's OID, not its name; the trigger body references NEW.* column names which are not being changed). The trigger continues to fire on inserts to the renamed table without any DDL touching it.


2. Current state — credit.foray_action_flows

2.1 Schema

Created by migration 0062_phase_47_credit_substrate.py. Schema credit, table foray_action_flows. Append-only with the trg_balance_update trigger maintaining credit.asset_balances atomically.

| Column | Type | Nullable | Default | Notes | |---|---|---|---|---| | id | UUID | NOT NULL (PK) | gen_random_uuid() | Row identifier | | transaction_id | UUID | NOT NULL | — | Groups multi-row transactions (consumption writes 5 rows under 1 txid) | | asset_id | VARCHAR(64) | NOT NULL | — | Asset moving (e.g., loomworks_credit_sonnet, anthropic_haiku_4_input, account_status) | | quantity | BigInt | NOT NULL | — | Amount moving (always positive at the flow level; the trigger applies signs per direction) | | from_party | VARCHAR(128) | NOT NULL | — | Source party identifier (UUID string or institutional name like dunin7 / anthropic) | | to_party | VARCHAR(128) | NOT NULL | — | Destination party identifier | | timestamp | TIMESTAMPTZ | NOT NULL | now() | When the flow occurred | | metadata | JSONB | NOT NULL | '{}'::jsonb | Per-event-kind details (DB column is metadata; Python attribute is flow_metadata per SQLAlchemy reserved-name workaround) |

Live data state in dev DB: 2 rows, 2 unique transaction_ids (no multi-row transactions present yet in dev). Production may have more shape; the migration handles both.

2.2 Indexes

Per migration 0062 (verbatim review needed at execution-time Step 0). The pkey on id is implicit; the index pattern serves the trigger + the two reader queries (from_party/to_party lookups for balances; metadata-extract for the two readers).

2.3 Foreign keys

One real FK: credit.asset_balances.last_flow_id references credit.foray_action_flows.id (constraint name fk_credit_asset_balances_last_flow). This is an intra-credit FK — both tables live in the credit schema and share the same DeclarativeBase (the credit module's independent metadata). The FK survives the rename transparently in PostgreSQL.

This is structurally different from CR-A's situation, where the FK would have crossed independent-metadata boundaries. CR-A's D5 deviation does not apply here. The FK stays.

2.4 PostgreSQL trigger — credit.update_balance_on_flow

The trigger lives at migrations/versions/0062_phase_47_credit_substrate.py:432-466. Body verbatim:


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();

Key observation: the trigger body references NEW.to_party, NEW.from_party, NEW.asset_id, NEW.quantity, NEW.id — none of these columns are being renamed by CR-C. The trigger survives the rename without modification.

The trigger does NOT reference metadata, timestamp, or any column being added in this CR. The DDL for the trigger does not need to change.

2.5 Constructor sites — 12 total

src/loomworks/credit/flows.py — 11 sites:

| Line | Function | Business event | |---|---|---| | 92 | write_issuance_flow | Initial credit grant claim (metadata.reason="grant_claim", metadata.grant_id=...) | | 157 | write_consumption_flows | Classifier-input token flow (person → anthropic, classify stage) | | 165 | write_consumption_flows | Classifier-output token flow (person → anthropic, classify stage) | | 173 | write_consumption_flows | Responder-input token flow (person → anthropic, respond stage) | | 181 | write_consumption_flows | Responder-output token flow (person → anthropic, respond stage) | | 223 | write_consumption_flows | Credit-debit flow (person → dunin7, credit_debit stage; the actual credit decrement) | | 255 | write_suspension_flow | Suspension event (metadata.event="suspension") | | 281 | write_reactivation_flow | Reactivation event (metadata.event="reactivation") | | 306 | write_deletion_flow | Deletion event (metadata.event="deletion") | | 344 | write_balance_zeroing_flows | Per-asset balance zeroing at deletion (metadata.event="balance_zeroing") | | 436 | write_referral_credit_flow | Referral credit issuance (metadata.event="referral_credit", metadata.converted_person_id=...) |

src/loomworks/credit/proposal_applier.py — 1 site:

| Line | Function | Business event | |---|---|---| | 399 | apply_proposal (corrective flow) | Reconciliation corrective flow (metadata.shape_event_id=..., metadata.applied_by=...) |

2.6 Downstream readers — 3 sites (inventory said 2)

CC's grep found a third reader inside write_referral_credit_flow itself, used for the function's own idempotency check. The inventory framed this as part of the writer; mechanically it's a third JSONB-extract reader.

Reader 1 — conversion_observer._conversion_credit_already_written (src/loomworks/credit/conversion_observer.py:77-82). Used to enforce Phase 48 §12.4 idempotency: at most one referral credit per converted-person-id. Extracts:

Reader 2 — reconciliation_evaluator._sum_recorded_consumption_for_turn (src/loomworks/credit/reconciliation_evaluator.py:211-222). Used for drift detection: finds the consumption flow for a given (person_id, credit_asset_id, turn_event_id). Extracts:

Reader 3 — write_referral_credit_flow inline (src/loomworks/credit/flows.py:399-423). Same idempotency check as Reader 1, run by the writer before deciding to write. Extracts the same fields: metadata.event == "referral_credit", metadata.converted_person_id == <uuid>.

All three readers will become typed-column queries after CR-C. This is the architectural upside: queries become readable and indexable.

2.7 Test surface — 8 files (inventory matches), but dense

CC's per-file grep:

| File | Construct/INSERT sites | Total ForayActionFlow references | |---|---|---| | tests/conftest.py | 0 | 1 (TRUNCATE chain) | | tests/test_phase_47_credit_substrate.py | 21 | 24 | | tests/test_phase_48_reconciliation_evaluator.py | 1 raw-SQL INSERT | 1 | | tests/test_phase_48_conversion_detection.py | 0 | 29 | | tests/test_phase_49_near_exhaustion.py | 1 | 2 | | tests/test_phase_49_proposal_applier.py | 0 | 5 | | tests/test_phase_49b_amendment.py | 0 | 1 | | tests/test_phase_50_conversion_credit_override.py | 0 | 17 |

8 files (inventory count holds). The per-file work is denser than the count suggests:

Most of the work is mechanical column-rename: ForayActionFlowRowCreditFlowRow; flow_metadataextra_metadata; timestampoccurred_at; raw-SQL metadataextra_metadata. Reader-side queries that did JSONB-extract need refactoring to the new typed columns (event_kind, turn_event_id, converted_person_id).

2.8 No other production-code dependencies

CC's grep across all of src/:

No production code outside src/loomworks/credit/ depends on the table being literally named foray_action_flows. No scheduled jobs. No monitoring queries (verified by absence in pg_views).


3. Target state — credit.flows

3.1 Naming

Table: credit.flows (drop foray_action_ prefix; preserve the "flow" vocabulary the codebase uses everywhere — write_*_flow, flow_metadata, last_flow_id).

Model class: CreditFlowRow (renamed from ForayActionFlowRow).

Module: src/loomworks/credit/models.py stays; only the class + tablename rename.

Why flows instead of events: CR-A picked audit.events because the audit substrate is event-flavored (one row per state change, no double-entry shape). Credit is flow-shaped — every row is half of a double-entry transaction with explicit from_partyto_party movement. The vocabulary is load-bearing: "flow" describes what each row represents; "event" would lose the bookkeeping framing.

3.2 Schema

| Column | Type | Nullable | Default | Notes | |---|---|---|---|---| | id | UUID | NOT NULL (PK) | gen_random_uuid() | unchanged | | transaction_id | UUID | NOT NULL | — | unchanged — actually groups multi-row transactions | | event_kind | VARCHAR(64) | NOT NULL | — | NEW — extracted from JSONB (was metadata.event); replaces the discriminator that lived in JSONB | | asset_id | VARCHAR(64) | NOT NULL | — | unchanged | | quantity | BigInt | NOT NULL | — | unchanged | | from_party | VARCHAR(128) | NOT NULL | — | unchanged | | to_party | VARCHAR(128) | NOT NULL | — | unchanged | | occurred_at | TIMESTAMPTZ | NOT NULL | now() | RENAMED from timestamp (same convention as CR-A and CR-B) | | turn_event_id | UUID | NULL | — | NEW — extracted for consumption flows (was metadata.turn_event_id); null for non-consumption | | converted_person_id | UUID | NULL | — | NEW — extracted for referral_credit flows (was metadata.converted_person_id); null for non-referral | | extra_metadata | JSONB | NOT NULL | '{}'::jsonb | RENAMED from metadata (Python attribute stays flow_metadata for SQLAlchemy compatibility); holds whatever remains in JSONB after typed columns are extracted |

Why rename the JSONB column from metadata to extra_metadata: the current ORM workaround maps Python flow_metadata to DB metadata. The reason was SQLAlchemy's reserved attribute metadata on DeclarativeBase. With CR-C extracting most of the per-event-kind fields into typed columns, the residual JSONB is "everything else / overflow." A new DB column name extra_metadata clears the SQLAlchemy reserved-name conflict and means the Python attribute can match the DB column name directly. ORM cleanup as a side effect.

3.3 The event_kind taxonomy

The new typed column replaces what was either metadata.event (explicit) or metadata.pipeline_stage (consumption) or implicit-from-context (issuance, corrective). The proposed taxonomy:

| event_kind value | When | Today's metadata | |---|---|---| | issuance | write_issuance_flow (grant claim) | metadata.reason="grant_claim", metadata.grant_id=... | | consumption_token | write_consumption_flows rows 1-4 (token-asset flows) | metadata.pipeline_stage="classify" or "respond" | | consumption_credit | write_consumption_flows row 5 (credit-debit) | metadata.pipeline_stage="credit_debit" | | suspension | write_suspension_flow | metadata.event="suspension" | | reactivation | write_reactivation_flow | metadata.event="reactivation" | | deletion | write_deletion_flow | metadata.event="deletion" | | balance_zeroing | write_balance_zeroing_flows | metadata.event="balance_zeroing" | | referral_credit | write_referral_credit_flow | metadata.event="referral_credit" | | corrective | proposal_applier.apply_proposal | metadata.shape_event_id=... |

Nine values. Application code writes event_kind explicitly at each of the 12 constructor sites.

3.4 Indexes

Three indexes survive untouched (assumes today's indexes serve id, (from_party, asset_id) and (to_party, asset_id) — Step 0 confirms):

Two new indexes for the typed-column readers:

Both new indexes are small and cheap because the filtered event kinds are minorities of total rows.

3.5 Foreign keys

Survive untouched: credit.asset_balances.last_flow_idcredit.flows.id (constraint name preserved through table rename in PostgreSQL).

No new FKs. Following CR-A's posture-keep precedent: turn_event_id could reference memory_events.event_id, but that would entangle credit metadata with memory metadata (cross-base in SQLAlchemy terms). The CR-A deviation D5 applies here at the ORM level. At the DB level, no FK needed either — referential integrity for turn_event_id is application-level (the consumption hook writes both the memory event AND the flow with the same UUID; orphan flows can't occur in practice).

Same logic for converted_person_id (could FK to persons.id but cross-base; not needed).

3.6 PostgreSQL trigger — survives unchanged

The trigger body at 0062:432-466 references NEW.id, NEW.to_party, NEW.from_party, NEW.asset_id, NEW.quantity. None of those columns are renamed by CR-C. The trigger continues to fire on inserts to credit.flows (post-rename) without modification.

The trigger's binding is to the table OID, not the table name — PostgreSQL's ALTER TABLE ... RENAME preserves the binding automatically.

No DDL touches the trigger in CR-C. This was the surprise CC expected the trigger to introduce; in practice it introduces none.

3.7 ORM model

src/loomworks/credit/models.py:

3.8 No trigger-removal decision

The kickoff §2 framed the trigger as a potential drop candidate ("does the new table need a trigger at all? If balance projection can be handled in application code, the trigger goes away entirely"). CC's recommendation: keep the trigger.

Rationale:

Defer the "should this be application logic" question to a future ship if it ever becomes a real friction point. CR-C scope is "the FORAY-shape framing on the table"; the trigger isn't FORAY-shaped, it's domain-shaped.


4. Migration plan

4.1 One Alembic migration, in-place transform

0073_credit_flows_cleanup.py. Revision 0073, revises 0072.

The migration uses ALTER TABLE operations rather than CREATE/COPY/DROP because PostgreSQL's RENAME operations are metadata-only and the existing FK, trigger, and indexes all follow the renamed table transparently.

Upgrade:


# 1. Rename the table.
op.rename_table(
    "foray_action_flows", "flows", schema="credit"
)
# (The trg_balance_update trigger continues to fire on the renamed
# table; the fk_credit_asset_balances_last_flow constraint follows
# the renamed target; indexes follow.)

# 2. Rename columns: metadata → extra_metadata; timestamp → occurred_at.
op.alter_column(
    "flows", "metadata", new_column_name="extra_metadata",
    schema="credit",
)
op.alter_column(
    "flows", "timestamp", new_column_name="occurred_at",
    schema="credit",
)

# 3. Add three new typed columns, nullable initially for backfill.
op.add_column(
    "flows", sa.Column("event_kind", sa.String(64), nullable=True),
    schema="credit",
)
op.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",
)

# 4. Backfill event_kind from existing JSONB.
op.execute("""
    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
           );
""")

# 5. Backfill turn_event_id and converted_person_id.
op.execute("""
    UPDATE credit.flows
       SET turn_event_id = (extra_metadata->>'turn_event_id')::uuid
     WHERE extra_metadata ? 'turn_event_id';
""")
op.execute("""
    UPDATE credit.flows
       SET converted_person_id = (extra_metadata->>'converted_person_id')::uuid
     WHERE extra_metadata ? 'converted_person_id';
""")

# 6. Tighten event_kind to NOT NULL.
op.alter_column(
    "flows", "event_kind",
    nullable=False,
    schema="credit",
)

# 7. Create the two new indexes for the typed-column readers.
op.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 new indexes, drop new columns, rename columns back, rename table back. The dropped columns lose their data; the JSONB still carries the original information (extracted-not-moved), so the application-level fallback works after downgrade.

4.2 Data preservation

The migration is in-place. The 2 dev-DB rows (and however many production rows) preserve every column value:

The trigger keeps maintaining credit.asset_balances atomically throughout the migration.

4.3 legacy_unknown for historical rows that don't match any pattern

The dev DB has 2 rows with no metadata.event and no pipeline_stage and no grant_id and no shape_event_id. These were likely seeded by tests and never matched the production patterns. They'll land at event_kind = 'legacy_unknown'. Production may or may not have such rows; the backfill handles both cases.

The legacy_unknown literal is a marker — application code never writes it (new rows always have an explicit event_kind). Future queries can filter for event_kind != 'legacy_unknown' to exclude historical noise.

4.4 Rollback

Production rollback path: fail-forward via a new migration. The downgrade is for local-dev iteration. Same pattern as CR-A and CR-B.


5. Code changes

5.1 ORM model — src/loomworks/credit/models.py

5.2 src/loomworks/credit/flows.py

Every constructor site updates to:

  1. Use CreditFlowRow(...) (was ForayActionFlowRow(...))
  2. Pass event_kind=<value> explicitly (per the §3.3 taxonomy)
  3. Pass turn_event_id=<uuid> where applicable (consumption flows; the consumption_credit row gets the same turn_event_id as the four token rows)
  4. Pass converted_person_id=<uuid> where applicable (referral_credit flow's idempotency key)
  5. The extra_metadata carries only the residual fields after the typed columns are extracted — e.g., for consumption flows, pipeline_stage survives in extra_metadata for diagnostic purposes
  6. Each db.add(CreditFlowRow(...)) call is followed by a _foray_reserved_emit call per the per-call-site discipline

Specific changes per function:

5.3 Reader refactoring — three sites

Reader 1 — conversion_observer._conversion_credit_already_written:


# Before:
select(ForayActionFlowRow.transaction_id).where(
    ForayActionFlowRow.flow_metadata["event"].astext == "referral_credit",
    ForayActionFlowRow.flow_metadata["converted_person_id"].astext == str(converted_person_id),
)

# After:
select(CreditFlowRow.transaction_id).where(
    CreditFlowRow.event_kind == "referral_credit",
    CreditFlowRow.converted_person_id == converted_person_id,
)

Cleaner. Indexed by ix_credit_flows_kind_converted_person.

Reader 2 — reconciliation_evaluator._sum_recorded_consumption_for_turn:


# Before:
select(ForayActionFlowRow.quantity).where(
    ForayActionFlowRow.from_party == str(person_id),
    ForayActionFlowRow.to_party == "dunin7",
    ForayActionFlowRow.asset_id == credit_asset_id,
    ForayActionFlowRow.flow_metadata["turn_event_id"].astext == str(turn_event_id),
)

# After:
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,
)

Adding event_kind filter explicitly. Indexed by ix_credit_flows_kind_turn_event.

Reader 3 — write_referral_credit_flow inline (lines 399-423):

Same refactor as Reader 1 — replaces both branches of the conditional (Phase 48 vs Phase 47 fallback) with typed-column predicates.

5.4 src/loomworks/credit/proposal_applier.py:399

5.5 Reserved-emit placement

All 12 sites get the reserved-emit hook immediately after db.add(...):


# 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 emit fires regardless of whether the flow is part of a multi-row transaction (12 sites → 12 emits per insert; a single consumption transaction fires 5 emits). This matches the per-call-site discipline CR-A validated at 3 sites and CR-B at 1: each business event emit once.

5.6 Comment + docstring sweeps

src/loomworks/credit/specialists.py:116, 162 — comment text references credit.foray_action_flows and ForayActionFlowRow. Update to credit.flows / CreditFlowRow.

src/loomworks/credit/bootstrap.py:276, 413 — comment text references the old name. Update.

src/loomworks/api/app.py:301 — comment text. Update.

src/loomworks/credit/conversion_observer.py:15, 72 — docstring references. Update.

src/loomworks/credit/reconciliation_evaluator.py:5, 32, 209 — docstring references. Update.


6. Test impact

6.1 tests/conftest.py

Single line update: TRUNCATE chain replaces credit.foray_action_flows with credit.flows.

6.2 tests/test_phase_47_credit_substrate.py

The densest test file (21 construct sites). Mechanical changes:

Approximate scope: ~21 construct sites × 3-5 line changes each = ~75 line changes in this file.

6.3 tests/test_phase_48_reconciliation_evaluator.py

6.4 tests/test_phase_48_conversion_detection.py

29 references — mostly read-side ORM queries. Mechanical updates:

6.5 tests/test_phase_50_conversion_credit_override.py

17 references. Similar shape to test_phase_48_conversion_detection — read-side queries against the referral-credit flow shape. Same refactor pattern.

6.6 tests/test_phase_49_*.py

Smaller surface (2 + 5 + 1 references across three files). Mechanical updates.

6.7 New tests — minimum surface

One smoke test file: tests/test_foray_reserved_emit_for_credit.py — 2-3 tests asserting the reserved-emit hook fires at credit flow writes with the correct kind and payload shape. Same pattern as CR-B's test_foray_reserved_emit_for_memory.py.

6.8 No test renames

Unlike CR-A and CR-B, no credit test file has a Phase-25-style filename framing that the cleanup invalidates. Test file names like test_phase_47_credit_substrate.py describe their phase-origin which is historically correct; no need to rename.

6.9 Total test churn estimate

Total: ~170 lines of test changes. Mechanical but voluminous.


7. Build sequencing (preview)

The full step plan lives in the companion CR document. Summary:

  1. Step 0 — pre-flight (verify baseline at ead29a8; alembic head 0072; row counts in dev + test DBs; trigger still bound to foray_action_flows; view dependency check)
  2. Step 1 — write Alembic migration 0073 (rename table + columns; add new columns; backfill; tighten NOT NULL; create indexes)
  3. Step 2 — apply migration on both DBs; verify schema; check trigger still binds to renamed table; round-trip downgrade/upgrade
  4. Step 3 — update src/loomworks/credit/models.py (class rename, column changes, FK target update)
  5. Step 4 — refactor the 3 readers to use typed-column queries
  6. Step 5 — update all 12 constructor sites with new typed-column kwargs + reserved-emit calls
  7. Step 6 — update tests (8 files; ~170 lines of changes)
  8. Step 7 — documentation sweep (comment-text references across src/)
  9. Step 8 — commit chain (5-7 commits on phase-63-credit-substrate-cleanup)
  10. Checkpoint A — Operator confirms before close
  11. Close — tag phase-63-credit-substrate-cleanup

8. Architectural decisions worth surfacing

8.1 In-place transform vs. drop-and-recreate

Design recommendation: in-place transform via RENAME operations. The PostgreSQL trigger binds to the table OID (not name), the intra-credit FK follows the renamed table automatically, the existing data carries through every column rename without motion. Drop-and-recreate would require coordinating the trigger drop/recreate, the FK drop/recreate, the data copy, and the index recreate — substantially more DDL with no benefit.

8.2 PostgreSQL trigger survives untouched

The trigger references NEW.id, NEW.to_party, NEW.from_party, NEW.asset_id, NEW.quantity — none of which are renamed. The trigger continues to maintain credit.asset_balances atomically across the migration. CR-C does not touch the trigger.

This was the surprise CC expected. The trigger turned out to be the least complicated piece of CR-C.

8.3 Trigger preservation (not removal)

The kickoff §2 framed trigger removal as a candidate. Recommendation: keep the trigger. Removing it would (a) move atomic balance-maintenance into application code (every flow writer must update balances correctly), (b) require concurrency handling at the application layer (the trigger's ON CONFLICT DO UPDATE is atomic via PostgreSQL row lock; reimplementing in Python is non-trivial), and (c) expand CR-C scope without demonstrated benefit. The "move DB logic to application" question is real but speculative; defer to a future ship if it ever surfaces as friction.

8.4 event_kind taxonomy explicit (9 values)

Application code writes event_kind explicitly at each of the 12 sites. No derivation, no default — the writer knows the kind. This makes the new typed column self-documenting and prevents the JSONB-shape ambiguity that today's code carries (some flows use metadata.event, some use metadata.pipeline_stage, some use neither). After CR-C, every flow has an explicit kind.

The 9 values come from the 7 functions in flows.py plus the corrective flow in proposal_applier.py plus splitting consumption into consumption_token (4 rows) and consumption_credit (1 row). The split is meaningful — consumption_credit is what Reader 2 needs to identify, and the asset_id distinguishes anthropic-token assets from loomworks-credit assets.

8.5 Three readers refactor to typed-column queries

Inventory said two readers; grep found three (the third inside write_referral_credit_flow itself for idempotency). All three refactor to typed-column queries. The JSONB-extract queries become predicates against event_kind, turn_event_id, converted_person_id. Indexed, readable, typed. This is real architectural cleanup.

8.6 Column rename metadataextra_metadata

The current ORM workaround (Python flow_metadata, DB metadata) was a SQLAlchemy-reserved-name escape. With CR-C extracting most fields into typed columns, the remaining JSONB is "overflow" — extra_metadata is the right name for it, and it cleans up the ORM at the same time. Python attribute and DB column match.

The Python attribute flow_metadata survives only as backward-compat — actually no, CC's recommendation is to also rename the Python attribute to extra_metadata. The workaround was annoying; the cleanup is the right moment to retire it. Test code refers to flow_metadata= in keyword arguments; the test sweep updates all references.

8.7 FK posture follows CR-A precedent for ORM-level; intra-credit FK survives at DB level

The credit.asset_balances.last_flow_idcredit.flows.id FK is intra-credit (same DeclarativeBase) and survives transparently. CR-A's D5 deviation does not apply at all.

No new cross-base FKs are added (the new typed columns turn_event_id and converted_person_id could FK to memory_events.event_id and persons.id respectively, but both would entangle credit metadata with other DeclarativeBases). Following CR-A's posture, these stay FK-free.

8.8 legacy_unknown event_kind for historical rows

Production rows that don't match any of the 9 explicit patterns get event_kind = 'legacy_unknown'. Application code never writes this value. Future analytics queries can filter it out. The 2 dev-DB rows fall in this category (test seed data with empty metadata). CR-C accepts these as historical noise rather than trying to derive a kind.

8.9 Index design — two new indexes, both small

The two new indexes ((event_kind, converted_person_id) and (event_kind, turn_event_id)) are small because filtered event kinds are minorities of total rows. Both serve specific reader queries that today are JSONB-extract sequential scans. The migration creates them after the backfill so the existing rows are indexed.

8.10 Test scope confidence

CC's per-file grep showed 8 test files (inventory match) but the per-file work is denser than the count suggests. test_phase_47_credit_substrate.py alone has 21 construct sites; test_phase_48_conversion_detection.py has 29 references. Reserved slot 1 should anticipate the test surface being denser than mechanical column-rename suggests — some tests may exercise edge cases that the new event_kind taxonomy doesn't quite fit, requiring local adaptation.


9. Risk assessment

9.1 Backfill query has unexpected pattern

Risk. Production rows have JSONB shapes the backfill doesn't anticipate. Some rows could end up at event_kind = 'legacy_unknown' when they should have been one of the explicit values.

Mitigation. Step 0 pre-flight runs the backfill query as a SELECT (not UPDATE) and prints the (event_kind, count) distribution. Surface before applying the migration. If unexpected values surface, the backfill query gets extended before Step 2.

9.2 Trigger doesn't survive the rename (very unlikely)

Risk. Some PostgreSQL version detail breaks the trigger-table binding during ALTER TABLE ... RENAME.

Mitigation. Step 0 documents the trigger's current binding via pg_trigger query; Step 2 verifies the same binding is present on the renamed table. If the trigger is unbound, halt and recreate (the migration's downgrade includes the trigger recreation pattern for completeness).

9.3 Test patterns rely on flow_metadata= keyword argument

Risk. Tests use the Python attribute name flow_metadata extensively. The rename to extra_metadata is mechanical but affects many lines.

Mitigation. The test sweep grep finds every usage. Mechanical change, no judgment calls. The work is voluminous but not risky.

9.4 The 9-value event_kind taxonomy misses a real case

Risk. During Step 5 execution, a write path emerges that doesn't fit any of the 9 values.

Mitigation. The taxonomy was built from a careful read of flows.py + proposal_applier.py. Any missed pattern surfaces at Step 5; halt and surface for v0.2 of the taxonomy.

9.5 Reader query refactor changes behavior subtly

Risk. A typed-column predicate could subtly differ from the JSONB-extract predicate in some edge case (e.g., empty-string vs NULL semantics).

Mitigation. Reader 1 + Reader 3 both check for existence; their predicate maps directly (event_kind = 'referral_credit' AND converted_person_id = X is equivalent to metadata->'event' = 'referral_credit' AND metadata->'converted_person_id' = X^cast-text). Reader 2 adds an explicit event_kind = 'consumption_credit' filter that wasn't in the original (which filtered by from_party/to_party/asset_id) — this tightens the query and shouldn't return extra rows for any production data, but it's worth verifying.

9.6 Reserved slot budget

Risk. The test surface is dense (170 lines of changes across 8 files). The per-call-site reserved-emit pattern adds ~30 lines across 12 sites. Total code churn is the highest of the three CRs.

Mitigation. Same 2-slot reservation as CR-A and CR-B. CC's anticipation:

9.7 Production rollback path

Risk. If something surfaces in production after CR-C ships, the alembic downgrade would un-rename the table, drop the new columns, and rename extra_metadata back to metadata. Application code that already started using the new ORM model would break.

Mitigation. Production rollback path remains fail-forward via a new migration (same as CR-A and CR-B). The downgrade is for local-dev iteration. The pattern is established.


10. Carry-forward

CR-C closes Phase A of the development assessment. Carry-forward items go to future work, not to a CR-D (there is none).

10.1 To Phase B (Memory-environment cleanup)

10.2 To future FORAY integration

When FORAY integration eventually opens, the substrate is ready:

10.3 Deferred from CR-C explicitly

10.4 Methodology notes from the three-CR arc

The CR-A → CR-B → CR-C arc validated several patterns. The implementation-notes pass after CR-C closes will consolidate them. Candidates:


11. Confidence

Ready for Operator review and CR execution.

The pattern inherits cleanly from CR-A and CR-B. The trigger preservation question was the main unknown going in; turns out the trigger needs no DDL changes at all. The reader refactoring is real architectural cleanup (JSONB-extract → typed-column predicates). The 12-site scale-up test of the per-call-site discipline is the methodology validation moment.

The work is voluminous (170 lines of test changes; 12 constructor-site updates; 3 reader refactorings; 1 migration with backfill) but mechanical and low-risk. The estimated CR-execution time is wider than CR-A's or CR-B's because the test surface is dense — most of the time is mechanical sweep work rather than design decisions.

Estimated CR-execution time: 4-7 hours including thorough test runs, sweeps, and Checkpoint A reporting. Could be longer if reserved slots are consumed early; could be shorter if the pattern is now so well-established that the work moves fast.

When CR-C closes, the FORAY-shape cleanup arc completes. Phase A done. Phase B and Phase C horizons open.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks CR-C Credit Substrate Cleanup Design — v0.1 — 2026-05-24