Term native control
The global TCP term registration sends completion notifications and, under
the default mesh-open posture (COSMIX_MESH_OPEN unset or any value other
than 0), serves the full targetless active-tab verb set to any mesh or
local caller with no grant: term.tabs, term.tab.new/select/close,
term.panes, term.pane.split/select/close, term.snapshot, term.type,
plus INFO/HELP (term 0.8.5, per the 2026-09-15 full-mesh-access law).
These verbs act on the active tab/pane of the instance holding the name at
delivery time; they carry no target binding. term.type revokes any
delegated control writer exactly as real keys do. Any mutating verb's body
(tab.*, pane.*, type) may add "request_id":"<string>": a resend of
the same request (same verb and arguments; JSON key order is free) replays
the recorded reply instead of re-executing the verb (last 128 remembered) —
use it on every mutation that might be resent after a lost reply. A reused
id with a different verb or arguments is refused as a conflict, never
answered with another request's reply; the replay is the recorded outcome
of the original attempt, so retrying after changing state needs a fresh id.
Reads never consult the cache and always answer current state. With
COSMIX_MESH_OPEN=0 the strict diagnostic-only lane returns: INFO/HELP
only, everything else refused with FORBIDDEN, including when
native-session bootstrap fails.
Target-bound protected controls belong on the broker-allocated, verified Unix Term identity. BROKER-023 defines their policy; a service name is never proof of authority, which is why the global lane's verbs stay targetless — a caller that needs instance/incarnation/pane-generation binding uses the native-session route.
Recipient gate
The native identity actor receives protected requests on its own verified Unix connection. It uses the broker-stamped principal, its own current Term record, and the live pane model. It never takes identity or policy from request bodies, service names, PIDs or ordinary TCP headers.
COSMIX_MESH_OPEN defaults to open (unset or any value other than 0), matching
CTK app-control and desktop.mix. Every validated broker principal has every
capability on every pane, regardless of caller UID, node, broker epoch, session
ownership, grant set or allocation policy. Caller session leases do not gate
this posture. Instance/incarnation continuity, live pane identity/generation,
recipient lifecycle, foreground freshness and PTY-boundary rechecks still apply.
With COSMIX_MESH_OPEN=0, the strict rules below apply.
COSMIX_TERM_POLICY=restricted selects the restricted policy at allocation;
the default is default-open. An invalid value disables native bootstrap.
The allocated record's policy remains authoritative on resumption.
Under default-open, an independent verified same-UID owner connection has all
six owner capabilities without confirmation. A bound pane-shell connection has
only its recorded capabilities and pane scope, under either policy. Opening
an independent connection is ambient owner access under default-open, including
when the process happens to be a child. Restricted policy removes that ambient
access. Other UIDs, TCP callers, names-only impostors and undelegated principals
receive the same FORBIDDEN refusal without target-existence details.
Every request names a target object:
{
"target": {
"instance_id": "00000000000000000000000000000001",
"incarnation": "00000000000000000000000000000002",
"pane_id": "1",
"pane_generation": "1"
}
}
IDs above are illustrative. Discover current records through the native session API. Instance/incarnation use BUS-016 hex encodings; generations, pane IDs and request IDs use decimal strings. A graphics-only pane has no protected control authority: delivering a launch descriptor alone is insufficient. The actor must first observe or reconcile a successful child attachment. There is no active-pane fallback for mutations.
Capabilities and owner restrictions in this table apply only in strict posture.
| Verb | Strict capability | Additional fields / result |
|---|---|---|
term.session, term.list, term.tabs, term.panes | read_state | Scoped pane/tab metadata, binding diagnostic, input generation, retry epoch/high-water and limits |
term.snapshot | read_state | Metadata only by default |
term.snapshot with contents:true | read_contents | Snapshot text, bounded by the response limit |
term.type | input | text, foreground_generation, mutation ID/epoch |
term.tab.new | manage_layout | Create a tab; requires owner/owning Term authority |
term.pane.split | manage_layout | dir: h, horizontal, v or vertical; requires owner/owning Term authority |
term.tab.select, term.pane.select | manage_layout | Select the explicitly targeted pane and its tab |
term.tab.close, term.pane.close | terminate, plus affected layout authority | Close the explicit target |
term.execute | execute | source, prompt_generation, mutation ID/epoch; forwarded to the pane shell's own admission surface |
term.exec.result | execute | operation_id: the forwarded execution's state and, once it has one, its result |
term.exec.cancel | execute | operation_id: cancel that execution; cooperative, and the reply says what actually happened |
term.task.submit | execute | source XOR argv, cwd, env, timeout_ms, mutation ID/epoch; forwarded to the pane shell's isolated-task surface |
term.task.result | execute | operation_id: the task's state (running, cancelling, settled) and, once settled, its outcome, streams and structured result |
term.task.cancel | execute | operation_id: SIGTERM to the task group, 2s grace, then SIGKILL — the one HARD termination in the surface |
term.operation | read_state | operation_id: retrieve the caller's retained operation outcome |
Layout requests supply affected, an array of additional explicit targets,
when source/destination tabs, sibling geometry or replacement focus affect more
than the primary target. The gate computes the affected set under the model
lock and checks every member. Bound children cannot create panes outside their
grant. Close requires termination authority for the panes being removed; layout
authority does not substitute for termination authority.
Execution
term.execute does not decide whether an execution may happen. The pane shell
does, against its own prompt, its own editor state and its own prompt
generation — none of which Term can observe. Term owns the same three things it
owns for term.type: the actor and target rules, the BROKER-022 retry rules,
and the guarantee that the request reaches the child this pane is bound to at
exactly this generation. A pane whose child has not enrolled, has been replaced,
or is at a different generation is refused before anything is forwarded.
The shell's refusals are relayed rather than replaced, because BUSY and
STALE_GENERATION tell a caller two different things to do next. BUSY means
the human is using the prompt and nothing was discarded; STALE_GENERATION
means the generation moved and the caller should re-read it. The full admission
rules, the visible echo, the result shape and the honest cancellation guarantee
table are in the Mix manual under "Native pane-shell execution (stage D)"
(mix man cli).
term.execute is a mutation: it spends a request ID and carries a request
epoch, and its ID is retired before the submission is forwarded, so a lost reply
can never become a second execution. term.exec.result and term.exec.cancel
are not: both address one immutable evaluation identity and are idempotent by
construction.
Request IDs are namespaced at the hop. Term forwards on its own connection,
so at the child every caller's IDs would otherwise land in one (actor, id)
space keyed to Term — one agent's ID 1 would replay another's operation, and two
agents using the same ID with different bodies would conflict with each other
forever. Term mints its own monotonic sequence per caller request and remembers
the mapping, so the child sees one ID per (Term, forwarded-seq). The mapping is
what makes a retry safe: it forwards the SAME child ID, reaching the child's own
dedupe rather than submitting again.
A forwarded submission whose answer never arrives is reported as
UNKNOWN_OUTCOME, and that placeholder is deliberately not retained. A
retained local timeout would make every byte-identical retry replay the
placeholder forever; not retaining it lets the retry re-forward to the child,
which is the only party that can say what actually happened. The same applies to
a reply too large to deliver.
The announcement names the originating agent, not Term. Term supplies the
principal from its own broker-stamped actor context — never from a caller-
supplied field, which would let one agent announce itself as another — and the
child renders it through the same allowlist escape as the source, as
<originator> via Term …. The "via" is load-bearing: the child authenticated
Term, not the name Term relayed.
Isolated tasks
term.task.* mirrors term.exec.* mechanically — same capability, same epoch
rule, same forwarded-id mapping, same retry discipline, same non-retention of
local placeholders — and forwards to the child, which owns every decision about
whether a task may run. Term adds no task state machinery of its own.
Two differences are deliberate. The forwarded-id map stays keyed
(identity, sequence) and is NOT split by family: splitting it would let one
caller request ID mint a fresh forwarded ID on each surface and BOTH execute.
Family scoping applies to ADDRESSING only, so term.exec.result cannot read a
task operation and term.task.result cannot read an evaluation — each refuses
a foreign ID exactly as it refuses an unknown one.
And a task submission carries no principal label. A task never renders into the pane, so there is no announcement for one to appear in; the visible-echo rule is about interactive submissions in an attached human pane, and an isolated task is neither.
Term relays the source/argv union exactly as sent and never picks a side, so
both-or-neither reaches the child's own refusal rather than being resolved at
the hop. Absent fields are OMITTED rather than relayed as JSON null: a null is
a present field of the wrong type, so it earns a malformed-body complaint about
Term's own framing instead of the child's honest answer about the caller's
request.
Non-retention has one addition for tasks. A refusal that is about the child's
LOAD rather than the request — RESOURCE_LIMIT at the concurrency cap, or
UNAVAILABLE when the machine could not start the work — settles nothing, so
Term relays it without recording it. The documented remedy for both is to back
off and retry the same submission, and a retry only reaches the child's own
dedupe if Term did not answer from a record first.
The full task contract — the enumerated environment, the termination ladder, the
caps and the advertised deferrals — is in the Mix manual under "Isolated
supervised tasks" (mix man cli).
Live properties
term.props.get supports property:"state" and property:"contents" with the
same explicit target and separate read capabilities. term.props.set supports
property:"selected", value:true and property:"input", value:"..."; selection
uses layout authority and input uses input authority. Mutating properties carry
the same generations, affected set, request ID and epoch as their corresponding
verbs. Input properties also need foreground_generation.
These are live pane properties, not a second persisted copy of terminal state.
The adapter constructs PeerIdentity only from verified context and resolves
the props-store AuthPolicy/capability set with the same Term-owned evaluator.
Fresh lease checks complete before synchronous resolution; commitment rechecks
scope and local lifetime under the model lock. Legacy property identities retain
an absent optional native context. Broad property-write strings cannot bypass
the pane gate. Private watches, audit subscriptions and retained screen replay
are not installed by this adapter; unsupported property operations are refused.
Input and revocation
Each accepted input request has a two-second delivery deadline. In strict posture
it also claims a one-writer lease: another actor receives BUSY while that lease
is live. Mesh-open admits every actor through the same serialised PTY queue.
Input requires the current
foreground generation; human keys, focus/layout changes and observed PTY foreground
process-group changes invalidate old generations. The first human key revokes
agent ownership before its own admission. It never waits for lease expiry.
The existing ASCII key encoder supports ordinary ASCII, newline/Enter, tab, backspace and Ctrl+C/D. This is bounded keyboard input, not the future paste or execution pump. Unsupported characters are rejected before queue admission. The request envelope is limited to 8,192 bytes; responses to 256 KiB. Queue admission is atomic and actual nonblocking PTY writes recheck the permit. Expired or revoked, unwritten agent bytes are discarded without counting them as PTY writes. Already written bytes cannot be recalled.
Term establishes conservative Deadlines outside the model/property locks. In
strict posture these come from
two different sources because lease.check answers only for a record the asking
connection holds a delivery dependency on. A bound caller's request registers
exactly that dependency, so its lease is a real lease.check, reused within one
five-second lease window and dropped by the lifecycle notices and gaps that drop
the permits it authorised. Term's own attachment never has such a dependency, so
its deadline comes from the renewal it must issue every five seconds anyway:
renew refuses unless the record is attached on that very connection, and returns
the refreshed remainder. A failed renew, a reconnect, a gap or a notice about
Term's own attachment leaves no deadline at all rather than a stale one. Queued
permits retain those deadlines and a read-only connection-liveness probe.
Mesh-open retains the recipient deadline and live pane guard, without the caller
lease or caller-session revocation gate.
Lifecycle revocation/suspension, successor binding generations, gaps, connection
loss, pane close and child exit invalidate permits. No missed notice can extend
a deadline. The verified client inbox is bounded at
256 deliveries; a full inbox drops that delivery without disconnecting, because
a gapped recipient must still resynchronise on the same connection
(BROKER-022). Only receiver closure or transport loss retires the reader and
invalidates connection liveness; broker-side lifecycle queue/gap behaviour is
unchanged. A drop is not a delivery, though: it raises a sticky gap flag the
consumer reads, and Term treats that flag as identical to a broker-signalled
gap, discarding cached lifecycle authority and resynchronising. The flag is
only read when the next delivery is handled, so a dropped notice can leave
cached authority live until then, bounded by the five-second lease window.
term.input.revoked is a private event carrying target, request ID, a
delivered-byte lower bound and an outcome derived from it: complete when every
byte reached the PTY before the lease ended, partial_or_unknown otherwise. The
retained operation result reports that same distinction, so the event and the
query never disagree about what happened. Its
recipient_connection routing constraint contains the original verified broker
epoch and connection ID. Noded accepts this direct event only from a currently
attached same-UID Term, checks the exact destination connection under its session
lock, and stamps its source. This also serves unnamed ambient owners without
inventing service names. Old names or successor attachments cannot collect an
old connection's event. Event queues are bounded and best-effort; retained
operation queries remain available within the retry window. Delivering one of
these events also takes a recipient dependency slot on the destination
connection, and those slots are capped: at the cap the notice is simply not
delivered. That is within BROKER-022's best-effort contract and is why the
retained operation result, not the event, is the answer of record — a client
that needs the outcome asks for it rather than waiting to be told. A completed input
outcome means bytes reached the PTY, never that a shell command completed.
Protected requests, replies, refusal bodies, property contents and private input events are omitted before tap enqueue. Observe receives only BROKER-024 metadata; general application diagnostics do not log payloads. Unsupported VT-event logs record only the event discriminant, not clipboard/title/text fields.
Retries and errors
Mutations require a nonzero, monotonically increasing request_id and
request_epoch equal to the authenticated connection's ID, available from
session.hello or term.session. Preserve the exact epoch and JSON payload when
retrying. The epoch constrains the ID; it never supplies the authenticated actor.
term.session reports the current actor's request high-water mark.
For grantless mesh-open input/layout requests, request_epoch may be omitted.
Term uses the broker-stamped current connection ID, so a one-shot send can type
without an earlier request on that same connection. IDs and retries remain scoped
to that connection; an explicitly supplied stale epoch still returns
UNKNOWN_OUTCOME. Preserve an explicit epoch for retries across reconnects: an
omitted epoch on a new connection represents a new request, not a retry of the
old connection's request. foreground_generation remains mandatory for typing.
Entries bind the authenticated actor, target incarnation/generation, verb and
payload hash. That hash covers the request bytes AS SENT: Term does not
canonicalise, so two encodings of the same object are two different payloads and
a retry MUST replay the body byte for byte, not re-serialise it. Repeated
accepted requests return the same operation ID and submission result;
conflicting arguments produce CONFLICT with
details:{"reason":"request_mismatch","retry_requires":"byte_identical_body"}. term.operation
can report a later PTY-write outcome. Successful close retries resolve before
live-pane lookup, so removal does not cause a duplicate action.
Retention is 15 minutes or the last 1,024 accepted mutations per actor, with an
instance cap of 4,096 retained entries and 256 actor histories. Actor high-water
marks remain until Term exits. Ageing alone never drops an actor's key, because
its high-water mark is what keeps a late retry from re-executing; at the actor
cap the coldest key is evicted instead, preferring one whose entries have all
expired. Exhausting the entry cap returns RESOURCE_LIMIT. Retired IDs, stale
instance incarnations and old connection epochs produce UNKNOWN_OUTCOME, never
automatic re-execution. The mark advances BEFORE the verb runs, so an ID can be
retired without its outcome ever being recorded — deliberate, so a committed
mutation cannot re-execute when its result was lost. Because an actor holding
only input or terminate cannot read the mark back through term.session,
UNKNOWN_OUTCOME carries
details:{"reason":"retired_request_id","request_high_water":"<n>"} so it can
resynchronise without a read capability. A bound actor
can recover retained results after authenticated resumption. There is no
exactly-once guarantee across crashes or a new authenticated actor.
Success uses ABP RC 0; typed application refusals use RC 10. The error vocabulary
includes INVALID_ARGUMENT, NOT_FOUND, STALE_GENERATION, CONFLICT, BUSY,
FORBIDDEN, UNSUPPORTED, RESOURCE_LIMIT, DISCONNECTED, EXPIRED, CANCELLED
and UNKNOWN_OUTCOME. Authority failures remain uniform FORBIDDEN; a stale
foreground generation is STALE_GENERATION. Transport failure and caller-side
timeout remain client transport outcomes, separate from an application reply.
The S4 fixture inventory and explicit gate prerequisites are in
src/desktop/apps/term/tests/README.md. Test/clippy acceptance is supplied by the
orchestrator; this page describes implementation, not a claim of passing gates.
The enforcement fixtures are #[ignore]d because each spawns a real broker and a
real Mix child over a PTY, so a plain cargo test reports them as ignored and
says nothing about enforcement. Run them mechanically from src/:
mix desktop/apps/term/check-s4-gates.mix # the 14 S4 enforcement fixtures
mix desktop/apps/term/check-production-e2e.mix # p0i-01, the production child-proof
It builds the current-HEAD Mix the fixtures demand — they refuse a stale or installed binary — runs them by exact test path, and requires the precise expected pass count, because libtest exits 0 when its filter matches nothing and a renamed module would otherwise leave the gate green while running none of them.
One fixture is deliberately outside both scripts. p0i_07_other_uid_both_policies
needs root and a second UID, and refuses rather than passing silently without
them. Run it explicitly:
cd src/desktop
BIN=$(ls -t target/debug/deps/term-* | grep -v \.d | head -1)
sudo env COSMIX_SESSION_TEST_UID=<a non-root uid> \
COSMIX_E2E_MIX_BIN=<abs path to src/target/release/mix> HOME=/root \
./$BIN --exact \
native_session::enforcement_tests::p0i_07_other_uid_both_policies --ignored