0104 - Parent bindings are L1 lifecycle
A resource may name one parent at spawn. The binding is immutable and server-enforced: closing a parent closes every child with CloseReason::ParentClosed,...
Full source summary
A resource may name one parent at spawn. The binding is immutable and server-enforced: closing a parent closes every child with CloseReason::ParentClosed, atomically under the single state lock, and closing a child never affects the parent. One level only in v1; an AgentSession requires a Terminal parent and a Terminal has none. The relation is lifecycle, not metadata, so it lives on L1 beside atomic teardown.
Status: Accepted Date: 2026-09-09
Context
An AgentSession
(ADR-0103) lives
inside a Terminal: the harness process that produces its records is the PTY
child, and when that Terminal closes the session has nothing left to report.
Something has to close it, and every observer has to agree when.
phux already has a place for relations between served things.
ADR-0027 put tags and group
links in L3 as phux.link/v1, opaque to the server and resolved by clients,
and that has held for every relation since. It holds because those relations
are advisory: nothing breaks if two clients disagree for a moment about which
Terminals are grouped.
ADR-0030 admitted exactly one irreducible group operation, atomic teardown, because a projection cannot make N kills atomic and a concurrent observer would see the partial state. That was stated as “one op, not a tier”.
Decision
- A parent is named at spawn and never changes.
SPAWN_RESOURCEfield 12parent: ResourceId. The server validates it:ParentNotFoundwhen it does not resolve,ParentKindMismatchwhen the kind does not accept that parent. There is no verb to rebind. - Closing a parent closes its children. A parent leaving for any reason
(exit,
KILL_RESOURCE,KILL_RESOURCES, server shutdown) closes every child withCloseReason::ParentClosed. The cascade runs under the singleServerStatelock, in the same acquisition that removes the parent, so no client can observe a child whose parent is gone. This is the guaranteeKILL_RESOURCESalready gives a batch. - Closing a child never affects the parent. An
AgentSessionending leaves its Terminal running. RESOURCE_CLOSEDcarries the reason. New field 3reason: CloseReason { Exited = 0, Killed = 1, ParentClosed = 2, ServerShutdown = 3, Unknown }. A consumer can tell a cascade from a kill without correlating frames.- One level in v1. A resource with a parent cannot itself be a parent.
AgentSessionrequires a Terminal parent; a Terminal has no parent. A deeper tree is a later ADR. - Parents federate like ids. A parent id is a
ResourceIdand routes as one. A hub retags parent ids in its aggregate inventory exactly as it retags resource ids, so a satellite child is reported under its satellite parent and never under a hub-local id. ResourceInfocarriesparentas a trailing additive field, so a consumer reading the inventory has the tree without a second query.- The server keeps the graph.
ServerStateholdschildren: HashMap<ResourceId, Vec<ResourceId>>; the cascade reads it inkill_resource(s)and on Terminal exit, under the one lock.
This amends ADR-0027 (the parent relation leaves L3; phux.link/v1 stays for
advisory, client-defined relations) and ADR-0030 (the irreducible set is two
operations, atomic teardown and parent cascade). It builds on
ADR-0102.
Why
The same argument that earned atomic teardown earns this. ADR-0030 kept
KILL_TERMINALS on L1 because no projection can make a group teardown
atomic. A resource that lives inside another has that problem in the
lifecycle direction: no client can guarantee the child closes when the parent
dies, because the client that would do it may be detached, racing, or
absent, and the Terminal can exit without any client asking. Only the process
that removes the parent can close the child in the same step. That makes the
relation lifecycle, and lifecycle is the L1 list.
Metadata cannot express it. phux.link/v1 is last-writer-wins bytes the
server does not read. A cascade encoded there is a client convention that
every consumer must implement identically, and a consumer that does not
leaves an orphan whose stream never ends. The one thing ADR-0027 was careful
to keep out of the server is the one thing this needs from it.
Immutable, because rebinding is a second lifecycle. A movable parent needs a verb, an event, a race with the cascade, and a federation story for a child whose parent moved hosts. None of that has a consumer. Spawn-time binding gives the child exactly one parent for its whole life and the server one invariant to hold.
One level, because two kinds need one edge. An AgentSession under a
Terminal is the only binding this program creates. A general DAG would have
to decide cycle detection, cascade order across levels, and what a
mid-tree kill means, for no caller.
Tradeoffs
- The Cockpit projection lags the cascade. Cockpit does not yet drop the
child row on
ParentClosed; tracked as phux-am9y.25. - A parent’s exit is a fan-out under the lock. One Terminal closing now also closes its children before the lock releases. Bounded by the one-level rule and by there being one session per harness in practice.
- A frozen parent is a frozen placement. A session cannot follow its
harness if the harness is restarted in a different Terminal; it closes
with the old one and a new session opens under the new one, which is what
ADR-0068’s
native_idis for. UnknowninCloseReasonis a client obligation, like every open enum here: an unrecognised reason is displayed as closed, not dropped.- Cross-host atomicity is out of scope, as it is for
KILL_RESOURCES: a hub-side parent with a satellite child cannot exist under the one-level and kind rules, so the case does not arise in v1.
Alternatives
Parent as an L3 link with a client-side cascade. Rejected: it races the parent’s own exit, it depends on a client being attached, and partial states (a child with no parent) are observable by every other client while the cascade runs.
Arbitrary DAG bindings. Rejected for v1: cycles, cascade ordering across levels, and a movable-parent story, with no consumer that needs any of it. The field shape does not preclude a later depth rule.
Reuse the owner_terminal spawn field. Rejected: that field
(ADR-0050) is a placement hint that
tells the server which layout slot a new Terminal lands beside; it is not
lifecycle, it applies to Terminal-kind only, and giving it a second meaning
would make every existing spawn a binding.