A hand-typed branch name skips the machinery
A branch name tidier than the generated one silently opts out of every automation that discovers work by parsing it. The naming was the interface all along.
There is a command here that starts a work session. Among other things, it creates a branch with a generated name: a fixed prefix, the workspace, and a timestamp.
The name is ugly. I typed a nicer one instead, once, and spent a while afterwards working out why nothing downstream could see my work.
The case this post is about
One branch name, typed by hand because it read better:
canonical: claude-session-acme-20260921165110800
improvised: feat/medium-blog-pipeline
Both are valid git branch names and both push fine. The second one made three separate automations quietly do nothing: PR discovery found no open session PR and opened a duplicate, the workspace binding could not resolve which workspace the branch belonged to, and the stale-merged check had nothing to parse.
None of the three failed. Each one parsed the name, matched nothing, and took the no-op branch — which is what a parser does when handed a string outside its grammar.
This post follows that one name, because the canonical form looked like a style preference right up until it turned out to be the interface three consumers were reading.
The symptom
Everything succeeds. That is the first and most important thing about this failure: there is no error anywhere in it.
The branch is created. The commits land. The push goes through. The pull request opens. Every step reports exactly what it should, and if you watch the output you will see a clean run from end to end. What does not happen is everything that was supposed to notice.
The session binding does not find the branch, so the next command starts a second session against the same work. Pull-request discovery does not match it, so instead of reusing the open PR it opens another one. The cross-repository ordering logic cannot identify which repo this branch belongs to, so it treats three related repositories as unrelated and commits them in whatever order it happens to walk them.
None of those three report a problem, because from each one's point of view there was nothing to find. No matching branch and no branch are the same answer.

The second way it shows up is the duplicate. You end up with two pull requests for the same work, which is visible, so this is the version that gets found. But it gets found later, by somebody looking at a PR list, and by then both have commits. Merging either one leaves the other half-stale, and reconciling them is manual.

What makes the duplicate worse than it sounds is that both pull requests are legitimate. Neither is a mistake you can simply close: each has real commits that the other does not. The duplicate is not a stray artifact to delete, it is a fork in the work created by a string mismatch, and somebody has to read both diffs to decide what survives.
The third is the ordering failure, and it is the expensive one because it corrupts rather than duplicates. A commit across linked repositories has to happen bottom-up: the inner repository first, then the pointer in the one that contains it. The logic that enforces that order identifies the repositories by the branch name they share. Break the name and it has three unrelated repositories in no particular order, which produces a pointer committed before the thing it points at — a state that looks fine locally and is broken for everyone who clones it.

Locally it is invisible because the target object is sitting in your own repository whether or not it was pushed. The breakage only exists from outside, which means the person who creates it is structurally the last person who can see it.
And the fourth is what it does to the next session. The binding file records which branch the session is on. With a name nothing recognises, the record and the reality drift, and the next command reads the binding, finds a branch that does not match what is checked out, and either fails confusingly or starts over. Every subsequent confusion traces back to one typed string, and nothing in any error message mentions the name.
Why the obvious fix failed
Be careful. The branch is created by a command; I did not need to type a name at all. The failure was choosing to. That makes "remember not to" look like the fix, and it is not, for the reason this class of problem is always not: the moment you are overriding a generated value is exactly the moment you believe you have a good reason, and carefulness does not help with a decision that felt correct.
Document the convention. It was documented. The format is written down, with the prefix and the timestamp and what each part is for. Documentation tells you what the shape is; it does not tell you that three automations will silently return empty if you deviate. The gap is not knowing the format, it is knowing the format is load-bearing — and a documented convention reads exactly like a style guide, which is a thing reasonable people deviate from on purpose.
Make the parsers more forgiving. The attempt that seems most generous and is most harmful. If discovery accepts anything that looks branch-shaped, it will match branches that are not this session's — somebody else's work, an old branch, a release branch — and now instead of finding nothing it finds the wrong thing. A strict parser that returns empty is recoverable. A loose parser that confidently returns the wrong branch commits to it.
Fuzzy matching also quietly converts a contract into a heuristic. Once the format is "roughly this", there is no longer a thing a generator can guarantee and a consumer can rely on; there is a similarity score, and similarity scores have no failure mode that is obviously wrong.
The actual mechanism
The name is not a label. It is the identifier three separate systems use to find each other, and that makes it an interface — one that happens to be transmitted as a string on an object people think of as theirs to name.
That is the whole insight and it is uncomfortable, because nothing about a branch name announces this. A branch name is one of the most casually-chosen strings in software. It lives in a namespace where improvisation is normal and where the tooling actively invites you to type whatever you want. There is no type on it, no schema, no validation, and no place where its structure is declared as a contract rather than described as a habit.

