plan: "0038" title: "iOS embedded kcp: the AT-IRC iOS app runs kcp and the socialweb requester in process, signs in with OAuth, and launches leased atirc agents through the market request flow" status: doing source: > 2026-10-30 atirc, owner: "see if you can get a build of kcp running in the ios app, we got it running in wasm so this should be possible", then the full shell.socialweb.computer scope set and an Agents section that launches agents "via the same market request flow ... via kcp the other agents are working on". Lead and plan owner: linux-kcp. App owner: atirc-ios-client-dev (its own milestone plan M1-M5: AT-IRC docs/plans/0001-ios-embedded-kcp.md in atirc-client-ios; bridge AtircgoKCPStart, AtircgoKCPRequest, AtircgoKCPSnapshot, AtircgoKCPReady, AtircgoKCPStatus, AtircgoKCPStop in package github.com/kcp-dev/kcp/contrib/wasm/ios/atircgo). Atirc server: atirc-dev. Port advice: wasm (the page is the model). principle: > The iOS app is one more requester host, like the page: it composes the same MarketRequester root and runs the same Requester component in process. No iOS-only market protocol, no iOS-only lexicon, no socket listener in the app; the app reaches the market only through the requester's outbound XRPC and the firehose (comms mode Firehose, as the page). The selected policy mode runs for every decision. decisions: runtime: > One Go runtime per process: AtircGo.xcframework holds the app's Go library and the kcp bridge (two gomobile frameworks crashed with two Go runtimes). Build GOOS=ios GOARCH=arm64 CGO_ENABLED=1 with -trimpath -ldflags "-s -w"; GOGC 25 and a GOMEMLIMIT; the apiserver watch cache off. storage: > kcp runs on the in-memory storage of kcp-wasm contrib/wasm/storage (no etcd), persisted to the app container through its snapshot/restore and persist hooks; the restore check is the page's (exact seqs both ways, key and owner). oauth: > The app has its own client metadata. Its scope is exactly the requester scope generated by socialweb cmd/requester-scope at the socialweb rev the app is built at, plus rpc:chat.atirc.revoke?aud=did:web:irc.fedfork.com%23atirc. Granted scopes are read from the token response, never from the access token's opaque scope claim. Simulator sign-ins are scripted with the alice credential file sourced into the one command that signs in; no sign-in URL is ever posted. agents: > The Agents section follows the page: one EphemeralInfra per app session, one ComputeContract entry per launch with spec.agent (and guestTools for R2), the requester mints a leased atirc token after the verified receipt, and Release stamps releaseRequestedAt. A release asked for before the app is suspended finishes on the next launch from the persisted state; the app asks iOS for background time to finish it first. policy: > The requester runs requester-tangled-vouch by default, as on every host. iOS has no deno: the policy sandbox needs an iOS provider on PolicyEngine spec.sandbox (the plan 0037 pattern: a contract runner with a Prober, Fenced only after the probe passes); until it exists the requester fails closed and Request VM shows why. policy_sandbox_api: > PolicySandboxConfig.Provider gains "embedded" with Embedded *PolicyEmbeddedSandboxConfig {TimeoutSeconds default 120, minimum 1}; the root PolicyEngineSandboxSettings gain the same value and Embedded {TimeoutSeconds}. "embedded" names what socialweb sees: a JavaScript sandbox the app that embeds the requester provides through pkg/embedded. The JS engine is the app's choice and the probe proves it; the API does not name it. On ios pkg/embedded always selects provider embedded for the requester's PolicyEngine; other platforms keep deno unless the app passes a host. policy_sandbox_host: > pkg/embedded.PolicySandbox (alias of internal/sandbox/embedded.Host), set on Config.PolicySandbox, gomobile types only: Run(requestJSON []byte) ([]byte, error); Kill(runID string); PlantProbe(marker string) ([]byte, error); ClearProbe(marker string) error. gomobile binds one package, so the app's bridge package (contrib/wasm/ios/atircgo) declares the same method set and passes its value to Config.PolicySandbox. Go calls every method from a Go thread and Run blocks until the program ends: the app must never block its main thread on it. Go runs one program at a time; Kill may arrive while Run is blocked and must make that Run return. Run request JSON is {id, source, env, timeoutMs}, the answer {exitCode, stdout, stderr, timedOut}, the page bridge's shapes. Run returns an error only when the host could not run the program; a failing program is an answer with a nonzero exitCode. The host ends a run at timeoutMs with timedOut true and exitCode -1; Go waits 5 s more, then calls Kill and reports timedOut itself. stdout and stderr are capped at 64 KiB each (Go caps again). policy_sandbox_program: > source is one ES module with deno semantics, as the page bootstrap of scripts/browser/ policy_sandbox.js (kcp-wasm) gives it: static imports of node:buffer, node:crypto and node:dns/promises resolve to that file's shims, top-level await, and the module's end exits 0. Globals: the dedicated Worker set (fetch, AbortController, setTimeout, TextEncoder, TextDecoder, atob, URL, crypto.getRandomValues and crypto.randomUUID), console.log/info/debug to stdout and warn/error/trace to stderr, and Deno {env from the request env, exit(code), stdout.writeSync, stderr.writeSync, inspect, errors.NotCapable, readTextFile(Sync) and writeTextFile(Sync) that reject with NotCapable}. An uncaught error or rejection writes "error: ..." to stderr and exits 1. Every run gets a fresh global scope and storage: nothing a run leaves behind is visible to the next. The program source is never exposed as a global. policy_sandbox_fence: > The fence denies policy code the app's authority: the Keychain, the app container files (and every file:// URL), UserDefaults, the app's HTTP cookies and credential storage, the Go runtime and the kcp it runs (no native bridge object of any name in the JS global scope), any DOM, and state across runs. Network is allowed, as --allow-net on Linux: fetch of https with no cookies or stored credentials (an ephemeral session). Recommended host: a WKWebView with WKWebsiteDataStore.nonPersistent() that runs the policy_sandbox.js frame unchanged (opaque-origin srcdoc iframe, one module Worker per run, Worker.terminate at the deadline), driven by callAsyncJavaScript in a private WKContentWorld, with no WKScriptMessageHandler or WKURLSchemeHandler in the page world; the page sandbox already passed plan 0037 W0 on iPhone Safari. A bare JavaScriptCore JSContext must add fetch, timers, modules and the Worker globals itself and has no public API to stop an endless loop, so it fails the timeout fact unless every run gets its own killable context. policy_sandbox_probe: > The Go Prober decides; the app only plants. Go makes a random marker and calls PlantProbe(marker); the app stores the marker as a Keychain generic password (service socialweb-fence-probe), a UserDefaults key socialweb-fence-probe, an HTTPCookieStorage.shared cookie socialweb-fence-probe (domain socialweb-fence-probe.invalid) and a file socialweb-fence-probe.js in its container whose exact content is: export default ""; and answers {engine, fileURL (the file:// URL of that file), planted {keychain, file, defaults, cookies: "yes" or why not}}. Go then runs four programs through Run: (1) the fence program, which must see Deno.env.get("SOCIALWEB_FENCE_ENV") equal to the env it was given, import node:buffer, have fetch, see no document, window, parent, top, localStorage or sessionStorage, see none of webkit, require, process, Bun, __socialwebSandbox, AtircGo, Atircgo, Go; fail to read the planted file by fetch, XMLHttpRequest and import(), and file:/// by fetch; find the marker in no string reachable from globalThis (own properties, depth 4); and stash a second token in globalThis, IndexedDB and Cache Storage where they exist; (2) for (;;) {} with timeoutMs 1000, which the host must end itself with timedOut true; (3) a rerun that must see none of the stash, print to stdout and stderr and exit 3 through Deno.exit; (4) a top-level throw that must exit 1. Then ClearProbe(marker) removes all four plants. Fenced=True only when every plant is "yes" and every fact holds; the facts are the Fenced condition message. A plant, run or answer error is Fenced=False, Ready=False, Deny. state_rules: - "No contract is created while the requester policy cannot run; Fenced=False means Deny." - "The app never holds a long atirc token for agents; agent tokens are leased and die with the contract." - "The account-level OAuth session is the owner identity for chat.atirc.answer and chat.atirc.owner_mode." items: - id: I1-BOOT title: "kcp boots in process in the iOS Simulator (memory storage, net.Pipe, no sockets); status bar shows kcp ready" owner: atirc-ios-client-dev status: done - id: I2-RUNTIME title: "One Go runtime: AtircGo.xcframework (app golib + kcp bridge); memory settings (GOGC, GOMEMLIMIT, watch cache off) measured on a device" owner: atirc-ios-client-dev status: done - id: I3-PERSIST title: "Persistence across app restarts: snapshot/restore and persist hooks into the app container; cold boot vs resume times measured" owner: atirc-ios-client-dev status: done - id: I4-SOCIALWEB-IOS title: "socialweb builds for ios/arm64: the MarketRequester root and Requester component compose in the app's kcp (comms Firehose, no ingress), with ios build tags for host-only packages; build gate covers ios/arm64" owner: linux-kcp status: done - id: I5-OAUTH title: "OAuth sign-in in the app with the requester scope plus chat.atirc.revoke (28); client metadata generated from requester-scope.txt; MarketIdentity Ready in the embedded kcp" owner: atirc-ios-client-dev status: done - id: I6-POLICY-SANDBOX title: "iOS policy sandbox provider (JavaScriptCore runner + Prober, Fenced condition) so requester-tangled-vouch runs in the app; decision recorded as an ADR" owner: linux-kcp status: doing - id: I7-AGENTS title: "Agents section: launch an agent VM through the market request flow (EphemeralInfra, ComputeContract with spec.agent), leased token row, Release, revoke; release finished in background time or on next launch" owner: atirc-ios-client-dev status: doing - id: I8-ASK-OWNER title: "Pending owner questions list, answer sheet and local notification on a new ask (chat.atirc.ask/questions/answer/decide/owner_mode)" owner: atirc-ios-client-dev status: done - id: I9-E2E title: "End to end on a device: sign in, launch an agent, agent joins the owner channel on its leased token, release, token dead by revoke and by lease; hosts clean" owner: linux-kcp status: todo