schema

package
v0.13.12 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 31, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package schema holds the daemon-free wire message types of the client<->daemon control surface: the Control envelope, its payload types, and the canonical remote-command tuple a phone signs (remote.go). Package protocol aliases every name declared here, so the split is invisible on the wire and to protocol's importers -- an alias IS the type.

It is a separate package because the problem is a dependency EDGE, not a type (PB-BIND-0, docs/specifications/remote-phaseB-requirements.md 4.2). A Go dependency closure is per PACKAGE: protocol also wraps a daemon.Daemon, so the phone core could not so much as name Control without dragging internal/daemon, internal/shim, internal/vt, internal/persist and github.com/creack/pty into the gomobile-bound closure shipped to a handset an adversary may hold -- against ADR-007 Decision 2, which deliberately keeps the PTY and the VT emulator off the network-facing edge.

Nothing here may import outside the bound allowlist (internal/phonecore/deps_allowlist.txt): standard library and internal/status.

The message schema is frozen and documented field-by-field in docs/specifications/protocol.md (kept in sync by the GG-7 drift check, which reflects these types through their protocol aliases).

Index

Constants

View Source
const (
	OutcomeApplied          = "applied"
	OutcomeUnknown          = "outcome_unknown"
	OutcomeUnknownOperation = "unknown_operation"
)

Operation-outcome states of the operation_status reconciliation read (ADR-017 T9 delivery vocabulary, machine side; ADR-007 D6's two-phase record is the source of truth). applied is authoritative and names the session; outcome_unknown is honest undecidability (a launch that died mid-flight, never silent); unknown_operation is an id this machine has no record of -- never an invented outcome.

View Source
const (
	ActionLaunch       = "launch"
	ActionKill         = "kill"
	ActionDelete       = "delete"
	ActionApprove      = "approve"
	ActionDeviceRevoke = "device_revoke"
	ActionTakeControl  = "take_control"

	// ActionTerminalWatch / ActionTerminalUnwatch start/stop a server-rendered terminal
	// peek for a session (A7 F2 wiring). Unlike the mutating actions above they are a
	// READ: the phone seals an UNSIGNED RemoteCommand carrying only the action + target
	// session, and the gateway routes it to its TerminalWatcher WITHOUT forwarding to the
	// daemon's device authenticator. The daemon still gates the peek itself (cap
	// remote-gateway + the kill switch, re-checked per snapshot in handleTerminalSubscribe),
	// so no device signature is required to merely ask the gateway to open the read.
	ActionTerminalWatch   = "terminal_watch"
	ActionTerminalUnwatch = "terminal_unwatch"

	// ActionTerminalRenew renews a live watch's HORIZON (ADR-017 amendment T4-b). It joins
	// the two reads above VERBATIM -- UNSIGNED, no actionClass entry, routed by the gateway
	// to its TerminalWatcher and never forwarded to the daemon's device authenticator --
	// because it is strictly WEAKER than the watch it renews: it never starts a peek, it
	// only extends one the capability gate already permitted.
	//
	// It exists because Watch/Unwatch/Close were the whole lifecycle, so a phone that
	// simply STOPPED READING left the machine rendering full screens, sealing them and
	// appending them against the shared 8-appends/s budget indefinitely, and building a
	// backlog the phone then replayed. The renewal is the only evidence the machine has
	// that anyone is still looking.
	ActionTerminalRenew = "terminal_renew"

	// ActionTerminalInput / ActionTerminalControlKeepalive are ADR-017 T6's two UNSIGNED
	// frame kinds, and they are exactly two: the exception to full-body signatures is held
	// to two body types and one live generation, which is what keeps it an exception
	// rather than a policy.
	//
	// They join journal_resync's class VERBATIM -- UNSIGNED, no actionClass entry, sealed
	// under the epoch content key and forwarded to the daemon as a plain op -- because
	// what authorises them is not a per-frame signature but the E2EE frame's authenticated
	// sender and sequence PLUS the confirmed control generation the daemon re-evaluates on
	// every frame (T6-e). ADR-007's 2026-07-24 Decision 1 is the same exception, and this
	// is deliberately the same one rather than a second.
	ActionTerminalInput            = "terminal_input"
	ActionTerminalControlKeepalive = "terminal_control_keepalive"

	// ActionJournalResync asks the gateway to republish an atomic roster+events snapshot
	// so the phone can repair a stale journal channel (PB-SYNC-2). Like the two reads
	// above and UNLIKE ActionPushPrefs it is UNSIGNED and is NOT forwarded to the daemon's
	// device authenticator, and that is PB-SYNC-5's decision rather than an omission.
	//
	// A device-SIGNED resync walks into a trap the spec names: actionClass
	// (internal/skeleton/deviceauth.go) is a CLOSED switch that fails closed on an
	// unmapped action, the only fitting existing class is ActionControl -- which would make
	// a READ REPAIR require the control tier -- and device capability is pinned at
	// enrollment and never read from the wire, so an observe-tier device could never
	// resync its own view at all. Sealing under the epoch content key is already proof the
	// asker is the paired device; the gate that matters is the DAEMON's, which serves
	// journal_read on the negotiated `journal` capability plus the kill switch
	// (PB-SYNC-4). This constant therefore has NO actionClass mapping, exactly as
	// terminal_watch and terminal_unwatch have none.
	ActionJournalResync = "journal_resync"

	// ActionInteractionHistory / ActionInteractionDetail are Mirror M3.1/M3.3's two reads
	// (ADR-014). They join journal_resync's class VERBATIM -- UNSIGNED, no actionClass
	// entry, forwarded to the daemon as a plain op and gated THERE on the negotiated
	// `journal` capability and the kill switch, exactly as handleJournalRead is. They ride
	// bodies (before_item/limit, item_id) which the gateway carries but never reads, and
	// which the gateway REFUSES to forward stripped: a bodyless history read would reach
	// the daemon as a page request naming no item and surface as some other refusal for a
	// frame that merely lost its payload (launch/approve/session_launch/composer_send's
	// rule, inherited by the two reads).
	ActionInteractionHistory = "interaction_history"
	ActionInteractionDetail  = "interaction_detail"

	// ActionPushPrefs sets the machine-side push preference for the signing device
	// (PB-PUSH-8): which categories of transition may wake it. Unlike the reads above it
	// IS signed and IS forwarded to the daemon's device authenticator -- the gateway holds
	// no device key, so a locally-decided preference would let anyone who can inject a
	// plaintext-shaped mailbox frame silence the owner's notifications. Its capability
	// class is READ (skeleton/deviceauth.go): it cannot start, stop or type into anything,
	// and a control-class mapping would leave a read-only paired phone receiving
	// notifications it has no way to silence. ADR-007 B20 records the consequence that the
	// capability check therefore cannot refuse this verb; the SIGNATURE is its gate.
	ActionPushPrefs = "push_prefs"

	// The R1 "refusal-ops" semantic vocabulary (playbook §6.3, ADR-017 T5, ADR-007 B144):
	// six signed actions landing as refusal-only daemon handlers ahead of their real
	// business logic. Each is MAPPED in actionClass (internal/skeleton/deviceauth.go) --
	// a name the daemon KNOWS but has not yet built a handler for -- rather than left
	// unmapped, so it stays distinguishable from a name the daemon has never heard of.
	//
	// terminal_input and terminal_control_keepalive are DELIBERATELY EXCLUDED from this
	// set and from actionClass (ADR-017 T6): they ride only the E2EE frame's
	// authenticated sender/sequence and a confirmed control generation, never a
	// per-frame signature, the same exception the existing lease input frame already
	// takes.
	//
	// push_prefs's "push-pairing shapes" sibling (push_address/submit_capability/
	// machine_revoke_capability/wake_key, ADR-015 P7) is OUT OF SCOPE here: it lives
	// inside the pairing exchange transcript, before any device-signing key exists, not
	// on this signed-command path at all.
	ActionSessionLaunch        = "session_launch"
	ActionComposerSend         = "composer_send"
	ActionOperationStatus      = "operation_status"
	ActionTurnInterrupt        = "turn_interrupt"
	ActionTerminalControlBegin = "terminal_control_begin"
	ActionTerminalControlEnd   = "terminal_control_end"
)

Canonical action strings signed over the remote command tuple (D4/R-POL.9). They are a wire contract: the phone-core signs the SAME string the daemon authorizes against, so they must never drift. Each maps to a capability action class in the authenticator (launch/kill/delete are all control-class).

View Source
const (
	SpawnIntentHandoff  = "handoff"
	SpawnIntentDelegate = "delegate"
)

The closed spawn-intent vocabulary (ADR-010 D2/D4): the two flavors of spawn share their mechanics and differ only in recorded intent.

View Source
const (
	SupervisionPassive = "passive"
	SupervisionManual  = "manual"
	SupervisionNone    = "none"
)

The closed supervision-mode vocabulary (ADR-010 Amendment 3 C1): passive is daemon-managed (the source is woken only when the child needs attention), manual is the source's own watch loop, none is launch-and-report.

View Source
const (
	DeclaredTerminalViewMaxLineBytes = builtinTerminalViewMaxLineBytes
	DeclaredTerminalViewMaxRows      = builtinTerminalViewMaxRows
	DeclaredTerminalViewMaxRateHz    = builtinTerminalViewMaxRateHz
)

The bounds a MACHINE publishes in its remote profile. They are the built-ins spelled again on the producing side rather than left zero: zero clamps to the phone's built-in and is therefore safe, but it is also indistinguishable from "this machine has no opinion", and a declared ceiling is checkable against what the machine actually sends while an absent one is not (ADR-017 T5-a). A machine that renders less may lower them; nothing it declares can RAISE the phone's ceiling.

View Source
const ActionLaunchPresets = "launch_presets"

ActionLaunchPresets is the signed READ of the machine-authored launch-preset list (playbook "launch_presets"). Unlike terminal_watch/journal_resync it IS signed and IS forwarded to the daemon's device authenticator: the preset custody lives daemon-side, and the list is what the phone's confirm sheet renders, so it rides the one authorization plane every semantic op rides. Its capability class is READ (skeleton/deviceauth.go): listing what could be launched starts nothing.

View Source
const CurrentCapabilityRecordVersion = 1

CurrentCapabilityRecordVersion is the SessionCapabilities record version a machine declares in RemoteProfileV1.CapabilityRecordVersion. A phone that reads ZERO there treats every record as untrusted and routes every session to the status card (ADR-017 T5-a), so this constant is what a machine must actually publish for the fallback to exist at all.

View Source
const CurrentProfileVersion = 1

RemoteProfileV1 is the machine-authored remote semantic profile ADR-017 T5 / playbook §6.3 seals to the phone during reconciliation. The asynchronous E2EE mailbox has no local `hello`, so this is the only channel a phone learns which action/body versions, interaction-schema version, TerminalView version and capability-record version the machine currently accepts. It rides the EXISTING ReconcileRecord (PB-SYNC-7) as a named field -- no new mailbox frame kind, no envelope change (IS-LAYER-1).

The profile VERSION is the compatibility unit, not this struct: companion decision records may add fields to it (ADR-017 T5), each carrying its own GG-7 obligation.

No field may carry omitempty, for the same reason ReconcileRecord's authorities may not: every field here is a live authority a phone routes on, and an absent key must stay distinguishable from a legitimately-zero one.

CurrentProfileVersion is the R1 profile version this ADR's two fields (InteractionSchemaVersion, TerminalViewVersion) and the rest of the companion R1 set share, per T5's "taken once across the R1 set rather than independently per ADR". ADR-016 is a co-owner: it adds relay_tls_policy, relay_host and the pin set to this same struct (ADR-016:194) and joined this SAME version number rather than bumping it; its three fields ARE declared below, and their GG-7 field-table obligation is ADR-016's own, not discharged by this constant. ADR-017 T5 is the second co-owner: it adds the three terminal_view_* bounds on the same terms.

WAVE R8 IS THE FIRST WAVE TO PUBLISH A NON-ZERO PROFILE VERSION (amendment T5-a), and that ENDS the "no deployed reader to break" argument that let R8's own additions join version 1 rather than bump it. The next ADR that adds a field here inherits a real compatibility decision, not R8's free window.

View Source
const CurrentTerminalViewVersion = 1

CurrentTerminalViewVersion is the TerminalViewV1 body version a machine declares in its remote profile (RemoteProfileV1.TerminalViewVersion). A phone that reads zero there concludes NO FALLBACK EXISTS on that machine, never "unversioned, therefore current" (ADR-017 T5-a).

View Source
const LaunchSessionSentinel = "@launch"

LaunchSessionSentinel is the canonical Session value signed over a launch command (D4/R-POL.9): a launch has no target session yet, but the signed tuple requires a non-empty Session, so both the phone-core (signer) and the daemon (verifier) use this reserved value. It contains no "/" so it can never collide with a namespaced session id (endpoint/local).

View Source
const OperationSessionSentinel = "@op"

OperationSessionSentinel is LaunchSessionSentinel's sibling for the wider R1 semantic-op vocabulary: the canonical Session value signed over an op that names no session instance (session_launch, which creates one; operation_status, which names none). crypto.Command.Canonical refuses an empty Session, so every signed action needs a non-empty value even when it targets nothing yet. Distinct from LaunchSessionSentinel so the two wire shapes' signed tuples can never be conflated, and contains no "/" so it can never collide with a namespaced session id.

Variables

View Source
var ErrCapabilityInconsistent = errors.New("schema: inconsistent session capability record")

ErrCapabilityInconsistent is what Validate wraps. ADR-017 T2-b: the record is the router, so an inconsistent one is REJECTED rather than resolved -- resolving it means choosing which boolean to believe, and either choice is a routing decision taken by the reader rather than by the daemon that authored the record. Every seam recognises the same refusal through errors.Is rather than by string-matching a message.

View Source
var ErrCapabilityUpgrade = errors.New("schema: capability record cannot upgrade structured_chat once degraded")

ErrCapabilityUpgrade is returned by SetStructuredChat when asked to flip a degraded record (structured_chat=false) back to true. Recovery is deliberately unavailable through this general mutation helper: only the machine-local, instance- and gap-bound sink proof path may publish it.

Functions

func ComposerSendContentHash

func ComposerSendContentHash(req *ComposerSendReq) []byte

ComposerSendContentHash is the 32-byte content hash bound into a composer_send command's signature, mirroring SessionLaunchContentHash exactly: it binds the signed tuple to the session, rendered-turn context and text, so a compromised gateway cannot re-point a validly-signed send or falsify its context. A nil body hashes as the zero body: structurally refused later, never a panic at the choke point.

Signer (phonecore.SignComposerSend) and verifier (protocol handleComposerSend) both call THIS function; re-deriving the length-prefixed encoding elsewhere is forbidden for LaunchContentHash's stated reason.

func LaunchContentHash

func LaunchContentHash(req *LaunchReq) []byte

LaunchContentHash is the 32-byte content hash bound into a remote launch command's signature (R-POL.9 launch content-binding). A launch has no session to name, so the signed tuple would otherwise not cover WHAT is launched -- letting a compromised gateway swap the agent, cwd, options, or prompt of a validly-signed launch. Both the phone-core (signer) and the daemon (verifier) compute this hash over exactly the fields the daemon acts on; a mismatch makes VerifyCommandSig fail.

It lives HERE, in the daemon-free schema package, rather than in protocol, for the same reason the wire types do (PB-BIND-0, requirements 4.2): the gomobile-bound facade is a SIGNER and must reach the canonical encoding without dragging internal/daemon onto a handset. package protocol re-exports it, so the wire and protocol's importers are unchanged. Reimplementing this encoding on the phone side is forbidden -- a one-byte divergence produces silent signature-verification failures with no compile error.

Bound fields: Agent, Cwd, sorted Options, InitialPrompt, Worktree. Env is excluded (a remote launch drops client env entirely, R-POL.5) and Cols/Rows are excluded (cosmetic terminal dimensions). SpawnedFrom/SpawnIntent (ADR-010 lineage) are excluded as display metadata the daemon does not act on -- the Name precedent: a tampered value corrupts a roster label, never what runs or where. The encoding is length-prefixed so no two distinct specs share an encoding.

func OperationStatusContentHash

func OperationStatusContentHash(subjectOperationID string) []byte

OperationStatusContentHash binds an operation_status query's SUBJECT -- the operation being asked about -- into the signed tuple (round-2 review, MAJOR 2). Unbound, a compromised gateway could re-point a validly-signed status query at any other operation id and read back that operation's namespaced session id; bound, swapping the subject breaks the signature, exactly as handleDeviceRevoke binds its target and session_launch binds its preset. The session slot stays the operation sentinel (the query names no session instance); the subject rides the content slot. Signer (phonecore, when the facade verb lands) and verifier (protocol server) both call THIS function -- LaunchContentHash's no-re-derivation rule.

func SessionLaunchContentHash

func SessionLaunchContentHash(req *SessionLaunchReq) []byte

SessionLaunchContentHash is the 32-byte content hash bound into a session_launch command's signature (R-POL.9's LaunchContentHash rule applied to the preset op): it binds the signed tuple to the preset id, the confirmed revision, and the phone's one free-text contribution, so a compromised gateway cannot re-point a validly-signed launch at a different preset or prompt. Cols/Rows are excluded as cosmetic, exactly as LaunchContentHash excludes them. A nil body hashes as the zero body: structurally refused later, but never a panic at the choke point.

