Skip to content

Derive the Runtime Host compatibility epoch instead of hand-editing it #3313

Description

@Astro-Han

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 > 27 still 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.keys ordering. 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

  1. yihanzhu commented on Aug 20, 2026

    @yihanzhu
    Contributor

    I'd like to take this one :)

  2. Astro-Han commented on Aug 20, 2026

    @Astro-Han
    ContributorAuthor

    Assigned!

  3. yihanzhu commented on Aug 20, 2026

    @yihanzhu
    Contributor

    One more direction worth weighing for the dev list: keep the integer but make each bump a new file (protocol/epochs/28.md describing 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.

  4. yihanzhu commented on Aug 21, 2026

    @yihanzhu
    Contributor

    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

  5. Astro-Han commented on Aug 21, 2026

    @Astro-Han
    ContributorAuthor

    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.

  6. Astro-Han commented on Aug 26, 2026

    @Astro-Han
    ContributorAuthor

    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_EPOCH off the head of all 127 open PRs:

    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:218
    • packages/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:458 and client/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:

    1. Discriminator — "do we share a wire contract?" Needs only inequality. Needs to differ whenever the contract differs.
    2. 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 optional generation, 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.mjs already 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>.md idea 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 beat main.

    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.org if 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 main and 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions