RACT

RACT event schema

Origin. SUBSTRATE spec §6 (Substrate Layer 5: The Trace is the Product) and §11 signals 9, 10, 11. Every load-bearing decision a run makes lands as one event in the hash-chained JSONL log at evals/runs/<run_id>/events.jsonl. The log is the source of truth; RunReporter is a projection over it (module_05 migration).

The vocabulary is closed. Adding a new event kind requires:

  1. Adding the string literal to EventKind in src/ract/trace/events.py.
  2. Adding the kind’s payload schema and one canonical example to this document.
  3. Bumping the schema_version in this document’s frontmatter.
  4. Naming the emit site in the module that introduced it.

Reference sources:

Common event envelope

Every event carries the following fields regardless of kind:

EventReader.load(path) reads the log line-by-line and re-hashes each event; a mismatched prev_hash or a re-hash disagreement raises ChainBrokenError at load time.

Determinism contract for ract trace replay

Replay requires that the tool layer be deterministic given the same inputs. The event log records the model’s response.received payloads, so the model layer is trivially replayable; the tool layer’s determinism depends on the sandbox (module_03) and worktree (module_02) already being deterministic given the same inputs. ract trace replay emits a warning when the workspace’s HEAD does not match the run’s initial snapshot; a mismatch means the replay may diverge on the first worktree-shaped operation.

Closed EventKind vocabulary

Every kind’s payload is a JSON dict; keys with no value are omitted.

Run lifecycle

run.started

Emitted by IntentCompiler.compile after the frozen AcceptanceSuite is built.

Fields:

Example payload:

{
  "intent_id": "d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6",
  "suite_digest": "sha256:…",
  "compiler_version": "0.4.0",
  "predicate_count": 4,
  "required_count": 4,
  "coverage_gate": 0.85
}

run.completed

Emitted at run close when the acceptance gate is satisfied.

Fields: final_decision (string), duration_ns (int), event_count (int).

run.aborted

Emitted at run close when the loop halts before the gate is satisfied.

Fields: termination_cause (string; one of TerminationCause names), reason (string), duration_ns (int).

Step transactions (module_02)

step.started

Emitted by open_transaction in ract.core.transaction.

Fields:

step.committed

Emitted by SubstrateLoop._finalize when the worktree commit lands.

Fields: outcome ("COMMITTED"), parent_snapshot_before (string), parent_snapshot_after (string), branch (string), reason (string).

step.rolled_back

Emitted for ROLLED_BACK and BLOCKED_ON_HANDSHAKE outcomes.

Fields: same as step.committed, with reason naming the block or failure.

Provider I/O (module_04)

prompt.sent

Emitted by send_with_trace in ract.providers.provider before the provider call.

Fields: provider (string), response_shape (string), intent_id (string), prompt_chars (int).

response.received

Emitted after the provider call returns; carries a preview of the response type and up to 200 characters of the payload.

Fields: provider (string), intent_id (string), response_type (string), preview (string).

response.validated

Emitted by ResponseValidator.parse on success.

Fields: step_id (string), kind (string; one of the closed ActionKind literals).

response.rejected

Emitted by ResponseValidator.parse on failure.

Fields: step_id (string), error (string), attempt (int), should_halt (bool).

Tool dispatch

tool.called

Emitted at the tool dispatch site (invocation of a PlannedStep’s action against the workspace).

Fields: tool (string), arguments (dict, redacted per profile).

tool.result

Emitted when a tool call returns.

Fields: tool (string), ok (bool), duration_ns (int).

tool.refused

Emitted when a tool call is refused (sandbox, policy, handshake, or manifest).

Fields: tool (string), reason (string), refused_by (string; "sandbox" / "policy" / "handshake" / "manifest").

Sandbox (module_03)

sandbox.granted

Emitted by LinuxSandbox / MacosSandbox on successful enter.

Fields: manifest_digest (string), reason (string), details (dict).

sandbox.denied

Emitted when enter refuses the manifest.

Fields: manifest_digest (string), reason (string), details (dict).

sandbox.unenforced

Emitted by UnenforcedSandbox on each enter — loud in the log by design (see ADR-0012).

Fields: manifest_digest (string), reason (string), details (dict).

sandbox.env_scrubbed

Added in v0.5.1 wiring module_04 (Lens C C-02 + C-10 closure).

