Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Decision records

ADR 0051 — Perpetual-agent workloads: schedule remodel, durable state, claim-check — placement verdicts

  • Status: Accepted (2026-07-15) — design; implementation tracked per-workstream (each lands with its own ADR/PR)
  • Date: 2026-07-15
  • Builds on: the perpetual-agents requirements rewrite (loom/docs/requirements/2026-07-15-perpetual-agents-sentinel-workloads.md), T1 timers/schedules (ADR 0025), K10 log lifecycle (ADR 0022), K11 code versioning (ADR 0023), the content-store seam (ADR 0046), effect middleware (ADR 0006), plugin packaging (ADR 0043).
  • Part of: the kernel/1P/3P triage discipline (P10 — bias outward). Additive/opt-in (G2) throughout; KERNEL_ABI unchanged; at most one additive MINOR event-schema change (D6).

Context

The database-copilot introduces two workload patterns: agent runs that never logically terminate (sentinels waking every ~5 minutes, indefinitely) and effect data volumes that would swamp the event log (telemetry reads whose raw payloads are high-frequency, individually low-value). An initial requirements draft proposed kernel primitives for both: rotate$ run chaining with atomic suspension handoff, and a tool$ result-by-reference subsystem with a first-class finding$ event.

Two independent evaluations converged after synthesis (recorded in the requirements rewrite): the draft derived kernel requirements from an application modeling choice — one perpetual execution per sentinel — and from problem statements that predate K10.4 compaction, K11 versioning, and T1 schedules. Evaluated against the best userland alternative rather than against “an app approximating the proposed primitive,” almost everything lands outside the kernel.

Decisions

D1 — Sentinels are schedules over bounded runs, not perpetual executions

A sentinel is a schedule$ cron entry; every occurrence starts a new, bounded execution on currently-deployed code. This dissolves, with zero new mechanism: per-run log growth and replay depth (each wake is a fresh small log), the code-versioning tension (every wake re-versions; no months-old run pinned to a stale flow hash), exactly-once wake delivery (T1’s deterministic scheduleId@occurrenceMs ids + idempotent starts + K9.4 fencing), and chain identity/lineage (the chain is the schedule; runs enumerate by id scheme or a projection).

The Temporal continue-as-new analogy was considered and rejected as a justification for a chaining primitive: Temporal needs it because signals address a stable workflow id and durable keyed state outside workflow history is not idiomatic there. Loom shares neither premise — it has first-class schedules and (with D2) contract-validated durable state. See ADR 0052 for the deferred chaining draft and its evidence gate.

D2 — Typed carry state is a first-party plugin (@loomwork/plugin-durable-state), built as recorded effects

Schedule-driven sentinels need state that survives across runs, validated on read, migratable on contract evolution. memory$ cannot carry this: it is an agent/app-layer side channel — flows cannot reach it, writes are not logged, reads are not replay-deterministic (by design; it is mutable conversation state, not a fold input).

The plugin therefore exposes load(key)/save(key, value) as effects: results land in STEP_COMPLETED, so replay folds the recorded value and never re-reads the store — replay-correct by construction. Values carry a { contractVersion, revision, payload } envelope; load validates against the flow-declared Zod contract, applies registered version-to-version transforms (recording the migration in the load result), and fails loudly (StateContractMismatch) when no path exists — never silent coercion. save supports compare-and-set on revision so an accidentally-overlapping occurrence fails fast instead of lost-updating. Backends ride ADR 0043 storage presets.

Kernel-placement rejected: no new event kinds, no ABI surface, and the capability is general to any long-lived keyed state — chain or no chain.

D3 — Claim-check middleware over ContentStore; the reference is the recorded value

claimCheckMiddleware on the ADR 0006 seam: when an effect result exceeds a configurable inline cap (default 16 KB), store the payload via ContentStore.put and return { ref: ContentRef, size, digest } as the effect output — which is what the log records. The digest is declared alongside the tool’s result schema as a pure function of the payload: it is the deterministic projection orchestration may branch on. Tools without a declared digest record { ref, size } only.

This is deliberately NOT ADR 0046 externalization, and the distinction is the contract:

