SPEC — `SP.Runtime.Lineage.snapshot/1` (D-D3)
How to read this page
A Plain and a Clear version of this page have not been written yet. What follows is the document itself.
Precise — the source document
This is the document. Rendered from the repository at the commit above, with nothing rewritten for the web. A gate re-renders it on every deploy and fails the build if a single byte differs.
Status: SPEC. Touches lib/sp/runtime/lineage.ex only (FE-adjacent) — no change to lib/sp/runtime/agent.ex or lib/sp/brain/mc.ex in v1. Awaits /lab-team-review.
Ship gate: MERGED VERDICT required.
Revision (2026-07-12): re-scoped after an independent review (readiness: NEEDS_CLARIFICATION, medium risk) found four gaps between the original draft and the live repo — a claimed atomic-write primitive that doesn't exist, a claimed live-kin enumeration function that doesn't exist, a claimed heartbeat caller that doesn't exist, and a second-writer collision with an already-registered spool. All four are resolved below by re-scoping v1 to what real, composable primitives already support and naming the rest as explicit deferred work. See "v1 Scope".
Purpose
Kin memory files (runs/colony/kin-*.bin) are today saved only on a save_every tick and at process end, all via SP.Brain.MC.save/2 (lib/sp/brain/mc.ex:552-554) called from inside SP.Runtime.Agent:
- the plain save-every tick,
lib/sp/runtime/agent.ex:185(and the analogous metabolic/homeostatic save-every branches at:259and:318); - the plain Port-exit handler,
lib/sp/runtime/agent.ex:195(and the analogous metabolic/homeostatic death branches at:251and:310); GenServer.terminate/2,lib/sp/runtime/agent.ex:358.
If UNI-LAB's disk dies between saves, the whole learned Dirichlet history dies with it. The 2026-07-12 runaway cleanup archived 178 kin files ad-hoc; there was no scheduled snapshot mechanism. This spec adds one.
v1 Scope (read this first)
- Atomicity — v1 is NOT atomic on the source side. No atomic temp-file write primitive exists on
SP.Runtime.Agenttoday — it exposes onlystats/1(lib/sp/runtime/agent.ex:87, aGenServer.calldiagnostics read); every real save goes throughMC.save/2, a plainFile.write!/2with no tmp+rename. v1snapshot/1reads the existing, already-on-diskkin-*.binoutput ofMC.save/2as-is and accepts the small, documented risk of a torn read (see "Snapshot mechanics" step 3). A newSP.Runtime.Agentatomic-dump call is real, buildable follow-on work, not built in this pass. - Live-kin enumeration — real, composable primitives, spelled out.
SP.Runtime.Lineage's GenServer state has no agent-pid/username field, andspawn_next/2discards itsSupervisor.spawn_agent/1return value (lib/sp/runtime/lineage.ex:121-141) — there is no ready-made "list my live kin" call. v1 composes real, existing functions instead:SP.Runtime.Lineage.name/1(registered-name pattern,lib/sp/runtime/lineage.ex:72) +Process.whereis/1, cross-referenced againstSP.Runtime.Supervisor.list_agents/0(lib/sp/runtime/supervisor.ex:85-89). See "Snapshot mechanics" step 2 for the exact composition. - Trigger — v1 is
:manualonly. No heartbeat caller exists anywhere in the Elixir codebase (repo-wide,lib/has zero hits for "heartbeat"). The only heartbeat in the repo isproduction/scripts/heartbeat.sh, a 60-second systemd-timer shell script, not a BEAM process — and its own header marks itstatus: pending (authored, not yet run on node hardware). Building a new Phoenix-endpoint bridge for a heartbeat mechanism that is not itself live yet is premature. v1 drops:heartbeat_idle/:heartbeat_liveto a named Phase 2 (a BEAM-side heartbeat mechanism, or an HTTP bridge fromheartbeat.sh, is a prerequisite not built in this pass). - Output path — v1 writes ONLY under
runs/colony/snapshot/YYYYMMDD/HHMM/.production/docs/OS_SPOOL_POLICY.mdalready declares/var/lib/uni/backups/colony/**sole-writerproduction/scripts/colony_archive.sh(daily, 03:30 UTC) — see that doc's own "Why" section, which documents a real EPERM crash on 2026-07-12 caused by exactly this two-writer pattern.snapshot/1does not write into that tree in v1. A bind-mount-shared output path is a named, deferred prerequisite: it requires amendingOS_SPOOL_POLICY.md's ledger with a new registered writer entry forsnapshot/1at the finer, sub-dailyHHMMgranularity it would add alongsidecolony_archive.sh's own daily entry.
Signature
defmodule SP.Runtime.Lineage do
@spec snapshot(opts :: keyword()) :: {:ok, snapshot_dir :: String.t()} | {:error, term()}
def snapshot(opts \\ [])
end
Behaviour
opts[:trigger]— v1 supports:manualONLY (default). Passing:heartbeat_idleor:heartbeat_livereturns{:error, {:unsupported_trigger, trigger}}— those are reserved atoms for Phase 2 (see "v1 Scope" item 3), not silently treated as:manual.opts[:idle_min_gap_s]andopts[:live_gap_ticks]are Phase-2-only options; v1 does not read them.opts[:kins]— the candidate kin-id range to probe for live lineages. Default0..9(the architectural kin-group range named inSP.Runtime.Lineage's own moduledoc,lib/sp/runtime/lineage.ex:3-4: "one per kin group 0..9"); override for a colony run configured with a narrower range (e.g.SP.Brain.Colony.start_evolution/2's own default0..3,lib/sp/brain/colony.ex:76), or a wider/out-of-band range (e.g. a test fixture). v1 does not boundopts[:kins]: each candidate id interns a permanent BEAM atom viaLineage.name/1(lib/sp/runtime/lineage.ex:72), so an unbounded range risks atom-table exhaustion in principle — accepted in v1 only because:manual-only triggering keepsopts[:kins]operator-typed, never reachable from an automated/public surface (see "v1 Scope" item 3). Bounding it to (or rejecting ranges wider than) the architectural0..9range is a named Phase-2 prerequisite, gated onopts[:kins]becoming reachable from a less-trusted caller — see "Test coverage" Phase 2 list.- If
trigger == :manual(the only v1 case), snapshot runs immediately, synchronously, in the calling process.
Snapshot mechanics
- Determine the candidate kin set:
opts[:kins] || 0..9(see "Behaviour"). - For each candidate kin id
k: a. Resolve the lineage process:pid = Process.whereis(SP.Runtime.Lineage.name(k)). Skipkifpidisnil— no lineage is running for that kin. b. Best-effort, diagnostic only in v1 — logged, not written to any manifest artifact (no field-bearing artifact exists in v1;manifest.sha256is a plain hash+filename listing, same shape ascolony_archive.sh's, with no room for extra fields): cross-referenceSP.Runtime.Supervisor.list_agents/0's[%{username, kin, mode}]for an entry whosekin == k(username pattern"UNI-#{k}-g<gen>", set inspawn_next/2,lib/sp/runtime/lineage.ex:123). Amanifest.jsonsidecar carrying alive_agentfield (if/when this cross-reference needs to land in a written artifact) is deferred Phase-2 work, not built in this pass. c. Compute the source pathpath = Path.join(@repo_root, "runs/colony/kin-#{k}.bin"). SkipkifFile.exists?(path)is false — a lineage can be running before its firstMC.save/2(e.g. immediately afterspawn_next/2, before any save-every tick or death has fired). - For each surviving
{k, path}pair:File.read!/1the current bytes ofpathand write them into the snapshot directory via a LOCAL tmp+rename (<dir>/kin-#{k}.bin.tmpviaFile.write!/2, thenFile.rename!/2to<dir>/kin-#{k}.bin). This guarantees the snapshot DIRECTORY never shows a partially-written file. It does not guarantee the bytes read from the still-live SOURCEkin-#{k}.binwere themselves non-torn, becauseMC.save/2(lib/sp/brain/mc.ex:552-554) is a plainFile.write!/2with no tmp+rename on the source side.- Documented v1 risk: if
snapshot/1readskin-#{k}.binin the same instantMC.save/2is mid-write, the copied bytes can be a torn:erlang.term_to_binaryblob that later fails to deserialize on restore. This is rare — the write is fast relative to the defaultsave_every(50 ticks) and to the snapshot cadence — and no worse than the risk every existing reader ofkin-*.binalready accepts (MC.load/2,lib/sp/brain/mc.ex:563-585, delegates tosafe_read/1atmc.ex:587-594, whoserescue/catchboth fall through to:error— seeload/2's own:error -> new(opts)branch atmc.ex:582-583— so a corrupt file already yields "start fresh" rather than a crash). True source-side atomicity needs a newSP.Runtime.Agent-side call (e.g.Agent.dump_atomic/1returning{:ok, tmp_path}, writing under the Agent's own control) — real, buildable, but new code this spec does not introduce.
- Documented v1 risk: if
- Compose the dated directory
runs/colony/snapshot/YYYYMMDD/HHMM/(repo-relative, under@repo_root,lib/sp/runtime/lineage.ex:25) — v1's output root is fixed to this path only (see "v1 Scope" item 4). - Write
manifest.sha256at the directory root: sha256 of every non-.tmpfile actually written, same shape ascolony_archive.sh's own manifest (find . -type f ! -name manifest.sha256 -print0 | xargs -0 sha256sum > manifest.sha256,production/scripts/colony_archive.sh:62-65). - Return
{:ok, dir}, or{:error, term()}— e.g.{:error, {:unsupported_trigger, trigger}}for a v1-unsupported trigger, or a filesystem-error tuple the caller converts from anyFile.*!raise.
Hot-file interaction
snapshot/1 is a reader of the hot file runs/colony/kin-*.bin (sole writer: SP.Brain.MC.save/2, lib/sp/brain/mc.ex:552-554, called from SP.Runtime.Agent's save-every tick and terminate/2 — see "Purpose" for the exact call sites — per SPEC_livepatch_hot_files.md's existing hot-files entry for this file). (Correction: the prior revision of this SPEC cited a SP.Runtime.Agent.save/1 function guarding this file; no such function exists anywhere in the codebase — every save call site goes directly through SP.Brain.MC.save/2, called inline from SP.Runtime.Agent. Corrected here.)
snapshot/1 does not introduce a new hot file. Its own output (runs/colony/snapshot/**) is written once per run into a fresh per-run directory with a local tmp+rename (step 3 above) — a private, single-writer-per-run path, not a shared multi-writer spool — so it needs no new entry in SPEC_livepatch_hot_files.md's hot-files list or OS_SPOOL_POLICY.md's spool ledger.
Being a reader (not a writer) of kin-*.bin, snapshot/1 does not participate in the livepatch hot-file WRITER guard (C-C4c): that guard protects the sole writer's atomicity promise, and readers of this particular file already tolerate torn reads today (see the v1 atomicity risk above). snapshot/1 inherits that same tolerance; it adds no new coordination requirement to the livepatch guard.
Test coverage the plan owes
test/sp/runtime/lineage_snapshot_test.exs:
- Enumeration: with lineages started for kin
10and12only (Lineage.ensure_started/2) and kin11never started — ids chosen outside the architectural0..9live/default range, matching the established out-of-band-kin convention this repo already uses to keep tests off real colony data (test/sp/runtime/lineage_test.exs:38useskin = 8; the paired-RED convention documented indocs/lab_team/04_red_experimentalist.md:12andruns/curiosity_lineage.exs:2uses kin10/11) —snapshot/1called with an explicitopts[:kins]: 10..12override (required: the default0..9would never scan these ids, so this also exercises the override path) includeskin-10.binandkin-12.binin the manifest and neverkin-11.bin. Must not start lineages inside0..9for this test:Lineage.spawn_next/2hardcodesmemory_pathtoruns/colony/kin-#{kin}.bin(lib/sp/runtime/lineage.ex:133, not opts-overridable) andAgent.terminate/2unconditionally saves onGenServer.stop(lib/sp/runtime/agent.ex:357-358), so an in-range kin id risks overwriting real learned colony data on test cleanup. - Enumeration skips pre-save kin: a lineage started but with zero saves yet (no
kin-N.binon disk) is skipped without error. - Local atomicity: killing the snapshot process mid-copy leaves no partially-named
kin-*.binfile under the dated dir — at most an orphaned.tmpfile, whichmanifest.sha256never references. v1 defines no reaper/retention mechanism forruns/colony/snapshot/**: because each:manualrun gets its ownYYYYMMDD/HHMM/directory and there is no automatic recurring cadence in v1, an orphaned.tmpis not reliably cleaned up by a later run in the general case — it is harmless (nothing reads it) but persists until an operator or a future Phase-2 mechanism removes it. Sincesnapshot/1runs synchronously in the calling process in v1 (no process of its own to kill — see "Behaviour"), the test wraps the call in its ownTaskand sends the kill at a chosen point inside the per-kin tmp+rename loop (step 3) to trigger this deterministically rather than relying on timing. - Manifest integrity: every non-
.tmpfile in the dir has a matching sha256 line inmanifest.sha256. - Path default: a
:manualsnapshot writes underruns/colony/snapshot/YYYYMMDD/HHMM/and never under/var/lib/uni/backups/colony/**. - Unsupported trigger:
snapshot(trigger: :heartbeat_idle)andsnapshot(trigger: :heartbeat_live)both return{:error, {:unsupported_trigger, trigger}}without touching the filesystem.
Falsifier: this SPEC makes no FE/behavioural claim (see "v1 Scope" — snapshot/1 is pure I/O composition over already-persisted bytes), so the paired PASS/FALSIFIES RED-gate apparatus (docs/LAB_PROTOCOL.md §II) is scoped to behavioural claims and does not transplant here; the persona review (§VII) is the applicable gate instead (see the review receipt). This SPEC's own, narrower falsifier: any named test above failing, or a :manual snapshot landing anywhere outside runs/colony/snapshot/**.
Phase 2 (not owed by this SPEC — each gated on a named prerequisite above):
- Idle no-op: two
:heartbeat_idletriggers withinidle_min_gap_sproduce ONE snapshot dir. Gated on a BEAM-side heartbeat caller (v1 Scope item 3). - Live cadence: N
:heartbeat_livetriggers withlive_gap_ticks = 4produce N/4 snapshot dirs. Same gate. - Source-side atomicity: once an
Agent-side atomic-dump call exists, a mid-snapshot kill also leaves no tornkin-*.binbytes at the SOURCE (not just the destination). Gated onAgent.dump_atomic/1(v1 Scope item 1). - Bind-mount-shared output: once
OS_SPOOL_POLICY.md's ledger carries a registeredsnapshot/1writer entry, snapshots land under/var/lib/uni/backups/colony/YYYYMMDD/HHMM/instead of (or alongside)runs/colony/snapshot/. Gated on the ledger amendment (v1 Scope item 4). opts[:kins]bound enforcement: reject (or clamp) candidate ranges wider than the architectural0..9kin-group range onceopts[:kins]is reachable from any less-trusted/automated caller, to prevent BEAM atom-table exhaustion viaLineage.name/1(lib/sp/runtime/lineage.ex:72). Gated onopts[:kins]leaving the:manual-only, operator-typed v1 surface (see "Behaviour").live_agentmanifest field: land the diagnosticSupervisor.list_agents/0cross-reference (Snapshot mechanics step 2b) in a writtenmanifest.jsonsidecar, once a real consumer needs it. Gated on that consumer existing.
Cross-references
production/scripts/colony_archive.sh— the daily archive job (D-D1). It does not consumesnapshot/1's output: it independently archives/var/lib/uni/colony-memory/(or its fallback,/var/lib/uni/broadcast-src/runs/colony/) into the shared/var/lib/uni/backups/colony/tree.snapshot/1'sruns/colony/snapshot/**output is a separate, unshared tree in v1 — see "v1 Scope" item 4. (Correction: the prior revision of this SPEC describedcolony_archive.shas consuming these snapshots; it does not, today.)production/systemd/uni-colony-archive.timer— the systemd side ofcolony_archive.sh.production/docs/OS_SPOOL_POLICY.md— the sole-writer ledger; a bind-mount-shared output path forsnapshot/1is a deferred prerequisite gated on a new ledger entry there (v1 Scope item 4).production/docs/SPEC_livepatch_hot_files.md(C-C4c) — the hot-files guardruns/colony/kin-*.binis registered under; see "Hot-file interaction" above.production/scripts/heartbeat.sh— the only heartbeat mechanism in the repo today (a 60s systemd-timer shell script,status: pending, not yet run on node hardware per its own header); not wired tosnapshot/1in v1 (see "v1 Scope" item 3).
sha256 10a1cf3c8ccf7519 — of the original file, so what was ingested stays checkable.