L3 — Metadata storage

Protocol reference

L3 — Metadata storage

TL;DR. The OPTIONAL typed key-value service the server hosts but does not interpret. Scopes are Terminal, Group, and Global; values are opaque bytes with a conventional CBOR-and-versioned-key shape. This document owns the metadata model and the grouping/session-name conventions consumers use to build sessions, groups, and layouts on top of L1, since there is no L2 collection tier.


1. L3 message catalog

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

  • Terminal { terminal_id, key, value }
  • Group { group_id, key, value }GroupId is an opaque grouping key, not a lifecycle tier (see L2.md)
  • Global { key, value }

Values are opaque bytes. The server enforces nothing beyond size limits. The conventional value shape is CBOR-encoded structured data under a versioned key (phux.session.name/v1, phux.tui.layout/v1); see §3 for the conventions consumers share.

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 (field-tagged TLV, per appendix-encoding.md; fields listed in field-id order, leaf primitives and nested unions positional within each field):

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
    GROUP      (GroupId),        // tag 0x01; u32 wire body
    GLOBAL,                      // tag 0x02; empty body
}

1.1 The inline-value rationale

Three S→C frames carry their value inline rather than make the consumer issue a follow-up GET_METADATA:

  • METADATA_CHANGED { scope, key, value }value: Some(bytes) on a SET, value: None (a tombstone) on a DELETE.
  • METADATA_VALUE { request_id, value } — correlates to a prior GET_METADATA.request_id, with value: None when the key is absent.
  • METADATA_KEYS { request_id, keys } — correlates to a prior LIST_METADATA.request_id; keys are lexicographically sorted and values are NOT included (clients fetch them with GET_METADATA, since a LIST result is large and most keys are not read).

The shared reason: the layout-coordination use case (ADR-0019) is a read-on-every-change pattern, so a separate notify-then-fetch round trip is waste the consumer always pays. An earlier draft routed GET/LIST replies through a generic COMMAND_RESULT envelope and had consumers GET after a change notification; both are dropped for these three frames. COMMAND_RESULT remains the envelope for L1 commands that need its tagged union (e.g. SPAWN returning a TerminalId); it does not subsume the L3 reply frames.

1.2 Subscriptions, conformance, and scope

A client subscribing via SUBSCRIBE_METADATA { scope, key } MUST receive METADATA_CHANGED when that specific (scope, key) is written or deleted.

The reply frames and 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 it silently (matching the SUBSCRIBE_METADATA precedent) or reply with ERROR { OUT_OF_TIER } once that code is allocated.

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.3.


2. L3 commands

Wire discriminants are allocated above in §1.

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),
    GROUP      (GroupId),        // opaque grouping key, not a tier
    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. Grouping and session conventions (non-normative)

This section is non-normative. It documents the conventional keys consumers use to build sessions, groups, windows, panes, and layouts on top of L1 metadata. There is no L2 collection tier (ADR-0030, L2.md), so these conventions plus client logic are where session and group vocabulary lives. The wire enforces no agreement; a consumer MAY ignore this section entirely.

Per ADR-0017, the reference TUI is one consumer among several. Its vocabulary — session, window, pane, layout tree, focus — is product shape, not a wire concept. These keys exist so an alternative consumer can shadow the reference TUI by reading and writing the same metadata.

Keys are versioned (/v1) so future schemas co-exist with old clients. Values are CBOR-encoded structured data unless noted. A consumer reads on attach, watches for METADATA_CHANGED, and writes on user action.

3.1 Session conventions

