@botiverse/porthole 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1774 @@
1
+ # Decision log
2
+
3
+ Append-only. Each entry records the decision, why, what we rejected, and **when to
4
+ revisit**. To change a decision, add a new entry that supersedes the old one and
5
+ mark the old one `Superseded by Pxxx`. The "revisit when" line is the most
6
+ important part.
7
+
8
+ Format: `Pxxx · YYYY-MM-DD · Title · Status`
9
+
10
+ ---
11
+
12
+ ## P001 · 2026-10-11 · Two layers: a framework of mechanisms, and a preset policy layer · Accepted
13
+
14
+ **Decision.** Split the library into a framework layer (isolation, egress
15
+ hardening, shell runtime, streaming, sizing, theming, the host-function bridge,
16
+ the guide generator — mechanisms only, no policy) and a preset policy layer
17
+ (inline-widget, artifact, mcp-app — named configs with safe defaults). A host
18
+ adopts a preset, passes theme tokens and callbacks, and gets a safe display plus
19
+ the agent manual.
20
+ **Why.** Policy (which origins, which confirmations, what theme) is
21
+ product-specific and changes faster than mechanism. Separating them lets the
22
+ mechanisms be audited once and reused, while hosts get "out of the box" from the
23
+ presets without touching internals.
24
+ **Rejected.** One monolithic widget component (couples mechanism to one policy);
25
+ mechanisms with no presets (every host re-derives a safe config and most get it
26
+ wrong).
27
+ **Revisit when** a host needs a mode no preset covers, or the preset set grows
28
+ enough to need its own structure.
29
+
30
+ ## P002 · 2026-10-11 · The core abstraction is agent-drawn HTML plus declared host functions · Accepted
31
+
32
+ **Decision.** The content may draw freely; every power beyond drawing is a host
33
+ function (capability) named in the config, each declaring its gate, its egress
34
+ needs, and its guide entry. Sending a prompt to the agent is one such capability,
35
+ not a privileged built-in.
36
+ **Why.** One capability list then drives the bridge, the CSP and the manual
37
+ together (P003), and "what can this content do?" is answered by reading the
38
+ config. Treating agent-interaction as ordinary keeps the model uniform and makes
39
+ hosts that don't want it simply omit it.
40
+ **Rejected.** A fixed built-in set of powers (inextensible, and couples the core
41
+ to one product's needs); ambient powers granted implicitly (unauditable).
42
+ **Revisit when** a capability needs structure the flat list can't express (e.g.
43
+ capabilities that compose).
44
+
45
+ ## P003 · 2026-10-11 · One config is the single source for the CSP, the shell, and the manual · Accepted
46
+
47
+ **Decision.** The CSP, the iframe sandbox/`allow` attributes, the in-frame shell
48
+ wiring, and the agent guide + tool description are all generated from one config
49
+ by a pure compiler. A test asserts the three outputs agree (the guide names only
50
+ injected variables and allowed capabilities; the CSP allows only what the manual
51
+ teaches).
52
+ **Why.** These artifacts must agree to be safe and usable; drift between them is
53
+ the most common defect in hand-built sandboxes, and a manual that promises what
54
+ the sandbox blocks wastes the agent's turns.
55
+ **Rejected.** A hand-maintained guide; a literal CSP string no test ties to the
56
+ capabilities.
57
+ **Revisit when** a host needs to hand-tune one artifact — then the override must
58
+ flow back through the config, never edited in place.
59
+
60
+ ## P004 · 2026-10-11 · The external contract is MCP Apps; the host side is protocol-pluggable · Accepted
61
+
62
+ **Decision.** Agent-written HTML speaks MCP Apps (`io.modelcontextprotocol/ui`,
63
+ 2026-01-26). We do not define a widget-side protocol; gaps go through the spec's
64
+ extension points (experimental capabilities, own-prefixed methods) and, when
65
+ general, upstream. Internally the host side is protocol-pluggable: a thin adapter
66
+ maps capabilities/lifecycle onto a wire protocol, with MCP Apps the first and
67
+ default adapter.
68
+ **Why.** Standard agent-UI is only valuable if one widget runs in many hosts and
69
+ one host renders many widgets; a bespoke protocol forfeits that. A pluggable
70
+ inside keeps the core host-agnostic and lets a second protocol be an adapter, not
71
+ a fork.
72
+ **Rejected.** A new wire format; coupling the core to one protocol SDK's object
73
+ model.
74
+ **Revisit when** the spec changes incompatibly, or a second protocol is needed.
75
+
76
+ ## P005 · 2026-10-11 · Phase-1 isolation is an opaque origin; domain mode is designed, built later · Superseded in part by P016 (domain mode is phase 1)
77
+
78
+ **Decision.** Phase 1 isolates content in an opaque-origin frame (sandboxed
79
+ without `allow-same-origin`): a different origin from the host with no persistent
80
+ storage. The domain mode (a per-instance subdomain on a separate registrable
81
+ domain, giving a real, stable origin with storage) is designed in phase 1 and
82
+ built in phase 2.
83
+ **Why.** An opaque origin is the cheapest isolation that fully solves the
84
+ host-attack class and needs no server, domain or certificate — so phase 1 ships
85
+ without infrastructure. Stable storage and a real origin require serving
86
+ infrastructure that is phase-2 work.
87
+ **Rejected.** Shipping domain mode in phase 1 (pulls server/DNS/cert work into
88
+ the first release); same-origin isolation with a strict CSP (a CSP mistake then
89
+ becomes host takeover — isolation must be the floor, not the CSP).
90
+ **Revisit when** a host needs persistent per-instance state or a stable origin
91
+ (then build domain mode).
92
+
93
+ ## P006 · 2026-10-11 · Egress is closed by default, including channels a frame CSP doesn't cover · Accepted
94
+
95
+ **Decision.** All outbound network and navigation is closed by default and opened
96
+ only to origins a capability declares. Beyond `connect-src`/resource directives,
97
+ the shell closes channels a frame's own CSP cannot: it removes the WebRTC
98
+ constructors (a peer connection reaches the network via ICE outside
99
+ `connect-src`), removes nested frames the content adds — including inside shadow
100
+ roots and after a content-initiated document rewrite (a nested frame runs in a
101
+ fresh realm where the removals never happened) — and the embedding page sets
102
+ `frame-src` to stop the frame navigating itself out with data in the URL. Each
103
+ item has a leak-suite probe (P008).
104
+ **Why.** Origin isolation stops the host-attack class but not exfiltration of
105
+ what the content already holds; egress hardening does. These three channels sit
106
+ outside a frame's own CSP, so they need in-shell defenses and the embedder's
107
+ policy, verified by measurement because engines differ.
108
+ **Rejected.** Trusting `connect-src` alone (misses WebRTC, nested-frame realms,
109
+ self-navigation); trusting one CSP directive honored unevenly across engines.
110
+ **Revisit when** an engine changes how it honors `webrtc`/`frame-src`, or a new
111
+ egress channel is found (add the probe first).
112
+
113
+ ## P007 · 2026-10-11 · The MCP adapter reimplements the host-side subset; the widget uses the official SDK · Accepted; amended by P014 (no AppBridge adapter) and P018 (upstream timing)
114
+
115
+ **Decision.** The MCP Apps adapter reimplements the thin host-side subset of the
116
+ protocol that maps to capabilities, rather than embedding the official
117
+ `AppBridge`. On the widget side we reimplement nothing — authors use the official
118
+ `App` SDK. An AppBridge-backed adapter is offered as an optional, separate entry.
119
+ We contribute upstream what is general: the conformance/leak runner, the
120
+ container-agnostic framing note, and the frame-escape findings.
121
+ **Why.** `AppBridge` extends the MCP SDK's `Protocol` class and pulls in its
122
+ client/core packages; embedding it would tie the host-agnostic core to that
123
+ dependency tree and class hierarchy (against P004/P007-core-agnostic) for a
124
+ subset we can state and test directly against the reference examples. The widget
125
+ side is where speaking the standard matters, so there we use the real SDK.
126
+ **Rejected.** Embedding AppBridge in the core (heavy coupling); reimplementing the
127
+ widget side (defeats the point of the standard).
128
+ **Revisit when** the host-side subset grows large or the SDK sheds its coupling
129
+ (then prefer wrapping), or a host standardized on the MCP SDK needs the
130
+ AppBridge adapter sooner.
131
+
132
+ ## P008 · 2026-10-11 · A conformance/leak suite is first-class, cross-engine, and host-runnable · Accepted; engine order amended by P017
133
+
134
+ **Decision.** A standalone Playwright suite drives a platform host through an
135
+ adapter via a small host test interface, attempts every egress and escape
136
+ channel, and asserts nothing leaks — on Chromium, WebKit and Firefox. It also
137
+ runs the MCP Apps examples and renders the generated guide's examples. Any host
138
+ can run it against its own integration. A hardening item exists only if a test
139
+ proves it (PRINCIPLE 5), and isolation facts are decided by measurement, not
140
+ assumption (PRINCIPLE 6).
141
+ **Why.** Browser isolation behavior changes across versions and engines; only an
142
+ executed probe shows the wall is there and keeps it there. Making the suite
143
+ host-runnable turns it into the proof hosts can trust and the artifact we offer
144
+ upstream.
145
+ **Rejected.** Unit-testing CSP strings only (doesn't prove the browser enforces
146
+ them); a single-engine suite (misses engine-specific gaps).
147
+ **Revisit when** a target engine is dropped or added, or an upstream conformance
148
+ interface stabilizes (align to it).
149
+
150
+ ## P009 · 2026-10-11 · Web first: a framework-agnostic core plus a thin React binding · Accepted
151
+
152
+ **Decision.** Ship a framework-agnostic TypeScript core and a separate thin React
153
+ binding first. Native hosts (WKWebView, maybe Android) come later behind the same
154
+ platform-host interface. Tooling: Node ≥ 24 with type stripping (erasable syntax
155
+ only), pnpm workspace, oxlint + oxfmt, vitest or node:test, Playwright.
156
+ **Why.** The web is where the first host lives, and a framework-agnostic core
157
+ keeps the library usable by non-React hosts and portable to native engines (the
158
+ shell is plain dependency-free script). The toolchain matches the wider workspace
159
+ so agents and contributors share one setup.
160
+ **Rejected.** React-only (excludes other hosts and native); a bundler-heavy build
161
+ (type stripping needs none).
162
+ **Revisit when** a native host is built, or a second UI framework needs a binding.
163
+
164
+ ## P010 · 2026-10-11 · Apache-2.0, private for now · Accepted
165
+
166
+ **Decision.** License Apache-2.0, copyright Botiverse. The repository stays
167
+ private during phase 1.
168
+ **Why.** Apache-2.0 is permissive with an explicit patent grant, standard for a
169
+ library meant for broad embedding. Staying private during design keeps the shape
170
+ settling before it is public.
171
+ **Rejected.** MIT (no patent grant); public from day one (premature while the API
172
+ is unsettled).
173
+ **Revisit when** phase 1 is done and the owner decides to open it.
174
+
175
+ ## P011 · 2026-10-11 · The first host is a normal user of the public API · Accepted
176
+
177
+ **Decision.** The first adopter migrates onto Porthole through the public API
178
+ with no privileged path; anything it needs goes into the config or a documented
179
+ extension point any host could use. The first host's existing shell, theme map,
180
+ embedding-page CSP, frame component and guide seed the framework layer, then are
181
+ consumed from the packages.
182
+ **Why.** A library shaped around one caller's internals is not reusable and leaks
183
+ that caller's assumptions into the core. Dogfooding by a real host is the test
184
+ that the API is general (OBJECTIVE 4).
185
+ **Rejected.** Private hooks for the first host; keeping the extraction in the
186
+ host's own repo (then it is not a library).
187
+ **Revisit when** a second host adopts it (confirm nothing was first-host-specific).
188
+
189
+ ## P012 · 2026-10-11 · Three modes: inline widget, MCP Apps view, artifact page; URL mode out of scope · Superseded in part by P020 (URL mode is phase 1)
190
+
191
+ **Decision.** Support inline widget, MCP Apps view, and artifact page (a
192
+ host-control spectrum from host-injected code to a host-served document). URL mode
193
+ (embedding a running app by URL, with only origin isolation and access auth, no
194
+ shell, no egress control) is out of scope for now.
195
+ **Why.** The three modes cover showing agent-made HTML the host or an MCP server
196
+ produced, which is the stated subject. URL mode is a different problem (isolating
197
+ a third party's live site) with little shared mechanism beyond origin isolation,
198
+ and no host needs it yet.
199
+ **Rejected.** Adding URL mode in phase 1 (scope creep for a mode nobody needs);
200
+ dropping the artifact page (it is the standalone-page half of the stated goal).
201
+ **Revisit when** a host needs to embed a live external app, or the artifact and
202
+ URL modes turn out to share enough to justify both.
203
+
204
+ ## P013 · 2026-10-11 · One package, `@botiverse/porthole`, with subpath exports · Accepted
205
+
206
+ **Decision.** Porthole ships as one package: the core at `@botiverse/porthole`, and
207
+ `/react`, `/mcp`, `/conformance` and `/server` as subpath exports of it. Optional
208
+ peers (`react`, `@playwright/test`) are needed only by the subpaths that use them.
209
+ The workspace exports the TypeScript sources directly; `pnpm build` emits JS and
210
+ declarations to `dist/`, which a packed or published package exports instead
211
+ (`publishConfig.exports`), because Node does not strip types under `node_modules`.
212
+ **Why.** The shell runtime, the compiler and the adapters must move in lockstep (a
213
+ shell from one version and a host from another is an untested pair); one version
214
+ makes that impossible to get wrong. The seams are module boundaries, which keep the
215
+ core free of React, Playwright and server code without separate packages.
216
+ **Rejected.** Six packages (`-react`, `-mcp`, `-components`, `-conformance`,
217
+ `-server`): version skew between core and shell, more release work, no isolation
218
+ gain.
219
+ **Revisit when** a subpath's dependencies or release cadence burden hosts that don't
220
+ use it.
221
+
222
+ ## P014 · 2026-10-11 · No AppBridge-backed adapter for now · Accepted
223
+
224
+ **Decision.** Porthole ships only its own MCP Apps adapter (P007); the optional
225
+ AppBridge-backed adapter P007 offered is not built.
226
+ **Why.** No host has asked for it, and every adapter is surface the leak suite must
227
+ cover.
228
+ **Rejected.** Building it speculatively in phase 1.
229
+ **Revisit when** a host standardized on the MCP SDK asks for it.
230
+
231
+ ## P015 · 2026-10-11 · Content is plain HTML/SVG plus the generated manual; no component vocabulary · Accepted
232
+
233
+ **Decision.** What an agent writes for Porthole is plain HTML and SVG, taught by the
234
+ generated manual. A consistent look comes from the injected theme variables and, at
235
+ most, plain CSS the shell ships (such as base styles for native form elements),
236
+ never from a component vocabulary. The "preset components" of the first design (a
237
+ stat tile, chart primitive, table and elicitation form as custom elements) are
238
+ dropped; the config has no `components` field.
239
+ **Why.** The owner: no DSL-like things for now, and a component vocabulary is one:
240
+ the agent must learn tag names and props, Porthole must version them, and content
241
+ stops being portable HTML. Theme variables already give content the host's look in
242
+ light and dark.
243
+ **Rejected.** Preset web components inside the sandbox; a host-extensible
244
+ component list.
245
+ **Revisit when** agents repeatedly hand-roll the same structure badly in a way base
246
+ styles and the manual can't fix.
247
+
248
+ ## P016 · 2026-10-11 · Domain mode is phase 1 · Accepted
249
+
250
+ **Decision.** Supersedes P005's timing. Domain mode is built in phase 1: content is
251
+ served on a registrable domain the host uses for nothing else, one subdomain per
252
+ storage scope (the label keyed from the host's scope with a secret: stable, DNS-safe,
253
+ not guessable), so each scope has a real, stable origin and its storage persists.
254
+ Documents go out with their CSP, plus `frame-ancestors` naming the host origins, in
255
+ HTTP headers. Private content is reached with a one-time ticket the host's backend
256
+ mints (`/server`'s `issue`) and the content origin exchanges for a cookie of that
257
+ origin only. `/server` is a fetch-style request handler a host's backend mounts.
258
+ The served frame's sandbox adds `allow-same-origin`.
259
+ **Threat it re-opens.** `allow-scripts allow-same-origin` lets a frame act with its
260
+ own origin's full powers; if that origin were same-site with the host, it could
261
+ reach cookies and storage the host shares with its site. Bounded by: the content
262
+ domain is a separate registrable domain; a mount refuses a content origin that is
263
+ same-site with the host; and the leak suite's item 9 (no reach into the host) must
264
+ pass in domain mode, shipped with the domain-mode work.
265
+ **Why.** Hosts need stable storage and a real origin for some content (and the MCP
266
+ Apps sandbox proxy needs a second origin anyway); building it now keeps the served
267
+ modes from being designed in the abstract.
268
+ **Rejected.** Holding it to phase 2 (P005).
269
+ **Revisit when** browsers' storage partitioning or third-party cookie rules change
270
+ how a framed origin keeps state.
271
+
272
+ ## P017 · 2026-10-11 · Leak-suite gate: Chromium and WebKit first, Firefox right after · Accepted; completed by P035
273
+
274
+ **Decision.** Amends P008's engine order. A hardening milestone is done when its
275
+ probes pass on Chromium and WebKit; Firefox joins the gate as the next milestone,
276
+ not later in the phase. The suite selects engines with `PORTHOLE_LEAK_ENGINES`;
277
+ where an engine's system libraries are missing (WebKit on the shared build machine),
278
+ it runs on a machine that has them.
279
+ **Why.** Chromium and WebKit cover the first host's web and iOS users and differ
280
+ most on the directives that matter (`webrtc`, `frame-src` for `srcdoc`); Firefox
281
+ follows quickly so no engine's gaps go unmeasured for long (PRINCIPLE 6).
282
+ **Rejected.** Waiting for all three before any milestone lands; dropping Firefox
283
+ from phase 1.
284
+ **Revisit when** an engine is added or dropped.
285
+
286
+ ## P018 · 2026-10-11 · Upstream contributions once code and tests exist · Accepted
287
+
288
+ **Decision.** Issues and proposals to the MCP Apps spec and SDK (the conformance
289
+ runner, the framing note, the frame-escape findings) go upstream once Porthole has
290
+ the code and tests that back them, not before.
291
+ **Why.** Evidence makes a proposal actionable; a design without a runnable suite is
292
+ an opinion.
293
+ **Rejected.** Filing now from the design alone.
294
+ **Revisit when** the leak suite and the MCP Apps adapter pass on two engines.
295
+
296
+ ## P019 · 2026-10-11 · The React binding ships a default, replaceable confirmation dialog · Accepted
297
+
298
+ **Decision.** `@botiverse/porthole/react` draws a default confirmation dialog
299
+ (outside every frame) for `confirm`-gated calls: the whole effect (the prompt's full
300
+ text, the link's full URL), the display's title, Cancel focused, and the accept
301
+ button ignoring presses for a moment after it opens. A host replaces it by passing
302
+ its own `confirm` function. The core never draws anything; `confirm` there is only a
303
+ function, and without one a gated call is refused.
304
+ **Why.** "Out of the box" needs a working confirmation; PRINCIPLE 8 rules out UI the
305
+ host can't control, and this one the host replaces with one prop.
306
+ **Rejected.** No default (every host must build a dialog before anything works);
307
+ a dialog in the core (couples the core to a UI framework).
308
+ **Revisit when** hosts routinely restyle rather than replace it (then expose its
309
+ parts).
310
+
311
+ ## P020 · 2026-10-11 · URL mode is phase 1: frame, isolation and access auth only · Accepted
312
+
313
+ **Decision.** Supersedes P012's exclusion of URL mode: the first host's dev-server
314
+ preview pane must dogfood Porthole. URL mode frames a running app by URL with
315
+ isolation and access auth only: no shell, no capabilities, no manual, no egress
316
+ control (the app is someone else's, on its own origin). Its config is the `url`
317
+ preset (`mode: "url"`, `isolation: "external"`, its `contentOrigins`); the embedding
318
+ page's `frame-src` lists those origins; access goes through the same ticket → cookie
319
+ exchange as domain mode. The frame's baseline sandbox is `allow-scripts
320
+ allow-same-origin allow-forms`: no popups, downloads, modals or top navigation.
321
+ **Threat it re-opens.** The framed app runs with its own origin's powers and can
322
+ submit forms. Bounded by: its origin must be a separate site from the host's (as in
323
+ P016); it gets no host function and nothing of the host's; the embedding page frames
324
+ only the declared content origins. The served-modes work ships the probes for this
325
+ (no reach into the host; no top navigation; no popup) and refines the sandbox tokens
326
+ with a decision entry if a real app needs more.
327
+ **Why.** A preview pane is "show content the host didn't write" too, and building it
328
+ on Porthole keeps one audited framing path in the host.
329
+ **Rejected.** Leaving the preview on the host's own code (two framing paths to
330
+ audit); giving URL mode a shell or egress control (it is a live app, not content
331
+ Porthole draws).
332
+ **Revisit when** a host needs URL-mode frames to talk to it (then that is a
333
+ protocol, through an adapter).
334
+
335
+ ## P021 · 2026-10-11 · Gates are a list that all must pass, safe by default, failing closed · Accepted
336
+
337
+ **Decision.** A capability's gates are a list (`user-activation`, `confirm`,
338
+ `{ rateLimit }`); every one must pass. A config that names no gates gets the
339
+ vocabulary's defaults (`sendPrompt` and `openLink`: a gesture and a confirmation;
340
+ `callTool`: a confirmation; reads and context updates: rate limits); `[]` is an
341
+ explicit "ungated". `confirm` is enforced by the framework calling the host's
342
+ `confirm` function; with none, the call is refused. `user-activation` is checked by
343
+ the shell and, where the engine passes a frame's gesture on to its ancestors
344
+ (measured by the leak suite), by the host too; the shell's check shares the
345
+ content's realm, so it stops calls made on load or on a timer, not a determined
346
+ author: the confirmation is the wall.
347
+ **Why.** The first design's single `gate` could not say "a gesture and a
348
+ confirmation", which the first host already requires for prompts. Defaults that are
349
+ safe and a refusal without a `confirm` function mean a host that forgets something
350
+ gets a call that doesn't run, not one that runs unasked (PRINCIPLE 1).
351
+ **Rejected.** One gate per capability; treating the shell's activation check as a
352
+ wall.
353
+ **Revisit when** a gate needs to depend on the call's arguments (then the host's
354
+ `confirm` decides, as it does for tool calls today).
355
+
356
+ ## P022 · 2026-10-11 · Every preset starts with egress closed; library CDNs are a one-line opt-in · Accepted
357
+
358
+ **Decision.** Every preset's base egress is empty: nothing loads from outside and
359
+ every fetch fails. `LIBRARY_CDNS` (cdnjs, jsDelivr, unpkg, esm.sh for scripts,
360
+ styles, fonts, images and data; Google Fonts) is exported for a host to open with
361
+ one line, and the manual teaches libraries (and pinning them) only when scripts may
362
+ load. The frame's CSP never allows `'self'`: an opaque frame has nothing of its own
363
+ to load, and its `'self'` may be the embedding page's origin.
364
+ **Why.** P006 says egress opens only to what a config declares; a preset that opened
365
+ CDNs by default would be a default the host never chose. The CDNs are read-only
366
+ package caches (reviewed by the first host), so opening them is a reasonable host
367
+ choice, but it is the host's.
368
+ **Rejected.** CDNs on by default (the first host's previous posture); per-path CDN
369
+ allowlists (break ESM imports and data fetches for no egress gain).
370
+ **Revisit when** most hosts open the CDNs anyway (then consider a preset variant,
371
+ by a decision that names the residual supply-chain risk).
372
+
373
+ ## P023 · 2026-10-11 · No `readState`/`writeState` capabilities · Accepted
374
+
375
+ **Decision.** The vocabulary drops `readState` and `writeState`. An opaque frame
376
+ keeps an in-memory `localStorage` for its life; content that needs storage that
377
+ persists runs in domain mode (P016), where `localStorage` is real and scoped by the
378
+ host's storage scope.
379
+ **Why.** Domain mode is now phase 1 and gives real storage without a protocol of our
380
+ own (MCP Apps has no state method, so these would have been Porthole-only methods).
381
+ **Rejected.** Own-prefixed state methods backed by the host.
382
+ **Revisit when** a host needs persisted state for opaque frames.
383
+
384
+ ## P024 · 2026-10-11 · The shell reports errors with MCP's log notification; its theme event is named by the config · Accepted
385
+
386
+ **Decision.** The shell reports what content throws, and what the sandbox blocks, as
387
+ the standard `notifications/message` (level `error`, logger `porthole`), which the
388
+ host's adapter turns into the `reportError` capability. The window event the shell
389
+ fires after a theme change is `config.theme.event` (default `porthole:theme`), and the
390
+ manual teaches that same name. The shell's content arrives as `tool-input(-partial)`
391
+ with `{ html }`.
392
+ **Why.** MCP already has a logging notification, so errors need no private method
393
+ (PRINCIPLE 2). A host migrating onto Porthole may already have content listening for
394
+ its own event name; naming it in the config keeps that content working, and the
395
+ agreement test keeps the shell and the manual on the same name.
396
+ **Rejected.** An own-prefixed error method; a fixed event name.
397
+ **Revisit when** MCP Apps standardizes error reporting or theme-change events for
398
+ views.
399
+
400
+ ## P025 · 2026-10-11 · A served document comes with its ticket's answer; the cookie serves what follows · Accepted
401
+
402
+ **Decision.** Refines P016's ticket flow. `/server` names each storage scope's origin
403
+ `<label>.<content domain>` (24 base32 characters of an HMAC of the scope, keyed from the
404
+ server's secret), and each instance a key (16 characters, an HMAC of the instance): a served
405
+ document (shell, artifact version, sandbox proxy) lives at `/__porthole/d/<key>`, and `issue`
406
+ returns that path with a one-time ticket (`?ticket=`, 60 s by default, stored as its hash). The
407
+ request that takes the ticket is answered **with the document itself**, CSP and `frame-ancestors`
408
+ in headers, plus a cookie of that origin and instance only: `__Host-porthole-<key>`, `Secure`,
409
+ `HttpOnly`, `Path=/`, `SameSite=None; Partitioned` in a frame (`Sec-Fetch-Dest` other than
410
+ `document`), `SameSite=Lax` on a tab of its own; signed, carrying the instance, the viewer and its
411
+ end (an hour by default, `Grant.sessionSeconds`). The cookie serves what follows: a reload, and
412
+ every request of a url-mode app, whose ticket is taken at `/__porthole/enter` and redirects to the
413
+ app's own landing path. Over http (loopback dev only) the cookie drops `__Host-`, and `Secure` on
414
+ a tab. Host origins on, or possibly same-site with, the content domain are refused when the server
415
+ is created; a mount checks a located frame the same way (`checkFrameLocation`). Without the
416
+ public-suffix list the same-site check errs toward "same site" (it refuses hosts sharing the last
417
+ two labels, so `a.github.io` and `b.github.io` are refused though browsers would allow them).
418
+ **Measured** (`.dev` probes, Playwright 1.63: Chromium 153, WebKit 26.6). WebKit keeps **no**
419
+ cookie in a cross-site frame: not `SameSite=None; Secure`, not `Partitioned`, not `__Host-`, over
420
+ http or https (`document.hasStorageAccess()` is false); on a tab of its own it keeps them over
421
+ https. Chromium keeps partitioned cookies in frames, including `Secure` ones over http on
422
+ `*.localhost`. Framed `localStorage` persists across reloads in both. So a redirect after the ticket
423
+ (the first design) shows WebKit nothing, while the document in the ticket's own answer shows it
424
+ everywhere; and a url-mode app, which needs its cookie for every subresource, cannot run framed in
425
+ WebKit: a host shows it on a tab there. The leak suite's `served.url.framed` asserts the WebKit
426
+ failure (`test.fail`), so a change in WebKit shows up as a failing case.
427
+ **Why.** The ticket must work on the weakest engine we support (PRINCIPLE 6), and a used ticket
428
+ left in the frame's URL is worthless (taken atomically, once), while the frame sends no referrer.
429
+ Per-instance cookies let several instances of one scope (one origin) reload side by side.
430
+ **Rejected.** Redirecting to a ticket-free URL (WebKit shows the 401 page); a token in the
431
+ document's path (a credential in every URL the content can read and log); a service worker that
432
+ adds the session to an app's requests (fragile beside an app's own worker, and no WebSockets).
433
+ **Revisit when** WebKit keeps partitioned cookies in frames (then `served.url.framed` fails as
434
+ "passed unexpectedly": framed url mode works there too), or a host needs revocation before a
435
+ cookie's end (then a check on every request).
436
+
437
+ ## P026 · 2026-10-11 · The MCP Apps sandbox proxy becomes its view · Accepted
438
+
439
+ **Decision.** The sandbox proxy `/server` serves (`ServedDocument` `mcp-proxy`) speaks the
440
+ spec's host-facing protocol, `sandbox-proxy-ready` until the host answers
441
+ `sandbox-resource-ready { html }`, then writes the shell's hardening head and the view into
442
+ **its own** document rather than into a nested same-origin frame. Writing a document erases the
443
+ window's listeners, so nothing of the proxy remains and the view talks to its host directly; the
444
+ spec's relay is the identity. The view's CSP goes out in the proxy's headers before the HTML
445
+ arrives: `issue` takes the view's declared egress (`_meta.ui.csp`, mapped to Porthole's
446
+ vocabulary by `/mcp`) in the instance and signs it into the ticket and cookie, opened on top of
447
+ the config's (closed by default, so a view gets exactly what it declares). The proxy takes the HTML
448
+ only from its parent, from a host origin, once; it ignores the message's `csp`, `sandbox` and
449
+ `permissions` (the policy is the headers'; the frame's sandbox and `allow` are the host's). Leak
450
+ cases `served.proxy.view`, `.parent-only` and `.egress` prove it on Chromium and WebKit.
451
+ **Threat it re-opens.** None beyond P016's: the view runs with `allow-same-origin` on a scope's
452
+ origin, as any domain-mode content.
453
+ **Why.** A view nested in a same-origin frame can script its proxy: a second realm where WebRTC
454
+ and frame creation are intact (outside the shell's hardening) and whose fetches run under the
455
+ proxy's own policy, so the proxy would need the view's hardening a second time. Writing in place
456
+ leaves one realm, hardened once, under one policy. The host and the view see the protocol the
457
+ spec describes.
458
+ **Rejected.** The reference host's nested inner frame (`document.write` into a same-origin child,
459
+ CSP from a `?csp=` query); a `<meta>` CSP written with the view (the header is in force before any
460
+ HTML arrives and can't be edited by it).
461
+ **Revisit when** the spec makes the relay observable to a view, or a host needs to send a proxy a
462
+ second resource; the framing note goes upstream with the leak runner (P018).
463
+
464
+ ## P027 · 2026-10-11 · The MCP Apps adapter: what a view is sent, and what it may ask · Accepted
465
+
466
+ **Decision.** `@botiverse/porthole/mcp`'s `mcpAppsAdapter` (P007) answers `ui/initialize` with
467
+ the spec's protocol version, the host's name, the capabilities the config declares (`openLinks`
468
+ for `openLink`, `serverTools` for `callTool`, `message` for `sendPrompt`, …, plus the egress it
469
+ opens as `sandbox.csp`) and the host context; it sends a view nothing else before the view says
470
+ `initialized` (queued tool input, result and cancellation go then, in order, the drafts collapsed
471
+ to the latest). It carries `ui/message`, `ui/open-link`, `ui/download-file`, `tools/call`,
472
+ `resources/read` and `ui/update-model-context` into the bridge with the host page's user
473
+ activation; a refusal answers with JSON-RPC error `-32000` (`-32601` when not declared), a host
474
+ function that ran but couldn't with `{ isError: true }`. Sizes go to `resize`, `notifications/message`
475
+ at error levels and above to `reportError`. Further:
476
+ - **Style variables are cut to the standard's 76 names.** Measured: the official App SDK (2.0.3,
477
+ zod 4) refuses a host context naming any other, so a view never initializes; Porthole's own
478
+ names (`--color-accent`, the chart palette) stay with its shell. `MCP_APPS_STYLE_VARIABLES` is
479
+ exported (a test checks it against the SDK's list).
480
+ - **`ui/request-display-mode` keeps the current mode**, answering with it (the spec's answer
481
+ when a request isn't granted): how a display is laid out is the host's, and the vocabulary has
482
+ no capability that could gate it.
483
+ - **A view's `frameDomains` and `baseUriDomains` open nothing** (`viewEgress`): the shell removes
484
+ nested frames (P006) and `base-uri` stays `'none'`; `connectDomains` and `resourceDomains` that
485
+ are exact origins (or `https://*.`) become egress, anything else is dropped.
486
+ - **Tool visibility is the host's policy:** its `callTool` handler refuses tools the view may not
487
+ call (`_meta.ui.visibility`).
488
+ The leak suite runs the official examples as published (`server-basic-vanillajs`,
489
+ `server-basic-react`, `server-threejs`, pinned dev dependencies, unmodified) through the adapter, in
490
+ an opaque frame and behind the sandbox proxy: tool input (streamed) and result, `tools/call`,
491
+ `ui/message`, `ui/open-link`, theme change, size, teardown and model context; all pass on Chromium
492
+ and WebKit. **Measured:** a click in a view, opaque or served, activates the host page on both
493
+ engines (each clicked call reached the bridge with `userActivated: true`).
494
+ **Why.** Interop is decided by the real SDK and examples, not by reading the spec (PRINCIPLE 6);
495
+ everything a view can do beyond drawing stays a declared, gated host function (PRINCIPLE 4).
496
+ **Rejected.** Sending all theme variables (breaks every SDK view); a host callback for display
497
+ modes outside the bridge (an ungated power); honoring `frameDomains` (re-opens P006's nested-realm
498
+ channel).
499
+ **Revisit when** the SDK accepts unknown style keys or the spec adds names (then widen the list),
500
+ or a host needs fullscreen views (then a `displayMode` capability with a gate).
501
+
502
+ ## P028 · 2026-10-11 · The host context carries only the spec's style names; Porthole's own go in `porthole/styles` · Accepted
503
+
504
+ **Decision.** In `ui/initialize` and `host-context-changed`, `styles.variables` holds
505
+ exactly the names MCP Apps defines (`MCP_APPS_STYLE_VARIABLES`, in the core, re-exported
506
+ by `/mcp`). The theme's other variables (`--color-accent`, the chart palette, a host's
507
+ own) travel in the host context's own-prefixed field `porthole/styles: { variables }`;
508
+ the shell applies both, filtered by the config's names. The shell numbers its own
509
+ requests with strings (`porthole-shell-<n>`) so content using the App SDK in the same
510
+ window never takes the shell's answers, nor the shell the SDK's.
511
+ **Why.** Measured: the official App SDK (ext-apps 2.0.3) validates the host context
512
+ strictly and fails the whole handshake on an unknown style name ("Invalid result for
513
+ ui/initialize: hostContext.styles.variables: Unrecognized keys"), while it keeps
514
+ unknown top-level fields. The `0.app-sdk-*` probes connect the official SDK from content
515
+ under the inline, artifact and MCP Apps presets; the `style-split` negative control
516
+ (every variable in `styles`) makes all three fail with that error.
517
+ **Rejected.** Dropping the extra variables (the manual teaches them); defaults baked
518
+ into the shell's CSS only (a host's live values for them would never arrive).
519
+ **Revisit when** the spec's style list grows (move names across) or it gains an
520
+ extension point for host-specific variables (use it, and take ours upstream).
521
+
522
+ ## P029 · 2026-10-11 · Documents are rendered inert and imported; content can't rewrite its document or build declarative shadow roots · Accepted
523
+
524
+ **Decision.** A whole document (an artifact page, an MCP Apps view in an opaque frame,
525
+ a served page, the sandbox proxy's view) is never handed to the page's parser: it
526
+ travels as data (the shell's plan, or the protocol's tool input), is parsed with
527
+ `DOMParser` (no scripts run, no declarative shadow roots built), guarded, imported in
528
+ place of the shell's, and its scripts run in order, as a fragment's are, followed by
529
+ `DOMContentLoaded` and `load`. Content can't rewrite its document
530
+ (`document.open`/`write`/`writeln` throw), and the entry points that would build a
531
+ declarative shadow root (`setHTMLUnsafe`, `Document.parseHTMLUnsafe`) get fragment
532
+ parsing's behavior. The sandbox proxy (P026) writes the shell's own document with the
533
+ view in its plan, not the view's markup.
534
+ **Why.** The frame guard (P006) reaches every shadow root `attachShadow` makes, but not
535
+ a closed declarative one: the parser builds it without any script seeing it, and a frame
536
+ inside it runs in a fresh realm. Measured on Chromium: with the `setHTMLUnsafe` block
537
+ taken out (`declarative-shadow` control) a child frame in a closed declarative root
538
+ runs; with `document.write` allowed (`parser-entry-points`) a rewritten document's child
539
+ runs. Rendering by import has no such path, and the re-serialization alternative
540
+ (parse, strip, serialize, write) reopens it through markup that parses differently the
541
+ second time.
542
+ **Rejected.** Writing documents with `document.write` (the first host's way); a string
543
+ sanitizer; letting content rewrite its document.
544
+ **Cost.** Content that calls `document.write` fails (reported as an error); declarative
545
+ shadow DOM in content is inert; a page's scripts see `document.readyState` "complete"
546
+ (their load events still fire, once).
547
+ **Revisit when** engines let a script observe or refuse declarative shadow roots (then
548
+ allow them), or a host needs `document.write` content.
549
+
550
+ ## P030 · 2026-10-11 · Guarded elements are refused synchronously, where they are connected or changed · Accepted
551
+
552
+ **Decision.** Besides the mutation observer (which runs at the next microtask), the shell
553
+ wraps every DOM insertion API (`appendChild`, `insertBefore`, `replaceChild`,
554
+ `append`/`prepend`/`replaceChildren`, `before`/`after`/`replaceWith`,
555
+ `insertAdjacentElement`, `Range.insertNode`/`surroundContents`) to drop frame-like
556
+ elements and resource hints before they are connected; parses markup set through
557
+ `innerHTML`, `outerHTML`, `insertAdjacentHTML` and `setHTML` in a detached element of the
558
+ same kind, guards it, then moves it in; refuses `execCommand("insertHTML")`; and removes a
559
+ connected `<link>` or `<meta>` from the document before any change (attributes, IDL
560
+ setters, `relList`, attribute nodes and maps) that would make it a guarded one. Each
561
+ wrapper is fixed in place (non-configurable).
562
+ **Why.** Measured on WebKit: a `rel=preconnect` link that script inserts, or turns into a
563
+ preconnect while connected, opens a TCP connection at once, outside the CSP, before any
564
+ observer runs (`8.preconnect` failed with 3 connections; it passes with these guards).
565
+ Chromium measured no preconnect from a frame here, but harden for the weakest engine
566
+ (PRINCIPLE 6). The `sync-guard` negative control makes `8.preconnect` fail on WebKit.
567
+ **Rejected.** Accepting the leak on WebKit; relying on the observer alone.
568
+ **Revisit when** WebKit applies the CSP to preconnect (then the observer suffices), or a
569
+ DOM insertion API is added (wrap it, and add its probe).
570
+
571
+ ## P031 · 2026-10-11 · A display whose content navigates its frame is started again, at most three times · Accepted
572
+
573
+ **Decision.** The web platform counts its frame's loads; any after the first means the
574
+ content navigated the frame. The display is started again from its content (an event
575
+ `navigated`), at most three times per run, then stopped with an error. The wall against
576
+ navigating out is the embedding page's `frame-src` (P006); a document the frame
577
+ navigates to must never run, and the leak suite asserts that, not the restart.
578
+ **Why.** Measured on Chromium and WebKit: a self-navigation the embedding page's
579
+ `frame-src` blocks still replaces the frame's document (an error page fires `load`), so
580
+ the content is gone either way; starting again keeps the display showing. Measured on
581
+ Chromium with the embedding CSP taken out (`embedder-csp` control): a `blob:` or `data:`
582
+ document the frame navigates to runs, and only the restart's timing kept it from
583
+ reaching the STUN oracle, which is a race, not a wall; so `5.blob`/`5.data` assert, with
584
+ a witness origin, that such a document never runs.
585
+ **Rejected.** Ignoring navigations (a blank display); restarting forever (content that
586
+ navigates on load would loop).
587
+ **Revisit when** an engine keeps the frame's document on a blocked navigation.
588
+
589
+ ## P032 · 2026-10-11 · Every hardening item has a negative control · Accepted
590
+
591
+ **Decision.** The leak suite's server can serve the core with one defense taken out
592
+ (`PORTHOLE_LEAK_WEAKEN=<name>`: `frame-guard`, `webrtc`, `attach-shadow`,
593
+ `parser-entry-points`, `declarative-shadow`, `sync-guard`, `connect-src`, `self`,
594
+ `sandbox`, `base-uri`, `style-split`, `embedder-csp`; later also `embedder-self`,
595
+ `confirm-gate`, `host-gesture`, `shell-gesture`, `link-scheme`, `draft-inert`, `document-theme`). Each names the exact text it
596
+ removes, and the server refuses to start if that text is gone, so a control can't
597
+ silently weaken nothing. DESIGN.md records, per engine, which probes each control makes
598
+ fail. Every probe that asserts "nothing left" also has a positive control (an allowed
599
+ origin, an unsandboxed peer connection, an unsandboxed hint) or says it has none.
600
+ **Why.** PRINCIPLE 6: a wall never seen to fail proves nothing. Running the controls found
601
+ what the passing suite could not: that `5.blob` passed by a race (P031), that two walls
602
+ stand behind form submission (`sandbox` alone doesn't fail `6.submit`: `form-action`
603
+ holds), and that an opaque frame's `'self'` doesn't reach the host on Chromium.
604
+ **Rejected.** Trusting a green suite.
605
+ **Revisit when** a defense is added (add its control) or a control stops failing its
606
+ probes (the probe has gone blind: fix it first).
607
+
608
+ ## P033 · 2026-10-11 · The embedding page frames nothing by default: `frame-src 'none'` for opaque content · Accepted
609
+
610
+ **Decision.** `embedderCsp` no longer includes `'self'`: for opaque displays it is
611
+ `frame-src 'none'; child-src 'none'`; served and external displays add their content
612
+ origins, and a host its own frames through `embedder.frameSrc`, where `'self'` is
613
+ accepted only by name. Resolves DESIGN's open question on `'self'`.
614
+ **Why.** Measured on Chromium and WebKit: a `srcdoc` frame loads and runs under its
615
+ embedding page's `frame-src 'none'`, so `'self'` bought opaque content nothing but a way
616
+ to navigate its frame to the host's own origin, a request carrying whatever the content
617
+ puts in its URL, with any of the host's `SameSite=None` cookies. Probe `5.host-origin`
618
+ asserts no such request; the `embedder-self` control makes it fail. This tightens a
619
+ default (PRINCIPLE 1 allows that freely); a host that frames its own pages names `'self'`
620
+ and accepts that navigation.
621
+ **Rejected.** Keeping `'self'` (the first host's policy) for compatibility.
622
+ **Revisit when** an engine stops letting `srcdoc` frames load under `frame-src 'none'`
623
+ (the opaque-origin probes would fail at once).
624
+
625
+ ## P034 · 2026-10-11 · The React binding owns lifecycle and page state; the core owns everything else · Accepted
626
+
627
+ **Decision.** `@botiverse/porthole/react` is mounting and lifecycle only: a
628
+ `PortholeProvider` per page holds the live-frame registry (`admitFrame`, the first host's
629
+ policy: frames off screen longest stop first, a stopped one keeps its height and offers
630
+ "Run again"), the heights displays reported (by display id), and the default dialog
631
+ queue; a `PortholeFrame` per display mounts the core's `createHost` when it nears the
632
+ screen, feeds it `content`/`partial`/`scheme` (and its id and title), and makes a new
633
+ display only for a new `config`, `document` or `instance` (or `generation`). It is
634
+ written in TSX (the React lint rules only understand JSX; a host's bundler or the
635
+ package's build compiles it). Handlers are read
636
+ from the latest props. The cap is each config's `limits.maxLiveFrames`. Without a
637
+ provider, frames run against a page-wide registry and no one confirms (gated calls are
638
+ refused). The shell reports no size before it has drawn something.
639
+ **Why.** These are the first host's widget-frame behaviors (OBJECTIVE 4: its code
640
+ reduced to config plus callbacks), and they are page state a framework binding owns,
641
+ not isolation. Measured while testing: the first host's shell reported an empty
642
+ shell's height at its handshake, which overwrote a display's remembered height and
643
+ made a re-mounted frame jump; `11.size` now asserts the first size is the content's.
644
+ **Rejected.** A binding with its own frame or protocol code; a default with no
645
+ provider that silently auto-confirms.
646
+ **Revisit when** a second framework needs a binding (then move the registry and
647
+ heights into a framework-free module both use: `live.ts` already is).
648
+
649
+ ## P035 · 2026-10-11 · Firefox joins the gate; its differences are measured and need no new wall · Accepted
650
+
651
+ **Decision.** Completes P017: every Playwright suite (leak, served, MCP examples, React)
652
+ runs on Chromium, WebKit and Firefox, and every negative control was run on Firefox.
653
+ All leak probes passed on Firefox as they stood; no defense was added for it. Its
654
+ differences, each measured (Firefox 155, Playwright 1.63):
655
+ - A navigation the embedding page's `frame-src` blocks **keeps** the frame's document
656
+ (Chromium and WebKit replace it with an error page, so their displays restart, P031).
657
+ The React binding's recovery test therefore navigates to `about:blank`, which every
658
+ engine follows.
659
+ - A frame without `allow-popups` gets an exception from `window.open` (the others get
660
+ `null`): no window either way. The served `no-popups` cases accept both.
661
+ - Firefox sends no STUN to a loopback server by default, which left `3.webrtc` without
662
+ its positive control; the suite's Firefox project sets
663
+ `media.peerconnection.ice.loopback` (test environment only), and an unsandboxed peer
664
+ connection then reaches the oracle while the sandboxed one, without constructors, can't.
665
+ - Without the embedding CSP, Firefox still refuses a frame's own navigation to `data:`
666
+ (`5.data` holds under the `embedder-csp` control); the other navigations out fail, as
667
+ on the other engines.
668
+ - Like Chromium, Firefox opened no preconnect even unsandboxed (`sync-guard` alone fails
669
+ nothing on it); WebKit stays the weakest engine there (P030).
670
+ - A click in the frame reaches the host page's user activation; a new child frame's
671
+ realm is unreachable in the task that made it (a `TypeError`, not a `SecurityError`).
672
+ - Headless Firefox has no WebGL on a machine without a GPU (Mesa installed or not): the
673
+ threejs example cases skip there with that reason; they are about the machine, not
674
+ Porthole. A Playwright worker crashed once (SIGSEGV) and never again on rerun.
675
+ - Waiting on script in a frame that `frame-ancestors` refused never settles on Firefox;
676
+ the served `frame-ancestors` case bounds that wait.
677
+ **Why.** PRINCIPLE 6: engines differ, so each is measured and the weakest decides. Firefox
678
+ is weaker nowhere the suite can see.
679
+ **Revisit when** an engine version changes any of these (the probes and controls will say
680
+ which), or Firefox gains a synchronous hint path like WebKit's.
681
+
682
+ ## P036 · 2026-10-11 · A host's origin may have any scheme, still exact · Accepted
683
+
684
+ **Decision.** The origins the shell takes messages from, and `/server`'s `hostOrigins`, are
685
+ exact origins of any scheme (`scheme://host[:port]`, lowercase, no path): a desktop app's own
686
+ registered scheme (`ferry://app`, `app://local`, `tauri://localhost`) works like `https://`.
687
+ `"null"` is refused with a message saying why (an opaque origin isn't one origin; `file:` pages
688
+ and unregistered custom schemes have one), as are wildcards, paths and uppercase. A custom-scheme
689
+ host is never the same site as web content, so the same-site check doesn't apply to it. Errors on
690
+ origins say what to do: http and ws are allowed on loopback names only ("use https"), and a
691
+ same-site content origin is refused with the host and the rule named. Item 0 (and 10) of 0.1.1.
692
+ **Why.** The first host's packaged desktop app serves its UI from `ferry://app`, and nothing
693
+ mounted there; the checks were written for the web. The check that matters (the sender is the
694
+ frame's parent, from exactly this origin) is unchanged.
695
+ **Rejected.** Accepting `"null"` (any opaque page would pass the origin check).
696
+ **Revisit when** a host must run from an opaque origin (then it needs another channel to prove
697
+ it is the parent).
698
+
699
+ ## P037 · 2026-10-11 · A url-mode frame may opt into popups, modals and downloads · Accepted
700
+
701
+ **Decision.** `presets.url({ sandbox: [...] })` adds tokens from a documented set
702
+ (`URL_SANDBOX_EXTRAS`: `allow-popups`, `allow-popups-to-escape-sandbox`, `allow-modals`,
703
+ `allow-downloads`) to url mode's baseline (`allow-scripts allow-same-origin allow-forms`). Nothing
704
+ else, never `allow-top-navigation*` (refused by name), and only in url mode. The safe default
705
+ stays: no extras. Item 7 of 0.1.1.
706
+ **Threat it re-opens.** The framed app (the person's own running app, on its own origin) may
707
+ open windows (which, with `-to-escape-sandbox`, are unsandboxed top-level pages of its origin),
708
+ show modal dialogs over the host, and start downloads. Bounded by: its origin is a separate site
709
+ from the host's (`checkFrameLocation`, P020), so a window it opens can't reach the host; it still
710
+ can't navigate the host's page; it is the host's policy to opt in (PRINCIPLE 8), for an app the
711
+ person runs. The React url-mode test shows the default refuses a popup and top navigation, and
712
+ the opt-in allows the popup and still refuses top navigation, on all three engines.
713
+ **Rejected.** A free-form token list; extras for agent-made content (inline, artifact, views).
714
+ **Revisit when** a host needs another token (add it to the set, with its test).
715
+
716
+ ## P038 · 2026-10-11 · The embedding page's CSP restricts frames only (no child-src) · Accepted
717
+
718
+ **Decision.** `embedderCsp` is `frame-src …` alone; it no longer repeats the sources in
719
+ `child-src`. Item 4 of 0.1.1.
720
+ **Why.** Measured on Chromium, WebKit and Firefox: under 0.1.0's `child-src 'none'` the host's
721
+ own workers (blob and file) were blocked, since `worker-src` falls back to `child-src`. All three
722
+ engines read frames from `frame-src` (the item-5 probes pass without `child-src`), so the repeat
723
+ protected nothing and broke hosts. With no `child-src` (and no `default-src`), Porthole's policy
724
+ says nothing about workers at all, whatever the host's own policy says.
725
+ **Rejected.** Emitting `worker-src 'self' blob:` (it would restrict hosts that load workers from
726
+ elsewhere).
727
+ **Revisit when** an engine Porthole supports reads frames only from `child-src`.
728
+
729
+ ## P039 · 2026-10-11 · A whole document takes the person's scheme only when it declares it · Accepted
730
+
731
+ **Decision.** An artifact page (or any whole document the shell renders) shows as its author
732
+ styled it, in light, unless it declares it supports both schemes
733
+ (`<meta name="color-scheme" content="light dark">`); then it takes the person's scheme
734
+ (`color-scheme`, `data-theme`) like a widget does. The theme variables are set either way (they
735
+ change nothing a page doesn't use). A host may set `theme.documents: "always"`. The manual says
736
+ so for artifact pages. Fragments (widgets) are unchanged. Item 12 of 0.1.1.
737
+ **Why.** A shown HTML file is the author's own page: forcing a dark `color-scheme` on a page
738
+ written for light turns its default text and canvas dark under its own light colors, and the
739
+ first host had to force light. The `11.document-theme` probe and its `document-theme` control
740
+ show both cases on every engine.
741
+ **Rejected.** Theming every document; never theming documents (pages that support dark should
742
+ follow the person).
743
+ **Revisit when** a host needs per-page control beyond the meta tag.
744
+
745
+ ## P040 · 2026-10-11 · A display reports how its first document went · Accepted
746
+
747
+ **Decision.** Every display emits `outcome` once per run: `loaded`, `blocked` (with a reason:
748
+ `framing`, `cookies`, `session`, `upstream`, `not-found`, `error`, and the HTTP status where
749
+ there is one), or `timeout` (`limits.loadTimeoutMs`, 15 s by default). A shell or view is loaded
750
+ when its handshake completes. A url-mode frame, which has no handshake, is ready (`initialized`)
751
+ on its first load and fills its container (at least `minHeightPx`), since an app reports no size.
752
+ Pages of Porthole's `/server` tell their host how it went with an own-prefixed notification,
753
+ `porthole/notifications/frame-status`, posted to the host origins only: every status page says
754
+ `blocked` and why; a framed url-mode app is entered through a small page (not a redirect) that
755
+ says `entering`, checks the session cookie its own answer set was kept (`/__porthole/session`),
756
+ then goes on to the app, or says `blocked: cookies`. `issue` marks its locations `reports: true`,
757
+ so a load from which nothing spoke is a page that never ran (`blocked: framing`). A host's own
758
+ gateway may send the same notification. Items 1 and 13 of 0.1.1.
759
+ **Why.** A host needs to show its own fallback ("Open in a new tab") without pages of its own,
760
+ and a cross-origin frame can't be looked into: the only honest signals are the frame's loads,
761
+ its words, and time. Measured on all three engines (`leak/served/outcome.spec.ts`): a framed app
762
+ loads on Chromium and Firefox and is `blocked: cookies` on WebKit (P025); a host the server
763
+ doesn't allow gets `blocked: framing`; a used ticket `blocked: session`; a hanging load a
764
+ timeout; a served shell `loaded`, and `blocked: framing` from a foreign host.
765
+ **Limits.** Without `reports`, a frame the browser refused (its own policy, or the embedding
766
+ page's) still fires `load`, so it reads as loaded: only Porthole's pages (or a gateway that
767
+ speaks the notification) can say otherwise. An app's own `frame-ancestors` after entry likewise.
768
+ **Rejected.** Injecting a script into an app's pages (rewrites someone else's HTML, breaks under
769
+ its CSP); guessing from timing.
770
+ **Revisit when** browsers expose why a cross-origin frame failed, or MCP Apps defines a status
771
+ message (then speak it).
772
+
773
+ ## P041 · 2026-10-11 · Porthole declares no test-only dependency; hosts register the conformance tests · Accepted
774
+
775
+ **Decision.** The package declares no dependencies, and React only as an optional peer.
776
+ `@playwright/test` and `@modelcontextprotocol/ext-apps` are no longer peers (optional or not):
777
+ `/conformance` runs on the host's own Playwright (and, for the App SDK probes, its own SDK,
778
+ skipped if absent). The tests are exported as entries (`leakTests`, `servedTests`) that a host
779
+ registers with its own `test()` in its own spec file, so they are located there and filtered by
780
+ that file or a probe id (`--grep "\[1\.connect\]"`); `defineLeakSuite`/`defineServedSuite`
781
+ remain for hosts that don't filter. Items 5 and 6 of 0.1.1.
782
+ **Why.** Some installs added the optional peers anyway (the first host's server image got
783
+ Playwright). A conformance runner is a host's test tool, not part of what it ships; and tests
784
+ registered inside the library were located in `node_modules`, so a host couldn't select them.
785
+ `test/package.test.ts` and `test/conformance-listing.test.ts` keep both true.
786
+ **Rejected.** A separate `@botiverse/porthole-conformance` package (a second package to publish
787
+ and version in lockstep, for one import); a CLI wrapper (the host's Playwright config already
788
+ runs its spec).
789
+ **Revisit when** a host's install layout can't resolve its own Playwright from the package (no
790
+ hoisting, Plug'n'Play): then split the package.
791
+
792
+ ## P042 · 2026-10-11 · Confirm says accepted, declined or redirected, and may run the effect in its click · Accepted
793
+
794
+ **Decision.** A host's `confirm(request, proceed)` resolves `true`/`"accepted"`,
795
+ `false`/`"declined"`, or `"redirected"` (the person took it elsewhere, "Edit first": not sent,
796
+ refusal `redirected`, neither counted nor resetting). Only declines **in a row** mute a display;
797
+ an accepted call resets the count. `proceed()` runs the call's effect at once, inside the
798
+ caller's own gesture, and counts as accepted; the default dialog's yes calls it in its click, so a
799
+ handler that opens a window does so within the person's click. Items 8 and 11 of 0.1.1.
800
+ **Why.** The first host's "Edit first" counted as a decline and three of any declines muted a
801
+ widget, against its own original rule. An effect that needs a gesture (a window) can't wait for
802
+ a promise to settle on engines that end the gesture on the next task. Measured in Playwright on
803
+ all three engines: a yes given 1.5 s later, without `proceed`, still opened a window there, so
804
+ the tests show `proceed` works rather than that it is needed; real browsers' popup blockers are
805
+ stricter than headless ones.
806
+ **Rejected.** Running the effect in the dialog itself (the effect is the host's).
807
+ **Revisit when** a host needs more answers than these three.
808
+
809
+ ## P043 · 2026-10-11 · Theme reads wait for the host's restyle; heights follow a renamed display; defaults can be dropped · Accepted
810
+
811
+ **Decision.** `setTheme` reads the host's values two animation frames after the flip (a host
812
+ that restyles its page on its next frame has by then); `refreshTheme()` (React: a new `themeKey`)
813
+ reads them again without a flip. A display renamed while it runs keeps its remembered height
814
+ under its new id, and `PortholeFrame`'s `aliases` find it under ids it had for a frame that
815
+ mounts anew. A host drops default theme variables with `null` in a preset's theme. Items 2, 3
816
+ and 9 of 0.1.1.
817
+ **Why.** The first host's design system swaps tokens a frame after its scheme flips, so a frame
818
+ read stale colors; its streamed widgets changed id and jumped when they mounted again; its
819
+ palette needs none of Porthole's chart colors. Each has a React test in a real browser (an async
820
+ restyle, a rename then a remount, an alias) and the agreement test covers a theme without the
821
+ chart colors and the accent.
822
+ **Rejected.** A fixed delay in milliseconds (frames are what a restyle waits on).
823
+ **Revisit when** a host restyles later than its next frame without being able to signal it.
824
+
825
+ ## P044 · 2026-10-11 · `/server` runs on any web-standard runtime: its hashing is plain JavaScript · Accepted
826
+
827
+ **Decision.** `/server` loads no Node module. SHA-256 and HMAC-SHA-256 are plain JavaScript
828
+ (`src/server/crypto.ts`, FIPS 180-4 and RFC 2104), randomness comes from
829
+ `crypto.getRandomValues`, and base64url and the constant-time compare are its own. It runs on
830
+ Cloudflare Workers without `nodejs_compat`, and on Node, Deno and Bun. Its outputs are byte for
831
+ byte those of 0.1.1's `node:crypto`, so labels, tickets and cookies issued before still hold.
832
+ Item 1 of 0.1.2.
833
+ **Why.** Web Crypto's HMAC is asynchronous, and `originFor` is synchronous (a host computes an
834
+ origin while it renders): async hashing would have changed the API. The inputs are a few dozen
835
+ bytes (a scope, a ticket, a cookie), so speed doesn't matter. `nodejs_compat` is a flag a host
836
+ may not want, and it pulls in polyfills. Tested three ways:
837
+ - the standard vectors (NIST, RFC 4231) and agreement with `node:crypto` (`test/crypto.test.ts`);
838
+ - a static check that nothing `/server` loads imports a `node:` module or uses `Buffer`,
839
+ `process` or `require`;
840
+ - a Worker bundled from `/server`, run in workerd (Miniflare) with no compatibility flags and a
841
+ real D1 database (`test/workers.test.ts`).
842
+
843
+ The Worker test covers the flat names, a ticket once, the cookie on a reload, a race, an
844
+ anonymous link, a revocation, and the reserved namespaces.
845
+ **Rejected.** `nodejs_compat` (the host's flag to set, not ours to require); Web Crypto (async);
846
+ a hashing dependency (P041: none).
847
+ **Revisit when** Web Crypto has a synchronous HMAC, or a profile shows the hashing costs.
848
+
849
+ ## P045 · 2026-10-11 · On Workers, tickets live in D1, taken by one `DELETE … RETURNING`; never in KV · Accepted
850
+
851
+ **Decision.** `d1TicketStore(db)` keeps tickets in a D1 table (`d1TicketSchema()`, applied once as
852
+ a migration). A ticket is taken by a single statement, `DELETE … WHERE id = ? RETURNING`, so two
853
+ requests racing for one ticket can't both get it. Expired tickets are swept as new ones go in.
854
+ The rows hold the ticket's hash only, as in memory. Sessions stay signed cookies with no store;
855
+ the host revokes them through its per-request check (P050). The in-memory store remains the
856
+ default. Item 2 of 0.1.2.
857
+ **Why.** A ticket must open its content once, across every isolate of a Worker. D1 runs writes
858
+ on its single primary, one statement at a time. Workers KV is eventually consistent (a write or a
859
+ delete may take up to a minute to be seen elsewhere) and has no atomic read-and-delete, so a
860
+ ticket read in two places could be used twice. Measured on real D1 in workerd: two requests
861
+ racing for a ticket get one 200 and one 401. Unit tests run the same SQL on SQLite (expiry, the
862
+ sweep, table names).
863
+ **Rejected.** KV (above). A Durable Object store would be just as strict, but each host would
864
+ need a class binding and its migrations; the host this was built for already has D1. A session
865
+ store (a cookie is enough; revocation is the host's check).
866
+ **Revisit when** a host uses D1 read replicas through the Sessions API. A `DELETE` is a write and
867
+ goes to the primary, but a store that read first would not be safe.
868
+
869
+ ## P046 · 2026-10-11 · Anonymous grants for public links · Accepted
870
+
871
+ **Decision.** `issue(instance, { anonymous: true, subject? })` mints a ticket that names no
872
+ viewer, for public links. Only documents qualify (a shell, an artifact version, the MCP Apps
873
+ sandbox proxy), never a url-mode app. Every other rule is the same as for a named viewer:
874
+ - the ticket works once, for a short time, and is bound to one instance (for an artifact, one
875
+ version);
876
+ - the cookie it buys is `__Host-`, `HttpOnly`, of that origin and that instance only;
877
+ - only the host origins may frame the content.
878
+
879
+ The host's check sees `anonymous: true` and an empty viewer. A grant naming both a viewer and
880
+ `anonymous` is refused. Item 3 of 0.1.2.
881
+ **Risk it re-opens.** Whoever reaches the host's public route gets the content. That route is
882
+ the only gate, because Porthole can't tell a link's holder from anyone else. A cookie bought
883
+ anonymously keeps opening that instance until its `sessionSeconds` end. Unsharing takes effect
884
+ at once only if the host's check looks at the share: give the grant the share id as its
885
+ `subject`, and deny it once unshared (P050). Anyone can make the public route mint tickets, so
886
+ the host rate-limits it (tickets expire and are swept).
887
+ **Bounded by** the server tests (once per ticket, never another instance, never an app, a viewer
888
+ and `anonymous` together refused) and the Worker test.
889
+ **Rejected.**
890
+ - A long-lived public URL without a ticket: a reusable bearer URL that shows up in logs and
891
+ referrers and never expires.
892
+ - Public content without a cookie: a reload would show nothing.
893
+ - Anonymous url-mode apps: every request to someone's running app, with its data and sessions,
894
+ would be open.
895
+
896
+ **Revisit when** a host needs anonymous access to an app (then a decision of its own).
897
+
898
+ ## P047 · 2026-10-11 · A host may name content origins itself; other servers' namespaces are reserved · Accepted
899
+
900
+ **Decision.** `content.name(scope)` gives the label a scope is served on, and Porthole checks the
901
+ answer:
902
+ - it is one lowercase DNS label (`a-z0-9-`, at most 63, no hyphen at either end);
903
+ - it is not in an IDNA form (`xn--`, any `??--`);
904
+ - it is not `reserved`.
905
+
906
+ `prefixedName("a-")` is the one-to-one naming most hosts want (`a-<scope>`, scopes already
907
+ label-safe, anything else throws rather than fold into another scope's name). `content.reserved`
908
+ lists labels that belong to other servers under the same domain: `f`, for a server whose domain
909
+ is `f.<domain>` and whose origins are `<label>.f.<domain>`, or `www`. This server never names
910
+ them, and a request for one, or for anything under one, gets null and goes to the host's routes.
911
+ Any other deeper name still gets this server's 404. The keyed label (24 base32 characters) stays
912
+ the default. A keyed label never has a hyphen and a prefixed one always does, so the two
913
+ namespaces never meet. Item 4 of 0.1.2.
914
+ **Why.** A host with ids of its own wants stable, readable origins (`a-<id>.example-usercontent.com`)
915
+ next to another host's under the same content domain (`<instance>.f.example-usercontent.com`).
916
+ **Risks.**
917
+ - A host's names are guessable. Nothing opens without a ticket or a cookie, but the keyed
918
+ default also hides which scopes exist.
919
+ - A naming that maps two scopes to one label merges their origins (storage, cookies). The helper
920
+ is one-to-one; a host's own function must be too.
921
+ - Every label under one registrable domain is same-site with every other. `__Host-` cookies
922
+ can't be set across labels, but SameSite doesn't separate them. Put the content domain on the
923
+ Public Suffix List, and each nested namespace with it (`example-usercontent.com` and
924
+ `f.example-usercontent.com`), so each origin is a site of its own.
925
+
926
+ **Rejected.** Free-form names (dots would nest namespaces, uppercase and IDNA forms would make
927
+ names that read as something else); a registry of names in Porthole (the host's ids are the
928
+ host's).
929
+ **Revisit when** the Public Suffix List entry lands: then the same-site note above can go.
930
+
931
+ ## P048 · 2026-10-11 · Artifact pages reach library CDNs only by the host's config · Accepted
932
+
933
+ **Decision.** For artifact pages to load libraries, a host passes `egress: LIBRARY_CDNS` to
934
+ `presets.artifact`. For served pages it does so under a config name of its own that the
935
+ instance's `document.preset` selects (the harness's `artifactCdn`). Nothing else changes. The
936
+ default artifact preset stays closed (P022). Item 5 of 0.1.2.
937
+ **Why.** Agent-made pages often load a chart or diagram library. Opening the CDNs is a fair
938
+ choice for a host to make, but it is the host's (P022). Tested on all three engines, both opaque
939
+ and served in domain mode (`leak/cdn.spec.ts`):
940
+ - a page's `<script src>` from a listed CDN loads;
941
+ - one from any other origin doesn't reach the network;
942
+ - under the default preset, nothing does.
943
+
944
+ The CDN is routed inside the test, so nothing leaves the machine, and a request reaches the
945
+ route only if the frame's CSP let it out.
946
+ **Risk (the host's, as P022 says).** Code from the CDNs is code the host doesn't control (a
947
+ compromised or typo-squatted package runs in the frame). It still runs inside the sandbox and
948
+ egress, but a path requested from an open CDN is a narrow write channel whose reader is the CDN's
949
+ operator. The manual teaches pinning versions.
950
+ **Revisit when** hosts want subresource integrity required on CDN scripts (the manual could
951
+ demand it, and the shell could check it).
952
+
953
+ ## P049 · 2026-10-11 · A url-mode frame may be delegated clipboard writes and full screen · Accepted
954
+
955
+ **Decision.** `presets.url({ permissions: ["clipboard-write", "fullscreen"] })` (config
956
+ `permissions`, url mode only, from `URL_PERMISSIONS`) sets the frame's `allow` attribute. By
957
+ default it delegates nothing (`allow=""`), and the other modes always delegate nothing. Item 6 of
958
+ 0.1.2 (the first host's backlog).
959
+ **Risk it re-opens.**
960
+ - Clipboard writes: the app can put any text on the clipboard in a click (pastejacking: a
961
+ command that looks like one thing and is another).
962
+ - Full screen: the app can cover the whole screen and draw a fake host or OS. The browser shows
963
+ its own notice, and Esc leaves.
964
+
965
+ Both need the person's click. A url-mode app is a running app the host chose to show, on an
966
+ origin of its own.
967
+ **Measured** (`leak/served/permissions.spec.ts`, with the clipboard permission granted, so the
968
+ delegation alone stands in the way):
969
+ - Full screen follows the delegation on Chromium, WebKit and Firefox (`fullscreenEnabled`, and
970
+ `requestFullscreen` in a click).
971
+ - Clipboard writes follow it only on Chromium. WebKit and Firefox let any frame write the
972
+ clipboard in a click, delegated or not, so there default-off is no wall.
973
+
974
+ Negative control: `url-permissions` (delegate both by default) fails the default case on all
975
+ three engines.
976
+ **Rejected.** `clipboard-read` (reads what the person copied anywhere); camera, microphone,
977
+ location and the rest (a decision each, if ever); delegation in opaque or domain mode
978
+ (agent-written HTML).
979
+ **Revisit when** WebKit or Firefox enforce the clipboard-write policy feature (then the
980
+ measurement changes), or a host needs another feature.
981
+
982
+ ## P050 · 2026-10-11 · The host's check runs on every request; Porthole carries the host's decision and never makes it · Accepted
983
+
984
+ **Decision.** `ServerOptions.authorize = { check(request), ttlSeconds? }`. Porthole calls it after
985
+ a ticket or cookie verifies and before anything of the content is served: a document's first
986
+ load and its reloads, a url-mode app's entry and its every request, `/__porthole/session`, and
987
+ `access()` (a host's WebSocket path).
988
+ - **Input.** The scope, the instance, the viewer (or `anonymous`), the request, and the host's
989
+ own opaque `subject`. The host sets the subject at `issue` (512 characters at most); it is
990
+ signed into the ticket and the cookie and never interpreted.
991
+ - **Answer.** `{ allow: true }` or `{ allow: false, code? }`. A deny, or a check that throws, is
992
+ a 403 status page with reason `denied` and the host's code (`[a-z0-9-]{1,40}`, dropped
993
+ otherwise). The page tells the display, whose `outcome` is `blocked: denied` with the code,
994
+ even after `loaded`.
995
+ - **Caching.** None by default. `ttlSeconds` (at most 300) reuses an answer for the same
996
+ instance, viewer, subject and session.
997
+
998
+ The scope line, now in PRINCIPLE 8 and ARCHITECTURE: authentication and authorization *policy*
999
+ are the host's; carrying the host's decision safely onto the content domain is Porthole's
1000
+ *mechanism*. Item 7 of 0.1.2.
1001
+ **Why.** A session cookie good for an hour outlived a revoked share or a member who left.
1002
+ Sessions are stateless (P045), so revocation needed either a session store in Porthole (and with
1003
+ it a notion of who someone is) or the host's own check. The check lets the host apply its own
1004
+ policy (shares, membership, deletion) while Porthole knows nobody.
1005
+ **Measured** on all three engines (`leak/served/authorize.spec.ts`):
1006
+ - A framed url-mode app mid-session: its very next request is a 403, its reload shows the denied
1007
+ page, and the host gets `[loaded, blocked: denied (revoked)]`. This runs on Chromium and
1008
+ Firefox; WebKit keeps no cookie in a cross-site frame (P025), so a framed app never has a
1009
+ session there.
1010
+ - A framed page's next load (WebKit: `session`, for the same reason).
1011
+ - A revoked subject's ticket, framed: denied on all three.
1012
+ - In a tab of its own, an app's next request and a page's reload: denied on all three.
1013
+
1014
+ Negative control: `authorize` (the harness's server without the check) fails every case except
1015
+ WebKit's framed page, which expects `session` either way.
1016
+ **Limits.**
1017
+ - What is already loaded keeps running until its next request. An app's own fetches get a 403
1018
+ the app must handle; the host hears of it at the next load.
1019
+ - A `ttlSeconds` delays revocation by up to that long.
1020
+ - The check's latency is on every request (a host caches its own lookups, or sets a short TTL).
1021
+
1022
+ Found on the way: the url-mode entry page could leave for the app before its own load, and then
1023
+ the host never counted that load and never decided `loaded`. It happened reliably in the full
1024
+ suite on Chromium under load. The entry page now leaves only once it has loaded.
1025
+
1026
+ **Rejected.** Identity, sessions or revocation lists inside Porthole (PRINCIPLE 8); short
1027
+ sessions as revocation (still a window, and more tickets).
1028
+ **Revisit when** a host needs Porthole to push a revocation into a loaded frame (it can't today:
1029
+ a cross-origin frame is the content's).
1030
+
1031
+ ## P051 · 2026-10-11 · Content never writes the clipboard · Accepted
1032
+
1033
+ **Decision.** Content cannot write to the clipboard by itself, on any engine. The first host's
1034
+ owner decided this.
1035
+
1036
+ The walls are:
1037
+ - The frame's `allow` denies `clipboard-read` and `clipboard-write` by name.
1038
+ - The shell makes the async Clipboard API refuse (`write`, `writeText`, `read`, `readText`).
1039
+ - `execCommand` refuses `copy`, `cut` and `paste`.
1040
+ - Copy and cut events are stopped at the window before any listener of the content's runs. A
1041
+ copy the person makes still copies their selection as the browser makes it.
1042
+
1043
+ Copy buttons go through the host instead (P052). Url mode is unchanged: an app is someone's own
1044
+ running app, and only the host can delegate clipboard writes to it (P049). Item 1 of 0.1.3.
1045
+
1046
+ **Why.** Measured on 0.1.2: a click in an opaque widget wrote the clipboard on all three engines.
1047
+ - WebKit and Firefox allowed the async API.
1048
+ - All three allowed the copy command and copy listeners that swap what the person copied.
1049
+
1050
+ Pastejacking into a terminal is a real risk for coding-agent hosts. A "copy this command" button
1051
+ that copies something else needs only a click.
1052
+
1053
+ **Measured** (`13.clipboard-api`, `13.clipboard-command`, `13.clipboard-event`; the oracle is the
1054
+ person's own clipboard, written and pasted on the host's page): every wall holds on Chromium,
1055
+ WebKit and Firefox. The negative control `clipboard` takes out the shell's walls:
1056
+ - The async API then writes on WebKit and Firefox. Chromium still refuses it through the `allow`
1057
+ denial.
1058
+ - The copy command and copy listeners then write on all three.
1059
+
1060
+ Moving the person's selection on their copy keystroke changed nothing on any engine, so no wall
1061
+ is claimed for it.
1062
+
1063
+ **What stays open.** Text hidden by CSS inside what the person selects goes with their copy. That
1064
+ copy is the browser's own, which the shell doesn't rewrite. Hosts point people at copy buttons,
1065
+ whose toast shows exactly what was copied.
1066
+
1067
+ **Rejected.**
1068
+ - A per-widget opt-in for direct writes: any widget would opt in.
1069
+ - Rewriting the person's own copy to "visible text only": an arms race with CSS, and it breaks
1070
+ honest copies.
1071
+
1072
+ **Revisit when** browsers let a page veto a cross-origin frame's clipboard writes (then the shell
1073
+ wall is a second one), or the person's copy can be shown before it lands.
1074
+
1075
+ ## P052 · 2026-10-11 · Copy buttons call `copyText`: the host copies, and shows what · Accepted
1076
+
1077
+ **Decision.** A new capability, `copyText({ text })`:
1078
+ - **Gates:** a click (`user-activation`, which the shell and the host check), and 30 a minute.
1079
+ - **Limit:** at most `MAX_COPY_CHARS` (20,000) characters.
1080
+ - **Served by the host:** its handler writes the clipboard on the host's own page (the core's
1081
+ `writeClipboard` helper tries the async API, then the copy command) and shows the person what
1082
+ was copied.
1083
+ - **Reached by:** the shell global `copyText(text)` in inline widgets and artifact pages, or
1084
+ Porthole's own request `porthole/copy-text` (MCP Apps has no clipboard request).
1085
+
1086
+ The React binding serves it by default. The provider's handler writes the clipboard and shows a
1087
+ small toast (`CopyToast`) with the text, clipped to four lines and 280 characters, plus its length
1088
+ when clipped. If the write failed, the toast says so and offers a Copy button: a click on the
1089
+ host's own page, which writes it. A host's own `copyText` handler replaces it.
1090
+
1091
+ The manual tells authors to use `copyText` for every copy button; the widget topic says the
1092
+ clipboard is closed to them otherwise. Item 1 of 0.1.3.
1093
+
1094
+ **Why.**
1095
+ - Copy buttons are common in agent-made content.
1096
+ - The host's page can write the clipboard in the person's click, because a click in a frame gives
1097
+ its ancestors user activation (measured on all three engines in 0.1.2, and the copy itself in
1098
+ 0.1.3).
1099
+ - Showing the text lets the person see what they are about to paste.
1100
+ - No confirmation dialog: a copy has no effect until the person pastes, and the toast shows it
1101
+ then.
1102
+
1103
+ **Measured:**
1104
+ - `12.copy-text`: a call on load is refused, and the whole text reaches the handler from a click.
1105
+ - `leak/react.spec.ts`: the toast shows the clipped text, the person's clipboard holds the whole
1106
+ text, and a failed write is recovered by the toast's own button. All on three engines.
1107
+
1108
+ **Rejected.** A confirm gate by default (too heavy for a copy; a host can add one, and its dialog
1109
+ shows the text); writing from inside the frame (P051).
1110
+ **Revisit when** MCP Apps defines a clipboard request (then the adapter maps it).
1111
+
1112
+ ## P053 · 2026-10-11 · Annotations are mechanism: the host's comments, anchored in content it can't trust · Accepted
1113
+
1114
+ **Decision.** A new optional capability, `annotate`, declared by the host. It is for inline widgets
1115
+ and artifact pages, and for MCP Apps views where the host chooses. With it, the host can:
1116
+ - let a person pick an element or select text to comment on;
1117
+ - mark existing anchors over the content;
1118
+ - bring one into view;
1119
+ - learn where each anchor is now, or that it is orphaned.
1120
+
1121
+ Policy (who may comment, where comments are stored, what they look like next to the frame) stays
1122
+ the host's (PRINCIPLE 8). The runtime is spliced into the shell only where `annotate` is declared.
1123
+
1124
+ **Messages.** Porthole's own (P002):
1125
+ - Host to frame: `porthole/annotate.start | stop | show | focus | clear`.
1126
+ - Frame to host: `selected` (an anchor plus a rect in the frame's viewport), `unselected`,
1127
+ `cancelled` and `anchored`.
1128
+
1129
+ **Anchors.** W3C Web Annotation selectors, so an anchor can be found again after the content
1130
+ changes: `CssSelector`, `TextQuoteSelector` (with prefix and suffix) and `TextPositionSelector`,
1131
+ at most one of each. Finding one again is best-effort, with an explicit `orphaned`.
1132
+
1133
+ **Item.** 2 of 0.1.3. The host this matches (an artifact viewer with its own injected bridge) can
1134
+ drop its bridge. Its features are covered:
1135
+ - element grab with a hover outline and a name tag;
1136
+ - selection to comment, with a host-drawn affordance at the reported rect;
1137
+ - jump with a flash;
1138
+ - live updates.
1139
+
1140
+ It also gains persistent marks and re-anchoring across versions, which it lacked.
1141
+
1142
+ **Trust.** The shell shares the content's realm, so content can interfere or fake any report. The
1143
+ host therefore:
1144
+ - takes a selection only while it asked for that kind, a cancel only while picking, and results
1145
+ only for its own marks;
1146
+ - rebuilds every report from known fields within `ANNOTATION_LIMITS` and drops the rest;
1147
+ - rate-limits reports at 120 a minute through the bridge.
1148
+
1149
+ What a frame reports is a UI hint, never a permission. It decides where a comment is drawn,
1150
+ never who may see or do anything.
1151
+
1152
+ **Measured** (`leak/annotate.spec.ts`, on Chromium, WebKit and Firefox):
1153
+ - picking, cancelling, selecting, and both together;
1154
+ - marks, focus and clear;
1155
+ - re-anchoring on a revised page (by id, by moved words, by respaced words, and a deleted one
1156
+ orphaned);
1157
+ - live changes reported only when they change;
1158
+ - an MCP Apps view;
1159
+ - a hostile page faking every kind of report. The negative control `annotation-checks` fails it.
1160
+
1161
+ **Rejected.**
1162
+ - Wrapping anchors in `<mark>` elements: it rewrites the content's DOM and breaks its scripts and
1163
+ styles.
1164
+ - The CSS Custom Highlight API alone: it can't outline elements and isn't in every engine
1165
+ Porthole supports.
1166
+ - XPath selectors: brittle to the edits agents make.
1167
+
1168
+ **Revisit when** content needs to cooperate (an author-declared anchor id API), or MCP Apps defines
1169
+ annotations.
1170
+
1171
+ ## P054 · 2026-10-11 · On a page with a nonce-based script-src, the shell carries the host's nonce · Accepted
1172
+
1173
+ **Decision.** A host whose page sends a nonce-based `script-src` (no `'unsafe-inline'`) passes its
1174
+ nonce to Porthole: `HostOptions.nonce` (a string, or a function read each time a frame starts),
1175
+ or `PortholeProvider`'s `nonce`. An opaque frame's shell document then carries it on its
1176
+ `<script>` and `<style>`. The shell gives it to every script it runs for the content, and to the
1177
+ annotation layer's style. Item 5 of 0.1.4 (the second host's friction).
1178
+
1179
+ **Why.** A `srcdoc` frame inherits the embedding page's whole policy, so a strict page blocked the
1180
+ shell. The host this was built for had to run the conformance suite on a stripped page. Served
1181
+ frames are navigations to an origin of their own, so they inherit nothing and need no nonce.
1182
+
1183
+ **Measured.** The whole leak suite runs again on a reference page whose policy is
1184
+ `script-src 'nonce-…'; object-src 'none'; base-uri 'none'` (`leak/strict-csp.spec.ts`). It
1185
+ passes on Chromium, WebKit and Firefox. Its negative control `nonce` (the shell without the
1186
+ nonce) fails it.
1187
+
1188
+ **Risk.** The frame learns the page's nonce:
1189
+ - its scripts carry it;
1190
+ - the shell's own script element is in its document.
1191
+
1192
+ A nonce is per response. It helps an attacker only together with a markup injection into that
1193
+ same response of the host's page, which the content can't make by itself. A host that also
1194
+ renders content-supplied HTML in its own page has a bigger problem than the nonce.
1195
+
1196
+ **Limits.** Inline event handlers in content (`onclick="…"`) stay blocked by such a policy, as they
1197
+ are on the page itself; scripts and `addEventListener` work. A nonce-based `style-src` would also
1198
+ block the content's inline styles; the shell doesn't rewrite those.
1199
+
1200
+ **Rejected.**
1201
+ - Hash-based sources Porthole publishes for its shell: the content's scripts would still be
1202
+ blocked.
1203
+ - `data:` or `blob:` frames to escape inheritance: they inherit too, or need `frame-src` entries.
1204
+ - Requiring `'strict-dynamic'`: it's the host's policy to choose.
1205
+
1206
+ **Revisit when** browsers let an embedding page give a `srcdoc` frame a policy of its own instead
1207
+ of its own.
1208
+
1209
+ ## P055 · 2026-10-11 · What the content did wrong and what failed on the host's side are separate · Accepted
1210
+
1211
+ **Decision.** A display reports `contentError` (what the content threw, what the sandbox blocked,
1212
+ that it kept navigating its frame away: the agent's to hear and fix) apart from `hostError`
1213
+ (`{ message, kind }`). `kind` is `locate` when the host's locator rejected; in the React binding
1214
+ it can also be `mount` (a display that couldn't be made) or `annotations` (an annotation call
1215
+ refused). A failed locate also ends the display's load as `outcome: blocked, reason: locate`.
1216
+
1217
+ `error` still fires for both, now with `source: "content" | "host"`, so hosts that listened there
1218
+ keep working. The React binding adds `onContentError` and `onHostError`; `onError` now gets
1219
+ `(message, source)`. Item 1 of 0.1.4 (both hosts' friction).
1220
+
1221
+ **Why.** Both hosts forward the content's errors to the agent. A failed locate (the server down,
1222
+ access gone) is the host's own, and the agent would have tried to fix a widget for it. One host
1223
+ tagged its own messages to tell them apart; the other caught inside its locator. A failed locate
1224
+ also used to leave no outcome at all.
1225
+
1226
+ **Tested.** On all three engines:
1227
+ - core (`leak/served/outcome.spec.ts`): a failing locator gives `blocked: locate` and one host
1228
+ error, no content error; a throwing widget gives a content error only;
1229
+ - React (`leak/react.spec.ts`): a throwing widget goes to `onContentError`, a display with an
1230
+ unserved capability to `onHostError` (`mount`), each with its source on `onError`.
1231
+
1232
+ Found on the way: on WebKit and Firefox a content error was reported without its message, because
1233
+ their `error.stack` doesn't start with it the way V8's does, so the agent heard only a stack. The
1234
+ shell now puts the message first there. Measured: WebKit mutes an opaque (`srcdoc`) frame's script
1235
+ errors to "Script error.", so there the host gets a content error without its message; served
1236
+ frames carry it on all three engines.
1237
+ **Rejected.** Dropping host failures from `error` (it would break hosts that relied on seeing a
1238
+ failed locate there).
1239
+ **Revisit when** `error` can be removed (a major version).
1240
+
1241
+ ## P056 · 2026-10-11 · `/server` routing a host can rely on · Accepted
1242
+
1243
+ **Decision.** Items 3, 4 and 8 of 0.1.4, and the second host's headers:
1244
+ - **Reserved labels.** `content.reserved` takes whole labels, prefixes ending in `*` (`p-*`), or a
1245
+ function of the label. A function that throws reserves the label, so the request goes to the
1246
+ host's routes rather than being served as content. Requests in a reserved namespace return
1247
+ null, so a host routes its own `p-`/`w-` labels without wrapping `handle`. A host's naming may
1248
+ never produce a reserved label.
1249
+ - **Host header.** The label is read from the request URL. If the Host header names another host
1250
+ and either of the two is under the content domain, the answer is a 400 page. A Host that is a
1251
+ local address (an IP literal, `localhost`, as Miniflare's `dispatchFetch` or a dev proxy sends)
1252
+ is not a name and isn't compared.
1253
+ - **No upstream.** A server without an `upstream` serves only Porthole's own paths
1254
+ (`/__porthole/…`). Any other path gets a plain 404, not url mode's 401 "Open this from the
1255
+ app".
1256
+ - **Indexing.** Every answer of Porthole's own says `x-robots-tag: noindex, nofollow`. Content
1257
+ origins are behind tickets.
1258
+ - **Frame sources.** `embedderFrameSourcesOf(configs)` returns the embedding page's `frame-src`
1259
+ sources as a list, for a host that writes its own policy around them.
1260
+
1261
+ **Why.**
1262
+ - One host shares its content domain with its own preview gateway on `p-…` labels. It had to
1263
+ route by label before `handle`.
1264
+ - A Node adapter (Hono's `getRequestListener`) builds the URL from an absolute-form target
1265
+ (`GET http://other/…`) while the host, and any cache in front of it, routes by Host. A crafted
1266
+ request could get content served under another host's name.
1267
+ - The second host rewrote every non-Porthole path to `/__porthole/not-found` to avoid the
1268
+ misleading 401, and sliced `embedderCsp`'s string to merge it.
1269
+
1270
+ **Tested.** Server tests cover:
1271
+ - prefixes, a predicate, and a throwing predicate;
1272
+ - names that would be reserved;
1273
+ - a crafted Host, an absolute-form target, an agreeing Host with a default port, and a local
1274
+ address;
1275
+ - the 404 without an upstream;
1276
+ - the noindex header.
1277
+
1278
+ The Workers test (where Miniflare sends a local Host) still passes.
1279
+ **Rejected.** Reading the label from the Host header instead: on Workers the URL is the routed
1280
+ truth, and test runners send a local Host. Refusing every mismatch: it would refuse those runners.
1281
+ **Revisit when** a host needs Porthole behind a proxy that rewrites Host (then
1282
+ `X-Forwarded-Host`, by a decision).
1283
+
1284
+ ## P057 · 2026-10-11 · Hosts lay out, identify and annotate displays as they write them · Accepted
1285
+
1286
+ **Decision.** Items 2, 6, 7, 9 and 10 of 0.1.4:
1287
+ - **Shared storage, in the manual.** A config may say what the displays sharing an origin share
1288
+ (`storage: { shared: "conversation" }`, domain isolation only). The manual then says storage is
1289
+ shared across that scope and that keys need a prefix of the display's own.
1290
+ - **Fill.** `MountOptions.fill` (React: `fill`) makes the frame fill its container, with the content
1291
+ scrolling inside, instead of growing with the content. Sizes are still reported.
1292
+ - **Identity in React.** `config`, `instance`, `annotations` and the provider's `hostInfo` are
1293
+ compared by value. The provider's `locator` (like `confirm`) is read when used. An inline object
1294
+ no longer starts a running frame again.
1295
+ - **Annotations settle.** Nothing is reported before the content is in place, nor while a run of
1296
+ changes settles. A `focus` asked for meanwhile waits, then scrolls and reports, so a focus right
1297
+ after `show` says where the mark is, never `orphaned` first. React's `focusedAnnotation` takes
1298
+ `{ id, key }` to focus the same mark again.
1299
+ - **Served shells in the conformance suite.** `ServedConformanceHost.show` gets `options.content`
1300
+ for each shell, and a host that speaks to the shell sends it. A shell draws nothing, and reports
1301
+ no height, until it has content.
1302
+
1303
+ **Why.**
1304
+ - One host scopes storage per conversation, so its widgets overwrote each other's keys untold.
1305
+ - The second host needed `!important` CSS on Porthole's internal attributes to make an artifact
1306
+ fill its viewer.
1307
+ - Hosts memoized config, instance, hostInfo and marks by hand, and one compared marks as JSON
1308
+ itself.
1309
+ - Both hosts worked around a focus that said `orphaned` first.
1310
+ - The first host's conformance host had to give shells content by itself.
1311
+
1312
+ **Tested.** On all three engines:
1313
+ - React: inline objects across re-renders keep one display; an artifact fills its 300 px box and
1314
+ scrolls inside, and without `fill` grows to its cap.
1315
+ - Annotations: a focus right after `show` gives two reports, both attached, the focused mark in
1316
+ view; marks shown before the content are reported once it arrives.
1317
+ - The served shell cases pass against a `createHost` host whose frames take their content's height.
1318
+ Without content they time out clicking (checked by hand).
1319
+
1320
+ Unit tests cover the storage note and its validation.
1321
+
1322
+ **Rejected.**
1323
+ - A dev-only warning for unstable props: comparing by value removes the need.
1324
+ - `fill` in the config: it is the host's layout, per mount.
1325
+
1326
+ **Revisit when** a host's config or instance carries something that isn't plain data (then
1327
+ compare it otherwise).
1328
+
1329
+ ## P058 · 2026-10-11 · An opaque frame is as closed as its page; the suite says when a case can't be observed · Accepted
1330
+
1331
+ **Decision.** This is now documented in ARCHITECTURE (the network boundary) and DESIGN: a `srcdoc`
1332
+ frame inherits the embedding page's whole policy along with its own. On a page with
1333
+ `default-src 'none'` and a tight `connect-src` or `img-src`, opaque-mode content is more closed
1334
+ than its config says. On such a page, hosts should prefer domain mode, whose frames inherit nothing.
1335
+
1336
+ The conformance suite detects this. When a positive control (`1.connect`'s allowed fetch,
1337
+ `2.resources`'s allowed image) fails, the probe loads from the allowed origin once more and
1338
+ watches for a CSP violation. A violation there can't come from the frame's own policy, which
1339
+ allows that origin, so it is the page's. The probe then reports itself **inapplicable on this
1340
+ host** (skipped, with the reason) instead of failing.
1341
+
1342
+ `12.no-confirm` does the same on a host that always has a confirm function (a default dialog), so
1343
+ a display without one can't be shown. When it is asked anyway, it answers no and reports
1344
+ inapplicable.
1345
+
1346
+ Probes get `context.inapplicable(reason)`. Item 3 of 0.1.5 (the second host's adoption).
1347
+
1348
+ **Why.** The second host's real page sends `default-src 'none'` and `connect-src 'self'`. Its
1349
+ suite failed two positive controls that prove nothing about its isolation, and failed one case
1350
+ that its React host can't set up.
1351
+
1352
+ **Tested.** `leak/inherited-policy.spec.ts` runs the three cases against a reference page closed
1353
+ like the second host's (with its nonce, P054) and against a host that always confirms. Each
1354
+ reports inapplicable with its reason. Against the plain reference host they still run and pass.
1355
+
1356
+ **Rejected.** Skipping the positive controls by host configuration (a host would turn them off
1357
+ everywhere); parsing the page's policy from its headers (frames see what applies, headers don't
1358
+ show meta policies).
1359
+ **Revisit when** a browser lets an embedding page give a `srcdoc` frame a policy of its own.
1360
+
1361
+ ## P059 · 2026-10-11 · Passive content may load from any https site, by the host's explicit choice · Accepted
1362
+
1363
+ **Decision.** `egress.anyHttps` lists the passive kinds that may load from any https site:
1364
+ `img`, `media` and `font`. Those CSP directives get `https:`. Scripts, styles, data (`connect`),
1365
+ workers and frames never get it, and a config naming any other kind is refused. The manual and
1366
+ the tool description say "images … from any https site". Nothing is opened by default. The MCP
1367
+ sandbox proxy keeps the host config's choice; a view can't ask for it. Item 2 of 0.1.5.
1368
+
1369
+ **Risk it re-opens.**
1370
+ - Any site the content names learns that someone viewed it, and from which address.
1371
+ - The content can send data out in an image URL to a server of its choosing. Egress closes this
1372
+ channel by default (P006); this flag reopens it for these kinds.
1373
+ - An attacker who controls an image can show anything in the frame. The frame is still the
1374
+ content's own; nothing reaches the host.
1375
+
1376
+ **Why.** A host showing existing reports needs their hotlinked images to work.
1377
+
1378
+ **Tested** (`leak/cdn.spec.ts`, all three engines): with `anyHttps: ["img", "media"]`, a hotlinked
1379
+ image from a routed stranger's site loads. A script, a stylesheet and a fetch to the same site
1380
+ never reach it. Without the flag, the image doesn't either. The agreement test checks that only
1381
+ passive directives carry `https:` and that the manual says "from any https site" exactly when the
1382
+ CSP does.
1383
+
1384
+ **Rejected.**
1385
+ - `*` or `http:` (cleartext, and loopback/LAN reach).
1386
+ - Allowing it for styles: CSS can probe and leak through selectors.
1387
+ - Per-site lists: the host doesn't know the hotlinked sites.
1388
+
1389
+ **Revisit when** a host needs it for a kind that runs code: never by this flag.
1390
+
1391
+ ## P060 · 2026-10-11 · A wildcard over a public suffix is refused · Accepted
1392
+
1393
+ **Decision.** An origin source `scheme://*.<base>` is refused when `<base>` is a single label
1394
+ (`*.com`, `*.dev`; except `localhost`, for loopback development). It is also refused when
1395
+ `<base>` is on a built-in list of common public suffixes:
1396
+ - registries' second levels (`co.uk`, `com.au`, `co.jp`, …);
1397
+ - hosting platforms where anyone gets a name (`github.io`, `pages.dev`, `workers.dev`,
1398
+ `vercel.app`, `netlify.app`, `amazonaws.com`, `cloudfront.net`, …).
1399
+
1400
+ The refusal says why and what to write instead. A wildcard over a domain under such a suffix
1401
+ (`*.example.co.uk`, `*.me.github.io`) is fine. This applies to egress, content origins, and an
1402
+ MCP view's declared domains (dropped there). Item 1 of 0.1.5 (a bug: `https://*.com` was
1403
+ accepted).
1404
+
1405
+ **Why.** A wildcard over a public suffix opens every stranger's site, which is what egress control
1406
+ exists to prevent.
1407
+
1408
+ **Tested** in unit tests: the refusals, the reason, wildcards accepted under such suffixes, and a
1409
+ preset refusing a content origin.
1410
+
1411
+ **Rejected.** Shipping the whole Public Suffix List (about 200 KB, and a moving target). A wildcard
1412
+ should name the host's own domain; the list covers the common ways it wouldn't.
1413
+ **Revisit when** a host hits a suffix the list lacks (add it).
1414
+
1415
+ ## P061 · 2026-10-11 · A theme event means the theme changed · Accepted
1416
+
1417
+ **Decision.** The shell fires the config's theme event only when the scheme or an applied value
1418
+ changed since the content was last told:
1419
+ - never for the theme a display starts with, at mount;
1420
+ - never for a host context that repeats what the content has.
1421
+
1422
+ The React binding no longer calls `setTheme` or `refreshTheme` on mount: it does so only when
1423
+ `scheme` or `themeKey` differ from what the display was made with. Item 4 of 0.1.5
1424
+ (botiverse/porthole#3).
1425
+
1426
+ **Why.** Under the React binding, every mount called `setTheme` with the scheme the display
1427
+ already had, two animation frames later. When the handshake and the content's scripts finished
1428
+ within those frames, the content heard a "flip" to its own scheme. `[11.theme-flip]` failed about
1429
+ 1 run in 5 on the first host's React conformance host.
1430
+
1431
+ **Tested:**
1432
+ - `11.theme-flip` now asserts no event at mount and none for a `setTheme` to the same scheme,
1433
+ before it flips (all three engines, repeated).
1434
+ - A React test mounts a display listening for theme events, re-renders it unchanged, then flips
1435
+ it: exactly one event.
1436
+
1437
+ **Revisit when** content needs an event for values that change while the scheme doesn't (it gets
1438
+ one now; the rule is "changed", not "flipped").
1439
+
1440
+ ## P062 · 2026-10-11 · `served.domain.egress` holds a host to its own config's egress · Accepted
1441
+
1442
+ **Decision.** The served egress case no longer assumes images are closed. It tries a fetch, an
1443
+ image, an audio element, a script, a stylesheet and a nested frame toward the host's canaries. It
1444
+ reads the policy that applied to the document from the violations those attempts raise.
1445
+ - **Always.** Scripts, styles, fetches and frames never reach the canary, and none of their
1446
+ directives (nor `default-src`) opens a scheme.
1447
+ - **Images and media** must stay closed, unless the config opened them to any https site
1448
+ (`anyHttps`, P059). Then, with an https canary, the image must reach it. The case reports this
1449
+ as expected (an annotation naming P059), not as a failure, the way P058 reports a page's own
1450
+ policy. Media is measured, not asserted (engines differ on preloading).
1451
+
1452
+ Follow-up to 0.1.5 (the second host's adoption).
1453
+ **Why.** A host that opts into `anyHttps` had to skip the built-in case and write its own
1454
+ (`served.domain.egress-any-https`). Its logic is folded in here, so the host can drop it.
1455
+ **Tested.**
1456
+ - The case passes against the reference serving and a `createHost` host, both closed.
1457
+ - It passes against a host whose shells open images and media (`leak/served/any-https.spec.ts`,
1458
+ with its canaries on a routed https site). The image and the audio reached the site; the fetch,
1459
+ script, stylesheet and frame didn't.
1460
+
1461
+ ## P064 · 2026-10-11 · The manual states every sandbox limit content can trip over, and each claim has a browser check · Accepted
1462
+
1463
+ **Decision.** The `widget` topic's "The sandbox:" list replaces the old "Not allowed" line. Each
1464
+ line comes from `manualLimits(config)`, which derives every limit from the config: the sandbox
1465
+ tokens, declared capabilities and globals, egress, and isolation. Every limit has an id, and
1466
+ `LIMIT_CHECKS` names the browser checks that prove it. These are new item-14 probes (`14.<id>`)
1467
+ and served cases (`served.domain.address`, `served.domain.storage-scope`).
1468
+
1469
+ `test/limits.test.ts` fails in three cases:
1470
+ - a limit names no check;
1471
+ - a limit names a check no suite defines;
1472
+ - the list holds a line that isn't a limit.
1473
+
1474
+ So a new line can't go untested.
1475
+
1476
+ The limits:
1477
+ - **Failure:** what's blocked fails silently (worded by `errors` since 0.1.8: P067).
1478
+ - **Loading:** the network; assets inline.
1479
+ - **Shell-less behavior:** dialogs (confirm false, prompt null); print; downloads; forms;
1480
+ mailto:/tel:/sms: links; windows.
1481
+ - **Platform:** devices denied without asking (file inputs work); blob: workers (no service
1482
+ workers, no WebRTC); sound only from a click.
1483
+ - **Context:** the frame's address; the shell's own removals.
1484
+ - **Storage** (in the interactive topic): it can throw in domain mode.
1485
+
1486
+ Item 1 of 0.1.7 (the owner's benchmark against another artifact host's page contract).
1487
+
1488
+ **Why.** The manual named only some limits ("Not allowed: …"), and several silently change
1489
+ behavior. The advice to handle forms on submit would have failed: Chromium and Firefox fire no
1490
+ submit event in a sandbox without `allow-forms`.
1491
+
1492
+ **Measured** on Chromium, WebKit and Firefox, in opaque frames and on the served origin. Each
1493
+ difference is stated as the behavior safe on all three:
1494
+ - submit events fire on WebKit only;
1495
+ - `window.open` returns null on two engines and throws on Firefox;
1496
+ - an opaque frame's `navigator.serviceWorker` throws on reading on Chromium and WebKit, and
1497
+ `register` rejects on Firefox;
1498
+ - `play()` without a click is refused on Chromium and WebKit and allowed by Playwright's Firefox;
1499
+ - getUserMedia rejects with `NotFoundError` on headless Chromium and `NotAllowedError` elsewhere;
1500
+ - Web Share is absent on all three.
1501
+
1502
+ **Limits.** "Reads and writes can throw" (domain storage) and "sound only from a click" are
1503
+ browser policy that the suite can't force everywhere. Their checks prove the half that is
1504
+ observable (storage persists per scope; `play()` in a click plays). On a page whose own policy
1505
+ closes blob: workers, `14.workers` reports itself inapplicable (P058).
1506
+ **Rejected.** A hand-written list of limits (it drifts from the config); stating per-engine
1507
+ behavior (the manual is read once, for every engine).
1508
+ **Revisit when** a sandbox token or capability changes what content can do: add its limit and a
1509
+ check, or the unit test fails.
1510
+
1511
+ ## P065 · 2026-10-11 · A config says a whole-page viewer's shape, and the manual and behavior follow · Accepted
1512
+
1513
+ **Decision.** The first artifact-page host's served config made the manual say false things. New
1514
+ config fields express its shape, and each drives the manual and the behavior it implies:
1515
+ - **`layout.kind: "fill"`** (default `"flow"`):
1516
+ - Behavior: every mount fills its container (`MountOptions.fill`, React `fill`), and the
1517
+ content scrolls itself.
1518
+ - Manual: "about N px wide on a phone and as wide as the window on a desktop"; "as tall as the
1519
+ window: the page scrolls itself, and position: fixed and sticky work"; read the window's
1520
+ width. The card's height cap and the "No position: fixed" line go. The tool description
1521
+ says it fills the viewer.
1522
+ - **`theme.documents: "never"`** (pages only; refused for fragments, which take the theme they
1523
+ are drawn in):
1524
+ - Behavior: the frame always gets the light values and a light `color-scheme`, and the shell
1525
+ never themes the page even if it declares light and dark. `setTheme` doesn't reach it, so it
1526
+ hears no theme event.
1527
+ - Manual: it always shows in light; style it yourself; `prefers-color-scheme` may report the
1528
+ reader's system setting, so don't rely on it. The theme-change and redraw sentences go.
1529
+ - **`limits.maxBytes`** is stated in KB, or MB from a megabyte up ("Keep it under 10 MB.").
1530
+ - **`storage.scope`** (domain only, exclusive with `storage.shared`) names what one display's
1531
+ storage is kept for ("this artifact, across its versions"). It adds no prefix advice, since
1532
+ only one display uses the origin at a time.
1533
+ - **`errors: "agent" | "host" | "none"`** says where the content's errors go:
1534
+ - `agent`: "comes back to you, so you can fix it";
1535
+ - `host`: "goes to the app, not to you: test what you write";
1536
+ - `none`: no `reportError` (the preset drops it; validation keeps the two consistent), and
1537
+ "nothing reports what your code throws".
1538
+
1539
+ Absent, the old "reported to the app" stays.
1540
+
1541
+ Item 2 of 0.1.7.
1542
+
1543
+ **Measured** with the person on dark: inside a page's frame whose element says light,
1544
+ `prefers-color-scheme` reports light on WebKit and Firefox and dark on Chromium. Hence the
1545
+ manual's "may report the reader's system setting".
1546
+
1547
+ **Tested.**
1548
+ - Browser checks (all three engines, in `LIMIT_CHECKS` under `layout`, `theme` and `errors`):
1549
+ - `14.fill`: the frame keeps its box's 400 px, the page scrolls, a fixed bar stays at the top;
1550
+ - `14.theme-never`: light values, no `data-theme`, a light frame element, no theme event after
1551
+ a flip to dark;
1552
+ - `14.errors`: a throw reaches the host with `reportError`, and nothing reaches it with
1553
+ `errors: "none"`.
1554
+ - Unit tests: the manual and tool description for the viewer's config, every field's
1555
+ validation, and an agreement case.
1556
+
1557
+ **Rejected.**
1558
+ - Host-side patches to the manual's text: the config is the source.
1559
+ - Making `"never"` the default for pages: hosts that theme pages would lose it.
1560
+ **Revisit when** a host needs a theme of its own that isn't light (then a fixed scheme value).
1561
+
1562
+ ## P066 · 2026-10-12 · The host kit: an app writes its policy, Porthole the machinery around `/server` · Accepted
1563
+
1564
+ **Decision.** Porthole 0.2.0 takes in the host-side machinery both hosts wrote around `/server`
1565
+ (DESIGN § "The host kit"):
1566
+ - `createContentHost`: the routing middleware (fetch/Workers; reserved labels handed back), the
1567
+ issue endpoint, `meta`, `frameSources`, the domain helpers and their guards, and an optional
1568
+ health gate with an opaque fallback;
1569
+ - `@botiverse/porthole/node`: the Node adapter, WebSocket upgrades included, and the health
1570
+ probe;
1571
+ - the client's `httpLocator` and `servedOrOpaque`;
1572
+ - a framework-free conformance runner an app points at its real page (`defineHostConformance`);
1573
+ - a prod-shaped docker profile (`ops/prod-shape`) and CI on every push.
1574
+
1575
+ The app keeps four callbacks (`instance`, `access`, `authorize`, `source`), plus `entry.at` with
1576
+ redirect entry (P068), and its settings. Answers to the questions left open by the proposal:
1577
+ 1. **Clashes.** Porthole never serves a content domain that holds an app origin or may share
1578
+ its site. With the health gate (the opaque fallback) on, such settings are a lasting
1579
+ "unhealthy: misconfigured, <reason>": logged once with a stable event name
1580
+ (`porthole.content.misconfigured`, its `code`), `meta().served` false, frames opaque.
1581
+ Without it, `contentDomain`/`createContentHost` throw a `ContentDomainError` at startup.
1582
+ Fail closed either way; no separate switch.
1583
+ 2. **The issue body** is always `{ instance }`, the `InstanceRef` wire shape. The app's
1584
+ `instance` callback treats it as a proposal: it validates and normalizes it, derives the scope
1585
+ from what it names, and refuses anything else. App ids travel in the document's fields. The
1586
+ client never chooses an origin.
1587
+ 3. **The conformance harness has no React.** It is framework-free, on the core's `createHost`,
1588
+ loaded on the app's page as a script from the page's own origin under its nonce. An app's own
1589
+ frame components are covered by its own e2e; Porthole's React binding by Porthole's tests.
1590
+
1591
+ The Workers path uses no Node API (tested under workerd with D1); the health probe is optional
1592
+ there.
1593
+ **Why.**
1594
+ - The first host wrote 1,738 lines (domain, displays, health, the content router, the client's
1595
+ served frames, its conformance host); the second 2,305 (content service, content mapping, the
1596
+ frame API, the client frame, its conformance tools). Most of it is the same code.
1597
+ - Their copies already disagreed: the scheme from a public URL or from `*.localhost`; a clash
1598
+ logged and disabled, or failed closed. Neither was tested over TLS or on same-site siblings.
1599
+
1600
+ **Measured** in the prod shape (https, Caddy's internal CA, `*.content.test` siblings):
1601
+ - the whole leak suite and the served cases pass on Chromium, WebKit and Firefox, as on
1602
+ loopback;
1603
+ - a `Domain=content.test` cookie set by a sibling label reaches every content origin (same site
1604
+ without a Public Suffix List entry, P047); `__Host-` cookies can't be set that way, and nothing
1605
+ a sibling plants opens content;
1606
+ - Playwright's Firefox reads neither the system's trust store nor an enterprise policy, so its
1607
+ prod-shape project overrides certificate errors (pages stay https, secure contexts); Chromium
1608
+ and WebKit check the chain.
1609
+
1610
+ **Rejected.** A separate switch for clash handling (two ways to fail, one of them open); the
1611
+ app's own body fields on the wire (each app's client would choose what the server trusts);
1612
+ bundling React in the harness (it would test Porthole's binding on the app's page, not the app).
1613
+ **Revisit when** a third host needs something the four callbacks can't say.
1614
+
1615
+ ## P067 · 2026-10-12 · The manual's silent line says where a block is reported, by `errors` · Accepted
1616
+
1617
+ **Decision.** The `silent` limit (P064) follows the config's `errors` (P065), as the Errors line
1618
+ does:
1619
+ - no `reportError` (`errors: "none"`, or none declared): "fails silently … and nothing shows an
1620
+ error", as before;
1621
+ - `agent`: a block shows nothing on the page, and comes back to the agent as an error, so it
1622
+ checks them;
1623
+ - `host`: the app is told of each block, the agent isn't;
1624
+ - unset with `reportError` declared: each block is reported to the app.
1625
+
1626
+ `14.silent` now checks both halves: nothing is shown and nothing leaves, and the host hears of a
1627
+ block where `reportError` is declared and nothing where it isn't (`errors: "none"`). Issue #4,
1628
+ 0.1.8.
1629
+ **Why.** The shell reports every CSP violation through `reportError` ("Blocked by the sandbox:
1630
+ …"). For a host with `errors: "agent"` (Ferry), "nothing shows an error" was false, and it
1631
+ contradicted the manual's own Errors line. The check asserted only that no dialog opened, so it
1632
+ passed while the line claimed more than it proved.
1633
+ **Rejected.** Ending the line at "a blocked fetch rejects": the agent then isn't told that blocks
1634
+ come back to it, which is what makes it check them.
1635
+ **Revisit when** the shell reports blocks other than through `reportError`.
1636
+
1637
+ ## P068 · 2026-10-12 · Redirect entry: every instance has a clean address that opens directly · Accepted
1638
+
1639
+ **Decision.** With `entry` set, every instance has a clean, stable address (`urlOf(scope,
1640
+ path)`, `https://a-<id>.content.example.com/v/3`) that works when opened directly: a new tab, a
1641
+ reload, a bookmark, a link sent to someone with access.
1642
+ - A request on a clean address without a cookie for it is sent (302) to the app's authorize
1643
+ route (`ContentHost.authorize`, on an app origin) with a signed state: the label, the path, a
1644
+ nonce, five minutes. The content origin sets a cookie holding the nonce, in the session
1645
+ cookie's context (partitioned in a frame, `Lax` on a tab).
1646
+ - The route asks the app: `entry.at(scope, path)` (the path names the instance; the query never
1647
+ does) and `access`. Not signed in: on a tab, the app's own sign-in (`signIn(returnTo)`), which
1648
+ comes back to the route; in a frame, never the sign-in, a plain page asking for a new tab
1649
+ (`blocked: sign-in`). No access: the app's refusal page on a tab (`entry.refusal`), a plain
1650
+ page in a frame; never content.
1651
+ - Granted: a 302 to `/__porthole/entry?code=…` on the instance's own origin. The code is a
1652
+ one-time ticket (60 s) bound to the label, the path and the nonce. It is redeemed once, only on
1653
+ its label, only with the nonce's cookie; the session it buys is bound to the path; a 303 goes
1654
+ to the clean address.
1655
+ - A browser that keeps no cookie in a frame (WebKit, P025) arrives at the redeem without the
1656
+ nonce: the "open in a new tab" outcome (`blocked: cookies`). A path whose session still didn't
1657
+ come back after a redeem is caught on the next bounce (the nonce cookie marks it entered for
1658
+ 60 s): the same outcome, no loop.
1659
+ - The redirect pages on the app origin are Porthole's (no app chrome), framable only by the app
1660
+ origins, their one script inline under its hash; the frame transport hears frame status, and
1661
+ nothing else, from the host's own origin.
1662
+ - Tickets stay: frames should use them (no extra round trip; no dependence on the app's cookie
1663
+ inside a frame). Clean addresses are for tabs, links, bookmarks and reloads: a host's "open in a
1664
+ new tab" and "copy link" use `urlOf`. Redirect entry needs a naming that says a label's scope
1665
+ (`prefixedName` does; keyed labels are one-way).
1666
+
1667
+ **Security.**
1668
+ - *Open redirects.* The authorize route redirects only to `/__porthole/entry` on the label in a
1669
+ state it verified; the redeem redirects only to its own origin plus the path from that state,
1670
+ as an absolute URL (`//evil.example/x` stays a path on the instance's origin). The app's
1671
+ `signIn` gets the authorize URL as `returnTo` and must come back only to its own paths.
1672
+ - *CSRF and state binding.* The state is signed (HMAC under the server's key, typed apart from
1673
+ session cookies) and expires in five minutes. The code is bound to the state's nonce, whose
1674
+ cookie lives only in the browser that began: a code sent to someone else's browser (login
1675
+ CSRF, session fixation) opens nothing. A code is bound to its label: taken to another
1676
+ instance's origin, it is refused and spent.
1677
+ - *The code.* It lives in an address for one hop (a redirect, so never in history), every
1678
+ redirect answer sends `Referrer-Policy: no-referrer` and `Cache-Control: no-store`, and
1679
+ Porthole never logs it (an edge's access log may: keep codes out of logs there, or log paths
1680
+ without queries).
1681
+ - *The new opening.* A request without a cookie used to be refused; it is now handed to the
1682
+ app, whose session decides. The app's sign-in and refusal never render in a frame. The host's
1683
+ own origin may now report a frame's status (only that: a protocol message from there is
1684
+ dropped, `test/transport.test.ts`).
1685
+
1686
+ **Measured** (the `served.entry.*` cases, on loopback and in the prod shape): a clean address
1687
+ opens directly signed in, signed out through the app's sign-in, and gives the app's refusal
1688
+ without access, on all three engines; a replayed code and a code taken to another instance's
1689
+ origin open nothing. In a frame, the app's `SameSite=Lax` cookie reaches its authorize route
1690
+ after the cross-site redirect from the content origin on Chromium (the frame shows the content),
1691
+ and not on Firefox or WebKit (the frame asks for a tab to sign in). That is why frames should use
1692
+ tickets.
1693
+
1694
+ **Rejected.**
1695
+ - A `?ticket=` address for tabs and links: not stable, not shareable, and spent once.
1696
+ - An entry page that checks the cookie with a script: the nonce cookie at the redeem tells the
1697
+ same thing on the server, before anything renders, with no script on either origin.
1698
+ - Binding the session to the query too: two queries on one path would take two cookies for one
1699
+ document.
1700
+
1701
+ **Revisit when** a host needs clean addresses for keyed labels (a lookup from label to scope),
1702
+ or a browser starts sending `Lax` cookies on framed redirects everywhere (then frames could use
1703
+ clean addresses too).
1704
+
1705
+ ## P069 · 2026-10-12 · The manual says what the page host means: call the guide tool, fill the viewer, no theme lines when unthemed; 14.sound reports an inherited block · Accepted
1706
+
1707
+ **Decision.** From the first artifact-page host reading its generated manual (0.2.1):
1708
+ - With `guide.guideToolName`, the tool description says to *call* the guide tool ("Call
1709
+ read_page_guide once before your first page"): it is a tool, not a document.
1710
+ - With `layout.kind: "fill"`, the frame fills the app's viewer, which is often smaller than the
1711
+ window (toolbars, side panels): the manual says "fills the app's viewer", "as tall as the
1712
+ viewer, not the window", and to read the frame's own width; it keeps "design for the phone's
1713
+ width first". This supersedes P065's "as tall as the window" wording.
1714
+ - With `theme.documents: "never"`, the manual has no theme line but "always light: style it
1715
+ yourself" and the `prefers-color-scheme` caveat: the example's "cards that read in both
1716
+ themes" and "the app's light palette" go.
1717
+ - `14.sound` detects that the page's own CSP (which an opaque frame inherits) blocks its `data:`
1718
+ media (`media-src`) and reports itself inapplicable, as `1.connect`, `2.resources`,
1719
+ `12.no-confirm` and `14.workers` do (P058); `leak/inherited-policy.spec.ts` proves it on a
1720
+ closed page on all three engines.
1721
+
1722
+ **Why.** Each line was false or misleading for that host (a document to read, the window's size,
1723
+ both themes), and the host skipped `14.sound` by hand on a page whose `media-src 'self'` the
1724
+ frame inherits.
1725
+ **Revisit when** a host's viewer is the whole window (the wording is still true there).
1726
+
1727
+ ## P070 · 2026-10-12 · The host kit, from its first two adopters: unknown addresses 404, pages that talk past a desktop scheme, streams that stop · Accepted
1728
+
1729
+ **Decision.** 0.2.2 fixes what Ferry and Raft Artifacts hit adopting the kit (#5, #6), so neither
1730
+ needs a workaround. Additions only; the API is otherwise the same.
1731
+ 1. **An address that names nothing is a 404 on the content domain.** A label the naming never
1732
+ makes (`www`, `f`, garbage), the bare domain, and a clean address whose `entry.at` (asked on
1733
+ the content origin with no viewer) gives null or undefined get Porthole's 404 there. Only an
1734
+ instance that exists and lacks a cookie goes to the app's authorize route. `/server` takes it
1735
+ as `entry.named(label, path)`; a naming's `scopeOf` already refuses labels it never makes.
1736
+ 2. **`reserved` is for labels the app itself serves** on the content domain (Ferry's `p-*`
1737
+ previews); a label nothing serves needs no entry (item 1).
1738
+ 3. **The entry hooks get the address decoded** (`EntryContext`: label, scope, path, clean URL,
1739
+ instance, framed), and `refusal` may return nothing to use Porthole's page.
1740
+ 4. **`/server` exports the types an app's callbacks use**: `InstanceRef`, `FrameLocation`,
1741
+ `ServedDocument`, `PortholeConfig`, `EntryContext`.
1742
+ 5. **The runner:** `instances` and the entry addresses may be async (a page published on demand,
1743
+ its real instance then asked of the issue endpoint); `entry.unknown` adds
1744
+ `served.entry.unknown`; tests are located at the app's `defineHostConformance` call (compiled
1745
+ there with `vm`'s file name and line), so `playwright test <spec>` selects them.
1746
+ 6. **The pages' frame status survives a desktop scheme among the host origins (#5).** Each web
1747
+ host origin is told in its own `try`; where the host origins hold another scheme
1748
+ (`ferry://app`, which no browser takes as a target origin) and the parent isn't known to be a
1749
+ web host origin (`location.ancestorOrigins`), the word goes to the parent as `*`. Before, one
1750
+ throwing `postMessage` stopped the entry page before its cookie check, so every framed
1751
+ url-mode app stayed on "Opening the app…".
1752
+ 7. **`nodeHost` streams (#6):** the request carries a signal aborted when the client leaves
1753
+ (`proxyTo` passes it to the relay); the body is written as the socket drains and cancelled
1754
+ when the client leaves; an error mid-body destroys the response.
1755
+ 8. **`health.onChange(view)`** is told each change of the gate's state.
1756
+ 9. **The npm package carries the docs** (`docs/DESIGN.md`, `DECISIONS.md` and the rest).
1757
+
1758
+ **Risks.**
1759
+ - Item 1 tells anyone whether an address names something (404, or a trip to the app). Labels
1760
+ under `prefixedName` were guessable already; an app that must not say so can answer `at` for
1761
+ every well-formed address and let `access` refuse.
1762
+ - Item 6 sends the frame status to `*` in one case. The pages that send it are framable only by
1763
+ the host origins (`frame-ancestors`), so their parent is one, and what they say (entering,
1764
+ blocked and why) is no secret. Bounded by `test/pages.test.ts` and the kit's
1765
+ `served.url.framed`, which runs with a desktop scheme among the host origins on every engine.
1766
+
1767
+ **Why.** Before 0.2.0 these labels were 404s on the content domain, and production said so. The
1768
+ other items are each a workaround an adopter wrote: the content-origin state re-parsed in its
1769
+ hooks, labels left out of `reserved` (which looked like the way to keep them unanswered), http
1770
+ origins only so the entry page wouldn't hang, its own streaming relay before `nodeHost`, its
1771
+ own served flag in the `log` callback, a `-g` filter to find its tests.
1772
+ **Revisit when** an app needs an address that exists but must not be told apart from one that
1773
+ doesn't (then `at` grows a way to say "ask the app").
1774
+