robot-army

The audit log

Where it is, what a record means, and how to answer “what happened?” from it alone.

Location and rotation

~/.local/state/robot-army/logs/audit-YYYY-MM-DD.jsonl

JSON Lines: one record per line, opened in append mode, flushed per record. A record still sitting in a buffer when the process dies is not a durable record, which is why the flush is per line rather than per block.

Daily files, never deleted automatically. The constitution requires that any rotation policy not discard records silently; nothing here discards anything. Prune by hand when the directory gets large.

A partially written final line is possible — the process can die between the write and the next flush. Readers skip unparseable lines and count them; robot-army log reports the count at the end. Refusing to read the log because of one truncated line would be the wrong trade.

Reading it

robot-army log --since 10m         # 30s, 10m, 2h, 1d
robot-army log --item 42           # everything about one work item
robot-army log --follow            # tail the current day's file
robot-army log --json | jq .       # machine-readable

Or directly, since it is just JSONL:

jq -c 'select(.action | startswith("github"))' ~/.local/state/robot-army/logs/audit-*.jsonl

Record shape

Every record carries what Principle III enumerates: when, which component, what action, against what target, with what result.

{
  "ts": "2026-08-23T14:07:11Z",
  "component": "daemon",
  "kind": "event",
  "action": "state.work_item",
  "outcome": "ok",
  "entity_type": "work_item",
  "entity_id": 42,
  "detail": {"from": "dispatching", "to": "active", "reason": "session confirmed present"}
}
Field Meaning
ts UTC ISO 8601, Z suffix. Always.
component daemon, cli, or web — which process wrote it
kind event, intent, or outcome
action Dotted name: github.poll, git.fetch, hook.step, state.session, …
outcome ok, error, or pending (only on an intent)
entity_type / entity_id What it happened to
target A path, URL, or socket, where one is more useful than an id
action_id Present on intent/outcome pairs, shared between them
dry_run / simulated Present and true only when the record concerns simulated work. Either one means it; robot-army log excludes such records unless given --include-simulated, and marks them [simulated] when it shows them
detail Everything else, action-specific

Recorded in UTC, displayed in local time

The record is UTC and always will be. Every ts above, every *_at column in the database, the heartbeat, and every machine-readable response — robot-army <command> --json and the web interface’s JSON — carry %Y-%m-%dT%H:%M:%SZ. That is what makes a record comparable against another record, against GitHub, and against itself a year later. A log whose timestamps depended on where the reader stood could not meet the constitution’s reconstruction standard, which is why this half does not move.

What you read is local. Since milestone 010, every timestamp the CLI prints and every timestamp the web interface renders is converted to this machine’s timezone and labelled with its offset — 2026-08-29 21:31:07 -04:00 for the record 2026-08-30T01:31:07Z. Note that these are the same instant on different calendar days; that is the point of the feature, not a bug in it.

The two halves differ on purpose, and the difference is easy to mistake for an oversight in either direction. Some notes for whoever reads this next:

The web component

Milestone 002 added a third writer. Records from the web interface carry "component": "web", which is what makes “which interface did this?” answerable from the record alone rather than by inference from the timestamp.

jq -c 'select(.component == "web")' ~/.local/state/robot-army/logs/audit-*.jsonl
robot-army log --item 42          # both interfaces' records, interleaved in causal order

Every state-changing request produces an intent/outcome pair named for its route — web.resume, web.abandon, web.cancel, web.dispatch.pause, web.poll — written before the action runs, including before the checks that might refuse it. That ordering is deliberate: it means a refused request, a crashed request, and a successful one all leave a record, so an error response with no corresponding record is impossible by construction rather than by discipline.

The operations underneath write their own records too, so a resume from the web reads as web.resume (intent) → state.work_item → worktree.prepare → … → web.resume (outcome). The two long actions, resume and restart, run on a worker thread and answer HTTP immediately; what the operation actually returned lands later as web.resume.result.

web.start records the address and port the interface is actually listening on, on every start. Under the exposure model in 002’s spec the bind address is the access policy, so it is the one fact that is never allowed to be silent.

Intent and outcome

An append-only log cannot amend a record after the fact. The Operating Constraints require irreversible or outward-facing actions to be logged before execution, so “log before” plus “record the result” necessarily means two records:

{"ts":"…:11Z","kind":"intent","action":"github.comment","outcome":"pending","action_id":"a1b2","target":"jantman/demo#42"}
{"ts":"…:12Z","kind":"outcome","action":"github.comment","outcome":"ok","action_id":"a1b2","detail":{"comment_url":"https://…"}}

Those two records say a comment was written; dispatch.confirmed says what was in it. Since #38 its detail carries host, session_name and attempt beside the session_id it always carried, plus resumed_from or supersedes when the attempt replaced an earlier session. Every one of those values also appears in the comment on the issue, which is the point: the log is on the machine that ran the session, and the issue is the only place a reader who is not on that machine can find out which machine it was.

jq -r 'select(.action == "dispatch.confirmed")
       | [.detail.host, .detail.session_name, .detail.session_id, .detail.attempt] | @tsv' \
  ~/.local/state/robot-army/logs/audit-*.jsonl

The pairing is also the crash signature. An intent with no matching outcome means the process died mid-action. Finding them:

jq -r 'select(.action_id) | "\(.action_id) \(.kind)"' ~/.local/state/robot-army/logs/audit-*.jsonl \
  | sort | uniq -c | awk '$1 == 1'

Secrets

A single redaction choke point applies to every record on its way to the file, keyed on field name. There is no path to the log that bypasses it. Field names containing token, secret, password, api_key, authorization, credential, or private_key become <redacted>; an env object has its secret-looking members redacted individually so the rest stays legible.

Issue titles and bodies are not redacted. They are the prompt, and reconstruction needs them.

grep -ri 'ghp_' ~/.local/state/robot-army/logs/ && echo "REDACTION FAILURE" || echo "clean"

The trello.* actions

Milestone 003 adds one source and ten actions. Every one of them names the card id, and, where one exists, the repository and issue — so a card’s whole life is grep-able by its id.