The session/collection lifecycle verbs that earlier code carried as L1 commands are withdrawn (ADR-0030); their behavior decomposes into SPAWN_TERMINAL plus the following metadata keys, with atomic group teardown served by the L1 KILL_TERMINALS op (L1.md). CLI, MCP, and TUI user-facing UX is unchanged.

  • phux.session.name/v1 — the human-facing group name. A SET on this key is a rename; there is no RENAME_SESSION wire verb. Value: a UTF-8 string. Scope: the group’s GroupId.
  • phux.session.create/v1 — a client-written request key describing a group to materialize (its name and the terminals to spawn into it). The client writes it, then issues the SPAWN_TERMINALs; this key records the intent, replacing the old CREATE_SESSION verb. Value: a CBOR record.
  • phux.session.created/v1 — the result a consumer reads back after the spawns complete: the assigned group id and member TerminalIds. This is the read-back path for phux.session.create/v1, replacing the CREATE_SESSION command result. Value: a CBOR record.

Group membership is the set of TerminalIds a consumer associates with a GroupId through these keys. The server stores it without interpreting, so it enforces no membership view; membership consistency is the client’s responsibility, and atomic teardown of the set is the one thing the client delegates to KILL_TERMINALS.

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

Scoped to a GroupId. The binary-split layout tree the reference TUI paints — one group’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 applies to this layout schema — the TUI — not to the wire. Cross-terminal references inside a layout follow ADR-0027: a layout names terminals by TerminalId, and an L3 link to a terminal is a metadata value, not a second wire identity.

3.3 phux.tui.window_order/v1 and phux.tui.focus/v1

  • phux.tui.window_order/v1 — scoped to a GroupId; a list<u32> of stable window indices in display order, driving tab-bar ordering.
  • phux.tui.focus/v1 — per-client state (a Global key namespaced by client UUID, since the server exposes no ClientId scope). Records which terminal the local user is aiming input at; not synchronized across clients. The L1 INPUT_FOCUS message (input.md) is unrelated — it carries host-OS focus into the terminal so VT-aware programs can react.

3.4 What these conventions do NOT use

  • No “session” or “window” wire concept. Both are names for structure encoded in metadata.
  • No LAYOUT_CHANGED / FOCUS_CHANGED / WINDOW_* events. A change is METADATA_CHANGED on the relevant key; subscribers re-read the value.

3.5 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 reuse the TUI’s schema. Sharing schema across consumers is opt-in, not the default. An agent SDK consumer typically declares HELLO.layers = { L1 } and ignores this section.

Unlike the rest of §3, the schema of these two keys is normative (ADR-0027 decision point 4): tags and links are a cross-consumer projection over TerminalId, so their meaning MUST NOT drift between clients. The server still stores the bytes opaquely and interprets nothing; “normative” constrains the consumers, not the wire. Both ride the existing SET_METADATA / GET_METADATA / LIST_METADATA / SUBSCRIBE_METADATA verbs (§2) — no new wire tag.

  • phux.tags/v1 — scoped to a TerminalId. Value: a UTF-8 JSON array of tag strings, each non-empty and free of the # sigil, the array duplicate-free, e.g. ["build","ci"]. An empty array or an absent key both mean “no tags”. The #tag selector (ADR-0027 decision point 5; tui.md §3) resolves to the set of TerminalIds whose phux.tags/v1 value contains tag, evaluated client-side against the snapshot exactly as a session/window name resolves — the server stays selector-agnostic (ADR-0017).

  • phux.link/v1 — scoped to the source TerminalId. Value: a UTF-8 JSON array of link records { "target": u32, "kind": str }, where target is the linked Terminal’s local wire id and kind is an open enum. v1 defines "group" (a soft grouping edge); a consumer that reads an unknown kind MUST preserve it on rewrite rather than drop it, so the vocabulary grows additively. A link is a metadata value, never a second wire identity — there is no LinkId.

Per-key size limits (§2, recommended 256 KiB) apply; a tag/link set that would exceed them is the client’s concern to bound.

3.7 phux.agent/v1 — agent identity and lifecycle

