Skip to content

The edit history names the ticker, and the event is only a wake-up

Decision record 0025

Supersedes 0005.

Amended by 0054: a scan opens a deployment record in one case: it hands a merged change on. It still never deploys and never clears a tick for it.

Record 0005 took a tick from the event whose own diff contains it (unticked in changes.body.from, ticked in issue.body) and took the ticker from that event’s sender. A lab test showed that the payload cannot carry that weight. issue.body and issue.updated_at in an issues.edited payload are the newest state at delivery time, not the state right after that edit. Three ticks one second apart gave three events that all carried the final body with all three rows ticked, and the runs did not even queue in edit order. So the first event’s diff contains every later tick, and a later ticker’s tick is authorized and recorded under the first ticker’s name. This can be scripted: a person with plain write access waits for an admin’s tick and ticks an admin stack within the same second.

GitHub keeps the truth elsewhere: the issue’s edit history. GraphQL userContentEdits returns one entry per edit with the editor, the editedAt time and diff, which is the full body right after that edit. The workflow token can read it with issues: read, and it costs one point. So the roles change. The issues.edited event is only a wake-up. resolve takes nothing from the payload except the cheap check that the edited issue is the dashboard (0017). It acts on every ticked row it finds, and the history names the ticker of each one.

resolve reads the body and the history in one query. For every ticked row it walks the history from the newest entry back, for as long as the row is ticked with the same stack id and hash. The ticker is the editor of the oldest entry in that unbroken stretch: the edit that made the tick. Entries by the bot inside the stretch are normal, because a scan or a row swap carries a tick through. The ticker then passes the test of 0018 unchanged, and their login goes into the deployment payload.

A run acts on a row only when all of these hold: the row is ticked in the body just read, the history names a ticker for it, discovery knows the stack, and the stack has no open deployment. One authorization rule still covers every stack, and nothing deploys without a named ticker.

The history names nobody when an entry inside the stretch has no body (a person with write access can delete an entry’s content in GitHub’s interface), or when the stretch reaches the end of the kept history without an entry in which the row was not ticked. Then nothing deploys, the box is cleared through the normal row swap with the note that asks for a fresh tick, no comment is written because there is nobody certain to mention, and the job stays green. An entry without a body always breaks the stretch. Skipping over it was rejected: a person who ticks a row they may not deploy, waits for an admin’s next edit and then deletes their own entry would otherwise be replaced by that admin.

Since any resolve run handles every tick, the runs no longer all have to execute. resolve uses plain concurrency: sluiceway-resolve, without queue: max and without cancel-in-progress. A running resolve finishes, and only the newest waiting run survives. That run starts after the last edit, so it sees every tick. apply keeps its own queue: max.

Keeping 0005’s shape was considered: find the entry that belongs to this event (its editor is the sender and the entry before it equals from) and act only on the rows that entry ticked. It was rejected because it compares whole bodies byte for byte, where line endings differ between edits from the interface and from the API, because it fails whenever the entry cannot be matched, and because it keeps a dependence on run order that GitHub does not guarantee. The other candidates were weaker. Refusing a diff with more than one new tick loses the first ticker’s tick in every race, since no later event contains it alone. Applying the strictest rule of all newly ticked rows still deploys the second person’s row on the first person’s rights. The next queued event’s from cannot be read from another run. Closing the window only for stacks with a narrowed rule was rejected too: who ticked is a deploy fact (0003) and should be right on every stack, and one code path is easier to defend than two.

Consequences

  • A tick has no age limit. A checked box with a nameable ticker is a valid request for as long as it is checked: the ticker is checked live and the hash must still match a fresh preview. A tick whose event was dropped is picked up by the next resolve run, whoever’s edit woke it.
  • Scans still sweep for orphan ticks and still never deploy them. A scan has no business starting a deploy. A row is an orphan when it is ticked, its stack has no open deployment, and no issues-triggered run of the workflow is queued or in progress. The scan renders it unticked with a note asking for a fresh tick. If a resolve run is still pending, the scan carries the tick through untouched.
  • A tick is a commit, not a toggle. Unticking after resolve has created the record does not stop the deploy.
  • Staleness is covered by reading the body at run time. An untick before the run starts, a re-run of an old workflow run, or a row whose hash changed all fall out as nothing to do. A replayed payload cannot matter, because nothing is taken from it.
  • The walk reads row markers only (0009). It never compares whole bodies, so line endings and edits elsewhere in the body do not matter.
  • The body and the history come from one query, so they describe one moment. If the newest entry’s body is not the body that was read, resolve reads again.
  • GitHub keeps 100 entries: the original body and the newest 99 edits. A walk that reaches the gap names nobody. With bot writes from every scan and deploy in the same history, that takes a tick that stayed unhandled through 99 later edits.
  • Each entry carries a full body, up to 262,144 bytes. resolve reads the history in small pages, newest first, and stops as soon as every ticked row has its answer. One page is the normal case.
  • An oldest entry whose editor is not a person falls under 0018: no deploy, no comment, and the next scan clears the box.
  • The rescan box gets its ticker the same way.
  • Replaced resolve runs show as cancelled in the Actions list. That is GitHub’s way of showing a waiting run that a newer one made unnecessary. Ten quick ticks cost two or three runs instead of ten, which matters on a small pool of self-hosted runners.
  • An edit through the API that ticks several boxes at once is one entry, so every one of those ticks has the same ticker, and each is judged on its own (0018).
  • The permissions: block does not change. issues: write already covers reading the history.
  • Not tested: what a deleted entry looks like through the API. GitHub’s docs say the editor and the time stay and the content goes. The rule does not depend on it, because any entry without a body breaks the stretch.

Settled while building (slice 2.2)

  • The walk is nameTickers in src/core/edit-history.ts. It takes the ticks to name and a function that reads one page of the history, and gives one answer per tick: the editor and the time of the edit that made the tick, or that the history names nobody and why. Whether that editor may tick is 0018’s question. The walk names a bot or an account GitHub no longer knows like anyone else, and the tick rule refuses them.
  • A tick is a row ticked at one hash, or the ticked rescan box. ticksIn reads the ticks of a body the same way the walk reads an entry, so the live body and the history can never be read by two rules. A row of a state this version does not know holds no tick, and neither does a row without a hash. Of two blocks for one stack the first counts, as it does for a scan that carries rows.
  • The same row ticked at another hash is another tick. So is a row whose hash a bot write changed under a tick: the stretch ends there and the bot is the editor, which 0018 refuses.
  • There are three ways the history names nobody. An entry inside the stretch has no body. The stretch reaches the end of the kept history. Or the newest entry does not hold the tick at all, which means the body moved between two reads, and the caller reads again.
  • An entry without a body ends the walk for every tick still inside its stretch, not only for the tick of the person who deleted it. What the entry held cannot be known. A person who loses a tick that way ticks again.
  • An entry has no body when GitHub gives no content for it, when it carries a deletion time whatever else it holds, or when its body is empty. A dashboard body is never empty, and what a deleted entry looks like through the API is still not observed (issue 27, item 7), so every form it could take is read as deleted.
  • The gap. A history of 100 entries or more is capped: its oldest entry is the original body, and nothing says how many edits were lost in front of it. The walk never uses that entry, so a stretch that reaches it names nobody, also when the original body shows the row unticked. A history of fewer than 100 entries lost nothing, and there the original body counts like any entry. A history of exactly 100 entries that lost nothing cannot be told from a capped one and is read as capped.
  • An issue that was never edited has no entries at all, not even its original body. Seen on real GitHub on 2026-09-21. The original body shows up as the oldest entry with the first edit.
  • The history names the bot as github-actions with the type Bot, without the [bot] that REST puts on the login (issue 28). The type is GraphQL’s __typename of the editor.
  • The query reads body and userContentEdits(first, after) with totalCount, pageInfo and per entry editedAt, deletedAt, editor and diff, newest first. GitHub names an end cursor on the last page too, so only hasNextPage says that a page follows. Checked with the real port against this repo, read only. Every page carries the body, so a reader can see that it moved.
  • A page is 10 entries. The normal walk needs two, the tick and the write before it.

Settled while building (slice 2.7)

  • The sweep runs at the scan’s late read, inside the builder of the write loop (0004), after the deployment records are read. So every try sees the ticks, the records and the runs as they are.
  • “No issues-triggered run of the workflow is queued or in progress” is read as: no such run that is not over. A run that waits for its concurrency group is pending, one that waits for a runner is queued, one that waits for a reviewer is waiting, and a run stays in_progress while its apply jobs deploy. All of them can still hold a resolve job that has not read the body yet, or a writer that has not written its row yet, so all of them mean hands off. The scan’s own run never counts.
  • The workflow is the one the scan runs in. Its file name comes from GITHUB_WORKFLOW_REF, and GitHub takes a file name where it asks for a workflow id (checked against real GitHub, read only). The call is GET /repos/{owner}/{repo}/actions/workflows/{file}/runs?event=issues&per_page=100, newest first, one request, and it needs actions: read, which the scan already has for the run of an open deployment (0003). It is listIssuesRuns on the port.
  • Only a late read that meets a ticked row whose stack has no open deployment makes that request. A scan that meets no tick costs nothing more than before.
  • A failed read of the runs fails the scan and leaves the body alone, as every other API error does. Clearing a tick without knowing whether resolve is on its way could take a tick away under a run that is about to act on it.
  • Hands off on a row the scan has a fresh preview for: the fresh row is written, and it carries the tick when its diff hash is the hash of the ticked row. That is the same tick (a row ticked at one hash), and a bot entry inside the stretch is normal. When the hash moved, the tick cannot be carried: on the new row it would be a tick nobody made, and the walk would name the bot. The row is written unticked with the note, whether or not a run is on its way, because resolve finds nothing to act on either way.
  • Hands off on a row the scan has no preview for: the row block is carried byte for byte, tick included, as before.
  • An orphan tick on a row the scan has no preview for makes the scan preview that stack, through the same return to the late read that a missing row uses (0011). Only the one row renderer can put the note on a row, and a row block is never patched inside (0004). This amends the line of 0011 that says the sweep costs API reads only: it costs one preview for each orphan on a carried row. An orphan is rare, and the person is asked to tick again, so a fresh hash under the new box is what they should be looking at.
  • When the fresh row has no box (the stack is in sync now, or its preview failed) the tick goes with the box and there is no note, because there is nothing to tick again.
  • One corner is left to the next scan: an orphan tick on a live row that the scan keeps although it previewed the stack, because a deploy ended after the preview started (0004). Previewing again for the tick could repeat for ever under a clock that runs behind GitHub’s.
  • A tick is read from the live body whatever the version of its root marker, so a scan that writes a body of another version again in its own clears the outstanding ticks with the note, as 0009 says. The rows of such a body are still not carried.
  • A scan never creates a deployment record and never calls resolve. The sweep only ever writes a row.
  • The scan writes the rescan box unticked, as it always has. Whether a ticked rescan box should survive a scan while a resolve run is on its way is resolve’s to settle (slice 2.4).

Settled while building (slice 2.4)

  • “Reads again” is at most three reads in one run. A tick that the newest entry still does not hold after the third read is left alone, with no swap and no comment. The edit that moved the body woke a run of its own, and that run sees it.
  • A tick on a row whose stack discovery does not know is left alone: no walk, no lookup, no swap. The next scan drops the row, because every scan ends with one row per discovered stack (0011).
  • A tick on a stack with an open deployment is dropped before anybody is looked up, so it gets no comment, whoever made it. Its row is swapped for the deploying row of the open record, which is the repair that 0004 describes. An open deployment whose run is over gets its result first (0003), and then the tick is a tick like any other.
  • resolve has no diff (0014) and never reads a row’s text (0009), so it cannot render a pending row again. It clears a box by changing the box itself, the one thing the tick regex reads at the start of the first line, and carries every other byte of the block. The note that asks for a fresh tick goes right under the first line, because a writer without a diff cannot tell the lines under it apart. A row that already has the note does not get a second one. The next scan renders the row in the order of 0027.
  • A box is cleared only while the live row is still ticked at the hash that was judged. A row that a scan wrote again in between, ticked again at its new hash, is another tick and belongs to the next run.
  • Slice 2.7 left it to this slice whether a ticked rescan box should survive a scan while a resolve run is on its way. It does not, as before: the scan writes the box unticked, and a request that is lost that way costs one more tick. Carrying it is a change to the scan and is on later.md.
  • The rescan box sits outside the row blocks, so writing the body again clears it. That happens for a rescan tick that was allowed, refused, unverified or nameless. A rescan tick by a bot is left alone like a row’s, and is cleared as a side effect when the same run writes the body for another reason.

Research: