Multi-device and surfaces¶
What this document is responsible for¶
Section 29 defers itself. Its last subsection states the position plainly — "Defer the Device concept, presence, device-scoped tool routing, and notifications to a milestone with concrete use cases, exactly as memory and MCP are deferred" — and the plan's sequencing table puts inbound surfaces and pairing at Milestone 10 and beyond. Writing contracts for the four ports Section 29.6 names would be building the thing the plan defers.
So this document does not design the Device. It audits the seam.
The rule that governs every choice below: a deferred design has to
be additive when it lands, and the only way to know whether it is
additive is to check. The check is the deliverable — where the
corpus already holds a device-shaped hole, where it holds something
that contradicts Section 29, and what each contradiction will cost
whoever resolves it.
Three things follow.
- A seam nobody has walked is a hope rather than a seam.
tool-system.md reserves
ExecutionTarget.kind = "device"and thedevice.tool domain and argues that doing so "costs nothing now and prevents the device path from arriving as a parallel pipeline later". That is a checkable claim about the rest of the corpus, and it is checked below rather than assumed. - The places that do not fit are the content. Eight already hold the hole and need no edit at all. Five do not, and those five are what a Milestone 10 implementer would otherwise find one at a time, each in the middle of doing something else.
- Nothing here is 0.1 work. Section 29.8 asks two things of 0.1 —
that every read and write be principal-scoped and served from the
core, and that a second client be confirmed able to attach to a
session and replay — and both are specified, the second by a hard
gate at Milestone 5,
gate.api.replay_exactin http-api-and-streaming.md. An audit is the right shape for this subject because the obligation that is actually due is already discharged.
ADR-0050 later adds an authoritative session index and session deletion as a separately authorized post-Milestone 9 API extension. Those operations reinforce the seam audited here: conversation history remains principal-scoped server state and every local history store is a cache. They require no Device identity, pairing, presence, notification, hand-off, or device-scoped tool mechanism.
The three subsections that need nothing¶
Sections 29.1 through 29.3 — one shared core, what must be cloud-shared, what stays device-local — describe the architecture the corpus already has rather than propose a change to it. PostgreSQL is the source of truth, devices are clients of the HTTP API, and every one of the thirteen components 29.2 lists as cloud-shared is cloud-shared because it is a table, a port, or a server-side service in some other document. readiness.md reached the same conclusion when it recorded the gap: the section's core claim is "defensible without" an expansion.
The audit below is therefore about 29.4 through 29.7, which is where
the Device is introduced and where the corpus can actually be
wrong.
The eight places the seam is already cut¶
Each of these exists today, and a Device landing later uses it
without editing it.
ToolSource.DEVICEis a declared enum member. tool-system.md declares four sources andDEVICEis one of them, so a device tool entering the registry is a value the type system already admits.- Device output is forced untrusted at registration. The
registry overwrites
output_trusttoEXTERNAL_UNTRUSTEDforDEVICEexactly as it does forMCPandSANDBOX— overwritten rather than validated, so a device cannot declare itself trustworthy. builtin-tools.md widens the same rule to key ontarget_kindas well assource, which catches a builtin whose execution target is a device. - The composition root is where both are enforced.
Reserved-domain enforcement and forced trust labels "both happen
at registration, which means they happen here or they do not
happen at all"
(bootstrap-and-composition.md),
and
DEVICEis named in that enforcement besideMCPandSANDBOX. - The
device.domain is reserved and a builtin cannot take it. The domain partition table assignsdeviceto device-scoped tools registered at attach, and registering a builtin in that domain is a startup error — which is what stops a device from shadowingworkspace.write_text. - A device is a field on the policy input, not a second policy
path.
ExecutionTargetcarrieskindwithdeviceamong its four values and a nullabledevice_id, and policy-and-approvals.md states why: "Modelling the device as a field on the input rather than as a separate evaluation path is how that stays true." - One authorization gate already names the device channel. Hard
gate 3 of policy-and-approvals.md
requires exactly one function to move an invocation from
PROPOSEDtoAUTHORIZED, asserted by an import-boundary test, and states that "the sandbox bridge and device channel both reach it". ADR-0005 found three surfaces that propose actions and the device channel is one of them. - Offline is a specified outcome rather than a hang. A call to
a tool whose device is not connected returns
unavailablewithtool.device_offline, which is a row of the availability table that already exists — added there, rather than as an exception to it, on the same argument that keeps a disconnected MCP server's tools advertised. - Concurrent approval resolution is already handled.
policy-and-approvals.md cites Section
29 by name for the requirement that any authorized device may
resolve an approval idempotently, implements it as a guarded
UPDATEwhere zero rows returned means someone else won, and carries "Two devices resolve at once" as a row of its failure-mode table.
The five places it is not¶
Attach is a third registration source, and not at session open¶
tool-system.md states that "The registry accepts
new entries from exactly two sources — the build, and MCP discovery
at session open — and skill_manage is not one of them." The domain
partition table in the same document registers the device domain at
attach. That is a third source, and the timing is the part that
matters: MCP discovery is at session open by design, because the
context engine pins the tool set there. A device attaches and
detaches on its own schedule, many times inside one session or with
no session open at all.
The sentence costs one word to correct and it is worth naming now
because it is load-bearing somewhere else — it is the argument for
why skill_manage writes a catalog rather than a tool. An editor who
widens "two" to "three" without noticing what the two were defending
weakens that argument by accident. The consequence of the timing
mismatch is the subject of the first conflict below.
Device lifecycle events have no session to be charged to¶
events.session_id is NOT NULL and the table's only uniqueness
constraint is UNIQUE(session_id, sequence), so every event in the
log belongs to exactly one session. Device attach, detach, and scope
revocation are none of a session's business: a device attaches before
any session exists and stays attached across many.
There are three ways out and this document picks none of them — make
session_id nullable and find another sequence source, give device
lifecycle its own table and its own audit trail, or synthesize a
per-principal session to charge them to. The first touches the event
log's central invariant, the second is the smallest, and the third is
the worst, because it makes the word session mean two things. The
cost is a schema decision, and the reason to record it now is that
Section 29.7 requires revocation to be immediate and observable —
"Revoking a device immediately removes its scopes and presence
server-side" — which is an audit requirement, and the log is where
audit requirements go.
The harness reaches the same wall and did not notice it.
evaluation-harness.md declares four eval.*
events on the harness rather than on a run, under a span root that is
explicitly not agent.run, so those have no session either. Two
documents arriving here independently is an argument for the second
way out, and for taking it once rather than twice.
A hand-off is a fourth suspension kind¶
runtime-loop.md tabulates three suspension kinds —
APPROVAL, USER, and CHILD_RUN — across two run states, and
tool-system.md declares the persisted column as
suspended_kind TEXT NULL with the comment user_input | child_run,
a closed two-value vocabulary. Section 29.5's hand-off is neither: a
run that needs a capability only an offline device has "enters
WAITING_FOR_USER or returns a structured 'capability unavailable'
result rather than failing silently".
WAITING_FOR_USER is entered by conversation.ask_user and resumed
by the input endpoint carrying a question_id. A device hand-off has
no question and is resumed by a device attaching. Reusing the state
without a fourth kind would make WAITING_FOR_USER mean two things
with two different resume paths, which is the exact failure
runtime-loop.md avoided when it declined to add a
WAITING_FOR_CHILD state: it kept one state and added a typed kind.
The same move works here and costs one enum value and one row of that
table.
The other branch of 29.5's sentence needs nothing. A structured
"capability unavailable" result is tool.device_offline, which is
item 7 above.
No client is attributed on a write¶
Every application-service signature takes a Principal and nothing
about where the request came from. RunService.deliver_input takes
principal, run_id, content, and an optional question_id;
submission and cancellation are the same shape. Neither sessions
nor runs carries an origin, source, channel, or device column.
For 29.1 that is correct and deliberate — a write from any device
goes to the shared core, and the core does not need to know which
one. For 29.7 it is not, because "Treat all device-provided content
as untrusted input for prompt-injection purposes" is a claim about
content, and a trust label is applied where content enters. Tool
output is covered: that is ToolSource.DEVICE and forced trust,
items 1 and 2. Tool input — a message typed on a paired Surface by
somebody who is not the principal — is not, because it arrives
through the same endpoint as a message the principal typed
themselves.
This is where Devices and Surfaces stop being one problem, and it is picked up below.
NotificationService is a port name with nothing behind it¶
The name appears exactly twice in the corpus, in Section 29.6 and in ADR-0011, and in both places it is a list item. No document states what it delivers, over what transport, or with what durability.
The corpus does have a notification mechanism and it is not this one.
ADR-0010 makes PostgreSQL LISTEN/NOTIFY the transport for worker
wakeup and live stream delivery, and
event-log-and-persistence.md is
explicit that it is "a latency optimization and never a delivery
guarantee", with every consumer polling from a watermark. That is
the right property for one server process waking another against the
same database and the wrong one for telling a phone that an approval
is waiting. Pushing to a client that holds no open connection is a
different problem with different durability requirements, and nothing
in the corpus solves it.
Naming this costs nothing and prevents one specific mistake: reading ADR-0010 as though it covered Section 29.5's "presence / notification service". It does not.
Per-device scopes are an intersection, computed once¶
This is the finding that justifies the audit. Section 29.4 requires that "a principal may grant a device a subset of scopes (a shared desktop gets fewer than a personal laptop), enforced centrally", and it is the requirement that sounds like it needs a new evaluation path.
It does not, because of a decision
policy-and-approvals.md already made for
an unrelated reason. The scope set is captured at submission and
stamped on the run in runs.principal_scopes, and
PrincipalResolver.for_run reads that stamp and never a table. Hard
gate 13 states the property directly: the scope set is the run's.
So per-device narrowing is the intersection of the principal's scopes with the device's granted scopes, computed once at submission and stamped like any other scope set. The policy engine is never told that a device exists.
Revocation does not then fall out for free, and the temptation to say it does is the trap here. Section 29.7 requires that revoking a device "immediately removes its scopes and presence server-side", and a stamped scope set is by construction not immediate: a run already in flight would go on acting under scopes an operator has just withdrawn, for as long as that run lasts. Extending the principal-scope stamp rule to device revocation would weaken a stated security requirement, which is not a change this document is entitled to make.
One thing is therefore added. Every action taken over a device channel revalidates the device's presence and granted scopes before it proceeds, so a revoked device fails on its next action rather than at its next submission. The stamped set stays the ceiling — revalidation can only narrow it, never widen it — so the policy engine still never learns that a device exists, and the run's recorded scope set is still the one it was submitted with. What changes is that a run cannot keep using a device whose grant is gone.
One constraint travels with this and is easy to miss. The scope
vocabulary is a closed list of fifteen strings with exactly one
escape — "an entry is legal when it is one of the fifteen, or when
its first segment is mcp and its second is the server id" — and
hard gate 11 asserts it. A device. scope prefix would require that
rule to change and that gate to be rewritten. The device.
reservation that exists today is a tool-name domain, not a scope
prefix, and the two namespaces are unrelated. Per-device scopes are
therefore a subset of the fifteen and never a new namespace, which is
both the cheaper version and the only one compatible with the gate.
Surfaces are Devices with an empty capability set¶
Section 29.4's last two bullets introduce Surfaces — Telegram, Slack,
email — as "a device-like client with a presence and a capability
set, unified under one session-key resolver (DM per user, group per
participant, thread shared)". Read against the Device model, a
Surface is that model with capabilities empty and kind naming the
channel. It serves no device-scoped tool; it originates messages.
That is one model, not two, and treating it as two would produce two
presence mechanisms for one property.
Two things do not come along, and they are why the plan's sequencing table lists inbound surfaces and pairing as a row separate from the multi-device core.
- The session-key resolver has nothing to resolve against.
sessionshas one unique index, onid, and no external key. A resolver that maps a group conversation to a session needs a unique(surface, external_key)and a rule for what happens when a long-lived thread outlives anagent_version, which is frozen at session creation. This is the one genuinely new mechanism in Section 29. Everything else in it is a placement or an intersection. - Pairing is a security boundary, and it is the one part already designed. ADR-0017 default-denies an unknown sender and requires an explicit one-time code with expiry, rate limit, and lockout before any run is created on their behalf, writing the sender into a per-Surface allowlist. That needs a home and an endpoint rather than a decision.
The trust question left open by the attribution finding resolves
here rather than on the device side. A message from a paired sender
is not USER trust in the sense the trust model means it, because
the sender is not the principal — they are somebody the principal
allowlisted. Section 29.4 says device output is "EXTERNAL_UNTRUSTED
(or USER where appropriate)" and leaves appropriate undefined. The
corpus has the labels; what it lacks is the rule for choosing between
them for inbound content, and that rule belongs with pairing, because
pairing is where the corpus learns who the sender is.
Where the Device would live¶
readiness.md states the gap as a placement problem:
"The Device model itself still has no home." Placement is
answerable now without deciding a single contract, and
bootstrap-and-composition.md supplies
the rule — a port lives in the module named for the capability it
abstracts, not for the component that calls it.
Under that rule the four items of Section 29.6 do not go in one module, and one of them is not a port.
DeviceRegistryandDevicePresenceabstract stored state and a query over it. That isports/repositories.py, beside the seven repositories already there. Presence is a column on a row, not a capability of its own, and splitting it into a second module would be naming the caller rather than the capability.DeviceChannelabstracts invoking a tool on a remote executor and streaming its result back. Section 29.4 says as much — "the same pattern as an MCP transport" — and the MCP ports live inports/tools.py, so this one does too.NotificationServiceabstracts delivering a message outward to a principal, and nothing inports/abstracts that today. It needs a new module rather than a new Protocol in an existing one, and that is the structural measure of the finding above: the port with no mechanism behind it is also the port with no neighbours.- The fourth item is not a port. "Extend the tool registry and context builder to filter and route device-scoped tools by presence" is an edit to two components the corpus already owns, and the first conflict below is what that edit runs into.
The Device type itself is a domain model rather than a port and
belongs with the other domain models. Its storage is a new table and
not a column on an existing one, because a device outlives every
session and belongs to a principal rather than to a conversation.
Conflicts this document resolves¶
Three, all resolved by naming which document a later milestone edits rather than by editing anything now.
- Presence-based exposure versus the pinned prefix. Section
29.4 requires the registry and context builder to expose a
device-scoped tool "only when a qualifying device is
connected". context-engine.md pins the tool
set at session open, and tool-system.md
generalizes that into "Advertisement is pinned; availability is
resolved at call time", with
tool.device_offlineas the call-time outcome. The pin wins, and it already does: the availability table carries the device row, so a device that disconnects mid-session is handled. The unresolved half is a device that attaches mid-session, whose tools miss the pin and are invisible for the life of that session. The corpus answers that by precedent rather than by new design — an MCP server sendingtools/list_changedhas the notification "recorded and not applied", with new tools available at the next session open, and a device is the same case with the same cache-invalidation risk behind it. What tool-system.md reserved as "whether a device tool may be advertised in a session opened while the device was absent" therefore resolves to: not in that session. The cost is real and belongs in the open questions below. - Two registration sources versus three.
tool-system.md says the registry accepts
entries from exactly two sources; its own domain table registers
deviceat attach. The count is what is wrong, not the table — attach is a genuine third source and the sentence was written before it mattered. Whoever lands device registration corrects the count and preserves what the sentence was defending, which is thatskill_manageis not a source. - Queue or reject, for a second device on a busy session. Section 29.5 has a second device submitting to a busy session "follow the configured policy (queue or reject)". runtime-loop.md already resolved that against ADR-0004: the partial unique index makes queueing impossible at the database level, and 0.1 rejects. That resolution stands and Section 29.5's parenthesis is the stale half. A device is not a special case of the single-active-run rule; it is the ordinary case with a second client.
Decisions¶
- This document declares no gates. It states no requirement that code could violate, because it specifies no behaviour. It joins bootstrap-and-composition.md and development-toolchain.md as a specification whose hard-gates section is absent rather than empty, and the gate census is unchanged by it.
- Placement is decided; contracts are not. The four items of
Section 29.6 get modules, the
Devicegets a table rather than a column, and no method signature appears anywhere in this document. Section 29.8 defers the design, and a deferral does not require its subject to be homeless. - A Surface is the
Devicemodel rather than a second model.capabilitiesempty,kindnaming the channel. The two bullets Section 29.4 spends on Surfaces describe the same registry and the same presence the device bullets describe. - Per-device scopes are a subset of the fifteen. Not a
device.scope prefix, which would mean rewriting the grammar rule and hard gate 11 for no gain, and which would collide conceptually with thedevice.tool-name domain that already exists and means something else entirely. - Section 29's 0.1 obligation is already met. Both halves of 29.8 — principal-scoped reads and writes served from the core, and a second client confirmed able to attach and replay — are specified, and the second is gated at Milestone 5. Nothing in this document is scheduled before the milestone Section 29.8 defers to.
Open questions for review¶
- Is "a device that attaches mid-session is invisible until the next one" acceptable? It is what the pinned prefix implies and what the MCP precedent already chose, and it is the safe direction to be wrong in — the failure it permits is a capability a user has to open a new session to reach, and the failure it prevents is a third party invalidating a tenant's prompt cache by connecting and disconnecting. It is also more visible to a user than the MCP case ever is, because plugging in a laptop is a deliberate act with an expectation attached. If the answer is no, the exception costs a prefix rebuild mid-session and needs its own rate limit, and it should be argued for on those terms rather than adopted quietly.
- Where do device lifecycle events go?
events.session_idisNOT NULLand a device has no session. A separate table is the smallest change and the only one that leaves the event log's central invariant alone; the argument against it is that Section 29.7's revocation requirement is an audit requirement, and splitting audit across two logs is how audit gaps happen. - Should the
Devicetable exist before device-scoped routing does? Registering a device and tracking presence is useful on its own — it is what an approval notification needs, and Section 29.5's approval routing is the one cross-device flow with no deferral marked against it. Landing the registry ahead of the channel would make the area incremental rather than one milestone-sized step. - Is
NotificationServiceone port or two? Delivering to a client that holds an open connection and delivering to a device that does not are different problems with different durability requirements, and one Protocol spanning both would hide that. This is the largest genuinely open design question Section 29 leaves, and it is the reason the port has no neighbours. - What trust label does a paired sender's message carry? Section 29.4 offers "EXTERNAL_UNTRUSTED (or USER where appropriate)" and does not define appropriate. A sender allowlisted by the principal but who is not the principal is a third position, and the two labels have no name for it.
What Milestone 12 settles¶
notifications-and-devices.md lands the first
half of what this document audited, without changing the audit. The Device
table exists before the device channel does, which is the incremental order
open question 3 asked about; device lifecycle is audited as process events,
the second way out named under the lifecycle-events gap; and NotificationService
is two ports — a durable NotificationOutbox and a PushTransport — with the
live-event broadcaster left as the third, existing thing, which answers open
question 4. The third registration source, the hand-off suspension kind, and
client attribution on a write remain open for Milestone 14 and the roadmap.
This document still declares no gates; the twenty that cover Milestone 12 are
declared by the notifications document.
What Milestone 14 settles¶
inbound-surfaces.md lands the second half: the
session-key resolver gets its unique (surface, external_key) and its rotation
rule for a chat that outlives an agent_version; pairing gets its home and its
endpoint; the origin of a paired message is attributed on the write rather than
on a session or run column, as the attribution finding concluded; and the
trust question is answered for the one sender this milestone serves — the
principal pairing their own account, whose message is USER for that
principal — while a third-party sender's label stays open on the roadmap. The
third registration source and the hand-off suspension kind remain open for the
device channel. This document still declares no gates; the twenty-one that
cover Milestone 14 are declared by the surfaces document.