Like §3.6, the schema of this key is normative (ADR-0040): agent identity is a cross-consumer projection over TerminalId, so its meaning MUST NOT drift between clients. The server stores the bytes opaquely on the L3 read/write path; a reference server MAY additionally act as a writer of this one key (ADR-0046, and see “Server as a producer” below). The key rides the existing SET_METADATA / GET_METADATA / DELETE_METADATA / SUBSCRIBE_METADATA verbs (§2) — no new wire tag.

  • phux.agent/v1 — scoped to the TerminalId the agent runs in. Value: a UTF-8 JSON object:

    {
      "name":      str,            // REQUIRED, non-empty human-facing name
      "kind":      optional<str>,  // open vocabulary slug, e.g. "claude", "codex"
      "state":     optional<str>,  // OPEN enum: "unknown" | "idle" | "working"
                                   //            | "blocked" | "done"
      "attention": optional<str>,  // OPEN enum: "none" | "low" | "normal" | "high"
      "session":   optional<str>   // free-form association label (fleet/job name)
    }

    state and attention are OPEN string enums: a consumer reading an unrecognized value MUST treat it as unknown (for state) or normal (for attention) rather than fail the parse, so the vocabulary grows additively. An absent state means unknown; an absent attention is derived from state (consumers conventionally map blocked to high). An absent key or a value that is not a JSON object with a non-empty name means “no declared agent”.

    Writes are whole-record (last writer wins); there are no field-merge semantics. DELETE_METADATA clears the declaration. The Terminal scope IS the terminal association; the per-Terminal store is dropped when the Terminal closes, so a record never outlives its pane.

    A consumer that finds this record MUST prefer it over heuristic derivations (OSC-title conventions such as the §-adjacent phux-ask sentinel of ADR-0035, or screen scraping); heuristics remain the fallback when the key is absent. Backs phux agent set/clear, the phux agent list/show/explain provenance-ranked report, and the reference TUI’s sidebar/tab labels.

    Server as a producer. A server MAY derive this record for a Terminal it owns — from the Terminal’s own OSC title, its live screen, or its PTY’s foreground process — and write it on the same L3 path any other writer uses (ADR-0046). This is a convention on top of the existing verbs, not a format change: a server-derived record is byte-identical in shape to a declared one, and a consumer neither can nor needs to distinguish them. A server that does so:

    • MUST NOT overwrite a record whose state was supplied by an explicit SET_METADATA. An explicit declaration of state outranks any derivation for the lifetime of that record; the derivation resumes only after a DELETE_METADATA clears it.
    • MUST preserve the name, kind, and session fields of an identity-only declaration (one that supplied no state) when it fills state in.
    • MUST only DELETE_METADATA a record it authored itself, never one an explicit writer set.
    • SHOULD write only on a change of the derived value, so a long-running derived state produces no repeated METADATA_CHANGED broadcasts (a reference server already deduplicates an equal-bytes SET, §2).

    A consumer MUST NOT assume a server derives the record: the key is absent on a server that does not, exactly as before.

3.8 phux.config.reload/v1 — the config-reload doorbell

A pure signal key (phux-foz.5): its broadcast, not its value, carries the meaning. The server stores the bytes opaquely and interprets nothing; the key rides the existing SET_METADATA / SUBSCRIBE_METADATA verbs (§2) — no new wire tag.

  • phux.config.reload/v1Global scope. Value: an opaque, writer-chosen nonce (the reference CLI writes a UTF-8 unix-nanos-pid string). Its only requirement is to DIFFER from the previously stored value: a reference server deduplicates an equal-bytes SET (no broadcast), so a repeated constant would ring the doorbell at most once.

    A consumer subscribed to this key treats a non-tombstone METADATA_CHANGED as “re-read your local configuration now”: it re-runs its own config load and rebuilds its config-derived state in place. Configuration itself NEVER crosses the wire — each consumer reads its own file, so hosts with different configs each apply their own. A consumer whose re-read fails MUST keep its previous configuration intact (surface the error locally; never crash, never half-apply). Tombstones (DELETE_METADATA) are ignored — deleting the key is housekeeping, not a reload request.

    Writers SHOULD validate their local config before ringing the doorbell so an obviously broken file fails at the writer with a useful error instead of fanning out no-op reload attempts. Backs phux config reload and the reference TUI’s in-place reload (tui.md §4.3).