Repository navigation
Derive the Runtime Host compatibility epoch instead of hand-editing it #3313
Description
Problem
RUNTIME_HOST_COMPATIBILITY_EPOCH in packages/runtime-host/src/protocol/index.ts is a hand-edited integer, and every contributor who changes the wire contract has to guess a value no one else has taken. Right now main is at 27 while three independent open PRs each set it to 28 for different, mutually incompatible wire changes — #3236 (access credential prepare/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:
- Every branch writes the same text to that line, so git's three-way merge resolves it with no conflict. Only the adjacent comment block conflicts, and keeping both comments is the natural resolution.
- Per-PR assertions of the form
assert epoch > 27still pass on all of them. - Nothing compares the merged value against the merge base.
So two incompatible protocols end up advertising one epoch. client/connection.ts then admits the peer because the epochs match, and the unknown operation fails decode and tears down the transport — bypassing the structured incompatible handshake 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 in host-kernel.ts — and the - 1 occurrences 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_SPECS in protocol/operations.ts together 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:
- The value stops being human-readable. Mitigation is to keep today's comment log as a plain changelog, decoupled from the wire value.
- Canonicalization has to be deliberate — sorted operation names, explicit key lists, no incidental dependence on declaration order or
Object.keysordering. If the input is sloppy the digest churns on unrelated edits, which is worse than the status quo. - Someone has to decide what enters the hash. Operation names alone are too coarse: a change to an existing operation's payload shape without a rename must still move the digest.
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.org rather 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 main and from each referenced PR head, and the equality-only comparison claim was verified against every use of the constant in the repository.
Activity
yihanzhu commentedon Aug 20, 2026
I'd like to take this one :)
Astro-Han commentedon Aug 20, 2026
Assigned!
yihanzhu commentedon Aug 20, 2026
One 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.ci: guard the compatibility epoch against silent same-number merges
yihanzhu commentedon 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-Han
Astro-Han commentedon Aug 21, 2026
Thanks 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.
Astro-Han commentedon Aug 26, 2026
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:maincurrently holdsSo 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:187Both 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:
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.scripts/protocol-epoch-check.mjsalready detects, so the guard keeps earning its keep instead of being replaced.@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.chore(runtime-host): declare the structured-only admission widening w…
chore(runtime-host): declare the structured-only admission widening w…