The device channel and SMS¶
Authorization and context¶
The owner authorized this milestone on 2026-08-26 (ADR-0081) as a parallel workstream on the ADR-0069 terms: its gates become green independently and the verified sequential ceiling still advances only in numerical order. This document designs the first concrete slice of Section 29's device channel — roadmap item B7's device channel and device-scoped tools — with SMS through the owner's iPhone as the concrete use case B7 was waiting for. Presence-based routing and hand-off remain on the roadmap.
Milestone 12 delivered everything this design stands on: the device
registry with client-minted identity, the APNs transport, the notification
outbox, and the least-privilege notify role
(notifications-and-devices.md). The
Section 29 seams are already cut: ToolSource.DEVICE is declared, the
device. tool-name domain is reserved, device output is forced
EXTERNAL_UNTRUSTED at registration, ExecutionTarget.kind = "device" is
a policy-input field, and tool.device_offline is a declared availability
outcome (multi-device-and-surfaces.md).
iOS provides no SMS API. An app cannot read or silently send a text. Sending rides the system compose sheet, where the owner's tap performs the send; reading rides a Shortcuts personal automation forwarding into an App Intent. The design is honest about both: sends are owner-confirmed by construction, and ingest is best-effort glue the platform can silently disable.
The DeviceChannel port¶
DeviceChannel lands in ports/tools.py, the placement
multi-device-and-surfaces.md decided:
invoke a device-scoped tool on a specific device and return its result.
Version one returns a single result; streaming is a later widening of the
same port, not a second port.
One adapter implements it: push-wake with poll-back.
- The worker writes a pending row to
device_invocationsin the same transaction that records the tool call, idempotent by invocation id. - A content-free APNs push wakes the device. This is a sixth entry in Milestone 12's trigger catalog, an explicit widening ADR-0081 records; the payload carries the invocation id and nothing else.
- The client fetches pending invocations over the authenticated API and
posts back exactly one result per invocation:
sent | cancelled | failed | expired.
The wait is bounded by invocation_timeout (default 300 seconds). An
unreachable or silent device yields tool.device_offline, and the run
reports it rather than hanging. A waiting-on-device suspension kind — the
hand-off residue of Section 29 — is the designed alternative and is not
built here.
Every device-channel action revalidates the device's presence and its granted scopes before it proceeds, so a revoked device fails on its next action rather than at its next submission.
device.sms.send¶
The first device-scoped tool. Registration uses the third source the seam
audit anticipated: the client's device registration declares
capabilities: ["device.sms.send"] on the Device.capabilities field
Section 29.6 defines and Milestone 12 deferred; this milestone lands it.
The registry exposes the tool with ToolSource.DEVICE while
the declaring device is registered and unrevoked. Dynamic device-tool
registrations are keyed by tenant and owning principal so another principal in
the tenant cannot advertise or resolve the device's tool. The
tool-system.md sentence closing registration at two
sources gains the amendment ADR-0081 records.
Execution: the invocation names a recipient and a body; the push wakes the phone; the owner taps the notification; the app presents a prefilled compose sheet; the owner's Send tap performs the send; the app posts the result. The platform enforces the tap — no code path sends without it.
Policy classifies the tool ALLOW through the profile's tool-name-keyed
entry (policy-and-approvals.md), which replaces
the EXTERNAL_MESSAGE row for this one tool and leaves it intact for every
other. The compose-sheet tap is a
non-bypassable human confirmation, stronger than an in-app approval, so a
second approval would be duplicate ceremony rather than added safety;
ADR-0081 records the argument. A turn whose origin is untrusted still
raises the send to an approval, so an ingested message cannot drive one.
Hardline rules still apply to the
arguments: the outbound body passes the secret-exfiltration scan before
any invocation row is written, so a text carrying a credential is blocked
before it reaches the phone. The tool's result is EXTERNAL_UNTRUSTED
like every device result.
SMS ingest¶
Capture requires iOS 17: a Shortcuts personal automation — "When I get a
message", run immediately — invokes the app's App Intent with sender and
body. The intent shares the app's Keychain access group, so no credential
ever lives in Shortcuts; it posts {channel, sender, body, received_at}
to the device-authenticated ingest route.
The server appends the message as device-originated untrusted content
(actor_type = device, origin recorded), idempotent by the SHA-256 digest
of (sender, body, received_at), rate-capped per device by
ingest_daily_cap (default 500) using the server acceptance day's UTC bucket,
never the device-supplied timestamp. Bodies never appear in process logs, and
the existing export redaction covers the content events.
Routing: one standing triage session per (device_id, channel), created
lazily; each ingested message continues the triage run, or seeds a new one
when the session is idle, with a system-framed instruction naming the
content as untrusted third-party data. The standing session is found
through a unique (device_id, channel) mapping row that pins the live
triage session id; the row is created lazily with the session and
replaced when the session rotates. The four owner-selected behaviors
ride existing machinery: alerting asks the owner through
run.waiting_for_user, which already pushes; remembering is Milestone
10's automatic formation over the run; acting uses existing tools under
existing policy and approvals; a draft reply goes out through
device.sms.send, where the owner's tap approves it. Nothing in a message
can widen scopes, pair anything, or bypass policy.
The iOS client and the owner ceremony¶
The client gains: a default-off "SMS integration" setting that adds the capability to device registration; the notification category and compose-sheet flow; the App Intent; pending-invocation fetch and result post. The Shortcuts automation setup is a documented owner ceremony with a verification step (send a test message end to end), on the Milestone 18 bootstrap-ceremony precedent, and its fragility is stated: iOS can silently disable the automation, and ingest is best-effort. The ceremony itself is documented in deployment.md, "The SMS capture ceremony," on the same operational-runbook footing as the macOS TestFlight delivery ceremony.
Persistence¶
Two content tables and the triage mapping row, all carrying the tenant RLS policy:
device_invocations
id UUID PRIMARY KEY
tenant_id UUID NOT NULL
device_id UUID NOT NULL REFERENCES devices(id)
run_id UUID NOT NULL REFERENCES runs(id)
tool_name TEXT NOT NULL
arguments JSONB NOT NULL
status TEXT NOT NULL -- pending | sent | cancelled | failed | expired
created_at TIMESTAMPTZ NOT NULL
resolved_at TIMESTAMPTZ NULL
device_ingest_receipts
device_id UUID NOT NULL REFERENCES devices(id)
tenant_id UUID NOT NULL
channel TEXT NOT NULL
digest TEXT NOT NULL
received_at TIMESTAMPTZ NOT NULL
accepted_at TIMESTAMPTZ NOT NULL
session_id UUID NULL REFERENCES sessions(id) ON DELETE SET NULL
run_id UUID NULL REFERENCES runs(id) ON DELETE SET NULL
PRIMARY KEY (device_id, channel, digest)
device_triage_sessions
device_id UUID NOT NULL REFERENCES devices(id)
channel TEXT NOT NULL
tenant_id UUID NOT NULL
session_id UUID NOT NULL REFERENCES sessions(id)
PRIMARY KEY (device_id, channel)
Configuration and flags¶
Two flags, default off, changing together at release validation:
AGENT_DEVICE_CHANNEL_ENABLED (the port, the routes, the invocation
push) and AGENT_DEVICE_SMS_ENABLED (the device.sms.send capability
and the ingest route). With either unset, no route mounts, no
capability-derived tool registers, and no push is sent. The two flags are
paired at load: a deployment that sets exactly one is refused, because a
channel without its capability and a capability without its channel are both
half-built. Limits block: invocation_timeout_seconds (300),
ingest_daily_cap (500), and invocation_poll_seconds (2), the interval the
push-wake adapter waits between reads of the row it is blocked on.
The routes take the existing device.read and device.write scopes; the
scope vocabulary does not grow.
Exclusions¶
No automatic replies from the owner's number; no silent send path; no websocket transport; no waiting-on-device suspension kind; no presence-based routing or hand-off (roadmap B7 residue); no Android capture; no iMessage/SMS distinction at capture. Each exclusion stays on the roadmap or in this document's open questions.
Build sequence¶
- Port, domain values, and the two migrations, with the contract suite. M24.
- The push-wake adapter against a fake APNs and a fake device client. M24.
- Capability-derived registration and the policy classification. M24.
- The ingest route, digest idempotency, and the triage session seeding. M24.
- The iOS client work and the App Intent. M24.
- The owner ceremony document and the end-to-end verification. M24.
Hard gates¶
- Capability-derived registration. A device tool is registered with
ToolSource.DEVICEexactly while a registered, unrevoked device declares its capability; revocation or deletion removes it. M24. - Invocation idempotency. Replaying an invocation id creates one
device_invocationsrow and at most one push. M24. - Foreign device denied. A device other than the invocation's target — or a revoked target — can neither fetch nor answer it. M24.
- No send without a device result. No server code path moves an
invocation to
sent; only a device-posted result does. M24. - Offline outcome. An invocation unanswered at
invocation_timeoutresolvesexpiredand surfacestool.device_offline. M24. - Untrusted device output. Every device tool result carries
EXTERNAL_UNTRUSTED, forced at registration. M24. - Presence revalidated. Every device-channel action revalidates presence and granted scopes; a revoked device fails on its next action. M24.
- Outbound secret scan. A
device.sms.sendbody matching a hardline secret pattern is refused before any invocation row is written. M24. - Ingest idempotency. A replayed
(sender, body, received_at)digest stores one receipt and seeds one run. M24. - Untrusted triage routing. An ingested message enters the standing triage session as device-originated untrusted content and cannot resolve a consequential action to a plain allow. Under ADR-0090 it may form only an SMS-attributed, tentative, local, sensitive memory and can never be treated as owner speech or retract owner memory. M24.
- No body in logs. Message bodies appear in no process log line and no event besides the designated content event. M24.
- Default off. With either flag unset, no device-channel route mounts, no capability-derived tool registers, and no invocation push is sent. M24.
Open questions¶
- Whether the Shortcuts "When I get a message" automation passes sender and body to an App Intent on the owner's iOS version — verified during the ceremony, before build step 5 is called done.
- Whether the App Intent reads the shared Keychain group with the app backgrounded — same verification.
- Whether a waiting-on-device suspension kind should replace the bounded wait once real usage shows expiry rates; that is a later ADR.