Signer (phonecore) and verifier (protocol server) both call THIS function; re-deriving the length-prefixed encoding elsewhere is forbidden for LaunchContentHash's stated reason.

func TerminalControlBeginContentHash

func TerminalControlBeginContentHash(req *TerminalControlBeginReq) []byte

TerminalControlBeginContentHash is the canonical body hash a device signs over for terminal_control_begin, on ComposerSendContentHash's exact pattern: the daemon RECOMPUTES it from the forwarded body, so a gateway that re-points the op at another session, another incarnation or another profile breaks the signature.

The instance and the profile are inside the hash for the reason T6 gives: the op "is bound to the selected remote profile, the paired device's command signing key, and the fallback session instance". A field outside the hash is a field a blind conduit may rewrite.

func TurnInterruptContentHash

func TurnInterruptContentHash(req *TurnInterruptReq) []byte

TurnInterruptContentHash is the 32-byte content hash bound into a turn_interrupt command's signature, mirroring ComposerSendContentHash exactly: it binds the signed tuple to the session AND the turn, so a compromised gateway cannot re-point a validly-signed Stop at a different turn. No new crypto -- the same length-prefixed encoding under the same signed content slot the tuple already had. A nil body hashes as the zero body: structurally refused later, never a panic at the choke point.

Types

type ActivityRecord

type ActivityRecord struct {
	Action      string    `json:"action"`
	DeviceID    string    `json:"device_id"`
	OperationID string    `json:"operation_id"`
	SessionID   string    `json:"session_id,omitempty"`
	Outcome     string    `json:"outcome"` // OutcomeApplied, or "refused"
	Code        ErrorCode `json:"code,omitempty"`
}

ActivityRecord is one remote-originated action's audit entry (ADR-007 D10): every remote mutation AND its refusal is logged, so the terminal owner can audit what a paired phone did -- or was refused -- on this machine.

type AgentInstanceRef

type AgentInstanceRef struct {
	ShimPID       int   `json:"shim_pid"`
	ShimStartTime int64 `json:"shim_start_time"`
}

AgentInstanceRef pins the agent-instance the approval binds to, mapping to the daemon's (shim PID, start-time) identity check (A6/shimIdentityMatches).

type ApproveReq

type ApproveReq struct {
	Session       string           `json:"session"`
	AgentInstance AgentInstanceRef `json:"agent_instance"`
	InteractionID string           `json:"interaction_id"`
	ContentHash   string           `json:"content_hash"`
	ExpiresAt     *time.Time       `json:"expires_at,omitempty"`
	// Decision is the id of the decision the owner chose, in the CLI's OWN vocabulary
	// (interaction-schema.md §3.5: spike-SB captured Codex offering accept |
	// acceptWithExecpolicyAmendment | cancel). The card labels its buttons from
	// decisions[].label and answers with the matching id; the daemon refuses an id the
	// request never offered.
	//
	// It is DELIBERATELY UNSIGNED (IS-LIFE-4). ContentHash is the signed tuple's one
	// content slot and ADR-007 D7 spends it on the interaction content, which the phone
	// echoes verbatim (IS-APR-2) and so cannot fold a choice into. The field rides inside
	// the epoch-sealed frame -- unforgeable by the relay, alterable only by the gateway,
	// which is the documented D4/D5 owner-uid residual and not a new one.
	Decision string `json:"decision,omitempty"`
}

ApproveReq is a remote approval of an agent interaction (amendment D.0-A6): operation_id (the idempotency identity of the approve op, on the enclosing Control) is separated from interaction_id (the agent interaction being approved). ExpiresAt is daemon-authoritative and omitempty.

Documenting it is a PROCEDURAL obligation and not a fenced one, exactly as for JournalRecord.Item: protocol.md documents RemoteCommand and its bodies at the field level in prose rather than as a wire table, and GG-7's drift check (internal/protocol/protocolmd_test.go) reflects Control, SessionView, LaunchReq and TerminalSnapshot only -- so no build fails on a missing row here (interaction-schema.md §1 says so in as many words).

type ComposerSendReq

type ComposerSendReq struct {
	Session         string `json:"session"`
	SessionInstance string `json:"session_instance,omitempty"`
	ExpectedTurn    string `json:"expected_turn,omitempty"`
	Text            string `json:"text"`
}

ComposerSendReq is the composer_send body (IS-LIFE-5): one structured message from the phone into a session's agent CLI. ExpectedTurn is the turn the phone RENDERED the send against. It remains signed render context for wire compatibility and diagnostics, but is advisory for message delivery: the daemon orders accepted messages per session and applies each queue head to current provider state. TurnInterruptReq remains strictly turn-scoped.

type Control

type Control struct {
	Op              string `json:"op"`
	EndpointID      string `json:"endpoint_id"`
	SessionID       string `json:"session_id,omitempty"`
	ProtocolVersion int    `json:"protocol_version,omitempty"`
	// BuildVersion is the daemon's internal/version.Version, carried on the hello
	// reply (E13.2). It is ADDITIVE: unlike ProtocolVersion (the wire skew gate,
	// unchanged by this field), a mismatch here is not fatal to the handshake — it
	// lets a client notice it is talking to a different-build daemon and nudge
	// `swarm daemon restart` even when the wire protocol still matches.
	BuildVersion string   `json:"build_version,omitempty"`
	Capabilities []string `json:"capabilities,omitempty"`
	Generation   uint64   `json:"generation,omitempty"`
	SnapshotLen  int      `json:"snapshot_len,omitempty"`
	Cols         int      `json:"cols,omitempty"`
	Rows         int      `json:"rows,omitempty"`
	// Name is the new session label carried on a rename op (v0.5). It is re-validated
	// and sanitized server-side (sanitizeName) before it reaches the daemon, exactly
	// like the label in a launch request. Restored in the 2026-08-02 merge: the schema
	// split forked before rename landed on main and the port predated the field.
	Name string `json:"name,omitempty"`
	// Tag is the manual grouping label carried on set_tag. Empty clears it.
	Tag      string        `json:"tag,omitempty"`
	Launch   *LaunchReq    `json:"launch,omitempty"`
	Sessions []SessionView `json:"sessions,omitempty"`
	Session  *SessionView  `json:"session,omitempty"`
	Error    string        `json:"error,omitempty"`

	// Remote-tier additive fields (R-PROT.2/.3/.7, amendments D.0-A1/A3/A6/A11).
	// Every field is omitempty so an existing-shape Control serializes
	// byte-identically (GG-7); the daemon-authoritative times are pointers so a zero
	// Control emits no new key (a zero time.Time is NOT omitted by encoding/json).
	OperationID     string          `json:"operation_id,omitempty"`      // idempotency key of a remote mutating op
	InteractionID   string          `json:"interaction_id,omitempty"`    // the agent interaction being approved (A6)
	DeviceID        string          `json:"device_id,omitempty"`         // pairing device id (never trusted alone, A1)
	DeviceSig       string          `json:"device_sig,omitempty"`        // detached Ed25519 over the canonical op tuple (D4)
	Cursor          uint64          `json:"cursor,omitempty"`            // journal cursor (journal_read/journal_event)
	JournalMaxBytes int             `json:"journal_max_bytes,omitempty"` // journal_read request: opt into bounded response pages
	JournalMore     bool            `json:"journal_more,omitempty"`      // journal_read response: more pages from this atomic read follow
	IssuedAt        *time.Time      `json:"issued_at,omitempty"`         // daemon-authoritative issue time
	ExpiresAt       *time.Time      `json:"expires_at,omitempty"`        // daemon-authoritative expiry
	Approve         *ApproveReq     `json:"approve,omitempty"`           // remote approval request (A6)
	ErrorCode       ErrorCode       `json:"error_code,omitempty"`        // machine-readable refusal reason (R-PROT.7)
	Journal         []JournalRecord `json:"journal,omitempty"`           // journal records (journal_read/journal_event)
	Roster          []JournalRecord `json:"roster,omitempty"`            // live sessions as-of Cursor on a journal_read snapshot (R-JRN.4)
	FullResync      bool            `json:"full_resync,omitempty"`       // the caller's cursor fell below the retained floor
	Devices         []DeviceView    `json:"devices,omitempty"`           // paired-device roster, carried on the device_list reply
	Policy          *PolicyView     `json:"policy,omitempty"`            // remote launch policy, carried on the policy_query reply
	TargetDeviceID  string          `json:"target_device_id,omitempty"`  // device_revoke: the device to REVOKE, distinct from the caller DeviceID (A3.2)
	Pairing         *PairingControl `json:"pairing,omitempty"`           // owner-tier pairing payload (pair_start/pair_pending/pair_confirm/pair_result, A3.3-a)
	TTLSeconds      int             `json:"ttl_seconds,omitempty"`       // take_control: caller-requested control-session lifetime (seconds), clamped server-side (A5-b)
	GateToken       string          `json:"gate_token,omitempty"`        // take_control: one-shot gate token bound into the device signature via content_hash and made single-use (A5-c)
	RemoteControl   *bool           `json:"remote_control,omitempty"`    // remote_set_control: the DESIRED remote-control master state (true=on, false=manual off). Pointer so false is transmittable and a zero Control emits no key (A4)

	Terminal *TerminalSnapshot `json:"terminal,omitempty"` // server-rendered terminal snapshot, carried on terminal_snapshot (A7 slice B)

	// TerminalView is the SAME snapshot, versioned (ADR-017 T4/T4-a/T8-a). It rides the SAME
	// terminal_snapshot op beside Terminal rather than on an op of its own, which is the
	// smallest step that puts view_epoch, revision, reset, session_instance and rendered_at on
	// the wire: an old gateway ignores an unknown key, a new one prefers this body, and no
	// RemoteProfileV1 version has to move (ADR-016's profile-version coordination is parked).
	//
	// WITHOUT IT THE FIELDS WERE PRODUCED AND NEVER SENT. `RenderTerminalView` minted the epoch
	// and the daemon threw it away; a phone watching a session REPLACED under the same id read
	// the new incarnation as a seamless continuation of the old screen, which is exactly what
	// T4-a and T8-a exist to prevent (closing review, finding 5).
	TerminalView *TerminalViewV1 `json:"terminal_view,omitempty"`

	SendInput *SendInputReq `json:"send_input,omitempty"` // owner-tier one-shot steering message, carried on send_input (ADR-010 A2)

	// BodyVersion is the per-op body-version tag the R1 refusal-ops vocabulary requires
	// (playbook §6.3, ADR-017 T5): the phone binds every session_launch/composer_send/
	// operation_status/turn_interrupt/terminal_control_begin/terminal_control_end to the
	// profile version it read from RemoteProfileV1. There is no body version 0: a Control
	// that omits this key is refused identically to a wrong version, never treated as an
	// implicit "version 1".
	BodyVersion int `json:"body_version,omitempty"`

	// Wave R5 launch-preset additive fields (ADR-007 B144(b), playbook "Wave R5").
	// SessionLaunch is the session_launch body: the phone's confirmed preset selection,
	// bound into the device signature via SessionLaunchContentHash.
	SessionLaunch *SessionLaunchReq `json:"session_launch,omitempty"`
	// Presets + PresetPolicyRevision ride the launch_presets reply: exactly the
	// machine-authored list (empty custody answers empty, never an invented default,
	// ADR-007 B135) plus the revision of the policy that produced it.
	Presets              []LaunchPresetView `json:"presets,omitempty"`
	PresetPolicyRevision string             `json:"preset_policy_revision,omitempty"`
	// SubjectOperationID is operation_status's QUERY SUBJECT -- the operation being
	// asked about -- distinct from the query's own OperationID exactly as ADR-007 D7
	// separates operation_id from interaction_id: the asking op and the asked-about op
	// are different coordinates.
	SubjectOperationID string `json:"subject_operation_id,omitempty"`
	// OperationOutcome rides the operation_status reply: applied (authoritative, with
	// the session id), outcome_unknown (honest undecidability), or unknown_operation
	// (no record; never invented).
	OperationOutcome *OperationOutcomeView `json:"operation_outcome,omitempty"`
	// Wave R6 complete-chat additive fields (Mirror M2.4 / M3.1 / M3.3, ADR-009 (8)).
	// ComposerSend is the composer_send body: one structured message plus the turn it was
	// rendered against, bound into the device signature via ComposerSendContentHash.
	ComposerSend *ComposerSendReq `json:"composer_send,omitempty"`
	// TurnInterrupt is the turn_interrupt body (Wave R6 review fix-pack B7): the session
	// plus the turn the phone RENDERED the Stop against, bound into the device signature
	// via TurnInterruptContentHash. See schema/chat.go for why the op stopped being
	// bodyless.
	TurnInterrupt *TurnInterruptReq `json:"turn_interrupt,omitempty"`
	// History is the interaction_history body (M3.1, ADR-014); the reply rides the
	// existing Journal carrier plus HistoryFloor.
	History *InteractionHistoryReq `json:"interaction_history,omitempty"`
	// Detail is the interaction_detail body (M3.3, IS-CAP-2's unsigned read); the reply
	// carries exactly one Journal record whose Item is the full pre-truncation body.
	Detail *InteractionDetailReq `json:"interaction_detail,omitempty"`
	// The Wave R8 terminal-fallback bodies (ADR-017 T6). TerminalControlBegin is the
	// SIGNED begin, bound into the device signature via TerminalControlBeginContentHash;
	// TerminalInput is one UNSIGNED raw-input frame naming the generation it was
	// authorised under. ControlGeneration rides the begin's REPLY (the minted, non-
	// transferable generation) and every keepalive.
	TerminalControlBegin *TerminalControlBeginReq `json:"terminal_control_begin,omitempty"`
	TerminalInput        *TerminalInputReq        `json:"terminal_input,omitempty"`
	ControlGeneration    string                   `json:"control_generation,omitempty"`
	// HistoryFloor rides the interaction_history reply: nothing older than the returned
	// page is retained, so the phone renders a retention floor instead of a spinner.
	HistoryFloor bool `json:"history_floor,omitempty"`
	// DeviceCapability rides the launch_presets reply: the SIGNING device's own
	// authorization tier ("full"/"read_only"/"read_approve") as pinned machine-side in
	// the device registry at enrollment (round-2 fix-pack). It is the phone's only
	// honest wire source for its own tier -- capability is never read FROM the wire
	// (deviceauth's rule), but nothing forbade the machine STATING it, and without it
	// the launch screen's tier-denied state had no data source. Empty when the backend
	// exposes no capability seam: absent fact, never an invented tier.
	DeviceCapability string `json:"device_capability,omitempty"`
}

