L3 — Metadata storage

L3 — Metadata storage

TL;DR. The OPTIONAL typed-key-value service the server hosts but does not interpret. Scopes are Terminal, Collection, and Global; values are opaque bytes. Reference frames carry value inline on change to avoid a round-trip. The reference TUI persists its layout, window order, and focus pointer here as a non-normative convention.


1. L3 message catalog

A typed key-value store the server hosts and does not interpret. Scopes:

  • Terminal { terminal_id, key, value }
  • Collection { collection_id, key, value } — present only if L2 is also implemented
  • Global { key, value }

Values are opaque bytes. The server enforces nothing beyond size limits. A recommended convention is CBOR-encoded structured data with a versioned key (phux.tui.layout/v1, phux.tui.window_order/v1); see §3 for the reference-TUI schema.

L3 messages, allocated by phux-4li.2 (commands + push) and phux-4li.8 (GET/LIST replies):

IDDirectionNameReferenceStatus
0x50C → S (cmd)GET_METADATA§2shipped
0x51C → S (cmd)SET_METADATA§2shipped
0x52C → S (cmd)DELETE_METADATA§2shipped
0x53C → S (cmd)LIST_METADATA§2shipped
0x54C → SSUBSCRIBE_METADATA§2shipped
0xD0S → CMETADATA_CHANGED§1shipped
0xD1S → CMETADATA_VALUE§1shipped
0xD2S → CMETADATA_KEYS§1shipped

Wire bodies (positional, per appendix-encoding.md primitives):

GET_METADATA       { request_id: u32, scope: Scope, key: str }
SET_METADATA       { request_id: u32, scope: Scope, key: str, value: bytes }
DELETE_METADATA    { request_id: u32, scope: Scope, key: str }
LIST_METADATA      { request_id: u32, scope: Scope }
SUBSCRIBE_METADATA { scope: Scope, key: str }
METADATA_CHANGED   { scope: Scope, key: str, value: optional<bytes> }
METADATA_VALUE     { request_id: u32, value: optional<bytes> }
METADATA_KEYS      { request_id: u32, keys: list<str> }

Scope = tagged_union {
    TERMINAL   (TerminalId),     // tag 0x00
    COLLECTION (CollectionId),   // tag 0x01; u32 wire body (L2 may grow
                                 //   this to a Local/Satellite tag, same
                                 //   as TerminalId under ADR-0016)
    GLOBAL,                      // tag 0x02; empty body
}

A client subscribing via SUBSCRIBE_METADATA { scope, key } MUST receive METADATA_CHANGED when that specific (scope, key) is written or deleted. The matching value is carried inline: value: Some(bytes) on a SET, value: None (tombstone) on a DELETE. The earlier “consumers GET_METADATA after the change-notification” shape is dropped; phux-4li.2 lifts the value into the event because the ADR-0019 layout-coordination use case is a read-on-every-change pattern and the round trip is wasteful.

Reply paths for GET_METADATA and LIST_METADATA ride dedicated S→C frames (phux-4li.8): METADATA_VALUE { request_id, value: optional<bytes> } correlates to a prior GET_METADATA.request_id (with value: None when the key is absent), and METADATA_KEYS { request_id, keys } correlates to a prior LIST_METADATA.request_id (lexicographically sorted; values are NOT included, clients fetch them separately via GET_METADATA).

The earlier draft routed these through a generic COMMAND_RESULT envelope (L1.md §commands). Dedicated frames are simpler and consistent with the phux-4li.2 precedent of METADATA_CHANGED carrying value inline (also a departure from the original sketch): the L3 metadata family is opinionated against the generic envelope where it would cost a round-trip the consumer always pays. COMMAND_RESULT remains reserved for L1/L2 commands that genuinely need its tagged union (e.g. SPAWN returning a TerminalId); it does NOT subsume the L3 reply frames.

The reply frames, like METADATA_CHANGED, MUST NOT be emitted to a consumer whose HELLO.client_caps.layers does not include L3 (proto.md §11.5). A server that receives an L3 request from a non-L3 consumer MAY drop the request silently (matching the SUBSCRIBE_METADATA precedent) or reply with ERROR { OUT_OF_TIER } once that code is allocated.

Subscription scope: subscriptions are connection-scoped. A client’s subscriptions are dropped automatically on DETACH (proto.md §7.2) and on transport close. There is no explicit UNSUBSCRIBE_METADATA in v0.1.

L3 conformance gating: the server MUST NOT emit METADATA_CHANGED to a consumer whose HELLO.client_caps.layers does not include L3 (per proto.md §11.5). The server MAY silently drop SUBSCRIBE_METADATA from such a consumer or reply with ERROR { code: OUT_OF_TIER } once that error code is allocated.