Emitted by every sandbox backend — LinuxSandbox, MacosSandbox, and the Windows UnenforcedSandbox stub — on every enter. Payload carries the environment-allowlist audit for the invocation:

Producer sites: src/ract/security/sandbox_linux.py::LinuxSandbox.enter, src/ract/security/sandbox_macos.py::MacosSandbox.enter, src/ract/security/sandbox.py::UnenforcedSandbox.enter.

Process group reap (v0.5.1 wiring module_05)

process.reaped

Added in v0.5.1 wiring module_05 (Lens C C-03 closure).

Emitted by SubstrateLoop._reap_active_processes once per handle SIGKILL’d. The wire turns the process-group tree-kill from a silent WARN into a first-class trace signal an auditor can grep process.reaped to reconstruct which descendant trees each rollback path terminated.

Payload:

Audit interpretation. A reason="dispose_unsuccessful" event with reap_latency_ms > 0 and argv0 matching a background test process is the fingerprint of a step_runner that spawned a long-running child and failed to await it before the rollback. A cluster of process.reaped events in a single run is not a red flag on its own — the substrate reaps every registered handle on rollback; the operational signal is whether the argv0 pattern points at a step_runner bug (spawn-and-forget) or a normal rollback of a long-running test suite.

Producer site: src/ract/executor/loop.py::_emit_process_reaped, called per-handle inside SubstrateLoop._reap_active_processes. The primitive that actually SIGKILL’d the tree lives at src/ract/executor/process_group.py::kill_tree.

Predicates (module_01)

predicate.evaluated

Emitted by AcceptancePredicate.evaluate after every evaluation.

Fields: predicate_id (string), kind (string; one of test / type / property / invariant / artifact), required (bool), ok (bool), reason (string), duration_ns (int).

Handshakes

handshake.requested

Emitted by HandshakeRegistry.add.

Fields: milestone_id (string), status (string; always "pending" at request time), description (string), reason (string).

handshake.resolved

Emitted by HandshakeRegistry.update_status.

Fields: same shape; status is one of approved / rejected / deferred.

Rootknot / provenance (module_06 extends)

rootknot.created

Emitted by ProvenanceIndex.save.

Fields: workspace_path (string), plan_id (string, hex UUID), step_id_ref (string, hex UUID), artifact_digest (string), assumption_digest (string).

rootknot.verified

Emitted by verify_workspace per-verified knot.

Fields: workspace_path (string), artifact_digest (string).

Assumptions

assumption.proposed

Emitted by AssumptionRegistry.propose.

Fields: assumption_id (string, hex UUID), digest (string), text (string).

assumption.discharged

Emitted by AssumptionRegistry.discharge.

Fields: assumption_id (string, hex UUID), digest (string).

assumption.violated

Emitted by AssumptionRegistry.violate for the primary and every propagated dependent.

Fields: assumption_id (string, hex UUID), root_id (string; the originating assumption id).

Contracts (module_06)

auction.proposal

Emitted by AuctionSweep.run — one event per staged DeletionProposal. The Auction is a scheduled between-iteration sweep (SUBSTRATE §8); nothing is deleted without operator sign-off.

Fields: workspace_path (string; workspace-relative path of the proposed deletion), last_modified_days (integer; file age at scan time), inbound_references (integer; count of graph edges into the target from other modules), reason (string; short human-readable justification).

Redaction profile

The optional RedactionProfile in ract.trace.writer scrubs listed regex patterns and fully-redacts named payload fields before write. It is off by default; enable it in ract.yaml:

trace:
  redaction:
    patterns:
      - "sk-[A-Za-z0-9]{20,}"
    fields:
      - api_key
    replacement: "[REDACTED]"

The profile is intentionally shallow — a first line of defence for shared logs, not a data-loss-prevention layer. Deeper redaction is v0.5 hardening (per ADR-0015).

Memory discipline (module_09, v0.5.0)

The seven kinds below extend the closed vocabulary per master spec §Signals items 11-13. Producers live in src/ract/memory/events.py (the module_01-08 helpers) and in src/ract/executor/loop.py (retrieval.satisfied when SubstrateStepSpec.metadata["retrieval_ bundle"] is populated). Payload keys reflect the retrieve / budget / probe surfaces landed in modules 01-08.

budget.declared