Control is the single JSON envelope for every control message (F-1: every message carries endpoint_id; a session-scoped op carries a namespaced session_id, <endpoint_id>/<local>). Which other fields matter depends on Op.

type DeviceCommandAuth

type DeviceCommandAuth struct {
	DeviceID    string    // registry lookup key; never trusted alone (A1)
	Action      string    // canonical action string (also selects the capability class)
	Machine     string    // endpoint id
	Session     string    // namespaced session id ("" for launch, which creates one)
	OperationID string    // idempotency identity; single-use, binds the signature
	ExpiresAt   time.Time // daemon-authoritative expiry; a past value is refused
	ContentHash []byte    // optional 32-byte hash binding op content (e.g. a launch spec)
	Sig         string    // detached Ed25519 signature (device_sig) over the tuple
}

DeviceCommandAuth is the authenticated context of one remote mutating op, passed to the DeviceAuthenticator (R-POL.9). Its fields are exactly the canonical command tuple (D4) plus the detached signature: the authenticator reconstructs the signing input from them, verifies the signature against the device's pinned command-signing key, and checks the device's capability permits Action — returning nil only if both hold.

type DeviceView

type DeviceView struct {
	DeviceID   string    `json:"device_id"`
	Name       string    `json:"name"`
	Capability string    `json:"capability"`
	PairedAt   time.Time `json:"paired_at"`
}

DeviceView is one paired-device row (R-DEV.1), carried on the device_list reply. Capability is the device's authorization tier rendered as its stable snake_case text (e.g. "full"/"read_only"/"read_approve").

type ErrorCode

type ErrorCode string

ErrorCode is the stable machine-readable refusal-reason taxonomy every R-POL/R-KS/R-IDP/R-REL refusal carries (plan R-PROT.7, amendment D.0-A11), so the phone can drive retry policy — a string-only error cannot. It rides on Control.error_code alongside the human-readable Error prose.

const (
	// CodeStaleTurn refuses a turn_interrupt whose expected_turn no longer names the
	// session's current turn. It remains in the shared vocabulary for older peers that
	// enforced the former composer precondition.
	CodeStaleTurn ErrorCode = "stale_turn"
	// CodeInterruptUnsupported is the honest ADR-017-shaped refusal for a turn_interrupt
	// against a session whose adapter proves no semantic interrupt seam: the caller is
	// fine, the capability is absent, and a guessed keystroke is forbidden (IS-TOOL-2's
	// posture one layer down).
	CodeInterruptUnsupported ErrorCode = "interrupt_unsupported"
	// CodeUnavailable is IS-CAP-3's answer for a detail read outside the bounded
	// retention window: the full body is gone and saying so beats presenting a partial
	// body as whole.
	CodeUnavailable ErrorCode = "unavailable"
	// CodeStructuredUnsupported refuses a composer_send against a session whose
	// structured_chat capability is absent or has been degraded by a proven
	// structured_gap (ADR-017 T2 rule 2, Mirror M5.5). It is the composer's sibling of
	// CodeInterruptUnsupported and exists for the same reason: a degraded session has no
	// message sink, so accepting the send would type the user's words into a PTY whose
	// transcript can never show them -- the gap silently bridged, which is the one thing
	// ADR-017 forbids. The caller is fine; the capability is absent.
	CodeStructuredUnsupported ErrorCode = "structured_unsupported"
	// CodeInputBusy refuses a composer_send whose message could not safely be written as
	// one message: the shim cannot prove the owner's logical input line is empty, so the
	// text might join a real or unknown draft and the carriage return would submit the
	// concatenation -- the B13 merge (skeleton/chat.go:337-345), and the two-sends-interleave
	// defect (agents-tracker-bzfe) which is the same hazard with the phone on both sides.
	//
	// IT NEVER INFERS FROM THE CLI'S DRAWN GRID. ADR-017:175's expected_input_revision
	// would require provider-specific characterisation, which chat.go rightly refuses to
	// guess at. Holding the PTY's only serialised writer, the shim tracks only operations
	// with characterized effects: character insertion/deletion, complete horizontal/home/end
	// navigation, line kill and submit. Provider-dependent word/Meta keys and lone/incomplete
	// escape sequences remain unknown and busy. A known draft deleted back to empty becomes
	// clean; a refusal still writes NOTHING, matching the over-long-body posture.
	CodeInputBusy ErrorCode = "input_busy"
)

Stable refusal codes of the complete chat (Wave R6), joining the existing taxonomy.

const (
	CodeUnknownPreset ErrorCode = "unknown_preset"
	CodeStalePreset   ErrorCode = "stale_preset"
	// CodeOutcomeUnknown is the session_launch reply for a launch whose outcome the
	// machine cannot decide (round 4, review MAJOR 2): the signed operation is in
	// flight under another driver and may yet apply or roll back. It is NOT a refusal
	// -- replied as `policy` the phone would tell the user the machine turned the
	// launch away, which is a different untruth from the "applied" it replaced. The
	// value is OutcomeUnknown's, deliberately: this is the same ADR-017 T9 delivery
	// state operation_status reports, reaching the phone one hop earlier.
	CodeOutcomeUnknown ErrorCode = ErrorCode(OutcomeUnknown)
)