Action When Notable detail
trello.board.check Startup, and robot-army doctor Each precondition and its verdict, plus the board’s member list, which is recorded and never gated on
trello.poll Once per board cycle How many cards carry the tag, and how many were newly tracked
trello.evaluated Per card, per evaluation Whether it resolved, to what, the candidates it considered, and source — declaration if a robot-army: line on the card decided it, scan if the card’s text did, onboarding if nothing was onboarded and so nothing on the card could have resolved (issue #83)
trello.needs_info A card is held The reason, and the reason last written onto the card
trello.issue.create An issue is filed from a card Intent/outcome pair. The failure branch carries the attempt count
trello.card.comment Any comment we write Intent/outcome pair
trello.card.move Any move we make Intent/outcome pair, naming both lists
trello.card.move_refused A move we declined Where the card is, where we last put it, and what we would have done
trello.recovered Any recovery path fires Which path — issue listing, or marker comment — and what it found
trello.dropped A card leaves the board Whether the mapping was kept, and why

A recovery that happened silently would be indistinguishable from nothing having gone wrong, which is why trello.recovered exists at all: the absence of duplicates is not observable, so the recovery that prevented one has to be.

Credentials and this source

Trello’s documented authentication is a query string. This project uses the header form instead, and that is a security decision rather than a stylistic one: the redaction choke point below is keyed on field names, so a secret embedded inside a string under a key called url would sail straight through it. No trello.* record carries a full URL — method and path only — and the boundary strips query strings and its own credential values from any text it records, in case a remote quotes back what it was sent.

grep -c "$TRELLO_API_TOKEN" ~/.local/state/robot-army/log/*.jsonl   # expect 0, always

The milestone 004 actions

Six new actions, and one changed one. Every one of them exists to answer a question that is asked long after the fact: why did nothing dispatch for twenty minutes? and why is this 499 MB still here?

Action When Notable detail
dispatch.at_capacity Changed — when a hold’s signature changes, not every pass The counts, the cap, the ours/others split, and which item is at the head. Since issue #61 also simulated and in_flight, the sessions robot-army dispatched that the registry has not seen, so ours + others + simulated + in_flight equals live_sessions
dispatch.hold_ended A capacity hold clears How long it lasted, how many passes it spanned, and what freed it
capacity.unobservable The registry and /proc both failed Which failed, and that dispatch is being withheld as a result. Also raises a de-duplicated anomaly of the same kind
cleanup.considered An item is evaluated for cleanup The decision and the guard that made it — including “not eligible”, so an item that was looked at and passed over is distinguishable from one nobody looked at
cleanup.retained A guard refused a removal Which guard, what it saw, and the worktree and branch it kept
notify.send A message is attempted The kind, the item, and whether the per-cycle bound suppressed it. Written whether or not it left the machine
notify.channel A delivery to one channel is attempted The channel name (webhook, pushover), the kind, the item, and our own reason on failure. One record per channel per message
notify.suppressed The per-cycle bound was reached How many were held back, and of which kinds
health.notify The failed-health alert is sent to one channel The channel name, the check’s own reason — which since issue #52 names the verdict, DIED and HUNG being different problems — and the outcome. One record per configured channel

Since issue #106 a message and a delivery are different things: one message may go to zero, one, or two channels. notify.send records the message and notify.channel records each delivery, which is what lets the log answer “the webhook took it and Pushover did not”. The per-cycle bound counts messages, not deliveries, so configuring a second channel does not halve how many events you are told about.

Neither a Pushover application token nor a user key can appear in any of these. The credentials travel in the request body rather than the URL, and no upstream response body is ever recorded — the two habits that would otherwise leak one.

The existing git.remove_worktree and git.delete_branch gain a new caller and are otherwise unchanged. git.delete_branch from cleanup always carries force: true, which here means a stronger guard than git’s has already passed — containment against the remote was proved — rather than that a guard was skipped.

The dispatch.at_capacity retention rule

A hold is recorded when its signature changes — (total, others, cap, head item) — and once more when it ends. Not once per pass.

This is a documented summarisation under Principle III’s retention clause, not an exception to it. Everything about the hold is recorded: that it happened, what caused it, the counts behind it, when it started, when it ended, and how many passes it spanned. What is not written is 17,280 identical records a day at a five-second tick, which would not make the log more reconstructible — it would make it less, by burying the records that carry information under records that carry none. The same judgement already lives in this project as raise_anomaly’s partial unique index.

The rule is written down here rather than improvised because the constitution requires a retention policy to be documented, and because milestone 004 makes holds routine rather than rare.

Notifications carry no credentials

A NotificationEvent has exactly six fields — kind, item id, repository key, title, detail, url — and every value is an identifier or a state name this system chose. There is no field a secret could reach, which makes it a property of the shape rather than of the discipline of whoever fills it in. A test asserts it across a run that includes an authentication failure, because that is the case where a token would otherwise ride along inside an error string.

grep -c "$ROBOT_ARMY_GITHUB_TOKEN" ~/.local/state/robot-army/logs/*.jsonl   # expect 0

The milestone 005 actions

One action changed, no new ones. repo.onboard was always an intent/outcome pair; what it carries and when it is written both changed.

The detail grew to answer “which repository did I actually approve?” — the question the whole milestone exists to make answerable:

Field Meaning
clone_path the location approved, absolute and with symlinks resolved
path_source derived (from [paths] repo_root) or configured (from a [repos.*] section)
remote which remote was consulted — origin, or the sole remote when there is no origin
verified_origin the normalised host/owner/name found there. Never a raw URL
owner_verdict owned, listed, or the owner found, from the one repository lookup

base_ref_source arrived with issue #150, beside the base_ref this record already carried. The ref alone said what was approved and not what decided it, and those had become different questions once the base ref stopped being a configuration default:

Field Meaning
base_ref the branch the settings were reviewed at, and that work will be cut from
base_ref_source repo_config, detected (from <remote>/HEAD), worker_config, or default

detected is the answer for any clone that has ever spoken to its remote. Anything else means the clone could not say and configuration filled in — worth knowing when a later dispatch reports a fingerprint that changed without anybody touching the repository:

jq -r 'select(.action == "repo.onboard" and .detail.base_ref_source != "detected")
       | "\(.ts) \(.entity_id) \(.detail.base_ref) \(.detail.base_ref_source)"' \
  ~/.local/state/robot-army/logs/audit-*.jsonl

Two more fields arrived with issue #41, on the same record and for the same kind of reason — they say what was true of the repository at the moment it was approved:

Field Meaning
speckit whether the clone was detected as a Spec Kit project
speckit_numbering timestamp, scanned, unknown, or null when speckit is false and nothing was asked

scanned is what the onboarding warning fires on, so this answers “was I told, and did I approve anyway” for any past onboarding:

jq -r 'select(.action == "repo.onboard" and .detail.speckit_numbering == "scanned")
       | "\(.ts) \(.entity_id)"' \
  ~/.local/state/robot-army/logs/audit-*.jsonl

null means not asked rather than no answer: the repository is not a Spec Kit project, so nothing read the file. The configured value — sequential, or whatever it said — is on the screen and in the --json document but deliberately not here. This record answers what was approved, and the verdict is that.

Refusals are now written, and that is a bug fix rather than new behaviour. Before this milestone onboard returned for a missing [repos.*] section before opening any audit action, so a refusal was printed and forgotten. Under Principle III’s reconstruction standard a refusal is a result, and one that leaves no trace is a gap. Every non-zero exit from onboard now writes an outcome carrying a cause:

cause What was refused
not_permitted neither owned-and-include_owned nor listed in extra_repos
no_such_repository the source system has no such repository
malformed_key not owner/name
no_clone nothing at the resolved path
linked_worktree the path is a linked worktree, not a primary clone
inside_worktree_root the path is inside [paths] worktree_root
no_remote / ambiguous_remote no remote, or several with none named origin
unparseable_url the remote URL is not a repository URL
wrong_repository a real clone of a different repository is there
source_unreachable the source system could not be asked — a bad token, or no network
unapproved_committed_settings --yes refused to skip an unreviewed settings change
aborted_at_prompt I said no
interrupted_at_prompt I pressed Ctrl-C at the prompt
no_answer_available input ended before the prompt was answered
jq -r 'select(.action == "repo.onboard" and .detail.refused) | "\(.entity_id) \(.detail.cause)"' \
  ~/.local/state/robot-army/logs/audit-*.jsonl

The last two are milestone 011, and are the same bug fix one milestone later. Onboarding asked for approval before showing what was being approved (issue #17), so nobody reached that prompt informed enough to walk away from it — and the two ways of walking away both exited non-zero writing nothing. Ctrl-C propagated past onboard before its audit action opened; end-of-input was an uncaught EOFError and a traceback. Now that the approval screen arrives first, giving up after reading it is the expected second answer, so both are recorded like the decline they resemble. Every terminating path through onboard now leaves exactly one outcome record: an approval, or a refusal naming its cause.

The dispatch-time re-verification writes only on failure

Before creating anything, dispatch re-checks that the recorded clone is still there, is still a primary clone, and still has the same origin. A pass writes nothing.

That is a deliberate omission and it is not a gap. The worktree-creation record for the same item follows milliseconds later on the same code path, so the question “did the clone still check out?” is answered by the presence of the next record — which is exactly Principle III’s reconstruction standard, met by the log as a whole rather than by one line in it. Writing a line per dispatch to say “yes, still fine” would add volume and no information.

A failure writes the existing DispatchBlocked path — the item goes failed with the reason, naming the recorded path — and the two failures that mean the machine changed under an approval also raise an anomaly: clone_path_missing and clone_origin_changed. Those two are distinguished from an ordinary gate refusal on purpose. An untrusted clone is a setup step I have not done yet; a clone that moved is a fact about the world I probably do not know.

No credential from a remote URL reaches this log

A git remote URL may embed credentials, and milestone 005 is the first time this codebase reads one at all. Normalisation strips the userinfo@ component before anything else, and what is recorded and compared is the normalised triple — which cannot carry a secret because it is three lowercase strings with no room for one. What is printed is the same triple with owner and name as the remote spelled them, so a refusal names ZoneMinder/zoneminder rather than a folded name nobody would recognise; it is taken from the same parse, after the credential is gone. The refusal path is held to the same rule, including the unparseable-URL refusal, which deliberately does not echo the URL back even though that is the case where doing so would feel most helpful.

The milestone 007 actions

Two new actions, both cheap, and one of them writes far less often than you would expect.

Action When Notable detail
speckit.detect Once per dispatch, before the prompt is composed detected, the reason verbatim, the command form found (skills, commands, mixed), whether the guidance was enabled, and suppressed_by naming the setting when it was turned off
speckit.phase A lifecycle phase changes from, to, and the feature_dir it was read from — plus previous_feature_dir when the directory itself changed

The suppressed_by field is the one worth knowing about. “This repository has no Spec Kit” and “this repository has Spec Kit and I turned the guidance off” produce the same behaviour and must not produce the same record, or a deliberately quiet repository reads as a broken one.

speckit.phase carries no evidence from the session, because none is solicited. Spec Kit’s extension hooks are instructions an agent chooses to follow rather than callbacks, so a report that never arrives is indistinguishable from a phase not yet reached; everything here is read from files in the worktree instead. The argument, and the three conditions that would make hooks worth revisiting, are in the 007 spec.

Phase observation writes nothing when nothing changed

One record per transition, not per reconciliation pass — the same shape of rule as dispatch.at_capacity above, and a documented summarisation rather than an exception.

With a 60-second reconciliation cycle and sessions that run for hours, a record per pass would be some thousands of lines a day saying a phase did not change, burying the handful that say it did. Everything about the progression is still recorded: every transition, with its time, its rungs, and the directory it came from. The pass summary in reconcile.pass carries speckit_phase_changes, so a cycle in which nothing moved is still accounted for.

An item that reports no phase at all is a legitimate resting state, not a fault — the prompt leaves the judgement of whether an issue warrants the lifecycle to the session, and a session that decides a typo fix does not need four phases produces exactly this. robot-army show explains the one case where silence is otherwise mysterious: a Spec Kit worktree whose baseline was never recorded, which can never report a phase and says so.

The issue #48 actions

Reading a GitHub project board. Five records, and none of them is an intent/outcome pair — the bracket exists for calls that change something outside the process, and every one of these is a read. The board is never written to.

Record When Notable detail
github.project.discover Every resolution attempt. ok when a board resolves and when the repository simply has none — an absent board is a third state, not a failure, and recording it as one would put an error a minute in the log for the ordinary condition of most repositories Candidate projects seen, the project and column chosen, and whether each was discovered or configured. On failure, the reason a surface shows — which is why “why is this repository not ordered by its board?” is answerable from the log as well as from status
github.project.read Every successful board read Project, column, how many items were ranked, how many were parked elsewhere, and the total the project holds
github.project.partial A GraphQL response carrying both data and errors The error type and count, written immediately before the read is failed. A partly believed board is worse than no board: the missing half is invisible, so the order would be confidently wrong rather than absent
poll.board Once per repository per pass, except for a repository that has never had a board and does not have one now — that writes nothing at all ok with the counts, ok with a skipped reason when the repository is in backoff, or error with the failure and the new backoff. A skip is recorded rather than silent — “the board was not read this pass” is a fact about the order being shown
poll.board.fallback A read failed or a resolution was lost, and a previous snapshot exists How old that snapshot is, and which of two different things follows — the two must not be confused. After a transport failure the repository stays governed by the stale snapshot, order and holds intact. After a resolution failure it becomes ungoverned: board order and off-column holds are released and it falls back to the configured order until resolution succeeds again. Both are permitted by FR-025; recording the wrong one would make the log evidence for something that did not happen

A resolution failure is not a transport failure and does not advance the backoff counter. There is nothing to retry away: only the author can clear an ambiguity, and backing off would delay recovery once they had.

No credential reaches this log. The token is read at the moment it is needed and never stored; the scope names appear in doctor output when a check fails, which is the whole point of that check, and scope names are not secrets.

The issue #73 action

A board_precondition anomaly is the one kind whose text follows its condition while it is open: when the board fails for a different reason, the open row is rewritten rather than left naming a failure that may have been fixed. That overwrites the database’s only copy of the previous reason, so the log keeps it.

Record When Notable detail
anomaly.restated New — when a failed board check differs from what the open board_precondition anomaly says. Written in the same transaction as the rewrite The anomaly id (as the entity), kind, anomaly_entity_id (the board), previous_failed_checks, failed_checks, previous_detected_at — when the overwritten reason had been detected — and reason. A stored detail that could not be read gives previous_failed_checks: null and previous_detail_unreadable: true rather than a guess. An unchanged failure writes none: the trello.board.check error record already says the board failed again, and nothing about the anomaly changed

The issue #138 actions

A worker never ends itself, so the successful path used to terminate in an orphan_session and a permanently held capacity slot. Retirement is what ends it, and — because ending a process cannot be undone from this side — the intent is on disk before the signal is sent.

Record When Notable detail
session.retire New — once per session, immediately before anything is signalled item_id, session_id, pid, proc_start, idle_s — how long the worker had been idle when the decision was taken — and signal, which of the two conditions authorised it: merged_pull_request or quiet_period. Written first on purpose, so a daemon that dies between the decision and the kill still leaves the reason for a process that is about to disappear
session.terminate As before Unchanged. Retirement is a new caller of the existing confirmed-termination path, not a new mechanism, so the rungs, the refusals and the escalation record exactly as they do for cancel
session.retired New — after a confirmed stop The termination’s method, confirmed and escalated, plus how the row settled. settled: left is not a failure: it means the session recorded its own ending between the decision and the settle, which is an ordinary outcome of a successful retirement
session.retire_refused New, outcome error The boundary declined to act on an implausible pid and sent nothing. Distinct from the next row, and the distinction matters: “we refused” and “we signalled it and it survived” are different claims about what happened to the machine
session.retire_unconfirmed New, outcome error The worker survived the termination. The row stays open and the slot stays subscribed — “I tried and could not” is never recorded as “it is gone”
anomaly.resolved When a re-checkable anomaly’s condition is confirmed false: an orphan_session’s process is gone, or a card_create_failing’s card has reached linked The anomaly id, its kind and entity, and the evidence — the pid and proc_start that no longer match, or the card’s state and the issue it now carries — plus the reason. This is where “on what evidence” lives: there is deliberately no resolved_reason column, because Principle III already makes this log the reconstruction path. A repeated pass writes nothing, so one resolution means one record
reconcile.pass Changed — as before, once per pass Gains retired and anomalies_resolved. retired is deliberately separate from reclaimed: both close a session row, but one means “this pass ended the worker” and the other means “the worker was already gone”

signal was added by issue #149, and idle_s alone is why. Retirement originally had one gate — 30 minutes of idleness — so idle_s implied the reason and nothing else needed saying. It now has two, the merged pull request being the primary one, and an idle_s of 47 is either a merge or a bug. Principle III’s standard is reconstruction from the log without re-running anything, and that cannot mean “and knowing which release wrote the line”, so the condition is recorded rather than inferred. idle_s stays and is still the truth about the worker; it is simply no longer the reason. The settle reason on the session row names the same fact in prose, so a row read on its own says why its worker was ended.

Closing the tab

The follow-up to the above, and a correction: retirement ends the worker, and a separate sweep closes the window it was running in. --hold keeps every window open after its command exits — deliberately, so a failed launch leaves evidence — so nothing closed one until this existed.

Record When Notable detail
kitty.close_window Newly reachable — once per window closed The action has existed since milestone 001 and had no caller at all until now. Intent and outcome both come from its own audit.action context; the target is the window id
window.list New, outcome error The terminal could not be listed. Only reachable when something actually qualified for closing — the sweep asks the database first and returns before touching the terminal when nothing does, so this record means “there was work to do and kitty could not be reached” rather than “this machine has no kitty”
window.close New, outcome error One window could not be closed. The sweep continues to the next one; this window is simply reconsidered next pass
reconcile.pass Changed Gains windows_closed

A window that was already gone by the time it was closed — the maintainer got there first — is success, not a failure, and writes no error record. Kitty exits 1 for an id that no longer matches, saying No matching windows; the boundary turns exactly that into a False rather than raising, and the sweep treats it as settled-but-not-counted. Every other non-zero exit, and every timeout, raises instead and is recorded — the two must not collapse together, because a real failure reported as “nothing was there” would settle the item and leak its window silently. So windows_closed counts only closes the system itself performed.

The one thing retirement does not write

A worker that is not yet idle produces no record at all — not an action, not a column, not an anomaly. It is listed with the other documented gaps below.

The issue #143 actions

Three actions for the pull requests a work item has. None of them is an intent/outcome pair, because every one of them is a read or a note about one: nothing here writes to GitHub.

Action When Notable detail
work_item.pull_requests The set a work item has changes — first discovery, a new pull request, or a state change from and to as number:state lists, and first_check marking the one transition whose “before” is not a set at all. Written inside the same transaction as the column update
reconcile.pull_requests_check A lookup fails error, with the item it was for and the failure. The stored answer is left exactly as it was, so the log is the only place that says the attempt happened at all
github.pull_requests.partial A GraphQL response carrying both data and errors — a deleted or inaccessible issue does this The error type and count, before the read is failed. The same record github.project.partial writes for a board read, under its own name: a pull-request failure filed as a project failure is a record answering the wrong question

first_check is the field worth knowing about. NULL → [] and [{…}] → [] both end with no pull request, and they are not the same event: the first is “we looked for the first time and found none”, the second is “the one it had went away”. Without the flag the log could not tell them apart afterwards.

reconcile.pull_requests_check is what makes a failed lookup visible at all. A failure writes neither column, so the row itself is unchanged and the interface simply keeps showing an answer that grows older — deliberately, because “I could not ask” is not “there are none”. The log is where the attempt and its error live.

The pull-request refresh writes nothing when nothing changed

The same rule as phase observation above, for the same reason and with the same accounting. One record per change, not per reconciliation pass: with a 60-second cycle and sessions that run for hours, a record per pass would be thousands of lines a day saying a pull request did not change. reconcile.pass carries pull_request_changes, so a cycle in which nothing moved is still accounted for, and work_items.pull_requests_at on the row carries the last confirmation.

The keep actions

A keep holds a done item’s session open past its issue closing (see keeping a session after the merge). Both verbs are intent/outcome pairs, like hold.item; from the web, _perform’s web.keep.item or web.unkeep.item pair comes first.

Record When Notable detail
keep.item robot-army keep <id>, or the web’s keep Intent: by (cli or web), was_kept, the item’s state, and live_session — whether a session row was open. Outcome: kept_at, kept_by and placed; a redundant keep is recorded with placed: false and the original time and surface. A refusal — the item is done or abandoned with no session open — is outcome error with a reason, and writes no row. An unknown item is an error with cause: no such work item
unkeep.item robot-army unkeep <id>, or the web’s release Intent: by. Outcome: released, was_kept_at, and open_sessions — the ids of the item’s session rows that were open at the moment of release
anomaly.resolved Changed — a second reason for orphan_session the work item is done and kept by the operator, so this session is accounted for, with work_item_id. Written when an orphan raised before the keep (the worker was busy when its item finished) is retracted. The pid re-check runs first, so a kept worker that has since died is resolved with the ordinary “no longer running” reason instead. Only the item’s current attempt is covered: an orphan from a superseded attempt is never retracted by a keep

A retirement that followed a release is told apart by joining on the session id. session.retire is unchanged; the unkeep.item before it names the sessions it released. Every retirement that came after a release — released == true, because releasing an item that was never kept is a no-op that still records open_sessions:

jq -sc '
  (map(select(.action == "unkeep.item" and .kind == "outcome" and .detail.released == true)
     | .detail.open_sessions[]?)) as $released
  | .[] | select(.action == "session.retire" and (.detail.session_id | IN($released[])))
' ~/.local/state/robot-army/logs/audit-*.jsonl

Two things a keep makes the system not write, both listed in the table below: the retirement a keep withholds, and the cleanup consideration of a kept item whose session is still open.

What is deliberately not logged

Principle III permits gaps only when they are named and justified in the feature plan. The full argument is in plan.md; the list, so a reader of the log knows what its silence means:

Gap Why
Individual successful, read-only GitHub GETs — one aggregate record per repository per poll instead They change no state outside the process, and at a 60-second poll the individual records would be pure volume. Every failure and every retry is still logged individually. The exemption is for reads a poll’s aggregate stands in for: onboarding’s repository lookup has no such aggregate and is recorded as github.get_repo (see the issue #64 section below)
Individual SQLite statements — the transition they effect is logged instead The transition is the meaningful unit for reconstruction, and the database is directly inspectable
Heartbeat writes, every 5 seconds ~17,000 records a day of noise. The heartbeat file is the record, and its staleness is the signal
Individual /proc and registry reads during reconciliation — one aggregate per pass Same disproportion. The conclusions — sessions found, orphans detected, states changed — are each logged individually
Actions the session itself takes inside the worktree They happen outside this process entirely. This log records the dispatch, the session identity, and where the transcript lives; the worker’s own transcript is the record of what it did. Claiming otherwise would be dishonest about what this log covers. Issue #32 is the sharp case: at no-remote a session pushed a branch to GitHub, and this log’s only honest account of it is daemon.start saying which delivery form the session was handed. What it did with that instruction is the transcript’s business, and no effect level makes it ours
Individual board reads made within a poll cycle — the freshness re-read before a move, and the comment fetch on the recovery path Same reasoning as the GitHub one, and the same limit: they change no state outside the process, and the cycle is logged with what it evaluated and what it decided about each card. Every board write is an intent/outcome pair, without exception
A notification never attempted because the process died between a state transition and the send The state change itself is fully recorded, so nothing the system did is unreconstructable. What is lost is the knowledge that I was not told. Closing it would need a durable outbound queue with its own retry and persistence, which is more machinery than an optional stretch feature is worth — the gap is named here rather than hidden
A passing dispatch-time clone re-verification The worktree-creation record that follows on the same item milliseconds later already implies it passed, so a record here would be one line per dispatch answering a question the next line answers anyway. Every failure is logged, and the two that mean the machine changed under an approval also raise an anomaly. See the milestone 005 section above
A reconciliation pass in which an item’s pull-request set did not change Same disproportion as the phase row below, and the same accounting: with a 60-second cycle almost every check confirms an unchanged set. Every change is recorded individually with what it changed from, reconcile.pass counts them, and the row’s own pull_requests_at carries the last confirmation — so neither the answer nor its age is unreconstructable. Every failure to look is logged individually. See the issue #143 section above
A reconciliation pass in which no lifecycle phase changed Same disproportion as the heartbeat: a 60-second cycle against work that moves every few hours. Every transition is recorded individually, and the pass summary counts the changes, so nothing about the progression is unreconstructable. See the milestone 007 section above
The individual file reads behind Spec Kit detection and phase observation Four stat calls per dispatch and a handful per active item per cycle. They change no state outside the process, and the decision they support is logged — logging each read would bury it
The read of .specify/init-options.json at onboarding Same reasoning, one milestone later (issue #41). A stat and at most one small file read, on a command run once per repository — and the answer it produces is written onto the repo.onboard record that same run, so what the maintainer was shown when they approved is reconstructable from the log without a line saying a file was opened
A repository that has never had a project board and does not have one now Not an action and not a failure — it is the ordinary condition of most repositories, and it would otherwise write a record a minute forever for every one of them. Nothing is persisted for such a repository either, so status stays quiet about it too. A board that goes away is different and is recorded: that is a change, and the author should hear about it
The project board’s item sequence on every read — the counts and the outcome are recorded instead The order itself is recoverable from work_items.board_position at any instant, and it is not a question about an action the system took. Writing the same list every 60 seconds per repository would be ~1,440 copies a day drowning the records that matter. What is genuinely lost is history: from the log alone you cannot say what order the board was in three days ago. Every read, every failure, every fallback and every dispatch is recorded, so nothing the system did is unreconstructable
A window that simply does not qualify for closing — its item is failed, or still has a live session The pass runs every 60 seconds and a working machine has several such windows permanently, so a record each would be thousands a day carrying no news. The condition is re-derivable from the item’s state at any instant, and everything the sweep does — every close, every failure to close, every failure to list — is recorded. Same reasoning as the row below, and as the transcript check
A retirement deferred because the worker was not idle yet, or idle without a merge and not for long enough The reconciliation loop runs every 60 seconds and a finished session may sit idle for hours, so recording “still busy” would write ~1,440 records a day carrying one bit. The condition is re-derivable at any instant from the worker’s own registry entry and the item’s stored pull requests, and the decision that acts — session.retire and everything after it — is logged in full. Both of retirement’s deferrals are covered by this one line and neither writes anything, unchanged by issue #149. Precedent: the transcript check writes nothing for a session still inside its grace period, for the same reason. See the issue #138 section above
A retirement withheld because the item is kept The same “not yet” as the row above, for an operator-chosen length of time — days, possibly — so a record per pass would be thousands carrying one bit. keep.item and unkeep.item already say when the keep was placed and released, and which sessions it was holding at release, so whether a session was kept at any moment is reconstructable from the log alone
A cleanup pass that leaves out a kept item whose session is open It used to write cleanup.considered with skipped every pass, which was tolerable for the minute between done and retirement and is not for a keep that stands for days. The decision is re-derivable from the keep and session tables at any instant, and cleanup <id> still considers the item and records skipped on demand. A keep whose session has ended does not exclude the item, so its cleanup is recorded as before
Read-only web requests — every GET the interface serves They change no state outside the process, so Principle III’s own scope does not reach them. The exception is written down because the volume makes the omission visible: a page auto-refreshing every 10 seconds issues a GET every 10 seconds, and logging those would bury the record this project exists to keep readable. Nothing a GET does is unreconstructable — the data it read is in the database and in this log. Every POST is logged, without exception

Silent failure is forbidden

Every swallowed error, retry, and fallback leaves a record. Specifically:

Reading it from the phone

GET /log renders the same records with the same filters — ?item=, ?since=, ?outcome= — newest first, a bounded page at a time, with GitHub repositories, issues, and pull requests turned into followable links. Only URLs that are already https://github.com/…, or targets shaped like owner/repo#123, become links; anything else in a record stays as text, because a record can carry an issue body and an arbitrary URL out of one must not become a link on a page I trust.

Paging reads daily files newest-first and stops the moment the page is full, so it stays bounded in time and memory against a log of any size rather than proportional to total history.

The milestone 013 actions

One new action, and two records that say more than they did. All three exist to answer the same question: the item said dispatching for fifteen minutes and then failed — what actually happened, and when did we know?

Action When Notable detail
dispatch.error An exception escaped the launch The exception, the item’s state at that moment, and settling — whether the item was failed here before the exception was re-raised. A dispatching item is settled at that moment rather than being left for the age reaper; an item past confirmation is left alone, because the state table does not allow it to be moved and the session really is running. Carries dry_run, so a crash on a simulated item does not read as a crash on a real one
dispatch.unconfirmed Changed — as before, when the confirmation window elapses Gains session_state and outcome. outcome is lost when the session recorded nothing, and already_exited when it had already reported its own ending from the daemon’s process while dispatch was still waiting
dispatch.confirmed Changed — as before, on a confirmed launch Gains resumed_from on a resume, naming the session whose context this attempt restored. It is inside launch_argv too, but that is the whole nested wrapper argv with the prompt body in it

outcome is the load-bearing one. “No session ever appeared” and “the session started, failed and told us so” are different failures needing different next steps, and before this they read identically in the log — when the second one appeared at all, which it did not: the contradiction it caused escaped as an unhandled error and the item was never failed.

The milestone 015 actions

No new action, one new counter, and one anomaly that could not previously be raised. All three answer the same question: the queue says repo_cap and nothing is running — what is holding the slot?

Record When Notable detail
state.session Changed — as before, on every session transition A session row is now closed as lost when its work item stops being one that can have a session in flight. The reason names the route: cancellation, abandonment, or the reconciliation sweep. That distinction survives nowhere else, and it is the difference between “the maintainer stopped this” and “this was found stale later”
reconcile.pass Changed — as before, once per pass Gains reclaimed: how many stale session rows that pass closed. Before this, a pass that reclaimed nothing and a pass that could not see the leaked row read identically, both as checked 0
orphan_session anomaly A live worker under a work item that is no longer running one Existing kind, newly reachable. Carries the pid, the working directory, and the work item’s state. The session row is deliberately left open: the slot really is taken, and reporting a count lower than the number of live workers would oversubscribe the very quota the cap protects

The issue #33 actions

No new action and no new anomaly kind. Two counters, and one anomaly that could not previously be raised — all answering the same question: status says the session is running, but its pid belongs to nothing. Why did reconciliation not notice?

Record When Notable detail
state.session Changed — as before, on every session transition A real session is now closed as lost at every effect level whose host is real, not only at live. Previously the sweep was skipped whenever the row was flagged simulated, which is true at no-remote — where the process is real — so a dead worker was never noticed there
state.session A superseded attempt whose process is gone running → lost, with a reason naming which attempt superseded which. That is the difference between “this session died” and “this session was replaced and then died”, and the log is the only place it survives
reconcile.pass Changed — as before, once per pass Gains skipped_never_real and superseded. The first is the important one: the reported defect looked like a clean pass, checked: 2, interrupted: 0, with nothing saying that one of those sessions had not been examined at all
orphan_session anomaly A live worker from an attempt its item has already replaced Existing kind, newly reachable. Carries the pid, the working directory, and both attempt and current_attempt. The row is deliberately left open, for the same reason milestone 015 leaves its own: reporting fewer running sessions than exist would oversubscribe the quota the cap protects

dry_run on a session row means “the effect level was not live”. The question the liveness sweep needs answered is “did this session ever have a process”, and those two disagree at exactly one level. Reconciliation now reads the recorded pid instead, which is written from the session-host boundary and is 0 for a simulated host — so the record answers the question without anyone having to remember to ask it correctly, and without reconciliation learning what an effect level is.

One gap in the record was accepted here and closed by issue #44. When the session registry could not be observed at all, reconciliation read that as every session being dead, and nothing distinguished “observed dead” from “could not observe”. That was pre-existing at live; issue #33 made the two levels behave alike rather than introducing it, and enumerated the omission rather than leaving it silent. The issue #44 record below is what closes it.

A session row occupies a global and a per-repository capacity slot for exactly as long as it is starting or running, and only the wrapper’s exit record used to close it. A simulated session has no wrapper and no process, so that record can never arrive — which is why robot-army cancel on a rehearsal used to hold its repository’s only slot forever, silently, with repo_cap as the queue’s stated reason.

The issue #79 actions

One new action, and it closes a gap that predates the issue it was added for. robot-army worktree remove wrote no record under its own name at all. The only records it produced came from the git boundary — git.remove_worktree and git.delete_branch — which name a path and never the work item, so “what happened when I removed item 21’s worktree” could not be answered without mapping a path back to an item by hand. And a guard that refuses before reaching git would have left nothing behind at all.

Record When Notable detail
worktree.remove New — an intent/outcome pair around every manual removal entity_id is the work item, target the worktree path. The intent carries force and is flushed before anything is removed, which is what the Operating Constraints require of an irreversible action. The outcome carries refused, worktree_removed, branch_deleted and forced_over_live_session
worktree.remove A refusal — a live session, git’s dirty-tree refusal, or a repository that no longer resolves refused: true plus refused_by, which is live_session, git or unresolved_repo. reason is the sentence shown at the terminal, and live_session names the session, its attempt, its state, its pid and how that pid looks from /proc
worktree.remove A forced removal over a live session forced_over_live_session: true. This is the single most destructive thing the command can do, and force: true alone does not identify it — that is equally what forcing past a merely dirty tree looks like. Those are not the same act, so they are not the same record
git.remove_worktree, git.delete_branch Unchanged Absent on a refusal, and that absence is the evidence that nothing was removed. It is what the tests assert on, in preference to a surviving directory — which is also what you get when a removal was attempted and merely failed

A refusal is outcome: "ok", not "error". The vocabulary is fixed to ok/error/ pending, and the precedent is cleanup.considered, which records a skipped decision as ok. Nothing failed here: the command was asked a question and answered it. error stays for a boundary that broke, and audit.action still writes one on its own if anything raises. A KeyboardInterrupt at the confirmation prompt is one of those — see the issue #23 records, which is what makes it say more than the name of an exception.

The live_session detail reports process liveness in one of four words — running, gone, unidentified, unrecorded — and none of them decides anything. The session row is what refuses; the liveness answer is what the operator is told. unidentified is the one worth knowing: a recorded pid with no recorded start time cannot be told apart from an unrelated process that inherited that number, and reporting it as running is the degradation that let a pid of 1 through the termination guard in #69.

Nothing this feature added goes unlogged, and no Principle III exception is claimed.

The issue #59 records

No new action. worktree.remove gains two callers — the path form and purge-simulated — and purge.simulated gains the worktrees it offered. A purge used to leave its rows’ worktrees on disk beyond the reach of any command, and nothing in this log said which directories those were.

Record When Notable detail
worktree.remove worktree remove <path> — a worktree no work item claims entity_type: worktree, entity_id and target the resolved path, detail.by: path. Refusals before git is reached: refused_by is outside_root, claimed (with claimed_by_item), not_a_directory, listing_failed, not_a_worktree or live_worker (with the worker’s pid, cwd, the session_id when the registry named it, and seen_by — the session registry or /proc, which is always enumerated too, so a missing or version-refused registry cannot read as “nothing running”). After git: git or directory_survived. forced_over_live_worker is this form’s forced_over_live_session
worktree.remove purge-simulated removing a rehearsal row’s worktree As the id form — entity_type: work_item — with detail.by: purge-simulated, each pair inside the purge’s own and before its rows are deleted. A session with the simulated host’s signature does not refuse it; any other open session does, as live_session
worktree.remove Any form, when a removal is reported and the directory is still there refused_by: directory_survived, and no git.delete_branch follows. The simulated version-control boundary reports success over a real directory; the record says what happened to the disk
purge.simulated As before The intent gains worktrees (the paths offered) and remove_worktrees; the outcome gains worktrees_removed and worktrees_left. An abandoned prompt carries worktrees too, so the log says which directories were nearly removed
anomaly.resolved An orphan_worktree whose directory is gone or now claimed reason is directory_gone, or claimed_by_item with the item id
reconcile.list_worktrees As before, now also from the orphan sweep Once per distinct failure per pass. The orphan is still reported, with listing_failed: true — a clone that cannot be listed must not hide disk
reconcile.pass Changed Gains orphan_worktrees: orphan_worktree anomalies newly raised this pass

The issue #70 records

No new action. Below local the removal verbs reported a simulated removal in the past tense, while the git.* records beneath them said [simulated]. The record that names the work item now says it as well.

Record When Notable detail
worktree.remove Any form, once git is reached The outcome gains simulated: true when the version-control boundary only pretends. worktree_removed: true, simulated: true reads “the boundary reported the removal, and it was simulated”. A refusal before git carries no simulated, because there was no git step to describe
cleanup.retained Cleanup, when a reported removal leaves the directory on disk state: retained, and reason is the same sentence worktree remove’s directory_survived refusal uses. No git.delete_branch follows. This used to be recorded done

An item’s cleanup_reason reads worktree removal simulated; … when the decision came through a simulated boundary.

The issue #113 records

No new action. worktree remove <id> now leaves a cleanup record on a finished item, and its outcome says so — and says why a removal git answered with “is not a working tree” was recorded as one.

Record When Notable detail
worktree.remove A removal of a finished item’s worktree The outcome gains cleanup_state: done or branch_retained, the value written to the row. Absent for an unfinished item, whose path is cleared instead
worktree.remove The directory was already gone before git was asked worktree_already_gone: true. Git’s refusal once its record is pruned (“is not a working tree”) is not refused — it is about git’s record, and there were no contents to protect
worktree.remove The branch was already gone branch_already_gone: true, and no git.delete_branch follows
worktree.remove The item’s record is done — worktree and branch both gone — and its directory is absent refused: true, refused_by: already_removed, reason naming the recorded state, time and reason. No git.* record follows, and sessions are not consulted. A branch_retained item is not refused: the re-run is how its branch half is finished
reconcile.pass As before prunable_worktrees now counts finished items too, whenever the directory is missing and the cleanup record does not account for it

A manual removal’s cleanup_reason is the worktree’s fate, then the branch’s, then the command that did it — for example worktree directory was already gone; branch deleted — by `robot-army worktree remove`.

The example_config.write action

robot-army example-config renders the commented example configuration. It records only when it writes a file, which is the one thing it can do that changes state outside the process.

Action When What it carries
example_config.write Once per --output invocation, on every path target, the absolute destination; detail.force, whether --force was given; and on success detail.bytes, or on failure detail.error

The target is absolute even when the author typed a relative path. A relative path is only interpretable if you also know the working directory the command ran in, which the record does not carry — and reconstruction from the log alone is the standard.

A refusal is recorded as a failure, not as nothing. Declining to overwrite an existing file writes an outcome of failure carrying error: "file exists", because “I ran it and nothing changed” is exactly the question this log exists to answer. An I/O failure records the operating system’s own message the same way.

The two documented exceptions

Rendering to standard output writes nothing. No file is created, no request is made, nothing outside the process is mutated, so there is no action to reconstruct. Under Principle III’s exception path this is named here and in the feature plan rather than left as an undocumented gap.

The record goes to the default layout’s log directory. This command is routed before the configuration is loaded — it exists to be run on a machine that has none — so a non-default [paths] state_dir cannot be honoured here and the record lands under $XDG_STATE_HOME/robot-army/logs/ regardless.

And a log that cannot be written never fails the write. The file is the point; a read-only state directory must not cost the author their config. The failure is announced on stderr instead, so it is neither silent nor fatal.

The prompt.preview action

robot-army prompt <owner/repo> <number> prints the prompt a dispatch of that issue would compose, and creates nothing. It still writes, because it reaches GitHub and reads a repository’s files, and because a question asked of the system is part of what the record has to be able to answer.

Action When What it carries
prompt.preview Once per invocation, on every path The repository, the issue, the branch and whether it was recorded or derived, which directory the contextual sections were read from, whether each optional section was included, and which delivery form was composed

Keyed entity_type: issue, entity_id: <owner>/<repo>#<number> — the same shape poll.rejected uses for an issue with no row. Refusals carry refused: true and a cause of malformed_arguments, not_onboarded, or issue_unavailable, which is the same distinction the exit code makes.

speckit.detect is written too, by the same function a dispatch uses. When the issue has no work item it is keyed on the repository rather than the row, because a sentinel id would put a claim about work item 0 into an append-only file. component separates the two callers otherwise: daemon for a dispatch, cli for a preview.

delivery is push or local (issue #32). The two flags beside it say whether an optional section was there at all; this one says which of two mandatory forms was used, because the delivery block is never absent and since #32 its content follows the effect level. It is one word rather than the block itself, for the reason the next section gives — and it is the only thing in the record that answers “was that session told to push?”.

The same question about a dispatch is answered a level up: daemon.start’s boundaries map carries delivery alongside the implementation each seam got, so the startup record says what every session that daemon launches will be told.

uv run robot-army log --limit 20 | grep daemon.start

What a preview does not record, and why

The composed prompt itself. Not the text, not the issue body, not the contents of .claude/robot-army.md, not the Spec Kit block. This is the same gap dispatch already has and already justifies — the log has never reconstructed a composed prompt — and closing it here alone would leave the record describing the rehearsal in more detail than the performance, while writing up to 60,000 characters of issue body per invocation. A unit test asserts the gap stays open.

A successful issue read. GitHubReader logs every retry and every failure but takes the aggregate-read exception for successes, as it has since milestone 001. The prompt.preview record names what was asked for and whether it was answered, which is what the reconstruction standard needs.

One invocation shape goes entirely unrecorded: a non-numeric issue number, which argparse rejects before the log is open. Nothing was read and nothing was reached, so there is no action to reconstruct — that is a malformed invocation, not an event.

The issue #23 records

Four commands stop and ask before acting. Giving up at the question — Ctrl-C, or a stdin with nothing in it — is now a recorded result rather than a traceback, at all four.

Record When Notable detail
repo.onboard Onboarding abandoned at its approval prompt refused: true and cause. Unchanged — milestone 011 wrote this one, and issue #23 is the other three catching up
worktree.remove A forced removal abandoned at the typed-id prompt The intent was already flushed before the question blocked, carrying force: true and the worktree path as target. Its outcome is now error with abandoned: true and cause — so the pair says a forced removal of that path was attempted, abandoned, and never happened. It used to say EOFError and nothing else
session.cancel New — a cancel abandoned at its prompt entity_id is the session, and the detail carries abandoned, cause and item_id
purge.simulated A purge abandoned at its prompt abandoned: true, cause, and the counts the question quoted — so the log says what was nearly deleted, not merely that something was

cause is one of two words, and they are kept apart deliberately:

cause What happened Exit
interrupted_at_prompt Ctrl-C. The maintainer read the question and walked away 1
no_answer_available stdin was at end-of-file. Nobody was there to ask — a pipeline, a cron entry, < /dev/null 4

“I changed my mind” and “this ran somewhere with no maintainer attached” are different things to find in a log, and different things for a shell script to branch on. Neither is ever read as consent, including at the force-removal prompt, where the expected answer is a typed item id rather than y.

# every question anyone gave up on, and why
jq -r 'select(.detail.cause == "interrupted_at_prompt" or .detail.cause == "no_answer_available")
       | "\(.ts) \(.action) \(.entity_id) \(.detail.cause)"' \
  ~/.local/state/robot-army/logs/audit-*.jsonl

session.cancel appears only here. A cancel that goes through is already reconstructable from session.terminate and the session and work-item transitions, so it never needed an intent/outcome pair of its own; an abandoned one has none of those to be read from. The asymmetry is deliberate rather than an oversight. Declining cancel or purge-simulated — typing n — writes no record either, for the same reason: a command that ran and changed nothing is what the absence of a record already says. The other two prompts are not like that, and already were not before issue #23: onboard records a decline as aborted_at_prompt, and typing the wrong item id at the force prompt sets aborted on the worktree.remove outcome that is open by then.

The issue #44 record

The record of what a reconciliation pass declined to do. Nothing distinguished a pass across an idle machine from a pass across a registry that had moved, so the mass interruption the second caused reconstructed afterwards as a genuine finding.

Record When Notable detail
reconcile.pass Changed — as before, once per pass Gains liveness_withheld, and directory_missing alongside the degraded, unknown_versions and unreadable the scan summary already carried
registry_unobservable anomaly The first pass that could not read the registry The reason — which of the three conditions it was — and liveness_withheld. Raised at the end of the pass, because that count is only final once every guarded sweep has run
anomaly.resolved The first pass that could read it The kind, and the reason: the observation failure the anomaly reported no longer holds

Three passes used to write the same line, and the two new fields separate them:

directory_missing / degraded / unknown_versions liveness_withheld The pass was
all clear 0 ordinary — an idle or healthy machine
any set 0 blind, but nothing was running to withhold a conclusion about
any set > 0 blind, with work in flight that was deliberately left alone

No record is written per withheld decision, and that is this feature’s one documented Principle III omission. Three open rows on a blind machine would put ~4,320 records a day into the log, each carrying one bit, and the condition is re-derivable from the registry at any instant. _retire_finished_sessions declines to record its own “not yet” for the same reason. The one caller outside the pass is abandon, which writes no pass summary — so it says in its own output that it left a session row open, and why.

The issue #60 record

No new action. github.poll still writes one aggregate record per repository per poll, but it now says whether the stored ETag was sent. That matters because the first poll after [github] label changes is a full 200 instead of the usual 304, and a reader should be able to tell why from the log alone.

Key When Value
etag_sent always true if If-None-Match was sent
etag_discarded always null if nothing was dropped. request_changed if a stored ETag belonged to a different request. request_unrecorded if a stored ETag had no request recorded with it (a row from before migration 015)
request only when something was dropped the request line that was sent
etag_request only for request_changed the request line the dropped ETag belonged to

The first poll after the label went from robot-army-verify back to robot-army:

{"action":"github.poll","outcome":"ok","entity_type":"repo","entity_id":"jantman/robot-army",
 "detail":{"status":200,"etag_hit":false,"items":9,"rate_limit_remaining":4987,
  "etag_sent":false,"etag_discarded":"request_changed",
  "request":"/repos/jantman/robot-army/issues?direction=desc&labels=robot-army&per_page=100&sort=updated&state=open",
  "etag_request":"/repos/jantman/robot-army/issues?direction=desc&labels=robot-army-verify&per_page=100&sort=updated&state=open"}}

The two request lines appear only when an ETag was dropped, so the minute-by-minute steady state stays a short line. Neither can hold a credential: they are built from the path and query only, never from headers. What the column holds and why it has no backfill is covered under state.

The issue #62 records

A queued item whose issue does not carry [github] label is held rather than dispatched (when the label changes). Working out the hold writes nothing, as with every hold reason. These records show where the hold came from and what released it.

Record When Notable detail
poll.labels_refreshed A poll lists the issue of a ready item whose stored labels are a different set old and new label lists, target the owner/name#N. old is null when the stored value could not be read. The same labels in another order write nothing, so the steady-state poll stays silent
daemon.label_warning Startup, before any dispatch, when one or more ready items do not carry the configured label outcome: error, like daemon.config_warning. label, count, item_ids, and the one-line warning. At most one per start
dispatch.at_capacity Unchanged reason may now be not_labelled, with detail naming the label. It is recorded when a pass dispatches nothing, the same as any other per-item hold

The issue #64 record

onboard asks GitHub one question: does owner/name exist, and who owns it. That one request is what SC-009 promises, one lookup and never a walk over every repository the author owns. Until issue #64 it left no record. The only way to confirm the promise was to read the source, and a later change to enumerate would not have shown in the log.

Each lookup now writes exactly one github.get_repo record, whatever the result:

outcome When Detail
ok GitHub answered 200 method, path, status: 200, exists: true
ok GitHub answered 404 the same, with status: 404 and exists: false. A missing repository is an answer, not a failure. The refusal it leads to is the repo.onboard record that follows, with cause: no_such_repository
error the lookup failed: any other error status, or no connection after the retries method, path, status (null when no response arrived), error_type, error. github.retry and github.request are still written for the attempts, and repo.onboard follows with cause: source_unreachable

The record is per lookup, not per HTTP attempt: a lookup that is retried and then succeeds writes its github.retry records and a single github.get_repo. path is the path alone, built from the repository key. It carries no host, no query string and no header, so it cannot hold a token.

The check from the 005 quickstart now works as written, one line per onboarding attempt that reached GitHub:

robot-army log --since 10m | grep -c 'github.*"/repos/'
… github.get_repo [ok] repo:jantman/typoed-nmae  {"method": "GET", "path": "/repos/jantman/typoed-nmae", "status": 404, "exists": false}
… repo.onboard [error] repo:jantman/typoed-nmae  {"refused": true, "cause": "no_such_repository", …}

A path other than /repos/<the key you typed> in that output would mean onboarding had started enumerating.

The issue #179 records

reset discards a checkout, re-reads the issue and requeues the item. Three of its four records already existed — the removal’s own pair, and the state transition — so what is new is the pair around the whole verb, plus the two the shared read path writes under reset’s name instead of retry’s.

Record When Notable detail
reset An intent/outcome pair around every reset entity_id is the work item, target is <repo>#<issue>. The intent carries force and the state the item was in, and is flushed before anything is destroyed, which is what the Operating Constraints require of an irreversible action. The outcome carries worktree_removed, branch_deleted and requeued, plus refused_by when it stopped
reset.blocked A local condition refused it before any network read blocked, the blocker’s own sentence. This record existing without a reset.evaluate beside it is how the log says GitHub was never asked
reset.evaluate After the live read eligible, reason, author, and refreshed — the columns the read rewrote. On a read that failed: cause, which is issue_unreachable or issue_absent, never one value for both, because “it does not exist” and “I could not ask” are different facts
worktree.remove, git.remove_worktree, git.delete_branch Unchanged A removal is what happened, and it is recorded under its own name. reset calls the command whole rather than reaching past it, so every refusal above is reachable from a reset and reads identically
state.work_item Unchanged from is interrupted, awaiting_review or failed; to is ready. columns shows failure_reason and blocked_reason cleared

reset.blocked and reset.evaluate are written by the same function that writes retry.blocked and retry.evaluate, with the verb supplied by the caller. The records are otherwise identical because the question asked is identical — which is the point of sharing the code rather than the wording.

A whole reset therefore reads, in order:

reset [intent]  →  reset.evaluate  →  worktree.remove [intent]  →  git.remove_worktree
  →  git.delete_branch  →  worktree.remove [outcome]  →  state.work_item  →  reset [outcome]

and a refused one stops at the record naming the guard that refused, with the reset outcome carrying refused_by.

Reconstructing an item’s history

robot-army log --item 42

Reading top to bottom gives: discovered → eligibility verdict → worktree prepared (with each preparation step’s exit code and duration) → launch argv → confirmation or its absence → exit code and signal → final state. Everything needed to answer what happened, when, to what, and with what result, without re-running anything.

The database is the other half of the picture and is directly inspectable:

sqlite3 ~/.local/state/robot-army/state.db 'SELECT id, state, failure_reason FROM work_items'