Emitted when a memory-discipline function declares its per-invocation budget. Fields: function (string; intake/research/plan/edit), declaration (dict; BudgetDeclaration.asdict), narrowing_log (list of BudgetNarrowing dicts), source (composition | runtime | cli | default).

budget.exceeded

Emitted by refuse_over_ceiling before the paired BudgetExceededError raise. Fields: function (string), section_name (string), delta (integer), boundary (input_max or hard_ceiling).

retrieval.requested

Emitted by retrieve() at cascade entry. Fields: canonical_query (dict; canonical_query_payload output), budget (integer; retrieve-local sub-budget), call_id (hex).

retrieval.satisfied

Emitted by retrieve() on a returned bundle AND by SubstrateLoop.run_step when the step’s spec carries a bundle in metadata["retrieval_bundle"]. Fields: call_id (hex string), total_tokens (integer), budget_used_pct (float), and step_id (hex) on the loop-side emission.

retrieval.cascaded

Emitted when the four-level retrieve cascade downgrades a level. Fields: call_id (hex), from_level (integer 1-4), to_level (integer 1-4), reason (string).

retrieval.refused

Emitted when the retrieve cascade exhausts every level and returns an empty bundle. Fields: call_id (hex), reason (string).

probe.evaluated

Emitted by the self-adjustment probe scheduler on each probe completion. Fields: probe (needle | coherence | adherence), score (float 0.0-1.0), repo_fingerprint (hex), note (string).

v0.5.1 External Review Response (schema_version 4)

The seven kinds below extend the closed vocabulary under schema_version 4. Producers:

assumption.accepted

Emitted by AssumptionRegistry.accept when a proposed assumption is durably persisted to the WAL at .ract/assumptions.wal. Fields: run_id (string, hex; ambient or empty), assumption_id (string, hex UUID), evidence_digest (string; SHA-256 of the evidence bytes that justified acceptance, canonicalised via ract.canonical.dumps_jcs).

Example payload:

{
  "run_id": "4a1c0f9e6b3d2a5c8f9e1b4d2a7c6f3e",
  "assumption_id": "d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6",
  "evidence_digest": "sha256:9f8e7d6c5b4a39281706f5e4d3c2b1a0…"
}

Wiring gap (v0.5.1 module_03 scope). The three tool.invocation.pre|post|refused kinds below are emitted directly by src/ract/executor/tool_gate.py as raw string literals; they are documented here as part of the v0.5.1 vocabulary but are NOT YET in the closed EventKind Literal at src/ract/trace/events.py (which today carries the older tool.called / tool.result / tool.refused shape). The wiring completion pipeline’s module_03 (tool gate chokepoint wiring) extends the Literal + wires every production tool caller through SubstrateLoop.invoke_tool. Consumers can treat the payload shapes below as stable; the type-enforcement gate lands with module_03.

tool.invocation.pre

Emitted by ToolInvocationGate.invoke before the tool callable runs, after all four gates (manifest / registry / args / budget) have accepted. Fields: run_id (string, hex; ambient), tool_id (string), args_repr (string; bounded / privacy-safe repr of the call arguments), budget_used (int; invocations already consumed this step), budget_max (int; per-step ceiling from InvocationBudget.max_invocations).

tool.invocation.post

Emitted by ToolInvocationGate.invoke after the tool callable returns (success) or raises (failure). Fields: run_id (string, hex), tool_id (string), ok (bool), latency_ms (float); on success: result_size_bytes (int; approximate serialised size of the return value); on failure: exception (string; type(exc).__name__ of the raised exception).

tool.invocation.refused

Emitted by ToolInvocationGate._refuse when any of the four gates rejects a call. Fields: run_id (string, hex), tool_id (string), gate (string; one of manifest / registry / args / budget), reason (string; short human-readable diagnostic), details (dict; gate-specific structured payload carrying e.g. the missing schema field name for args, the ceiling / used counts for budget, or the declared-set diff for manifest / registry).

manifest.ledger.appended

Emitted by ManifestLedger.append on every successful entry write. Fields: entry_index (int; the 0-based ledger position of the appended entry), manifest_digest (string; hex of the observed manifest’s canonical digest), prev_ledger_hash (string; hex of the previous entry’s chain hash, or GENESIS sentinel for the first entry), tool_ids_invoked_count (int; number of distinct tool ids invoked in the substrate step at ledger-append time).