L2 dependency: Scope::Collection references a CollectionId that L2 has not yet wire-allocated. Until L2 ships, v0.1 servers expose a single default Collection at CollectionId(1) so the reference TUI’s phux.tui.layout/v1 key (ADR-0019) has a Collection to write into. The wire shape of CollectionId is a bare u32 in v0.1; L2 may grow it into a Local/Satellite tagged union the same way TerminalId did under ADR-0016, but that growth is a v0.2 wire bump and out of scope for phux-4li.2.


2. L3 commands

Reserved by ADR-0015. Wire discriminants are allocated above in §1; the command envelope shape is preserved for forward-compatibility with future tier-spanning operations.

Command_L3 = tagged_union {
    GET_METADATA     { scope: MetadataScope, key: str },
    SET_METADATA     { scope: MetadataScope, key: str, value: bytes },
    DELETE_METADATA  { scope: MetadataScope, key: str },
    LIST_METADATA    { scope: MetadataScope, prefix: optional<str> },
}

MetadataScope = tagged_union {
    TERMINAL   (TerminalId),
    COLLECTION (CollectionId),   // L2 must also be in tier set
    GLOBAL,
}

The server MUST NOT interpret metadata values. Implementations MAY enforce a per-key size limit (recommended: 256 KiB) and return RESOURCE_EXHAUSTED if exceeded.


3. TUI consumer conventions (non-normative)

This section is non-normative. It documents how the reference TUI uses L3 metadata to maintain its session / window / pane / layout / focus vocabulary on top of the L1+L2+L3 substrate. Other consumers MAY ignore this section entirely; a conforming consumer need not implement, recognize, or even acknowledge these conventions.

Per ADR-0017, the reference TUI is one consumer among several. The vocabulary in this section — window, pane, layout tree, focus, session-as-presented-by-the-TUI — is the TUI’s product shape, not a wire concept. It exists here so that an alternative TUI implementation can shadow the reference TUI by reading and writing the same metadata keys.

DESIGN.md is the more detailed home for this vocabulary. The schema below is the seam between the two documents.

3.1 Where TUI state lives

Every piece of TUI state is an L3 metadata key. The TUI reads on attach, watches for METADATA_CHANGED, and writes on user action. The server does not interpret values; it is opaque storage.

Keys are versioned (/v1) so future schemas can co-exist with old clients. Values are CBOR-encoded structured data unless noted.

3.2 phux.tui.layout/v1 — the layout tree

Scoped to a Collection. Contains the binary-split layout tree the reference TUI paints. The schema (one Collection’s “session” in tmux vocabulary):

Layout = {
    windows: list<Window>,
    focused_window_index: u32,
}

Window = {
    name: str,
    root: LayoutNode,
    focused_terminal: TerminalId,
}

LayoutNode = tagged_union {
    LEAF  { terminal_id: TerminalId, weight: u16 },
    SPLIT { direction: SplitDirection,
            children: list<LayoutNode>,
            weights: list<u16> },
    TABBED { children: list<LayoutNode>, active: u32 },  // reserved
}

SplitDirection = enum { HORIZONTAL = 0, VERTICAL = 1 }

The binary-split-not-n-ary decision from ADR-0012 continues to apply to this layout schema — i.e. to the TUI — not to the wire.

3.3 phux.tui.window_order/v1 — window order

Scoped to a Collection. A list<u32> of stable window indices in the consumer’s preferred display order. The TUI uses this to drive tab-bar ordering.

3.4 phux.tui.focus/v1 — focus pointer

Scoped per-client (Global key namespaced by client UUID, since the server does not expose ClientId as a metadata scope). Records which Terminal the TUI’s local user is currently aiming input at:

Focus = {
    collection_id: CollectionId,
    window_index:  u32,
    terminal_id:   TerminalId,
}

This is per-client state. The TUI does not synchronize it across attached clients; each attach has its own focus. The L1 INPUT_FOCUS message (input.md §INPUT_FOCUS) is unrelated — it carries host-OS focus state into the Terminal so VT-aware programs can react.

3.5 What the TUI does NOT use

  • No “session” wire concept. “Session” in tmux vocabulary is the TUI’s name for an L2 Collection. The wire knows Collections.
  • No WindowId on the wire. Window indices are positions within phux.tui.layout/v1; they have no protocol identity.
  • No LAYOUT_CHANGED event. Layout changes are METADATA_CHANGED { scope: Collection, key: "phux.tui.layout/v1" }. Subscribers re-fetch the value to learn the new tree.
  • No FOCUS_CHANGED event. Focus changes are METADATA_CHANGED on the per-client focus key.
  • No WINDOW_OPENED / WINDOW_CLOSED / WINDOW_RENAMED events. Window lifecycle is “I edited phux.tui.layout/v1”; observers see METADATA_CHANGED.

3.6 Alternative consumers

A native GUI consumer mounting L3 MAY (and SHOULD) use its own metadata keys with a different prefix (e.g. app.foo.layout/v1) rather than reusing the TUI’s schema. Sharing schema across consumers is opt-in, not the default; the wire enforces no agreement. An agent SDK consumer typically declares HELLO.layers = { L1 } and ignores this section entirely.