Skip to content
Go to console
Go to console

A write that went over an edit writes it back, and the walk looks through it

Decision record 0119

Amends 0004 (a write that stuck is checked against the edit history, and one that went over an edit is tried again from that edit), 0025 (the stretch looks through the bot’s write that went over a tick) and 0017 (every write that sticks costs one GraphQL query). For issue 291.

Found in the acceptance walk-through of 2026-10-06, from the homelab dashboard’s edit history. On 2026-09-25 the owner ticked six boxes within about 20 seconds. The resolve run that an earlier tick started read the body before the last tick landed and wrote its body about 5 seconds after it. The write replaced the body, so the tick was gone. The resolve run that the last tick’s own event started then read the live body and logged No box is ticked. Nothing to do. No deployment record, no note, no comment. The owner found it the next morning and ticked again.

The write loop of 0004 reads back after every write, and the read back catches a write of ours that did not stick. It does not catch the opposite: our write stuck, and it went over a person’s edit that landed between our late read and our write. GitHub has no compare-and-swap for issue bodies. 0004 says that a lost write heals itself, and that is true of a row a writer renders from facts. It is not true of a tick: a tick lives only in the body, and nothing ever looks for it again. The window is not small. A builder makes its own late reads, the deployment records and the attribution walk, between the read and the write, which is where the 5 seconds went.

The edit history (0025) still holds the edit. GitHub keeps one entry per edit with the whole body right after it, so the entry right before our own write is the body our write replaced.

Decision

  • A write that stuck reads the newest two entries of the edit history. One GraphQL query, after the read back, in the write loop every mode shares (writeBody in src/github/write-loop.ts). The newest entry is our write. The entry before it is the body our write replaced. When that is the body the write was built from, nothing landed in between, and the write stands.
  • When it is not, the write is tried again, built from the body it replaced. That body is the late read the write should have had: it holds what the person did, and every writer already builds from a late read and carries everything it does not own byte for byte. So the next try puts the person’s tick back, together with the rows this writer owns. The try is one of the three of 0004. A body that comes out as the one already stored is not written again: the edit was about something the writer replaces anyway.
  • The job log says so, once, when the body was written again: An edit landed on the dashboard (#N) between the read and the write, and the write went over it. The dashboard was written again on top of that edit.
  • When the third try went over an edit too, the job goes red. The error says that each time another edit landed, that the last edit is in the edit history, and that a box ticked in it needs a fresh tick. A tick is never dropped without a word.
  • The walk looks through the bot’s write that went over a tick. The written back body is a bot entry that holds the tick, and right before it is the bot entry that went over it, without the tick. 0025 would end the stretch there and name the bot, which may not tick (0018). So the walk looks through one entry when all of these hold: the entry is by the bot and does not hold the tick, the newest entry of the stretch so far is by the bot, and the entry right before it holds the tick. The stretch then goes on, and the ticker is the editor of its oldest entry, as before. In any other case the entry ends the stretch as it always did: a person’s entry without the tick, a bot entry after which a person ticked again, an entry before it that does not hold the tick, has no body, or is past the end of the kept history.
  • The run that the tick’s own event started deploys it. Every edit by a person wakes a run (0025), and that run waits for the running one in the concurrency group, in the split workflow (sluiceway-resolve) and in the one-step workflow (per edited issue, 0077) alike. By the time it reads the body, the tick is back and the walk names the person.

Why the look-through is safe

The walk exists to name the person who made a tick, and 0025 refused to skip an entry without a body because that could name someone else. This rule never names anyone who did not make this tick. It looks through one bot entry only into an entry that holds the same tick, the same stack at the same hash, and the walk still ends at the oldest entry of an unbroken run that holds it. The bot cannot make a tick (0018), and a person cannot write a bot entry. A bot entry holds a tick only because the bot carried it from a body that held it. The worst case is a bot entry that dropped a tick for a reason, followed by a stale bot write that carried the tick back: the walk then names the person who made it, and the judgement does what it always does with that person’s tick. A stack with an open deployment drops it (0003), a refused ticker is refused again, and apply deploys nothing whose hash and fingerprint a fresh preview does not give (0008, 0102).

Considered

  • Shrinking the window, by reading the body once more right before the write. It makes the window smaller and never closes it, and the tick is still lost when it lands. Not done: the rule above covers the window whatever its size.
  • Finding the tick in the history instead of in the body, so that the next resolve acts on a tick the body no longer holds. Rejected: 0025 acts only on a row that is ticked in the body just read, and a run would have to tell a tick that a write went over from one that was cleared on purpose, refused, dropped or handled, from bot entries alone.
  • Reporting the tick instead of writing it back: a note on the row that asks for a fresh tick. Simpler, and the person ticks twice. Kept only for the case where three tries were not enough, as the red job.
  • Writing the tick back from resolve alone. Every writer has the same window, apply, settle and the scan as much as resolve, and the write loop is where they all write. One rule in one place.
  • Comparing bodies by their ticks instead of byte for byte. The entry is compared with the body the write was built from as a whole. A difference that is not a tick, or an entry GitHub stored with other line endings than the REST read gave, makes the next try build a body equal to the one already stored, so nothing is written again. It costs one more build, never a wrong body.
  • Reading the history instead of the issue for the read back. One request less, but the read back of 0004 is what issue 17 proved against real GitHub, and nothing has proved that GraphQL reads its own write at once. Kept as two reads.

Consequences

  • Every write that sticks costs one GraphQL query more, in every mode. A scan writes twice (0108), so it costs two more. The worst case of 0017 becomes 413 requests on the first try and 817 with three tries that each went over an edit. The ordinary scan stays far below the budget.
  • An entry without a body, and a history that already moved past our write when it was read, give nothing to build from. The write stands as before. In the second case another edit landed on top of ours, and its own run reads it.
  • Not observed on real GitHub: whether the history lists a write the moment the read back sees it. If it does not, the newest entry is not ours and the write stands, as before this record.
  • CONTEXT.md: the stretch looks through the bot’s write that went over a tick.
  • Tested on the fake GitHub: a person’s tick lands between resolve’s read and its write, resolve writes it back, and the run of that tick’s own event deploys it with the person as the ticker (test/modes/resolve-tick-during-write.test.ts).