Stable refusal codes of the preset flow, joining the existing kill_switch / not_authorized / policy / invalid_field taxonomy. Two codes rather than one because the remedies differ: unknown_preset means re-list (the machine never authored this id), stale_preset means re-confirm (the machine re-authored it between the phone's list and its confirm).

const (
	CodePolicy     ErrorCode = "policy"
	CodeKillSwitch ErrorCode = "kill_switch"
	// CodeRateLimit is RESERVED: no daemon or gateway site produces it (agents-tracker-ksvb.9
	// verified this by grep -- every throttle in the repo lives on a different wire vocabulary).
	// internal/remote/relay/errors.go's codeQuotaExceeded is the relay's OWN connection
	// protocol, not Control.error_code. internal/protocol/pairing.go's PairFailRateLimited is
	// the closed PairFailure enum, a different wire field. And the one path that DOES see a
	// relay quota refusal -- remotegw's outbound append handling -- deliberately does NOT
	// promote it to a definitive machine-side refusal (ADR-007 B125/B127: the relay is the
	// adversary for that read, so its error code is evidence about nothing). The daemon's
	// inbound command path (internal/protocol/server.go, replyErrorCode's call sites) has no
	// rate limiter of its own to seal this from. Left in place for the day one exists:
	// ErrorCode.Transient() below, android CommandVerdict.kt's REFUSED_TRANSIENTLY/RETRY_HINT,
	// and MachineAndLaunch.kt's LaunchResult.REFUSED_TRANSIENTLY all already read it.
	CodeRateLimit     ErrorCode = "rate_limit"
	CodeStaleApproval ErrorCode = "stale_approval"
	CodeNotAuthorized ErrorCode = "not_authorized"
	CodeInvalidField  ErrorCode = "invalid_field"
	// CodeNotImplemented is the SEALED, STABLE refusal a Wave R1 semantic op (playbook
	// §6.3, ADR-017 T5) answers once it is authorized but has no real handler yet: a name
	// the daemon KNOWS (mapped in actionClass, reachable past requireRemoteAuthz) rather
	// than a name it has never heard of. Distinct from CodeNotAuthorized (the signature/
	// capability gate failed) and CodeInvalidField (a structural or body-version failure);
	// "recognised, not yet served" is a different fact from either.
	CodeNotImplemented ErrorCode = "op_not_implemented"
)
const (
	// CodeCapabilityRefused is the sealed, stable refusal for a terminal verb the
	// SESSION's capability record does not permit (ADR-017 T2-c, playbook:450-451). An
	// old, downlevel or rolled-back client degrades legibly and never receives a
	// malformed screen, which needs a code it can recognise rather than a message it
	// must parse. It covers all four fail-closed states with one wire value, because
	// they are one predicate: no record, an inconsistent record, a record with no
	// session instance, and a record whose destination is not the terminal.
	CodeCapabilityRefused ErrorCode = "capability_refused"

	// CodeStaleGeneration refuses an input or keepalive frame whose control generation
	// is no longer live: the horizon passed, or control was released. The remedy is to
	// ENTER CONTROL AGAIN, on a session that is still there.
	CodeStaleGeneration ErrorCode = "stale_generation"

	// CodeStaleInstance refuses a frame whose session instance no longer matches the
	// session's current incarnation (ADR-017 T8-a). It is a DIFFERENT fact with a
	// DIFFERENT remedy from the one above, and collapsing the two is how a user is told
	// to "try again" about a screen that no longer exists: the session was REPLACED, and
	// entering control on the new incarnation is a separate, deliberate act.
	CodeStaleInstance ErrorCode = "stale_instance"
)

func (ErrorCode) Transient

func (c ErrorCode) Transient() bool

Transient reports whether a refusal is worth retrying: only rate_limit is transient; policy / kill_switch / stale_approval / not_authorized / invalid_field are permanent (retrying reproduces the same refusal).

type InteractionDetailReq

type InteractionDetailReq struct {
	Session string `json:"session"`
	ItemID  string `json:"item_id"`
}

InteractionDetailReq is the interaction_detail body (Mirror M3.3, IS-CAP-2): the unsigned read of ONE item's full pre-truncation body. Outside retention the answer is IS-CAP-3's `unavailable`, never a partial body presented as whole.

type InteractionHistoryReq

type InteractionHistoryReq struct {
	Session    string `json:"session"`
	BeforeItem string `json:"before_item"`
	Limit      int    `json:"limit"`
}

InteractionHistoryReq is the interaction_history body (Mirror M3.1, ADR-014): the paged, per-session read of transcript items. BeforeItem names the item_id the page must end strictly before; empty asks for the newest retained page so an anchorless client can begin. Limit bounds the page. The reply rides the EXISTING Control.Journal carrier plus Control.HistoryFloor.

type JournalRecord

type JournalRecord struct {
	Cursor    uint64       `json:"cursor"`
	SessionID string       `json:"session_id"`
	Type      string       `json:"type"`
	Group     status.Group `json:"group,omitempty"`
	// Agent is the session's agent identity (persist.Meta.AgentType), carried so the
	// phone can label a session with the CLI running it. Like Group it is omitempty
	// because most record types do not carry it: the roster snapshot does, and a bare
	// event may not. An absent agent means the record does not carry one -- readers must
	// not read the empty string as an agent named "".
	Agent string `json:"agent,omitempty"`
	// Item is one unit of the phone's chat transcript (ADR-009,
	// docs/specifications/interaction-schema.md §1 and §2). It is populated when Type is
	// "interaction" or "structured_gap", where the daemon payload IS transcript content;
	// every other type's payload stays daemon-internal, except the separately validated
	// capability-transition record below. In all cases,
	// internal/skeleton/api.go's toWireJournalRecord is the sole producer of this field.
	//
	// It is carried raw. The gateway seals and forwards items and parses none of them
	// (interaction-schema.md §10), so the item's `kind` discriminator is only ever read
	// inside the AEAD-covered plaintext and never reaches SenderKeyID or EpochID
	// (IS-LAYER-2, PB-SYNC-1). An unknown kind or an unknown field costs a consumer a skip,
	// not a decode failure (IS-COMPAT-1/-2). omitempty keeps every record type that
	// predates the field byte-identical.
	//
	// Documenting it is a PROCEDURAL obligation, not a fenced one: GG-7's drift check
	// (internal/protocol/protocolmd_test.go) reflects Control, SessionView, LaunchReq and
	// TerminalSnapshot only, so no build fails on a missing row. Its row lives in
	// docs/specifications/protocol.md under "The JournalRecord message" and was written in
	// the same change as this field.
	Item json.RawMessage `json:"item,omitempty"`
	// Name is the session's user-given label (persist.Meta.Name), carried so the phone can
	// head a row with what a person called the session rather than the random local id.
	// Like Agent it is omitempty because most record types do not carry it, and because most
	// sessions are never labelled at all: an absent name means the record carries none --
	// readers must fall back to the id and must never fabricate a label (ADR-007 B135).
	//
	// A daemon predating this field simply omits the key, and the phone then renders exactly
	// what it renders today (mobile/namefacade_test.go pins that compatibility).
	Name string `json:"name,omitempty"`
	// Capabilities is the session's daemon-authored capability record (ADR-017 T2),
	// carried on ROSTER records and on the cursor-ordered "capability_transition" event.
	// A transition carries its own historical value; it is never reconstructed from a
	// current roster lookup. The phone reads this record rather than inferring support.
	//
	// It is a POINTER with omitempty because absence is wire-distinguishable ON PURPOSE
	// (T2-a): "no record" and terminal_fallback=false take ONE code path, and a reader
	// that could not tell them apart would have to guess about every pre-R8 session. A
	// daemon predating this field simply omits the key, and the phone then renders the
	// status card, which is the honest answer for a session whose machine cannot say what
	// it supports.
	Capabilities *SessionCapabilities `json:"capabilities,omitempty"`
	// TS is the daemon's own stamp of when this record was appended (journal.Record.TS),
	// carried so the Activity screen can show a time that a MACHINE clock produced
	// (phone-refit-playbook W7.4). A roster record is never appended and carries none.
	// omitzero: a zero stamp is absent on the wire, never the epoch, and every record written
	// before this field existed serialises byte-identically.
	TS time.Time `json:"ts,omitzero"`
	// StateSince is the MACHINE's stamp of when the session entered its CURRENT state
	// (persist.Meta.EffectiveGroupEnteredAt()), carried on roster records and the four
	// journalworthy transitions so an Inbox row can say how long the session has been in the
	// state it shows (W7.1). It is the session's stamp, not this record's, and it is not
	// LastActivity, which moves only at launch and exit; a record whose meta carried no
	// instant omits it, for TS's reason.
	StateSince time.Time `json:"state_since,omitzero"`
}

JournalRecord is one wire-facing journal event (R-PROT.3). It mirrors the daemon journal's record fields the phone needs. Interaction and structured-gap payloads cross as Item; a capability-transition payload crosses as Capabilities.

type JournalReseed

type JournalReseed struct {
	// Roster is the machine's COMPLETE live session set as of Cursor. It replaces the
	// phone's cached set; a session absent here has gone away while the phone was not
	// listening, and leaving it behind is how a killed session stays on the roster forever.
	Roster []JournalRecord `json:"roster"`
	// Events are the journal records after the phone's last known cursor and up to Cursor,
	// applied ON TOP of Roster -- the "atomic roster+events snapshot" of PB-SYNC-2.
	Events []JournalRecord `json:"events"`
	// Cursor is the snapshot boundary the roster is current as of. It REPLACES the phone's
	// cache cursor (PB-SYNC-8); it is never merged, never max'd.
	Cursor uint64 `json:"cursor"`
	// DiscardRecoveryToken echoes the exact token from the sealed roster-refresh command
	// whose explicit mailbox discard requested this fast-forward snapshot. An absent or
	// different token is an ordinary reseed and cannot complete durable discard recovery.
	DiscardRecoveryToken string `json:"discard_recovery_token,omitempty"`
}

JournalReseed is the machine->phone JOURNAL RESEED (PB-SYNC-2 / PB-SYNC-8): the ATOMIC roster+events snapshot that repairs the journal channel after a gap, carried as ONE sealed frame on the EXISTING machine->phone stream.

WHY ONE FRAME AND NOT N ROSTER RECORDS. PB-SYNC-3 requires the repair to be committed atomically with the matching transport watermark, and N independent roster frames cannot be: a process death between frames 3 and 4 leaves the phone with half a snapshot and a watermark that says it has the whole thing. One frame's own arrival seq IS the matching watermark, exactly as ReconcileRecord's arrival certifies its JournalCeiling.

WHY IT CARRIES ITS OWN CURSOR. The daemon emits roster records with Cursor DELIBERATELY UNSET (0) -- "a roster record is a set member keyed by SessionID, NOT a point in the cursor-ordered event stream" (internal/daemon/journal.go) -- while the phone's SessionCache drops any record whose Cursor is below the highest applied one. Merging a reseed into a live cache therefore discards every roster record the moment one event has advanced the cursor, and the designated repair channel becomes a silent no-op. Cursor here is the snapshot BOUNDARY (JournalResume.Cursor), and it REPLACES the cache cursor rather than being merged into it.

The three snapshot fields never carry omitempty, for the reason ReconcileRecord states: a legitimately-empty roster (a machine with no live sessions) must stay distinguishable on the wire from a producer that never published the field. DiscardRecoveryToken is an additive mode echo and deliberately DOES use omitempty, keeping every ordinary repair byte-compatible while letting a recovering phone distinguish a current gateway's answer.

type LaunchPresetView

type LaunchPresetView struct {
	ID          string            `json:"id"`
	DisplayName string            `json:"display_name"`
	Agent       string            `json:"agent"`
	Root        string            `json:"root"`
	Options     map[string]string `json:"options,omitempty"`
	Worktree    bool              `json:"worktree,omitempty"`
	Revision    string            `json:"revision"`
}

LaunchPresetView is one machine-authored preset as the wire carries it to the phone: the stable opaque id the phone selects by, the display facts the confirm sheet renders, the CANONICAL (symlink-resolved) workspace root, the allowlisted options, the worktree-isolation default, and the content-bound revision the phone must echo in its confirm.

type LaunchReq

type LaunchReq struct {
	Agent         string            `json:"agent"`
	Name          string            `json:"name,omitempty"` // optional user-provided session label; re-validated + sanitized server-side (E6.6)
	Cwd           string            `json:"cwd"`
	Options       map[string]string `json:"options"`
	Env           []string          `json:"env"`
	Cols          int               `json:"cols"`
	Rows          int               `json:"rows"`
	InitialPrompt string            `json:"initial_prompt"`
	// Worktree opts this session into launch-time git-worktree isolation (Epic 12):
	// the daemon runs the session's agent in a fresh isolated worktree/branch. It is
	// carried to the daemon's PreLaunch/PreDelete hooks by the assembly (skeleton),
	// which registers worktree.Create/Remove gated on this flag; the protocol layer
	// only transports it.
	Worktree bool `json:"worktree,omitempty"`
	// SpawnedFrom, when non-empty, is the LOCAL id of the session that requested this
	// launch (ADR-010 D4), mirroring the ResumedFrom pattern: a daemon-launched session
	// is no process-group descendant of its spawner (S-4), so lineage must be explicit
	// metadata. SpawnIntent tags the link and is one of SpawnIntentHandoff or
	// SpawnIntentDelegate, valid only alongside a SpawnedFrom. Both omitempty: an
	// un-lineaged launch is byte-identical to the pre-lineage shape.
	SpawnedFrom string `json:"spawned_from,omitempty"`
	SpawnIntent string `json:"spawn_intent,omitempty"`
	// Supervision is the supervision mode of a handoff child (ADR-010 Amendment 3
	// C1): one of SupervisionPassive, SupervisionManual or SupervisionNone, valid only
	// alongside SpawnIntentHandoff -- a mode names how the SOURCE follows its child,
	// so it is meaningless on any other launch. omitempty: an unsupervised launch is
	// byte-identical to the pre-supervision shape.
	Supervision string `json:"supervision,omitempty"`
}

LaunchReq is a client's request to launch a new session. Every field is re-validated server-side (E6.6) before it reaches the DaemonAPI.

type OperationOutcomeView

type OperationOutcomeView struct {
	State     string `json:"state"`
	SessionID string `json:"session_id,omitempty"`
}

OperationOutcomeView is the operation_status reply body.

type PairingControl

type PairingControl struct {
	Capability   string     `json:"capability,omitempty"`
	TTLSeconds   int        `json:"ttl_seconds,omitempty"`
	QR           string     `json:"qr,omitempty"`
	RendezvousID string     `json:"rendezvous_id,omitempty"`
	ExpiresAt    *time.Time `json:"expires_at,omitempty"`
	// ShortCode is the pair_start reply's human-typeable spelling of the same ceremony
	// (ADR-007 B140). Additive and omitempty on both sides of the version skew.
	ShortCode  string   `json:"short_code,omitempty"`
	SAS        []string `json:"sas,omitempty"`
	DeviceName string   `json:"device_name,omitempty"`
	Allow      bool     `json:"allow,omitempty"`
	DeviceID   string   `json:"device_id,omitempty"`
	Name       string   `json:"name,omitempty"`
	// Failure is why a pair_result enrolled nothing (ADR-007 B71(1)): one code from
	// protocol.PairFailure's closed vocabulary, empty on success. It is a CODE and never
	// the daemon's error text -- the pairing path parses attacker-influenced bytes, and
	// the client normalises anything it does not recognise, so no wire-supplied string
	// can reach the owner's terminal.
	Failure string `json:"failure,omitempty"`
}

PairingControl is the owner-tier pairing payload (slice A3.3-a, ADR-007 amendment "Pairing host: Option A"): wire type only in this slice — no handlers, no pairing logic. Each pair_* op uses a distinct field subset: pair_start carries a request subset (Capability/TTLSeconds) outbound and a reply subset (QR/RendezvousID/ExpiresAt) inbound; pair_pending carries SAS/DeviceName/RendezvousID; pair_confirm carries Allow/RendezvousID; pair_result carries DeviceID/Name.

type PolicyView

type PolicyView struct {
	AllowedCwdRoots []string `json:"allowed_cwd_roots"`
}

PolicyView is the machine's remote launch policy (R-POL.3), carried on the policy_query reply: the configured allowed cwd roots a remote launch is confined to.

type PushPrefs

type PushPrefs struct {
	Version    uint64 `json:"version"`
	NeedsInput bool   `json:"needs_input"`
	Finished   bool   `json:"finished"`
}

PushPrefs is the machine-authoritative record of which push categories may wake the paired device (PB-PUSH-8, PB-PUSH-10). The two toggles are exactly PB-APP-7's coarse categories: NeedsInput covers a transition into needs_input, Finished covers one into ready_for_review or completed.

Version is a device-supplied monotonic counter, not a timestamp: the relay is the declared adversary and may reorder or replay what it retains, so a preference frame from before the user turned pushes off must not turn them back on. The machine refuses any update whose Version does not strictly exceed the stored one. Version 0 is reserved for the never-configured bootstrap record, so the phone's first real update always wins.

type ReconcileRecord

type ReconcileRecord struct {
	Machine          string `json:"machine"`            // endpoint id the authorities belong to
	EpochID          uint32 `json:"epoch_id"`           // epoch the content key (and both seq buckets) belong to
	InboundHighWater uint64 `json:"inbound_high_water"` // (a) the gateway's durable inbound accepted high-water (PB-GW-1)
	JournalCeiling   uint64 `json:"journal_ceiling"`    // (b) highest seq ISSUED on the shared journal/terminal bucket
	ReplyCeiling     uint64 `json:"reply_ceiling"`      // (b) highest seq ISSUED on the command-reply bucket
	GrantEpoch       uint32 `json:"grant_epoch"`        // (c) the daemon's grant-issuance epoch
	GrantSeq         uint64 `json:"grant_seq"`          // (c) the daemon's grant-issuance seq
	IssuedAt         int64  `json:"issued_at"`          // unix millis, the SAME value the envelope header carries

	// Profile is the machine's sealed RemoteProfileV1 (ADR-017 T5), riding this EXISTING
	// record rather than a new mailbox frame kind. It carries no omitempty for the same
	// reason as every authority above: a struct-typed field is never "empty" to
	// encoding/json anyway (omitempty would be a silent no-op here), but the tag is kept
	// off deliberately so a reader never mistakes it for a field that may legitimately be
	// absent -- enforced as a source-level convention by
	// TestReconcileRecord_ProfileFieldTag_NoOmitempty.
	Profile RemoteProfileV1 `json:"profile"`
}

ReconcileRecord is the machine->phone RECONCILE RECORD (PB-SYNC-7): the wire carrier of the three per-coordinate rollback authorities PB-STATE-4 names. The phone's inbound plaintext set is otherwise journal record / terminal snapshot / command reply / epoch grant, none of which carries an authority -- so without this record PB-STATE-4's "fail closed until reconciled" is PERMANENT.

It rides the EXISTING machine->phone sealed mailbox stream as an ordinary plaintext with a kind tag (internal/remotegw seals it, internal/phonecore demuxes it), so internal/remote/crypto stays frozen and no new signed device action is introduced -- a phone-INITIATED reconcile would need one, and PB-SYNC-5's actionClass switch is closed and fail-closed.

The three authorities, one field group each:

(a) phone send-seq            -> InboundHighWater
(b) phone receive high-waters -> JournalCeiling (the shared journal/terminal bucket)
                                and ReplyCeiling (the separate sender-zero reply bucket)
(c) grant watermark           -> GrantEpoch + GrantSeq

NO FIELD MAY CARRY omitempty. A legitimately-zero authority (a gateway that has accepted nothing inbound yet, a machine that has issued no grant beyond the first) must stay distinguishable on the wire from a producer that never published the field at all: with omitempty they are the SAME BYTES, and a phone that reads "absent" as "zero" raises no high-water and silently leaves every retained pre-rollback frame acceptable.

type RemoteCommand

type RemoteCommand struct {
	DeviceCommandAuth
	// PushPrefs is the push_prefs body (PB-PUSH-8). It is deliberately NOT bound by
	// ContentHash the way a launch spec is, and the difference is load-bearing: a launch
	// spec is forwarded through the gateway IN CLEARTEXT (Control.Launch), so the hash is
	// what stops the gateway altering it, whereas a preference body never leaves the
	// gateway -- it arrives sealed under the epoch content key, which the relay cannot
	// forge, and the gateway is itself the custodian that decides delivery. A hash it
	// recomputed against its own file would protect nothing it could not simply overwrite.
	PushPrefs *PushPrefs `json:"push_prefs,omitempty"`
	// Approve is the approve body (IS-LIFE-4): the ApproveReq the gateway reconstructs
	// Control.Approve from. It is the ONE wire field this spec adds, and it adds no signed
	// action -- ActionApprove already exists.
	//
	// It is bound to the signature the way a launch spec is, and only in part. ContentHash is
	// the digest of the INTERACTION CONTENT the phone echoes verbatim (IS-APR-2, ADR-007 D7),
	// so a gateway that swaps the hash to redirect the approval breaks the signature. The
	// remaining fields are not covered, and do not need to be: agent_instance, interaction_id
	// and expires_at are checked against the daemon's own stored binding tuple, so altering
	// any of them yields CodeStaleApproval rather than a misapplied decision. The DECISION is
	// deliberately unsigned (IS-LIFE-4) -- the signed tuple has one content slot and D7 spends
	// it on the content -- riding instead inside the epoch-sealed frame, unforgeable by the
	// relay and alterable only by the gateway, which is the documented D4/D5 owner-uid
	// residual and not a new one.
	Approve    *ApproveReq `json:"approve,omitempty"`
	Launch     *LaunchReq  `json:"launch,omitempty"`
	GateToken  string      `json:"gate_token,omitempty"`  // take_control: one-shot gate token; the gateway reconstructs Control.GateToken from it. Bound into the signature via ContentHash=SHA256(GateToken), not carried in the signed tuple.
	TTLSeconds int         `json:"ttl_seconds,omitempty"` // take_control: caller-requested control-session lifetime (seconds), clamped server-side. Not signed (cosmetic like Cols/Rows).
	// ResyncCursor is journal_resync's from-cursor: the boundary the phone's session cache
	// currently stands at. The gateway's journal_read(from) answers with the complete roster
	// as-of a new boundary plus the events in between, which is exactly JournalReseed's
	// {Roster, Events, Cursor} -- so without it the gateway must read from 0 and re-send
	// every event the machine has ever journalled to repair one hole.
	//
	// Not signed, like TTLSeconds, and it does not need to be: the frame is sealed under the
	// epoch content key, which the relay cannot forge, and the worst a wrong value can do is
	// make the reseed carry more or fewer events than needed -- the roster it replaces the
	// cache with is complete either way.
	ResyncCursor uint64 `json:"resync_cursor,omitempty"`
	// RosterOnly distinguishes an authoritative roster refresh from PB-SYNC-2's full
	// journal repair. The gateway still reads one atomic daemon snapshot, but publishes
	// only its roster at ResyncCursor, leaving every later backlog event admissible on the
	// phone. Additive + omitempty keeps ordinary resync frames byte-compatible.
	RosterOnly bool `json:"roster_only,omitempty"`
	// DiscardedBacklog is the explicit recovery proof attached to a roster-only refresh
	// after the phone has successfully discarded its own stale relay mailbox generation
	// and durably adopted the relay's replacement cursor. It tells the gateway to publish
	// the bounded roster reseed at the daemon snapshot's FINAL cursor, so the next live
	// journal record is contiguous with the phone's new authoritative boundary. A healthy
	// roster refresh omits it and keeps ResyncCursor, preserving unread backlog.
	//
	// The bit is meaningful only with RosterOnly. It is sealed under the epoch content key
	// with the rest of RemoteCommand, so the relay cannot invent destructive recovery.
	DiscardedBacklog bool `json:"discarded_backlog,omitempty"`
	// DiscardRecoveryToken is the opaque durable recovery generation the phone created
	// BEFORE asking the relay to delete anything. A supporting gateway echoes it only on
	// the authoritative fast-forward JournalReseed that answers this command. The phone
	// clears its pending recovery only when that exact echo commits contiguously.
	DiscardRecoveryToken string `json:"discard_recovery_token,omitempty"`
	// SessionLaunch is the session_launch body (Wave R5): the phone's confirmed preset
	// selection. It rides beside the signed tuple under launch's own rule -- the body is
	// bound into the signature via ContentHash = SessionLaunchContentHash(SessionLaunch),
	// which the daemon recomputes from the forwarded body, so a gateway that alters the
	// preset id, revision or prompt breaks the signature.
	SessionLaunch *SessionLaunchReq `json:"session_launch,omitempty"`
	// TerminalControlBegin is the terminal_control_begin body (ADR-017 T6). It rides
	// beside the signed tuple under launch's own rule -- bound into the signature via
	// ContentHash = TerminalControlBeginContentHash(it), recomputed daemon-side, so a
	// gateway that re-points a valid begin at another session, another INCARNATION or
	// another profile breaks the signature.
	TerminalControlBegin *TerminalControlBeginReq `json:"terminal_control_begin,omitempty"`
	// TerminalInput is the terminal_input body (ADR-017 T6): one UNSIGNED raw-input frame.
	// It carries no content hash because the op carries no device signature at all --
	// sealing under the epoch content key is already proof the asker is the paired device,
	// and the authority that matters is the generation, which the daemon re-evaluates per
	// frame alongside the kill switch, the device registration and the capability record.
	TerminalInput *TerminalInputReq `json:"terminal_input,omitempty"`
	// ControlGeneration rides terminal_control_keepalive, which has no body of its own:
	// the generation IS the frame.
	ControlGeneration string `json:"control_generation,omitempty"`
	// SubjectOperationID is operation_status's query subject (the asked-about op), copied
	// by the gateway onto Control.SubjectOperationID. It IS bound into the signature
	// (round-2 fix-pack, MAJOR 2): ContentHash = OperationStatusContentHash(subject),
	// recomputed daemon-side from this forwarded field, so a gateway that re-points a
	// valid signature at another operation id breaks it. The earlier reading ("the worst
	// a swapped subject yields is the honest outcome of a different op this device
	// already owns the ids of") was wrong across devices: operation_status is READ class,
	// so ANY paired tier could read back another operation's namespaced session id.
	SubjectOperationID string `json:"subject_operation_id,omitempty"`
	// ComposerSend is the composer_send body (Wave R6, IS-LIFE-5): the structured message
	// the phone authored, riding beside the signed tuple under launch's own rule -- the
	// body is bound into the signature via ContentHash = ComposerSendContentHash(it),
	// which the daemon recomputes from the forwarded body, so a gateway that alters the
	// text or re-points expected_turn breaks the signature.
	ComposerSend *ComposerSendReq `json:"composer_send,omitempty"`
	// TurnInterrupt is the turn_interrupt body (Wave R6 review fix-pack B7). The op was
	// bodyless until finding B7 proved a Stop with no turn coordinate lands the cancel
	// sequence in whatever turn is current when it ARRIVES. The body is bound into the
	// signature via ContentHash = TurnInterruptContentHash(it), recomputed daemon-side,
	// on composer_send's exact pattern.
	TurnInterrupt *TurnInterruptReq `json:"turn_interrupt,omitempty"`
	// History and Detail are the two UNSIGNED M3 read bodies (ADR-014). They carry no
	// signature binding because the ops carry no device signature at all -- sealing under
	// the epoch content key is already proof the asker is the paired device, and the
	// daemon gates them on the negotiated journal capability and the kill switch
	// (handleInteractionHistory / handleInteractionDetail), exactly as journal_read is.
	History *InteractionHistoryReq `json:"interaction_history,omitempty"`
	Detail  *InteractionDetailReq  `json:"interaction_detail,omitempty"`
	// BodyVersion binds this command to the R1 profile version the phone selected
	// (RemoteProfileV1.AcceptedBodyVersions, ADR-017 T5 / playbook §6.3: "every durable
	// semantic mutation binds the selected profile ... signs a canonical hash of its full
	// body"). It rides UNCHANGED from here through the gateway to Control.BodyVersion --
	// the gateway is a blind conduit and does not alter it. NOT YET content-hash bound (a
	// compromised gateway could alter it in flight); that gap closes as each R1 op's real
	// body lands and folds BodyVersion into ContentHash. There is no body version 0: an
	// absent value is refused identically to a wrong one.
	BodyVersion int `json:"body_version,omitempty"`
}

RemoteCommand is the plaintext a phone seals into a command envelope for the untrusted relay: the signed command tuple plus, for a launch, the LaunchReq spec it is bound to. DeviceCommandAuth is embedded (its fields inline in the JSON, no tags), and Launch is omitempty, so this wrapper is byte-compatible with a bare DeviceCommandAuth envelope in BOTH directions -- a bare-auth envelope decodes here with Launch nil, and a RemoteCommand decodes as a plain DeviceCommandAuth ignoring the extra field. The launch spec is NOT part of the signed tuple; it is bound instead by ContentHash = protocol.LaunchContentHash(spec), which the daemon recomputes from the forwarded spec, so a gateway that alters the spec breaks the signature.

type RemoteProfileV1

type RemoteProfileV1 struct {
	Version                  int            `json:"version"`
	AcceptedActions          []string       `json:"accepted_actions"`
	AcceptedBodyVersions     map[string]int `json:"accepted_body_versions"`
	InteractionSchemaVersion int            `json:"interaction_schema_version"`
	TerminalViewVersion      int            `json:"terminal_view_version"`
	CapabilityRecordVersion  int            `json:"capability_record_version"`
	// The three ADR-016 W1 fields: the machine's authoritative relay TLS policy, joining
	// this same R1 profile version rather than bumping it (ADR-016:194). RelayTLSPolicy and
	// RelaySPKIPin are INDEPENDENT -- a pin's presence never implies pinned_spki and a
	// pin's absence never implies webpki -- and none of the three carries omitempty: this
	// struct rides EVERY reconcile, and an absent key must stay distinguishable from a
	// legitimately-zero one (W9 step 6's compatibility-pin withdrawal depends on exactly
	// that distinction being resolvable).
	RelayTLSPolicy string `json:"relay_tls_policy"`
	RelayHost      string `json:"relay_host"`
	RelaySPKIPin   []byte `json:"relay_spki_pin"`
	// The three ADR-017 T5 TerminalView bounds: "size, line and rate bounds declared in
	// the remote profile, so a phone knows the ceiling it is rendering under rather than
	// discovering it". Zero on any of them means CLAMP TO THE PHONE'S CONSERVATIVE
	// BUILT-IN, never "unlimited" (T5-a), and none carries omitempty for the same reason
	// nothing else here does. TerminalViewBounds() is the one resolver.
	TerminalViewMaxLineBytes int `json:"terminal_view_max_line_bytes"`
	TerminalViewMaxRows      int `json:"terminal_view_max_rows"`
	TerminalViewMaxRateHz    int `json:"terminal_view_max_rate_hz"`
}

func (RemoteProfileV1) OffersTerminalView

func (p RemoteProfileV1) OffersTerminalView() bool

OffersTerminalView reports whether this machine declares a TerminalView the phone knows how to read. Zero is "no fallback exists on this machine" (T5-a) -- which is literally what every machine deployed before Wave R8 sends.

func (RemoteProfileV1) TerminalViewBounds

func (p RemoteProfileV1) TerminalViewBounds() TerminalViewBounds

TerminalViewBounds resolves the bounds this profile declares. A zero field clamps to the built-in; a declared field is believed only where it is LOWER. The profile tells a phone the machine's ceiling; the phone's own ceiling is not the machine's to move, or a compromised or skewed machine grants itself an unbounded render.

func (RemoteProfileV1) TrustsCapabilityRecord

func (p RemoteProfileV1) TrustsCapabilityRecord() bool

TrustsCapabilityRecord reports whether this machine declares a capability-record version. Zero is "record untrusted", which composes with T2-a into one predicate: status card, both verbs refused.

type SendInputReq

type SendInputReq struct {
	Text   string `json:"text,omitempty"`
	Submit bool   `json:"submit,omitempty"`
	Key    string `json:"key,omitempty"`
}

SendInputReq is one owner-tier steering message (ADR-010 Amendment 1 A2), carried in Control.send_input on a send_input op against Control.session_id. EXACTLY ONE MODE per request: Key names a single key from the closed vocabulary, or Text is the message, submitted with a trailing CR when Submit. Both set, neither set, an unknown key, or text past the server's bound are refused invalid_field with nothing written.

The daemon -- not the caller and not the shim -- applies the r3p submit-boundary discipline to Text (internal/submitframe): a PTY write is never a mixture of text and the CR that runs it. All three fields are omitempty, so a control that carries no send_input serializes byte-identically to the pre-ADR-010 shape.

type SessionCapabilities

type SessionCapabilities struct {
	Provider         string `json:"provider"`          // adapter identity: claude, codex, opencode, agy, ...
	ProviderVersion  string `json:"provider_version"`  // the DETECTED version of the installed CLI
	AdapterRevision  string `json:"adapter_revision"`  // the revision of the Swarm adapter that produced the record
	SessionInstance  string `json:"session_instance"`  // the per-incarnation identifier every generation and snapshot binds to (ADR-017 T8-a)
	StructuredChat   bool   `json:"structured_chat"`   // true only when every T3 row passes against provider_version
	TerminalFallback bool   `json:"terminal_fallback"` // whether the sanitized terminal view may be offered at all
	TerminalControl  bool   `json:"terminal_control"`  // whether raw input may be entered on that view (ADR-017 T6-b)
	Interrupt        bool   `json:"interrupt"`         // whether a semantic interrupt reaches the current turn
}

SessionCapabilities is the daemon-authored, per-session-instance capability record of ADR-017 T2 / playbook §6.2: which phone surface a session gets, and which individually testable seams it supports. Launch facts and terminal authority are immutable for the life of the instance. StructuredChat may degrade on an explicit gap/sink failure and may recover only through the machine's exact-current-sink proof path; SetStructuredChat below remains the public degrade-only helper and cannot perform that recovery.

func (*SessionCapabilities) AllowsTerminalControl

func (c *SessionCapabilities) AllowsTerminalControl() bool

AllowsTerminalControl is the same predicate one authority level up. terminal_fallback alone never grants it: control is granted only where terminal_control was authored true AT LAUNCH, never where terminal_fallback was derived by a degrade (ADR-017 T6-b).

func (*SessionCapabilities) AllowsTerminalWatch

func (c *SessionCapabilities) AllowsTerminalWatch() bool

AllowsTerminalWatch is the ONE predicate every watch gate is written over, on both booleans, every time (ADR-017 T2-b). It is nil-safe on purpose: "no record" and terminal_fallback=false take one code path, so the fail-closed default is not a nil check every caller writes for itself and one caller forgets (T2-a).

func (*SessionCapabilities) SetStructuredChat

func (c *SessionCapabilities) SetStructuredChat(v bool) error

SetStructuredChat is the public degrade-only mutation helper. It allows a healthy record to degrade (true -> false), historically forcing TerminalFallback true for compatibility consumers. Android never routes to that terminal surface, and the history-gap producer now authors the stricter {false,false,false} shape directly. This helper is idempotent in either steady state and refuses an upgrade attempt (false -> true), leaving the record unchanged.

IT NEVER TOUCHES TerminalControl, and that omission is a RULING rather than an oversight (ADR-017 T6-b / D-DEGRADE-ORIGIN fence 1). The symmetrical-looking next edit -- also setting TerminalControl = true "for consistency" -- silently inverts T6-b and hands a keyboard to a degraded Claude session whose live TUI has an uncharacterized input region (the expected_input_revision gap T9 discloses as still open). A degrade grants a SCREEN, never a keyboard.

func (*SessionCapabilities) Validate

func (c *SessionCapabilities) Validate() error

Validate enforces the record's validity rule (ADR-017 T2-b), at the three seams that matter: where the record is authored, where it is decoded off the wire in the gateway, and where it is decoded on the phone.

  • structured_chat && terminal_fallback is UNREPRESENTABLE. Today the pair is consistent only by construction; a gate written over one of the two booleans enforces T2 rule 4 only for as long as that derivation stays right.
  • terminal_control without terminal_fallback authorizes bytes to a screen that does not exist (T6).
  • a record with no session_instance binds no incarnation, so no generation and no snapshot it carries can be refused when the session is replaced (T8-a).

A nil receiver is INVALID rather than vacuously valid: absence is not validity, and a caller that validates before routing must be told there is nothing to route on (T2-a).

type SessionLaunchReq

type SessionLaunchReq struct {
	PresetID string `json:"preset_id"`
	// PresetRevision is the staleness binding (playbook:447-448): the phone echoes
	// exactly the revision it displayed, so a machine-side re-authoring between the
	// list and the confirm answers stale_preset instead of silently launching
	// different policy.
	PresetRevision string `json:"preset_revision"`
	InitialPrompt  string `json:"initial_prompt,omitempty"`
	Cols           int    `json:"cols,omitempty"`
	Rows           int    `json:"rows,omitempty"`
}

SessionLaunchReq is the session_launch body: the phone's selection of ONE machine-authored preset at the CONFIRMED revision, plus the single free-text field the phone may contribute (the initial prompt) and the cosmetic terminal geometry. There is deliberately no cwd, no argv, no env, and no options field: everything that decides WHAT runs and WHERE is the resolved preset's, machine-side (D8).

type SessionView

type SessionView struct {
	EndpointID     string        `json:"endpoint_id"`
	ID             string        `json:"id"` // namespaced: <endpoint_id>/<local>
	Agent          string        `json:"agent"`
	Name           string        `json:"name,omitempty"` // user-provided label; empty (or absent, from an older daemon) falls back to Agent at display
	Tag            string        `json:"tag,omitempty"`  // user-assigned grouping label; empty means untagged
	Cwd            string        `json:"cwd"`
	Status         status.Status `json:"status"`           // the three raw dims
	Group          status.Group  `json:"group"`            // precomputed server-side (E6.9)
	GroupEnteredAt time.Time     `json:"group_entered_at"` // when the session entered Group; drives newest-first ordering
	LastActivity   time.Time     `json:"last_activity"`
	CreatedAt      time.Time     `json:"created_at"`
	Summary        string        `json:"summary"` // V-4 one-line last-output summary
	// SpawnedFrom / SpawnIntent expose the session's lineage (ADR-010 D4) so the
	// roster can show where a session came from. Both are omitempty: an ordinary
	// session's row serializes exactly as it did before the fields existed.
	SpawnedFrom string `json:"spawned_from,omitempty"`
	SpawnIntent string `json:"spawn_intent,omitempty"`
	// BackendPlanError is the persisted reason this session launched with no backend
	// although its adapter declared one (persist.Meta.BackendPlanError): the agent's
	// PTY runs but nothing serves the attach channel. Surfaced on the roster so the
	// degradation is visible where the user looks (`swarm ls`, the TUI) instead of
	// one daemon.log line. omitempty: a healthy row serializes exactly as it did
	// before the field existed, and an older daemon simply never populates it.
	BackendPlanError string `json:"backend_plan_error,omitempty"`
	// Supervision is the persisted supervision mode of a handoff child (ADR-010
	// Amendment 3 C1/C5) and SupervisionPending reports that an attention event of
	// that child awaits its source: LIVE daemon state, sampled at stamp time exactly
	// like RemoteControlled, so the board can show a supervisor pending or gone. Both
	// omitempty: an unsupervised row serializes exactly as it did before the fields
	// existed.
	Supervision        string `json:"supervision,omitempty"`
	SupervisionPending bool   `json:"supervision_pending,omitempty"`
	// RemoteControlled reports that a PAIRED DEVICE is driving this session: it holds
	// the remote-tier controller lease, OR it sent a message recently
	// (skeleton.phoneRecentlyActive). The second clause is what keeps the field
	// meaningful once take-control leaves the product -- composer_send never needed a
	// lease, so a lease-only answer would be false for every phone there is.
	//
	// THE CLAIM THAT USED TO STAND HERE IS WITHDRAWN. It read: "a TUI attach and a phone
	// take_control compete for the SAME single shim subscriber slot, so the two are
	// mutually exclusive and no live in-attach indicator is possible". M0.1 disproved it
	// (docs/verification/mirror-m0.md): production runs two protocol Servers over one
	// coreAPI with separate lease maps, and coreAPI.Attach subscribes to the SHARED
	// per-session tap, so the shim's single-subscriber slot is claimed once and both live
	// streams survive in both orders. The roster row is still where this is shown, but
	// because a live in-attach indicator needs a side channel attach.Session does not
	// have -- not because the two states cannot coexist.
	//
	// omitempty: an uncontrolled row serializes exactly as it did before the field
	// existed, so the released 0.8.0 gateway -- which relays these frames verbatim
	// and is untouched by this change -- sees no new bytes.
	RemoteControlled bool `json:"remote_controlled,omitempty"`
	// RemoteActivityAt is WHEN a paired device last delivered a message to this session,
	// carried only while that instant is still inside the daemon's horizon
	// (skeleton.phoneActiveHorizon). It is what the terminal's marker SAYS -- "phone sent
	// 09:41" -- and it is an instant rather than another flag because the row has to state
	// an EVENT: a bare noun sitting beside "supervisor pending" reads as a CONDITION, "a
	// phone is on this session", which is precisely the presence claim nobody on this wire
	// measures (conversation surface, plan G.5).
	//
	// A POINTER with omitempty, like Capabilities and for the same reason in a different
	// key: encoding/json does not omit a zero time.Time, so a value field would stamp
	// "0001-01-01T00:00:00Z" onto every row that has never seen a phone. Absent means no
	// message is in the window -- there is no in-band way to say "never" as opposed to
	// "not lately", and the marker needs no such distinction because both draw nothing.
	RemoteActivityAt *time.Time `json:"remote_activity_at,omitempty"`
	// Capabilities is the daemon-authored per-session capability record (ADR-017 T2): a
	// pointer with omitempty, so an absent record (an older daemon, or a session the
	// daemon has not stamped yet) is wire-distinguishable from a stamped record that says
	// structured_chat=false. The phone renders from this record and infers nothing from
	// whether a transcript happens to be empty (T2 rule 3).
	Capabilities *SessionCapabilities `json:"capabilities,omitempty"`
}

SessionView is one general-view row (V-4), stamped for the receiving client: a namespaced id + endpoint id + the daemon-computed status Group (E6.9 — clients never call status.Derive), alongside the three raw status dimensions.

type TerminalControlBeginReq

type TerminalControlBeginReq struct {
	Session         string     `json:"session"`
	SessionInstance string     `json:"session_instance"`
	Profile         int        `json:"profile"`
	ExpiresAt       *time.Time `json:"expires_at,omitempty"`
}

TerminalControlBeginReq is the signed request that mints one non-transferable control generation over a terminal_fallback session.

It binds the SESSION INSTANCE and the SELECTED REMOTE PROFILE as well as the session, because a signature that verifies over bytes which do not name what it authorised is a signature over the wrong thing: without the instance a generation minted against one PTY authorises bytes into its replacement, and without the profile a phone's idea of what "control" means can differ from the machine's.

type TerminalInputReq

type TerminalInputReq struct {
	Session           string `json:"session"`
	SessionInstance   string `json:"session_instance"`
	ControlGeneration string `json:"control_generation"`
	Bytes             []byte `json:"bytes"`
}

TerminalInputReq is one unsigned raw-input frame. It names the generation it was authorised under and the instance that generation belongs to, so both can be re-evaluated per frame (ADR-017 T6-e) rather than trusted from the begin.

IT IS NOT AN APPROVAL VERB AND MUST NEVER BECOME ONE (T6). An approval answered from a fallback screen still travels as the signed ActionApprove of IS-LIFE-4, or the button is not shown.

type TerminalSnapshot

type TerminalSnapshot struct {
	Session string   `json:"session"` // namespaced session id the snapshot is for
	Lines   []string `json:"lines"`   // sanitized plain-text grid rows, top to bottom
	Cols    int      `json:"cols"`    // grid width the snapshot was rendered at
	Rows    int      `json:"rows"`    // grid height the snapshot was rendered at
}

TerminalSnapshot is one server-rendered, sanitized terminal snapshot (A7 renderer slice B), carried in Control.Terminal on a terminal_snapshot op. The daemon renders the session's VT grid to plain text (every control byte already stripped) so only sanitized text crosses the daemon->gateway socket; the phone displays Lines as-is.

type TerminalViewBounds

type TerminalViewBounds struct {
	MaxLineBytes int
	MaxRows      int
	MaxRateHz    int
}

TerminalViewBounds is the ceiling a phone renders under. It is resolved from the machine-declared profile fields, clamped by the phone's own built-in ceiling.

type TerminalViewV1

type TerminalViewV1 struct {
	Session         string    `json:"session"`
	SessionInstance string    `json:"session_instance"` // T8-a: which incarnation this screen belongs to
	ViewEpoch       uint64    `json:"view_epoch"`       // minted per render-loop start (T4-a)
	Revision        uint64    `json:"revision"`         // strictly increasing within one epoch
	Reset           bool      `json:"reset"`            // true on the FIRST snapshot of every epoch, on every path
	Cols            int       `json:"cols"`
	Rows            int       `json:"rows"`
	Lines           []string  `json:"lines"`       // machine-sanitized, one string per grid row (vt.SnapText)
	RenderedAt      time.Time `json:"rendered_at"` // the MACHINE's clock: T4-b's staleness is derived from the snapshot's own age
}

TerminalViewV1 is ADR-017 T4's read path: a FULL COALESCED SNAPSHOT of a terminal_fallback session's sanitized screen. There is no patch language and no delta -- a slow observer drops superseded snapshots and receives the newest complete revision, which is what internal/remotegw/coalesce.go already does and what T4 makes a WIRE CONTRACT.

WHY BOTH ViewEpoch AND Revision (amendment T4-a). The daemon's render loop is PER INVOCATION and the gateway's watcher re-runs it after every transport hiccup with a fresh emulator. A bare revision restarted at 1 while the phone holds revision N makes the phone's only sane rule -- "drop anything not strictly greater" -- discard every snapshot of the second run, with no error on either side: the user reads a plausible, frozen screen. So the epoch is minted per render-loop start and the revision is monotonic WITHIN it, and the phone's rule is: differing epoch = hard reset that discards prior state; same epoch = strictly greater revision only.

A watch grants NO INPUT AUTHORITY (T4). Nothing in this body carries or implies one.

type TurnInterruptReq

type TurnInterruptReq struct {
	Session      string `json:"session"`
	ExpectedTurn string `json:"expected_turn"`
}

TurnInterruptReq is the turn_interrupt body (Mirror M2.4).

THE OP USED TO BE BODYLESS, and that was a defect, not an economy (R6 review finding B7, pre-recorded in docs/verification/r6-red/chat-red.txt). composer_send carries expected_turn for one stated reason -- "a tap lands later than it was rendered" -- and a Stop button is tapped under exactly that race. Probed: with turnA superseded by turnB, a composer_send rendered against turnA was refused stale_turn while the interrupt rendered against the SAME turnA succeeded and typed the cancel sequence into turnB. In playbook §8.1 that turnB is the turn the OWNER just started from the terminal, and the adapter's own note records that the cancel key at an idle Claude prompt CLEARS the composer -- so a late phone Stop wipes the terminal user's half-typed line.

ExpectedTurn is therefore REQUIRED and non-empty: an interrupt names the turn it stops or it is not an interrupt. That also closes the idle case by construction -- there is no spelling of "interrupt whatever is running", so a Stop can never land on a turn nobody rendered it against. Refusals reuse the composer's own CodeStaleTurn: same subject, same remedy (re-read the transcript).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL