docs/state.md gates and conflict resolver¶
Every PR that closes a bug, opens a bug or rules a Netflix upstream report not-affecting-the-fork updates docs/state.md in the same PR (rule 15 of the agent hard rules, ADR-0165). This page covers the three tools that keep that file honest: the touch check that requires the edit, the row-hygiene check that keeps rows in the right section, and the resolver that merges the file during a rebase.
| You are... | Read |
|---|---|
| Opening a bug-fix PR | Bug-status hygiene gate |
Rebasing a branch that touches docs/state.md | Resolving a rebase conflict |
| Reading a failure from the row check | Row hygiene |
Resolving a state.md rebase conflict¶
docs/state.md conflicts on almost every rebase of a branch that touches it, and unlike the other append-only bookkeeping files it is deliberately not in the merge=union list in .gitattributes. Its rows move between the "Open bugs" and "Recently closed" sections, so a union merge would duplicate the row and leave a closed bug reading as open forever.
When git rebase stops on it, run the resolver and continue:
# mid-rebase, with docs/state.md conflicted
python3 scripts/dev/resolve-state-md-conflict.py docs/state.md
git add docs/state.md && git rebase --continue
Exit 0 means the file is written and scripts/ci/check-state-md-rows.sh already passed on it; the resolver runs the gate itself. The file is written with LF line endings on every platform.
What the resolver does¶
scripts/dev/resolve-state-md-conflict.py ignores the conflict markers. It reads the three versions git keeps in the index for a conflicted path (git show :1:docs/state.md is the merge base, :2: is ours, :3: is theirs) and merges them three-way:
| Line | Keyed by | Rule |
|---|---|---|
Bug row (first cell opens with an id: **T-ID**, T-ID, **T7-16**, Netflix#NNN, **Netflix/vmaf#NNN**) | bug id | Its state is its text plus its ## section. Same on both sides: kept. Changed on one side only: that side wins, so an edit, a move to "Recently closed", or a deletion carries over. Changed differently on both: conflict. |
Move tombstone (<!-- T-ID moved to Recently closed ... -->) | bug id | Same as a bug row. |
Disposition row under "First-release phase classification" (first cell a bold label such as **RC8 benchmarks, profiling and tuning**, second cell a <br> list of ids) | bold label | Both sides changed it: the id list merges as a set (ours, plus the ids theirs added, minus the ids either side removed; ours' order, theirs' additions after) and every other cell three-way by text. Rows repeating a label on one side are folded into one first, and the resolver says so. |
Anything else (headings, prose, _Updated lines) | line text | Line-level three-way. Lines both sides add at the same place are all kept, theirs after ours. A non-blank line both sides added is kept once, which is what a branch stacked on an already squash-merged PR needs. Lines either side deleted go, even where the two deletions overlap. |
The row and tombstone shapes are the ones check-state-md-rows.sh recognises, so the two agree on what a row is.
Placement keeps ours' order; a line only theirs has goes after its nearest theirs neighbour that is still present in the same section. Two consequences are deliberate:
- your branch's new
_Updatedline and its newly closed rows land below the ones master added since you branched (move them up by hand if you want the ledger newest-first); - a reorder of existing rows within one section on your branch is not carried over.
During a rebase ours is master plus the branch commits already replayed and theirs is the commit being replayed. That is why neither side may simply win. "Ours wins" keeps master's stale Open copy of a row the branch closes. It also keeps an earlier branch commit's text of a row that a later commit of the same branch rewrote. The resolver before this one did exactly that, three times on 2026-09-30 (ADR-1383).
When it stops¶
When both sides changed the same row, tombstone, disposition cell or line differently, the resolver writes nothing, names each one with its base, ours and theirs text, and exits 1. Decide which side is right and rerun with one --take per reported name:
python3 scripts/dev/resolve-state-md-conflict.py docs/state.md \
--take T-FOO-2026-09-30=theirs \
--take 'RC8 benchmarks, profiling and tuning=ours' \
--take line:468=theirs
A bug id takes that side's row and tombstone; a disposition label takes that side's whole row; line:N is the handle the report prints for a conflicting plain line (N is its line number in the merge-base version). A --take that names nothing in the file is an error.
| Exit | Meaning |
|---|---|
| 0 | Written; the row gate passes. |
| 1 | Conflicts; nothing written. |
| 2 | Bad usage, or the path has no unmerged index stages (not mid-rebase, or already git add-ed). |
| 3 | Written, but the row gate rejects the result or a bug id sits in two disposition rows. Fix the file before git add. |
The resolver only works while git still holds the three stages. After a bad resolution has been committed, fix docs/state.md by hand; the gate in CI still catches duplicated and misfiled rows.
Tests¶
scripts/dev/test-resolve-state-md-conflict.py builds throwaway repositories and drives the resolver through real git rebase conflicts:
- a branch closing a bug, and a later commit rewriting an earlier one's row;
- both sides adding
_Updatedlines and closed rows; - a branch stacked on a PR master already squash-merged;
- a row closed on master while the branch edited it;
- one-side and overlapping deletions, tombstones, disposition rows edited on both sides, and the refusals.
Every resolved file must pass the row gate.
CI runs it, and the gate's own self-test, in the required Tooling Tests job, which runs every test of the tooling suite once (test suites, ADR-1568). The Release Script Contract job of .github/workflows/rule-enforcement.yml runs the gate on the live docs/state.md.
Row hygiene¶
A duplicate is not the only way a closed bug reads as open. A row filed under ## Open bugs whose own rightmost cell already says closed or fixed reads as an open bug forever without any duplicate being involved: the PR appended its row to the section it happened to be reading instead of moving it, or a rebase dropped the move hunk and kept the status edit. 24 of the 62 rows under ## Open bugs were in that state on 2026-09-21.
scripts/ci/check-state-md-rows.sh reads the section heading each row sits under together with the status token in its status cell and requires them to agree:
| Status token | Allowed under |
|---|---|
closed, fixed, resolved, done | ## Recently closed only |
open | ## Open bugs only |
How the token is read:
- the status cell is the column a table header calls
Status, or the last non-empty cell when no header names one; - the token read is the word that opens that cell, so
fixed (PR #1425)is judged exactly likefixed; - rows that lead with no status token (a verification date, a branch name, prose) make no status claim and are not judged.
The fix is always to move the row, never to rewrite its status to match where it landed.
Tombstones¶
An explicit move tombstone closes the common no-status case. If a comment under ## Open bugs says an id "moved to Recently closed", the same id may not still have a table row in Open bugs. This is checked independently of table status columns: a bookkeeping change once added the PTQ row's tombstone while leaving the stale Open row directly above it, and all status-token checks reported clean. The valid result is the tombstone in Open bugs plus the single authoritative row under ## Recently closed.
Code spans and brackets close on their line¶
Every line of docs/state.md outside a fenced block must pair each backtick run with a later run of the same length on that line (a code span) and close every [ that is not inside a code span. The gate names the line and column of the first run or bracket that does not close.
Most of the ledger is one long paragraph of rows, so one stray backtick re-pairs every code span after it in the rendered page, and a [ left outside a span makes the GFM autolink-literal parser behind the Documentation Governance job walk back to it from every later URL candidate. One row with a debugger frame name in single backticks took the lint of this file from 4 s to over two minutes, past the job's 120 s budget per lint process (T-STATE-MD-UNPAIRED-CODE-SPAN-LINT-TIMEOUT-2026-10-06). To fix a hit:
- write a backtick that belongs to the text inside a longer run, for example
`` mod`close ``for a debugger frame name that joins module and function with a backtick; - put a
[that has no]into a code span, or escape it as\[.
Limits of the check¶
The check is a floor on this class of drift, not a proof of its absence. It reads one cell per row, so a status it does not recognise (buried mid-cell, in a column that is neither the last nor headed Status, or spelled outside the vocabulary above) is passed over in silence and the file still reports clean.
Of the two ways the check can be silently disabled it fails closed on one: if any row claims a status that belongs to a section and that section's heading is missing, the gate errors instead of passing over rows that have quietly become ungated. The other, an unrecognised status cell, is uncovered, and is the likelier of the two, since it takes a single row edit rather than a heading rename.
Bug-status hygiene gate (ADR-0165, ADR-0334)¶
The state-md-touch-check job in rule-enforcement.yml (check name docs/state.md Gate) fails a bug-shaped PR that does not touch docs/state.md. It is backed by the single-purpose script scripts/ci/state-md-touch-check.sh (ADR-0334); until that ADR the rule was reviewer-enforced.
When the gate fires¶
Any of these:
- the PR title carries a Conventional-Commit
fix:orfix(scope):prefix; - the PR title contains the bare token
bug(word boundary, sodebugdoes not fire); - the PR title or body contains a
closes,fixesorresolves#NGitHub issue close keyword (case-insensitive); - the PR body has the
## Bug-status hygienetemplate section with thedocs/state.mdcheckbox left unchecked.
When it clears¶
Either:
- The diff against
BASE_SHA..HEAD_SHAincludesdocs/state.md(the row landed in the appropriate section: Open, Recently closed, Confirmed not-affected or Deferred) and none of the inserted lines carry a placeholder PR or commit reference (below); or - The PR description contains
no state delta: REASON, where REASON is any non-empty token that is not the literal placeholderREASON. Use this for purefeat,refactorandinfraPRs that genuinely have no bug-status impact.
Placeholder references¶
Touching docs/state.md is necessary but not sufficient (ADR-0334 status update 2026-05-09). PR #541's row audit found that the dominant staleness pattern is post-merge backfill drift: closing PRs write this PR as the closer-PR placeholder, the merge happens, and the placeholder never gets rewritten to the merged numeric references. The gate therefore also rejects any inserted line in docs/state.md containing:
| Placeholder | Why |
|---|---|
this PR | post-merge backfill drift (most common) |
this commit | same drift mode for SHA-shaped refs |
TBD | obvious fill-it-in-later marker |
<PR> | template placeholder |
#NNN | template placeholder (real refs are digits) |
The canonical accept forms, explicitly not matched, are PR #N (any positive integer) and commit `<sha>` (the SHA wrapped in backticks). For an in-flight PR whose number is not yet final, either:
- land the row with a placeholder, then push a follow-up commit that rewrites it to
PR #<number>aftergh pr createreturns the number; or - use
PR #<this-pr-number>once GitHub has assigned it (the number is known the momentgh pr createexits).
Run it locally¶
The dry-run mirrors the deliverables-check.sh pattern:
PR_TITLE="fix: foo segfault" \
PR_BODY="$(gh pr view 999 --json body -q .body)" \
bash scripts/ci/state-md-touch-check.sh
Or pipe the body on stdin if gh is not on PATH:
gh pr view 999 --json body -q .body \
| PR_TITLE="fix: foo segfault" bash scripts/ci/state-md-touch-check.sh
The companion fixture script scripts/ci/test-state-md-touch-check.sh exercises the gate against 18 cases (5 primary, 3 regression, 10 placeholder-ref). Run it after touching either script: