DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path substrate/cleanup/loomworks-cr-a-audit-replacement-design-v0_1.html
Loomworks · Substrate · Cleanup

CR-A Audit Replacement 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 2f04e7a (voice listening silence-submit ship). Alembic head 0070.


1. Purpose

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.


2. Current state — audit.foray_events

2.1 Schema

Created 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

2.2 Indexes

Four indexes, schema audit:

2.3 Writers

One helper function, three call sites.

Helper writersrc/loomworks/audit/events.py:52write_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 1src/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 2src/loomworks/orchestration/tune_setting.py:596 — Companion-routed reset (tune_setting orchestrator, ACTION_RESET branch). Same wrapper, same swallow discipline.

Call site 3src/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.

2.4 Readers

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.

2.5 Tests

Four test files reference the audit table directly:


3. Target state — audit.events

3.1 Naming

Table: 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."

3.2 Schema

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:

Renamed columns:

New column:

3.3 Indexes

Two indexes (down from four):

Dropped:

3.4 Foreign keys

New in audit.events:

The original table had no FKs. The rationale to add them:

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.

3.5 PostgreSQL trigger

None. Audit is append-only; no balance projection to maintain; no derived state. The trigger absence carries forward from audit.foray_events.

3.6 ORM model

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_typeevent_kind and timestampoccurred_at, adds rationale, adds the two foreign-key declarations.


4. Migration plan

One Alembic migration: 0071_audit_events_replacement.py. Atomic — table creation, data copy, and old-table drop happen in a single revision.

4.1 Upgrade

  1. Create audit.events with the schema in §3.2 (columns, defaults, FKs)
  2. Create the two indexes in §3.3
  3. Copy data from 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_typeevent_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) - timestampoccurred_at (rename only)
  4. Drop the four indexes on audit.foray_events
  5. Drop audit.foray_events

4.2 Downgrade

Reverse the upgrade:

  1. Re-create audit.foray_events (with original schema, indexes)
  2. Copy from audit.eventsaudit.foray_events (re-generating tx_id = id to fill the column; signature = NULL)
  3. Drop the indexes on audit.events
  4. Drop audit.events

The 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.

4.3 Data semantics during migration

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.

4.4 Schema-level concern

The audit schema itself stays. Only the table inside it changes. The schema-creation CREATE SCHEMA audit from 0068 is not re-run.


5. Code changes

5.1 New module — 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.

5.2 Helper writer rename + update

src/loomworks/audit/events.py:

src/loomworks/audit/models.py:

src/loomworks/audit/__init__.py:

5.3 Call-site updates

Three 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 1src/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 2src/loomworks/orchestration/tune_setting.py:596 — same shape with "action": "reset".

Call site 3src/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.

5.4 Docstring sweeps

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).


6. Test impact

6.1 tests/test_voice_tune_setting_foray_audit.py

The most-affected file. Touches every aspect of the audit substrate.

Changes:

6.2 tests/test_voice_silence_submit_setting.py

One SQL string at line 437 updates: FROM audit.foray_eventsFROM audit.events; event_typeevent_kind.

6.3 tests/test_voice_person_settings.py

One SQL string at line 283 updates: FROM audit.foray_eventsFROM audit.events; column renames as applicable.

6.4 tests/conftest.py:79

TRUNCATE chain updates: audit.foray_eventsaudit.events.

6.5 New test surface — none

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.


7. Build sequencing (preview)

The full sequencing lives in the CR document. Summary for design-doc context:

  1. Step 0 — pre-flight (verify baseline, verify inventory still matches)
  2. Step 1 — create new audit.events table (migration upgrade half)
  3. Step 2 — add src/loomworks/foray/ module with _foray_reserved_emit
  4. Step 3 — update helper writer + model + module re-exports
  5. Step 4 — add three reserved-emit call sites
  6. Step 5 — migrate data (migration upgrade other half)
  7. Step 6 — drop audit.foray_events (migration completes)
  8. Step 7 — update tests
  9. Checkpoint A — Operator confirms before close
  10. Close — tag the engine repo

Migration is one Alembic file; the upgrade function does create-table + data-copy + drop-old-table atomically.


8. Open questions / decisions

8.1 Reserved emit — per call site vs. centralized?

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.

8.2 Rationale column — fill historical rows or leave NULL?

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.

8.3 Foreign keys — add now or defer?

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.

8.4 Event-kind naming convention — 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.

8.5 Index on engagement_id — keep or drop?

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.


9. Architectural surprises during design

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.


10. Confidence

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