Data model
The in-process types the server manipulates: the resources it owns (a Terminal is the first kind, an agent session the second), the parent bindings between...
Full source summary
The in-process types the server manipulates: the resources it owns (a Terminal is the first kind, an agent session the second), the parent bindings between them, the grouping metadata over them, and the attached clients. Pure-data phux-core::Registry on slotmaps with generational keys; I/O state lives separately on phux-server::ServerState. This shape is distinct from the wire; the bridge crosses at IdBridge. Grouping is metadata over resources, not a built collection type.
The server is a graph of long-lived nodes with stable identity. The domain
(phux-core) uses one SlotMap per node type rather than Rc<RefCell<>>
because:
- Stable IDs are exactly what the wire protocol needs anyway.
- Cross-references (“this client’s active terminal”) become an ID, not a borrowed reference — no aliasing problem.
- Deletion is
O(1)and slotmap’s generational keys catch use-after-free in tests.
The shape splits in two: the domain (pure data, in phux-core) and the
attached-client + I/O state (in phux-server). The split is deliberate;
see ADR-0008 and the crate-graph note above.
Resources and kinds
Everything the server serves is a ResourceDescriptor. ResourceKind is a
#[non_exhaustive] enum with two variants today, Terminal and
AgentSession; a consumer that meets a kind it does not know treats the
resource as opaque, never as a Terminal. The kind fixes which facet the
descriptor carries: a Terminal carries TerminalFacet { dims, cwd, title }
and a window slot; an AgentSession carries AgentFacet { provider, native_id, state } and a parent. Exactly the facet named by kind is
populated, and the Registry is the only constructor
(ADR-0102).
Grouping is metadata, not a collection tier
There is no L2 collection tier; see ../spec/L2.md.
Grouping a set of resources — what a user thinks of as a session — is L3
metadata plus client logic over the L3 metadata model,
keyed by an opaque grouping identity. GroupId is retained only as that
opaque key, not as a lifecycle entity the server creates, names, or tears
down. The lone irreducible group operation — atomic multi-resource teardown —
is a single L1 op (KILL_RESOURCES) rather than a tier. GroupId’s
retention as an opaque grouping key is settled, not a remnant awaiting
removal (bead phux-0bmc closed as resolved-by-rename).
The Registry’s Session and Window types are the in-process carriers of
that grouping metadata. They are domain bookkeeping, not a wire tier: under
ADR-0017 the session,
window, pane-focus, and layout vocabulary is a TUI-consumer convention stored
as L3 metadata, never a protocol-privileged concept.
// phux-core::registry::Registry — domain only, no I/O.
pub struct Registry {
sessions: SlotMap<SessionId, Session>, // grouping metadata, not a wire tier
windows: SlotMap<WindowId, Window>, // TUI L3 convention
resources: SlotMap<ResourceId, ResourceDescriptor>, // L1 resources of every kind
}
pub struct Session { id, name, windows: Vec<WindowId>, active: Option<WindowId> }
pub struct Window { id, session, slots: Vec<ResourceId>, layout: Option<LayoutNode>, active: Option<ResourceId> }
pub struct ResourceDescriptor {
id, kind: ResourceKind, parent: Option<ResourceId>,
window: Option<WindowId>, // Terminal kind only
terminal: Option<TerminalFacet>, // present iff kind == Terminal
agent: Option<AgentFacet>, // present iff kind == AgentSession
}
pub struct TerminalFacet { dims, cwd, title }
pub struct AgentFacet { provider, native_id: Option<String>, state: Option<String> }
// ResourceId is the slotmap key; location and kind are orthogonal.
// LayoutNode is a binary split tree of ResourceId leaves; only a Terminal
// occupies a window slot. Per ADR-0017 the whole tree (LayoutNode + Window
// + active-slot focus) is a TUI-consumer convention stored in L3 metadata,
// not a wire concept. ADR-0012's "binary split, not n-ary" decision applies
// to the TUI's tree, not the wire.
The PTY handle and libghostty_vt::Terminal for a Terminal are not fields
of the descriptor. They are server-side concerns and hang off ResourceId
in side tables in phux-server. Keeping the descriptor free of I/O is what
lets phux-core stay forbid(unsafe_code) and ship without an async
runtime.
The binding graph
A resource’s parent is set at creation and never changes.
Registry::new_agent_session(parent, facet) is the only way to create a
bound resource: it refuses an unknown parent (UnknownResource) or a parent
of another kind (ParentKindMismatch), so an AgentSession always hangs off
a live Terminal and a Terminal has no parent. One level only: a child holds
no children of its own. There is no inverse index; Registry::children (parent) scans the resource slotmap, which is O(N) in resource count and
fine for the same reason session lookup is (below).
Removal cascades downward and never upward. Registry::remove_resource(id)
removes the resource, then every resource whose parent is id, then
vacates the Terminal’s window slot; remove_window and remove_session
run the same cascade for every slot they hold. Removing a child never
touches the parent (ADR-0104).
Server-side state
// phux-server::state::ServerState — domain + clients + I/O.
pub struct ServerState {
pub registry: Registry,
pub attached: HashMap<ClientId, AttachedClient>,
// ResourceTable: one ResourceHandle per live resource, its cancel
// token, its subscribers, its output pumps, and the engine JoinSet.
resources: ResourceTable,
// Core ids (slotmap keys, generational) <-> wire ids (u32), for
// sessions, terminals, and windows. All three go through `IdBridge`.
pub idspace: IdSpace,
// Per-scope L3 key/value store (Terminal, group, global).
metadata: MetadataStore,
next_client_id: u64,
}
// phux-server::resource — what the table holds for any kind.
pub struct ResourceHandle {
pub kind: ResourceKind, pub parent: Option<ResourceId>,
pub output: broadcast::Sender<PaneOutput>,
pub consumer_attach, consumer_detach, consumer_ack, // ADR-0018 consumers
pub subscribe_to_events, unsubscribe_from_events, // semantic events
pub upgrade, control, // ADR-0032, ADR-0033
pub facet: ResourceFacetHandle, // non_exhaustive: Terminal | AgentSession
}
pub struct AttachedClient {
pub id: ClientId, // server-assigned, monotonic
pub session: phux_core::SessionId,
pub tx: tokio::sync::mpsc::Sender<OutboundFrame>,
}
ServerState is shared across tasks behind a single std::sync mutex; see
threading and I/O for why a synchronous mutex is safe on
the current-thread runtime and how KILL_RESOURCES applies atomically under
one acquisition.
The engine side of a resource is ResourceCore: kind, parent, wire id, the
checked output sequence, the output broadcast sender, the event-subscriber
registry and fan-out, the cancel token and exit notification, and the
control mailbox. Each engine (resource::terminal::TerminalActor,
resource::agent_session) embeds one, keeps its own kind-specific state
beside it, and builds the ResourceHandle in its constructor. Runtime code
never holds a TerminalHandle on its own: it holds a ResourceHandle and
calls ResourceHandle::terminal() where a grid, PTY, or input operation is
needed — the one place a request aimed at a resource of another kind becomes
a WrongResourceKind error.
Teardown runs under one lock acquisition. KILL_RESOURCES resolves every
wire id and cancels every engine inside a single with_mut, so no other
command interleaves between the first and last removal. A Terminal engine
that observes PTY EOF fires its exit notification; the exit watcher then
gathers the subscribers, reaps the domain entity through
ServerState::reap_terminal (cascading to the window and session when they
empty), and forgets the ResourceTable entry, all in the same critical
section, before the RESOURCE_CLOSED sends are awaited.
Session name lookup goes through Registry::sessions() rather than a side
index — it is O(N) in session count, which is fine: session count is small
(single digits typical, double digits worst-case) and an extra index would
have to be kept consistent across cascading deletes.
Status
No remaining target-versus-shipped gaps in the in-process types this
document owns. Parent cascade announces CloseReason::ParentClosed, the
runtime calls Registry::new_agent_session and writes AgentFacet.state,
and ResourceId is the key name on both sides of IdBridge.
| Gap | Today | Owner | Tracked |
|---|