Task — engineering-spec@1

"interaction-event schema & contract — the one work-interaction event shape (event_id, subject, module, event_type, target_ref, content_ref) every module emits into l1_audit_log, versioned + RLS + emit API; the single BRAIN capture primitive"

doneTASK-MEMORY-121
module memory · class product · priority p0 · created 2026-06-29 · shipped 2026-07-12
depends on TASK-MEMORY-101, TASK-EVAL-001 · blocks TASK-MEMORY-122, TASK-MEMORY-123

§1 — Description (BCP-14 normative)

This task defines one interaction-event shape that every CyberOS module emits for every platform work-interaction, and the API + contract + storage + access rules around it. It is the single BRAIN capture primitive (Phase 1 of docs/strategy/cyberos-brain-evaluation-plan.md). It captures emitters for no module itself — TASK-MEMORY-122 wires AUTH + CHAT and defines the emitter contract for the rest. Each piece:

  1. MUST define the row kind memory.interaction_event and enumerate it in services/memory/contracts/interaction-event.schema.json (JSON Schema draft 2020-12), alongside the existing aux audit kinds (memory.precondition_failed, memory.acl_denied, memory.status_overridden, memory.awh_gate_result). The schema is the published contract other modules depend on.
  2. MUST define the event field set, frozen for schema_version: 1:
  1. MUST carry no raw sensitive content in the row where a reference suffices (DEC-2701). Specifically: message bodies, document/IP text, email subjects+bodies, and file contents are referenced via content_ref, never inlined. Privacy-safe digests follow the AUTH *_hash16 precedent (e.g. an email address that must appear is a 16-hex SHA-256 prefix, never plaintext).
  2. MUST define content_ref as a closed union:
  1. MUST chain each interaction-event into the existing hash-chained l1_audit_log via the shared cyberos-audit-chain writer (DEC-2703) — byte-identical anchor SHA-256(prev_hash_hex ‖ body) so the memory reconcile invariant (TASK-MEMORY-101) and the layer-2 ingest accept it with no special case. The event body is the canonical-JSON serialisation of §1 #2 with "event_type" set to the row kind memory.interaction_event at the audit-row level and the interaction's own event_type inside the payload (so the existing TASK-OBS-008 event_type generated column and the TASK-APP-005 viewer keep working).
  2. MUST derive the audit-row op from event_class: read → 'view'; everything else → 'put'. This requires cyberos-audit-chain::emit_genesis_with_op (an op parameter; the existing emit_genesis stays as a 'put' shim). Read interactions (e.g. opening a document, viewing a channel) are recorded as view so they are distinguishable from mutations and so the chain's op enum stays meaningful.
  3. MUST expose the emit API in services/memory/src/interaction/emit.rs: ``rust pub async fn emit(pool: &PgPool, ev: &InteractionEvent) -> Result<EmitOutcome, EmitError>; ` returning EmitOutcome::Recorded { seq } on success or EmitOutcome::Skipped { reason } when the consent gate (§1 #8) blocks it. emit is best-effort from the caller's perspective: it MUST NOT make the calling interaction (a sign-in, a message send) fail if the audit pool is unreachable — it returns EmitError and the caller logs + swallows, exactly as AUTH's emit_token_issued and chat's audit::emit` already do.
  4. MUST gate emit on the TASK-EVAL-001 monitoring-notice acknowledgment (DEC-2702). consent_gate::has_acknowledged(pool, tenant_id, subject_id) reads the notice-ack ledger that TASK-EVAL-001 owns; when the subject has not acknowledged the current notice version, emit returns Skipped { reason: ConsentNotAcknowledged } and writes NO row. System actors (subject_id = null) are exempt (there is no person to notify). The gate result MUST be cache-safe for a bounded TTL (§1 #12) so it does not add a DB round-trip to every interaction.
  5. MUST validate every event before it is written: module ∈ the closed enum; event_type begins with "<module>."; event_class ∈ the closed enum; content_ref is one of the three union arms; attributes serialises to ≤ 2 KiB; schema_version == 1. A validation failure returns EmitError::Invalid and writes no row (a malformed interaction-event must never enter the chain).
  6. MUST bound the row size: attributes ≤ 2 KiB serialised and the whole body ≤ 16 KiB. Over-size → EmitError::Invalid. This keeps the audit chain and the TASK-MEMORY-101 ingest cheap and stops a module from smuggling raw content through attributes.
  7. MUST make the event body canonical and deterministic: a fixed field order, no insignificant whitespace, UTF-8, so the same logical interaction produces the same bytes (and the same chain anchor) on any host. The canonicaliser is shared with the rest of the memory chain (matches chain_anchor::canonicalise).
  8. MUST cache the consent-gate verdict per (tenant_id, subject_id) for ≤ 60 s in-process, so a burst of interactions from one signed-in person does not issue a consent-ledger query per event. A revocation (subject withdraws acknowledgment) takes effect within the TTL; the 60 s window is the documented bound.
  9. MUST emit OTel metrics:
  1. MUST be tenant-scoped on read via the existing l1_audit_log RLS path (TASK-AUTH-003 pattern); interaction-events inherit it because they ARE l1_audit_log rows. There is no second table and therefore no second RLS policy to drift.
  2. MUST publish the contract as a versioned, frozen artifact: interaction-event.schema.json carries schema_version const 1 and an explicit additive-only evolution rule (new optional fields and new event_type verbs are allowed without a bump; removing/retyping a field or changing the required set requires schema_version: 2 and a migration note). Downstream emitter tasks (TASK-MEMORY-122 and beyond) depend on this file, not on each other.
  3. MUST provide a typed builder so emitters cannot construct an invalid event: InteractionEvent::builder(module, event_type, event_class).subject(..).occurred_now().target(..).content(ContentRef::..).source(..).build() validates §1 #9–#10 at build() and returns Result. The free emit re-validates (defence in depth) but the builder is the ergonomic, misuse-resistant front door for TASK-MEMORY-122's emitters.
  4. MUST make event_id idempotent across replay: re-emitting the same event_id is a no-op at ingest (TASK-MEMORY-101 already UPSERTs on the chain row; the BRAIN layer-2 dedups on event_id). An emitter that retries after a transient EmitError reuses the same event_id so a retry never double-counts an interaction.
  5. MUST document, in the schema and in §11, the closed module/event_class/source_channel/target_ref.kind/content_ref.kind enums as the cardinality-bounded vocabulary the evaluation engine (TASK-EVAL-003) and the BRAIN ingestion (TASK-MEMORY-123) build against, so those tasks index on stable, low-cardinality dimensions rather than free strings.

§2 — Why this design (rationale for humans)

Why one shape, not per-module payloads (DEC-2700)? The goal is "record all interactions" and then evaluate a person across all of them. If chat, auth, and proj each shape their audit rows differently, the ingestion worker and the evaluation engine carry a growing pile of per-module adapters, and a new module means new special-casing everywhere downstream. One frozen shape means TASK-MEMORY-123 ingests once and TASK-EVAL-003 evaluates once; a new module just emits the same event.

Why aux rows on l1_audit_log, not a new store (DEC-2703)? The brain plan is explicit that the foundations exist and the work is additive. The hash-chained audit log is already the tamper-evident system of record; the layer-2 pipeline already tails it; AUTH, CHAT, and OBS already write to it through cyberos-audit-chain. Interaction-events are the same kind of fact, so they ride the same chain. A second capture store would fork the system of record, double the RLS surface, and break the single-reconcile invariant for no benefit.

Why content_ref instead of raw bodies (DEC-2701)? Two reasons. Privacy: the audit chain is retained for years; inlining message/document/email bodies there is exactly the data-maximisation the PDPD framing warns against, and it would duplicate content the owning store already holds under its own RLS. Integrity: the BRAIN should point at the canonical content (chat's message row, proj's document) so there is one source of truth, not a stale copy. The hash arm covers the case where the content lives nowhere durable but we still need "did it change / are these the same" — without keeping the content.

Why a consent gate in the capture primitive (DEC-2702)? Governance comes before capture in the plan's phase order for legal and trust reasons. Putting the gate in the one emit path means there is no way for a module to capture a person before that person has acknowledged the monitoring notice — the property is enforced at the primitive, not left to each emitter to remember. System actors are exempt because there is no person behind them to notify.

Why version the schema and freeze the field set (DEC-2704)? Six modules will depend on this shape. If the shape can change under them, the first change is a silent break. A schema_version const plus an additive-only rule lets the vocabulary grow (new verbs, new optional attributes) without breaking pinned emitters, and forces the breaking changes to be loud (a version bump + migration note).

Why UUIDv7 for event_id (§1 #2)? Time-sortable ids make the BRAIN's recency queries and replay dedup cheap, and they are collision-safe across the many emitters producing events concurrently. v4 would work for uniqueness but loses the free time ordering the ingestion and evaluation paths want.

Why derive op from event_class (§1 #6)? The chain's op enum (put | move | delete | view) already distinguishes mutations from reads. Recording "Alice opened this document" as view and "Alice edited it" as put keeps that distinction meaningful and lets read-versus-write analyses run off the existing column. Reads are interactions too — opening a channel is signal — but they must not look like mutations.

Why best-effort emit (§1 #7)? A sign-in or a message send must not fail because the audit pool is briefly unreachable. AUTH and CHAT already treat their audit writes as best-effort and let an OBS alarm catch a sustained drop. The interaction-event emit follows the same rule so capture is never on the critical path of the thing it is capturing.

Why a typed builder plus re-validation (§1 #16)? The builder makes the common case correct by construction (an emitter physically cannot set an unknown module or inline a raw body), which is what TASK-MEMORY-122's many call sites need. The free emit re-validates anyway so a hand-built event or a future caller that bypasses the builder still cannot write a malformed row.

Why bound size and watch body bytes (§1 #10, #13)? The cheapest way to defeat content_ref discipline is to dump raw text into attributes. A 2 KiB attributes cap plus a body-bytes histogram makes that both impossible at the limit and visible below it — if one module's bodies suddenly grow, the metric shows it.

Why cache the consent verdict (§1 #12)? A signed-in person generates a stream of interactions; a consent-ledger query per event would put a DB round-trip on every keystroke-adjacent action. A 60 s in-process cache makes the gate effectively free while keeping revocation latency to a documented, short bound.


§3 — API contract

Migration

-- services/memory/migrations/0005_interaction_event.sql
--
-- No new table: interaction-events are aux rows on l1_audit_log (DEC-2703). This migration adds
-- generated columns + partial indexes so the BRAIN ingestion (TASK-MEMORY-123) and the console viewer
-- (TASK-APP-005) can scan interaction-events by subject/module/event_type without parsing JSON per row.
-- The audit-row `event_type` column (TASK-OBS-008, migration 0004) already equals 'memory.interaction_event'
-- for these rows; these columns reach INTO the payload.

-- The interaction's own module, pulled from body.payload.module.
ALTER TABLE l1_audit_log
    ADD COLUMN iev_module TEXT
    GENERATED ALWAYS AS ((body::jsonb -> 'payload' ->> 'module')) STORED;

-- The interaction's subject, pulled from body.payload.subject_id (distinct from the row's subject_id,
-- which cyberos-audit-chain sets to the same value; this generated column is the typed, indexable view).
ALTER TABLE l1_audit_log
    ADD COLUMN iev_event_type TEXT
    GENERATED ALWAYS AS ((body::jsonb -> 'payload' ->> 'event_type')) STORED;

ALTER TABLE l1_audit_log
    ADD COLUMN iev_event_class TEXT
    GENERATED ALWAYS AS ((body::jsonb -> 'payload' ->> 'event_class')) STORED;

-- Partial indexes scoped to interaction-event rows only (keeps them small; other audit kinds are excluded).
CREATE INDEX l1_iev_subject_idx
    ON l1_audit_log (tenant_id, subject_id, ts_ns DESC)
    WHERE event_type = 'memory.interaction_event';

CREATE INDEX l1_iev_module_class_idx
    ON l1_audit_log (tenant_id, iev_module, iev_event_class, ts_ns DESC)
    WHERE event_type = 'memory.interaction_event';

-- Dedup guard for replay (§1 #17): the interaction's event_id is unique per tenant.
CREATE UNIQUE INDEX l1_iev_event_id_uq
    ON l1_audit_log (tenant_id, (body::jsonb -> 'payload' ->> 'event_id'))
    WHERE event_type = 'memory.interaction_event';

Rust — the event type + enums

// services/memory/src/interaction/event.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Module {
    Auth, Chat, Proj, Email, App, Mcp, Memory, Ai, Obs, Cuo,
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EventClass {
    Auth, Presence, Content, Activity, Admin, Read,
}

#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceChannel {
    Web, Desktop, Mobile, Api, Cli, System, Import,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TargetRef {
    Channel { id: String },
    Dm { id: String },
    Message { id: String },
    Issue { id: String },
    Document { id: String },
    Thread { id: String },
    Tool { id: String },
    Session { id: String },
    Subject { id: String },
    None,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ContentRef {
    /// The raw content lives in the owning store under its own RLS; the BRAIN holds only this pointer.
    Pointer { store: String, id: String },
    /// No durable store; keep a digest only. `preview_len` is 0 unless a module opts into a short,
    /// non-sensitive prefix.
    Hash { sha256: String, bytes: u64, preview_len: u32 },
    /// The interaction carried no content (sign-in, presence, module-open).
    None,
}

/// The one work-interaction event every module emits. `schema_version` is frozen at 1 for TASK-MEMORY-121.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InteractionEvent {
    pub schema_version: u16,          // const 1
    pub event_id: Uuid,               // UUIDv7
    pub tenant_id: Uuid,
    pub subject_id: Option<Uuid>,     // None only for system actors
    pub occurred_at_ns: i64,
    pub module: Module,
    pub event_type: String,           // "<module>.<verb>"
    pub event_class: EventClass,
    pub target_ref: TargetRef,
    pub content_ref: ContentRef,
    pub session_id: Option<Uuid>,
    pub trace_id: Option<String>,
    pub source_channel: SourceChannel,
    #[serde(default)]
    pub attributes: serde_json::Map<String, serde_json::Value>,
}

pub const SCHEMA_VERSION: u16 = 1;
pub const AUDIT_ROW_KIND: &str = "memory.interaction_event";

Rust — the emit path + consent gate

// services/memory/src/interaction/emit.rs
use crate::interaction::event::{InteractionEvent, EventClass, AUDIT_ROW_KIND, SCHEMA_VERSION};
use sqlx::PgPool;

#[derive(Debug)]
pub enum EmitOutcome { Recorded { seq: i64 }, Skipped { reason: SkipReason } }
#[derive(Debug)] pub enum SkipReason { ConsentNotAcknowledged }
#[derive(Debug, thiserror::Error)]
pub enum EmitError {
    #[error("invalid interaction-event: {0}")] Invalid(String),
    #[error(transparent)] Db(#[from] sqlx::Error),
}

pub async fn emit(pool: &PgPool, ev: &InteractionEvent) -> Result<EmitOutcome, EmitError> {
    validate(ev)?;                                            // §1 #9, #10
    // §1 #8: consent gate (system actors exempt).
    if let Some(subject) = ev.subject_id {
        if !crate::interaction::consent_gate::has_acknowledged(pool, ev.tenant_id, subject).await? {
            metrics::counter!("memory_interaction_consent_skipped_total",
                "module" => ev.module_str()).increment(1);
            return Ok(EmitOutcome::Skipped { reason: SkipReason::ConsentNotAcknowledged });
        }
    }
    // §1 #5/#6: chain as an aux audit row; op from event_class.
    let op = if ev.event_class == EventClass::Read { "view" } else { "put" };
    let body = canonical_audit_body(AUDIT_ROW_KIND, ev);      // §1 #11; {"event_type":<kind>,"payload":<ev>}
    let path = format!("iev/{}/{}/{}/{}",
        ev.tenant_id, ev.module_str(), ev.subject_str(), ev.event_id);
    let subject = ev.subject_id.unwrap_or(uuid::Uuid::nil());
    let seq = cyberos_audit_chain::emit_genesis_with_op(
        pool, ev.tenant_id, subject, op, &path, &body,
    ).await?;
    metrics::counter!("memory_interaction_events_total",
        "module" => ev.module_str(), "event_class" => ev.event_class_str(),
        "outcome" => "recorded").increment(1);
    Ok(EmitOutcome::Recorded { seq })
}

fn validate(ev: &InteractionEvent) -> Result<(), EmitError> {
    if ev.schema_version != SCHEMA_VERSION {
        return Err(EmitError::Invalid(format!("schema_version {} != {}", ev.schema_version, SCHEMA_VERSION)));
    }
    if !ev.event_type.starts_with(&format!("{}.", ev.module_str())) {
        return Err(EmitError::Invalid(format!("event_type '{}' lacks module prefix", ev.event_type)));
    }
    let attrs_len = serde_json::to_vec(&ev.attributes).map(|v| v.len()).unwrap_or(usize::MAX);
    if attrs_len > 2 * 1024 { return Err(EmitError::Invalid("attributes > 2KiB".into())); }
    Ok(())
}
// services/memory/src/interaction/consent_gate.rs
use sqlx::PgPool;
use uuid::Uuid;

/// Reads the TASK-EVAL-001 notice-acknowledgment ledger. A subject that has acknowledged the *current*
/// notice version returns true. Cached in-process for ≤ 60s (§1 #12) keyed by (tenant, subject).
pub async fn has_acknowledged(pool: &PgPool, tenant: Uuid, subject: Uuid) -> Result<bool, sqlx::Error> {
    if let Some(v) = cache::get(tenant, subject) { return Ok(v); }
    // TASK-EVAL-001 owns `eval_monitoring_ack (tenant_id, subject_id, notice_version, acknowledged_at)`
    // and `eval_current_notice (tenant_id, notice_version)`. Until TASK-EVAL-001 lands, this resolves
    // against a stub that returns the operator-seeded default (documented in §11).
    let ok: bool = sqlx::query_scalar(
        "SELECT EXISTS (
           SELECT 1 FROM eval_monitoring_ack a
           JOIN eval_current_notice n
             ON n.tenant_id = a.tenant_id AND n.notice_version = a.notice_version
          WHERE a.tenant_id = $1 AND a.subject_id = $2)",
    ).bind(tenant).bind(subject).fetch_one(pool).await.unwrap_or(false);
    cache::put(tenant, subject, ok);
    Ok(ok)
}

Shared crate — op-aware genesis emit

// services/shared/cyberos-audit-chain/src/lib.rs  (added; emit_genesis becomes a 'put' shim over this)
pub async fn emit_genesis_with_op(
    pool: &PgPool, tenant_id: Uuid, subject_id: Uuid,
    op: &str, path: &str, body: &str,
) -> Result<i64, sqlx::Error> {
    debug_assert!(matches!(op, "put" | "move" | "delete" | "view"));
    let anchor = chain_anchor(None, body);
    let ts_ns = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
    let row: (i64,) = sqlx::query_as(
        "INSERT INTO l1_audit_log
            (tenant_id, subject_id, op, path, body, prev_hash_hex, chain_anchor_hex, ts_ns)
         VALUES ($1, $2, $3, $4, $5, NULL, $6, $7)
         RETURNING seq",
    )
    .bind(tenant_id).bind(subject_id).bind(op).bind(path).bind(body)
    .bind(&anchor).bind(ts_ns)
    .fetch_one(pool).await?;
    Ok(row.0)
}

Contract artifact (excerpt)

// services/memory/contracts/interaction-event.schema.json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://cyberos.cyberskill.world/contracts/interaction-event/v1",
  "title": "CyberOS interaction-event",
  "x-evolution": "additive-only: new optional fields and new `<module>.<verb>` event_type values are allowed without a version bump; removing or retyping a field, or changing `required`, requires schema_version 2 + a migration note",
  "type": "object",
  "required": ["schema_version","event_id","tenant_id","occurred_at_ns","module","event_type","event_class","target_ref","content_ref","source_channel"],
  "properties": {
    "schema_version": { "const": 1 },
    "event_id":       { "type": "string", "format": "uuid" },
    "tenant_id":      { "type": "string", "format": "uuid" },
    "subject_id":     { "type": ["string","null"], "format": "uuid" },
    "occurred_at_ns": { "type": "integer" },
    "module":         { "enum": ["auth","chat","proj","email","app","mcp","memory","ai","obs","cuo"] },
    "event_type":     { "type": "string", "pattern": "^[a-z]+\\.[a-z0-9_]+$" },
    "event_class":    { "enum": ["auth","presence","content","activity","admin","read"] },
    "source_channel": { "enum": ["web","desktop","mobile","api","cli","system","import"] },
    "content_ref":    { "oneOf": [
      { "type":"object","required":["kind","store","id"],"properties":{"kind":{"const":"pointer"}}},
      { "type":"object","required":["kind","sha256","bytes"],"properties":{"kind":{"const":"hash"}}},
      { "type":"object","required":["kind"],"properties":{"kind":{"const":"none"}}}
    ]}
  }
}

§4 — Acceptance criteria

  1. Row kind enumeratedinteraction-event.schema.json lists memory.interaction_event and validates a well-formed event (AC for §1 #1).
  2. Frozen field set serialises — an InteractionEvent round-trips through serde with all §1 #2 fields; schema_version == 1 (AC for §1 #2).
  3. Raw content rejected — building an event with a >2 KiB attributes blob → EmitError::Invalid; nothing written (AC for §1 #3, #10).
  4. content_ref union enforced — a Pointer{store:"chat_messages", id} validates; a hash arm without sha256 is rejected by the schema (AC for §1 #4).
  5. Chains into l1_audit_logemit of a valid event inserts one l1_audit_log row with event_type='memory.interaction_event' and a chain_anchor_hex that re-verifies under the memory reconcile (AC for §1 #5).
  6. op derived from class — a Read-class event writes op='view'; a Content-class event writes op='put' (AC for §1 #6).
  7. emit is best-effort — with the audit pool down, emit returns EmitError::Db and does NOT panic; the caller can swallow it (AC for §1 #7).
  8. Consent gate blocks — for a subject with no acknowledgment row, emit returns Skipped{ConsentNotAcknowledged} and writes zero rows; memory_interaction_consent_skipped_total increments (AC for §1 #8).
  9. Consent gate passes — after seeding an acknowledgment of the current notice, the same emit returns Recorded{seq} (AC for §1 #8).
  10. System actor exemptsubject_id = null event skips the gate and records (AC for §1 #8).
  11. Validation rejects bad module/type — an event_type without the module prefix → EmitError::Invalid (AC for §1 #9).
  12. Body size bound — a 17 KiB body → EmitError::Invalid (AC for §1 #10).
  13. Canonical determinism — the same logical event serialised twice yields byte-identical bodies and identical chain anchors (AC for §1 #11).
  14. Consent cache — two emits for the same (tenant, subject) within 60 s issue ≤ 1 consent-ledger query (AC for §1 #12).
  15. Metrics emitmemory_interaction_events_total{outcome="recorded"} increments on a recorded event (AC for §1 #13).
  16. RLS isolation — tenant A's interaction-event rows are invisible to a tenant-B read through the l1_audit_log RLS path (AC for §1 #14).
  17. Schema is frozen/versioned — the contract test asserts schema_version const 1 and that every emitted body validates against the published schema (AC for §1 #15).
  18. Builder validates at build()InteractionEvent::builder(..) with an unknown-prefixed event_type returns Err before any emit (AC for §1 #16).
  19. Replay idempotent — emitting the same event_id twice yields one row (unique index); the second is a no-op/ON CONFLICT (AC for §1 #17).
  20. Indexes usedEXPLAIN of a per-subject interaction scan uses l1_iev_subject_idx (AC for §1 #2/#18 indexing).

§5 — Verification

#[tokio::test]
async fn valid_event_chains_into_audit_log() {
    let env = TestEnv::new().await;
    env.ack_notice(env.tenant(), env.alice()).await;          // pass the gate
    let ev = InteractionEvent::builder(Module::Chat, "chat.message_created", EventClass::Content)
        .subject(env.alice()).occurred_now()
        .target(TargetRef::Message { id: "msg-1".into() })
        .content(ContentRef::Pointer { store: "chat_messages".into(), id: "msg-1".into() })
        .source(SourceChannel::Web).tenant(env.tenant()).build().unwrap();
    let out = emit(&env.audit_pool, &ev).await.unwrap();
    let seq = match out { EmitOutcome::Recorded { seq } => seq, _ => panic!("expected recorded") };

    let row: (String, String, String) = sqlx::query_as(
        "SELECT op, event_type, chain_anchor_hex FROM l1_audit_log WHERE seq = $1")
        .bind(seq).fetch_one(&env.audit_pool).await.unwrap();
    assert_eq!(row.1, "memory.interaction_event");
    assert_eq!(row.0, "put");                                  // Content class → put
    assert!(env.reconcile_verifies(seq).await);                // anchor verifies under memory reconcile
}

#[tokio::test]
async fn read_class_records_as_view() {
    let env = TestEnv::new().await;
    env.ack_notice(env.tenant(), env.alice()).await;
    let ev = InteractionEvent::builder(Module::Proj, "proj.document_opened", EventClass::Read)
        .subject(env.alice()).occurred_now()
        .target(TargetRef::Document { id: "doc-1".into() })
        .content(ContentRef::None).source(SourceChannel::Web).tenant(env.tenant()).build().unwrap();
    let EmitOutcome::Recorded { seq } = emit(&env.audit_pool, &ev).await.unwrap() else { panic!() };
    let op: String = sqlx::query_scalar("SELECT op FROM l1_audit_log WHERE seq = $1")
        .bind(seq).fetch_one(&env.audit_pool).await.unwrap();
    assert_eq!(op, "view");
}

#[tokio::test]
async fn consent_gate_blocks_unacknowledged_subject() {
    let env = TestEnv::new().await;                            // no ack seeded
    let ev = sample_event(env.tenant(), Some(env.bob()));
    let out = emit(&env.audit_pool, &ev).await.unwrap();
    assert!(matches!(out, EmitOutcome::Skipped { reason: SkipReason::ConsentNotAcknowledged }));
    let n: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM l1_audit_log WHERE event_type='memory.interaction_event'")
        .fetch_one(&env.audit_pool).await.unwrap();
    assert_eq!(n, 0, "no row written before consent");
}

#[tokio::test]
async fn system_actor_exempt_from_gate() {
    let env = TestEnv::new().await;
    let ev = sample_event(env.tenant(), None);                 // subject_id = null
    assert!(matches!(emit(&env.audit_pool, &ev).await.unwrap(), EmitOutcome::Recorded { .. }));
}

#[tokio::test]
async fn oversize_attributes_rejected() {
    let env = TestEnv::new().await;
    let mut ev = sample_event(env.tenant(), Some(env.alice()));
    ev.attributes.insert("blob".into(), serde_json::json!("x".repeat(3000)));
    assert!(matches!(emit(&env.audit_pool, &ev).await, Err(EmitError::Invalid(_))));
}

#[tokio::test]
async fn canonical_body_is_deterministic() {
    let ev = sample_event(uuid::Uuid::nil(), Some(uuid::Uuid::nil()));
    assert_eq!(canonical_audit_body(AUDIT_ROW_KIND, &ev), canonical_audit_body(AUDIT_ROW_KIND, &ev));
}

#[tokio::test]
async fn replay_same_event_id_is_idempotent() {
    let env = TestEnv::new().await;
    env.ack_notice(env.tenant(), env.alice()).await;
    let ev = sample_event(env.tenant(), Some(env.alice()));
    let _ = emit(&env.audit_pool, &ev).await.unwrap();
    let _ = emit(&env.audit_pool, &ev).await;                  // same event_id
    let n: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM l1_audit_log WHERE (body::jsonb->'payload'->>'event_id') = $1")
        .bind(ev.event_id.to_string()).fetch_one(&env.audit_pool).await.unwrap();
    assert_eq!(n, 1);
}

#[tokio::test]
async fn every_emitted_body_validates_against_schema() {
    let schema = load_contract_schema();                       // contracts/interaction-event.schema.json
    for class in [EventClass::Auth, EventClass::Presence, EventClass::Content, EventClass::Read] {
        let ev = sample_event_with_class(uuid::Uuid::now_v7(), class);
        let payload = serde_json::to_value(&ev).unwrap();
        assert!(schema.validate(&payload).is_ok(), "class {class:?} failed schema");
    }
}

§6 — Implementation skeleton

See §3 (event + enums, emit + gate, op-aware genesis, contract). The builder (InteractionEvent::builder) lives in interaction/event.rs; canonical_audit_body is shared with the memory chain canonicaliser.


§7 — Dependencies


§8 — Example payloads

Sign-in (auth, no content)

{
  "event_type": "memory.interaction_event",
  "payload": {
    "schema_version": 1,
    "event_id": "018f9c2a-7e10-7c3b-9a44-6b1d2e3f4a55",
    "tenant_id": "cyberskill-tenant-uuid",
    "subject_id": "stephen-subject-uuid",
    "occurred_at_ns": 1782950400000000000,
    "module": "auth",
    "event_type": "auth.signed_in",
    "event_class": "auth",
    "target_ref": { "kind": "session", "id": "jti-abc123" },
    "content_ref": { "kind": "none" },
    "session_id": "jti-abc123",
    "trace_id": "0af7651916cd43dd8448eb211c80319c",
    "source_channel": "web",
    "attributes": { "source_ip_hash16": "9f86d081884c7d65" }
  }
}

Chat message created (content as pointer, never raw body)

{
  "event_type": "memory.interaction_event",
  "payload": {
    "schema_version": 1,
    "event_id": "018f9c2a-9b22-7e88-8c01-11aa22bb33cc",
    "tenant_id": "cyberskill-tenant-uuid",
    "subject_id": "daria-subject-uuid",
    "occurred_at_ns": 1782950460000000000,
    "module": "chat",
    "event_type": "chat.message_created",
    "event_class": "content",
    "target_ref": { "kind": "channel", "id": "general-channel-uuid" },
    "content_ref": { "kind": "pointer", "store": "chat_messages", "id": "msg-7e57c0de" },
    "session_id": "jti-def456",
    "trace_id": null,
    "source_channel": "web",
    "attributes": { "channel_kind": "channel", "has_attachment": false }
  }
}

Document opened (proj, read → op=view)

{
  "event_type": "memory.interaction_event",
  "payload": {
    "schema_version": 1,
    "event_id": "018f9c2b-0140-7a51-9d77-44ee55ff6600",
    "tenant_id": "cyberskill-tenant-uuid",
    "subject_id": "stephen-subject-uuid",
    "occurred_at_ns": 1782950520000000000,
    "module": "proj",
    "event_type": "proj.document_opened",
    "event_class": "read",
    "target_ref": { "kind": "document", "id": "doc-ssl-l4-spec" },
    "content_ref": { "kind": "none" },
    "session_id": "jti-abc123",
    "source_channel": "web",
    "attributes": {}
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Audit pool unreachable at emitsqlx errorEmitError::Db; caller logs + swallows; interaction itself unaffectedSelf-heals; OBS alarm on sustained drop
Subject has not acknowledged noticeconsent gate falseSkipped{ConsentNotAcknowledged}; no rowBy design; person acknowledges, then capture begins
Notice version bumped (mass re-ack needed)consent_skipped spikeEveryone suppressed until re-ackBy design; operator re-circulates notice (TASK-EVAL-001)
Raw content smuggled in attributes2 KiB cap + body-bytes histogramEmitError::Invalid at the limit; metric flags growth below itAuthor fixes emitter to use content_ref
Unknown module valueserde enum + validateEmitError::Invalid; no rowAuthor uses the closed enum
event_type lacks module prefixvalidateEmitError::Invalid; no rowAuthor namespaces the verb
Malformed content_ref armJSON Schema oneOfrejected at contract test + validateAuthor fixes the union arm
Replay / CDC re-delivers same eventunique index on event_idsecond insert ON CONFLICT no-opBy design (§1 #17)
Consent cache serves stale true after revoke60 s TTLup to 60 s of extra captureBy design; documented bound
Consent cache serves stale false after ack60 s TTLup to 60 s of dropped captureBy design; documented bound
TASK-EVAL-001 ledger absent (pre-landing)stub resolves defaultoperator-seeded default appliesReplace stub when TASK-EVAL-001 lands
Body > 16 KiBsize boundEmitError::Invalid; no rowAuthor trims attributes / uses pointer
Schema bumped to v2 while emitter pins v1version checkv1 emitter still validates against v1 schema; v2-only fields ignoredAdditive rule; bump is loud
Clock skew makes occurred_at_ns < a prior eventnone (events independent)ordering by event_id (v7) is monotonic enoughBy design
RLS bypass attempt on readl1_audit_log RLS policy0 rowsBy design
op='view' row deleted by privileged rolechain reconcile (TASK-MEMORY-101)detected as a chain gapIncident response
Module emits before AUTH session existssession_id nullrecorded with null sessionBy design; not all interactions have a session
OTel exporter downbuffered then droppedmetrics gap; rows still writtenRestore TASK-OBS-001
Two emitters race the same event_id (bug)unique indexone wins; SEV-3 warnAuthor fixes id generation
Generated column NULL (payload missing module)partial index excludesrow still chained; just unindexed by moduleValidate guarantees module present for valid rows
Very high emit rate from one subjectconsent cache + best-effortDB write pressure on l1_audit_logSlice-2 batched emit

§11 — Implementation notes


AI Risk Assessment


End of TASK-MEMORY-121.

Audit

TASK-MEMORY-121 audit

Ship record (2026-07-12 - status-drift reconciliation)

  • Implemented by a parallel session (services/memory/src/interaction/, 2207 lines); surfaced by the drift sweep of stale implementing tasks. 18/18 clause verification PASS (packet: docs/tasks/.workflow/TASK-MEMORY-121/review-packet.md); deviation-with-rationale on #13 (structured tracing events as the metrics path, native meters deferred) + bonus backfill.rs recorded.
  • Test evidence: 697-line suite (main/RLS/contract); operator confirmed tests green (CI/cargo) - sandbox carries no Rust toolchain, gap named.
  • HITL: operator verdict 2026-07-12 in-chat "Tests green - approve + done" (both gates).