L3 — Metadata storage
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 }—GroupIdis 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):
| ID | Direction | Name | Reference | Status |
|---|---|---|---|---|
| 0x50 | C → S (cmd) | GET_METADATA | §2 | shipped |
| 0x51 | C → S (cmd) | SET_METADATA | §2 | shipped |
| 0x52 | C → S (cmd) | DELETE_METADATA | §2 | shipped |
| 0x53 | C → S (cmd) | LIST_METADATA | §2 | shipped |
| 0x54 | C → S | SUBSCRIBE_METADATA | §2 | shipped |
| 0xD0 | S → C | METADATA_CHANGED | §1 | shipped |
| 0xD1 | S → C | METADATA_VALUE | §1 | shipped |
| 0xD2 | S → C | METADATA_KEYS | §1 | shipped |
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 aSET,value: None(a tombstone) on aDELETE.METADATA_VALUE { request_id, value }— correlates to a priorGET_METADATA.request_id, withvalue: Nonewhen the key is absent.METADATA_KEYS { request_id, keys }— correlates to a priorLIST_METADATA.request_id; keys are lexicographically sorted and values are NOT included (clients fetch them withGET_METADATA, since aLISTresult 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. ASETon this key is a rename; there is noRENAME_SESSIONwire verb. Value: a UTF-8 string. Scope: the group’sGroupId.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 theSPAWN_TERMINALs; this key records the intent, replacing the oldCREATE_SESSIONverb. Value: a CBOR record.phux.session.created/v1— the result a consumer reads back after the spawns complete: the assigned group id and memberTerminalIds. This is the read-back path forphux.session.create/v1, replacing theCREATE_SESSIONcommand 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 aGroupId; alist<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 noClientIdscope). Records which terminal the local user is aiming input at; not synchronized across clients. The L1INPUT_FOCUSmessage (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 isMETADATA_CHANGEDon 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.
3.6 phux.tags/v1 and phux.link/v1 — terminal tags and links
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 aTerminalId. 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#tagselector (ADR-0027 decision point 5; tui.md §3) resolves to the set ofTerminalIds whosephux.tags/v1value containstag, 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 sourceTerminalId. Value: a UTF-8 JSON array of link records{ "target": u32, "kind": str }, wheretargetis the linked Terminal’s local wire id andkindis an open enum. v1 defines"group"(a soft grouping edge); a consumer that reads an unknownkindMUST 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 noLinkId.
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 theTerminalIdthe 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) }stateandattentionare OPEN string enums: a consumer reading an unrecognized value MUST treat it asunknown(forstate) ornormal(forattention) rather than fail the parse, so the vocabulary grows additively. An absentstatemeansunknown; an absentattentionis derived fromstate(consumers conventionally mapblockedtohigh). An absent key or a value that is not a JSON object with a non-emptynamemeans “no declared agent”.Writes are whole-record (last writer wins); there are no field-merge semantics.
DELETE_METADATAclears 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-asksentinel of ADR-0035, or screen scraping); heuristics remain the fallback when the key is absent. Backsphux agent set/clear, thephux agent list/show/explainprovenance-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
statewas supplied by an explicitSET_METADATA. An explicit declaration ofstateoutranks any derivation for the lifetime of that record; the derivation resumes only after aDELETE_METADATAclears it. - MUST preserve the
name,kind, andsessionfields of an identity-only declaration (one that supplied nostate) when it fillsstatein. - MUST only
DELETE_METADATAa 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
stateproduces no repeatedMETADATA_CHANGEDbroadcasts (a reference server already deduplicates an equal-bytesSET, §2).
A consumer MUST NOT assume a server derives the record: the key is absent on a server that does not, exactly as before.
- MUST NOT overwrite a record whose
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/v1—Globalscope. Value: an opaque, writer-chosen nonce (the reference CLI writes a UTF-8unix-nanos-pidstring). Its only requirement is to DIFFER from the previously stored value: a reference server deduplicates an equal-bytesSET(no broadcast), so a repeated constant would ring the doorbell at most once.A consumer subscribed to this key treats a non-tombstone
METADATA_CHANGEDas “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 reloadand the reference TUI’s in-place reload (tui.md §4.3).