Cloud sync — the emit contract¶
What snodo sends when cloud sync is enabled, in what shape, and what it deliberately never sends. This is the authoritative description of the wire.
The contract is 0.x and tracks main. It changes when snodo changes, without
notice and without a deprecation window. Treat it as a disclosure of what
leaves the machine, not as a stable integration surface.
Compiled against the code, not against intent. When this file and
infrastructure/cloud_sync.py disagree, the code is right and this file is a
bug.
Sync is opt-in: nothing is transmitted unless cloud.sync_enabled is true and
cloud.api_key is set. A run with sync disabled makes no network call.
The wire¶
POST {lease_url}/m # mint a lease on the app host; no body
Authorization: Bearer <full cloud.api_key>
POST {api_url}/i/{jti} # send audit batch on the API host
Authorization: Bearer <lease token>
With the defaults these are https://app.snodo.dev/m and
https://api.snodo.dev/i/{jti}. Mint returns jti, token, expires_at
(ISO 8601), and cadence_s; the client renews before expires_at. jti is
the lease identifier and is used in the ingest URL. A 401 from ingest causes
one re-mint and one retry. cloud.lease_url (or cloud.lease_api_url)
overrides the mint app host/base path; cloud.api_url sets the ingest host.
When only cloud.api_url is set, the mint host is derived by replacing its
api hostname label with app (or retaining a single-host custom/local host).
The successful lease-mint response also advertises the highest accepted
interface_version. Missing or invalid values mean v5. Snodo sends v6 event
types, v6-added event data, and v6 liveness sections only under a lease that
advertises version 6 or later. Until then, unchanged v5-compatible events are
sent; the cursor stops before the first v6-only event/data and its chain suffix
is retried after a later mint. Data is never stripped from a hash-chained event.
snodo-cloud must advertise v6 only after both ingest and liveness accept it.
Interface v7 — usage on audit events¶
Under a lease advertising v7 or later, task_complete, halt, validate and
recon_completed may carry data.usage. This is v7-only hash-chained data:
v5/v6 leases stop the cursor before the first such event and retry that event
and its suffix unchanged when a v7 lease is available. Usage is projected from
locally persisted per-call records; estimated costs are never sent.
Each item requires role (coder, validator or recon), model (the
configured model asked for), and outcome (succeeded, failed or
timed_out). Optional fields: task_ref, attempt, validator_id, phase
(pre or post), agent, coder, served_model, provider, calls,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
cost_usd, duration_ms, and error_class. Irrelevant identity fields are
null/omitted (recon has null task_ref; validator and recon have null
attempt). Token and cost aggregates are null if any contributing call is
unknown. cost_usd is provider-reported only; estimates are null, never zero.
Local outcomes success and error normalize to succeeded and failed.
Batched 1-50 events. Dispatched from a bounded background thread during
snodo run teardown, after recon completion, and from snodo cloud sync.
Recon completion starts this best-effort sync even when there is no session;
the shared project cursor tracks that same project audit chain. Session and
project cursors share delivered progress to prevent duplicate sends. The cursor
advances only on a 2xx, so a failed batch re-sends rather than being lost.
Interface v6 is sent only after the cloud accepts v6; until then the client
continues sending v5. New events are recorded locally while waiting. Liveness
is a second, separate wire, described below.
{
"session_id": "sess_20260902_prod_363b8e",
"project_path": "/absolute/path/to/project",
"display_name": "nfc-card-v2",
"events": [
{
"sequence": 15,
"timestamp": "2026-09-02T21:50:01.701669+00:00",
"event_type": "task_merged",
"project_id": "local:6bd1d012554546c4b9462bfaaa4183d8",
"scope": "local",
"data": {},
"previous_hash": "9f2c...",
"event_hash": "sha256 over sequence|timestamp|event_type|project_id|data|previous_hash"
}
]
}
Response handling: 2xx advances the cursor; 401 re-mints once; 429 respects
Retry-After; 5xx and network errors retry with exponential backoff up to five
times. A 404 is a route mismatch and remains retryable (it never marks a
session refused). Other 4xx, including batch-level 413/422, mark the session
refused and stop until --force; the URL, status, and server message are shown.
Admission errors also show the mint URL and response details. “Cloud admission
unreachable” is reserved for cases where no server response was received.
project_id is an input to event_hash and always has been. Transmitting it
therefore changes no hash and invalidates no chain — it was simply being
dropped by the transmit path until #202.
display_name is the project's directory basename. There is exactly one name
field on the envelope; an earlier draft of #202 also sent project_name with
the same value and it was removed. Consumers read display_name.
scope is transmitted alongside project_id on every event. It is derived
from the id itself (scope_for_project_id), so the two cannot disagree: a
local: id carries "local", anything else carries "remote". Its purpose is
to tell the consumer when not to reconcile — a local id is a leaf and must
never be merged with anything, however many clones report the same
display_name.
Project identity¶
Identity has three states and one of them is deliberately a dead end.
| scope | id | meaning |
|---|---|---|
remote |
normalized git remote URL | The same project on every machine. Engineers working from one remote converge on one id. |
local |
local:<uuid> |
A single checkout. Intentionally unreconcilable — nothing can establish that two local checkouts are the same project, across machines or across paths on one machine. |
Two local: projects sharing a display name are two projects. That is the
correct reading, not a duplicate to be merged.
Promotion is one-way: a project acquires a remote and its id becomes the remote
id. There is no demotion. Once established, project identity is stable: a cached
remote or override id is returned as-is. If a git remote is repointed or
changed, snodo warns on stderr that the identity is unchanged and that running
snodo init adopts the new remote if desired. It does not automatically change
the project id or split the audit history.
get_project_id re-resolves only cached local: identities; cached remote
and override identities are stable and returned without repeated subprocess
calls.
The decision governing all of this is documented in ADR 012 (docs/decisions/012-project-identity-from-git-remote.md).
Liveness — what is running right now¶
HISTORY (the ingest path above) is append-only and outlives the machine. LIVENESS is mutable and worthless once stale: "task 2.1 is in_progress" is true for forty minutes and then false. So it travels a separate wire with opposite mechanics (Fixes #291) — on the app host rather than the ingest host: giving liveness the cursor's delivery guarantee would replay a stale status after a failed push, which is worse than the gap it covered.
POST {liveness_url}/i/{jti}
Authorization: Bearer <lease token>
Content-Type: application/json
Body: full liveness snapshot including session_id
With defaults the client sends POST https://app.snodo.dev/i/{jti} with the
same lease minted at POST https://app.snodo.dev/m; audit ingest sends to
https://api.snodo.dev/i/{jti}. cloud.api_url configures ingest and derives
the app host; cloud.liveness_url overrides the liveness base independently.
The client renews near expiry and re-mints once on a 401. Its push cadence
follows the lease's cadence_s when available.
The machine pushes; nothing reaches inward. The tunnel remains a convenience, not a requirement.
Change-driven, with a floor while running. A push starts when something
actually changes: a plan/task status write (planner update_status), or an
engine transition observed through the audit log of a running process
(dispatch, transition, halt, task_complete, task_merged,
token_consumed, post_validation_route, verification_executed,
unverified_merge_blocked, execution_failed, session_started,
session_task_changed). The task's final word is on the wire regardless of
when its state.json was last rewritten: every snapshot carries what the
last event was, and a terminal event (halt, task_complete, …) forces the
push past the throttle. But a run that is quiet is still a run: while a
session has work running, at least one push per interval is sent even when
nothing changed, so silence on the far side means the machine stopped rather
than that the session had nothing new to say (Fixes #323). The floor is not a
heartbeat — a repeat carries the same true snapshot and no state is
fabricated — and a session with nothing running still sends nothing: if no
plan has begun and there is no task or job record, the snapshot is not built.
Once every task and job has settled, the terminal push is the run's last
word and the floor stops.
Two clocks, deliberately distinct (Fixes #324). A plan/task status write
changes what is running and fires a push, but it appends no audit event — so
"the last audit event" is not "the last thing that happened", and a consumer
that conflates them renders a working machine as idle. The snapshot carries
both. last_event is the last recorded decision — event type and timestamp
from the audit tail, the governance log — and it does not move when a status
write happens without one. last_activity_at is when something last happened
in this session, across everything the snapshot reports: the newest of the
plan status.json and task/job state.json modification times and the last
audit event's own timestamp. Render "last activity" from last_activity_at;
read last_event for what was last decided. No audit event is appended to
move either clock, and neither is a heartbeat: with nothing changing, both
stand still.
At most one push per interval per session, and at least one while
running, so a burst of transitions coalesces into one write and a quiet
session is still heard from. The interval is 60 seconds by default and
configurable with cloud.liveness_interval_seconds; lowering it trades
bandwidth for freshness. The snapshot is built at send time inside the
worker, so the coalesced write carries the later state. A transition that
ends a run's claim to be live — a terminal audit event or a plan-task status
of completed/blocked/errored/unmerged — bypasses the throttle:
dropping the sole record of "this stopped" would strand a false "running"
until some later event displaced it.
A full snapshot, never a delta, and it carries the plan's shape. A lost
push is harmless; the next supersedes it entirely. A failed push is dropped —
no queue or cursor. cloud_sync.json records the last push attempt, last
liveness error and failure count independently of the audit cursor; the first
failure per run warns with URL, status and server message. Shape, not flat
lists (Fixes #303): plans arrive as plan → waves →
tasks → the jobs beneath them, the structure snodo plan status renders. A
branch that has completed — a wave whose every task has settled, a plan whose
every wave has — is carried as a count plus the summary needed to render
"wave 1: 3/3 done" (total and a per-status status_counts), not as a full
list of its members. A collapsed wave also carries wave_ids, the distinct
durable registry identifiers already carried by its tasks, when any exist. The
running frontier keeps its detail, because that is the part a viewer is
watching: unsettled waves enumerate their started tasks, and each enumerated
task carries its own wave_id when classified. Each task node also carries the
live jobs beneath it. Terminal jobs inside an
incomplete plan stay in the picture as the plan's job_status_counts. Task or
job records that no plan claims ride the top level: live ones enumerated in
full, settled ones tallied in task_status_counts / job_status_counts.
Nothing still running is ever reduced to a count or hidden by the collapsing;
a live record whose plan node settled is enumerated. What is sent grows with
the plan's shape and what is in flight, never with elapsed time — a session
kept open for weeks re-ships its settled history as digits, not as lists.
The key is the session. A session is already project-scoped, persisted, and survives a restart (a resumed run rejoins it), so it accumulates no rows the way an instance id would. The project rides along so the far side can join on it; machine identity adds nothing a session does not already imply. No user identifier is sent — the credential identifies the person, and attribution is taken from it on the far side so a sender cannot claim to be someone else.
{
"session_id": "sess_20260902_prod_363b8e",
"project_id": "local:6bd1d012554546c4b9462bfaaa4183d8",
"scope": "local",
"display_name": "nfc-card-v2",
"run_started_at": "2026-09-02T21:10:04+00:00",
"plans": [
{
"name": "wave8",
"total": 6,
"status_counts": {"completed": 4, "in_progress": 1, "pending": 1},
"job_status_counts": {"completed": 8},
"waves": [
{"id": 1, "total": 3, "status_counts": {"completed": 3},
"wave_ids": ["w_0009"]},
{
"id": 2, "total": 3,
"status_counts": {"completed": 1, "in_progress": 1, "pending": 1},
"tasks": [
{"id": "2.1", "status": "completed", "wave_id": "w_0009"},
{
"id": "2.2", "status": "in_progress",
"started_at": "2026-09-02T21:10:05+00:00",
"jobs": [
{"id": "j_20260902_211005_a1b2", "status": "running",
"started_at": "2026-09-02T21:10:06+00:00"}
]
}
]
}
]
}
],
"tasks": [
{"id": "implement-oob", "status": "running", "started_at": "2026-09-02T21:10:05+00:00"}
],
"jobs": [
{"id": "j_20260902_211100_c3d4", "status": "running", "started_at": "2026-09-02T21:11:00+00:00", "task_ref": "implement-oob"}
],
"task_status_counts": {"completed": 12, "failed": 1},
"job_status_counts": {"completed": 40, "failed": 2},
"last_event": {"event_type": "dispatch", "timestamp": "2026-09-02T21:50:01.7+00:00"},
"last_activity_at": "2026-09-02T21:50:02.5+00:00",
"snapshot_at": "2026-09-02T21:50:02.9+00:00"
}
The plan ordinal (waves[].id, such as 1 or 2) and the registry identifier
(wave_id / wave_ids, such as w_0009) are different groupings. The ordinal
means execution order and membership in this plan; the registry identifier is
assigned independently per task by classification and may be shared by tasks
in different plan waves. One plan wave can therefore carry several registry
identifiers, or none. Consumers must join history to liveness through the
task-level wave_id, or a settled wave's wave_ids, not by treating the plan
ordinal as a durable wave identifier.
Statuses are the ones the machine already records — planner statuses, task
and job state.json statuses — passed through verbatim; this path adds no
status, halt type or state value. Counts (total, status_counts,
job_status_counts, task_status_counts) tally those existing statuses;
they introduce no vocabulary. Only when and how they travel is changing.
A job is linked to its plan task by the task identity the engine already
records for the job; the link's own payload fields (the spec, the
description) stay on the disk. started_at values (epoch on disk) are
normalised to ISO. run_started_at is the session file's created_at.
display_name is the project directory basename. last_activity_at is ISO
too — the newest of the record-file modification times and the last_event
timestamp — so a status write that appends no audit event still moves it.
What the liveness wire never carries is what the ingest path never carries:
payloads, prompts, diffs, file contents, absolute paths, usage records, halt
payloads — and, unlike the ingest envelope, no project_path. Opt-in is the
same single gate: cloud.sync_enabled off or no cloud.api_key means a run
makes no network call on this path either. Log streaming is out of scope.
Every long-lived process reports liveness: snodo run, the MCP server and
snodo queue run. Recon entries are included in the full snapshot while their
process is running (id, question excerpt, agent count and start time); a recon
whose process is gone is treated as stale and omitted. These entries describe
currently running work, not recon history.
What is in data¶
Everything the event carries, verbatim. That is the whole audit payload, so it includes task specs, validator justifications quoting file contents, shell commands, absolute working directories and captured test output. "Opaque" is not an adequate description of a field that leaves the machine; the table below is.
Every audit event type emitted by snodo is declared and transmitted; the
event tag is part of the hash chain and no event is skipped client-side. The
ingest service must accept interface v6 before the client sends the v6
contract; until acceptance, the client sends v5. Data for
some tags is intentionally an opaque JSON object (additionalProperties is
allowed): the event type is pinned on the wire even when its data shape is not.
This expands what the cloud receives, but creates no engine state, severity,
halt type or status value. The existing never-transmitted rules below still
apply.
The v6 additions include recon_started (the full recon question and run
metadata) and recon_completed (the existing final status, agent outcome
counts, duration and answer summary capped at about 2,000 characters with a
truncation marker). Thus, when cloud.sync_enabled is on, recon questions and
answer summaries now leave the machine in audit events. task_merged also
includes the pre-merge base SHA, full commit count, up to 50 commit SHAs and
subjects (with a truncation marker when capped), changed-file count, insertions
and deletions. Measurement is best-effort; diffs, file contents and commits
outside snodo's merge range are not sent. The plan-owned task events
task_classified, dispatch, task_complete, task_merged and halt carry
plan_name and the plan's plan_wave when applicable. task_unmerged carries
the pinned task_ref, branch, reason and session_id, plus plan_name and
plan_wave when the task ran in a plan. plan_proposed and plan_run carry the
authored intent (capped at 2,000 characters with the same … [truncated]
marker) and the pinned hierarchy {plan_name, waves: [{wave_id, task_refs}]};
each run reports the intent present in plan.yml for that run, even if edited
since proposal. Plan runs also identify their trigger and, for queue-triggered
runs, the queue. Each plan_run also carries job_id (the background job
running it, or null for a foreground run) and the protocol mode at run start.
disagreement_escalated is pinned on v6 with task_ref, phase and policy,
plus plan_name and plan_wave when the task ran in a plan. It carries no
validator output: the cloud can identify which planned task is waiting for
snodo authorize without receiving verdict justifications or full results.
The event and plan fields remain behind the v6 lease gate; a v5 sender holds
the event and its chain suffix unchanged.
These two v6 event definitions are intentionally explicit: task_unmerged has
the six declared keys above, and disagreement_escalated has the five declared
keys above. Their data models allow extra fields for forward-compatible
ingestion, but those extra fields are not part of the pinned Snodo contract.
| event_type | data keys |
|---|---|
project_announced |
project_id, scope, display_name |
readiness_checked |
project_id, scope, display_name, protocol_id, score, total_checks, passed_checks, repository_findings_count, workstation_findings_count, findings |
recon_started |
recon_id, query, paths, agent_count, agent_models, session_id, created_at |
recon_completed |
recon_id, existing final status, succeeded_agents, failed_agents, duration, completed_at, answer summary (about 2,000 characters maximum), usage |
dispatch |
task_ref, mode, token_id, artifacts_count, plan_name, plan_wave (when applicable) |
work_already_present |
task_ref, base_ref, artifacts_count, files |
governance_check |
task_ref, mode, constraints_checked |
validate |
phase, task_ref, validators_invoked, results, outcome, policy_decision, usage |
task_classified |
task_ref, flow_type, wave_id, task_summary, plan_name, plan_wave (when applicable) |
wave_created |
wave_id, feature_description |
task_complete |
task_ref, artifacts, session_id, commit, change_size, usage |
task_merged |
task_ref, branch, merge_sha, spec, session_id, base_sha, commit_count, commits (up to 50), files_changed, insertions, deletions, plan_name, plan_wave (when applicable) |
task_unmerged |
task_ref, branch, reason, session_id, plan_name, plan_wave (when applicable) |
halt |
task_ref, reason, blocker_validators, halt_type, raw_halt_type, usage |
disagreement_escalated |
task_ref, phase, policy, plan_name, plan_wave (when applicable) |
transition |
from_mode, to_mode, task_ref |
token_consumed |
task_ref, session_id |
post_validation_route |
decision, task_ref |
post_validate_bypassed |
mode, reason, task_ref |
session_started |
session_id, mode, project_root |
session_task_changed |
old_task, new_task |
session_decision_updated |
key, value |
recovery_resolved |
depth, attempts_used |
recovery_internal_error |
depth, error |
execution_failed |
error, task_ref |
verification_executed |
command, commit, returncode, outcome, validator_id, working_directory, output_tail |
coder_test_run |
command_type, exit_code, test_path, turn_index, job_id |
test_modified |
mutations, task_id, job_id |
unverified_merge_blocked |
task_ref, branch, target_commit, reason, session_id |
adjudication_carry_forward |
opaque object |
coder_respawned |
opaque object |
coder_timed_out |
opaque object |
coder_turn_budget_exhausted |
opaque object |
coder_unavailable |
opaque object |
decision_record_task_mismatch |
opaque object |
disagreement_resolved |
opaque object |
dispatch_refused_coder_unavailable |
opaque object |
dispatch_request |
opaque object |
environment_prep_failed |
opaque object |
head_not_moved |
opaque object |
human_review_recorded |
opaque object |
job_state_corrupt |
opaque object |
merge_conflict_escalated |
opaque object |
merge_failed_escalated |
opaque object |
mode_change |
opaque object |
no_file_operations |
opaque object |
plan_proposed |
plan_name, intent (up to 2,000 characters), waves (wave_id, task_refs) |
plan_run |
plan_name, intent (up to 2,000 characters), waves (wave_id, task_refs), trigger, queue (for queue trigger), job_id (nullable), mode |
protected_path_blocked |
opaque object |
protected_paths_unchecked |
opaque object |
recovery_exhausted |
opaque object |
recovery_stalled |
opaque object |
session_audited_but_missing |
opaque object |
session_corrupt |
opaque object |
session_deleted |
opaque object |
session_memory_updated |
opaque object |
session_pointer_audited_but_missing |
opaque object |
session_resumed |
opaque object |
severity_cap_applied |
opaque object |
snodo_mutation_blocked |
opaque object |
spec_authored |
opaque object |
spec_authored_failed |
opaque object |
spec_premise_stale |
opaque object |
subtask_spawned |
opaque object |
task_add_rejected |
opaque object |
task_added |
opaque object |
task_replaced |
opaque object |
task_status_corrected |
opaque object |
token_store_unavailable |
opaque object |
tool_call |
opaque object |
validator_contradiction_detected |
opaque object |
validator_results |
opaque object |
wf3_runtime_violation |
opaque object |
worktree_isolation_failed |
opaque object |
The schema gate scans literal calls to append_event() and _audit() under
packages/ and snodo/; a newly emitted literal event type must be added to
the contract before the gate passes. All data values remain JSON objects, and
the ingest service must be updated to accept these additional event tags.
session_id is injected into every engine event by _audit(), so it is
present on events whose call site does not name it.
task_complete.commit is the commit the completed task produced, or null.
An explicit null is the honest answer for a task that completed without
committing — a documentation task, an investigation, a no-op outcome — and is
deliberately distinguishable from an older event recorded before the field
existed, which carries no key at all.
task_complete.change_size records how much the task changed and the
repository-relative paths it covered, without recording what was in them:
line totals and per-shape file counts of the task branch against
its own branch point — the merge-base of the resolved base branch and the
branch, never the base branch's current tip, so a long-running task is
never credited with work others merged while it ran. The diff itself is
never recorded; only counts leave the machine. A null change_size is
the honest absence of a measurement (no repository, no task branch, an
unresolvable base), never a fabricated zero. Inside the record:
lines_added/lines_deleted count only lines that exist to be counted; a
binary file carries no line count and appears in files_binary, so a
zero-line binary change is distinguishable from a zero-line text change;
renames appear in files_renamed (line totals still reflect any content
edit they carry); a mode-only change moves no line and appears in
files_mode_only; a deletion's removed text lines land in
lines_deleted. Past CHANGE_SIZE_MAX_FILES changed files the line
totals are not computed at all — lines_added/lines_deleted are null
and capped is true — because a plan run must never stall on a statistic
nobody is waiting for; files_changed stays real. paths contains every
changed path when the comparison is measured. When capped is true, it
contains only the bounded prefix and is therefore incomplete; files_changed
remains the real total. The interface version moved to 4 for this field.
readiness_checked.findings carries repository method scaffolding findings
with relative paths only and never workstation detail (such as local binaries on
PATH or environment variables). Workstation prerequisites are recorded in
workstation_findings_count only, ensuring that audit logs from different
machines remain consistent for the same repository state.
work_already_present is emitted when a retry finds the task's work already
committed on its task branch — the coder wrote nothing this attempt, but the
branch is ahead of its base — and carries it into post-execute validation
instead of halting as no_file_operations. base_ref is the branch base the
post-execute judges diff against (base_ref..HEAD). files lists the produced
file paths and carries repository-relative paths only, never absolute paths or
contents.
verification_executed.outcome is "pass", "fail", "error", or
"no_tests". "no_tests" is the honest record of an ungated run: the shipped
no-op default test command ran and no tests were executed (no
tooling.test_command configured and no marker file detected). A consumer
counting ungated projects or distinguishing a real pass from a placeholder
must key on that outcome — an ungated run never carries "pass" (ADR 031).
When a quality run fails, it may rerun only the reported pytest failure node ids in a detached worktree at the task's base commit. If those same tests fail there, the failure is reported as pre-existing and remains a blocking, fail-closed validator error; it is not sent through task recovery and is not silently converted to a pass. If the baseline rerun passes, the original failure remains blocking and is treated as a task failure for routing. This is also the flaky-test rule: a passing rerun never clears a failure unless the failure is reproduced at the base commit.
project_announced is emitted on session creation (SessionManager.create_session)
and on session resume (_resolve_session in run_cmd when adopting an existing
session or explicitly resuming; Fixes #214, #219), where the project identity is
already resolved. It carries the resolved project_id, its scope, and the
display_name (the project's directory basename) so a consumer reading only the
event stream can create the project row without inspecting the transport
envelope. It is deliberately re-emitted on every run — a consumer that joined late,
lost a batch, or rebuilt from an advanced cursor still sees a project that is being
worked on. The payload is identity, scope and display name only: no paths, no machine
details. display_name remains on the sync envelope for now; removing it is a
separate wire-trim.
flow_type and wave_id are emitted on task_classified. When they are
absent it is because the classifier failed, not because they are unplumbed —
the run prints "Classifier failed after N attempts, leaving task unwaved".
wave_created is emitted once when a wave is created, carrying wave_id and
feature_description.
halt distinguishes why a task stopped and who stopped it. halt_type is
the canonical five-outcome name (escalate, blocker, validator_error,
internal_error, environment_error; ADR 015); raw_halt_type is the specific
value the loop actually set
(e.g. turn_budget_exhausted, recovery_stalled), preserved next to the
canonical one so the coarse outcome never erases the precise cause.
blocker_validators names the judges that returned a blocking verdict. A judge
that could not reach a verdict is an operational error (error=True) and is
counted there like any other blocker; there is no separate no-verdict list.
The halt event carries a single outcome field
(halt_type); the legacy duplicate final_decision, which always equalled
halt_type, was retired from the event once consumers read halt_type
(the persisted job/session halt payload still carries it and is a different
shape, not this wire event).
session_decision_updated¶
Roughly three quarters of everything transmitted, and mostly restatement. The event ships the whole sub-dictionary for whichever key changed, re-sent on every write. Measured over one small session:
| key | events | value bytes | disposition |
|---|---|---|---|
halt |
20 | 460,509 | Remove. A snapshot of every halt in the session; per-task halts arrive as halt. |
classification |
20 | 17,149 | Remove. Duplicate of task_classified. |
task_failure |
12 | 14,150 | Trim to task_ref, attempt, branch, reason. |
pending_decisions |
6 | 1,757 | Keep. Escalations awaiting snodo authorize, emitted nowhere else. |
Consumers should not build on the halt or classification keys.
Planned changes¶
Coming off the wire:
session_decision_updatedkeyshaltandclassification.verification_executed.output_tail— 400 characters of unfiltered test output, the highest-risk field on the wire.- Absolute paths carrying the operator's home directory: envelope
project_path,session_started.project_root,verification_executed.working_directory. Now redundant besideproject_idanddisplay_name. - Raw exception text in the
errorfields.
Coming onto the wire:
- Protocol identity:
protocol_idand a content hash, never the file itself. A receiver can currently see which validators ran but not which the protocol declared. - Cost and duration per task, aggregated.
.snodo/tasks/<id>/state.jsonholds usage and, since #69, an attribution record for in-place coder runs.
Never transmitted¶
Not omissions — stated non-goals. A change that would send any of these needs to argue against this list.
config.yml— live provider and cloud credentials in plaintext.checkpoints.db— serialized graph state including full prompts.tokens.db— the single-use token ledger; consumption is attested bytoken_consumedevents instead.- Job
stdout.logandstderr.log— unbounded, unredacted toolchain output. - File contents and diffs. snodo transmits what happened, never the code it happened to.
- The RS256 signing key, which lives outside the project tree entirely.
Where the code is¶
| Concern | Location |
|---|---|
| Cursor state | infrastructure/cloud_sync.py — CloudSyncState, ~/.snodo/cloud_sync.json |
| Batching, retry, refusal | infrastructure/cloud_sync.py — CloudSyncDispatcher |
| Payload construction | CloudSyncDispatcher._post_batch |
| Liveness snapshot, throttle, push | infrastructure/cloud_liveness.py |
| Liveness triggering from engine events | infrastructure/audit.py — register_event_listener |
| Run-teardown hook | cli/commands/run_cmd.py |
| Connect / disconnect / status / sync | cli/commands/cloud_cmd.py |
| Config schema | snodo/config.py — cloud.api_key, cloud.api_url, cloud.lease_url, cloud.sync_enabled, cloud.liveness_interval_seconds |
History¶
This file began as the implementation ticket for snodo cloud connect and
audit sync — config schema, cursor, dispatcher, retry policy, and the
acceptance criteria for each. That work shipped, and those behaviours are now
pinned by tests rather than by prose, so the ticket has been replaced by the
contract it produced. The retry and cursor semantics described above are
unchanged from the original specification.