manifest.ledger.refused

Emitted by the ledger observer when a ledger IS bound (an ambient ledger accessor returns a live instance) but the append fails (disk full, permission change, lock contention, malformed payload). Fields: entry_index (int; the attempted position; -1 when the position could not be resolved), reason (string; one of lock_contended / disk_full / permission_denied / malformed_payload / chain_mismatch), details (dict; free-form structured diagnostic carrying exception_name

whisperer.contract_violation

Emitted by ract.antilazy.sycophancy_v2.SycophancyClassification.emit_event whenever is_sycophantic is True (SP Q4a lifted the emit gate to the composed verdict, not the commitment-floor branch alone). Fields: run_id (string, hex; ambient or empty), commitment_count (int; sum of AST commitments + factual claims), floor (int; MIN_COMMITMENT_FLOOR at classify time, default 3), response_excerpt_hash (string; 16-hex prefix of SHA-256 over the first 256 bytes of the response), response_full_hash (string; 64-hex SHA-256 over the entire response body – SP Q4b amendment, disambiguates long-prefix collisions), null_op_score (float 0.0-1.0), null_op_threshold (float; the NULL_OP_SCORE_THRESHOLD in effect at classify time, default 0.7 – SP Q3 exposes runtime overrides that land on this payload), trigger (string; one of null_op / commitment_floor / both – SP Q4a; names which signal fired), used_regex_fallback (bool; True when grammar-parse failed and the classifier degraded to the regex-only fallback path).

See docs/ADRs/ADR-0042-sycophancy-v2-tuning-band.md for the tuning-band provenance behind the floor and null_op_threshold defaults.

v0.5.1 spec-completeness module_02 (schema_version 6)

The kind below extends the closed vocabulary under schema_version 6. Producer:

state.budget_capped

Emitted by ract.memory.functions.provider_adapter.seat_state_section when the proposed state_context section’s token cost exceeds floor(0.15 * declaration.input_target) – the master spec’s sub-budget cap (docs/RACT_v0.5.0_MEMORY_DISCIPLINE_SPEC.md §Context Composition line 71: “state_context bounded at 15% of input budget”). The truncation strategy is truncate_tail: the helper drops trailing lines one at a time and appends a one-line [TRUNCATED: state_context capped at N tokens; K lines dropped from tail] marker; the marker counts toward the seated size so the seated total is <= cap_tokens. Future strategies may report entry drops or summarization; the strategy field names which one fired.

Fields:

Example payload:

{
  "function": "plan",
  "cap_tokens": 600,
  "requested_tokens": 872,
  "seated_tokens": 599,
  "dropped_entry_count": 14,
  "strategy": "truncate_tail",
  "requested_hash": "3f5e…"
}

v0.5.1 spec-completeness module_03 – repair-synthesized close events (schema_version 6, no new kind)

Module_03 (Lens 2 Delta 1) adds :mod:ract.trace.repair, which synthesizes close events for open handles in a possibly-truncated event log. Repair uses the EXISTING closed EventKind vocabulary (no new kind, no schema bump). Synthesized close events carry three payload-shape additions on top of the base close event’s schema:

Additional per-close-kind fields:

Example synthesized run.aborted:

{
  "kind": "run.aborted",
  "payload": {
    "synthesized": true,
    "reason": "interrupted",
    "source_event_id": "3fa85f6417174562b3fc2c963f66afa6"
  }
}

Consumers filtering for real closes MUST check payload.get("synthesized") – a synth close is a repair projection, not an authoritative run terminator. See :func:ract.trace.repair.repair docstring for the full open->close map and determinism contract.

Fiber-lifecycle event kinds (fiber.activated / fiber.disposed / fiber.failed) are NOT part of the closed vocabulary in v0.5.1 per the audit’s Delta 1 recommendation (§5.2 loops-as-fibers not adopted); their addition would be a schema_version bump.

v0.5.1 spec-completeness module_04 – cross-function grouping (schema_version 7)

Module_04 (Lens 1C HIGH C-1 closure) adds :mod:ract.memory.grouping and wires it into :func:ract.memory.retrieve.retrieve at bundle- assembly time. The retrieve primitive emits one retrieval.grouping.applied event per grouping rule that fired against a primary symbol seated at the cascade’s final level. Producers:

retrieval.grouping.applied

Emitted when one of the four grouping rules (dataclass_methods / trait_impls / test_subject / function_type_aliases) fires for a primary symbol seated in the bundle. One event per (primary, rule) pair; a primary that matched no rule emits no event. The event carries the count of companions actually seated at this format cascade level AND the count of companions the rule identified but the budget floor dropped, so an auditor can distinguish “no rule fired” from “rule fired but budget starved every companion”.

Fields:

Example payload:

{
  "call_id": "b13a4c0f9e6b3d2a5c8f9e1b4d2a7c6f",
  "primary_symbol_id": 4712,
  "companion_count": 3,
  "rule_fired": "dataclass_methods",
  "companion_format": "full",
  "dropped_companion_count": 0
}

Budget cascade for companions (bundle-level, not the four-level primary cascade): grouping runs AFTER the primary cascade has packed its winners. Each companion is tried at the caller’s format first; on non-fit, downgraded to sig; on further non-fit, added to :attr:RetrievalBundle.dropped_companions and counted in dropped_companion_count. The primary is never downgraded to seat a companion.

Backward compatibility: :attr:RetrievalQuery.grouping_enabled defaults to True; a caller who sets it to False gets today’s pre-module_04 behavior (no grouping, no events). The event kind extends the closed vocabulary under schema_version 7.

v0.5.1 spec-completeness module_07 – subagent cascade (schema_version 8)

The kind below extends the closed vocabulary under schema_version 8. Producer: :meth:ract.executor.loop.SubstrateLoop._reap_subagent_handles (module src/ract/executor/loop.py). Fires once per subagent handle disposed on a non-T1 halt (or from the run_step exception path).

subagent.disposed

Emitted per subagent handle disposed during a non-T1 cascade. Covers long-lived helper resources – Legacy Whisperer, Chesterton’s Fence, LSP language servers, embedding sidecars – registered via :meth:SubstrateLoop.register_subagent_handle that the loop OWNS the disposal contract for on any non-success halt path.

Payload:

{
  "kind": "subprocess",
  "descriptor": {
    "role": "whisperer",
    "label": "legacy_whisperer[intent=refactor_extract]"
  },
  "reason": "dispose_unsuccessful",
  "ok": true
}

Fields:

Backward compatibility: the event kind is additive. The prior schema_version 7 stays valid for readers that need not surface subagent cascades; readers at schema_version 8 gain the new kind.

v0.5.2 hardening pipeline – ten additive kinds (schema_version 9)

The v0.5.2 Deep-Audit Hardening pipeline extends the closed vocabulary under schema_version 9 with ten additive kinds. Producers cited per kind. Payloads are informal (documented here; the LEGAL_EVENT_KINDS frozenset in src/ract/trace/events.py is the load-bearing gate). Backward compatibility: every kind is ADDITIVE; readers at schema_version 8 that ignore unknown kinds continue to work.

substrate.subagent.tree_kill_invoked

Emitted every time :meth:SubprocessSubagentHandle.dispose calls into :func:process_group.kill_tree, regardless of whether the parent Popen has already exited. The poll_exited path is the load-bearing DA-A F-4 fix: pre-hardening the short-circuit skipped tree-kill in that case and grandchildren leaked; now it fires unconditionally.

Payload: pid, creation_time_ns, path (one of "poll_exited" / "timeout" / "explicit" / "error").

substrate.subagent.pid_reuse_detected

Emitted when :func:process_identity.same_process refuses a signal because the pid’s live creation_time_ns no longer matches the value captured at spawn. The event MUST cause the caller to skip the signal – killing the wrong tenant would be worse than a leaked descendant.

Payload: stored_pid, stored_ctime (creation_time_ns from spawn), current_ctime (creation_time_ns of the pid RIGHT NOW).

substrate.subagent.orphan_reaped

Emitted when tree-kill actually terminated live descendants after the parent Popen exited. Distinct from tree_kill_invoked because that fires unconditionally on every dispose while orphan_reaped only fires when the substrate ACTUALLY caught reparented descendants – the audit signal an operator uses to confirm the DA-A F-4 defense is doing real work.

Payload: count (int), pids (list of ints, capped at 32).

runtime.run_id.env_injected

Emitted when :meth:SubstrateLoop.spawn_step_subprocess has plumbed RACT_RUN_ID into a spawned child (parent had an ambient bound), OR when :func:ract.runtime.bootstrap_ambient_from_env at subagent boot binds an ambient from RACT_RUN_ID env.

Payload: run_id, child_pid, source (one of "spawn_step_subprocess" / "env").

runtime.run_id.env_rejected

v0.5.2 module_06 (m04 C-6 fold). Emitted when :func:ract.runtime.bootstrap_ambient_from_env discards a RACT_RUN_ID env value that fails the ^[A-Za-z0-9_-]{1,240}$ format regex (path-shape poisoning defense). The subagent falls through to orphan_generated after the reject.

Payload: reason (truncated to ≤80 chars), child_pid, source (typically "env").

runtime.run_id.env_stripped_from_parent

Emitted when :func:_inject_ract_run_id_env discards a caller-supplied RACT_RUN_ID (attacker sneak-vector: shell sets RACT_RUN_ID=victim_run before invoking ract). Raw poisoned value NEVER logged.

Payload: stripped_key, stripped_value_hash (16-hex prefix of sha256).

runtime.run_id.orphan_generated

Emitted when a subagent is invoked WITHOUT RACT_RUN_ID (legitimate: operator debug, external orchestrator). :func:bootstrap_ambient_from_env generates a synthetic RUN-ORPHAN-{uuid} and binds it.

Payload: synthetic_run_id, reason, child_pid.

sidecar.header.written

Emitted each time a sidecar writer emits a header via :func:ract.sidecar_header.write_json_sidecar_with_header (or an equivalent header-first path).

Payload: path, sidecar_type, schema_version, run_id.

sidecar.header.missing_refused

Emitted when :func:ract.sidecar_header.read_sidecar_header refuses a sidecar in strict mode (headerless), OR when the header is present but violates schema-allowlist / downgrade policy.

Payload: path, reason (one of "headerless" / "unknown_schema" / "downgrade").

sidecar.header.mismatch_refused

Emitted when the header run_id differs from the verifier’s expected_run_id.

Payload: path, header_run_id, expected_run_id.

budget.clamp_refused

Emitted by :func:ract.memory.budget_registry.request_budget when a probe-informed clamp would drop the input target below the caller’s declared floor. The registry refuses the clamp and returns the original target unchanged.

Payload: call_id, declared_target, proposed_target, floor, reason.

budget.adjusted_by_probes

Emitted by :func:ract.memory.budget_registry.request_budget when a probe-informed clamp is applied; the reduced input target replaces the caller’s declared value.

Payload: call_id, declared_target, adjusted_target, probe_signal.

tool.invocation.bypassed

Emitted by :func:ract.executor.steps.run_step when the step skipped a declared tool invocation (dry-run guard, allowlist refusal, or precondition failure). The event lets the trace distinguish “tool was never asked for” from “tool was requested but refused before dispatch”.

Payload: tool_id, reason, step_id.

laziness.skipped

Emitted by :func:ract.antilazy.pre_commit when the pre-commit gate elects NOT to run for the current diff (small edit, docs-only, or explicit operator opt-out). Distinct from laziness.violated (gate ran and found a violation).

Payload: reason, diff_summary.

whisperer.classifier_error

Emitted by the loop controller when the sycophancy classifier raises; the controller downgrades to a WARN log so the run continues.

Payload: error_kind, message.

laziness.gate_error

Emitted by the loop controller when the anti-laziness gate raises; the controller downgrades to a WARN log.

Payload: error_kind, message.

memory.probe_scheduler_error

Emitted by the loop controller when the probe scheduler raises; probes are best-effort telemetry, never load-bearing on the run.

Payload: error_kind, message.

memory.composition_runner_error

Emitted by the loop controller when the context-composition runner raises; the controller downgrades to a WARN log.

Payload: error_kind, message.

memory.cascade_error

Emitted by :meth:ract.memory.watcher.MemoryWatcher when one index in the memory cascade (semantic / symbol / lexical) fails to attach; the watcher records that the cascade continued with the survivors.

Payload: index, error_kind, message.

memory.freshness_gap

Emitted by :meth:ract.memory.watcher.MemoryWatcher when an index’s freshness is trailing the workspace clock beyond the policy threshold.

Payload: index, gap_ms, threshold_ms.