@chatpanel/events 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/event.js ADDED
@@ -0,0 +1,170 @@
1
+ // The event envelope — durable facts only.
2
+ //
3
+ // ORDER WITHOUT CLOCKS. `at` is wall time and is ADVISORY: it is shown to humans and
4
+ // never consulted for ordering. `(host, seq)` plus `causes` are the authority, because
5
+ // once two hosts append concurrently a timestamp is not an order (see order.js).
6
+ //
7
+ // EPHEMERAL STREAMS ARE NOT THIS TYPE. Market ticks, live captions and DOM mutations
8
+ // live in an in-memory ring buffer with no id, no seq and no persistence; only the
9
+ // windowed aggregate that entered a model request or crossed the device boundary is
10
+ // promoted to an Event. That is structural rather than a rule to remember: durably
11
+ // logging a caption stream is ~3 MB per meeting, ~5.5 GB/year, and there is no Event
12
+ // type that would accept one.
13
+ //
14
+ // METADATA ONLY. Payloads carry Refs and counts, never content. `privacy.redacted`
15
+ // carries how many of each entity type were redacted and never the values — a log of
16
+ // what was redacted must not itself contain the redacted data.
17
+
18
+ import { isRef } from './ref.js';
19
+
20
+ export const CURRENT_VERSION = 1;
21
+
22
+ /** The seven families of v1. Adding is cheap; removing is not. */
23
+ export const EVENT_TYPES = Object.freeze({
24
+ turn: ['started', 'ended'],
25
+ // What the model was SHOWN and what it SAID. Deliberately arriving as refs, not text —
26
+ // see the note on the validators below.
27
+ assistant: ['prompted', 'message', 'reasoning'],
28
+ context: ['assembled', 'attached', 'expanded'],
29
+ capability: ['offered', 'granted', 'denied', 'activated', 'revoked', 'invoked', 'resulted'],
30
+ privacy: ['redacted', 'egress'],
31
+ policy: ['changed', 'guard_denied'],
32
+ data: ['created', 'updated', 'deleted'],
33
+ automation: ['fired', 'suppressed'],
34
+ });
35
+
36
+ export const ALL_TYPES = Object.freeze(
37
+ Object.entries(EVENT_TYPES).flatMap(([fam, kinds]) => kinds.map((k) => `${fam}.${k}`)),
38
+ );
39
+
40
+ export const ACTOR_KINDS = Object.freeze(['user', 'rule', 'schedule', 'model', 'agent']);
41
+ export const SCOPE_KINDS = Object.freeze(['global', 'site', 'tab', 'session', 'agent']);
42
+ export const CLASSES = Object.freeze(['R', 'M', 'L', 'C', 'A', 'X', 'H']);
43
+ export const EFFECTS = Object.freeze(['pure', 'idempotent', 'replay-safe', 'non-replayable']);
44
+ export const EGRESS = Object.freeze(['none', 'redacted', 'delegated']);
45
+
46
+ export class EventError extends Error {
47
+ constructor(code, message, detail = null) {
48
+ super(message);
49
+ this.name = 'EventError';
50
+ this.code = code;
51
+ this.detail = detail;
52
+ }
53
+ }
54
+
55
+ const str = (v) => typeof v === 'string' && v.length > 0;
56
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
57
+ const arr = (v) => Array.isArray(v);
58
+
59
+ function actorOk(a) { return !!a && ACTOR_KINDS.includes(a.kind) && str(a.id); }
60
+ function scopeOk(s) { return !!s && SCOPE_KINDS.includes(s.kind) && str(s.id); }
61
+
62
+ // Per-type payload requirements. Deliberately light — enough that a malformed event
63
+ // cannot enter an append-only log, not a full schema language.
64
+ const PAYLOAD = {
65
+ 'turn.started': (p) => str(p.turnId),
66
+ 'turn.ended': (p) => str(p.turnId),
67
+
68
+ // ── assistant ─────────────────────────────────────────────────────────────
69
+ // "Model-visible means logged" was true of the toolset and false of the conversation:
70
+ // the log could say a turn happened and what it cost, but never what was asked or
71
+ // answered. That is the half a trajectory view needs, and the half replay cannot be
72
+ // checked without.
73
+ //
74
+ // CONTENT IS NEVER IN THE EVENT. Each of these carries a Ref — a content hash — and the
75
+ // bytes live in the blob store. Three reasons, in order of how much they matter:
76
+ // 1. The log stays metadata, so exporting or replicating it does not export the user's
77
+ // conversations by accident. That property is the whole reason an event log is safe
78
+ // to keep at all.
79
+ // 2. Deletion stays honest: an append-only log cannot unsay a message, but a blob can
80
+ // be crypto-shredded and the ref then resolves to verified-but-unavailable. Inlined
81
+ // text would make "delete my data" a lie the schema enforces forever.
82
+ // 3. Repeated content (the same system prompt on every turn) is stored once.
83
+ 'assistant.prompted': (p) => str(p.turnId) && isRef(p.ref),
84
+ 'assistant.message': (p) => str(p.turnId) && isRef(p.ref),
85
+ 'assistant.reasoning': (p) => str(p.turnId) && isRef(p.ref),
86
+
87
+ 'context.assembled': (p) => num(p.budget) && num(p.used) && !!p.parts && typeof p.parts === 'object'
88
+ && arr(p.resident) && p.resident.every(isRef) && num(p.reachableCount),
89
+ 'context.attached': (p) => isRef(p.ref),
90
+ 'context.expanded': (p) => isRef(p.ref) && num(p.tokens),
91
+
92
+ 'capability.offered': (p) => str(p.capability) && str(p.reason),
93
+ 'capability.granted': (p) => str(p.capability) && actorOk(p.actor),
94
+ 'capability.denied': (p) => str(p.capability) && actorOk(p.actor),
95
+ 'capability.activated': (p) => str(p.capability) && CLASSES.includes(p.classUsed),
96
+ 'capability.revoked': (p) => str(p.capability) && str(p.cause),
97
+ // I3 is enforced here structurally: a non-pure invocation without a key is not a
98
+ // valid event, so it cannot reach the log at all.
99
+ 'capability.invoked': (p) => str(p.capability) && actorOk(p.actor) && scopeOk(p.scope)
100
+ && EFFECTS.includes(p.effects) && (p.effects === 'pure' || str(p.idempotencyKey)),
101
+ 'capability.resulted': (p) => str(p.capability) && typeof p.ok === 'boolean'
102
+ && CLASSES.includes(p.classUsed) && !!p.cost && num(p.cost.ms),
103
+
104
+ // counts only — never values
105
+ 'privacy.redacted': (p) => !!p.counts && typeof p.counts === 'object'
106
+ && Object.values(p.counts).every(num),
107
+ 'privacy.egress': (p) => str(p.host) && typeof p.redacted === 'boolean'
108
+ && typeof p.controlled === 'boolean',
109
+
110
+ 'policy.changed': (p) => str(p.dial) && actorOk(p.actor) && 'from' in p && 'to' in p,
111
+ 'policy.guard_denied': (p) => str(p.capability) && str(p.reason),
112
+
113
+ 'data.created': (p) => isRef(p.ref),
114
+ 'data.updated': (p) => isRef(p.ref),
115
+ 'data.deleted': (p) => isRef(p.ref) && typeof p.shredded === 'boolean',
116
+
117
+ 'automation.fired': (p) => str(p.ruleId) && CLASSES.includes(p.classUsed),
118
+ 'automation.suppressed': (p) => str(p.ruleId) && str(p.reason),
119
+ };
120
+
121
+ /** Validate a durable event. Throws EventError; never mutates. */
122
+ export function validateEvent(e) {
123
+ if (!e || typeof e !== 'object') throw new EventError('SHAPE', 'event must be an object');
124
+ if (e.v !== CURRENT_VERSION) throw new EventError('VERSION', `expected v=${CURRENT_VERSION}, got ${e.v} — upcast first`, e.v);
125
+ if (!str(e.id)) throw new EventError('SHAPE', 'id required');
126
+ if (!str(e.host)) throw new EventError('SHAPE', 'host required');
127
+ if (!Number.isInteger(e.seq) || e.seq < 0) throw new EventError('SHAPE', 'seq must be a non-negative integer');
128
+ if (!arr(e.causes) || !e.causes.every(str)) throw new EventError('SHAPE', 'causes must be string[]');
129
+ if (!num(e.at)) throw new EventError('SHAPE', 'at required (advisory wall clock)');
130
+ if (!ALL_TYPES.includes(e.type)) throw new EventError('TYPE', `unknown type ${e.type}`, e.type);
131
+ if (!e.payload || typeof e.payload !== 'object') throw new EventError('SHAPE', 'payload required');
132
+ const check = PAYLOAD[e.type];
133
+ if (check && !check(e.payload)) throw new EventError('PAYLOAD', `payload invalid for ${e.type}`, e.type);
134
+ return e;
135
+ }
136
+
137
+ export function isValidEvent(e) {
138
+ try { validateEvent(e); return true; } catch { return false; }
139
+ }
140
+
141
+ /**
142
+ * A per-host append cursor. Owns `seq` — the only monotonic thing in the system — so
143
+ * callers cannot skip or reuse one.
144
+ *
145
+ * `now` and `newId` are injected so tests are deterministic and so the package holds no
146
+ * ambient dependency on a clock or a crypto implementation.
147
+ */
148
+ export function createAppender({ host, seq = 0, now = () => Date.now(), newId }) {
149
+ if (!str(host)) throw new EventError('SHAPE', 'host required');
150
+ const genId = newId || (() => globalThis.crypto.randomUUID());
151
+ let n = seq;
152
+ return {
153
+ get host() { return host; },
154
+ get seq() { return n; },
155
+ /** Build + validate the next event for this host. Does not persist — the caller stores it. */
156
+ append(type, payload, causes = []) {
157
+ const e = Object.freeze({
158
+ v: CURRENT_VERSION,
159
+ id: genId(),
160
+ host,
161
+ seq: n++,
162
+ causes: Object.freeze([...causes]),
163
+ at: now(),
164
+ type,
165
+ payload,
166
+ });
167
+ return validateEvent(e);
168
+ },
169
+ };
170
+ }
package/harness.js ADDED
@@ -0,0 +1,101 @@
1
+ // THE REPLAY HARNESS — what turns the determinism claim into a checked one.
2
+ //
3
+ // An unverified invariant decays silently. The claim we actually sell is replay
4
+ // determinism ("same log, same reconstructed inputs"), so it needs a job that re-runs
5
+ // recorded logs and fails the build when reconstruction drifts — the same discipline as
6
+ // the `sync:pii --check` drift guard and the CSP assertion in build-editor.mjs.
7
+ //
8
+ // What it verifies, and what it deliberately does not:
9
+ // • ORDER is reproduced exactly, from (host, seq) and causes, never from a clock.
10
+ // • MODEL-VISIBLE INPUT is reconstructable: every resident Ref resolves by hash.
11
+ // • A Ref whose blob is gone reports VERIFIED-BUT-UNAVAILABLE. That is a PASS, not a
12
+ // failure — crypto-shredding is a feature, and the log still proves what was sent.
13
+ // • A Ref whose source CHANGED reports DRIFTED, and that IS a failure, because the
14
+ // alternative is replay quietly substituting today's note for the one actually sent.
15
+
16
+ import { upcastAll } from './upcast.js';
17
+ import { linearize } from './order.js';
18
+ import { checkInvariants } from './invariants.js';
19
+ import { resolveRef, RESOLUTION } from './ref.js';
20
+
21
+ /**
22
+ * @param stored events as persisted (any schema version)
23
+ * @param blobs { lookup(ref) } — omit to skip content reconstruction
24
+ * @returns a report; `ok` is the CI signal.
25
+ */
26
+ export function replay(stored, { blobs = null, invariantOptions = {} } = {}) {
27
+ const events = upcastAll(stored);
28
+ const ordered = linearize(events);
29
+
30
+ // Determinism: linearize is a function of the SET. Feed it back reversed; if the order
31
+ // changes, replay depends on how the log happened to be read off disk.
32
+ const stable = linearize([...events].reverse()).map((e) => e.id).join(',')
33
+ === ordered.map((e) => e.id).join(',');
34
+
35
+ const violations = checkInvariants(ordered, invariantOptions);
36
+
37
+ const refs = { exact: 0, unavailable: 0, drifted: [] };
38
+ const turns = [];
39
+ if (blobs) {
40
+ for (const e of ordered) {
41
+ // What the model was SHOWN and what it SAID are refs too, and they are the half a
42
+ // reader most wants replayed. Checking only `resident` verified the toolset while
43
+ // leaving the conversation unverified — the part that actually reconstructs a turn.
44
+ if (String(e.type).startsWith('assistant.')) {
45
+ const r = resolveRef(e.payload.ref, (x) => blobs.lookup(x));
46
+ if (r.resolution === RESOLUTION.EXACT) refs.exact++;
47
+ else if (r.resolution === RESOLUTION.UNAVAILABLE) refs.unavailable++;
48
+ else refs.drifted.push({ eventId: e.id, ref: r.ref, actualHash: r.actualHash });
49
+ continue;
50
+ }
51
+ if (e.type !== 'context.assembled') continue;
52
+ const resolved = e.payload.resident.map((ref) => resolveRef(ref, (r) => blobs.lookup(r)));
53
+ for (const r of resolved) {
54
+ if (r.resolution === RESOLUTION.EXACT) refs.exact++;
55
+ else if (r.resolution === RESOLUTION.UNAVAILABLE) refs.unavailable++;
56
+ else refs.drifted.push({ eventId: e.id, ref: r.ref, actualHash: r.actualHash });
57
+ }
58
+ turns.push({
59
+ turnId: e.payload.turnId,
60
+ budget: e.payload.budget,
61
+ used: e.payload.used,
62
+ parts: e.payload.parts,
63
+ reconstructable: resolved.every((r) => r.resolution !== RESOLUTION.DRIFTED),
64
+ });
65
+ }
66
+ }
67
+
68
+ return {
69
+ ok: stable && violations.length === 0 && refs.drifted.length === 0,
70
+ events: ordered.length,
71
+ stable,
72
+ violations,
73
+ refs,
74
+ turns,
75
+ order: ordered.map((e) => e.id),
76
+ };
77
+ }
78
+
79
+ /** One-line CI summary. */
80
+ export function formatReport(report) {
81
+ const lines = [
82
+ `${report.ok ? 'PASS' : 'FAIL'} — ${report.events} events`,
83
+ ` order stable ${report.stable ? 'yes' : 'NO — replay is order-dependent'}`,
84
+ ` invariants ${report.violations.length === 0 ? 'I1-I6 hold' : `${report.violations.length} violation(s)`}`,
85
+ ];
86
+ for (const v of report.violations) lines.push(` ${v.invariant} ${v.eventId || ''} ${v.message}`);
87
+ if (report.refs.exact || report.refs.unavailable || report.refs.drifted.length) {
88
+ lines.push(` refs ${report.refs.exact} exact · ${report.refs.unavailable} shredded/evicted · ${report.refs.drifted.length} DRIFTED`);
89
+ }
90
+ for (const d of report.refs.drifted) lines.push(` drifted ${d.ref.kind}:${d.ref.id} — the source changed since capture`);
91
+ return lines.join('\n');
92
+ }
93
+
94
+ /** Parse a JSONL log. Blank lines ignored so a truncated export still loads. */
95
+ export function parseJsonl(text) {
96
+ return String(text).split('\n').map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l));
97
+ }
98
+
99
+ export function toJsonl(events) {
100
+ return events.map((e) => JSON.stringify(e)).join('\n');
101
+ }
package/index.js ADDED
@@ -0,0 +1,44 @@
1
+ // @chatpanel/events — the ChatPanel event-log and capability contracts.
2
+ //
3
+ // Two contracts everything else inherits from:
4
+ // • the EVENT SCHEMA — append-only, versioned forever, metadata only, ordered
5
+ // without clocks;
6
+ // • the CAPABILITY SIGNATURE — one call shape a rule, a schedule, the user or a
7
+ // model all invoke identically.
8
+ //
9
+ // Pure and dependency-free so the identical code runs in the extension (browser ESM,
10
+ // MV3/CSP-safe), the gateway and the bridge — the @chatpanel/pii delivery pattern.
11
+
12
+ export {
13
+ CURRENT_VERSION, EVENT_TYPES, ALL_TYPES,
14
+ ACTOR_KINDS, SCOPE_KINDS, CLASSES, EFFECTS, EGRESS,
15
+ EventError, validateEvent, isValidEvent, createAppender,
16
+ } from './event.js';
17
+
18
+ export { REF_KINDS, RESOLUTION, makeRef, isRef, resolveRef } from './ref.js';
19
+ export { linearize, compareEvents, causesAreWellFormed } from './order.js';
20
+ export { UPCASTERS, upcast, upcastAll } from './upcast.js';
21
+ export {
22
+ DATA_SCOPES, validateCapability, validateInvocation, canSatisfy,
23
+ toModelSchema, toModelSchemas,
24
+ } from './capability.js';
25
+ export { checkInvariants, INVARIANTS } from './invariants.js';
26
+ export { createMemoryAdapter, createLogStore, createBlobStore } from './store.js';
27
+ export { createRegistry, REGISTRY_STATES } from './registry.js';
28
+ export { defineSearchEngine, reconcileEngines, attemptOrder, ENGINE_KINDS, SearchEngineError } from './search-engines.js';
29
+ export { defineToolGroup, createToolGroupRegistry, ToolGroupError } from './tool-groups.js';
30
+ export { toolNeedFor } from './tool-need.js';
31
+ export { routeGraph, projectChain } from './route-graph.js';
32
+ export { defineAdapter, createAdapterRegistry, AdapterError } from './adapters.js';
33
+ export { linkifyCitations, sourcesFromToolText } from './citations.js';
34
+ export { buildTrajectory, phasesOf, lanesOf, filterEntries, displayName, ENTRY_KINDS, threadsOf, threadTitle, promptEntries, turnsOf, threadTree } from './trajectory.js';
35
+ export { createTurnRunner, defineLoop, LOOP_KINDS, LoopError } from './loop.js';
36
+ export { defineModel, defineMiddleware, defineRouteStrategy, createModelRouter, signalsFrom, requirementsFor, requirementsForStep, preferenceFor, failoverOrder, pinnedOrderOf, FAILOVER_CLASS_GAP, FAILOVER_CAPABILITY_GAP, sameModelKey, REACH, RouterError } from './router.js';
37
+ export { makeSourceStore, manifestText, shortUrl, readSource, sourceId } from './sources-retrieval.js';
38
+ export { classifySource, extractUrls, hostMatches, meetReach, sourcePolicyFor, DEFAULT_INTERNAL_PATTERNS, INTERNAL_PATTERN_CATALOG } from './sources.js';
39
+ export { defineRule, createRuleEngine, SUPPRESSED, RuleError } from './rules.js';
40
+ export { defineMeetingAnalyzer, createAnalyzerRegistry, CADENCES, AnalyzerError } from './meeting-analyzers.js';
41
+ export { explainMcpError, packageFromArgs } from './mcp-errors.js';
42
+ export { createManifest, ManifestError, SOURCES } from './manifest.js';
43
+ export { createKernel, meetDecisions, KernelError, REQUIRED_PLUGINS, ALLOW_ALL } from './kernel.js';
44
+ export { replay, formatReport, parseJsonl, toJsonl } from './harness.js';
package/invariants.js ADDED
@@ -0,0 +1,174 @@
1
+ // The six invariants the replay harness asserts.
2
+ //
3
+ // These are what make the claims CHECKABLE rather than aspirational. An unverified
4
+ // invariant decays silently, so each of these becomes a CI assertion the way the
5
+ // `sync:pii --check` drift guard and the CSP assertion in build-editor.mjs already are.
6
+ //
7
+ // I1 every model request is preceded by a context.assembled that reconstructs it
8
+ // I2 every byte leaving the device has a privacy.egress (controlled:false for agents)
9
+ // I3 every non-pure capability.invoked carries an idempotency key
10
+ // I4 every capability.revoked names its activated in causes
11
+ // I5 no durable event exists for a per-item ephemeral stream
12
+ // I6 replay under the linearization rule is stable across runs and across hosts
13
+ // I7 no assistant event carries content — only a Ref to it
14
+ //
15
+ // checkInvariants() returns violations rather than throwing, so a harness can report
16
+ // every problem in one pass instead of one per run.
17
+
18
+ import { linearize } from './order.js';
19
+ import { isRef } from './ref.js';
20
+
21
+ const v = (id, event, message) => ({ invariant: id, eventId: event ? event.id : null, message });
22
+
23
+ /** I1 — a turn that ran must have assembled its context, and every resident Ref is hashed. */
24
+ /**
25
+ * I7 — an assistant event names its content, it never contains it.
26
+ *
27
+ * The temptation is obvious: inlining the text makes the trajectory view trivial. It also
28
+ * makes the log a second copy of every conversation, so exporting the log exports the
29
+ * user's data, and shredding a message leaves it readable in an append-only file forever.
30
+ * Structural, not advisory: the checker fails on any payload field holding a long string,
31
+ * because the failure it prevents is silent — everything keeps working, and privacy is
32
+ * quietly gone.
33
+ */
34
+ function checkI7(events) {
35
+ const out = [];
36
+ const MAX = 200; // a model, a stop reason, a hash — never a message
37
+ for (const e of events) {
38
+ if (!String(e.type).startsWith('assistant.')) continue;
39
+ if (!isRef(e.payload?.ref)) out.push(v('I7', e, 'assistant event has no content Ref'));
40
+ for (const [k, val] of Object.entries(e.payload || {})) {
41
+ if (k !== 'ref' && typeof val === 'string' && val.length > MAX) {
42
+ out.push(v('I7', e, `payload.${k} carries ${val.length} chars of content — assistant events must reference, never contain`));
43
+ }
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function checkI1(events) {
50
+ const out = [];
51
+ const assembledByTurn = new Set();
52
+ for (const e of events) {
53
+ if (e.type === 'context.assembled') {
54
+ if (e.payload.turnId) assembledByTurn.add(e.payload.turnId);
55
+ for (const r of e.payload.resident) {
56
+ if (!isRef(r)) out.push(v('I1', e, 'resident entry is not a valid Ref'));
57
+ }
58
+ }
59
+ }
60
+ for (const e of events) {
61
+ if (e.type === 'turn.ended' && e.payload.stepped !== false && !assembledByTurn.has(e.payload.turnId)) {
62
+ out.push(v('I1', e, `turn ${e.payload.turnId} ended with no context.assembled — model-visible input is not reconstructable`));
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** I2 — an invocation that egresses must produce a privacy.egress caused by it. */
69
+ function checkI2(events) {
70
+ const out = [];
71
+ const egressCauses = new Set();
72
+ for (const e of events) {
73
+ if (e.type === 'privacy.egress') for (const c of e.causes) egressCauses.add(c);
74
+ }
75
+ for (const e of events) {
76
+ if (e.type === 'capability.invoked' && e.payload.egress && e.payload.egress !== 'none') {
77
+ if (!egressCauses.has(e.id)) {
78
+ out.push(v('I2', e, `invocation declares egress '${e.payload.egress}' but no privacy.egress references it`));
79
+ }
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** I3 — non-pure invocations carry an idempotency key. (Also enforced by validateEvent.) */
86
+ function checkI3(events) {
87
+ return events
88
+ .filter((e) => e.type === 'capability.invoked'
89
+ && e.payload.effects !== 'pure'
90
+ && !e.payload.idempotencyKey)
91
+ .map((e) => v('I3', e, `'${e.payload.effects}' invocation without an idempotencyKey — a retry would repeat the side effect`));
92
+ }
93
+
94
+ /** I4 — a revoke names the activation it undoes. */
95
+ function checkI4(events) {
96
+ const activated = new Map();
97
+ for (const e of events) if (e.type === 'capability.activated') activated.set(e.id, e);
98
+ return events
99
+ .filter((e) => e.type === 'capability.revoked' && !e.causes.some((c) => activated.has(c)))
100
+ .map((e) => v('I4', e, 'revoked does not name its activated in causes — the effect has no recorded inverse'));
101
+ }
102
+
103
+ /** I5 — ephemeral stream items never become durable facts. */
104
+ function checkI5(events, { ephemeralBudget = 200 } = {}) {
105
+ const out = [];
106
+ const perTypePerHost = new Map();
107
+ for (const e of events) {
108
+ const k = `${e.host}:${e.type}:${e.payload.streamId || ''}`;
109
+ perTypePerHost.set(k, (perTypePerHost.get(k) || 0) + 1);
110
+ }
111
+ for (const [k, n] of perTypePerHost) {
112
+ if (k.split(':')[2] && n > ephemeralBudget) {
113
+ out.push(v('I5', null, `${n} durable events for stream ${k} — per-item stream data must be windowed, not logged`));
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /** I6 — linearization is a function of the event SET, not of the input array's order. */
120
+ function checkI6(events, { shuffles = 8, rng = mulberry32(0x5EED) } = {}) {
121
+ if (events.length < 2) return [];
122
+ const base = linearize(events).map((e) => e.id).join(',');
123
+ for (let i = 0; i < shuffles; i++) {
124
+ const shuffled = shuffle(events, rng);
125
+ if (linearize(shuffled).map((e) => e.id).join(',') !== base) {
126
+ return [v('I6', null, 'linearize() is order-dependent — replay is not deterministic')];
127
+ }
128
+ }
129
+ return [];
130
+ }
131
+
132
+ /** Run every invariant over an event set. Returns [] when the log is sound. */
133
+ export function checkInvariants(events, opts = {}) {
134
+ return [
135
+ ...checkI7(events),
136
+ ...checkI1(events),
137
+ ...checkI2(events),
138
+ ...checkI3(events),
139
+ ...checkI4(events),
140
+ ...checkI5(events, opts),
141
+ ...checkI6(events, opts),
142
+ ];
143
+ }
144
+
145
+ export const INVARIANTS = Object.freeze({
146
+ I1: 'model-visible input is reconstructable from the log',
147
+ I2: 'every egress is recorded',
148
+ I3: 'non-pure invocations are idempotent',
149
+ I4: 'every activation has a recorded inverse',
150
+ I5: 'ephemeral streams never become durable facts',
151
+ I6: 'replay is deterministic',
152
+ I7: 'assistant events reference content, never contain it',
153
+ });
154
+
155
+ // Seeded PRNG so a failing I6 reproduces exactly — the package holds no ambient
156
+ // dependency on Math.random.
157
+ function mulberry32(seed) {
158
+ let a = seed >>> 0;
159
+ return () => {
160
+ a = (a + 0x6D2B79F5) >>> 0;
161
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
162
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
163
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
164
+ };
165
+ }
166
+
167
+ function shuffle(list, rng) {
168
+ const a = [...list];
169
+ for (let i = a.length - 1; i > 0; i--) {
170
+ const j = Math.floor(rng() * (i + 1));
171
+ [a[i], a[j]] = [a[j], a[i]];
172
+ }
173
+ return a;
174
+ }