Repository navigation
Derive the Runtime Host compatibility epoch instead of hand-editing it #3313
Description
Activity
I'd like to take this one :)
Assigned!
Reacted by Yihan ZhuOne more direction worth weighing for the dev list: keep the integer but make each bump a new file (
protocol/epochs/28.mddescribing the change), deriving the constant from the highest number. Two branches taking the same number then hit an add/add conflict instead of merging silently — collisions become loud at merge/rebase time without the canonicalization risk of the digest. The CI check in #3339 stays useful either way since it catches forgotten bumps, which the file scheme alone doesn't.- added a commit that references this issue
on Aug 21, 2026 Now that #3339 has merged and prevents silent same-number merges, should #3313 remain open for the derived-discriminator design, or is the CI guard sufficient as the project’s current endpoint?
cc @Astro-HanThanks for following up, and for landing the corrected guard in #3339. I think the guard is sufficient as the project’s current endpoint, so we can close this issue.
The material failure here was the silent merge of incompatible protocol changes under the same epoch. Comparing the synthetic merge result with
HEAD^1, together with the sibling-branch Git-graph regression, now turns that into an explicit CI failure before merge.A derived discriminator would be a broader architecture change rather than a remaining correctness fix. Our protocol contracts are still expressed through executable codecs rather than one complete canonical schema, so deriving a digest without either missing semantic changes or introducing incidental churn would require a separate design decision. I do not think the remaining manual bump cost currently justifies that complexity.
If repeated epoch maintenance becomes a concrete problem again, we can take the canonical-contract question to the dev list and open a focused follow-up with those requirements. For now, I suggest treating #3339 as resolving #3313. Thanks again for taking this through review and fixing the merge-base edge case.
— Posted with OpenAI Codex assistance.
Recording an observation against this closed issue rather than opening a new one. I am not asking to reopen it — the guard from #3339 does what it claimed, and I verified it is still wired in at
.github/workflows/ci.yml:74-79(node scripts/protocol-epoch-check.mjs --base 'HEAD^1', with its own test alongside). The failure this issue was about — two incompatible protocols merging silently under one number — remains guarded.What follows is the accepted cost of the manual scheme showing up at a scale worth writing down, plus a correction to something I asserted when I closed this.
The observed cost
I read
RUNTIME_HOST_COMPATIBILITY_EPOCHoff the head of all 127 open PRs:- 22 PRs declare 50, which is what
maincurrently holds - 4 PRs declare 51: feat(desktop): show context compaction in transcript #3651, fix(desktop): make proxy credential updates explicit and Host-owned #3704, feat(desktop): warn that deleting a parent keeps and archives its subtasks #3781, feat(workhub): add typed action gate #3818
So the next number is contested four ways, and everything else has already gone stale. #3299 is the visible case: it has been blocked on this number four separate times — 27→28 in the original three-way collision here, then 49 against #3651, then 49 against
main's 50 — and never once on the change itself, which has been approved and green throughout.Worth being precise about which pain this is. It is not the silent-merge failure; the guard catches that. It is churn: a contributor who picked correctly still has to re-pick on every rebase, and the changelog comment block conflicts by construction because every branch edits the same two lines.
Correction: the epoch is no longer compared by equality alone
When I closed this I wrote that every runtime comparison is equality, so the value need not be a monotonic integer. That is no longer true on
main. Two ordered comparisons have appeared since:packages/cli/src/runtime-host-cli-context.ts:218packages/cli/src/runtime-host-capability-provider-command.ts:187
Both are
handshake.compatibilityEpoch < RUNTIME_HOST_COMPATIBILITY_EPOCH.But the nuance matters, and it is what makes the rest of this worth writing. Neither one gates admission. Admission is still pure equality, at
host-kernel.ts:458andclient/connection.ts:1380. Both ordered sites run after the peer has already been rejected as incompatible, and they use the direction only to choose the wording of the diagnostic — "the remote Host is older than this capability provider, upgrade it" versus a bare protocol-range mismatch.So ordering is a diagnostics affordance, not a correctness requirement. That is a better position than I assumed at closing time, not a worse one.
The reframe I would put to the list
One hand-edited integer is currently doing two unrelated jobs:
- Discriminator — "do we share a wire contract?" Needs only inequality. Needs to differ whenever the contract differs.
- Recency — "which of us is older?" Needs ordering. Used only to word a diagnostic.
Every coordination cost in this issue comes from job 2 being welded onto job 1. Job 1 alone imposes no requirement that two contributors agree on a number; monotonicity does. Split them and the churn disappears without needing to settle the harder derivation question, because the discriminator stops having to be larger than anything.
The recency half then needs some ordered value, though it does not have to be this one — the handshake frames already carry
protocolMin/protocolMax,compositionRevision, and an optionalgeneration, and picking which of those carries recency is part of the design rather than something I have settled here.On the discriminator half, for DX specifically
The digest over the protocol surface, as originally sketched above, removes coordination entirely but has the cost I flagged then: canonicalization has to be perfect, or the value churns on refactors that changed no contract. A spurious churn is a worse outcome than the status quo, because it forces an incompatibility on users who did not need one.
There is a variant that I think dominates it on DX, and it was not considered when this was open. Keep a derived value, but derive it from an explicitly declared set of capability tokens rather than from the inferred shape of the code. A protocol change adds one entry naming what it added —
workhub-coordination-summary, say — and the wire value is a hash of the sorted set.- Nobody discovers a free number; you name your own change. Two branches picking the same descriptive name is near-impossible, and would be an add/add conflict anyway.
- Rebase does not touch your token, so the churn is structurally gone.
- No shared line, so no conflict on the changelog.
- The canonicalization risk of the pure digest does not apply, because the input is a human-declared list rather than something inferred from source. A refactor that renames a field cannot move it by accident.
- You can forget to add a token — and that is exactly the case
scripts/protocol-epoch-check.mjsalready detects, so the guard keeps earning its keep instead of being replaced. - It buys a diagnostic that no integer can give. Today the best a mismatch can say is "epoch 49 versus 50", which tells a user nothing about what is wrong. A set can be diffed, so the handshake failure can name the capability the peer lacks.
@yihanzhu's per-file
protocol/epochs/<n>.mdidea from this thread sits between the two: it makes a collision loud at rebase instead of silent, and it removes the shared-line conflict, but it keeps both the manual pick and the re-pick on rebase, because the number still has to beatmain.None of this is a proposal to implement. It is a material decision about a public compatibility contract, so it belongs on
dev@maka.apache.orgif anyone wants to take it up, and the guard is a legitimate resting point until someone does.
Filed with Claude Code (Opus 5) assistance. The 127-PR epoch scan, the two ordered call sites, the equality-only admission path, and the CI wiring were each read from current
mainand from the PR heads rather than carried over from the original issue.
Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.
- 22 PRs declare 50, which is what
Problem
RUNTIME_HOST_COMPATIBILITY_EPOCHinpackages/runtime-host/src/protocol/index.tsis a hand-edited integer, and every contributor who changes the wire contract has to guess a value no one else has taken. Right nowmainis at 27 while three independent open PRs each set it to 28 for different, mutually incompatible wire changes — #3236 (access credentialprepare/finalize), #3199 (goal.arm), #3133 (session trace cursor pages) — and #3299 takes 29 on the assumption that exactly one 28 lands.What makes this worth fixing rather than just coordinating around is that the collision is silent by construction:
assert epoch > 27still pass on all of them.So two incompatible protocols end up advertising one epoch.
client/connection.tsthen admits the peer because the epochs match, and the unknown operation fails decode and tears down the transport — bypassing the structuredincompatiblehandshake the epoch exists to provide. The failure surfaces to a user as a connection that dies instead of a version mismatch that explains itself.This is not the first time; it recurs whenever two protocol changes are open at once, and rebasing does not surface it.
Desired outcome
Two peers advertise the same compatibility discriminator if and only if they actually share a wire contract, without any contributor having to know what other branches are doing.
A useful constraint for whoever picks this up: the epoch is never ordered at runtime. Every comparison is equality —
client/connection.ts:1287,:1328,:1448, and the corresponding check inhost-kernel.ts— and the- 1occurrences are test fixtures constructing "some other epoch". So the value does not have to be a monotonically increasing integer. It only has to differ whenever the contract differs.Alternatives or workarounds
Derive it from the protocol surface. Replace the hand-edited constant with a digest computed at build time over the canonical contract — the aggregated
*_OPERATION_SPECSinprotocol/operations.tstogether with the frame and codec key lists. Two branches that add different operations then produce different digests on their own, which makes the collision impossible rather than merely detectable, and removes the "what number is free?" question entirely. It also gets the converse right: a refactor that preserves the contract yields the same digest, whereas the manual scheme encourages a bump whenever someone is unsure.Costs worth weighing before committing to this:
Object.keysordering. If the input is sloppy the digest churns on unrelated edits, which is worse than the status quo.Keep the manual bump and add a merge-base check. A CI check that compares the epoch on the PR head against the epoch on the merge base, failing when they are equal while anything under
protocol/changed. Much cheaper, and it can land today. It does not remove the manual work, and being per-PR it still cannot see a sibling branch — but it converts a silent bad merge into a red run for the second PR to land, which is the part that currently has no guard at all.These are not exclusive; the check is a reasonable interim step either way.
Whichever direction is chosen, this is a material decision about a public compatibility contract, so it probably belongs on
dev@maka.apache.orgrather than being settled in a PR thread. The immediate three-way collision also needs resolving before those PRs land, independent of this issue.Filed with Claude Code (Opus 5) assistance. The epoch values were read from
mainand from each referenced PR head, and the equality-only comparison claim was verified against every use of the constant in the repository.