The lifecycle runs backwards too — pull, and a board that cannot be lost
The chapter that gave a board a way home, and then made it hard to lose.
milestone ms-392423 · four cards · four workers · branch ms/the-lifecycle-runs-backwards-too
written by agent:berna/close2 on tk-648a6c, 2026-08-10 · every figure below is re-derivable
What it set out to do
A board's life ran one way. taskops board create made one on a
host and taskops board push promoted a local history into it — and
that was the end of the road. Nothing brought a board back down, and
nothing took one off a host. The only removal that existed was
ssh plus rm -rf on a directory.
Two things were wrong with that, and the second is the serious one. The first
is a contradiction: the design's own line is that after the bootstrap, nothing on
a host is ever administered over a shell, and here was an act — arguably the
largest act — that could only happen over one. The second is that the act was
unguarded. A board is its events.jsonl; that log is the only
copy of a history nobody can regenerate, and rm -rf asks nothing
of anybody.
So the chapter had a shape before it had cards: build the guardrail first, and build the way home before the way out. Destroying a history had to stay possible — a system whose only door is its own API cannot be repaired when that API is what broke — but it had to become a sentence somebody typed on purpose, never a side effect of anything.
taskops init board create+push board pull board rm
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ a LOCAL │ ───▶ │ LIVE on the │ ───▶ │ a SNAPSHOT │ ───▶ │ off the host │
│ board │ │ host │ │ back in here │ │ altogether │
└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘
destroys destroys nothing: destroys nothing: DESTROYS the host's
nothing the local board is the host keeps its board — the only step
RENAMED to .taskops/ board byte for byte in the whole diagram
board.local-<date> and goes on moving that destroys anything
Five acceptance criteria, and the four cards were cut against them:
board pull <name>materialises the hosted history locally, proves every event id arrived, flips the config last, and leaves the host untouched — verified by reading the host after;- an interrupted pull re-runs to a no-op and never leaves a checkout half pointed at two boards;
board rm <name>on a board this checkout has not pulled refuses, and its sentence names the two commands that make it possible;- after a pull,
board rmsucceeds, the host no longer lists the board, and the local copy still answers every read; - a removed board cannot be re-created into the same history by accident:
board createon that name makes an EMPTY board, andboard pushinto it still refuses if it is not empty.
What each card actually built
tk-b68686 — the seam: "is this history held here?", by id
src/taskops/core/holding.py — 100 lines, layer 1, pure. Ids in,
judgement out: no path, no store, no clock, no network. One function,
compare(theirs, mine), returning a Holding that is
computed and never stored — complete, theirs,
mine, missing, and up to three of the missing ids in
sorted order. A second function, phrase(), renders the gap in
words; the sentence around the gap belongs to whichever caller is
refusing, exactly as core/reports.py::under() leaves its wording
to its callers.
It landed alone, before either command, because both of them ask the same question and a concept named by two cards is a seam. Had they each answered it for themselves, "safe" would have meant two things by the end of the day.
tk-1f721b — taskops board rm: the guardrail is the whole card
A board.remove admin verb in http/removal.py
(106 lines), server scope, OWNER only, registered beside
board.visibility in http/admin.py with one new
Operation in core/scope.py — same table, same role gate, no second
door. cli/rm.py (63 lines) is the client half: it reads the event
ids this checkout can still see out of
.taskops/board/events.jsonl through store.log.read,
so a quarantined line does not count as held, and sends them.
The judgement is made on the HOST. The refusal names both ways out, in the
sentence family join's orphan refusal established. The flag past
it is --discard-history and only a literal true opens
it — "false" is refused rather than obeyed, and
force is not a synonym. The answer a human gets back says what
was removed and how many events went with it, read from the board
before the delete: it is the only record of the removal that will ever
exist, because the board is the log and the log is gone.
tk-069df3 — taskops board pull: push's five steps, reversed
cli/pull.py (162 lines) is the ORDER;
cli/paging.py::whole_log (75 lines) is the transport — it pages a
board's whole log down through the events verb that already
existed, keyset by seq, verifying each event's id against its own
content on arrival. Two small shared seams came out of it:
core/event.of, the one coercion, split out of
from_line so a caller holding a parsed object reaches it without
re-serialising a dict to have it parsed back; and
gitwork/install.write_local, now shared with taskops
init, which carried the same two lines inline.
The command prints what a pulled board is, every time, rather than
leaving it in a document somebody read once — a snapshot that stops moving.
remote.json is left completely untouched, which is what makes the
pull → rm sequence work with no flags at all: the host
is still where board create, board push and
board rm go.
tk-648a6c — this close: the lifecycle drawn whole
README gains the lifecycle as one picture with a "what does this destroy"
line under every step. ARCHITECTURE gains §19, which argues the three things a
reader would otherwise re-litigate and writes down the bug below so it is
findable. CLAUDE.md gains the CLI surface with both lists derived from
--help rather than copied. And the five criteria were run against a
real socket to write this page.
The decisions that were argued
By ID, never by a count — and the count still has a job
The seam takes sets of ids, and that is a scar rather than a
preference. cli/push.py carries the post-mortem in its own docstring:
it compared totals, and totals per kind, and a total that agrees says
nothing about which events arrived — two logs of 402 events can share
none of them. An event id is sha256 of the event's own canonical
bytes, so "did this exact event arrive" is set membership, and a re-run is a
no-op instead of a duplicate.
The direction is theirs ⊆ mine, not equality. Ids held only here
are not a fault and are not counted: the question both callers ask is
would anything be LOST, never are these identical. And an empty
host history is complete, because there is nothing there to lose —
theirs rides along on the answer so a caller surprised by that can
say 0 out loud instead of guessing.
What the pull card then found is that the count is not redundant after all,
and the two checks do not substitute for each other: total guards the
paging, because a page silently lost shrinks both sides of an id
comparison and makes it agree with itself; the id set guards the
write. Drop either and there is a way to be wrong quietly.
The step-1 guard had to recognise its own interrupted retry
Step 1 of a pull refuses a local board that would be merged into the incoming one. The obvious way to write that is "is there a local board here?" — and it is the wrong guard, in a way that only shows up on the second run.
A pull that dies after step 3 leaves exactly that: a local board, half written,
and a config that never flipped. The naive guard would make the retry
refuse itself, and the checkout would sit between two boards with no
command in the system able to finish the job. What must actually be refused is a
local history the host does not hold — that one really would be
two histories in one log, with an order they never had. So the same
holding.compare decides, asked the other way round, and a partial copy
of the board being pulled is recognised as the resumed pull it is. Step 3 then
writes only what is missing, because Stores.write appends everything
it is handed to the log while the cache ignores a repeated id.
The general shape: a guard on "does this state exist" cannot tell a half-finished run from a conflict. A guard on "whose state is this" can.
Possession, not a confirmation prompt
Every other tool would have put are you sure? [y/N] here. It was
never on the table, and the reason is worth saying plainly: a prompt asks
whether you meant it; possession asks whether the history survives you.
Those are different questions, and only the second one is about the thing at
risk. Somebody who typed board rm facturador meant it — that is
precisely the state a prompt is answered yes in — and none of that
establishes that the 402 events exist anywhere else.
It also could not have been a prompt mechanically: the removal happens on the
host, at the far end of an RPC, in a process with no terminal. A prompt would
have had to live on the client, which is the weakest place in the system to put a
wall — and a prompt cannot be satisfied from a script, which is how a
--yes flag gets born and how it ends up in somebody's Makefile.
The judgement lives on the HOST, and the flag names what it destroys
A client-side wall is a convention a hand-written call can skip.
If cli/rm.py had compared for itself, curl would be the
way past the only wall in taskops standing in front of something irreversible, and
nothing would have been refused, logged, or even surprised. So the client says
what it holds and the host — which owns the data — decides:
holding.compare(stores.ids(), held). A client that fetched the host's
ids to judge for itself would be re-deciding a wall it does not own.
The way past is --discard-history, and --force stays
banned with its own row in ARCHITECTURE §11. Every tool spends --force
on something recoverable, so the word names no consequence and therefore cannot
warn. This is the last command in the system that should be vague about what it
takes away.
removing 'facturador' would destroy a history nothing else holds: 5 of the host's 5 event(s) are not here (this copy holds 0) — e.g. 225fc3e6…, afbad07b…, c086c9f1…, …. Two ways forward, and both are explicit: taskops board pull facturador take the history down first, then remove taskops board rm facturador --discard-history destroy it anyway; say so out loud (there is no --force: a flag that does not name what it overrides is how somebody destroys a history they meant to keep.)
The two bugs found on the way
1. sqlite keeps answering through an UNLINKED file
Mounts caches a Stores per board for the life of the
process. So shutil.rmtree on a board directory was not the
end of that board: the handle the running server still held went on serving the
destroyed cache, and board.create on the same name a moment later
handed the destroyed history straight back. A push into the
supposedly new board was then refused as two histories — which is to say the
removal had become a route around the exact wall board.ingest exists
to hold, arrived at from behind.
The fix is one call in the right order: Mounts.forget(name) drops
the handle and the watch before the rmtree. It belongs in
mounts.py because forget is create's
counterpart and _boards lives nowhere else.
Why it generalises: a cache keyed by a path outlives the path. Anything here that deletes a directory a long-lived process has opened has the same bug waiting, and neither a type checker nor a test of the answer will see it — the answer is correct. It is just the correct answer to a question about a file that no longer exists.
2. A mutation survivor: blank ids were only pinned on one side
The seam strips blank ids from both sides of the comparison —
the empty string is not a sha256, it is what a truncated wire row
degrades into, and counting one would make a correct board read as incomplete
forever. Mutation-checking that code one site at a time turned up a survivor: the
strip on the local side could be deleted and the suite stayed green,
because the test only put blanks on the host's side.
The test was extended to put blanks on both, and the mutation died. This is the project's own house rule earning its keep — a batch mutation proves something is pinned, never which — and it is the second time in two chapters that a green suite was hiding an unpinned half.
The criteria, run for real
Not a summary of the tests: a real host on a real socket, a real ed25519
keypair, real argv through taskops.cli.main.main, on four throwaway
repos. Every step below is the command's own output.
| # | what ran | what it proved |
|---|---|---|
| 1–2 | init → plan → board create → board push |
4 local events promoted; the host holds 5 (its birth certificate plus the four); the local board renamed to board.local-2026-08-10, not deleted |
| 3 | board rm from a checkout that never pulled |
refused, naming both ways out — criterion 3 |
| 4 | board pull |
5 events written; ids equal host-to-local; the host's bytes unchanged; board.json now {}; remote.json keeps its login — criterion 1 |
| 5 | board pull again |
0 new events, the log byte-identical — criterion 2 |
| 6 | board rm, now that the history is held |
gone, "5 event(s) went with it"; the host's root holds zero boards; board ls says 0; the local copy still answers board with its milestone and both ready cards — criterion 4 |
| 7–9 | board create on the removed name, then two pushes |
the reborn board holds 1 event — its birth certificate — and shares nothing with the destroyed history; a first push lands, so the name is genuinely reusable; a second, different history is refused as two histories — criterion 5 |
### 4 · board pull
reading http://127.0.0.1:56578/facturador …
5 new event(s) written · all 5 event(s) are held here
…/elsewhere/.taskops/board.json now reads the local copy in …/elsewhere/.taskops/board
http://127.0.0.1:56578/facturador still holds every one of those events — a pull destroys nothing,
and remote.json keeps its login: board create / board push still go there.
This copy is a SNAPSHOT and it STOPS MOVING: nothing syncs from here on, so
anything done on http://127.0.0.1:56578/facturador after this second will never appear in it.
by id, host vs local: True
the host's bytes are unchanged: True
### 6 · board rm, now that the history is held here
facturador is GONE from http://127.0.0.1:56578 — 5 event(s) went with it
all 5 event(s) are held here — this checkout still reads them, and it is now the copy
the host's root now holds: []
http://127.0.0.1:56578 — 0 board(s), as owner
the local copy still answers: milestones=['MVP facturador']
ready cards=['invoice model', 'CSV parser']
What is not a sync, and why the ban is what made this safe
Git replication between clones is banned — §11's second row, the one whose cost was split-brain and two machines "owning" the same card. A command that brings a hosted history down looks exactly like that ban being broken, and it is the opposite: the ban is what made it safe to add.
Nothing new was built to move the events. The log comes down through the
events verb — the same paged read the dashboard's Event pane makes,
by a client with no more rights than any reader — and
test_pulling_added_no_verb_to_either_registry asserts that
board.pull is not a verb this host answers. No cursor is stored on
either side, no second run is scheduled, nothing is kept in step. What lands is a
snapshot: a card taken on the host a second later is never in it, and no mechanism
anywhere would notice the divergence. Replication is a channel with an ongoing
promise. This is one read, verified, that ends.
What this close could not dogfood
The reports loop is four moves: read the chapter with
taskops_activity, write the file, commit it, register it with
taskops_filed. The chapter that built those two tools could
not run steps 1 and 4 — they were on its own branch, and the session's MCP server
runs the installed taskops, loaded once at session start.
That was written down as a structural finding rather than a defect.
It happened again here, from the other side, and it is worth recording because
the second occurrence is what turns an anecdote into a rule. The reports chapter
has landed on master — taskops_activity and taskops_filed
are in this repo's src/, in mcp/schema.py, right now —
but the installed tool on this laptop predates them, so they are absent from
tools/list and unreachable to every actor in this session, the
orchestrator included. Driving the board from a shell instead is refused, and
correctly so.
So this page was assembled from taskops_card on each of the three
cards — the same threads activity depth=full folds — plus
git log --numstat, and its registration belongs to the session after
ms-b9bf00 puts master on the host and the tool on the laptop. The
reader's half was proved the way the previous chapter proved it:
http/gitdoor.py::answer(), the call the dashboard itself makes, reads
this file back at its commit.
The rule that generalises: a chapter can dogfood any tool the
installed taskops already has, and no chapter can dogfood one
it introduced. Verification of a tool belongs to the session after the landing,
not to the close that lands it.
The read that proves this page renders
gitdoor.answer(repo, "file/<sha>", "path=.taskops/reports/lifecycle-chapter.html")
— the same call the dashboard's Reports tab makes, against this worktree's own
clone, at the commit that carries the file:
path : .taskops/reports/lifecycle-chapter.html rev : <the close commit, resolved to a 40-hex sha> content_type : text/html truncated : False cap: 524288 len(text) : ~26.5k characters first line : <!doctype html> last line : </html> lines equal to the committed bytes: True
One honest detail found doing it: the door's text is the file
stripped — gitwork/run.py::tool calls .strip()
on every subprocess's stdout, so a served report loses its leading and trailing
whitespace, here exactly the one trailing newline. Invisible for HTML, and every
line is identical; but a text/plain report that ever depended on
trailing whitespace would not survive the trip, and that is the sort of thing
worth knowing before somebody debugs it.
The gates, from the close's worktree
| gate | result |
|---|---|
uv run ruff check src tests | All checks passed! |
uv run pyright | 0 errors, 0 warnings, 0 informations |
uv run pytest | 522 passed |
cd ui && npm ci && npm run check | typecheck + build + smoke ok, committed bundle unchanged (no .tsx moved in this chapter) |
Module budgets are the thing to re-derive rather than trust, and this chapter
left two files with almost none: http/mounts.py at 198 and
cli/main.py at 198 of 200, with core/types.py at exactly
200. The next thing that lands in any of them splits it, at a cohesive seam and
never by relaxing the rule.
find src/taskops -name '*.py' -exec wc -l {} + | awk '$2!="total" && $1>=190' | sort -rn
commits: b8f3178 (seam) · c793690 (rm) · 86c535b (pull) · this close
tests added: 7 in tests/test_core.py, 11 + 9 in tests/test_topology.py — counted from the diffs
ARCHITECTURE.md §19 carries the arguments · README carries the picture · CLAUDE.md carries the surface