Three properties make the generated name work, and each of them is load-bearing:
The prefix is the namespace. It says this branch belongs to the session machinery, which is how discovery distinguishes a session branch from a release branch, a hotfix, or somebody's experiment. Without it, discovery has to guess, and guessing is the loose-parser failure above.
The workspace segment scopes the match. Three repositories carry the same branch name deliberately; that shared name is what identifies them as one unit of work and gives the bottom-up ordering something to order.
The timestamp makes it unique and sortable. Not decoration — it is what lets a second session coexist with a first, and what makes "the most recent session branch" a question with an answer.
Each piece is doing a job. The name looks arbitrary because the jobs are invisible from where you type it.
The fix
The real fix is the one I did not want: do not pass the name. The command generates it; the generated value is the contract; there is no case where my string is better, because the only property that matters is machine-recognisability and I cannot improve on that by hand.
But "do not do the thing" is a rule enforced by memory, which this post has already argued against twice, so the mechanical half matters:
generator: one function produces the name, and nothing else constructs one
consumers: parse through that same module, never with an inline pattern
gate: refuse a session branch whose name the parser does not accept
One module owns the shape, and both directions go through it — which is what makes the gate possible at all:
# lib/session-branch.sh — the ONLY place this pattern is written down.
SESSION_BRANCH_RE='^claude-session-([a-z0-9-]+)-([0-9]{14})$' # <!-- preflight-ignore --> regex, not a secret
session_branch_new() { # ws -> name. Nothing else constructs one.
printf 'claude-session-%s-%s' "$1" "$(date -u +%Y%m%d%H%M%S)"
}
session_branch_parse() { # name -> "ws<TAB>stamp", or non-zero
[[ "$1" =~ $SESSION_BRANCH_RE ]] || return 1
printf '%s\t%s' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
}
session_branch_require() { # the gate
if ! session_branch_parse "$1" >/dev/null; then
echo "refusing: '$1' is not a session branch" >&2
echo " expected: claude-session-<workspace>-<YYYYMMDDHHMMSS>" >&2
echo " generate one with session_branch_new, never by hand" >&2
return 2
fi
}
The parser is the same regex the generator satisfies, so a name that cannot be parsed could not have been generated — which is exactly the property a hand-typed name breaks:
$ session_branch_require "$(session_branch_new mbs)"
$ echo $?
0
$ session_branch_require "feat/seo-fixes"
refusing: 'feat/seo-fixes' is not a session branch
expected: claude-session-<workspace>-<YYYYMMDDHHMMSS>
generate one with session_branch_new, never by hand
$ echo $?
2
The third line is what turns the convention into a contract. A branch that does not parse should be rejected at creation, loudly, with the generated alternative in the message — because the cost of being told that name will not work, use this one is a few seconds, and the cost of finding out later is a duplicate PR and a reversed commit order.
The second line is the one people skip. Once two different places parse the same string with two different inline patterns, the format has silently forked, and a change to the generator fixes one consumer and breaks the other. One module that both produces and recognises the name keeps that impossible — and it is also the only way the gate can be correct, since it has to accept exactly what the generator emits and nothing else.
How we proved it
The failure was concrete. A hand-typed branch name produced a second pull request for work that already had one, and the commit ordering across three repositories was no longer bottom-up. Both are observable afterwards: the duplicate is in the PR list, and the ordering shows up as a pointer commit whose target did not exist yet.
And the diagnosis was available the whole time — the discovery step was reporting that it found no matching branch, which is correct, true, and completely unalarming. Every piece of information needed to understand this was in the output. It was not actionable because an empty result does not look like a failure.
What I have not solved: the branches that already exist. Anything created before the gate still carries whatever name it was given, and the gate only fires at creation. There is no sweep that finds non-conforming session branches already in flight and tells you which automations are going to miss them. I know the count is small; I do not know that it is zero, which is a different statement.
And a limitation I want to be honest about: this works because one command creates every session branch. Where people also create branches by hand — directly, through the platform's web UI, from an IDE — the gate is not in the path at all, and the contract is back to being a convention. I have not solved that, and I am not sure it is solvable without a server-side hook. What I have is a contract that holds on the path the tooling actually uses, which covers my case and would not cover a larger team's.
What this generalizes to
Any string that one system generates and another parses is a protocol, and it does not stop being a protocol because it is short, human-readable, or stored somewhere informal.
Branch names, bucket keys, queue names, log prefixes, file names in a watched directory, topic names — all of these routinely carry structure that something downstream depends on, and none of them are usually declared anywhere as an interface. The tell is a split: something generates the value and something else interprets it. The moment those two exist, the format between them is a contract, whatever the thing is called.
So when you find yourself improving a generated identifier because it looks untidy, the question is not whether your version is nicer. It is who reads this, and what do they do when they do not recognise it. If the answer is "returns empty and continues", you have found a place where being wrong looks exactly like there being nothing to do.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".