A walk through the Loomworks engine code, naming every place that writes to credit.foray_action_flows, audit.foray_events, or the Phase 25 FORAY-readiness wiring on memory_events. For each location, the inventory captures the event in plain English, the data being recorded, what's Loomworks-essential versus FORAY-readiness, and what the cleanup would look like.
The inventory is read-only — nothing in the engine has been changed. The next step (Step 2, designing replacement tables) waits for the Operator's review of this document. A complexity assessment closes the document.
/Users/dunin7/loomworks-enginemain, pull verified clean2f04e7a — Voice listening silence-submit CR v0.3 §A + §B + re-eye-test correctionsThe credit substrate writes to credit.foray_action_flows from eight distinct logical operations across twelve ForayActionFlowRow constructor calls. All writes share a common shape (transaction_id, asset_id, quantity, from_party, to_party, flow_metadata). The downstream PostgreSQL trigger update_balance_on_flow maintains credit.asset_balances from these flows.
src/loomworks/credit/flows.py:68-102 (the writer); src/loomworks/credit/grants.py:590 (the caller).
What happens, in plain English: When a person claims a credit grant (via the claim-token endpoint), the credit substrate records the issuance as a single flow from the institutional party dunin7 to the recipient's UUID, for the granted amount.
Current write:
ForayActionFlowRow(
transaction_id=txid,
asset_id=asset_id, # e.g. 'loomworks_credit_haiku'
quantity=amount, # the granted amount
from_party=DUNIN7_PARTY, # literal string 'dunin7'
to_party=str(person_id), # UUID-as-string
flow_metadata={"reason": "grant_claim", "grant_id": str(grant_id)},
)
Data being recorded: which person received which credit type in what amount, with the grant identifier linking back to the grant row.
What Loomworks actually needs: the operational fields (person_id, asset_id, amount, when, grant_id, why). No FORAY-shape fields are load-bearing here. The transaction_id is FORAY-decoration; the from_party='dunin7' is a polymorphic-VARCHAR institutional-party identifier that wouldn't be needed in a Loomworks-natural table.
Proposed Loomworks-natural replacement: a row in a credit.events table with fields (event_kind='grant_claimed', person_id, asset_id, amount, grant_id, occurred_at). The balance trigger replacement reads from credit.events instead of credit.foray_action_flows.
Proposed reserved location marker:
await _foray_reserved_emit("credit.grant_claimed", {
"person_id": str(person_id),
"asset_id": asset_id,
"amount": amount,
"grant_id": str(grant_id),
})
src/loomworks/credit/flows.py:110-237 (the writer); src/loomworks/credit/seam.py:219 (the caller via InProcessCreditSeam).
What happens, in plain English: When the Operator's Companion turn completes against a Loomworks-managed key (system-key path, not Operator-key path), the credit substrate records five flows in one logical transaction: classifier input tokens, classifier output tokens, responder input tokens, responder output tokens, and the credit debit that prices the four token-asset flows into credits.
Current write: five ForayActionFlowRow constructors at lines 157, 165, 173, 181, 223. The first four are person → anthropic flows recording token consumption; the fifth is person → dunin7 recording the credit debit. All share transaction_id and carry flow_metadata with engagement_id, turn_event_id, and pipeline_stage ('classify', 'respond', or 'credit_debit').
Data being recorded: which Operator consumed how many tokens of which model in which pipeline stage during which engagement-turn, and the converted credit cost the substrate charged for that consumption.
What Loomworks actually needs: the operational ledger fields (person_id, engagement_id, turn_event_id, per-stage token counts, credit asset and amount, when) — the substrate's accounting depends on these. The FORAY-shape (5-row pseudo-transaction, polymorphic parties) is decoration.
Proposed Loomworks-natural replacement: a single credit.consumption_events row per turn carrying the operational fields (turn_event_id PK, engagement_id, person_id, classifier_input_tokens, classifier_output_tokens, responder_input_tokens, responder_output_tokens, credit_asset_id, credit_debited, occurred_at). The five-flow structure collapses to one row because the operational question — "how much did this Operator's turn cost?" — is one question, not five.
Proposed reserved location marker:
await _foray_reserved_emit("credit.turn_consumption", {
"engagement_id": str(engagement_id),
"turn_event_id": str(turn_event_id),
"person_id": str(person_id),
"classifier_tokens": {"in": classifier_input_tokens, "out": classifier_output_tokens},
"responder_model_token_pair": responder_model_token_pair,
"responder_tokens": {"in": responder_input_tokens, "out": responder_output_tokens},
"credit_asset_id": credit_asset_id,
"credit_debit": credit_debit,
})
src/loomworks/credit/flows.py:245-269 (the writer); src/loomworks/credit/lifecycle.py:136 (the caller).
What happens, in plain English: When an Operator's account is suspended (via the Phase 48 evaluator or admin action), the credit substrate records a one-flow synthetic-asset transaction using loomworks_account_status as the asset, with quantity=1 and metadata carrying the suspension expiry and who requested it.
Current write:
ForayActionFlowRow(
transaction_id=txid,
asset_id=ACCOUNT_STATUS_ASSET, # 'loomworks_account_status'
quantity=1,
from_party=DUNIN7_PARTY,
to_party=str(person_id),
flow_metadata={
"event": "suspension",
"requested_by": str(requested_by),
"expires_at": expires_at.isoformat(),
},
)
Data being recorded: that this person's account was suspended at some time, by someone, until some expiry.
What Loomworks actually needs: the lifecycle event fact and its metadata (person_id, event_kind='suspended', requested_by, expires_at, occurred_at). The loomworks_account_status asset and quantity=1 are FORAY-shape decoration — this is not really a value flow, it's a lifecycle event being shoehorned into the value-flow table.
Proposed Loomworks-natural replacement: a row in a Loomworks persons.lifecycle_events table (or extend the existing persons columns plus an audit-shape table). The data is account-lifecycle state, not value flow.
Proposed reserved location marker:
await _foray_reserved_emit("account.suspended", {
"person_id": str(person_id),
"requested_by": str(requested_by),
"expires_at": expires_at.isoformat(),
})
src/loomworks/credit/flows.py:272-294 (the writer); src/loomworks/credit/lifecycle.py:176 (the caller).
What happens, in plain English: When a suspended Operator's account is reactivated (e.g., they verify identity via WebAuthn or operator override), the credit substrate writes a one-flow synthetic-asset transaction recording the reactivation and the auth method that allowed it.
Current write: same shape as suspension, with flow_metadata = {"event": "reactivation", "auth_method": auth_method}.
Proposed replacement / reserved marker: row in the same Loomworks lifecycle table.
await _foray_reserved_emit("account.reactivated", {
"person_id": str(person_id),
"auth_method": auth_method,
})
src/loomworks/credit/flows.py:297-319 (the writer); src/loomworks/credit/lifecycle.py:236 (the caller).
What happens, in plain English: When an Operator's account is deleted, the credit substrate writes a one-flow synthetic-asset record (the deletion fact) plus zero or more balance-zeroing flows (next item).
Current write: same shape as suspension/reactivation, with flow_metadata = {"event": "deletion", "deletion_kind": deletion_kind}.
Proposed replacement / reserved marker: Loomworks lifecycle table row.
await _foray_reserved_emit("account.deleted", {
"person_id": str(person_id),
"deletion_kind": deletion_kind,
})
src/loomworks/credit/flows.py:322-359 (the writer); src/loomworks/credit/lifecycle.py:235 (the caller).
What happens, in plain English: Immediately before the deletion flow is written, the credit substrate iterates over every credit asset the person holds with a positive balance and writes a person → dunin7 flow draining the balance to zero. Zero, one, or many flows per deletion depending on how many credit types the person held.
Current write (per credit type with positive balance):
ForayActionFlowRow(
transaction_id=txid,
asset_id=asset_id, # iterates CREDIT_ASSET_IDS
quantity=balance, # the positive balance being zeroed
from_party=person_str,
to_party=DUNIN7_PARTY,
flow_metadata={"event": "balance_zeroing", "reason": "account_deletion"},
)
Data being recorded: that this person's balance in each credit type was drained at deletion time, and the amount that was drained.
What Loomworks actually needs: the operational fact that balances were zeroed at deletion plus the amounts (useful for any future audit query of "what did this Operator's account hold at deletion"). The mechanism — using inverse-direction flows to trigger the balance-update trigger — is FORAY-shape; in a Loomworks-natural design, deletion sets balances to 0 directly (UPDATE) and the audit row records the prior balances as JSON.
Proposed Loomworks-natural replacement: a single account.balances_zeroed_at_deletion row in a Loomworks lifecycle table carrying {person_id, balances_zeroed: {asset_id: amount, ...}, occurred_at}. The balance-update trigger goes away with the action-flows table.
await _foray_reserved_emit("account.balances_zeroed_at_deletion", {
"person_id": str(person_id),
"balances_zeroed": {asset_id: amount for ...},
})
src/loomworks/credit/flows.py:367-446 (the writer); src/loomworks/credit/conversion_observer.py:166 and src/loomworks/credit/seam.py:222 (the callers); also src/loomworks/api_keys/hooks.py triggers this through the seam.
What happens, in plain English: When a referee converts (saves their own LLM API key), the conversion observer fires a referral-credit flow from dunin7 to the referrer for the configured referral amount. The function is idempotent per converted_person_id to ensure a referrer of two referees accumulates both credits but a single referee can never trigger the referral twice.
Current write:
ForayActionFlowRow(
transaction_id=txid,
asset_id=asset_id,
quantity=amount,
from_party=DUNIN7_PARTY,
to_party=referrer_str,
flow_metadata={
"event": "referral_credit",
"referrer_person_id": referrer_str,
# plus optional converted_person_id, source_grant_id, reason='conversion_detected'
},
)
Data being recorded: the referrer earned a referral credit, in which asset, in what amount, attributed to which converted-referee.
What Loomworks actually needs: referrer_person_id, asset_id, amount, converted_person_id, occurred_at. The conversion observer's idempotency check reads flow_metadata['converted_person_id'] via JSONB extract — a Loomworks-natural table makes this a typed column.
Proposed replacement: a row in the same credit.events table proposed for §1.1 (or a sibling credit.referral_events table) with (event_kind='referral_credit', referrer_person_id, asset_id, amount, converted_person_id, source_grant_id, occurred_at). The unique constraint (referrer_person_id, converted_person_id) WHERE event_kind='referral_credit' replaces the JSONB-extract idempotency check.
await _foray_reserved_emit("credit.referral_credit", {
"referrer_person_id": str(referrer_person_id),
"converted_person_id": str(converted_person_id),
"asset_id": asset_id,
"amount": amount,
})
src/loomworks/credit/proposal_applier.py:299-471 (the function); the write itself at lines 395-408.
What happens, in plain English: Phase 48 added a reconciliation evaluator that detects drift between what credit.foray_action_flows says happened and what should have happened, producing a ReconciliationProposal memory event. When the Operator approves the proposal (via an Approval Card), apply_reconciliation_correction writes one corrective flow to credit.foray_action_flows to bring the ledger back into agreement, and appends a new ReconciliationProposal version with state='approved'. The corrective flow's metadata carries applied_by (the approving person) and shape_event_id linking to the proposal.
Current write:
flow = ForayActionFlowRow(
transaction_id=txid,
asset_id=flow_kwargs["asset_id"],
quantity=flow_kwargs["quantity"],
from_party=flow_kwargs["from_party"],
to_party=flow_kwargs["to_party"],
flow_metadata=flow_metadata, # includes applied_by, shape_event_id, ...
)
The flow_kwargs are computed upstream by the Shaping specialist (pure function on the proposal payload) and the Render specialist (builds the row kwargs).
Data being recorded: an Operator-approved correction to the credit ledger, with attribution to the approving Operator and a link to the proposal that surfaced the drift.
Proposed replacement: a credit.corrections table or a row in credit.events with event_kind='reconciliation_correction' and fields (person_id, asset_id, delta_amount, applied_by_person_id, proposal_id, occurred_at). The reconciliation evaluator's drift-detection query (currently reading ForayActionFlowRow.quantity) reads from the replacement table instead.
await _foray_reserved_emit("credit.reconciliation_correction", {
"person_id": str(person_id),
"asset_id": flow_kwargs["asset_id"],
"delta_amount": flow_kwargs["quantity"],
"applied_by_person_id": str(person_id),
"proposal_id": str(proposal_id),
})
flows.py helpers except for §1.8 (proposal_applier), which constructs the row inline. The cleanup naturally lifts every write through a single seam.credit.update_balance_on_flow) reads credit.foray_action_flows and maintains credit.asset_balances. Removing the FORAY-shaped table means replacing this trigger with one that reads the Loomworks-natural credit-event table.credit.foray_action_flows via JSONB-extract metadata filters. The replacement tables turn these into typed-column queries (cleaner).flow_metadata is doing real work — the JSONB column carries engagement_id, turn_event_id, pipeline_stage, converted_person_id, applied_by, shape_event_id, etc. Replacement tables need typed columns for the load-bearing fields.The audit substrate is structurally smaller. There is one writer helper (write_setting_change_event) called from three places, all writing the same row shape with event_type='setting_change'. No other event types are written.
src/loomworks/audit/events.py:52-89 (the writer); src/loomworks/orchestration/tune_setting.py:65-99 (the local helper _audit_setting_change); call sites at tune_setting.py:489 and tune_setting.py:596.
What happens, in plain English: When the Companion classifies an Operator turn as the tune_setting intent (e.g., the Operator says "make the backdrop blur stronger"), and the substrate write succeeds, an audit row is written recording the setting key, previous value, new value, and the engagement context where the change was requested. The audit write is best-effort — a failure here is logged and swallowed because the setting change is already authoritative at that point.
Current write:
AuditForayEventRow(
tx_id=tx_id, # uuid4() per call
event_type="setting_change", # only event type today
actor_person_id=actor_person_id,
engagement_id=engagement_id, # nullable
payload={
"setting_key": setting_key, # e.g. 'voice.listening.blur_intensity'
"previous_value": <json-safe>,
"new_value": <json-safe>,
},
signature=None, # reserved for Kaspa signing; never written
timestamp=now,
)
Data being recorded: an Operator (actor_person_id) changed a setting (setting_key) from a previous value to a new value, in some engagement context.
What Loomworks actually needs: actor_person_id, setting_key, previous_value, new_value, engagement_id (nullable), occurred_at. All operational data. The tx_id (separate from id), the event_type discriminator (only one value), and the signature column are FORAY-shape decoration.
Proposed Loomworks-natural replacement: a settings.changes table (or extend person_settings with a sibling audit table) with the operational fields. Drop tx_id, event_type, signature. If future settings need typed previous/new value columns rather than JSONB, that's a refinement; today they're all primitive so JSONB is fine.
await _foray_reserved_emit("settings.changed", {
"actor_person_id": str(actor_person_id),
"setting_key": setting_key,
"previous_value": previous_value,
"new_value": new_value,
"engagement_id": str(engagement_id) if engagement_id else None,
})
src/loomworks/api/routers/me_settings.py:117-132.
What happens, in plain English: When an Operator changes a setting via the UI (e.g., clicks a control on a settings panel), the PUT endpoint writes the setting via set_setting and then calls write_setting_change_event so the click-driven change shows up in the same audit ledger as Companion-spoken changes. engagement_id is None on this path because /me/settings is person-scoped. Same best-effort posture as §2.1.
Current write: same AuditForayEventRow shape as §2.1; engagement_id=None.
Data being recorded: same fields, just sourced from the HTTP request rather than the Companion turn.
await _foray_reserved_emit("settings.changed", {
"actor_person_id": str(person.id),
"setting_key": setting_key,
"previous_value": previous,
"new_value": normalized,
"engagement_id": None,
})
signature column (reserved for future Kaspa-anchoring signing) has never been written. Like memory_events.foray_tx_ref, it's a column declared for an integration that didn't happen.tx_id separate from id is FORAY-decoration — the writer always generates a fresh UUID per call (tx_id=uuid.uuid4()), no grouping happens. A Loomworks-natural table uses one identifier.This is the more nuanced section. Memory events themselves are Loomworks-essential — they record assertion lifecycle, engagement creation, seed handling, and the substantive work the engagement does. What gets removed is the Phase 25 wiring that prepared for FORAY anchoring inside Loomworks.
src/loomworks/memory/events.py:98-104.
# Phase 25 — FORAY readiness columns. content_hash is written on
# every new event; attestation and foray_tx_ref are populated
# only when attestation is provided (Step 5) or FORAY anchors
# land (deferred).
content_hash: Mapped[str | None] = mapped_column(Text(), nullable=True)
attestation: Mapped[dict | None] = mapped_column(JSONB(), nullable=True)
foray_tx_ref: Mapped[str | None] = mapped_column(Text(), nullable=True)
Per-column status (verified by reading every source file that references these columns):
| Column | Written? | Read? | Verdict |
|---|---|---|---|
content_hash | Yes — every event, at events.py:271 via compute_content_hash(payload) | No — no source file outside memory/events.py reads this column | Pure FORAY-readiness. Removable. |
attestation | Yes — by stamp_commit_attestation at engagement-commit time (commit_orchestration.py:124-143) | Implicit — engagement-committed events carry it for future audit walks; no code reads it back | Memory-relevant data, FORAY-shape column name. The data is the Operator's WebAuthn signature at commit time, which is identity/auth provenance — Loomworks-essential. The column name attestation is FORAY-overloaded. |
foray_tx_ref | No — never written by any source file | No — never read | Pure dead column. Removable. |
content_hash column. No reader; the value is recomputable from payload if ever needed.attestation to commit_webauthn_attestation (or similar). The data is Memory-essential (the WebAuthn proof of the Operator's commit), but the FORAY-shape name muddles its meaning. Keep the column under its true name.foray_tx_ref column. Never written, never read — pure dead weight.src/loomworks/memory/events.py:107-165.
The registry maps event_kind strings to anchor-priority levels (critical | high | standard | low). 19 entries today, organised by phase introduction:
| event_kind | Priority | Introduced by |
|---|---|---|
engagement_committed | critical | Phase 25 |
engagement_committed_divergent | critical | Phase 25 |
membership_created | high | Phase 25 |
agent_registered | high | Phase 25 |
seed_drafted | standard | Phase 25 |
seed_amended | standard | Phase 25 |
discovery_to_seed_extracted | standard | Phase 53 (P53-D12) |
seed_committed_from_brief | standard | Phase 54 (P54-D5) |
engagement_created_from_assistance | standard | Phase 55 (P55-D8) |
finding_produced | standard | Phase 25 |
finding_addressed | standard | Phase 25 |
induction_cycle_recorded | standard | Phase 25 |
candidate_engagement_discarded | low | Phase 25 |
conversation_started | standard | Phase 31 |
operator_turn | standard | Phase 31 |
companion_turn | standard | Phase 31 |
key_provided | high | Phase 31 |
upload_event_received | standard | Phase 58 |
manual_content_contributed | standard | Phase 59 |
Today, the registry is used only by the injection logic at events.py:244-255. Nothing else reads it.
Keep the registry but rename it. Candidates: _FORAY_INTEGRATION_PRIORITY (neutral) or _FORAY_RESERVED_EVENT_PRIORITY (more aligned with the reserved-location pattern). Either preserves the load-bearing design intent (which Memory events would deserve FORAY anchoring when integration eventually happens) without continuing to populate columns. Recommend the second — it pairs explicitly with _foray_reserved_emit.
Move the registry to where the reserved emitter lives (see §4). The registry shouldn't sit in memory/events.py once the injection logic is gone — it's reference data for the FORAY integration plan, not Memory machinery.
src/loomworks/memory/events.py:236-255 (inside append_event).
# Phase 25 — content hash + optional _foray namespace.
# Hash first (without `_foray` if it somehow already exists),
# then for known engagement-creation event kinds inject the
# `_foray` block carrying the priority and a denormalized copy
# of the hash. The DB column `content_hash` is the source of
# truth; the in-payload copy is for downstream consumers that
# walk payloads directly.
content_hash = compute_content_hash(payload)
anchor_priority = _ANCHOR_PRIORITY.get(event_kind)
if anchor_priority is not None:
payload["_foray"] = {
"anchorable": True,
"anchor_priority": anchor_priority,
"content_hash": content_hash,
# Set by the caller for engagement-committed events when
# an attestation accompanies the commit (Step 5). Self-
# reference on the same event_id is the convention; null
# when no attestation was provided.
"attestation_ref": None,
}
Status: This logic injects a _foray block into the JSONB payload for anchorable event kinds. No source file outside this function reads the _foray block. The injection writes documentation-in-data; nothing consumes it.
Proposed treatment: Remove the injection block entirely. The _foray namespace inside payloads is dead weight on every event whose kind appears in _ANCHOR_PRIORITY (roughly half the engine's event kinds). Removing it shrinks every such row's payload by ~150 bytes plus mental-model load. append_event becomes shorter and the comment above it (currently 8 lines of Phase 25 explanation) collapses.
src/loomworks/memory/events.py:168-180.
def compute_content_hash(payload: dict[str, Any]) -> str:
"""Deterministic SHA-256 of the canonicalized event payload..."""
hashable = {k: v for k, v in payload.items() if k != "_foray"}
canonical = json.dumps(hashable, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
Callers: only line 243 of append_event, populating the content_hash column and the _foray.content_hash sub-block.
Proposed treatment: remove the function when the content_hash column is removed and the _foray injection is gone. There are no other callers in src/loomworks/. (Note: there is a different content_hash function in src/loomworks/storage/object_store.py:150 for file-blob hashing — that one is Loomworks-essential and stays.)
src/loomworks/engagement/commit_orchestration.py:109-143; called from src/loomworks/api/routers/engagements.py:536 and :632.
What it does: Updates the most-recent engagement-committed event's attestation JSONB column with the verified WebAuthn assertion payload, after the Operator completes the WebAuthn ceremony at commit time.
Status: This is Memory-essential functionality — capturing the Operator's WebAuthn proof of approval on engagement commits. The column name is FORAY-decoration; the work is identity provenance. Do not remove. Rename the column (§3.1) and update this function and its callers to use the new name.
content_hash, foray_tx_ref). One does real work under a misleading name (attestation → WebAuthn proof)._ANCHOR_PRIORITY registry contains real design intent. It names the 19 Memory event kinds that would deserve FORAY anchoring if integration happens. Worth preserving as reference data; not worth using to populate dead columns._foray block from payloads is a backward-compatible change for current callers — the column reads return cleaner payloads and nothing breaks.tests/test_phase_25_content_hash_and_foray.py, 166 lines) is dedicated to verifying the readiness wiring works. It needs surgery — see §6.The scoping note proposed _foray_reserved_emit(event_kind, payload) as the reserved location pattern — a no-op function that holds the spot in code where the future FORAY call will eventually go.
Create a new top-level module src/loomworks/foray/ and place the reserved emitter at src/loomworks/foray/reserved.py.
foray/ module establishes that home now. Today it holds only the reserved emitter and (per §3.2) the priority registry. Over time it accumulates the real integration code.src/loomworks/ sees a foray/ directory; the name signals "this is the FORAY integration surface." No confusion with credit/ (which holds value events that happen to have been FORAY-shaped) or audit/ (which holds setting-change events that happen to have been FORAY-shaped). Those modules are about credit and audit; foray/ is about FORAY.from loomworks.foray.reserved import emit works from any of them._ANCHOR_PRIORITY registry (renamed per §3.2) belongs in the same module: src/loomworks/foray/priorities.py. The two pieces — "where are the reserved emit points" + "what priority would each event get" — sit alongside each other.src/loomworks/foray/
├── __init__.py # exports: emit_reserved, FORAY_RESERVED_EVENT_PRIORITY
├── reserved.py # async def emit_reserved(event_kind, payload) → None
├── priorities.py # FORAY_RESERVED_EVENT_PRIORITY: dict[str, str]
└── README.md # documents the reservation pattern and what it isn't
The function itself is intentionally minimal:
# src/loomworks/foray/reserved.py
"""Reserved locations in the engine where FORAY emissions will eventually
happen. Today: no-op. The reservations preserve the design intent in code
so that the eventual integration phase has named locations to wire up.
NOT for use in tests as a mocking hook. NOT a side-channel logger. A
zero-cost code marker.
"""
from __future__ import annotations
from typing import Any
async def emit_reserved(event_kind: str, payload: dict[str, Any]) -> None:
"""No-op reservation. Holds the spot for a future FORAY emit call.
The eventual implementation will accept the same signature and route
to whichever FORAY adapter the deployment configures (PostgreSQL via
dunin7-foray ForayClient today; blockchain when Pluggable Persistence
Phase 3 lands).
"""
# Intentionally no-op.
return None
Alternative considered and set aside: placing the reserved emitter inside src/loomworks/audit/foray_reserved.py. Rejected because the audit module is being narrowed in this cleanup (it currently holds the FORAY-shaped audit.foray_events table that will become a Loomworks-natural settings audit table). Colocating reserved-FORAY material with the narrowed-audit module would conflate the two cleanups.
Five observations surfaced during the read. None expand the cleanup scope; all are factual flags.
Per §3.1 and §3.5: content_hash and foray_tx_ref are dead, but attestation carries the Operator's WebAuthn proof on every engagement commit. The scoping note's general framing — "remove the Phase 25 wiring" — is right in direction but the attestation column's content needs to survive under a different name. This is a small clarification but worth catching before any column-drop migration runs.
The credit.conversion_observer (idempotency check for referral credits) and credit.reconciliation_evaluator (drift detection) both READ from credit.foray_action_flows via JSONB-extract metadata queries. Replacement table design (Step 2) needs to keep these consumers in mind — the operational queries become cleaner with typed columns, but the queries themselves have to be rewritten.
credit.foray_action_flows has an AFTER INSERT trigger that maintains credit.asset_balances. Replacing the table means replacing or removing the trigger. The replacement is straightforward (a new trigger on the Loomworks-natural credit-event table, or a simpler imperative update at write time) but worth naming because it's not a code-only change — it's a schema-and-trigger change.
The engine memory notes record the audit.foray_events ship as the first FORAY-narrative substrate use, distinct from credit's FORAY-value substrate; POC of the pattern, future ships extend to role/identity/workspace event types. The POC was solving the question "can we have one FORAY-narrative table that captures multiple kinds of non-value events?" — with the FORAY-as-external clarification, that question doesn't need a yes/no answer in Loomworks anymore. Three follow-ups that were extending the pattern (role/identity/workspace) can be safely deprioritised once this cleanup lands.
Test files referencing the affected code:
ForayActionFlowRow — including the conftest setup.content_hash, _ANCHOR_PRIORITY, foray_tx_ref, or _foray — including a dedicated 166-line test_phase_25_content_hash_and_foray.py that exclusively tests the readiness wiring.The Phase 25 dedicated test file goes away with the readiness wiring removal. Other tests need to be rewritten to assert against the replacement tables rather than the FORAY-shaped ones. Mechanical work, not redesign.
| Wrong-pattern table | Direct write call sites | Helper functions | Indirect callers (test surface) |
|---|---|---|---|
credit.foray_action_flows | 12 ForayActionFlowRow(...) constructor calls in flows.py (11) + proposal_applier.py (1) | 7 writer helpers + 1 inline write in apply_reconciliation_correction | 8 test files |
audit.foray_events | 3 call sites (tune_setting × 2, me_settings PUT × 1) | 1 writer (write_setting_change_event) + 1 local helper | 4 test files |
memory_events Phase 25 wiring | 1 injection site (inside append_event at events.py:236-255) + 3 column declarations + 1 column writer | compute_content_hash, _ANCHOR_PRIORITY registry | 11 test files (including 1 dedicated to the wiring) |
Total cleanup surface: 16 distinct write code locations plus 3 column declarations plus the _foray injection block plus 1 trigger.
credit.foray_action_flows + credit.asset_balances + credit.update_balance_on_flow trigger; create replacement Loomworks-natural credit-event table(s) + new balance-update mechanism. Heaviest migration of the three.audit.foray_events + audit schema (if no other use); create replacement Loomworks-natural settings-audit table. Lightweight.memory_events: drop content_hash, drop foray_tx_ref, rename attestation to commit_webauthn_attestation. Three column changes on one table.ForayActionFlowRow / AuditForayEventRow / Phase 25 columns.test_phase_25_content_hash_and_foray.py) is deleted wholesale.ForayActionFlowRow may need to switch to the replacement model.src/loomworks/foray/ module (3 files: reserved.py, priorities.py, __init__.py) + README.credit.grant_claimed, credit.turn_consumption, account.suspended, account.reactivated, account.deleted, account.balances_zeroed_at_deletion, credit.referral_credit, credit.reconciliation_correction, settings.changed.Moderate — bigger than the scoping note's casual framing of "cleanup three wrong-shape tables," but not entangled with downstream logic that would require redesign elsewhere.
The work breaks into three uneven pieces:
credit.foray_action_flows is the heaviest lift by far. 12 write sites, a balance-update trigger, two downstream readers (conversion observer, reconciliation evaluator), and the replacement table design has to satisfy a real audit-and-billing query workload. This is multi-day work, not afternoon work. Reasonable scoping: a CR of its own for the credit substrate cleanup.audit.foray_events is the lightest lift. 3 write sites through 1 helper, no downstream readers, replacement is a simple table. Half a day of work plus tests.memory_events Phase 25 wiring is mechanical for the column removals (content_hash, foray_tx_ref) and injection deletion. The attestation column rename touches 3 files plus tests. The _ANCHOR_PRIORITY registry move is one file edit plus import updates. Half a day.Overall: a CR-and-a-half of work. The right shape might be three sibling CRs that can ship independently (audit cleanup first because it's smallest, then memory_events Phase 25 wiring, then credit substrate cleanup as the largest). Each CR is self-contained — no cross-CR dependencies because each table is independent.
credit.foray_action_flows.transaction_id outside the engine; nothing reads audit.foray_events.tx_id._ANCHOR_PRIORITY registry has grown organically across Phases 25/31/53/54/55/58/59 — each phase added entries. If the cleanup is deferred and more phases land that add entries, the registry grows. Pinning this work before the next event-kind addition is worth doing.update_balance_on_flow trigger is the only piece that's not pure-Python — replacing it requires PostgreSQL DDL plus testing that the new mechanism preserves the balance-integrity invariant. Not hard, but it's the one piece of the cleanup that crosses the SQL/Python boundary cleanly.