"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"
§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:
- MUST define the row kind
memory.interaction_eventand enumerate it inservices/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. - MUST define the event field set, frozen for
schema_version: 1:
schema_version(int, const1for this task) — downstream emitters pin it.event_id(UUIDv7) — globally unique; v7 so it is time-sortable and dedup-safe across replay.tenant_id(UUID) — the emitting subject's tenant; drives RLS.subject_id(UUID) — the person who acted (TASK-AUTH-002 subject); the unit of evaluation.nullonly for genuine system actors (e.g. a scheduled job), never to hide a person.occurred_at_ns(int64) — nanoseconds since epoch at the moment of the interaction (not ingest time).module(enum) — the emitting module: one ofauth | chat | proj | email | app | mcp | memory | ai | obs | cuo. Closed set; unknown values rejected at emit (§1 #9).event_type(string, namespaced"<module>.<verb>", e.g.chat.message_created,auth.signed_in,proj.issue_assigned) — the specific interaction. Free-form within themodule.prefix so modules add verbs without a schema bump.event_class(enum) —auth | presence | content | activity | admin | read. Coarse class for ingestion + theopderivation (§1 #6); stable even asevent_typegrows.target_ref(object, nullable) — what the interaction was about:{kind, id}wherekind∈channel | dm | message | issue | document | thread | tool | session | subject | noneandidis the target's UUID or stable id. Not raw content — a reference.content_ref(object, nullable) — a pointer or hash of any content the interaction carried, per §1 #4. NEVER the raw body where a pointer/hash suffices.session_id(UUID, nullable) — the AUTH session (TASK-AUTH-004 jti-derived) the action happened in.trace_id(string, nullable) — the W3C traceparent trace-id (TASK-OBS-003) so an interaction correlates with its request span.source_channel(enum) —web | desktop | mobile | api | cli | system | import— how the interaction reached the platform.attributes(object, optional) — a small, bounded bag of module-specific scalars (e.g.{prev_status, new_status}for a status change). MUST NOT carry raw content; MUST be ≤ 2 KiB serialised (§1 #10).
- 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*_hash16precedent (e.g. an email address that must appear is a 16-hex SHA-256 prefix, never plaintext). - MUST define
content_refas a closed union:
pointer—{kind:"pointer", store, id}wherestore∈chat_messages | proj_documents | email_objects | memory | attachmentsandidis the row/object id. The raw content stays in the owning store (e.g. chat's own DB), under that store's own RLS; the BRAIN holds only the pointer.hash—{kind:"hash", sha256, bytes, preview_len}wheresha256is the lowercase-hex digest of the canonical content,bytesis its length, andpreview_lenis0by default (a non-zero preview is opt-in per module and MUST be a short, non-sensitive prefix only).none—{kind:"none"}for interactions with no content (e.g. a sign-in, a presence change, opening a module).
- MUST chain each interaction-event into the existing hash-chained
l1_audit_logvia the sharedcyberos-audit-chainwriter (DEC-2703) — byte-identical anchorSHA-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 kindmemory.interaction_eventat the audit-row level and the interaction's ownevent_typeinside the payload (so the existing TASK-OBS-008event_typegenerated column and the TASK-APP-005 viewer keep working). - MUST derive the audit-row
opfromevent_class:read → 'view'; everything else →'put'. This requirescyberos-audit-chain::emit_genesis_with_op(anopparameter; the existingemit_genesisstays as a'put'shim). Read interactions (e.g. opening a document, viewing a channel) are recorded asviewso they are distinguishable from mutations and so the chain'sopenum stays meaningful. - MUST expose the emit API in
services/memory/src/interaction/emit.rs: ``rust pub async fn emit(pool: &PgPool, ev: &InteractionEvent) -> Result<EmitOutcome, EmitError>;`returningEmitOutcome::Recorded { seq }on success orEmitOutcome::Skipped { reason }when the consent gate (§1 #8) blocks it.emitis 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 returnsEmitErrorand the caller logs + swallows, exactly as AUTH'semit_token_issuedand chat'saudit::emit` already do. - 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,emitreturnsSkipped { 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. - MUST validate every event before it is written:
module∈ the closed enum;event_typebegins with"<module>.";event_class∈ the closed enum;content_refis one of the three union arms;attributesserialises to ≤ 2 KiB;schema_version == 1. A validation failure returnsEmitError::Invalidand writes no row (a malformed interaction-event must never enter the chain). - 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 throughattributes. - 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). - 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. - MUST emit OTel metrics:
memory_interaction_events_total{module, event_class, outcome}(counter; outcome ∈recorded | skipped_consent | invalid | emit_error).memory_interaction_event_emit_seconds{module}(histogram).memory_interaction_consent_skipped_total{module}(counter — visibility into how much is suppressed by the gate; a sudden spike means a notice-version bump locked people out).memory_interaction_event_body_bytes{module}(histogram — watches for content leaking throughattributes).
- MUST be tenant-scoped on read via the existing
l1_audit_logRLS path (TASK-AUTH-003 pattern); interaction-events inherit it because they AREl1_audit_logrows. There is no second table and therefore no second RLS policy to drift. - MUST publish the contract as a versioned, frozen artifact:
interaction-event.schema.jsoncarriesschema_versionconst1and an explicitadditive-onlyevolution rule (new optional fields and newevent_typeverbs are allowed without a bump; removing/retyping a field or changing the required set requiresschema_version: 2and a migration note). Downstream emitter tasks (TASK-MEMORY-122 and beyond) depend on this file, not on each other. - 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 atbuild()and returnsResult. The freeemitre-validates (defence in depth) but the builder is the ergonomic, misuse-resistant front door for TASK-MEMORY-122's emitters. - MUST make
event_ididempotent across replay: re-emitting the sameevent_idis a no-op at ingest (TASK-MEMORY-101 already UPSERTs on the chain row; the BRAIN layer-2 dedups onevent_id). An emitter that retries after a transientEmitErrorreuses the sameevent_idso a retry never double-counts an interaction. - MUST document, in the schema and in §11, the closed
module/event_class/source_channel/target_ref.kind/content_ref.kindenums 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
- Row kind enumerated —
interaction-event.schema.jsonlistsmemory.interaction_eventand validates a well-formed event (AC for §1 #1). - Frozen field set serialises — an
InteractionEventround-trips through serde with all §1 #2 fields;schema_version == 1(AC for §1 #2). - Raw content rejected — building an event with a >2 KiB
attributesblob →EmitError::Invalid; nothing written (AC for §1 #3, #10). - content_ref union enforced — a
Pointer{store:"chat_messages", id}validates; a hash arm withoutsha256is rejected by the schema (AC for §1 #4). - Chains into l1_audit_log —
emitof a valid event inserts onel1_audit_logrow withevent_type='memory.interaction_event'and achain_anchor_hexthat re-verifies under the memory reconcile (AC for §1 #5). - op derived from class — a
Read-class event writesop='view'; aContent-class event writesop='put'(AC for §1 #6). - emit is best-effort — with the audit pool down,
emitreturnsEmitError::Dband does NOT panic; the caller can swallow it (AC for §1 #7). - Consent gate blocks — for a subject with no acknowledgment row,
emitreturnsSkipped{ConsentNotAcknowledged}and writes zero rows;memory_interaction_consent_skipped_totalincrements (AC for §1 #8). - Consent gate passes — after seeding an acknowledgment of the current notice, the same
emitreturnsRecorded{seq}(AC for §1 #8). - System actor exempt —
subject_id = nullevent skips the gate and records (AC for §1 #8). - Validation rejects bad module/type — an
event_typewithout the module prefix →EmitError::Invalid(AC for §1 #9). - Body size bound — a 17 KiB body →
EmitError::Invalid(AC for §1 #10). - Canonical determinism — the same logical event serialised twice yields byte-identical bodies and identical chain anchors (AC for §1 #11).
- Consent cache — two
emits for the same(tenant, subject)within 60 s issue ≤ 1 consent-ledger query (AC for §1 #12). - Metrics emit —
memory_interaction_events_total{outcome="recorded"}increments on a recorded event (AC for §1 #13). - RLS isolation — tenant A's interaction-event rows are invisible to a tenant-B read through the
l1_audit_logRLS path (AC for §1 #14). - Schema is frozen/versioned — the contract test asserts
schema_versionconst 1 and that every emitted body validates against the published schema (AC for §1 #15). - Builder validates at build() —
InteractionEvent::builder(..)with an unknown-prefixed event_type returnsErrbefore any emit (AC for §1 #16). - Replay idempotent — emitting the same
event_idtwice yields one row (unique index); the second is a no-op/ON CONFLICT(AC for §1 #17). - Indexes used —
EXPLAINof a per-subject interaction scan usesl1_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
- TASK-MEMORY-101 — the layer-1 audit chain + reconcile + layer-2 ingest these rows ride on;
cyberos-audit-chainanchor compatibility. - TASK-EVAL-001 — owns the monitoring-notice + acknowledgment ledger the consent gate reads (
eval_monitoring_ack,eval_current_notice). Until it lands, the gate resolves against a documented operator-seeded stub (§11). - TASK-AUTH-002 / TASK-AUTH-004 —
subject_idandsession_idcome from AUTH subjects + sessions (jti). - TASK-OBS-003 —
trace_idis the W3C traceparent the request middleware sets. - TASK-OBS-008 — the
event_typegenerated column onl1_audit_logthese rows populate; TASK-APP-005 viewer reads it. - Downstream: TASK-MEMORY-122 (emitters depend on this contract), TASK-MEMORY-123 (ingestion reads this shape), TASK-EVAL-003 (evaluation indexes these dimensions).
- Crates:
sqlx@0.7,serde,serde_json,uuid(v7 feature),chrono,thiserror,jsonschema(contract test),sha2(content_ref hash).
§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:
- Per-
event_typeretention classes (e.g. presence pruned sooner than IP-activity) — owned by TASK-EVAL-001 governance/retention, applied at ingest in TASK-MEMORY-123. - A streaming emit (batched, async channel) for very high-frequency emitters — slice 2; the synchronous best-effort
emitis sufficient for AUTH + CHAT volumes at P0 scale. - Cross-tenant aggregate views for CyberSkill-wide analytics — out of scope; RLS is per-tenant by design.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Audit pool unreachable at emit | sqlx error | EmitError::Db; caller logs + swallows; interaction itself unaffected | Self-heals; OBS alarm on sustained drop |
| Subject has not acknowledged notice | consent gate false | Skipped{ConsentNotAcknowledged}; no row | By design; person acknowledges, then capture begins |
| Notice version bumped (mass re-ack needed) | consent_skipped spike | Everyone suppressed until re-ack | By design; operator re-circulates notice (TASK-EVAL-001) |
Raw content smuggled in attributes | 2 KiB cap + body-bytes histogram | EmitError::Invalid at the limit; metric flags growth below it | Author fixes emitter to use content_ref |
Unknown module value | serde enum + validate | EmitError::Invalid; no row | Author uses the closed enum |
event_type lacks module prefix | validate | EmitError::Invalid; no row | Author namespaces the verb |
Malformed content_ref arm | JSON Schema oneOf | rejected at contract test + validate | Author fixes the union arm |
| Replay / CDC re-delivers same event | unique index on event_id | second insert ON CONFLICT no-op | By design (§1 #17) |
Consent cache serves stale true after revoke | 60 s TTL | up to 60 s of extra capture | By design; documented bound |
Consent cache serves stale false after ack | 60 s TTL | up to 60 s of dropped capture | By design; documented bound |
| TASK-EVAL-001 ledger absent (pre-landing) | stub resolves default | operator-seeded default applies | Replace stub when TASK-EVAL-001 lands |
| Body > 16 KiB | size bound | EmitError::Invalid; no row | Author trims attributes / uses pointer |
| Schema bumped to v2 while emitter pins v1 | version check | v1 emitter still validates against v1 schema; v2-only fields ignored | Additive rule; bump is loud |
| Clock skew makes occurred_at_ns < a prior event | none (events independent) | ordering by event_id (v7) is monotonic enough | By design |
| RLS bypass attempt on read | l1_audit_log RLS policy | 0 rows | By design |
op='view' row deleted by privileged role | chain reconcile (TASK-MEMORY-101) | detected as a chain gap | Incident response |
| Module emits before AUTH session exists | session_id null | recorded with null session | By design; not all interactions have a session |
| OTel exporter down | buffered then dropped | metrics gap; rows still written | Restore TASK-OBS-001 |
| Two emitters race the same event_id (bug) | unique index | one wins; SEV-3 warn | Author fixes id generation |
| Generated column NULL (payload missing module) | partial index excludes | row still chained; just unindexed by module | Validate guarantees module present for valid rows |
| Very high emit rate from one subject | consent cache + best-effort | DB write pressure on l1_audit_log | Slice-2 batched emit |
§11 — Implementation notes
- Interaction-events are NOT a new store. They are
memory.interaction_eventaux rows onl1_audit_log, written throughcyberos-audit-chainexactly likeauth.token_issuedand the obs rows, so memory's reconcile and the TASK-MEMORY-101 layer-2 ingest accept them with no special path. This is the whole point of DEC-2703. - The audit-row
event_type(TASK-OBS-008's generated column) is the constantmemory.interaction_event; the interaction's own verb ispayload.event_type, surfaced by the newiev_event_typegenerated column so it is indexable without per-row JSON parsing. The TASK-APP-005 audit viewer keeps working because it reads the row-levelevent_type. content_refis the privacy spine. Chat message bodies stay in chat's own DB under chat's RLS; the BRAIN holdspointer{store:"chat_messages", id}. The evaluation engine resolves the pointer only when it needs the content and only for a viewer who already has access to it — the BRAIN never becomes a second, looser copy of sensitive content.- The consent gate reads TASK-EVAL-001's acknowledgment ledger. TASK-EVAL-001 is Phase 0 and lands first; this task depends on it. Until it ships,
consent_gateresolves against a stub table the operator seeds (default: capture OFF for every subject, so the safe default is "no capture without explicit acknowledgment"). The stub is replaced, not the call site, when TASK-EVAL-001 arrives. subject_id = nullis reserved for genuine system actors (scheduled jobs, the dream loop). It MUST NOT be used to anonymise a real person's action — that would defeat both the evaluation purpose and the audit trail. Validation cannot enforce intent here, so it is a code-review rule, called out in the schema description.opderivation: read-class interactions chain asview, mutations asput. The chain'sopenum already hadview; this task is the first heavy producer ofviewrows, which is whyemit_genesis_with_opis added (the oldemit_genesisbecomes a thin'put'shim so AUTH/OBS callers are untouched).- UUIDv7 for
event_idbuys time-ordering for free (the BRAIN's recency-decay recall, TASK-MEMORY-113, and replay dedup both want it) and keeps the unique index selective. - The 2 KiB
attributescap and the body-bytes histogram are the anti-leak pair: the cap makes the worst case impossible, the histogram makes drift visible. If a module's body bytes climb, that is the signal someone started inlining content instead of referencing it. - The contract file is the dependency surface for every emitter task. TASK-MEMORY-122 (AUTH + CHAT emitters) and every later module emitter depend on
interaction-event.schema.jsonatschema_version: 1, not on each other. The additive-only rule (new optional fields, new verbs) lets the vocabulary grow without a flag day; a real breaking change is aschema_version: 2with its own migration note. - The builder is the misuse-resistant front door, but
emitre-validates so a hand-constructed event or a future non-builder caller still cannot write a malformed row. Defence in depth, cheap. - This task is
eu_ai_act_risk_class: limitedbecause the events it captures feed a downstream system (the BRAIN + the evaluation engine, TASK-EVAL-003) that informs human decisions about people. This task itself runs no model and makes no decision — it is the capture contract — but the limited classification is carried here so the whole capture-to-evaluation chain is consistently flagged. See the AI Risk Assessment below.
AI Risk Assessment
- Why limited, not minimal/not_ai: this task defines the capture primitive whose output feeds the BRAIN ingestion (TASK-MEMORY-123) and, ultimately, an AI-assisted evaluation of employees against the three signed documents (TASK-EVAL-003/004). The capture is not itself AI, but it is the data foundation of a limited-risk AI use (workplace evaluation support), so it is classified and governed as part of that chain.
- Personal data captured: identity and activity of CyberSkill employees on the platform — who signed in, who messaged in which channel, who opened or changed which document/issue. It is platform work-interaction metadata, plus pointers/hashes to content; it is NOT keystroke logging, screen capture, or anything from private life (DEC-2701). Content references prefer pointers/hashes over raw bodies, so the captured row carries who/what/when, not the sensitive payload.
- Lawful basis + notice (human governance): capture for any subject is hard-gated on that subject's acknowledgment of the TASK-EVAL-001 monitoring notice (DEC-2702). The gate lives in the one emit path, so there is no code path that captures a person before notice is acknowledged — disclosed monitoring, never covert. The Vietnamese PDPD framing (lawful basis, notice, purpose limitation, data-subject rights) and the Labor Code context are owned by TASK-EVAL-001; this task enforces the technical gate that makes the notice meaningful.
- Purpose limitation + minimisation: the closed
module/event_class/target_ref/content_refvocabulary bounds what can be recorded; the 2 KiB attributes cap and the content_ref-over-raw-body rule enforce minimisation; the per-event_typeretention classes are applied downstream (TASK-EVAL-001 / TASK-MEMORY-123). The point is to record the work, not the person's life. - Access + tenancy: interaction-events inherit the
l1_audit_logper-tenant RLS, so a tenant's records never cross to another tenant. Who-can-read (manager + HR + the employee for their own record) is enforced by the consuming surfaces (TASK-EVAL-005 views), not loosened here. - No autonomous decision: this task records; it never scores, ranks, or decides. Every consequential use of these events runs through the human-in-the-loop evaluation workflow (TASK-EVAL-004). The capture primitive deliberately has no opinion about the events it stores.
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
implementingtasks. 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).