0046 externalizationclaim-check (this ADR)
Log recordsvalue logically inline, physically externalthe ref + digest as the value
Fold/replayContentResolvingEventStore resolves bytes back; blob is load-bearingnever resolves; replay touches no content store
Erasure of blobloud ContentMissingError on foldinvisible to replay (only resolveRef outside the determinism boundary notices)
ForPII/residency: log must never have contained the bytestelemetry: replaying a million stat rows verifies nothing

Both ride the same ContentRef/ContentStore SPI (content-addressing gives tamper-evident evidence for free: a finding’s cited EXPLAIN plan verifies byte-for-byte against its hash). A reach-through lint (flow control that resolves a ContentRef instead of branching on the recorded digest) lives in first-party dev tooling (tools/*, per ADR 0044/0047), not the kernel.

D4 — Findings are application domain events; no finding$ primitive

“Finding” (category, severity, confidence, target objects, evidence refs, acting model) is copilot vocabulary. Apps emit findings via ctx.emit with an app-owned Zod schema — durable, inline (small by construction; evidence goes by reference per D3), and queryable by projections. A kernel finding$ would be exactly the genericity violation the K7–K12 gatekeeping exists to catch. Promote to a contrib package only when a second application wants the schema — not before.

D5 — K10.6: a pin seam in retention (kernel accepts a predicate; the plugin owns the index)

Evidence cited by a finding must outlive both raw-telemetry TTL and archival of the run that recorded it — today neither LogLifecycle.applyRetention nor content-blob reclamation can know that. The retention path gains a pin predicate (“refs/executions reachable from designated event kinds are exempt”), evaluated before archive/delete/reclaim. The kernel checks the predicate; what pins (findings) and the pin bookkeeping (refcount vs mark-and-sweep) live in the claim-check plugin, which knows the envelope format. This is also the missing shape for ADR 0046’s deferred refcounted blob GC — one seam serves both. Pins survive run archival by construction: the index is keyed by ref, not by run.

D6 — K11.5: contract-versioned state migration on resume (general, not chain-specific)

K11 migrates code; a suspended execution whose folded state must transform to match an evolved contract has no path — resumeAs replay-check rightly refuses it today, and no ADR flagged the gap. resumeAs gains an optional registered state transform (old contract → new), applied at migration time and recorded durably (extend EXECUTION_MIGRATED’s payload or one new kind — additive MINOR on EVENT_SCHEMA 1.14.0; decide in the implementation ADR). Absent a needed transform, migration fails explicitly, exactly as now. Specified as a K11 amendment precisely so it is built once: sentinels barely need it (D2 owns carry-state migration), but any long-suspended execution — human gates, A2A waits — has this problem.

D7 — Opt-in inputSchema enforcement at start()

FlowDefinition.inputSchema is metadata today (manifest/deploy tooling only). An opt-in runtime flag parses input at start() for every entry path (schedules, A2A submit, direct). Unwired = byte-identical (G2).

Rejected / deferred

  • finding$ kernel primitive — rejected (D4).
  • Identity-preserving transfer of live suspensions across executions (the original RC-4 option a) — rejected permanently; recorded with rationale in ADR 0052.
  • rotate$ run chaining — deferred with an explicit evidence gate: ADR 0052.
  • checkpoint$ — not needed; snapshots (snapshotEvery, loadLatestBefore) + K10.4 compaction already bound replay depth.

Consequences

  • Kernel surface: two seams (D5 predicate hook, D6 transform hook) + one opt-in flag (D7). No new syscalls; KERNEL_ABI stays 1.1.0; EVENT_SCHEMA bumps once, additively, in D6.
  • New 1P packages: @loomwork/plugin-durable-state; claim-check lands in @loomwork/middleware beside retry/redaction (same seam, same wiring discipline).
  • The copilot ships on today’s kernel: D1 is a pattern, D2/D3 are plugins. Kernel items D5–D7 harden guarantees but do not block the sentinel workload.
  • Implementation order, ADR-per-PR: durable-state → claim-check → pin seam → K11.5 → D7 flag → lint → a DST-simulated 12-month sentinel example proving the acceptance criteria in the requirements doc.

Source: docs/adr/0051-perpetual-agents-placement-schedule-remodel.md in the loom repository.