Chapter 6 of 9 · 8 min read
What the blackboard must tell on-call at 2am without logs
On this page
The page fires at 02:14 with run_c118: no output for 20m. The engineer on call did not build this system, has not read its code, and is looking at the artifact for the first time. This is what the file gives her:
{
"run_id": "run_c118",
"version": 88,
"updated_at": "2026-04-02T01:51:16Z",
"tasks": [
{"id": "parse", "status": "done", "owner": null,
"output": {"uri": "s3://intake/c118/parsed/", "count": 61}},
{"id": "clause", "status": "in_progress", "owner": "agent-clause-2f9@i-04bb",
"owned_since": "2026-04-02T01:38:02Z", "lease_expires": "2026-04-02T01:48:02Z",
"output": {"uri": "s3://intake/c118/clauses/", "count": 61}},
{"id": "risk", "status": "in_progress", "owner": "agent-risk-71c@i-04bb",
"owned_since": "2026-04-02T01:44:31Z", "lease_expires": "2026-04-02T01:54:31Z",
"output": null},
{"id": "report", "status": "blocked", "owner": null, "output": null}
]
}Everything from the previous four chapters is present. Ownership is separate from status, leases have expiries, outputs are pointers with counts, and the version counter is climbing. The document is correct. It is also insufficient, and working out why takes her nine minutes and three log searches.
The clause task shows 61 of 61 outputs written but status in_progress, which could mean the agent finished writing and died before updating status, or that the count is a running total and there are more to come. Both leases have expired — 01:48 and 01:54, and it is now 02:14 — so either the reclaim mechanism is broken or nothing is running that would reclaim them. Both agents were on instance i-04bb, which suggests one host went away and took both with it, but the document does not say that; she inferred it from the identifier format. And having concluded that two tasks are orphaned, she has no idea what to do about it, because nothing in the file says how a task is returned to the pool or whether it is safe to do so while an agent might still be alive.
Four questions, and the fields that answer them
A shared artifact earns its place in the design when an engineer with no context can answer four things from it alone. Everything that follows is downstream of those four.
| Question | What answers it | What does not |
|---|---|---|
| Who owns each outstanding item, and for how long? | owner, owned_since, heartbeat_at, plus the expected duration to judge it against | An owner field with no clock, or a clock with no baseline |
| What was the last thing that actually worked? | A last_transition record naming what changed, when, and by whom | updated_at, which moves on every heartbeat and says nothing about progress |
| Why is nothing moving now? | blocked_on with a reason code and a target | An absence — a task sitting in in_progress and the reader inferring |
| What can I do about it? | A recovery field naming the available action and its safety condition | Institutional knowledge held by whoever is on holiday |
The first row's right-hand column is the one people leave out. Knowing that agent-clause-2f9 has held a task for thirty-six minutes is only actionable if you know whether thirty-six minutes is normal, and at 02:14 nobody is going to open a dashboard to find out. Carrying the expected duration in the document — p95_seconds: 240 — turns a timestamp into a judgement anyone can make. It costs one integer per task and it is what converts "this looks stuck" into "this is nine times the ninety-fifth percentile".
The second row is where updated_at misleads. In a design with lease heartbeats, the document is written every thirty seconds by every live agent, so updated_at is always recent and always meaningless. What the reader needs is the last event that represented progress, held separately from the last event of any kind.
Here is the same run with those fields present:
{
"run_id": "run_c118",
"version": 88,
"updated_at": "2026-04-02T01:51:16Z",
"last_transition": {
"at": "2026-04-02T01:44:31Z",
"what": "risk: pending -> in_progress",
"by": "agent-risk-71c@i-04bb"
},
"tasks": [
{"id": "clause", "status": "in_progress", "owner": "agent-clause-2f9@i-04bb",
"owned_since": "2026-04-02T01:38:02Z", "heartbeat_at": "2026-04-02T01:51:16Z",
"lease_expires": "2026-04-02T01:48:02Z", "p95_seconds": 240,
"progress": {"done": 61, "total": 61},
"blocked_on": {"reason": "lease_expired", "detail": "no heartbeat for 23m"},
"recovery": {"action": "release", "safe_if": "fence < current", "attempts": 1,
"cmd": "runctl release run_c118 clause"}}
]
}progress with an explicit total removes the ambiguity about 61 of what. heartbeat_at at 01:51 against a lease that expired at 01:48 says the agent was alive three minutes past its own expiry and then stopped, which is a different story from an agent that died at 01:40 and is worth telling apart. blocked_on states the conclusion rather than leaving it to be derived. And recovery names the action, the condition under which it is safe, and the exact command, so the person at 02:14 does not have to reason about whether releasing a lease can cause the duplicate-completion problem — the fencing rule is written next to the button.
The action is the field everyone hand-waves
Of the four questions, the last one is the one that gets designed last and worst, because the people writing the schema know the answer and never have to look it up. A recovery field is only useful if the set of actions is closed and each one carries the condition under which it is safe, since an engineer at 02:14 is being asked to do something irreversible to a run she has known about for six minutes.
The pipeline in this chapter has five actions and no others, which is itself part of the design; an open-ended "do whatever seems right" field is the same as no field.
| Action | Offered when | Safe if | Cost when wrong |
|---|---|---|---|
release | Lease expired, no heartbeat | The holder's fence is below the current fence, so its late writes will be rejected | Duplicate work, one stage's compute |
retry | Task is failed and attempts is below the cap | The task's writes are idempotent under the identifier scheme from chapter 4 | Duplicated outputs if the identifier was not honoured |
skip | Downstream consumers declare the input optional | No task lists this one in inputs.from_task as required | A silently incomplete result, which is the failure chapter 3 opened on |
abort | The run has exceeded its budget or deadline | Always; it writes a terminal status and stops claiming | Work that would have finished is thrown away |
escalate | None of the above applies | Always | An engineer woken up for nothing |
Three properties of that table matter more than its contents. The conditions are machine-checkable, so the render evaluates them and offers only the actions currently safe, rather than printing all five and asking the reader to reason about fencing at two in the morning. Every action is expressible as a change to the document, not as a side effect somewhere else, which keeps the artifact authoritative — runctl release writes owner: null and increments the fence, and that is the whole operation. And escalate exists so that the absence of a safe action is itself a stated outcome; a document that offers nothing has told the reader something, whereas a document with an empty recovery field has told her the schema did not think about her case.
The condition text is worth writing for a stranger rather than for the author. "Safe: fence 12 < current 12, output complete" is a sentence someone can act on without having read chapter 5, because it names the check and its result rather than the concept behind it.
Rendering, not just storing
The document above is 1.9 KB per task and a fourteen-task run makes it 26 KB of JSON. Nobody reads 26 KB of JSON at 02:14. The artifact being machine-readable is what makes it possible to render, and the rendering is what makes it legible:
$ runctl show run_c118
run_c118 v88 started 01:31 last progress 01:44 (30m ago) ⚠ STALLED
TASK STATUS OWNER HELD p95 PROGRESS
parse done – – – 61/61
clause in_progress agent-clause-2f9@i-04bb 36m ⚠ 4m0s 61/61
risk in_progress agent-risk-71c@i-04bb 29m ⚠ 6m0s 0/61
report blocked – – – –
BLOCKED
clause lease_expired no heartbeat for 23m
risk lease_expired no heartbeat for 30m
RECOVERY
runctl release run_c118 clause # safe: fence 12 < current 12, output complete
runctl release run_c118 risk # safe: fence 13 < current 13, no output written
NOTE both owners on i-04bb — check host before releasingThat took eleven seconds to read and answered all four questions. It is the same data; the difference is that the render computes the derived quantities the reader would otherwise compute in her head — elapsed time against p95, heartbeat age, the shared instance identifier — and puts the recovery commands where the diagnosis is.
Building this is a couple of hundred lines and it is routinely skipped, because the JSON is technically sufficient and the person who built the system can read it fluently. The people who page at 02:14 are not that person, and the whole argument for the shared artifact is that somebody other than its author can act on it.
The pressure that ruins the document
The failure mode here is not minimalism, which is easy to spot and easy to fix. It is accumulation. Once the artifact is the thing everybody opens, every debugging session ends with someone adding a field, and the fields that get added are the ones that were useful once: the prompt that was sent, the model's raw response, the retry history, the token counts, the tool call arguments. Each is defensible on its own and the aggregate is a document nobody can read.
The version of run_c118 that exists a year later is 340 KB, and the on-call engineer scrolls through 4,000 lines of embedded model output looking for the ownership fields. It answers all four questions and it answers them worse than the 26 KB version did, because legibility is a property of the whole document and not of any field in it. A working discipline is that the artifact holds coordination state and pointers, and everything that is content lives behind a URI: output.uri, last_error.log_uri, prompt_uri if it must exist at all. The document then stays at a size where a render fits on one screen, which is the actual requirement.
What the artifact is not for
It does not replace logs, and the requirement in this chapter is not that logs are unnecessary. The four questions are all about coordination — who has what, since when, what moved last, what can be done. None of them is "why did the risk agent produce an empty clause list from a document that clearly contains clauses", and no amount of schema design will answer that from the board. That is a content question, it lives in the agent's trace, and the artifact's job is to tell you which agent's trace to open and for which task, which is a large saving over having no idea.
It is also not an SLA or a monitoring system. A service-level agreement is a stated promise about how the system will behave — a run finishing within thirty minutes, an incident acknowledged within five — and monitoring is the machinery that watches for the promise being broken; the artifact is neither, because it makes no promise and watches nothing. A stalled run is visible in the document to whoever opens it, and somebody still has to be told to open it. The document supports the diagnosis; the alert that fired at 02:14 came from a separate check comparing last_transition.at against the current time, and that check has to exist independently. A team that puts everything into the artifact and no alerting around it has built something that would have explained the outage beautifully, had anyone looked.
There is one more thing this chapter has not established, and it is the weak point of the whole argument. Everything here assumes that a document a reader can act on quickly is a document a reader acts on correctly, and those are separate claims. The 02:14 render is fast, tidy, and entirely a statement about the present; the transitions that produced it are not in it. Whether that combination actually improves diagnosis is a measurable question, and it is worth measuring before the migration is justified on the strength of it.
Blackboard / shared-artifact architectures — read the current version at https://daviddominguez.dev/learn/en/blackboard-shared-artifact-architectures/what-the-blackboard-must-tell-on-call-at-2am-without-logs