ADR 054 — The cloud sees recons, the plan hierarchy and the commits a merge delivered¶
Status¶
Accepted (2026-09-25), with the amendments below.
Context¶
The cloud's view is built from two streams: the audit log (history, synced
by cloud_sync) and liveness snapshots (what is running now, pushed by
cloud_liveness). Three things the operator cares most about are missing
from both.
- Recons. A recon writes
.snodo/recons/<id>/state.jsonandresults.jsonand appends no audit event. The snapshot reads only.snodo/plans,.snodo/tasksand.snodo/jobs. Projects hold dozens of recon runs; the cloud's Recons counter has no source. - Delivered work.
task_mergedis emitted and synced withtask_ref,branch,merge_sha,specandsession_id, but carries nothing about what the merge delivered. Projects merge tens of commits a day through snodo; the cloud can count merges but cannot show commits, files or lines. - The plan hierarchy. No task event carries its plan:
dispatch,task_complete,task_mergedandhaltcarrytask_refandsession_idonly. Thewave_idontask_classifiedis the wave classifier's own id (w_002a), not the plan's wave.plan_runandplan_proposedare opaque in the ingest union, and only the MCP path emitsplan_run;snodo plan runemits neither. Without a live snapshot the cloud cannot rebuild plan → wave → task, and inferring it from branch names ortask_refprefixes is a convention, not a contract. - Processes that never arm liveness.
cloud_liveness.install()is called only fromsnodo run. Recon and MCP-started runs happen inside the MCP server process, which pushes only through the planner's status-write hook. The queue runner (ADR 053) would have the same gap.
Decision¶
- Recon appends history.
recon_started(recon_id, query, paths, agent count, agent models, session_id, created_at) andrecon_completed(recon_id, the recon's existing final status, succeeded and failed agent counts, duration, completed_at, and a summary of the answer capped at about 2,000 characters with a truncation marker).recon_startedcarries the question in full. Both are declared audit event types with fixed shapes, like every event in the ingest union. - Liveness carries recons. The snapshot gains a
reconslist of running recons (id, query excerpt, agent count, started_at). A recon whose process is gone is reported stale and dropped, the way stale jobs already are, so a recon leftrunningon disk does not show as live forever. task_mergedcarries what was delivered. Added fields:base_sha(the base branch head before the merge),commit_count,commits(sha and subject, capped at 50 with a truncation marker;commit_countis always the full count),files_changed,insertions,deletions. All are read from git betweenbase_shaandmerge_shaat merge time; a failure to measure omits the fields and never fails the merge. Commits made outside snodo stay out of scope: snodo reports what it merged.- History carries the plan hierarchy.
task_classified,dispatch,task_complete,task_mergedandhaltgainplan_nameandplan_wave(the plan's wave id, distinct from the classifier'swave_id) when the task belongs to a plan.plan_proposedandplan_runget pinned shapes,{plan_name, waves: [{wave_id, task_refs}]}, and both the CLI and the MCP path emit them.plan_runalso carriestrigger(mcp,cliorqueue) and, when the trigger isqueue, thequeuethat started it, plusjob_id(the background job running the plan, or null for a foreground run) andmode(the protocol mode at run start). Queue state itself is not shipped: the record is who started a run, not which queue a plan belongs to. A liveness plan carriesqueuewhile a queue runner is running it. - Every long-lived snodo process arms liveness:
snodo run, the MCP server andsnodo queue run. Every push still passes the sync gate, so withcloud.sync_enabledoff nothing goes on the network. - One interface bump. New event types, new
task_mergedfields and the new snapshot sections ship together as cloud interface version 6. Older events keep validating as they are; the new fields are optional in the shape. The lease-mint response advertisesinterface_version; a missing or invalid value means version 5. The client sends v6 only when the current lease advertises 6 or later. Under v5, it sends unchanged v5-compatible events and holds at the first v6-only event or v6-added data field, including the remaining chain suffix. It does not strip fields, since that would makeevent_hashattest to different data. A held event is retried with a newly minted lease on a later sync. Sync is not refused, no event is lost, and the hash chain the cloud receives has no gap. The new events are always written to the local audit log; only what goes on the wire waits. snodo-cloud must include the currently acceptedinterface_versionin its successful lease-mint response and change it to 6 only after ingest and liveness accept v6. - No new state. Recon statuses are the ones recon already writes; queue "stopped" is derived as in ADR 053. No plan or task status, severity or halt type is added.
Amendment — plan intent and unmerged outcomes¶
The history events plan_proposed and plan_run also carry the authored
intent from plan.yml. It is capped at 2,000 characters using the same
… [truncated] marker convention as the recon answer summary. Every run reads
the plan's current file, so an edit between runs is reflected by the next
plan_run event.
task_unmerged has a pinned v6 data shape: task_ref, branch, reason and
session_id, with plan_name and plan_wave when the task ran in a plan. A
plan wave is the existing field name plan_wave, not the classifier's
wave_id. These fields let an outcome be attributed even when task references
repeat between plans. v5 continues to hold v6-only events/data and their chain
suffix; no v6 field is stripped into a v5 payload because that would invalidate
the event hash.
Amendment — plan-owned authorization escalations¶
disagreement_escalated has a pinned v6 shape: task_ref, phase and
policy, plus plan_name and plan_wave when the task belongs to a plan.
The engine uses its task's existing plan ownership; MCP validation accepts the
owning plan_name and resolves the task's existing plan wave. The event omits
validator output and detailed justifications: the notification needs to name
the task waiting for snodo authorize, not repeat the verdict. v5 retains its
previous opaque event shape; plan-owned escalation events remain held with the
hash-chain suffix until a v6 lease is advertised. No task, plan, job or recon
status, severity or halt type is introduced.
Consequences¶
Amendment — provider-reported usage (interface v7)¶
task_complete, halt, validate and recon_completed may carry aggregated
usage records. cost_usd is provider-reported only: local estimates are null.
Because usage is hash-chained data, v5/v6 leases hold at the first event
carrying it and its suffix until v7 is advertised; fields are never stripped.
No task, plan, job or recon status, severity or halt type is added.
The cloud can wire its Recons counter to recon_started and its Commits
counter to task_merged.commit_count, show delivered lines and files per
project, plan and day, and rebuild plan → wave → task for any window from
history alone. History before this change has merges without
stats; they count as merges with unknown size. snodo-cloud must accept
interface version 6 before a client sends it; until then a client sending
version 6 events would be refused, so the cloud side ships first or
together.
Alternatives¶
Having the cloud read commits from the git host was rejected: it needs repository access the cloud does not have and would count commits snodo did not make. Deriving recon history from liveness alone was rejected: liveness is a view of now, and a recon that finishes between pushes would never be counted. Shipping recon state files through sync was rejected in favour of declared events, which keep the hash chain and the closed ingest union.