Purpose: define the file-based lock mechanism that lets multiple humans and AI agents work the same backlog without colliding. A lock has authority only where it is visible — it is git-tracked, so a lock commit binds exactly the refs that carry it. It has a time-to-live (TTL) and requires no external service. Read “What the lock does and does not guarantee” before relying on it for mutual exclusion; under a PR-only workflow the default form is cooperative signalling with late collision detection, and the shared-ref protocol is what makes it authoritative.
The lock lives on the Lock: field of the backlog item’s frontmatter table (see 04_backlog_items.md). The protocol is small enough that humans and AI agents can both follow it, and audit-friendly enough that every change is a commit.
A backlog used by more than one contributor — and especially one used by AI agents that can read and write to the same files — needs a way to answer “is anyone currently working this?” without ambiguity.
Without an answer:
The standard solutions either don’t fit or are too heavy:
The file-based lock with TTL solves these. It is the same data the rest of the backlog uses (markdown frontmatter), tracked by the same tool (git), audited by the same mechanism (commit history). No new dependencies. No new service to babysit. And the TTL makes it self-healing — a crashed lock-holder cannot wedge an item forever.
Every backlog item carries a Lock: field. Two valid states:
Lock: —
Unlocked. The item is free. Anyone may acquire it.
Lock: <holder>@<ISO-8601-timestamp>
Locked until the timestamp expires. The named holder is currently working the item. Until the timestamp passes, no one else may acquire it.
That is the entire mechanism. Everything below is the protocol that operates on this field.
The em dash (—) is visually distinct from a holder string. It is hard to mistake for a typo. Grep tools display it cleanly. Markdown tables render it cleanly. Compared to alternatives (null, empty string, none), it is more readable in a frontmatter table that humans skim.
ISO-8601 is unambiguous, sortable, and timezone-aware. Compared to “2 PM Tuesday” or epoch seconds:
The recommended format is YYYY-MM-DDTHH:MMZ (UTC, minute precision). Higher precision is unnecessary for this purpose.
The lock has a time-to-live for one critical reason: lock-holders die.
Without a TTL, every such event would leave a lock in place indefinitely. The item would become un-pickable, and someone would have to manually decide it’s safe to clear — without good information about whether the holder is still active.
With a TTL:
The cost of refreshing the lock periodically is one commit + push. The cost of a stuck lock without a TTL is human investigation, ambiguity, and trust erosion.
Before starting work on an item, every contributor (human or AI) must follow this protocol:
1. Pull the latest changes from the repo so the Lock: field is current.
2. Read the item's Lock: field.
3. Decide:
- If Lock: —, the item is free. Proceed to acquire.
- If Lock: <holder>@<timestamp>:
- If the timestamp is in the past → the lock has expired.
Proceed to acquire (see "Expired locks" below first).
- If the timestamp is in the future and the holder is not you →
SKIP this item. Pick another. Never steal a live lock.
- If the timestamp is in the future and the holder is you →
you already have it. Proceed to work.
4. To acquire: set Lock: <your-id>@<now + TTL>. Optionally also flip the
Status field to in-progress in the same edit. Commit. Push.
5. Pull again immediately after pushing. If the push raced with someone
else's acquire and the file conflicts, that other contributor wins
(or you do — see "Race conditions" below).
The lock state in your local checkout may be stale. If another contributor acquired the lock thirty seconds ago, your local file still shows it free. A git pull (or equivalent) before reading the field minimizes the race window.
Pull-before-decide narrows the window; it does not close it, and on the default protocol nothing does — see “What the lock does and does not guarantee”. The race can be eliminated without an external coordinator, but only by moving every lock write onto one shared ref so git’s own ref-update rules reject the loser; that is the shared-ref protocol.
chore(backlog): lock BL-### for <holder>
Lock acquired: BL-### -> Lock: <holder>@<timestamp>
The shape:
chore type — locking is housekeeping, not a feature or fix.(backlog) scope — disambiguates from other chore commits.git log -p readers.Keep the commit small: only the lock change. Combining the lock-acquire with substantive code changes makes the audit trail harder to follow.
Push the acquire commit immediately. The lock has no effect on other contributors until it is visible in the shared remote. A locally-committed but unpushed acquire is useless — another contributor can still acquire the same item without seeing yours.
Two contributors may try to acquire the same item at the same time. The outcomes:
git pull --rebase (or merge), see the other contributor’s lock now in place, and either abandon their acquire or stage it behind the first acquire.Lock: field; resolve in favour of the earlier-acquired lock (use commit timestamps if needed) and notify the loser. Note what this is: detection after the fact, not acquisition. It recovers evidence; it does not prevent duplicated effort. See “What the lock does and does not guarantee”.Races are rare because each acquire is a small, fast commit; the window of vulnerability is seconds. Disciplined pull-before-acquire keeps the rate low enough that the merge-conflict path is the fallback, not the norm.
A lock has authority only where it is visible. A lock committed to a feature branch binds that branch and nothing else.
This matters because it collides with a rule this methodology states elsewhere. 09 requires every change to land on the trunk via a pull request and forbids direct trunk commits. So the acquire commit goes to the agent’s own feature branch — and a contributor who pulls the trunk cannot see it. Two agents branch from the same trunk state, each reads Lock: —, each commits a different lock value, each pushes to a different ref. Both pushes succeed. Both do the work. The conflict appears when the branches merge, which is after the duplication, not before it.
Git rejects a non-fast-forward update only when two writers update the same ref. The default protocol has them updating different refs, so there is no compare-and-swap on the backlog row and nothing to lose the race against.
What the default protocol therefore provides, stated plainly:
| Property | Default protocol | With the shared-ref protocol |
|---|---|---|
| Announces intent to anyone reading the same ref | ✅ | ✅ |
| Survives a crashed holder (TTL) | ✅ | ✅ |
| Auditable — every state change is a commit | ✅ | ✅ |
| Detects a collision | At merge, after the work | At acquire, before the work |
| Prevents two contributors starting the same item | ❌ | ✅ |
Cooperative signalling with late collision detection is a real and often sufficient property. On a small team with short items and a shared habit of pulling first, duplicated pickups are rare and cheap. What it is not is mutual exclusion, and a project that needs the stronger guarantee — because agents run unattended, or because items are long enough that duplicated work is expensive — must use the shared-ref protocol or assign items out of band.
Never state the weaker property as the stronger one. An adopter who believes the lock is authoritative when it is advisory will size their concurrency to a guarantee they do not have.
Opt-in. It costs one extra round-trip per pickup and provides genuine compare-and-swap, still with no database, no lock service, and no new dependency.
The rule is one sentence: every lock write targets one ref that every contributor reads.
Pick the ref once, per project — a long-lived locks branch, or a dedicated refs/locks/backlog — and record it in the project instruction file. Then:
1. Fetch the lock ref. Read the item's Lock: field from it (never from your
feature branch).
2. If it is held and unexpired by someone else, SKIP. Pick another item.
3. If it is free or expired, commit your acquire on the lock ref and push it
to the lock ref.
4. If the push is REJECTED (non-fast-forward), someone acquired between your
read and your write. You lost. Re-fetch and go to step 2 — do not retry
blindly, and do not force.
5. Only after the push succeeds do you start work.
Step 4 is the whole mechanism. Because both writers target the same ref, the remote rejects the second one, and the rejection is the compare-and-swap — the same primitive a lock service would give you, supplied by git’s ref update rules.
Three things to get right:
The cost is honest: one fetch and one push before work starts, and a second place to look. That is the price of the guarantee, and a project that does not need the guarantee should not pay it.
| Situation | Use |
|---|---|
| Small team, short items, everyone pulls before starting | Default — the cost of a rare duplicate is lower than the cost of the ceremony |
| Unattended or overnight agent runs | Shared-ref — nobody is watching to notice the collision |
| Items long or expensive enough that duplicated work hurts | Shared-ref |
| An external tracker already owns assignment atomically | Neither — let the tracker be the authority and mirror it into the item |
When the work on a locked item is done (passed Definition of Done), abandoned, or merged, release the lock:
1. Set Lock: — in the item's frontmatter.
2. Commit and push.
3. Best practice: include the release in the same PR that lands the work.
This ensures the lock is released atomically with the work it covered.
When the release is its own commit:
chore(backlog): release lock on BL-###
Lock released: BL-### -> Lock: —
When the release rides along with the PR that closes the item, the PR’s merge commit captures the release naturally. Either path is fine; the rule is that the lock must end at — once the work is concluded.
A common failure: the work is done, the PR is merged, but the Lock: field still shows the holder’s timestamp. The lock will eventually expire on its own, but in the meantime no one knows whether the item is genuinely free.
Defenses:
Status: done and Lock: not —. (Trivial to write; one grep.)If you decide not to continue the work — change of priorities, blocker discovered, scope misjudged:
Status back to ready (or blocked, with a Blocker: line).Notes: line summarizing where the work got to and what’s left. The next contributor can pick up cleanly.Not every action requires a lock. Locks are for exclusive work claims on an item. They are overhead, and overhead applied to wrong cases adds friction without benefit.
BACKLOG.md to ARCHIVE.md is not a work claim. Anyone may do it as part of housekeeping.EPICS.md counts after an archive is housekeeping. No lock.BACKLOG.md does not require locking the file. (The item starts with Lock: —.)Status: in-progress and produce non-trivial code/content changes.When a lock’s timestamp is in the past, it is expired — the previous holder either finished without releasing, abandoned the work, or died mid-task.
Expired locks are free to acquire. But before acquiring, do one thing:
Investigate what partial work the previous holder may have left.
The previous holder may have:
git log -p -- <epic>/BACKLOG.md — see the lock acquire commit and any other commits in the area. The acquire commit may be useful as a timestamp anchor.git log --all --author=<holder> — see other commits by the previous holder around the same time.feature/<item-slug> containing the partial work.If you find useful partial work, decide:
in-progress, lock to your ID, and pick up. Note in the body what you inherited.ready, then re-acquire fresh. Sometimes the partial work isn’t recoverable.If you find nothing, just acquire cleanly.
The holder field identifies who holds the lock. The format:
<agent-or-user>-<short-session-id>
Examples:
claude-session-a4f2 — an AI agent’s specific session.alice-laptop — Alice’s primary workstation.alice-mobile — Alice’s secondary device (so two of her sessions don’t collide).bob-ci — Bob’s automation that occasionally acquires locks.Give each agent its own holder ID. claude-session-a4f2 and claude-session-b193 are separate; both might be initiated by the same human, but each is a distinct lock-holder for protocol purposes. If they need to coordinate (e.g., the human is running two parallel agents on different items), the coordination happens above the lock layer.
The TTL is the duration of the lock — how long from acquire until the lock expires unless refreshed.
If unsure, use 2 hours. That’s the right default for ~80% of cases. The table below is for when you have a reason to deviate.
| Holder type | Recommended TTL | Rationale |
|---|---|---|
| AI agent session (default) | 2 hours | Matches a typical session length. If the agent finishes faster, it releases. If it runs longer, it refreshes (which is one commit). |
| Human contributor — focused work | 4 hours | Comfortably covers a focused work block without forcing refreshes. |
| Human contributor — open-ended | 8 hours (one workday) | Covers a full day’s intermittent attention on the item. |
| Automation (CI, scripted backfill) | 30 minutes | Automation is fast and crashes loud; short TTL bounds blast radius. |
| Coordinated multi-day work (rare) | Maximum 24 hours, refreshed daily | The TTL ceiling for rare cases — NOT the default. Long work should be split into smaller items or refreshed daily; a TTL of 24h chosen because “the work might take that long” is the anti-pattern. |
If unsure, default to 2 hours (see callout above). It is small enough that a forgotten lock is forgiven quickly; it is long enough to cover a non-trivial unit of work.
To refresh: commit a new Lock: value with a later timestamp. Same holder, later expiration. This signals “still working” to other contributors.
Refresh before the existing lock expires, not after. A lock that has already expired is — at that moment — anyone’s to acquire; refreshing it after expiration races against any contributor who may already be trying to take it.
A common pattern: an orchestrator agent picks up an item, holds the lock, and delegates specific subtasks to subagent sessions that do focused work. The subagent does not touch the lock; the orchestrator remains the lock-holder throughout.
Orchestrator agent:
1. Acquires lock on BL-###.
2. Plans the work; identifies independent subtasks.
3. Spawns subagent(s) — e.g., one for research, one for testing,
one for documentation.
4. Subagent does its task; returns results to orchestrator.
5. Orchestrator integrates results; produces the change.
6. Orchestrator releases lock when work is complete.
When the orchestrator spawns multiple subagents in parallel:
Every lock change is a git commit. This makes the lock history fully auditable.
git log -p --follow -- backlog/epics/E<NN>-<slug>/BACKLOG.md
Filter to lines mentioning the item ID and you see every lock state change in order.
git log --all --grep="lock .* for <holder>"
rg "^\| Lock\s+\|" backlog/ | rg -v "Lock\s+\|\s+—"
Items whose locks are currently expired (lock is set but past now):
A short script: parse each Lock: line, compare timestamp to wall clock, list items where timestamp < now and value != —.
This example walks through a realistic scenario with two AI agent sessions and one human. All times in UTC.
BL-0428 — Add CSV export to the activity report.BACKLOG.md shows: | Lock | — |claude-session-a4f2, claude-session-b193, and alice-laptop.14:00. claude-session-a4f2 pulls the repo. Reads BL-0428. Sees Lock: —. Decides to take it.
14:00. claude-session-a4f2 commits:
chore(backlog): lock BL-0428 for claude-session-a4f2
Lock acquired: BL-0428 -> Lock: claude-session-a4f2@2026-04-18T16:00Z
(TTL = 2 hours; expiration is 16:00.) Pushes.
14:02. claude-session-b193 pulls. Reads BL-0428. Sees Lock: claude-session-a4f2@16:00Z. Timestamp is in the future and the holder is not them. Skips. Picks a different item.
14:30. alice-laptop pulls. Reads BL-0428. Same conclusion: locked. Skips.
15:50. claude-session-a4f2 is still working. The lock will expire at 16:00. They refresh:
chore(backlog): refresh lock on BL-0428
Lock refreshed: BL-0428 -> Lock: claude-session-a4f2@2026-04-18T18:00Z
Pushes.
16:30. alice-laptop pulls again. Reads BL-0428. Sees Lock: claude-session-a4f2@18:00Z. Still in the future. Still skips.
17:15. claude-session-a4f2 finishes the work. Opens a PR. The PR’s diff includes the lock release:
| Lock | — |
And the item’s Status flips to to-be-tested.
17:30. PR is reviewed, tests pass, merged. Lock is now — on master.
17:40. alice-laptop pulls. Sees the lock is released. Item is to-be-tested. Alice picks up the UI verification step. Locks the item to herself:
chore(backlog): lock BL-0428 for alice-laptop
Lock acquired: BL-0428 -> Lock: alice-laptop@2026-04-18T21:40Z
Performs UI verification. Sets Test: pass. Flips Status: done. Releases the lock. Moves the item to ARCHIVE.md.
A git log -p -- backlog/.../BACKLOG.md filtered to BL-0428 lines, in order:
14:00 Lock: — -> claude-session-a4f2@16:00Z (acquire)
15:50 ...@16:00Z -> ...@18:00Z (refresh)
17:15 Lock: claude-session-a4f2@18:00Z -> — (release; embedded in PR)
17:40 Lock: — -> alice-laptop@21:40Z (acquire)
18:30 Lock: alice-laptop@21:40Z -> — (release)
Every state change is recorded. Anyone reviewing the history can reconstruct what happened, when, and by whom.
At 14:30, alice-laptop would have read BL-0428 with no lock indication and may have started parallel work. By the time both pushed their work, conflicts would have appeared. The waste: one of them did the work for nothing, and the merge took longer than the change itself.
Lock: field lives on the item’s frontmatter table. Acquiring usually pairs with a Status: in-progress change in the same commit.Lock: —” — a done item with a non-empty lock has not finished the DoD properly.| Mistake | Fix |
|---|---|
| Acquire commit not pushed; you start working with only a local lock. | Push immediately after acquire. Local-only locks protect no one. |
| Pulled before acquire was skipped; you race another contributor. | Always pull, then read, then decide. The pull is the cheapest race-mitigation. |
| Stole a live lock because “they’re probably done by now.” | Never steal a live lock. Skip. If the lock genuinely seems abandoned, wait for expiration (or contact the holder out-of-band). |
| Lock left in place after work completed. | Release as part of the PR that lands the work. A periodic sweep script catches forgotten releases. |
| Same lock ID used across two sessions. | Use a unique session ID per session. Two of your sessions are not the same holder for protocol purposes. |
| TTL set to 24 hours by default. | Use the recommended TTL for the holder type. Long defaults waste recovery time when the holder dies. |
| Lock refresh forgotten; lock expires mid-work; another contributor takes the item. | Refresh proactively, before expiration. If you do lose the lock and another contributor takes the item, coordinate out-of-band; do not commit on top of theirs. |
| Acquire commit bundled with substantive code changes. | Keep the lock commit small. One acquire = one tiny commit. Substantive code goes in subsequent commits. |
| Lock holder ID is “anon” or empty. | Use an identifiable holder ID. Anonymous locks are unhelpful in incident triage. |
Lock present on a done or rejected item. |
Bug. Release immediately. Check whether the work actually finished or was abandoned. |
The lock mechanism described here is one point in a small design space. A project may adapt for its own needs:
Lock: at the top of each file rather than in an item frontmatter.The file-based lock with TTL is the recommended default because it has the lowest moving parts and works equally well for humans and AI agents.
Locks bind work — no contributor may start in-progress work on an item without holding its lock. The lock outranks intent; “I was going to work this” is not the same as “I hold this lock.”
Binding is not the same as enforcing. The rule above is an obligation on contributors; whether the mechanism can stop a second contributor depends on which protocol is in use — see “What the lock does and does not guarantee”. Under the default protocol the obligation is real and the enforcement is not; that is a discipline, not a guarantee, and it should be described as one.
Locks do not bind discussion, research, or design. Anyone may read, discuss, or sketch ideas about an item without acquiring its lock. The lock applies to exclusive work claims that produce committed changes.
A user (with explicit authority over the project) may override a lock — for example, releasing a clearly-stuck lock that an agent forgot to release, or breaking a lock during an incident. The override should be recorded as a commit with a clear message; the original holder is informed if reachable.
AI agents do not override locks. They follow the protocol exactly. If they observe what appears to be a wrongly-held lock, they surface the observation; they do not take action on it.
Next: 06 — Working principles