Watch State — Spec
Normative. Board model, lifetimes, overdue detection, handover.
Watch state event — 10910
Replaceable, published by the node, unencrypted. A cold client MUST be able to read this before signing on [C23, invariant 4].
{
"v": 4,
"state": "station | automated-oncall | automated | dark",
"holder": "callsign | null",
"holder_kind": "human | agent | null",
"oncall": [
{ "author": { "kind": "human", "callsign": "Wren" },
"channel": "sms", "expires": 1755310000, "sig": "hex | absent" }
],
"since": 1755300000,
"agent_health": "ok | degraded | down",
"last_drill": {
"at": 1755300000, "result": "pass | fail",
"author": { "kind": "node" }, "acknowledged": []
}
}
There is no field for how many operators are overdue, and there must not be one. A
daemon once published an aggregate overdue_count here, because it was the only way to tell
whoever held watch. 10910 is unencrypted, so that announced that somebody was overdue to
anybody subscribed — it named nobody, but a watcher correlating timing learns something, and
that is the Doxxer's method. Removed in v4, once the watch became a mode of the app that
reads the board directly.
Version history, since v4 is the first subtractive change: a v3 reader defaulted a
missing overdue_count to 0 and would render "nobody overdue" for a watch that had simply
stopped saying — a claim nobody made. v2 added log_root; v3 made it required-or-null.
on-stationMUST carrycallsign. The board has no other way to learn a human-readable name from a bare pubkey, and DoD check 3 requires the board entry to show one. Optional on the wire; a daemon falls back to a short deterministic label derived from the pubkeyholder_kindMUST be accurate. An agent MUST NOT be published ashuman[C25]agent_health: degradedMUST NOT be published asokWhen the node is unreachable, clients render
dark— absence is not ambiguity, it is DarkStaleness is also Dark, and this is the part that is easy to miss.
10910is replaceable, so a relay keeps serving the last published copy after the daemon has died. A client checking only for absence fetches that copy, readsautomated, and tells an operator a watch exists when nothing is running — invariant 4 failing in the exact way it was written to prevent.A client MUST therefore treat an event older than
stale_after_secondsas Dark, and MUST treat an event of unknown age as Dark rather than assuming it is fresh.A client MUST treat an event stamped in its own future as Dark, beyond a small tolerance for delivery. The staleness rule above is arithmetic on two clocks, and it is only worth anything while they agree.
The asymmetry is why this matters. A device clock running fast makes a live watch look old, so it reads Dark — wrong, in the safe direction. A device clock running slow makes a dead watch look fresh, and tells an operator somebody is watching when nobody is, which is invariant 4 failing precisely as written. Only the second is detectable from a single event, and only the second is dangerous.
A client SHOULD say which it is. "Nobody is watching" sends an operator out relying on themselves, which is safe; "this phone's clock is wrong" is fixable in thirty seconds and gets the watch back.
stale_after_secondsis configurable, and should be a small multiple of the daemon's publish interval. The daemon MUST republish at that interval even when nothing changed
On-call is a list of statements, not a count
oncall is an array of authored declarations, and the count is derived from its length.
A number can be inflated by whoever publishes it; a list of signed statements can only be
inflated by forging keys, so a consumer counts evidence rather than trusting a total.
- Only declarations that are reachable right now are published: unexpired, and not
console-openstanding alone [C40] - When the reachable list is empty the state MUST be
automated, neverautomated-oncall - A failed or absent drill also demotes
automated-oncalltoautomated— the on-call claim is exactly what a drill tests [C29].stationis never demoted: a human is present regardless
The honest limit, stated in the spec rather than discovered later
Everything here is currently authored by the node, about the node. agents.md says
self-report grants nothing, and a Watchtower publishing its own capability is a self-report
by that same standard.
The shape does not assume it. oncall[].sig and last_drill.acknowledged exist and are
empty, so operators signing for themselves is an additive change rather than a payload
break. Until then, a consumer should read this as what the Watchtower claims, which is
weaker than what is true — and is still worth publishing, because a contemporaneous signed
claim makes a false one attributable.
Gate: counter-signing ships before the Watchtower pubkey goes to anyone who has not been personally vetted. Inside a circle of people you trust directly, node self-report is adequate; the moment it isn't, the shape is already there.
Board entries
Held in memory on the node. Live tier — never written to durable storage [C27].
operator pubkey
callsign string
area string, coarse
signed_on timestamp
expected_until timestamp
routine_due timestamp | null
last_contact timestamp
position {lat, lon, precision_m} | null
status active | overdue | distress
A stood-down signal is acknowledged and the entry is removed from the board.
Stand-down is not a status an entry rests in, which is why it is absent above — the board
holds who is out, and someone who has stood down is not out.
Lifetimes
Configurable; defaults given.
| Default | Behaviour | |
|---|---|---|
| Routine interval | 3600s | Set by operator at sign-on; null disables |
| Overdue grace | 1800s | Past expected_until, or past routine_due |
| Hard expiry | expected_until + 14400s |
Entry is dropped — except status = distress |
| Distress hold | Until human closure | A distress entry MUST NOT expire automatically, and MUST NOT be dropped by hard expiry |
Hard expiry exists so a forgotten sign-on doesn't linger on the board forever. It is not a stand-down — the log records expiry, not a completed op.
Overdue
On crossing overdue grace, the node MUST:
- Mark
status = overdue - Make it visible to whoever holds watch
- Attempt contact with the operator
"Make it visible", not "notify". Whoever holds watch reads the board itself and derives overdue from the entries they already have. The node MUST NOT publish anything about an overdue operator — not a name, not an area, not a count. The transition is written to the accountability log, where the operator it concerns can read it.
It MUST NOT escalate, page, or trigger any part of the ladder [C4, invariant 3]. Only a human reviewing an overdue may raise it. An agent MUST NOT close an overdue.
Rationale, so nobody "improves" this: people are late for ordinary reasons far more often than dangerous ones, and false alarms train everyone to ignore real ones.
Handover
station → station named successor accepts; board state transfers intact
station → automated explicit drop; agent assumes the board
any → dark node down or agent down with no human
Watch MUST NOT be silently abandoned — a holder going offline without handover transitions
to automated, or to dark if the agent is unavailable. Standing down MUST publish dark
rather than going quiet: a stale station left on a relay tells every operator reading it
that a human is present.
Nothing is transferred, and that is the change
An earlier version of this section required handover to transfer all board entries, all unanswered signals and all overdue entries. It does not, and must not.
A board that was handed to you is a board you were told, not one you derived — and the property this whole system rests on is that nobody holds anybody else's picture. Each device draws its own from what it can decrypt.
So the incoming watch starts empty and fills from the operators themselves:
- A field terminal that sees the holder change MUST re-announce
on-stationif it is signed on. This is what populates the new board, and it comes from the operator rather than from the outgoing watch - The re-announce MUST state the duration remaining, not the duration originally declared. Restating the original moves the operator's due-back time forward by however long they have already been out
- Until it arrives, an empty board means "nothing heard yet", and a client MUST NOT present it as "nobody is out"
Shared watch [C28]
Exactly one holder has the board at a time and is the accountable party shown in
10910.
Additional operators MAY be registered as query responders. They receive query
signals, and their callsign appears as responder on answers they give. They hold no
board entries, receive no distress, and carry no escalation duty.
This lets an operator with deep local knowledge and no capacity for a full shift still answer questions.
Accountability log
Separate from the board, and this distinction resolves the C27/C33 conflict.
The board is Live and expires. The accountability log is append-only and retained. It records actions, never positions:
at · actor{kind, callsign, pubkey} · action · subject{kind, callsign, pubkey} · outcome · prev · hash · countersig?
Actions: took-watch, handed-over, acked, answered, marked-overdue,
contacted, escalated, drill-run, drill-result.
- MUST NOT contain positions, areas, or query text
- Each operator MUST be able to review entries where they are the subject [C33]
subjectis keyed on pubkey, never on callsign. There is no registry, so callsigns are not unique — two operators may both be Raven, and matching on the name would show one person another's record. In the mechanism that holds the watch accountable, that is a correctness failure rather than a cosmetic one- Retained 90 days by default, configurable
- Agent actions logged including inaction — an overdue that passed without contact is an entry
The log is written by the party it holds accountable
That is a real hole, not a quibble: the Hostile Watch is a named adversary whose stated mitigation is this log. A watch that can rewrite its own record defeats it.
Three problems, and the middle one was missing from this table until an operator review screen was designed against it:
| Tampering — editing history afterwards | Closed for a whole-log reader. Each entry hashes its content plus the previous hash, so an edit anywhere breaks every link after it |
| Selective disclosure — handing an operator a filtered view they cannot check | Closed, by inclusion proofs against a published root. A link points at the entry before it in the full log, which is usually about somebody else, so a filtered chain view can never verify — but a Merkle proof is log₂(n) sibling hashes and discloses nothing about anyone else |
| Fabrication / omission — a false entry written at the time, or a true one never written | Not closed. Only countersig — the subject signing that this is what happened to them — closes it, and nothing counter-signs yet |
The middle row is the one that matters here, because the operator is precisely the party who cannot be given the whole log. The earlier version of this table said an operator "can verify the chain without trusting whoever wrote it." That was wrong for a filtered view, and the two library functions written to make it possible could not compose.
log_root — the commitment
10910 carries a Merkle root over the log, republished on the heartbeat:
"log_root": { "root": "<hex sha256>", "size": 128, "at": 1755300000 }
- Structured as RFC 6962: leaves and internal nodes are domain-separated, and the tree
splits at the largest power of two below
nrather than duplicating a lone trailing leaf. Both are needed; the obvious implementation has neither and is forgeable - A leaf is the entry's stated chain hash, so the root does not commit to readable content on its own. A verifier MUST recompute the entry's own hash before checking the path. The two are only sound together
- Null MUST be published when the node keeps no log. "This watch commits to nothing" is a fact an operator should be able to read
- It is a checkpoint, not a live value. An entry written since the last heartbeat is
genuinely not covered, and
sizeis what makes that legible rather than confusing
Anchoring a root to Bitcoin
A published root proves a log has not changed since somebody saw that root. It does nothing about a stretch of time when nobody was watching — a hostile watch can rewrite that window and republish, and no client holds anything to contradict it.
OpenTimestamps closes that. The root is submitted to public calendar servers, aggregated with thousands of unrelated hashes into a single Bitcoin transaction, and the node receives a proof that this exact value existed before a given block.
- Free, no wallet, no token, no transaction fee, no account
- Only a hash is submitted. A calendar learns nothing about the log's contents
- The proof is a few hundred bytes and is verifiable by anyone, forever, without trusting the node or the calendar
"log_root": {
"root": "<hex>", "size": 128, "at": 1755300000,
"anchor": { "state": "pending | confirmed", "at": 1755300400, "height": 912345 }
}
- Anchored on a schedule — default daily, configurable. The interval is the promise: a daily anchor means at most the last 24 hours of history is rewritable, and the node should say which
pendingMUST NOT be shown asconfirmed. A fresh stamp is held by a calendar and not yet in a block. Same discipline as a drill that has not run- The full proof travels with a
log-reviewresponse, so an operator verifies their entries and the anchor together - A failed anchor MUST NOT stop the watch. Calendars are run by volunteers and go down. An unanchored root is a weaker root, not an emergency — the same rule that says a broken chain does not stop the watch
It lets a client verify a root it never saw. Until now, checkReview could only trust a
root the device had itself observed — so an operator who was offline, or new, could check
nothing. An anchored root carries its own evidence of when it existed, which removes that
gap entirely.
What it still does not prove, and this belongs beside every mention of it:
| These entries existed at this time, unchanged since | Proved |
| These entries are all of them | Not proved. Omission needs counter-signing |
| These entries are true | Not proved. A false entry written at the time anchors just as well as an honest one |
The one thing it costs: a calendar server sees an IP address submitting a hash on a schedule. Nothing about content, but it is one more place a timing pattern exists. Submit to several calendars, or run one, or accept it — but do not describe it as leaking nothing.
Clients MUST keep the roots they have seen
10910 is replaceable, so a relay serves only the newest. A node that is the sole
custodian of the evidence against itself is not being held to anything.
A client keeps its observations and reports three findings:
diverged |
Two different roots at the same tree size. Nothing legitimate does this — history was rewritten after being committed to |
shrank |
Fewer entries than a root already seen. Retention does this on a schedule; so does deletion |
stopped |
A watch that was committing to a log no longer is |
These are kept in the accruing tier and survive a panic wipe. A hash says nothing about where anyone was, and the record is worth more the further back it goes.
What none of this closes: omission. A watch that never writes an entry publishes a root
over a tree that never contained it, and every proof still verifies. Only countersig
closes that, and the screen rendering a review must say so plainly.
The chain does not make a lie impossible and this spec does not pretend otherwise. Same gate
as oncall: counter-signing ships before the Watchtower opens past people personally
vetted.
Retention breaks the chain, and the node declares where
Dropping entries past the retention window leaves the oldest survivor pointing at a hash that no longer exists — indistinguishable from tampering. The node MUST record the dropped tail's final hash as the declared start, and verification MUST be performed against it. Without this the log accuses itself every 90 days.
A declared start MUST NOT be usable to launder an edited history: entries after it are still chained, so an edit inside the retained window is still caught.
outcome is a closed set, not free text
Free text is the one field through which an area, a position or a query text could reach a log that MUST NOT contain any of them. No care at the call sites removes that channel; a union does.
Two outcomes record inaction, which agents.md requires:
contact-not-attempted— an operator went overdue and nothing tried to reach themescalation-not-attempted— aDistressarrived and no ladder ran
Both are distinct from their "tried and failed" counterparts on purpose. escalation- reached-nobody claims an attempt; while the ladder is unbuilt, the true entry is
escalation-not-attempted, and it should read badly until it stops being true.
A broken chain does not stop the watch
A node that finds its own log broken at boot MUST record the break permanently and keep holding the watch. People's safety depends on the watch running, and a watch that refuses to start because its record looks edited has turned an accountability failure into an availability one — which is the trade a hostile watch would take every time.
An operator reviewing a watch sees: acknowledged your sign-on 21:04, answered your query 22:41, no escalation. Not a movement history.