Version. 0.1
Date. 2026-05-24
Author. Claude Code, on direction from Marvin Percival (DUNIN7 Operator)
Companion documents.
loomworks-substrate-hygiene-scoping-note-v0_2.md (record substrate/cleanup/)loomworks-foray-integration-locations-v0_1.md (record substrate/cleanup/)phase-61-cr-audit-cleanup-v0_1.md (this design's executable CR; produced alongside)Baseline. loomworks-engine 2f04e7a (voice listening silence-submit ship). Alembic head 0070.
CR-A — the audit cleanup — is the first of three sibling Change Requests proposed by the substrate hygiene scoping note v0.2. It replaces the FORAY-shaped audit.foray_events table with a clean Loomworks audit.events table, drops the FORAY-readiness fields that have never been written, and seeds the reserved-location pattern that CR-B and CR-C inherit.
This document captures the design. The companion CR document captures the executable step-by-step build plan.
The work is small and well-bounded: one table replacement, three call sites, four affected test files, one new module (src/loomworks/foray/) created with a single no-op function. Half a day of work. No downstream readers (confirmed in §2.4). No PostgreSQL trigger.
The design also validates the reserved-location pattern at the smallest possible surface before CR-B applies it to the foundational memory-events substrate and CR-C applies it to credit.
audit.foray_eventsCreated by migration 0068_audit_foray_events.py. Schema audit, table foray_events. Append-only. No PostgreSQL trigger. No materialized side table.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL (PK) | gen_random_uuid() |
Row identifier |
tx_id |
UUID | NOT NULL | — | FORAY transaction identifier; 1:1 with id today |
event_type |
VARCHAR(64) | NOT NULL | — | Today: "setting_change" only |
actor_person_id |
UUID | NOT NULL | — | Who performed the action |
engagement_id |
UUID | NULL | — | Engagement context; null for project-less converse |
payload |
JSONB | NOT NULL | '{}'::jsonb |
Type-specific data (today: {setting_key, previous_value, new_value}) |
signature |
LargeBinary | NULL | — | FORAY-readiness; never written in v0.1 |
timestamp |
TIMESTAMPTZ | NOT NULL | now() |
When the event occurred |
Four indexes, schema audit:
ix_audit_foray_events_tx_id on (tx_id)ix_audit_foray_events_actor_ts on (actor_person_id, timestamp DESC)ix_audit_foray_events_type_ts on (event_type, timestamp DESC)ix_audit_foray_events_engagement_ts on (engagement_id, timestamp DESC)One helper function, three call sites.
Helper writer — src/loomworks/audit/events.py:52 — write_setting_change_event(...). Takes actor_person_id, setting_key, previous_value, new_value, engagement_id, db, optional now. Returns the generated tx_id. Calls db.add(row) + db.flush(); does not commit (the calling transaction owns commit).
Call site 1 — src/loomworks/orchestration/tune_setting.py:489 — Companion-routed tune (tune_setting orchestrator, ACTION_TUNED branch). Wrapped in _audit_setting_change (tune_setting.py:65) which swallows SQLAlchemyError per CR §A.4 best-effort discipline.
Call site 2 — src/loomworks/orchestration/tune_setting.py:596 — Companion-routed reset (tune_setting orchestrator, ACTION_RESET branch). Same wrapper, same swallow discipline.
Call site 3 — src/loomworks/api/routers/me_settings.py:117 — Button-click PUT /me/settings/{key}. Calls write_setting_change_event directly (not through _audit_setting_change), with the same SQLAlchemyError swallow inlined.
Every write today produces an event_type = "setting_change" row. No other event types exist in code.
Searched src/ for FROM audit. and audit.foray_events and AuditForayEventRow queries. No application reads. The inventory's claim ("no downstream readers") confirmed.
Test code reads via raw SQL — tests/test_voice_tune_setting_foray_audit.py + tests/test_voice_silence_submit_setting.py + tests/test_voice_person_settings.py execute SELECT ... FROM audit.foray_events to assert audit rows landed.
tests/conftest.py:79 includes audit.foray_events in the TRUNCATE chain between tests.
Four test files reference the audit table directly:
tests/test_voice_tune_setting_foray_audit.py — dedicated audit test file. Asserts setting_change rows land with correct payload, transition values, FK-population, swallow-discipline on SQLAlchemy error, etc. ~330 lines.tests/test_voice_silence_submit_setting.py — silence-submit ship adds an audit assertion for the new setting (message_order). One audit-row read at line 437.tests/test_voice_person_settings.py — voice person-settings ship asserts audit.foray_events row alongside the substrate setting write. One read at line 283.tests/conftest.py — TRUNCATE in the per-test cleanup chain.audit.eventsTable: audit.events (drop foray_ prefix).
Module: src/loomworks/audit/ stays. The module's docstrings and the model class name change (see §3.6).
The rationale on the prefix drop: audit is the Loomworks audit substrate; it captures Loomworks operational events; foray framed it as something it isn't. The cleanup framing is "rename and reshape the Loomworks-native table; don't pretend it was FORAY substrate."
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL (PK) | gen_random_uuid() |
Row identifier |
event_kind |
VARCHAR(64) | NOT NULL | — | Renamed from event_type; matches Loomworks naming (memory_events.event_kind) |
actor_person_id |
UUID | NOT NULL | — | FK to persons(id) (new — see §3.4) |
engagement_id |
UUID | NULL | — | FK to engagements(id) (new — see §3.4) |
payload |
JSONB | NOT NULL | '{}'::jsonb |
Event-kind-specific data |
rationale |
TEXT | NULL | — | New. Free-text "why" for events that carry one |
occurred_at |
TIMESTAMPTZ | NOT NULL | now() |
Renamed from timestamp (SQL type-name awkwardness avoided) |
Dropped columns:
tx_id — FORAY transaction identifier. Loomworks never used it (1:1 with id today). Future audit needs can re-introduce a separate transaction column with concrete Loomworks semantics.signature — FORAY-readiness LargeBinary, never written. If FORAY anchoring eventually lands, the integration will write its attestation through the reserved-emit path (which today is a no-op; later writes to FORAY's own substrate, not back to audit.events).Renamed columns:
event_type → event_kind. Loomworks convention is event_kind (memory_events). event_type is also a SQLAlchemy attribute name that occasionally collides with ORM internals; event_kind avoids the shadow.timestamp → occurred_at. timestamp is the SQL type name; using it as a column name is allowed but reads awkwardly in query builders and ORM contexts. occurred_at is the conventional audit-table name.New column:
rationale TEXT NULL. Today no caller passes a rationale. The column lands now so future event kinds that carry rationale (e.g., a Companion-said setting change where the Companion's "I'm setting blur because X" explanation lands in audit) can populate it without a follow-up migration. Cost is minimal (NULL-default TEXT); the field has obvious audit value.Two indexes (down from four):
ix_audit_events_actor_ts on (actor_person_id, occurred_at DESC) — the only index exercised today ("show me events for this actor")ix_audit_events_kind_ts on (event_kind, occurred_at DESC) — preserved against the case where future event-kind addition triggers per-kind queries; cheap insuranceDropped:
tx_id index — column droppedengagement_ts — no application or test query uses it. If a "show engagement events" surface lands later, re-create then. Don't carry indexes that no query reads.New in audit.events:
actor_person_id references persons(id) ON DELETE RESTRICTengagement_id references engagements(id) ON DELETE RESTRICT (when present)The original table had no FKs. The rationale to add them:
account_status; deletion is not a real operation).If a future operation legitimately needs to delete a person or engagement, that operation also needs to handle the audit rows explicitly — which is the correct discipline anyway.
None. Audit is append-only; no balance projection to maintain; no derived state. The trigger absence carries forward from audit.foray_events.
AuditForayEventRow becomes AuditEventRow in src/loomworks/audit/models.py. The DeclarativeBase stays independent (per the established loomworks.audit/loomworks.credit/loomworks.system_config pattern).
The model class loses the tx_id and signature columns, renames event_type → event_kind and timestamp → occurred_at, adds rationale, adds the two foreign-key declarations.
One Alembic migration: 0071_audit_events_replacement.py. Atomic — table creation, data copy, and old-table drop happen in a single revision.
audit.events with the schema in §3.2 (columns, defaults, FKs)audit.foray_events into audit.events:
sql
INSERT INTO audit.events
(id, event_kind, actor_person_id, engagement_id, payload, rationale, occurred_at)
SELECT id, event_type, actor_person_id, engagement_id, payload, NULL, timestamp
FROM audit.foray_events;
- id preserved (so any external reference to a specific row id survives the rename)
- event_type → event_kind (rename only; values unchanged)
- tx_id dropped silently (not used downstream)
- signature dropped silently (always NULL today)
- rationale filled with NULL (no historical rationale to carry forward)
- timestamp → occurred_at (rename only)audit.foray_eventsaudit.foray_eventsReverse the upgrade:
audit.foray_events (with original schema, indexes)audit.events → audit.foray_events (re-generating tx_id = id to fill the column; signature = NULL)audit.eventsaudit.eventsThe downgrade exists for completeness. Production rollback path is "fail forward with a new migration" rather than running this downgrade. The downgrade is for local-dev iteration where a migration gets dropped and reapplied.
Existing rows in audit.foray_events carry only event_type = "setting_change" today (one event kind exists). The copy preserves every row 1:1 with no semantic transformation other than column renames. No data loss; no data reshaping.
The audit schema itself stays. Only the table inside it changes. The schema-creation CREATE SCHEMA audit from 0068 is not re-run.
src/loomworks/foray/Created in CR-A. Two files:
src/loomworks/foray/__init__.py — module docstring + re-exports:
"""FORAY integration reservation.
Today (post-CR-A): holds only the no-op reserved emitter. When FORAY
integration eventually opens, this module becomes the home for the
actual ForayClient wrapper and the emitter writes real attestations.
CR-B will move the `_ANCHOR_PRIORITY` registry into this module
(possibly renamed `_FORAY_INTEGRATION_PRIORITY`). CR-C will extend
with value-flow emit support.
"""
from loomworks.foray.reserved_emit import _foray_reserved_emit
__all__ = ["_foray_reserved_emit"]
src/loomworks/foray/reserved_emit.py — the no-op function:
"""Reserved-location emitter for future FORAY attestation.
Today: no-op (debug log only). Tomorrow: writes the attestation to
the FORAY substrate via the dunin7-foray SDK.
Every call site that emits a reserved-location event tags itself with
the comment marker:
# FORAY_RESERVED_LOCATION: <event_kind>
so grep-discoverable at integration time.
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def _foray_reserved_emit(event_kind: str, payload: dict[str, Any]) -> None:
"""No-op today. When FORAY integration opens, writes an attestation
to the FORAY substrate."""
logger.debug(
"FORAY reserved emit (no-op today): kind=%s", event_kind
)
The module is structured to host the ForayClient wrapper and the relocated _ANCHOR_PRIORITY registry in CR-B and beyond.
src/loomworks/audit/events.py:
write_setting_change_event stays (the function name is Loomworks-native, not FORAY-shaped)AuditEventRow instead of AuditForayEventRowAuditEventRow(event_kind=EVENT_KIND_SETTING_CHANGE, ...) (renamed constant)EVENT_TYPE_SETTING_CHANGE → EVENT_KIND_SETTING_CHANGEtimestamp → occurred_at; drop tx_id and signature parametersid UUID, which now equals what was previously tx_id)src/loomworks/audit/models.py:
AuditForayEventRow → AuditEventRow__tablename__ from "foray_events" to "events"tx_id and signature mapped_columnsevent_type → event_kind, timestamp → occurred_atrationale: Mapped[Optional[str]] = mapped_column(String(), nullable=True)actor_person_id and engagement_idsrc/loomworks/audit/__init__.py:
AuditEventRow and EVENT_KIND_SETTING_CHANGEThree call sites get the reserved-emit added. The scoping note v0.2 prescribes per-call-site emit (not centralized inside the helper writer) because the reserved emit is conceptually about the business event (the Operator tuned a setting via voice, the Operator reset a setting via voice, the Operator clicked a button), not about the audit write (which is the persistence side effect).
Call site 1 — src/loomworks/orchestration/tune_setting.py:489:
await _audit_setting_change(
actor_person_id=...,
setting_key=...,
...
)
# FORAY_RESERVED_LOCATION: audit.setting_change
_foray_reserved_emit(
"audit.setting_change",
{"setting_key": setting_key, "action": "tuned"},
)
Call site 2 — src/loomworks/orchestration/tune_setting.py:596 — same shape with "action": "reset".
Call site 3 — src/loomworks/api/routers/me_settings.py:117:
try:
await write_setting_change_event(...)
except SQLAlchemyError:
logger.exception(...)
# FORAY_RESERVED_LOCATION: audit.setting_change
_foray_reserved_emit(
"audit.setting_change",
{"setting_key": setting_key, "action": "tuned_button"},
)
The reserved emit lives outside the try/except — the audit write swallow discipline is independent of the reserved-location pattern, which is a future hook and cannot fail today.
src/loomworks/audit/events.py, src/loomworks/audit/models.py, src/loomworks/audit/__init__.py — module docstrings drop FORAY framing. "FORAY narrative-event audit substrate" becomes "Loomworks audit substrate"; references to credit.foray_action_flows stay as cross-references (that cleanup is CR-C's scope, not CR-A's).
tests/test_voice_tune_setting_foray_audit.pyThe most-affected file. Touches every aspect of the audit substrate.
Changes:
test_voice_tune_setting_foray_audit.py → test_voice_tune_setting_audit.py (drop foray_ infix). The file should not retain FORAY framing in its name after CR-A.FROM audit.foray_events → FROM audit.events; event_type → event_kind; timestamp → occurred_at.AuditForayEventRow → AuditEventRow.EVENT_TYPE_SETTING_CHANGE → EVENT_KIND_SETTING_CHANGE.tests/test_voice_silence_submit_setting.pyOne SQL string at line 437 updates: FROM audit.foray_events → FROM audit.events; event_type → event_kind.
tests/test_voice_person_settings.pyOne SQL string at line 283 updates: FROM audit.foray_events → FROM audit.events; column renames as applicable.
tests/conftest.py:79TRUNCATE chain updates: audit.foray_events → audit.events.
The reserved-emit function is a no-op; testing it would be testing "this function logs a debug line and returns None." Add a single import-level test in test_voice_tune_setting_audit.py (or a new tests/test_foray_reserved_emit.py) asserting the function exists and is callable. Don't over-test a no-op.
The full sequencing lives in the CR document. Summary for design-doc context:
audit.events table (migration upgrade half)src/loomworks/foray/ module with _foray_reserved_emitaudit.foray_events (migration completes)Migration is one Alembic file; the upgrade function does create-table + data-copy + drop-old-table atomically.
Decision: per call site, per the v0.2 scoping note.
Rationale: the reserved emit is about the business event, not the persistence side effect. Three business events → three reserved emits. The audit write is centralized in the helper writer; the reserved emit is not.
If during CR-A execution this proves awkward (e.g., the three emits feel like ceremony), v0.2 of the design surfaces and proposes centralization.
Decision: NULL for historical rows.
Rationale: there is no rationale to carry forward — the current helper writer doesn't accept a rationale parameter. Future writes can populate rationale once the helper writer accepts it; that change is a separate evolution, not CR-A scope.
Decision: add now.
Rationale: persons and engagements are never deleted in Loomworks; FK RESTRICT cost is zero; the protection against orphan-audit rows is real. Adding FKs in a separate future migration would require a separate validation pass (do any rows actually have invalid references today?) — better to do it once during this cleanup.
audit.setting_change or setting_change?Decision: audit.setting_change for the reserved-emit event_kind argument; "setting_change" for the event_kind column value in audit rows.
Rationale: the reserved-emit argument is the namespaced name (<substrate>.<event_kind>) so future emits from credit (credit.flow_recorded) and memory (memory.commit) are unambiguous. The audit row's event_kind value stays bare ("setting_change") because the substrate is implicit in the table name.
Decision: drop.
Rationale: no application query reads by engagement; no test query reads by engagement. Re-introduce when a surface that needs the index lands. Carrying unused indexes makes writes slower without a query benefit.
None substantial. CR-A is the simple shape the v0.2 scoping note expected.
Two minor design notes worth surfacing:
A. The tx_id column has always equalled the id column for the rows actually written today. The migration drops tx_id cleanly because no row distinguishes the two. Future events that legitimately span multiple rows under one transaction id can re-introduce a tx_id (or audit_tx_id) column with concrete Loomworks semantics — but the speculative-shape-for-future-multi-row-events column doesn't earn its keep today.
B. The signature column is being dropped because it has never been written. If FORAY anchoring eventually lands, the signed attestation flows through the FORAY substrate (the reserved-emit hook), not back into audit.events. The audit row records the Loomworks-side event; the attestation is the FORAY-side proof. They are different substrates with different lifecycles. The column being on audit.foray_events was a category error — corrected by CR-A.
Ready for Operator review and CR execution.
The design is small, the migration is atomic, the test impact is mechanical (string substitutions plus one file rename), no downstream readers are affected, no PostgreSQL trigger is involved, no cross-CR dependencies surface.
The reserved-location pattern's first application happens at the right scale — three call sites, all sharing one logical event kind. CR-B (eleven test files, the memory-events substrate, the _ANCHOR_PRIORITY migration) and CR-C (twelve constructor sites, the PostgreSQL trigger replacement) inherit a validated pattern.
If the Operator approves both this design doc and the companion CR document, a separate CC kickoff executes CR-A. Estimated CR-execution time: 3-5 hours including test run-up and tag-and-close.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks CR-A Audit Replacement Design — v0.1 — 2026-05-24