0011 — `phux-protocol` and `phux-core` are independent; `IdBridge` is their only meeting point
0011 — phux-protocol and phux-core are independent; IdBridge is their only meeting point
TL;DR. phux-core and phux-protocol have no dependency edge in either direction. Both define same-named IDs and mirror info types deliberately: core’s IDs are slotmap-generational keys for in-process safety, protocol’s are u32 newtypes for wire stability. The two namespaces only meet in phux-server’s IdBridge, which allocates wire IDs monotonically and never recycles them.
Status: Accepted Date: 2026-05-25
Context
phux-core and phux-protocol live as sibling workspace crates with
no dependency edge in either direction. Both crates define types
with the same names — SessionId, WindowId, PaneId, plus mirror
structs SessionInfo/WindowInfo/PaneInfo/LayoutNode/SplitDir —
and the two namespaces only meet inside phux-server, where the
IdBridge (crates/phux-server/src/id_bridge.rs) maps between them.
This boundary is the hardest architectural invariant in the codebase and the least self-evident from any one file. The two reasons it matters are:
-
Core’s IDs are slotmap-generational keys; the protocol’s IDs are u32 newtypes addressable across the wire.
phux_core::ids::*areslotmap::new_key_type!keys carrying a generation counter — the compiler uses that counter to catch use-after-free at test time (ADR-0001/0004 rest on this), and the bit layout is aslotmapimplementation detail that is explicitly not stable across releases.phux_protocol::ids::*are plainu32newtypes server-allocated monotonically from1, with0reserved as a sentinel — they are the addressable identifiers the wire codec writes into frames. Forcing one type to serve both jobs costs us either wire stability (if we publish slotmap keys) or in-process safety (if we drop the generation tag). We want both. -
phux-protocolis a published crate;phux-coreis not. Per ADR-0008, the protocol crate’sserverfeature depends onlibghostty-vtso it can re-export libghostty’s input and style atoms. The published-default surface (default-features = []) is the IDs, the protocol-version constant, and the codec — a git-dep-free shell thatcrates.ioanddocs.rscan build. This is the third-party-client story from ADR-0010: future CC-adapter crates, future tmux-CC compat shims, future Go/WASM viewers, and any iTerm2/Blink-style consumer that ever materializes attach by importingphux-protocoland nothing else. Lettingphux-protocoldepend onphux-corewould drag the PTY plumbing, the slotmap registry, and the in-process domain types into every consumer that only wants to speak the wire. Lettingphux-coredepend onphux-protocolwould either re-introduce the libghostty build chain into the domain crate (which currentlyforbid(unsafe_code)s and ships without an async runtime) or force the wire format to track in-process refactors.
The duplication between phux_core::Session/Window/Pane/LayoutNode/ SplitDir and phux_protocol::wire::info::{SessionInfo, WindowInfo, PaneInfo, LayoutNode, SplitDir} looks like technical debt on first
read. It is not. The wire types carry presentation-time denormalizations
(SessionInfo::window_count, SessionInfo::attached_client_count),
cross-language-friendly representations (i64 Unix seconds, not
SystemTime; String, not PathBuf), #[non_exhaustive] markers for
forward-compat wire evolution, and pub fields chosen for the codec.
The core types carry in-process semantics (a Vec<WindowId> ordered
list, PathBuf cwds, SystemTime timestamps) chosen for the
registry. Trying to unify them collapses one set of choices onto the
other.
New contributors have repeatedly tried to add From/Into impls
between core IDs and wire IDs and been blocked by clippy and review.
This ADR makes the constraint first not learned.
Decision
Three invariants. Stated as MUST.
1. No dependency edges between phux-core and phux-protocol
Neither crate’s Cargo.toml lists the other. phux-core does not
import phux_protocol::*; phux-protocol does not import
phux_core::*. PRs that add either edge are rejected.
2. Parallel ID types with identical names are intentional
phux_core::ids::SessionId // slotmap::new_key_type! — generational, opaque, in-process
phux_protocol::ids::SessionId // pub struct SessionId(pub u32) — wire-stable, server-allocated
Same shape for WindowId, PaneId. ClientId exists only on the
protocol side (clients are an attached-state concept, not a domain
concept). The two SessionIds are deliberately distinct types — they
MUST NOT acquire From/Into/AsRef conversions, nor a
super-trait abstracting over them. Code paths that need both spell
both out and route the conversion through IdBridge.
3. Bridging happens in phux-server::id_bridge::IdBridge only
IdBridge (crates/phux-server/src/id_bridge.rs, lines 33-137) is
the only place in the workspace that imports both
phux_core::ids::* and phux_protocol::ids::*. Its contract:
intern(core) -> wireis idempotent: calling it twice with the same core key returns the same wire id.- Wire id allocation is monotonic from
1. Zero is reserved as a sentinel for any futureOption<SessionId>encoding to claim without collision. forget(core)does not recycle the wire id. Once a wire id has been handed out it stays retired for the server’s lifetime — destroyed sessions reverse-lookup toNonerather than aliasing some new core key. This is what makes “wire ids are stable for the server’s lifetime” a contractphux-clientcan rely on for cache invalidation, predictive-echo state, and SPEC §13 attach replay.
The mirror snapshot types — phux_protocol::wire::info::{SessionInfo, WindowInfo, PaneInfo, LayoutNode, SplitDir} — follow the same
pattern: they duplicate core’s Session/Window/Pane/LayoutNode/SplitDir
deliberately and meet core’s types in phux-server (a parallel
info-bridge module, by convention, when it lands). The bridge code
is the price of the boundary; the boundary is what we’re buying.
Consequences
Positive
phux-protocolpublishes independently. Third-party-client implementers (per ADR-0010, including any future tmux-CC compat shim) depend onphux-protocolalone and never pull in slotmap, PTY plumbing, or the in-process registry.crates.ioanddocs.rssee a git-dep-free default surface; theserverfeature gate activates the libghostty-backed surface for consumers that need it.- The wire format evolves on its own version cadence (SPEC §6).
Bumping
PROTOCOL_VERSIONis aphux-protocolchange with zero semver impact onphux-core. Conversely, refactoring the slotmap layout inphux-corecannot cause a wire-format break — there is no path from aphux-corechange to aphux-protocolrecompile. phux-corestaysforbid(unsafe_code)and async-runtime-free. It is reachable from tests without spinning up the wire codec, the libghostty allocator, or any I/O.- Generational-key safety is preserved. Slotmap’s generation
counter catches use-after-free in core unit tests; the wire never
sees it, so a drop-and-recreate of a session changes its
coregeneration but itswireid is either still valid (if not yetforget-ed) or retired (if it was) — clients always see a monotone, never-aliasing id space.
Negative
- Type duplication is real.
SessionInfovsSession,LayoutNode(twice),SplitDir(twice) — five paired types today, potentially more as the snapshot graph grows. Every new piece of domain state that needs to ship inATTACHEDadds a mirror. - Bridge code in
phux-serveris mechanical but load-bearing.IdBridgeis ~100 lines today; an analogousinfo-bridgewill add more. Mechanical does not mean free — every conversion site is a place aNone-on-unknown-wire-id can leak into anunwrap()if the contract is misread. - New contributors must learn the constraint. It is invisible
from looking at either crate in isolation; it shows up as a
reviewer comment the first time someone reaches for a
Fromimpl. This ADR is the answer to “why was that rejected?”
Alternatives considered
Shared phux-types crate that both core and protocol depend on
Tempting and superficially clean. Rejected for v0.1 for three reasons.
First, it adds a third crate to maintain, version, and document
without removing the duplication: the wire-vs-internal differences
(PathBuf vs String, SystemTime vs i64, Vec<WindowId> vs the
denormalized snapshot triple) are real, not cosmetic — phux-types
would either pick one representation (forcing the other to convert
anyway) or hold both (achieving nothing). Second, the bridge code is
already small — IdBridge is ~100 lines including tests, and the
info-bridge will be similar — so the per-unit cost of conversion is
not the constraint. Third, the published-crate stance (consequence
#1 above) requires that the shared crate be git-dep-free too — which
either means phux-types cannot re-export libghostty atoms (gutting
its value, since the wire types compose them) or it must replicate
the default/server feature-flag dance that already lives in
phux-protocol. Net: more surface area, same duplication, no concrete
win.
Worth filing as a follow-up to revisit once the protocol
stabilizes (post-v0.1, after ATTACHED and PANE_SNAPSHOT ship and
we know the actual shape of the duplication). If phux-types would
absorb four or more paired types AND the in-process / on-wire shapes
have converged into one canonical form, the bookkeeping math flips.
Not before.
Single shared SessionId (and friends)
Considered and rejected on first principles. Slotmap generational
keys are not wire-stable — slotmap::KeyData packs an index and a
generation into a u64 (or u32-pair, depending on the key type)
whose bit layout is documented as an implementation detail. Drop-
and-recreate of a session changes its generation. Shipping that on
the wire either commits us to slotmap’s bit layout forever (worse
than the current wire format, which we own) or strips the generation
tag (losing the in-process use-after-free check that makes
forbid(unsafe_code) in phux-core work). Conversely, replacing
slotmap keys with bare u32s inside phux-core re-introduces the
ABA problem the slotmap was bought to solve. Two types is the right
answer.
phux-protocol depends on phux-core (downward edge)
Briefly considered when the snapshot-info types were designed —
mirror types could trivially be impl From<phux_core::Session> for SessionInfo if the edge existed. Rejected because every third-party
consumer of phux-protocol would transitively link phux-core — PTY
plumbing, slotmap, the registry. ADR-0010’s third-party-client story
is the constraint that breaks the tie: a tmux-CC compat shim, a
WASM viewer, or a Go phux-client has no business depending on the
in-process domain crate of the server.
phux-core depends on phux-protocol (upward edge)
Considered and rejected. Would force phux-core to either link
libghostty-vt (when the server feature is on) or peer through
default-features = false, neither of which buys anything — the
domain crate has no use for wire IDs and no use for libghostty atoms.
Adds a recompile chain (phux-protocol change → phux-core rebuild)
in the wrong direction.
References
crates/phux-server/src/id_bridge.rs:33-137—IdBridgedefinition, allocation contract, and tests (intern_is_idempotent,intern_allocates_monotonically_from_one,forget_does_not_recycle_wire_ids).crates/phux-server/src/id_bridge.rs:1-32— the doc comment that was previously the only written statement of the boundary; this ADR is its load-bearing twin.crates/phux-protocol/src/ids.rs— wire ID newtypes.crates/phux-core/src/ids.rs— slotmap-keyed core IDs.crates/phux-protocol/src/wire/info.rs:1-15— module doc spelling out “WITHOUT crossing the core/protocol independence boundary.”crates/phux-protocol/Cargo.toml— note the absence ofpublish = falseand thedefault/serverfeature split (the published-crate stance this ADR underwrites).crates/phux-core/Cargo.toml— notepublish = false(the in-workspace-only stance this ADR underwrites for the symmetric direction).- ADR-0008 — libghostty types re-exported by
phux-protocol; the published-crate boundary this ADR extends to the domain types. - ADR-0010 — frontend-agnostic; the third-party-client story this ADR’s no-cross-deps invariant makes mechanically achievable.
- ADR-0001/0004 — Rust and
libghostty-vtas the grid; theforbid(unsafe_code)-in-core stance the boundary preserves. - SPEC §6 — protocol version cadence (the version this boundary lets evolve independently of core).
- SPEC §13 —
ATTACHEDandSessionSnapshot(the consumer of the mirror types this ADR justifies).