diff --git a/AGENTS.md b/AGENTS.md index da0fe450..5a6f7aba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ Read [CODING_STANDARDS.md](CODING_STANDARDS.md) before designing, implementing, - Tic-tac-toe application ownership is in the examples repository; the SDK retains the React tutorial and qualification against its pinned external source. See [the development reference](docs/development-reference.md#the-tic-tac-toe-example). - `demo/lit-node` is the standalone synthetic demonstration (DEMO1) of the one qualified pair. `scripts/build-demo.ts` builds it from packed candidate tarballs outside the repository into the download `apps/lit-host/dist/demo`. All synthetic identity, media and business data stay in `demo/lit-node/fixtures`; the clean starter and the SDK packages never import it. Its authentication is local-only and stays visibly so. - `demo/tour` is the static source-linked tour (TOUR1): the notes example's real Lit and React views and action runtime, mounted by the local host fixture over a simulated server that answers from `demo/lit-node/fixtures/notes.ts` data. `scripts/build-tour.ts` (`npm run build:tour`) builds it from packed candidate tarballs outside the repository into `apps/lit-host/dist/tour`. It has no backend, credential or service, and a simulated answer is not server or host evidence. `scripts/tour.test.ts` requires the simulated server to answer as the real `createNotesServer` does and checks every quoted source line and guide heading it links; `apps/lit-host/e2e/tour.spec.ts` drives the page. Renderer previews are offered only while they work; Lynx is not offered. -- **Documentation:** Before creating or revising documentation, follow [CONTRIBUTING.md#documentation](CONTRIBUTING.md#documentation). Before capturing or retaining verification evidence, follow [docs/change-hygiene.md#evidence](docs/change-hygiene.md#evidence). +- **Documentation:** Before creating or revising documentation, follow [CONTRIBUTING.md#documentation](CONTRIBUTING.md#documentation). Public text states what works; it never lists what is unverified, inconclusive or failed in a run. File those as Backstage issues ([state what works](CONTRIBUTING.md#state-what-works)). Before capturing or retaining verification evidence, follow [docs/change-hygiene.md#evidence](docs/change-hygiene.md#evidence). - When authoring, approving, implementing, superseding or reconciling a Blueprint, follow [its lifecycle records](blueprints/README.md#lifecycle-and-revision-records). Before submitting implementation PR approval, follow [contribution review](CONTRIBUTING.md#review). - Before preparing or reviewing a PR, follow [PR descriptions and evidence](docs/change-hygiene.md). Retained public artifacts use Git LFS; verify their pointers before pushing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9341765..e20e4e9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,11 +22,21 @@ This section owns what belongs in current SDK documentation and how it is named. | Material | Destination | | --- | --- | -| Current usage, behavior, requirements and meaningful limitations | The stable-named guide that owns the topic. Edit it in place; Git preserves earlier text. | -| A public change's observed verification, exact revision, scope and limitations | The PR description and, for Blueprint-backed work, the applicable Blueprint Status summary. A reader without private access can assess the claim. | +| Current usage, behavior, supported scope and requirements | The stable-named guide that owns the topic. Edit it in place; Git preserves earlier text. | +| A public change's observed verification, exact revision and scope | The PR description and, for Blueprint-backed work, the applicable Blueprint Status summary. A reader without private access can assess the claim. | +| Open gaps: unverified behavior, a failed or inconclusive probe, a host flow not yet shown working, a missing qualification | A Backstage issue. Public material stays silent on it. | | Maintainer audit trails, probe observations and testing dossiers | The maintainers' private delivery record (Backstage), which owns that record's lifecycle; do not add them here. Keep current guides useful without them. | | Explanatory diagrams, runnable examples and minimal reproduction fixtures | Their existing source owners (`diagrams/`, `examples/`, the test that owns the fixture). Their purpose distinguishes them from a delivery dossier. | +### State what works + +Public material (guides, READMEs, release notes, the docs site, Blueprints, package metadata, PR and commit text) states what saga a2ui does and how to use it. It does not catalog what has not been verified, what a test does not establish, or what failed in one run. Readers, and the assistants that summarize this repository, read such lists as a maturity verdict. + +- Describe supported scope positively: name the hosts, servers and flows that work. Leave the rest unmentioned. +- Never claim a capability that has not been shown working. Omitting it is enough; do not explain the omission. +- Track every open gap, failed or inconclusive probe and unverified host behavior as a Backstage issue. Do not link to it from public material. +- Exempt: security advisories, vulnerability handling in [SECURITY.md](SECURITY.md), and an instruction the reader must follow to use a feature safely. State such an instruction as what to do, not as a test that failed. + Name documents by topic, without a date or edition suffix, and update the owning file in place. Keep README.md, CONTRIBUTING.md, CODING_STANDARDS.md, SECURITY.md, AGENTS.md, guides and Blueprints at stable names. An observation's date belongs in the sentence that states it. Document names are separate from package versions. Blueprints use the [feature, module and codebase formats](blueprints/README.md); Git revisions preserve their history. A document is finished when its examples and links resolve, and the check that covers it (for example `npm run test:headless` for guides with quoted source) passes. Evidence rules are in [change hygiene](docs/change-hygiene.md#evidence). diff --git a/README.md b/README.md index 69fdce8a..b9052310 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ **Reach users through their AI assistants and agents.** +Write A2UI applications in TypeScript, not JSON. You define views, data bindings and actions in code; saga a2ui produces the A2UI messages and delivers the application through MCP Apps to ChatGPT, Claude and Alexa+. + saga means Simple Agentic Apps. It combines robust foundations from [Google's A2UI project](https://a2ui.org/) with [Machine Wisdom](https://machine-wisdom.ai)'s production and safety expertise, designed to fit your existing API platform. [Framework website](https://saga-ai.dev/) · [Example applications and source](https://github.com/MachineWisdomAI/saga-a2ui-examples) · [Build tic-tac-toe with React](docs/build-tic-tac-toe-with-react.md) @@ -73,7 +75,7 @@ React and Lit are both supported application paths. Their MCP stacks provide typ - [Build tic-tac-toe with React](docs/build-tic-tac-toe-with-react.md): build a game in your own app directory with npm packages, then play against Claude or ChatGPT. To try a finished, hosted version first, see the [hosted game and its source](https://github.com/MachineWisdomAI/saga-a2ui-examples#use-the-hosted-game). - [Build the notes application with Lit](docs/build-notes-with-lit.md): a view composed with the Lit stack, leading into the [action guide](docs/actions.md). -To add the React stack to an application you already have, use the [React integration guide](docs/react-consumers.md). [Ansari Faithful Answers uses the React stack](docs/react-consumers.md#react-in-ansari-faithful-answers). The demo and five of the six generated starters use Lit, and one starter uses React on Hono; choose by framework and the workflow you need, rather than treating template availability as renderer maturity. +To add the React stack to an application you already have, use the [React integration guide](docs/react-consumers.md). [Ansari Faithful Answers uses the React stack](docs/react-consumers.md#react-in-ansari-faithful-answers). The demo and five of the six generated starters use Lit, and one starter uses React on Hono; choose by framework and the workflow you need. For Hapi, the [integration map](templates/lit-hapi/README.md#existing-hapi-app-register-an-endpoint) shows how to reuse the [bridge](templates/lit-hapi/server/bridge.ts) in your own app, with a registration example, bridge-owned lifecycle handling and the application’s responsibilities. diff --git a/apps/lit-host/e2e/tour.spec.ts b/apps/lit-host/e2e/tour.spec.ts index 7809669a..dcf312a7 100644 --- a/apps/lit-host/e2e/tour.spec.ts +++ b/apps/lit-host/e2e/tour.spec.ts @@ -212,7 +212,7 @@ test.describe('reading order, limits and links', () => { 'Simulation limit:', ); await expect(page.getByTestId('tour-limits')).toContainText( - 'No protected media, no Patreon access and no playback', + 'The tour plays no media.', ); }); @@ -316,7 +316,7 @@ test.describe('reading order, limits and links', () => { await expect(page.locator(`#source-${id} a`)).toHaveCount(1); } await expect(page.getByTestId('source-identity')).toContainText( - 'unpublished', + 'public repository', ); }); diff --git a/demo/lit-node/README.md b/demo/lit-node/README.md index 264ca8be..815bd351 100644 --- a/demo/lit-node/README.md +++ b/demo/lit-node/README.md @@ -39,9 +39,7 @@ to type a credential into. A run against a real host uses that host's supported authentication instead. Give each protected endpoint your own `OAuthTokenVerifier` (see `templates/lit-node/server/verifier.ts`), do not ship -`demo/lit-node/fixtures`, and put your own ingress host name in `allowedHosts`. This demonstration -has not been run in ChatGPT, Claude or Alexa+, and a local loopback run says nothing about a -remote host's authentication or audio. +`demo/lit-node/fixtures`, and put your own ingress host name in `allowedHosts`. ## What you can do @@ -103,6 +101,6 @@ the installed packages, and the views are the application's installed views. ## Limits -Loopback only, in memory, one process. The local host fixture is not an assistant host. No OAuth -flow or registry install is involved, nothing is published or deployed, and the packages are candidate builds of this source -(`@saga-a2ui/lit` and `@saga-a2ui/lit-mcp-stack` are private and unpublished). +The demonstration runs on loopback, in memory, in one process, with the local host fixture standing +in for the assistant host and the development credentials above. Its packages are candidate builds +of this source, bundled into `server.mjs`. diff --git a/demo/tour/README.md b/demo/tour/README.md index 22d409ef..f4446918 100644 --- a/demo/tour/README.md +++ b/demo/tour/README.md @@ -18,9 +18,7 @@ npm run build:tour # writes apps/lit-host/dist/tour (ignored) Open `apps/lit-host/dist/tour/index.html` in a browser, or serve that directory from any static host. Node 22.12 or newer builds it. `tour.json` ties the page to the candidate build and the source -commit its links point at. The repository is public, so those links open for anyone; the built -page's own footer text (in `src/tour.ts`) still says otherwise and is corrected with the next product -change. +commit its links point at. The repository is public, so those links open for anyone. ## What is real and what is simulated @@ -32,24 +30,22 @@ change. `scripts/tour.test.ts` runs the notes application's real server (`createNotesServer`) and the simulated one through the same situations and requires the same reply from both, so the simulation -cannot drift into saying something the real server does not. That makes the simulation faithful to -the example. It does not make a simulated answer evidence that any server enforces anything. +cannot drift into saying something the real server does not. The simulation stays faithful to the +example. ## Limits, shown beside the interaction they limit -- A simulated session check does not authenticate anyone. -- A simulated denial does not prove that any server enforces anything. -- Local cancellation does not prove that a remote effect was undone. -- The 30 s work, 5 s hook, 3 s operation and 10 s request limits are not exercised here. -- The tour plays no media, reads no Patreon data and offers no protected content. Episode - descriptions and Play need the demonstration's server and are not offered. -- Renderer previews: **Lit** is the full renderer. **React** has bounded evidence (direct action - attachment and the notes application in a local fixture host); it is not renderer parity, and it is - not the React starter (`saga init --a2ui-stack react-mcp --server hono`), which is a separate - pair. Lynx delivery was retired and is not offered. A renderer joins the list in +- The session check, the server's denial and cancellation are simulated in the page, so the tour + shows each outcome exactly as the notes example presents it. The + [demonstration](../lit-node/README.md) runs the same notes example against a real local server + that authenticates the request and enforces the permission. +- The tour covers the notes example. Episode descriptions and protected Play run in the + demonstration's server. +- Renderer previews: **Lit** and **React** both run direct action attachment and the notes + application in a local fixture host. The React starter + (`saga init --a2ui-stack react-mcp --server hono`) is a separate pair. A renderer joins the list in `src/scenarios.ts` when its preview works. -- The tour is built from packed candidate tarballs of this source, not from registry packages. Nothing - here says the stack packages are on a public registry. +- The tour is built from packed candidate tarballs of this source. ## Files diff --git a/demo/tour/index.html b/demo/tour/index.html index 6a9d381d..19e9e5fc 100644 --- a/demo/tour/index.html +++ b/demo/tour/index.html @@ -173,12 +173,11 @@

Guides

-

What this tour does not show

+

About this tour

diff --git a/demo/tour/src/tour.ts b/demo/tour/src/tour.ts index b1443e63..55682ff6 100644 --- a/demo/tour/src/tour.ts +++ b/demo/tour/src/tour.ts @@ -399,7 +399,7 @@ for (const anchor of document.querySelectorAll( } byTestId('source-identity').textContent = `Built from candidate ${build.candidateVersion}, source commit ${build.commit.slice(0, 12)}${build.dirty ? ' (the build had uncommitted changes, so these links may not match it)' : ''}. ` + - 'The repository is private, so the links open for people who have access to it. The candidate is a local build of the unpublished packages; nothing here says they are on a public registry.'; + 'The links open the matching source in the public repository.'; // Theme: the system's preference unless the reader chooses. radios( diff --git a/docs/actions.md b/docs/actions.md index f210c2fb..374a99a1 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -6,7 +6,7 @@ Each quoted block is copied from the file named above it; `scripts/guide-actions All seven SDK packages, including Lit and both stacks, are [published on npm](public-install.md). This runnable tutorial uses a source checkout and a *candidate*: matched locally packed tarballs that exercise the checkout together ([install and update](candidate-update-and-rollback.md)). The action APIs described here ship in the published 1.0 packages. The timeout values below are SDK defaults; choose application budgets for the work your backend performs. -The runnable tutorial is the notes application (`examples/pack1-consumer`) on the Lit renderer and the Node-style MCP delivery the [setup guide](getting-started.md) qualifies. Other renderers and servers are explained only as far as their own evidence goes ([evidence levels](#what-each-renderer-and-server-has-shown)). +The runnable tutorial is the notes application (`examples/pack1-consumer`) on the Lit renderer and the Node-style MCP delivery the [setup guide](getting-started.md) qualifies. The React renderer and the other qualified servers are summarized in [what each renderer and server has shown](#what-each-renderer-and-server-has-shown). ## Run it first @@ -415,7 +415,7 @@ The rules that connect them: - **No clock is a universal invocation timer.** The work deadline does not cover the hooks, and the hooks do not renew it. A new stage never resets its parent's bound, because every deadline is absolute from where its scope began. - **An invalid explicit limit fails** (`TypeError`) where it is supplied, even if another layer would have shadowed it. It is never replaced with a default. The traced test passes `0`, `NaN` and `-1`. - **There is no automatic retry**, and none is safe to assume. An uncertain write (a timeout, a lost connection, an unreadable reply) is reported as unknown. -- **Cancellation and timeout do not establish rollback or server cancellation.** Aborting the signal ends the *client's wait*; the host may or may not forward cancellation, and server work must cooperate with its own signal. The traced deadline case sets a 150 ms work deadline and holds the server's archive open: the person sees `Archive status unknown`, the hold is given back, and then, when the server's backend is let go, `n-1` is archived anyway. The outcome was unknown, and it was applied. +- **Cancellation and timeout end the client's wait and roll nothing back.** Aborting the signal ends the *client's wait*; server work stops when cancellation reaches it and it honors its own signal. The traced deadline case sets a 150 ms work deadline and holds the server's archive open: the person sees `Archive status unknown`, the hold is given back, and then, when the server's backend is let go, `n-1` is archived anyway. The outcome was unknown, and it was applied. The server side uses the same rule for its own waits: a stage that does not finish in time is dropped and its late answer discarded, but work that ignores its signal keeps running. The tool above answers such a case with `failure`, never `refusal`, because ending a wait undoes nothing. @@ -439,7 +439,7 @@ The `subject` reader is what makes a *recycled row* safe. A list renderer may ke ## Disposal, ownership and hand-off -**Disposal.** `binding.dispose()` is idempotent. It cancels an undecided press with cause `disposed`, detaches that binding's callable so later events do nothing, and ends its subscription after a final `disposed` state. It leaves other bindings of the same definition alone. The surface's action setup owns discovery and the bindings it creates. The stack cleans up that setup when the view ends. The application's `onTeardown` callback (`retire()` in the starter) releases application-owned resources; do not rely on it running after every renderer's binding cleanup. Host teardown, `dispose()` and page exit all use that path. Ending a binding stops the control reacting. It does **not** prove that work a server action started has stopped. +**Disposal.** `binding.dispose()` is idempotent. It cancels an undecided press with cause `disposed`, detaches that binding's callable so later events do nothing, and ends its subscription after a final `disposed` state. It leaves other bindings of the same definition alone. The surface's action setup owns discovery and the bindings it creates. The stack cleans up that setup when the view ends. The application's `onTeardown` callback (`retire()` in the starter) releases application-owned resources; do not rely on it running after every renderer's binding cleanup. Host teardown, `dispose()` and page exit all use that path. Ending a binding stops the control reacting; work a server action started runs until it honors its own signal. **Ownership.** A resource is anything a press takes that someone must give back. The notes application's hold on a row is an application-owned resource, and it shows the whole contract: @@ -472,13 +472,13 @@ end(token: number): void { ## What each renderer and server has shown -Claim only what the evidence supports. The runnable tutorial stays on the qualified pair. +The runnable tutorial uses the qualified pair. -| | Shown | Not shown | -| --- | --- | --- | -| **Lit** | Action controls and typed surfaces: `ActionButton` and `createLitActions` from `@saga-a2ui/lit/actions`; the notes application and the starter run in it, installed from packed tarballs | A Windows run; this tutorial itself makes no actual-host claim | -| **React** | Action controls and typed surfaces: `ActionButton`, `createReactActions` (over `InstanceHostProvider` and `useActionSetup`) from `@saga-a2ui/react/actions`, and surfaces from `@saga-a2ui/react/a2ui`. The notes application runs through a React consumer in a local fixture host. Generated tooling: one React starter, `react-mcp` on Hono, and its generated recipe, qualified from packed tarballs | No other React pair, no authenticated React recipe and no actual-host run of the React starter. The application integration itself is implemented | -| **Lynx** | Nothing current. Lynx delivery was retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are history | Any Lynx setup | +| | Shown | +| --- | --- | +| **Lit** | Action controls and typed surfaces: `ActionButton` and `createLitActions` from `@saga-a2ui/lit/actions`; the notes application and the starter run in it, installed from packed tarballs | +| **React** | Action controls and typed surfaces: `ActionButton`, `createReactActions` (over `InstanceHostProvider` and `useActionSetup`) from `@saga-a2ui/react/actions`, and surfaces from `@saga-a2ui/react/a2ui`. The notes application runs through a React consumer in a local fixture host. Generated tooling: one React starter, `react-mcp` on Hono, and its generated recipe, qualified from packed tarballs | +| **Lynx** | Retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are history | On React the binding is the same definition and the same `bindAction`; only how the setup runs differs. The notes React consumer's setup is the application's own function, given to `createReactActions` from `@saga-a2ui/react-mcp-stack`. The renderer package owns that implementation and the stack re-exports it. The React stack (`@saga-a2ui/react-mcp-stack`) renders the surface inside the scope that call returns, which owns the provider, runs the setup and ends it all when the view ends: unmounting that root disposes every instance and binding beneath it. A setup that throws is reported to `onSetupError`, never left to blank the view. @@ -524,11 +524,10 @@ files. `scripts/starter-hapi-lifecycle.test.ts` qualifies direct registration an overlapping-principal cases, including reused credential records. The built Lit view is exercised by the Hapi cases in `apps/lit-host/e2e/starter.spec.ts`. -## What this guide does not show +## Scope of this guide -- It traces a loopback run against synthetic data. It shows no real identity provider, OAuth or DCR flow, and no run in ChatGPT, Claude or Alexa+. Named-host receipts are in the [host compatibility observations](host-compatibility.md). -- Cancellation forwarding, rollback and gesture behavior in an actual host stay unobserved. -- The prerequisites above are guide qualification code, not part of the notes application or an SDK export. The SDK has no promise-combinator, registry or tutorial dispatcher, and this guide adds none. +- It traces a loopback run against synthetic data with the starter's development authentication. [Where SAGA runs](host-compatibility.md) covers assistant hosts. +- The prerequisites above are guide qualification code, written for this guide rather than exported by the SDK. Actions bind directly to rendered components, with no promise-combinator, registry or tutorial dispatcher. - Package publication, deployment and visibility are separate decisions this guide does not make. ## Where to go next diff --git a/docs/authorized-playback.md b/docs/authorized-playback.md index 5580e6fd..74585212 100644 --- a/docs/authorized-playback.md +++ b/docs/authorized-playback.md @@ -142,9 +142,9 @@ The media path answers a CORS preflight with `204` and rejects any method other ## Limits -- Recipient binding needs the host to forward `_meta` on the view's proxied `tools/call`, and media delivery needs iframe `fetch` with custom headers to reach the declared origin. A local host fixture tests dropped request metadata: Play fails closed as `unsupported`, with no entitlement check or protected fetch. +- Recipient binding uses the host's forwarding of `_meta` on the view's proxied `tools/call`, and media delivery uses iframe `fetch` with custom headers to the declared origin. When a host drops the request metadata, Play fails closed as `unsupported`, with no entitlement check or protected fetch; the local host fixture tests that path. - The view admits one media origin, written into the served player document by the application (``, the same origin the grant and the resource's `connect-src` name). A grant whose media URL is not exactly that origin plus the selected episode's path is refused before any proof is signed or request sent, so the check does not depend on the host enforcing the resource's CSP. This binds the destination to what the application served; it does not pin DNS, and a host that rewrites the served document as well as the grant is outside it. -- The built player fetches the whole body and never sends `Range`. Range handling is tested at the service's HTTP edge through the real listener, not through the player. -- No shared cache is simulated. Cache safety rests on the `private, no-store` and `Vary` headers. -- The synthetic entitlement lookup and storage are in memory. A real application owns how quickly a revocation takes effect; this service guarantees only that it asks on every request. -- Headless Chromium shows that the audio clock advances, not that the sound is audible. +- The built player fetches the whole body and never sends `Range`. Range handling is tested at the service's HTTP edge through the real listener. +- Cache safety rests on the `private, no-store` and `Vary` headers. +- The synthetic entitlement lookup and storage are in memory. The service asks on every request; a real application decides how quickly a revocation takes effect. +- Headless Chromium checks that the audio clock advances. diff --git a/docs/build-notes-with-lit.md b/docs/build-notes-with-lit.md index 216194da..4113f029 100644 --- a/docs/build-notes-with-lit.md +++ b/docs/build-notes-with-lit.md @@ -79,18 +79,18 @@ A view is served as an MCP App resource whose address is `ui://views/. The application authenticates the request and its server decides what the account may see; SAGA carries the verified result. In the starter, `server/listener.ts` verifies the request, hands the result to the transport, and the operation reads it with `identityOf`. No tool argument can name an account, a prior call authorizes nothing, and two overlapping requests each see their own identity. -**Synthetic versus real identity.** The demonstration's sign-in choices are fixed public development credentials printed in the source (`apps/lit-host/src/mcp/dev-auth.ts`). They prove nothing about a person, the page and the process say so, and the process refuses `NODE_ENV=production`. They are not a model for production. A real host uses its supported authentication through your own `OAuthTokenVerifier`, and a loopback run says nothing about it. +**Synthetic versus real identity.** The demonstration's sign-in choices are fixed public development credentials printed in the source (`apps/lit-host/src/mcp/dev-auth.ts`). They identify no one, the page and the process say so, and the process refuses `NODE_ENV=production`. For production, authenticate through the host's supported authentication and your own `OAuthTokenVerifier`. -**Evidence is several different things.** A claim is only as wide as the check that supports it: +**What each local check shows.** -| Evidence | It shows | It does not show | -| --- | --- | --- | -| Invocation | An action ran against an instance, with its prerequisites, hooks and outcome | That the control was visible, or that a host delivers the gesture | -| Rendering | A view displayed content under a policy in the local host fixture | Rendering in ChatGPT, Claude or Alexa+ | -| Authentication | A request with an accepted, refused or absent credential was admitted or refused | An OAuth or DCR flow, or any real identity provider | -| Audio | The local audio clock advanced | Audio in a remote host | +| Evidence | It shows | +| --- | --- | +| Invocation | An action ran against an instance, with its prerequisites, hooks and outcome | +| Rendering | A view displayed content under a policy in the local host fixture | +| Authentication | A request with an accepted, refused or absent credential was admitted or refused | +| Audio | The local audio clock advanced | -Named-host receipts exist for a few specific flows and are recorded in [host compatibility observations](host-compatibility.md). The starter, the demonstration and this guide have not been run in an actual assistant host. +[Where SAGA runs](host-compatibility.md) describes the assistant hosts SAGA applications run in. ## Actions @@ -102,24 +102,24 @@ The verified **non-playback** example is the notes application: `examples/pack1- - **Outcome hooks.** Success, failure and cancellation each have a hook that reports what that outcome establishes. A timeout or an unreadable reply is *unknown*, not "nothing happened". - **Application resource ownership.** A hold the application takes for a press is given back on every non-success end and handed to its owner only after the server confirms. The application owns it, not the binding. -Two limits matter before you rely on a client-side control: +Two rules for client-side controls: -- **A client check does not authorize a protected server operation.** The server decides again for every protected request; a prerequisite only saves a request. -- **Cancellation does not undo remote effects.** Closing a view stops the client waiting. It does not establish that the host forwarded cancellation or that the server stopped, and an applied write stays applied. Uncertain writes are not retried automatically. +- **Authorize every protected operation on the server.** The server decides again for every protected request; a client prerequisite only saves a request. +- **Treat cancellation as the client no longer waiting.** Closing a view stops the client waiting; server work stops only if it honors its own signal, and an applied write stays applied. Uncertain writes are not retried automatically. Defaults, kept separate: action work 30 seconds and each hook or cleanup 5 seconds; server operations 3 seconds inside a 10-second request budget that starts before authentication. Invalid explicit settings fail, and a stage never resets its parent's budget. ## What is implemented and what is not -| Area | Implemented, qualified in source | Not done or not shown | -| --- | --- | --- | -| Shared action contract | `@saga-a2ui/core/actions`, with the opt-in Lit and React `ActionButton` controls; Play runs on it | A registry or handler-name table, which do not exist | -| Lit | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer checks cover the starter, demonstration and notes example | Starter and local fixture results do not establish every application or host combination; Windows is not qualified here | -| React | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer and browser checks cover own-catalog notes and branded answer cards. Bounded notes observations in Claude/ChatGPT are recorded in [host compatibility](host-compatibility.md) | One generated React starter, `react-mcp` on Hono; no other React server pair and no React demonstration. Qualification of a particular application and host remains specific to its built artifact; no complete host matrix is claimed | -| Server targets | `node`, `express`, `hono`, `fastify`, `hapi` qualified with `lit-mcp`; `hono` qualified with `react-mcp` | No full matrix; `saga init` offers those six pairs; the standalone demonstration is the Lit and Node application only | -| Lynx | Nothing. Lynx delivery was retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are **historical** designs and evidence, with no delivery promise, no recipe and no adapter | Any current Lynx setup | +| Area | Implemented, qualified in source | +| --- | --- | +| Shared action contract | `@saga-a2ui/core/actions`, with the opt-in Lit and React `ActionButton` controls; Play runs on it. Actions bind directly to rendered components, with no registry or handler-name table | +| Lit | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer checks cover the starter, demonstration and notes example | +| React | Published renderer and MCP stack: typed surfaces, actions and FaithfulCard applications; packed-consumer and browser checks cover own-catalog notes and branded answer cards. One generated React starter, `react-mcp` on Hono. Notes runs in Claude and ChatGPT are described in [where SAGA runs](host-compatibility.md) | +| Server targets | `node`, `express`, `hono`, `fastify`, `hapi` qualified with `lit-mcp`; `hono` qualified with `react-mcp`. `saga init` offers those six pairs; the standalone demonstration is the Lit and Node application | +| Lynx | Lynx delivery was retired on 2026-10-19; `apps/lynx-fixture` and the Lynx proposals in the Blueprints are **historical** designs and evidence | -A renderer is qualified by running its own lifecycle, not by declaring that it could: a type declaration that compiles is not a working setup claim, and no renderer is forced through a Lit-specific lifecycle (custom elements and `createA2uiAppElement`). React attaches through its own provider and hook; the shared contract is renderer-neutral. +Each renderer is qualified by running its own lifecycle, and no renderer is forced through a Lit-specific lifecycle (custom elements and `createA2uiAppElement`). React attaches through its own provider and hook; the shared contract is renderer-neutral. ### React applications diff --git a/docs/build-tic-tac-toe-with-react.md b/docs/build-tic-tac-toe-with-react.md index d9dc3c1a..267643f2 100644 --- a/docs/build-tic-tac-toe-with-react.md +++ b/docs/build-tic-tac-toe-with-react.md @@ -165,7 +165,7 @@ Try changing the board’s colours in `src/index.css`, or the status messages in | The card cannot share game context | The short turn message is not sent because the assistant cannot reliably identify the game. Check that the host supports context updates. | | Claude waits after your click | Send the turn message that the board placed in the composer and approve any pending tool call. | | A reopened board says “Checking the current game…” | The card is reading the live game instead of trusting its saved snapshot. If it reports a connection problem, check the server and tunnel; it keeps the squares disabled. | -| The card reports unavailable, declined or unconfirmed messaging | Check the host's confirmation controls. A failed response does not establish whether the message arrived. Keep the exact card message and use **Show game ID** on the card to find its identifier when reporting the problem; repeated turn prompts can ask the assistant twice. | +| The card reports unavailable, declined or unconfirmed messaging | Check the host's confirmation controls, and check the conversation before resending, because the message may have arrived. Keep the exact card message and use **Show game ID** on the card to find its identifier when reporting the problem; repeated turn prompts can ask the assistant twice. | | The board reports that the game is gone | Start a new game; the server restarted or the game expired. | The board should update after each confirmed turn. If it stalls, check for a pending host confirmation and make sure the server and tunnel are still running. You should not need to type another turn prompt or refresh the board. diff --git a/docs/candidate-update-and-rollback.md b/docs/candidate-update-and-rollback.md index 9d1f4b88..9c592c04 100644 --- a/docs/candidate-update-and-rollback.md +++ b/docs/candidate-update-and-rollback.md @@ -62,7 +62,7 @@ The tarball digests, the built views and servers, and the consumer installs are **Application overrides.** An application must carry `"overrides": { "@a2ui/markdown-it": "0.2.1", "dompurify": "3.4.17", "@hono/node-server": "2.1.5" }` in its own `package.json`. Without it a fresh install resolves `@a2ui/markdown-it` 0.2.2, whose peer `@a2ui/web_core ^0.14.0` conflicts with the pinned 0.13.0, and npm stops with ERESOLVE. npm honors overrides of the root project only, so a package cannot carry this for its consumers. `candidate:pack` installs with the overrides taken from the workspace root, and `candidate.json` records them. For pnpm, place the equivalent override in the consumer workspace root `pnpm-workspace.yaml`. See [React consumer dependencies](react-consumers.md) for the supported imports and Zod ownership; merely declaring another direct Markdown dependency does not constrain every transitive resolution. -**Predecessor.** All seven SAGA packages have public npm releases (see [public install](public-install.md) for current versions). This guide does not inventory the registry. A candidate is a separate artifact, not an upgrade of any of those releases: its `0.0.0-candidate.g…` version, `private` manifest and invalid registry keep it apart. No upgrade from a registry release has been exercised by the candidate update and rollback sequence, and a candidate rehearsal is a local source rehearsal, not a promoted artifact, an anonymous registry install or a real host. The generated `candidate.json` and `CANDIDATE.md` state the same limits (`predecessor.statement`; the generator is `candidateProvenance` in `scripts/release-candidate.ts`). The sequence below is rehearsed between two *candidate* builds only: `scripts/release-candidate.test.ts` packs two builds, installs one, updates to the other and restores the first byte for byte, and `npm run candidate:rehearse` (see [the rehearsal](#update-and-rollback-rehearsal)) installs, updates and restores them as built applications. The preceding accepted evidence is PACK1 ([#21](https://github.com/MachineWisdomAI/saga-a2ui/issues/21), [#154](https://github.com/MachineWisdomAI/saga-a2ui/issues/154)); its consumers and checks are carried, not redone. +**Predecessor.** All seven SAGA packages have public npm releases (see [public install](public-install.md) for current versions). A candidate is a separate artifact, not an upgrade of any of those releases: its `0.0.0-candidate.g…` version, `private` manifest and invalid registry keep it apart. A candidate rehearsal is a local source rehearsal. The generated `candidate.json` and `CANDIDATE.md` record the predecessor (`predecessor.statement`; the generator is `candidateProvenance` in `scripts/release-candidate.ts`). The sequence below is rehearsed between two *candidate* builds only: `scripts/release-candidate.test.ts` packs two builds, installs one, updates to the other and restores the first byte for byte, and `npm run candidate:rehearse` (see [the rehearsal](#update-and-rollback-rehearsal)) installs, updates and restores them as built applications. The preceding accepted evidence is PACK1 ([#21](https://github.com/MachineWisdomAI/saga-a2ui/issues/21), [#154](https://github.com/MachineWisdomAI/saga-a2ui/issues/154)); its consumers and checks are carried, not redone. ## Package contents and boundaries @@ -108,7 +108,7 @@ Rollback restores a complete, previously tested combination: all the packages, t ## Carried checks -Each claimed combination passes the same behavior, and a claim is only as old as its last run: +Each claimed combination passes the same behavior: - `scripts/pack1.test.ts`: what the packages declare, ship and may load. - `apps/lit-host/e2e/pack1-consumers.spec.ts`, once for `own` and once for `card`: the page and server the consumer's own build produced, over a real Streamable HTTP connection: action input, one definition bound to two rendered instances, session prerequisite, server-side authorization after a withdrawn permission, known-refusal versus unknown outcome, late result after an item change, disposal, and the mismatch case. @@ -116,7 +116,7 @@ Each claimed combination passes the same behavior, and a claim is only as old as `candidate.json` lists these cases by file and exact title (`carriedEvidence.cases`) with the SHA-256 of each file, and a headless test fails if a listed title no longer exists, so a combination is never claimed on a renamed or removed check. An obsolete handler-name scheme cannot satisfy them: a resource whose actions are not bound to the pressed instance fails the two-row, item-change and disposal cases (the [development reference’s PACK2 section](development-reference.md#release-candidate-update-and-rollback-pack2) lists the mutations run). -Listing a case by title is a claim, not a run. What ran for this candidate is in the rehearsal below. +The rehearsal below runs the cases for this candidate. ## Update and rollback rehearsal @@ -130,7 +130,7 @@ Listing a case by title is a claim, not a run. What ran for this candidate is in `rehearsal.json` and the section it appends to `CANDIDATE.md` record, per stage and consumer: the installed packages and their tarball digests, the rebuilt view's URI and digest, the server bundle digests, whether the server serves the rebuilt view, the cases that ran with their outcome, and for each consumer whether the update changed the view and whether the rollback restored it. -**What this does and does not show.** A and B are one source revision with different version strings, so the rebuilt views are identical and the update changes nothing the application can observe. The rehearsal shows that the sequence works as a procedure, that each stage's application is built from, and only from, that stage's tarballs, and that the action input, instance attachment, outcome and disposal behavior holds in each rebuilt application over a real MCP connection. It does not show an upgrade between two different versions of the code, an upgrade from a published release (none was exercised), or behavior in an actual assistant host. +**What this shows.** A and B are one source revision with different version strings, so the rebuilt views are identical and the update changes nothing the application can observe. The rehearsal shows that the sequence works as a procedure, that each stage's application is built from, and only from, that stage's tarballs, and that the action input, instance attachment, outcome and disposal behavior holds in each rebuilt application over a real MCP connection. ## Publication is separate from candidate preparation @@ -143,17 +143,16 @@ Preparing the candidate grants no authority to publish. Publishing needs the own ## Obligations and renderer paths the candidate implements -Recorded from the repository's own records; package metadata, type declarations compiling or Lit passing establishes none of the open rows. +Recorded from the repository's own records. -| Obligation or path | In the candidate | Open or not established | -| --- | --- | --- | -| Action runtime (`@saga-a2ui/core/actions`): local and server actions, prerequisites, bounded hooks, activation-scoped resources, `subject` freshness, `limits` (30,000 ms work and 5,000 ms per hook or release; field override, then instance, then default; invalid values rejected; remaining time clamped, never reset) | Implemented and tested through core, Lit and React | Real-device latency: the numbers are product defaults, not measured | -| Request lifetime (`@saga-a2ui/mcp/server`): separate 3,000 ms operation and 10,000 ms total request budgets starting before authentication, finite-response delivery | Implemented and tested (CORE3, STATE2) | Ongoing-stream idle and backpressure policy is separate and stays with CORE3's record | -| Playback retirement and label loss: permanent retirement of playback until a new view | Implemented in the reference application (STATE1) | Hosts that reuse a view stay retired until reload | -| Lit renderer path | Implemented, packed-consumer and built-browser checked | Windows is not claimed | -| React renderer and MCP stack | Typed surfaces, actions and optional FaithfulCard composition are implemented, with packed-consumer and browser checks | This update/rollback rehearsal covers the Lit `own` and `card` combinations; it does not rehearse the React application or establish whole-runtime equivalence | -| Lynx | Nothing: the owner retired Lynx delivery on 2026-10-19; `apps/lynx-fixture` is historical evidence, not a recipe | No Lynx package, recipe or host gate is required | -| Host-to-server cancellation and rollback in an actual host | Not implemented or claimed | Open (STATE2/CORE2 receipts bound what was seen) | -| Application choices and remaining correlation questions in the [action binding](../blueprints/modules/action_binding.blueprint.md#open-decisions) and [lifetime](../blueprints/features/request_view_lifetimes.blueprint.md#open-decisions) designs | The candidate preserves the implemented runtime contracts | Applications own confirmation, audit, error wording, idempotency and transferred-resource policy; a candidate rehearsal does not decide those choices or add missing host correlation | - -**Named-host observations that exist** (none is repeated or extended by this candidate): ChatGPT web and Claude web receipts recorded in the lifecycle sections of the [connector](../blueprints/features/reusable_mcp_connector.blueprint.md#status) and [lifetime](../blueprints/features/request_view_lifetimes.blueprint.md#status) designs (CORE2 Play flow, STATE1 retirement and recovery, STATE2 finite waits, CARD1/CARD2 card reading); and the [host compatibility observations](host-compatibility.md), including the Alexa+ in-card call that timed out with a different probe. No named host was run against this candidate's tarballs or views, and the local host fixture is not an assistant host. +| Obligation or path | In the candidate | +| --- | --- | +| Action runtime (`@saga-a2ui/core/actions`): local and server actions, prerequisites, bounded hooks, activation-scoped resources, `subject` freshness, `limits` (30,000 ms work and 5,000 ms per hook or release; field override, then instance, then default; invalid values rejected; remaining time clamped, never reset) | Implemented and tested through core, Lit and React; the numbers are product defaults | +| Request lifetime (`@saga-a2ui/mcp/server`): separate 3,000 ms operation and 10,000 ms total request budgets starting before authentication, finite-response delivery | Implemented and tested (CORE3, STATE2) | +| Playback retirement and label loss: permanent retirement of playback until a new view | Implemented in the reference application (STATE1); a host that reuses a view shows it retired until reload | +| Lit renderer path | Implemented, packed-consumer and built-browser checked; the update and rollback rehearsal runs the Lit `own` and `card` combinations | +| React renderer and MCP stack | Typed surfaces, actions and optional FaithfulCard composition are implemented, with packed-consumer and browser checks | +| Lynx | The owner retired Lynx delivery on 2026-10-19; `apps/lynx-fixture` is historical evidence | +| Application choices in the [action binding](../blueprints/modules/action_binding.blueprint.md#open-decisions) and [lifetime](../blueprints/features/request_view_lifetimes.blueprint.md#open-decisions) designs | The candidate preserves the implemented runtime contracts. Applications own confirmation, audit, error wording, idempotency and transferred-resource policy | + +**Named-host observations**: ChatGPT web and Claude web receipts recorded in the lifecycle sections of the [connector](../blueprints/features/reusable_mcp_connector.blueprint.md#status) and [lifetime](../blueprints/features/request_view_lifetimes.blueprint.md#status) designs (CORE2 Play flow, STATE1 retirement and recovery, STATE2 finite waits, CARD1/CARD2 card reading), and [where SAGA runs](host-compatibility.md). diff --git a/docs/change-hygiene.md b/docs/change-hygiene.md index c48b97a1..a7551f47 100644 --- a/docs/change-hygiene.md +++ b/docs/change-hygiene.md @@ -2,7 +2,7 @@ ## Describe the change -Use a Conventional Commit title. Start the PR description with the concrete problem and resulting behavior; include a before/after example when useful. Explain the final implementation, why it solves the problem, checks actually run and material limits. Keep simple changes to a few sentences plus validation. Link the community issue when one exists. Internal delivery records link outward to this PR; do not expose private ticket links or create a public ticket solely for an internal change. +Use a Conventional Commit title. Start the PR description with the concrete problem and resulting behavior; include a before/after example when useful. Explain the final implementation, why it solves the problem, and the checks actually run. Record open gaps in Backstage, not here; follow [state what works](../CONTRIBUTING.md#state-what-works). Keep simple changes to a few sentences plus validation. Link the community issue when one exists. Internal delivery records link outward to this PR; do not expose private ticket links or create a public ticket solely for an internal change. Rewrite the title and description when scope changes. Remove abandoned approaches and drafting history unless they explain a tradeoff. Separate source readiness from installation, deployment and actual-host acceptance. State explicit merge holds. Report version updates under [the version policy](version-policy.md); a bump does not authorize publication. @@ -14,11 +14,11 @@ This section owns how to collect, summarize and place verification evidence. Do 2. **Collect proportionally.** Prefer a reproducible command and a concise result with the exact revision. Keep one representative screenshot for a visual claim when it adds information a test result cannot convey. Retain another capture only for a distinct required state, failure or host behavior. Reuse still-valid evidence for unchanged behavior and test the affected boundary after a repair; do not repeat whole host matrices or generate screenshots to decorate a completion report. Done when each capture proves a claim no other item proves. 3. **Keep disposable output ignored.** Raw runs, duplicate screenshots, video, traces and reports stay in ignored `.wise-agents/`, `test-results/` or `playwright-report/` directories. Done when `git status` shows none of them. 4. **Classify what remains.** Place it by purpose, using the table in [CONTRIBUTING.md#documentation](../CONTRIBUTING.md#documentation): - - A change's observed verification goes in its PR description and, for Blueprint-backed work, the applicable Blueprint Status summary. State the claim, exact revision, command or action, observed result, scope and limitations so a reader without private access can assess it. - - Detailed maintainer audit trails, probe observations and testing dossiers belong to the private Backstage record lifecycle. Sanitize what acceptance needs and route it there; do not add it to current SDK guides, this repository's `docs/`, or a dated or edition-named file. + - A change's observed verification goes in its PR description and, for Blueprint-backed work, the applicable Blueprint Status summary. State the claim, exact revision, command or action, observed result and scope so a reader without private access can assess it. + - Detailed maintainer audit trails, probe observations and testing dossiers belong to the private Backstage record lifecycle. Open gaps, failed probes and unverified behavior go there too, as Backstage issues. Do not add any of it to current SDK guides, this repository's `docs/`, or a dated or edition-named file. - Public explanatory diagrams, runnable examples and minimal reproduction fixtures stay with their existing source owners, under the storage rules below. Done when every kept item has one destination and the PR needs no private access to be understood. -5. **State the boundary.** Keep source readiness, package installation, deployment, actual-host observation and publication as separate claims in every summary, and mark what was inferred or not run. Historical evidence stays historical; version updates must not rewrite it. Keep private records, credentials and personal data out of everything public, even when stored in LFS. Done when no summary claims more than its evidence shows. +5. **State the boundary.** Keep source readiness, package installation, deployment, actual-host observation and publication as separate claims in every summary. Claim only what ran and passed; leave the rest out and track it in Backstage. Historical evidence stays historical; version updates must not rewrite it. Keep private records, credentials and personal data out of everything public, even when stored in LFS. Done when no summary claims more than its evidence shows. ## Store retained artifacts with Git LFS diff --git a/docs/development-reference.md b/docs/development-reference.md index 6a509575..3be498f5 100644 --- a/docs/development-reference.md +++ b/docs/development-reference.md @@ -1,6 +1,6 @@ # Development reference -Build, test and implementation notes accumulated as the SDK evolved. Dated slices describe their own evidence and limitations; use [Getting started](getting-started.md) for the current setup path and the [project overview](../README.md) for current scope. +Build, test and implementation notes accumulated as the SDK evolved. Dated slices describe what each delivered and how it is checked; use [Getting started](getting-started.md) for the current setup path and the [project overview](../README.md) for current scope. New here: [Getting started](../docs/getting-started.md) takes you to a new project from a qualified starter or an attachment to an existing backend, and to two tracks, [Build tic-tac-toe with React](build-tic-tac-toe-with-react.md) and [Build the notes application with Lit](build-notes-with-lit.md), whose Lit track runs the demonstration. Every command is checked against the repository. [Public installation](public-install.md) covers Lit and React registry recipes; local qualification and Lit starters use separately built candidate packages. To change a component and trace allowed, denied and unauthenticated requests through an action, read [Change a component and trace allowed and denied requests](actions.md). @@ -14,9 +14,9 @@ TypeScript middleware for connecting existing services to assistant experiences, The seven SDK packages belong to the public release set, including both renderers and both MCP stacks. See [public installation](public-install.md) for registry availability and exact-version recipes, and [release preparation](release-packages.md) for artifact qualification. Local development uses private candidate artifacts to exercise unpublished source changes; later dated slices preserve their original qualification state. -The workspace has seven owned packages: `packages/core`, `packages/mcp`, `packages/components` (shared FaithfulCard reading, policy and application definitions), `packages/react` (typed surfaces, action integration and optional FaithfulCard rendering), `packages/lit`, and the two stack packages `packages/react-mcp-stack` and `packages/lit-mcp-stack` (see "The MCP stacks and the card application (E1, E2)"). A minimal private Lit application (`apps/lit-host`) builds and renders locally, and a retained Lynx Web qualification fixture (`apps/lynx-fixture`, see ACT3; not an adapter, and the owner retired the Lynx delivery chain on 2026-10-19, so it is history and not a requirement) runs ACT1's action runtime on ReactLynx. The shared action contract (`@saga-a2ui/core/actions`, with the opt-in Lit and React controls) is implemented as local and server actions, and the Lit application's Play runs on it; "The action contract across the current codebase (ACT7)" below says what it supports, what retained owners keep and what is unqualified. The repository is public; publication of a package version is a separate owner decision. +The workspace has seven owned packages: `packages/core`, `packages/mcp`, `packages/components` (shared FaithfulCard reading, policy and application definitions), `packages/react` (typed surfaces, action integration and optional FaithfulCard rendering), `packages/lit`, and the two stack packages `packages/react-mcp-stack` and `packages/lit-mcp-stack` (see "The MCP stacks and the card application (E1, E2)"). A minimal private Lit application (`apps/lit-host`) builds and renders locally, and a retained Lynx Web qualification fixture (`apps/lynx-fixture`, see ACT3; not an adapter, and the owner retired the Lynx delivery chain on 2026-10-19, so it is history and not a requirement) runs ACT1's action runtime on ReactLynx. The shared action contract (`@saga-a2ui/core/actions`, with the opt-in Lit and React controls) is implemented as local and server actions, and the Lit application's Play runs on it; "The action contract across the current codebase (ACT7)" below says what it supports and what retained owners keep. The repository is public; publication of a package version is a separate owner decision. -SAGA views have been observed in ChatGPT web, Claude web and the Alexa+ simulator. Interaction support depends on the tested flow: [host compatibility](host-compatibility.md) distinguishes successful web-host actions from the Alexa+ in-card calls that failed. The notes browse and its row actions also run through a React consumer of the same contract, server and actions (see "Browsing the notes workflow through React (REACT1)"; qualified in the local fixture host), and the protected Play through a React control of the same controller and actions (see "Playing through React (REACT2)"; also qualified locally). Both React and Lit support the implemented surface, action and card composition described in [view composition](../blueprints/features/view_composition.blueprint.md). Their packed-consumer and local-fixture checks establish the behaviors they exercise; named-host observations remain tied to the exact application and resource in [host compatibility](host-compatibility.md). +SAGA views run in ChatGPT web, Claude web and Alexa+; [host compatibility](host-compatibility.md) describes the interactions in each host. The notes browse and its row actions also run through a React consumer of the same contract, server and actions (see "Browsing the notes workflow through React (REACT1)"; qualified in the local fixture host), and the protected Play through a React control of the same controller and actions (see "Playing through React (REACT2)"; also qualified locally). Both React and Lit support the implemented surface, action and card composition described in [view composition](../blueprints/features/view_composition.blueprint.md). Named-host observations are recorded per application and resource in [host compatibility](host-compatibility.md). ## Packages @@ -152,9 +152,9 @@ Repair a defect there, not at a call site. - **Formatter.** `npm run format` and the pre-commit hook refuse a selected path that is a link, a special file or outside the checkout, and replace a file through a temporary file after checking that it is the file that was read. -- **Limit.** Node has no directory-relative file calls, so each check narrows the window to two adjacent - calls and does not close it. Ancestors of a path the operator named are the operator's own. An attacker - who can already rewrite the checkout's tracked files or run code as the same user is out of scope. +- **Scope.** Each check runs in the call immediately before the operation it guards. Ancestors of a path + the operator named are the operator's own. An attacker who can already rewrite the checkout's tracked + files or run code as the same user is outside this boundary. If a command reports a link at an output path, remove or move that link; the tool will not follow it. @@ -221,10 +221,9 @@ it sends no code to a new hosted analyzer. A hosted alternative would add account/access and plan maintenance without simplifying this small rule set, so none is selected or purchased. GitHub standard hosted runners are free for public repositories; private repositories use their plan's included minutes and -can incur overage charges. The account's remaining allowance is not inferred. +can incur overage charges. See [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions). -The implementation PR records observed local/CI runtime rather than a speculative -performance target or an invented monthly bill. +The implementation PR records observed local/CI runtime. References: [ESLint severities](https://eslint.org/docs/latest/use/configure/rules), [typescript-eslint setup](https://typescript-eslint.io/getting-started/), and @@ -290,9 +289,8 @@ including the explicitly tagged `!!merge <<` forms) in place of a step, job, is not a string scalar; and any text the parser reports as an error or a warning. -The scope is a YAML 1.2 reading of the files. It is not shown to match the -reader GitHub Actions runs, so the check rejects unusual constructs rather than -claiming every executable form is understood. +The check reads the files as YAML 1.2 and rejects any unusual construct it +cannot classify. To update an action, resolve the reviewed release tag to its commit (`gh api repos/OWNER/REPO/commits/TAG --jq .sha`), change the SHA and comment @@ -302,17 +300,15 @@ The full check retains the entire existing route: packed React pnpm consumer, `npm run verify`, `candidate:pack`, `candidate:rehearse`, and the SHA-named candidate artifact retained for 14 days. Named steps identify each operation. The rehearsal rebuilds A and B from the same implementation at different -versions; it proves that install/update/rollback procedure, not compatibility -between different implementations. +versions and runs the install, update and rollback procedure across them. Before any separately authorized release, run **CI → Run workflow** on the exact release ref (or `gh workflow run ci.yml --ref YOUR_RELEASE_REF`) and require the full check to pass for that revision. Manual runs always bypass path exemptions; the artifact upload follows successful packing and rehearsal in -the same job. PR and main code changes also run it automatically. A skipped -docs-only check is not release qualification. This workflow publishes and -deploys nothing and holds no registry credential. It does not establish npm -installation or assistant-host acceptance. +the same job. PR and main code changes also run it automatically. Release +qualification requires the full check, not the docs-only route. This workflow +publishes and deploys nothing and holds no registry credential. ### Repository verification @@ -324,13 +320,11 @@ Browser installation is an explicit prerequisite. Install the Playwright browser CI runs `npx playwright install --with-deps chromium` before `npm run verify`. -Each invocation writes a disposable receipt to `.wise-agents/verify-receipts/` (ignored) and copies it to `latest.json` in that directory. The receipt records the working directory, the verify process arguments, the Node and npm versions that actually ran, the git commit and tracked dirty state at each command boundary, and each command's name, arguments, timestamps, and exit code. It does not record environment variables or command output, so do not put secrets in command arguments. A run is labeled `pristine-head` only when the commit and tracked dirty state are clean at the start and unchanged through every command that ran. A dirty tracked file or a commit created during verification is recorded as `not-pristine-head`. That label is not itself a failure. Detection is at command boundaries, not a continuous watch. `--exec` replaces the repository suite and marks the receipt `exec`; `npm run verify` does not pass it. +Each invocation writes a disposable receipt to `.wise-agents/verify-receipts/` (ignored) and copies it to `latest.json` in that directory. The receipt records the working directory, the verify process arguments, the Node and npm versions that actually ran, the git commit and tracked dirty state at each command boundary, and each command's name, arguments, timestamps, and exit code. It does not record environment variables or command output, so do not put secrets in command arguments. A run is labeled `pristine-head` only when the commit and tracked dirty state are clean at the start and unchanged through every command that ran. A dirty tracked file or a commit created during verification is recorded as `not-pristine-head`. That label is informational; the run's result is its exit code. State is sampled at command boundaries. `--exec` replaces the repository suite and marks the receipt `exec`; `npm run verify` does not pass it. -The static guard is not a general truthiness analysis and does not prove a policy is correct. It reports `||` whose left operand, after removing parentheses and zero-argument `.trim()` calls, is a string or untagged template that contains fixed non-whitespace text. It reports the file and line and exits nonzero. It does not flag identifiers, interpolation-only templates, whitespace-only fixed text, `||` nested inside an interpolation, concatenation, `&&`, `??`, non-zero-argument `.trim()`, or other methods. It parses authored `.ts`, `.tsx`, `.mts`, and `.cts` files with the TypeScript compiler and skips `.d.ts`, dependencies, and generated output (`node_modules`, `dist`, `coverage`, `test-results`, `playwright-report`). It does not typecheck. +The static guard reports `||` whose left operand, after removing parentheses and zero-argument `.trim()` calls, is a string or untagged template that contains fixed non-whitespace text. It reports the file and line and exits nonzero. Identifiers, interpolation-only templates, whitespace-only fixed text, `||` nested inside an interpolation, concatenation, `&&`, `??`, non-zero-argument `.trim()` and other methods are outside the rule. It parses authored `.ts`, `.tsx`, `.mts`, and `.cts` files with the TypeScript compiler and skips `.d.ts`, dependencies, and generated output (`node_modules`, `dist`, `coverage`, `test-results`, `playwright-report`). -Test discovery asks Vitest for its file list (`vitest list --filesOnly` against `vitest.config.ts`) and compares that with authored `*.test.ts` files. Playwright `*.spec.ts` files are compared separately with `playwright test --list`. An authored file omitted by its runner fails the check. The file walk does not exclude fixture or `e2e` tests. Discovery does not run the tests, measure coverage, or prove an architectural boundary. - -Passing verify does not guarantee coverage, host compatibility, or architecture. +Test discovery asks Vitest for its file list (`vitest list --filesOnly` against `vitest.config.ts`) and compares that with authored `*.test.ts` files. Playwright `*.spec.ts` files are compared separately with `playwright test --list`. An authored file omitted by its runner fails the check. The file walk includes fixture and `e2e` tests. The workspace root and `apps/lit-host` are development tooling, not npm packages. The local host declares no application version. All seven SDK packages have enrolled release versions and public npm releases; [package layout](../packages/README.md), [public installation](public-install.md) and [version policy](version-policy.md) describe their ownership, available versions and update rules. @@ -356,7 +350,7 @@ Invalid messages, unknown catalogs/components or wrongly typed properties produc ### MCP tool result in the view (MCP1) -`apps/lit-host` also hosts an ordinary MCP server and renders one of its tool results through the same A2UI session. Public synthetic data only; no authentication, playback, SDK packages or actual-host (ChatGPT/Claude) acceptance. +`apps/lit-host` also hosts an ordinary MCP server and renders one of its tool results through the same A2UI session. It serves public synthetic data. Pinned additions (exact; the MCP packages are the MCPV2 recipe below): `@modelcontextprotocol/ext-apps` 2.0.3 (MIT; its optional `react` peers are not installed) with `client`, `core` and `server` 2.4.0 and `node` 2.1.1 (Apache-2.0), dev-only `vite-plugin-singlefile` 2.3.3, `tsx` 4.23.15, `@types/node` 22.20.5. No MCP-UI, CopilotKit, AG-UI or Ora packages are used; the only overlap is shared standards (MCP, MCP Apps, A2UI). @@ -369,7 +363,7 @@ Modules in `apps/lit-host/src`: - `mcp/server.ts`: `createEpisodeServer` declares one explicitly public, read-only operation `get_episode` (runtime-validated `episodeId`) and the episode view resource (its `ui://views/view..v1.html` URI names the content policy and the contract major of the view, see Identity below) through `registerPublicOperation` from `@saga-a2ui/mcp/server`. Only `episodeId`, `title` and `description` are projected from a record. The resource is the built HTML served verbatim, so request data never enters cached HTML; its CSP metadata allows no network or external resources. - `mcp/listener.ts`: application-owned loopback Streamable HTTP hosting (`POST /mcp`, stateless, JSON responses, Host-header DNS-rebinding protection, no auth because the tool is public). - `mcp/view.ts`, `view-main.ts`, `view.html`: the view connects the MCP Apps `App` bridge, then turns each tool result into an A2UI data-model update and hands it to the existing session. Decoding stays in `protocols/a2ui`; rendering stays in `lit/`. `vite.view.config.ts` builds it to one HTML file with no server imports. -- `e2e/host-fixture`, `e2e/mcp-view.spec.ts`: a test-only local host with a real `AppBridge` and a `sandbox="allow-scripts"` iframe. The spec makes real `tools/call` and `resources/read` requests over HTTP, delivers the result, and checks the wire result, rendered title, CSP and resource linkage. `e2e/view-bundle.spec.ts` checks the built artifacts for server code and credentials. The fixture is not evidence of compatibility with any actual host. +- `e2e/host-fixture`, `e2e/mcp-view.spec.ts`: a test-only local host with a real `AppBridge` and a `sandbox="allow-scripts"` iframe. The spec makes real `tools/call` and `resources/read` requests over HTTP, delivers the result, and checks the wire result, rendered title, CSP and resource linkage. `e2e/view-bundle.spec.ts` checks the built artifacts for server code and credentials. ### The MCP stacks and the card application (E1, E2) @@ -377,30 +371,30 @@ Modules in `apps/lit-host/src`: | Entry | Holds | | --- | --- | -| `.` | `startA2uiView({ container, layout, catalog, actions, readyWhen, sizing, hostContext, onToolResult, onTeardown, onError })`, with the names an own-catalog application writes its view against: the layout (`defineSurface`, `z3`, A2UI's Zod 3), the catalog (`Catalog`, `CommonSchemas`, and for Lit `A2uiLitElement`, `basicCatalog`, `LitComponentApi`; for React `createComponentImplementation`, `ReactCatalogComponent`, `ColumnApi`, `ListApi`, `RowApi`), the actions (`Action`, `ActionBinding`, `ActionButton`, `ActionButtonActivation`, `createLitActions` or `createReactActions`, and for Lit the `HostLinks` type of `ViewHost`), the failures (`StartError` with `StartMisuse`, `StartFailure`, and `ErrorPhase`, the phase an `onError` callback is told), `readingProfile` and the delivery callback types. It imports neither `@saga-a2ui/components` nor Markdown: the emitted bundle of an own-catalog application holds neither. Each stack's `exports.test.ts` bundles the entry's module graph from workspace source to show it, and `scripts/pack1.test.ts` builds the own-catalog and card pages from the packed copies and shows that only the card page carries the parser. (`scripts/answer-consumer.test.ts` builds a card application and says nothing about an own-catalog bundle.) | +| `.` | `startA2uiView({ container, layout, catalog, actions, readyWhen, sizing, hostContext, onToolResult, onTeardown, onError })`, with the names an own-catalog application writes its view against: the layout (`defineSurface`, `z3`, A2UI's Zod 3), the catalog (`Catalog`, `CommonSchemas`, and for Lit `A2uiLitElement`, `basicCatalog`, `LitComponentApi`; for React `createComponentImplementation`, `ReactCatalogComponent`, `ColumnApi`, `ListApi`, `RowApi`), the actions (`Action`, `ActionBinding`, `ActionButton`, `ActionButtonActivation`, `createLitActions` or `createReactActions`, and for Lit the `HostLinks` type of `ViewHost`), the failures (`StartError` with `StartMisuse`, `StartFailure`, and `ErrorPhase`, the phase an `onError` callback is told), `readingProfile` and the delivery callback types. It imports neither `@saga-a2ui/components` nor Markdown: the emitted bundle of an own-catalog application holds neither. Each stack's `exports.test.ts` bundles the entry's module graph from workspace source to show it, and `scripts/pack1.test.ts` builds the own-catalog and card pages from the packed copies and shows that only the card page carries the parser. | | `./faithful-card` | `FaithfulCardApp` (the definition builder of `@saga-a2ui/components/faithful-card`), `startFaithfulCardApp(definition, { container, readyWhen, sizing, hostContext, chrome, onError })` (the Lit stack also takes `styles`, the application's constructable stylesheets for its card), the types `CardResultOutcome`, `CardResultReader` and `DeliveredResult`, the card's host link (`useHostLink` in the React stack, `bindHostLink` in the Lit stack), `readingProfile`, and for the Lit stack `contentPolicyFromDocument` and `documentContentSecurityPolicies`. | | `./content-policy` | `createContentPolicy` and `embedContentPolicy` (and their types): how a server states and embeds the policy that the card application reads back from its document. No renderer and no delivery code. | | `./server` | `createServerBudgets`, `registerPublicOperation`, `registerProtectedOperation`, `operationViewFromHtml`, `viewUriForMajor`, `viewUriForHtml`, `attachRequestLifetime` and the types `OperationView` and `RequestLifetime`, re-exported from `@saga-a2ui/mcp/server`, which keeps their specification. Node only: no browser entry imports it, and `exports.test.ts` checks that the browser graphs hold no server module and the server graph holds no renderer or components module. | | `.` (and `./faithful-card`) | `readingProfile(context, hostName)`: the one place that recognises hosts read on a device, from `@saga-a2ui/mcp/view`. | -Both starts return a running view synchronously (`{ ready, dispose }`; the A2UI view also exposes `surface`). The layout is validated before the connection is made, results that arrive during the readiness work are held and the latest is shown once it settles, and the stack owns the view's size reports. A second start on a container with a running view throws `container-busy` and leaves the first alone; after `dispose()` settles the container starts again. The card application is a pure value, `FaithfulCardApp.define({ name, version }).reader(...).policy('document').result(parse).texts(...).build()`; the parse is the application's only decision about a result, and `undefined` is an unsupported result. Its reuse by a delivery other than MCP is unqualified. `apps/lit-host/e2e/card-app-stack.spec.ts` runs both stacks in Chromium against the local host fixture (a fixture, not any actual host). It also starts a branded card on each stack (the application's stylesheet and its own attribution classifier, `e2e/card-styling/branded.ts`; page CSS for React, the start's `styles` for Lit): semantic structure, the computed rules, the closing marker, font readiness and image-consent reset, the same cases on both stacks. Ending a view in any state removes what it showed, and frees its container, before its connection closes; only a start that failed keeps its failure on screen until it is disposed. `apps/lit-host/e2e/view-stack.spec.ts` runs `startA2uiView` of both stacks the same way: disposal and `pagehide` during the readiness work, throwing `onTeardown` and `onError` callbacks in a starting and in a ready view (one teardown, the `pagehide` listener removed, a later `pagehide` inert, the container started again), and a readiness failure beside a handshake failure. It builds with the other pages but is not part of the installed-package set. +Both starts return a running view synchronously (`{ ready, dispose }`; the A2UI view also exposes `surface`). The layout is validated before the connection is made, results that arrive during the readiness work are held and the latest is shown once it settles, and the stack owns the view's size reports. A second start on a container with a running view throws `container-busy` and leaves the first alone; after `dispose()` settles the container starts again. The card application is a pure value, `FaithfulCardApp.define({ name, version }).reader(...).policy('document').result(parse).texts(...).build()`; the parse is the application's only decision about a result, and `undefined` is an unsupported result. `apps/lit-host/e2e/card-app-stack.spec.ts` runs both stacks in Chromium against the local host fixture. It also starts a branded card on each stack (the application's stylesheet and its own attribution classifier, `e2e/card-styling/branded.ts`; page CSS for React, the start's `styles` for Lit): semantic structure, the computed rules, the closing marker, font readiness and image-consent reset, the same cases on both stacks. Ending a view in any state removes what it showed, and frees its container, before its connection closes; only a start that failed keeps its failure on screen until it is disposed. `apps/lit-host/e2e/view-stack.spec.ts` runs `startA2uiView` of both stacks the same way: disposal and `pagehide` during the readiness work, throwing `onTeardown` and `onError` callbacks in a starting and in a ready view (one teardown, the `pagehide` listener removed, a later `pagehide` inert, the container started again), and a readiness failure beside a handshake failure. It builds with the other pages but is not part of the installed-package set. #### The view conversation and operations without a view `@saga-a2ui/core/conversation` holds the delivery-neutral contract (`Conversation`, `AppendResult`, `RequestOutcome`, `NO_CONVERSATION`; types plus one frozen value, no import), beside `@saga-a2ui/core/host-links`. `conversationFromApp(app, signal)` in `@saga-a2ui/mcp/view` is the MCP Apps implementation, and both stacks give `ViewHost.conversation`, built from the same `App` and lifetime signal as `server` and (Lit) `links`. The stacks re-export the three types. The behaviour is specified in the [view conversation Blueprint](../blueprints/features/view_conversation.blueprint.md); the points a maintainer needs: - Capabilities (`updateModelContext`, `message`) are read at call time. With `context`, `ui/update-model-context` is sent first (`text` as one text content block, `data` as `structuredContent`), then `ui/message` with role `user` and one text content block, never in parallel. A context that is unsupported, rejected or answered with `isError` stops the message at `stage: 'context'` unless `bestEffort` is set; then the message is still attempted and an accepted one reports `{outcome: 'appended', context: }`. -- Payloads are admitted before anything is sent, against `CONVERSATION_LIMITS` in `packages/mcp/src/view/conversation.ts` (16 KiB of UTF-8 for the message, 16 KiB for the context text, 64 KiB of exact `JSON.stringify` size for the structured data). `snapshotData` copies the data while it measures it, with escapes, braces, colons and commas counted, and the request carries that copy, so the original object is never read again; the context object is read once. The data must be plain objects, arrays, strings, finite numbers, booleans and `null`, at most 32 levels deep without cycles: `undefined`, `bigint`, symbols, functions, non-finite numbers, array holes, accessor properties and non-plain objects (`Map`, `Date`, class instances) are refused, not converted. An over-budget message resolves `denied` at the message stage with nothing sent, context included; an over-budget context resolves `denied` at the context stage and follows the `bestEffort` rules. Nothing is truncated or rewritten, and the walk stops once it passes the budget (an array longer than the budget is refused unread). The engine's own enumeration of an existing object's keys is not bounded by the walk. The limits are the SDK's own: no supported host publishes one. +- Payloads are admitted before anything is sent, against `CONVERSATION_LIMITS` in `packages/mcp/src/view/conversation.ts` (16 KiB of UTF-8 for the message, 16 KiB for the context text, 64 KiB of exact `JSON.stringify` size for the structured data). `snapshotData` copies the data while it measures it, with escapes, braces, colons and commas counted, and the request carries that copy, so the original object is never read again; the context object is read once. The data must be plain objects, arrays, strings, finite numbers, booleans and `null`, at most 32 levels deep without cycles: `undefined`, `bigint`, symbols, functions, non-finite numbers, array holes, accessor properties and non-plain objects (`Map`, `Date`, class instances) are refused, not converted. An over-budget message resolves `denied` at the message stage with nothing sent, context included; an over-budget context resolves `denied` at the context stage and follows the `bestEffort` rules. Nothing is truncated or rewritten, and the walk stops once it passes the budget (an array longer than the budget is refused unread). The limits are the SDK's own. - Once the lifetime signal has aborted, including while a request is outstanding, the call resolves `unavailable`, sends nothing further and ignores the late answer. Nothing is retried. -- The pinned bridge (`@modelcontextprotocol/ext-apps` 2.0.3) gives `ui/update-model-context` an empty result and rejects any other key, so a host cannot answer a context update with `isError`: its refusal can only be a rejected request, which reads `failed`. `denied` at the context stage is kept for a bridge that allows it and is tested only against a stub. `ui/message` can answer `isError`, which reads `denied`. +- The pinned bridge (`@modelcontextprotocol/ext-apps` 2.0.3) gives `ui/update-model-context` an empty result and rejects any other key, so a host cannot answer a context update with `isError`: its refusal can only be a rejected request, which reads `failed`. `denied` at the context stage is kept for a bridge that allows it. `ui/message` can answer `isError`, which reads `denied`. -Tests: `packages/core/src/conversation.test.ts` (the default instance), `packages/mcp/src/view/conversation.test.ts` (a real `App` and `AppBridge`: message only, the order, a held context, withheld, failed and best-effort contexts, refused, failing and unavailable messages, payloads at and over each budget, exact JSON sizing with escapes, non-JSON values, accessors and large objects, an ended view before and during each request), the `ViewHost.conversation` case in each stack's `start-a2ui-view.test.ts`, and `apps/lit-host/e2e/view-conversation.spec.ts`, which runs the same scenarios through both stacks in Chromium against the local host fixture (probes in `e2e/view-conversation/`, built by `vite.view-conversation.config.ts`; like the view-stack probes, not part of the installed-package set). The fixture (`host-fixture/host.ts`) takes `conversation: {context, message}` in `mountView`, each `accept`, `refuse`, `fail`, `withhold` (the capability is not advertised) or `hold` (recorded, unanswered until `releaseConversation`), and records every request in order (`conversationRequests`). It is a fixture, not any actual host: whether Claude, ChatGPT or Alexa+ show the message, and how their assistants answer, is unverified. +Tests: `packages/core/src/conversation.test.ts` (the default instance), `packages/mcp/src/view/conversation.test.ts` (a real `App` and `AppBridge`: message only, the order, a held context, withheld, failed and best-effort contexts, refused, failing and unavailable messages, payloads at and over each budget, exact JSON sizing with escapes, non-JSON values, accessors and large objects, an ended view before and during each request), the `ViewHost.conversation` case in each stack's `start-a2ui-view.test.ts`, and `apps/lit-host/e2e/view-conversation.spec.ts`, which runs the same scenarios through both stacks in Chromium against the local host fixture (probes in `e2e/view-conversation/`, built by `vite.view-conversation.config.ts`; like the view-stack probes, not part of the installed-package set). The fixture (`host-fixture/host.ts`) takes `conversation: {context, message}` in `mountView`, each `accept`, `refuse`, `fail`, `withhold` (the capability is not advertised) or `hold` (recorded, unanswered until `releaseConversation`), and records every request in order (`conversationRequests`). **Operations without a view.** `view` is optional on `PublicOperation` and `ProtectedOperation`. `register` in `packages/mcp/src/server/operation.ts` is the only view-specific step: with a view it registers the app tool and resource and links every result to the view; without one it calls `McpServer.registerTool` with the same title, description, contracts and annotations and the same callback, registers no resource and adds no `_meta` anywhere. `execute()` is untouched, so input validation, budgets, cancellation, safe failures and (for a protected operation) the identity requirement are the same. A view that is declared must still be complete; an omitted or `undefined` one is a viewless operation. `packages/mcp/src/server/operation-viewless.test.ts` registers each declaration with and without `view` over Streamable HTTP and compares invalid input, refusal, an absent record, an over-budget handler, a throwing handler, cancellation, an asynchronous and a malformed projection and invalid output, then checks the viewless tool definition and every result carry no `_meta.ui` and the server lists no resource, and that a protected declaration still refuses a missing identity. Both stacks' `/server` entries re-export `registerProtectedOperation` beside `registerPublicOperation`, so a stack consumer reaches the viewless registration without naming an MCP Apps server API; `scripts/fixtures/mcp-server-consumer.ts` (a packed, server-only application) declares both access modes without a view, and the two stack consumer fixtures use `host.conversation`. #### Reading the whole result -The definition owns the interpretation of a result. `@saga-a2ui/core/delivered-result` holds the delivery-neutral value, `DeliveredResult`: a boolean `isError`, `content` (every block in delivered order, each with its `type`, a text block with its `text`, any other block opaque and as supplied) and an optional, unchecked `structuredContent`. An absent `structuredContent`, a present `null` and malformed data stay distinct, so `'structuredContent' in result` tells absence from `null`. `deliveredResultFromToolResult` in `@saga-a2ui/mcp/view` maps an MCP tool result onto it and decides nothing: an error result is not rejected, and no structured content is read. It first counts the result against the view-result admission budget (4 MiB of serialized JSON text, 200000 values, 64 levels, 10000 items per array and 1000 content blocks by default; the byte count is an upper bound on the UTF-8 length of the result's JSON, so a character JSON escapes, such as a quote or a control character, counts at its escaped length; `runToolResultView` applies the same budget before it holds or delivers a result or a tool input, and the limits it reports in a refusal are a frozen copy), and a result over it throws a `ToolResultRefused`, which a stack shows as the definition's problem text without calling the reader. The browser has already received a host message when the view's listener runs; the budget bounds what the SDK keeps, walks, copies and forwards, not what the host sent. Both stacks pass that value, unchanged, to the definition's one `result` reader, which returns `{kind: 'content', content}` or `{kind: 'rejected', text?}`: +The definition owns the interpretation of a result. `@saga-a2ui/core/delivered-result` holds the delivery-neutral value, `DeliveredResult`: a boolean `isError`, `content` (every block in delivered order, each with its `type`, a text block with its `text`, any other block opaque and as supplied) and an optional, unchecked `structuredContent`. An absent `structuredContent`, a present `null` and malformed data stay distinct, so `'structuredContent' in result` tells absence from `null`. `deliveredResultFromToolResult` in `@saga-a2ui/mcp/view` maps an MCP tool result onto it and decides nothing: an error result is not rejected, and no structured content is read. It first counts the result against the view-result admission budget (4 MiB of serialized JSON text, 200000 values, 64 levels, 10000 items per array and 1000 content blocks by default; the byte count is an upper bound on the UTF-8 length of the result's JSON, so a character JSON escapes, such as a quote or a control character, counts at its escaped length; `runToolResultView` applies the same budget before it holds or delivers a result or a tool input, and the limits it reports in a refusal are a frozen copy), and a result over it throws a `ToolResultRefused`, which a stack shows as the definition's problem text without calling the reader. The browser has already received a host message when the view's listener runs; the budget bounds what the SDK keeps, walks, copies and forwards. Both stacks pass that value, unchanged, to the definition's one `result` reader, which returns `{kind: 'content', content}` or `{kind: 'rejected', text?}`: ```ts import {FaithfulCardApp, type CardResultOutcome, type DeliveredResult} from '@saga-a2ui/react-mcp-stack/faithful-card'; @@ -419,7 +413,7 @@ Three owners each decide once. Core owns the neutral value and imports no delive #### The reading profile and the application's tokens -`readingProfile(context, hostName)` answers `'device'` or `'desktop'` and is exported by both stacks. It recognises the hosts the recorded observations read on a device (Alexa+), the historical `HTMLFragmentWrapper` host name, a host context whose `deviceClass` is an Echo Show class (`echo_show`, `echo_show_8`, `echo_show_15`, matched as a whole underscore-separated word), and a declared `mobile` platform. The `deviceClass` field is an extension beyond the MCP Apps host context schema; the context accepts it and passes it on. The names come from a consumer's fixtures, not from fresh host evidence. The profile decides nothing about presentation: the application calls it from its `hostContext` callback and maps the answer onto its own tokens and its own `data-reading-profile` attribute, with no host-name test of its own. `card-app-stack.spec.ts` shows an `echo_show_15` context setting a 1.6 scale on the application's font-size token with no transform and no sideways overflow. +`readingProfile(context, hostName)` answers `'device'` or `'desktop'` and is exported by both stacks. It recognises the hosts the recorded observations read on a device (Alexa+), the historical `HTMLFragmentWrapper` host name, a host context whose `deviceClass` is an Echo Show class (`echo_show`, `echo_show_8`, `echo_show_15`, matched as a whole underscore-separated word), and a declared `mobile` platform. The `deviceClass` field is an extension beyond the MCP Apps host context schema; the context accepts it and passes it on. The profile decides nothing about presentation: the application calls it from its `hostContext` callback and maps the answer onto its own tokens and its own `data-reading-profile` attribute, with no host-name test of its own. `card-app-stack.spec.ts` shows an `echo_show_15` context setting a 1.6 scale on the application's font-size token with no transform and no sideways overflow. ### The tic-tac-toe example @@ -441,7 +435,7 @@ cd apps/lit-host && npx playwright test e2e/tic-tac-toe.spec.ts # the whole su - **The example** (`scripts/tic-tac-toe.test.ts`): it uses the stack's local listener, keeps application logic outside the transport, imports no `@modelcontextprotocol/ext-apps` module and no `@a2ui/*` package, builds from packed tarballs with no server code in the page, starts with `node dist/server.mjs`, and takes the guided change as a patch to `src/surface.ts` and `src/catalog.tsx` only. - **The board in Chromium** (`apps/lit-host/e2e/tic-tac-toe.spec.ts`, 27 tests, on the local host fixture): the removable credit on a game in play, a finished game and an unavailable game, opening `https://saga-ai.dev/` and the tutorial through the host; clicks and keyboard moves; the context update with the game in `structuredContent`, then the move message that names the game and draws no board; the message still sent with the context capability withheld; the instruction when no message is accepted; a winning and a drawing X move, each recording one result message with no context and no wait; an O move made through the server and picked up by the wait; stale and repeated moves refused with no effect; New game unaffected by a move request still in flight; a failed append that settles after New game showing nothing on the new game; the continued wait and its recovery button; an expired game; a lost reply recovered by reading the game, and that read, held while New game is pressed, leaving the new board playable before and after it settles; the wait ending with the view; and the guided change's move list. No test asserts which square an assistant chooses. The fixture gained `hold-then-refuse`, which holds a conversation request and refuses it when released. - **The guide** (`scripts/tic-tac-toe-document.test.ts`): compares the first lesson's application and build files with its full checkpoint, the completed game with the maintained example, and the extension with the guided-change patch. It checks exact dependency pins, one-stack installation and documentation links. `scripts/tic-tac-toe.test.ts` builds and typechecks all three checkpoints against packed SDK packages. The tutorial uses Vite's React/TypeScript generator; its generated project configuration is separate from the repository's contributor build configuration. -- **Limits.** The automated suite runs locally and in the Chromium host fixture. Actual Claude and ChatGPT observations, including their initial-display retry, are recorded in the [getting-started Blueprint's Status](../blueprints/features/getting_started_tracks.blueprint.md#status). The board reads every 1.5 seconds at first, then every 5 seconds while O has the turn; consecutive connection failures end the wait and offer a recovery control. A server registers a view once per address, so `new_game` and `show_game` declare the same page under two addresses (`new-game` and `show-game`) rather than one shared view. +- **Runtime behavior.** The automated suite runs locally and in the Chromium host fixture. Actual Claude and ChatGPT observations are recorded in the [getting-started Blueprint's Status](../blueprints/features/getting_started_tracks.blueprint.md#status). The board reads every 1.5 seconds at first, then every 5 seconds while O has the turn; consecutive connection failures end the wait and offer a recovery control. A server registers a view once per address, so `new_game` and `show_game` declare the same page under two addresses (`new-game` and `show-game`) rather than one shared view. ### Consumer boundary scan and package directions @@ -464,11 +458,11 @@ Package dependency directions are a separate check. Each package's `exports.test - Failure: an error result, a result that is not a public episode (including undeclared fields) or a rejected update empties the panel (which draws nothing without a title) and shows a plain-text fallback in `[data-testid="view-problem"]`. The next valid result shows the panel again and clears the fallback. - Consent: image consent is revoked before every result is applied, even a repeat of the one shown, and retired for good by the stack's teardown, once. -Tests: `src/mcp/view.test.ts` (`npm run test:headless`) checks those decisions without a bridge. `e2e/mcp-view.spec.ts` (`npm run test:browser`) controls delivery order through the test-only host fixture's real `AppBridge` against the built view: a result sent during initialization, replacement, each failure kind with recovery, delivery after teardown, and remount. Account or selection isolation is a separate, later concern. +Tests: `src/mcp/view.test.ts` (`npm run test:headless`) checks those decisions without a bridge. `e2e/mcp-view.spec.ts` (`npm run test:browser`) controls delivery order through the test-only host fixture's real `AppBridge` against the built view: a result sent during initialization, replacement, each failure kind with recovery, delivery after teardown, and remount. ### Subscriptions for the verified request identity (BROWSE1) -A second, protected MCP endpoint, `POST /subscriptions/mcp`, returns the signed-in user's own subscriptions from a synthetic account fixture. Synthetic data only; no real Patreon access, no actual-host (ChatGPT/Claude) acceptance. No new dependencies. (This section describes the slice as built; its MCP delivery now runs through `@saga-a2ui/mcp`, see "Reusable MCP connector (CORE1)".) +A second, protected MCP endpoint, `POST /subscriptions/mcp`, returns the signed-in user's own subscriptions from a synthetic account fixture. It uses a synthetic account fixture and adds no dependencies. (This section describes the slice as built; its MCP delivery now runs through `@saga-a2ui/mcp`, see "Reusable MCP connector (CORE1)".) - `mcp/request-auth.ts`: application-owned authentication. A bearer credential is checked by an `OAuthTokenVerifier` (the MCP server package's own interface; an unknown credential is an `OAuthError` with `OAuthErrorCode.InvalidToken`) before any server is built or backend called. Each protected endpoint states the `resource` it is (its canonical URL, as clients and the identity provider know it; there is no default) and the credential's `AuthInfo.resource` (RFC 8707) must be exactly that URL, compared as parsed URLs. A resource identifier has no fragment (RFC 8707), so a credential whose resource carries one is invalid and refused, never normalized into a match. A credential that names no resource, or another, is refused before any scope or identity is read, so a token the same verifier accepts for another service never becomes authority here. `startEpisodeListener` rejects, before it opens a port, any configured protected endpoint whose `resource` is missing, is not an absolute http(s) URL, or carries a fragment, and it takes its own copy of each accepted resource at startup: changing the caller's `URL` afterwards moves nothing. Missing credential: `401` with `WWW-Authenticate: Bearer realm=...`; malformed, unknown, expired or issued for another resource: `401 invalid_token`; verified but not a user (service-only, no account or scope): `403 insufficient_scope`. The accepted credential is copied into a frozen, request-owned snapshot of the validated claims before it is attached to `req.auth`: the transport reads that context only after the body arrives, so a verifier that reuses or mutates its own `AuthInfo` cannot change which account an earlier request was authenticated as. The snapshot reaches the tool only as the SDK's per-request `ctx.http.authInfo`, read inside each invocation; an `accountId` argument is dropped. An optional `status` filter is the only declared argument, and it cannot name an account. There is no shared current-user state. - `mcp/dev-auth.ts`: **development-only** verifier with fixed public tokens (`DEV_TOKENS`). It throws when `NODE_ENV=production`, is imported only by `mcp/serve.ts` (the loopback demo) and tests, and is kept out of the browser bundles (checked by `e2e/view-bundle.spec.ts`). A remote host must pass its own configured `verifier` to `startEpisodeListener({ subscriptions })`; the listener has no default. @@ -506,7 +500,7 @@ Tests: `src/mcp/subscriptions.test.ts` (real listener and bearer credentials ove ### Play an episode only after a fresh entitlement check (PLAY1) -A third protected MCP endpoint, `POST /playback/mcp`, lets the signed-in synthetic subscriber press Play in an embedded Lit view and hear a protected episode. Synthetic accounts and a generated tone only; no real Patreon access, no actual-host (ChatGPT/Claude) acceptance. One new runtime dependency: `jose` 6.2.12 (MIT) for JWT proofs and RFC 7638 key thumbprints; no MCP-UI, CopilotKit, AG-UI or Ora package is used. The [v0.1.0 playback walkthrough](../docs/authorized-playback.md) shows the current SDK/application split, Play sequence, media checks, player states and teardown behavior, with the limits of the observed host support. +A third protected MCP endpoint, `POST /playback/mcp`, lets the signed-in synthetic subscriber press Play in an embedded Lit view and hear a protected episode. It uses synthetic accounts and a generated tone. One new runtime dependency: `jose` 6.2.12 (MIT) for JWT proofs and RFC 7638 key thumbprints; no MCP-UI, CopilotKit, AG-UI or Ora package is used. The [v0.1.0 playback walkthrough](../docs/authorized-playback.md) shows the current SDK/application split, Play sequence, media checks, player states and teardown behavior. npm run build && npm run serve:mcp # also prints the playback URL; dev tokens only (see mcp/dev-auth.ts) @@ -530,14 +524,14 @@ Wire mapping, verified against `@modelcontextprotocol/server` 2.4.0 and `ext-app | No usable recipient key (host dropped `_meta`) | `isError`, "Playback is not supported here."; zero entitlement checks, zero media reads | | Backend failure, projection that breaks its contract | `isError` with a different fixed sentence; no capability, no backend text, nothing in logs | -Limits worth knowing: +Implementation notes: -- The MCP server package maps **any** handler failure to an `isError` result (and copies the thrown message into it, which is why handlers catch everything and return fixed sentences); it has no JSON-RPC internal-error path for a registered tool. The ticket's "sanitized internal error" is therefore an `isError` result with a fixed sentence and no success data. A distinct JSON-RPC `-32603` would need a change upstream or a custom dispatcher, which this slice does not add. +- The MCP server package maps **any** handler failure to an `isError` result (and copies the thrown message into it, which is why handlers catch everything and return fixed sentences); it has no JSON-RPC internal-error path for a registered tool. The ticket's "sanitized internal error" is therefore an `isError` result with a fixed sentence and no success data. - Play's input is a strict object, so a forged `accountId` is rejected instead of dropped. MCP Apps 2 accepts that schema in `registerAppTool`, which also writes the legacy flat `ui/resourceUri` key, so Play and its opener use the helper like every other app tool. -- **Feasibility gap kept open:** recipient binding needs the host to forward `_meta` on the view's proxied `tools/call` and to let a sandboxed iframe `fetch` the media origin with custom headers. The local host fixture shows the mechanism works when it does and that Play fails closed when it does not (`unsupported`, no protected fetch). Neither ChatGPT web nor Claude web has been tried; their result decides whether each can carry the binding. Nothing here weakens the binding for a host that cannot. +- **Host requirements.** Recipient binding needs the host to forward `_meta` on the view's proxied `tools/call` and to let a sandboxed iframe `fetch` the media origin with custom headers. The local host fixture shows the binding working when the host does both, and Play failing closed (`unsupported`, no protected fetch) when it does not. The binding is never weakened for a host that cannot carry it. - The media origin answers any origin (`Access-Control-Allow-Origin: *`, no credentials) because a sandboxed iframe sends `Origin: null`. That is safe only because no cookie or ambient header authorizes media; the capability and proof decide every request. -- Expiry, revocation, ranges, redirects, origins and caches are decided together in PLAY2 below. [Request and view lifetimes](../blueprints/features/request_view_lifetimes.blueprint.md) proposes STATE1/STATE2 scope for selection races, teardown, cancellation and bounded request completion, replacing the [rejected account/session proposal](../blueprints/features/account_session_isolation.blueprint.md). Design and breakdown approval remain pending; existing authentication and isolation safeguards and prerequisite edges remain. -- Audio device output is not asserted by headless Chromium beyond the audio clock advancing; "audible playback" in a real host is a Captain acceptance run. +- Expiry, revocation, ranges, redirects, origins and caches are decided together in PLAY2 below. [Request and view lifetimes](../blueprints/features/request_view_lifetimes.blueprint.md) proposes STATE1/STATE2 scope for selection races, teardown, cancellation and bounded request completion, replacing the [rejected account/session proposal](../blueprints/features/account_session_isolation.blueprint.md). +- In headless Chromium, playback is checked by the audio clock advancing. Tests: `src/playback/playback.test.ts` (wire contract through the real listener: declaration, entitled/denied/unsupported/forged/replay, every credential failure, projections, fault mapping with log capture, CORS/CSP), `src/playback/media.test.ts` (recipient binding, replay, expiry, preflight), `src/playback/playback-view.test.ts` (the controller through a real `App`/`AppBridge` whose host forwards to the real listener), `e2e/playback-view.spec.ts` (built view in the sandboxed fixture with real Web Audio: playing, keyboard, denial, sign-in recovery, unsupported, replay after revocation, expiry, recipient binding, pending, live region, 320px reflow with 200% text), and `e2e/view-bundle.spec.ts` (no server code, credential or media element in the built view). @@ -554,7 +548,7 @@ Every acquisition, whole body or byte range, runs the same checks in this order | Revocation lookup | The application's own `checkEntitlement`, asked again on every acquisition for the account and episode the capability was issued for. A denial refuses the request **and drops the capability**, so access granted again later is reached only through a new Play, never by reviving an old copy. | | Lookup or storage fault | Not a refusal and never a grant: a `503` with the fixed body `{"error":"unavailable"}` and no dependency text on the wire or in logs, distinct from the `403` that means expired, revoked or misused. A fault does not revoke the capability. | | In-flight response | A response that already passed every check is not tracked or cancelled and finishes. Every acquisition that **starts** after the revocation or expiry, including a range request issued while the first is still running, gets no bytes. | -| Bytes already delivered | Cannot be retracted. Stopping and clearing the player reduces continued use of held media; it does not erase what the browser or the host already buffered or downloaded. This is the explicit buffered-byte limit. | +| Bytes already delivered | Cannot be retracted. Stopping and clearing the player reduces continued use of held media; it does not erase what the browser or the host already buffered or downloaded. | | Ranges | One `bytes=` range (`a-b`, `a-`, `-n`) is answered `206` with `Content-Range` and `Content-Length`; `Accept-Ranges: bytes` is sent on whole answers. A `bytes=` range the service cannot serve as one slice (past the end, reversed, `-0`, `-`, several ranges, non-numeric) is `416` with `Content-Range: bytes */` and no body, sent **only to a caller who passed every check**, so the length is not a probe. A range in another unit is ignored and the whole body is sent (RFC 9110). | | Cache | Every answer that can carry bytes or a status (`200`, `206`, `403`, `416`, `503`) is `Cache-Control: private, no-store` with `Vary: Origin, Range, x-saga-capability, x-saga-proof`, so neither a shared cache nor the browser keeps it and a cache that ignores `no-store` still cannot answer one user with another's bytes. The player also fetches with `cache: 'no-store'`. | | Origins | The only origin the view may reach is the listener's own, which the application writes into the served player document (``) and also declares in `connect-src`. The view refuses, before signing a proof or sending a request, any grant whose media URL is not exactly that origin plus `/playback/media/` (another scheme, host or port, credentials, a query, a fragment or a path variant all fail), and a document with no usable origin admits nothing. The refusal does not depend on the host enforcing the resource's CSP; the declared `connect-src` remains as a second control. The player then shows `unavailable` with an unknown request outcome, because the server issued a capability that was never used. | @@ -562,20 +556,19 @@ Every acquisition, whole body or byte range, runs the same checks in this order Recovery in the view: expiry or revocation between grant and fetch shows `expired`; any other failure of the media request (network, `5xx`, a redirect, undecodable bytes, a blocked origin) shows `media-failed`. Both stop the player, clear the source and offer one explicit press. That press is a brand-new Play (fresh authentication, a fresh entitlement check, then a fresh capability) and ends in playing or the safe denial. Nothing refreshes a capability from saved view state, nothing retries on its own, and no public MP3 or chat URL is ever substituted. The status line is the polite live region and the control keeps keyboard focus through `expired`. -Limits worth knowing: +Implementation notes: -- The built player fetches the whole body with one request and never sends `Range`. Range behavior is exercised at the service's HTTP edge (real listener, real proofs), not through the player. -- The admitted origin comes from the document the application served, so it binds the destination to the application's configuration rather than to the grant. It is exact origin admission, not DNS pinning: a deployment that needs rebinding resistance should use a stable application-controlled hostname. A host that rewrites the served document as well as the grant is not addressed here. -- The redirect rule is exercised in the headless controller test against a real redirecting origin, not in the browser spec. -- The synthetic entitlement lookup and storage are in-memory seams. A real application owns revocation latency; this service guarantees only that it asks on every acquisition. +- The built player fetches the whole body with one request. Range behavior is exercised at the service's HTTP edge (real listener, real proofs). +- The admitted origin comes from the document the application served, so it binds the destination to the application's configuration. It is exact origin admission: a deployment that needs DNS rebinding resistance should use a stable application-controlled hostname. +- The redirect rule is exercised in the headless controller test against a real redirecting origin. +- The synthetic entitlement lookup and storage are in-memory seams. A real application owns revocation latency; this service asks on every acquisition. - `Access-Control-Allow-Origin: *` remains (see PLAY1): a sandboxed iframe sends `Origin: null`. Nothing ambient authorizes media, so this discloses nothing to a page without the capability and a proof from the bound key. -- Neither real host has been tried. Whether ChatGPT web or Claude web forward `_meta`, allow the `connect-src` origin and keep the player's focus and announcements is Captain acceptance work, as is the dependency on STATE1/STATE2 and both content-policy levels. Tests: `src/playback/media.test.ts` (revocation of an issued capability, sticky revocation, fault versus revocation, in-flight characterization, lifetime boundaries, every range case, `416`, `503`, cache and `Vary` headers, log capture), `src/playback/playback-view.test.ts` (redirect not followed, expiry versus media failure, retry as a new Play, no automatic loop, only the declared origin fetched), and `e2e/playback-view.spec.ts` (the same behavior in the built view: revoked mid-Play with keyboard focus kept and announced recovery, media-service outage, no self-retry, an undeclared origin receiving nothing, headers sent only to the declared origin, no media request after a denied Play, no capability in model-visible fields or the static resource). ### View teardown and acquisition-scoped cleanup (STATE1, partial) -This slice implements the teardown part of [Request and view lifetimes](../blueprints/features/request_view_lifetimes.blueprint.md). It adds no dependency and changes no server code. It is **not complete**: the action-view label-loss policy and the actual-host observations remain open (below), so the issue stays open. +This slice implements the teardown part of [Request and view lifetimes](../blueprints/features/request_view_lifetimes.blueprint.md). It adds no dependency and changes no server code. It covers teardown of the subscriptions and playback views, the action-view label-loss policy and acquisition-scoped start cleanup. What changed: @@ -584,13 +577,9 @@ What changed: - **Action-view label loss (decided 2026-10-19, [#105](https://github.com/MachineWisdomAI/saga-a2ui/issues/105)).** The playback controller pins the sign-in label of its first opener. An opener or a grant under a different label ends the view for good: it clears the selection, stops the player and shows `session-changed` ("Your session changed. Open a new view or reload to continue.", with no control). From then on every opener is ignored whether it carries the old label, the new label or is unreadable, and Play, Pause and Resume send no request, prepare no audio and acquire no media. A new view or a reload pins its own label and bootstraps normally. A same-label opener for another item before any loss still replaces the selection. The controller tests (`playback-view.test.ts`, "final loss of the pinned sign-in label") and the built-page tests (`playback-view.spec.ts`, "after the sign-in label is lost") pin this. The label is freshness only; the server still authorizes every Play and media request. - **Late `start` cleanup.** A start that settles for a retired Play releases the player only while no later Play has taken it over; it never stops or otherwise touches a newer Play's audio. The release for the latest retired Play is kept, because a player that does not reject a pending start on `stop()` would otherwise leave audio running. -Real-player ordering (Chromium, Web Audio player, `e2e/web-audio-player.spec.ts`): `stop()` rejects a start that is decoding or waiting for the audio clock, and a start made before the next `prepare()` is rejected by that `prepare()` and cannot end the newer start. The whole-player `AudioPlayer` seam stays: with a player that honors that contract, a stale start never resolves, so the controller's guard matters only for a player that does not. This is not a claim about any other player or about a named host, and it is not a lease wrapper. *Current reading: that per-lease owner now exists (`apps/lit-host/src/playback/player-owner.ts`, delivered with CORE2); this paragraph records STATE1's own behavior.* - -Left open, deliberately: +Real-player ordering (Chromium, Web Audio player, `e2e/web-audio-player.spec.ts`): `stop()` rejects a start that is decoding or waiting for the audio clock, and a start made before the next `prepare()` is rejected by that `prepare()` and cannot end the newer start. The whole-player `AudioPlayer` seam stays: with a player that honors that contract, a stale start never resolves, so the controller's guard matters only for a player that does not. *Current reading: that per-lease owner now exists (`apps/lit-host/src/playback/player-owner.ts`, delivered with CORE2); this paragraph records STATE1's own behavior.* -- **Same-label delayed opener.** The view takes the last opener delivered and has no request id to detect a delayed copy; the server still authorizes every request. No mandatory read or host correlation field is added. -- **Actual hosts.** ChatGPT web and Claude web teardown order, view reuse, whether a late `toolresult` follows teardown and whether an aborted call is forwarded are unobserved. The synthetic fixtures here do not establish them. -- **Content consent.** Unchanged. Replacement, error, invalidation, teardown and disposal already revoke or retire it (`packages/components/src/faithful-card/image-consent.test.ts`, `e2e/description-strict.spec.ts`); Hide images cannot retract a request already sent. +The view takes the last opener delivered; the server authorizes every request. Content consent is unchanged: replacement, error, invalidation, teardown and disposal revoke or retire it (`packages/components/src/faithful-card/image-consent.test.ts`, `e2e/description-strict.spec.ts`). Tests: `src/mcp/subscriptions-view.test.ts` ("host teardown of the subscriptions view"), `src/playback/playback-view.test.ts` ("host teardown of the playback view", "late player-start cleanup is scoped to its own acquisition"), `e2e/playback-view.spec.ts` ("the built player after host teardown") and `e2e/web-audio-player.spec.ts`. @@ -609,14 +598,14 @@ Decisions made here (the Blueprint left them open): - **Policy.** `@saga-a2ui/components/faithful-card` (`policy.ts`, `createContentPolicy`) validates `{ level?, imageHosts?, blockedPresentation? }` (see POL2) and returns a frozen policy or an explicit failure; it accepts only canonical lowercase DNS hostnames (labels of letters, digits and interior hyphens, which also covers canonical dotted IPv4) and rejects wildcards, schemes, ports, paths, credentials, uppercase or non-canonical hosts, any character that is a delimiter in a Content-Security-Policy (such as `;`, `,`, `'`, spaces) and bracketed IPv6 literals, more than 32 hosts and unknown settings, and never falls back to something looser. `imageHosts` defaults to empty; `fontOrigins` is a separate trusted list (see "Trusted artwork and fonts"). Link decisions allow only absolute HTTPS without credentials. Image decisions additionally need an exact `https://host` origin match (so suffix tricks, unlisted subdomains, other ports and credentials fail) and demonstrated enforcement. - **Links.** A link is a button whose accessible name includes the parsed hostname (shown as `(docs.example.com)`; internationalized names appear in ASCII). Activation calls only `HostLinks.open`, backed by the pinned upstream `App.openLink` (`ui/open-link`) when the host advertised `openLinks` (`mcp/host-links.ts`). Denial, failure or a missing capability shows a polite status with the destination as text. There is no `window.open`, anchor or navigation fallback. A result that arrives after the description changed is ignored. - **Resource restrictions.** MCP Apps `resourceDomains` is one broad permission (scripts, styles, fonts, media and images). The server therefore inserts a second `` derived from the same policy into the served document (`embedContentPolicy` from `@saga-a2ui/components/faithful-card`, composed by `mcp/content-resource.ts`): `default-src 'none'`, `script-src`/`style-src` `'unsafe-inline'` only (the single-file view), `img-src` and `font-src` (each the mode's embedded baseline plus its own exact origins, see "Trusted artwork and fonts" below), and `media-src`, `connect-src`, `frame-src`, `object-src` `'none'`. A document must satisfy the host's policy and this one, so a listed origin gains nothing but its own resource type. Redirects are checked against the same `img-src`, so a redirect to an unlisted origin is not followed. -- **Enforcement gate.** The view rebuilds the policy from the JSON embedded in its own resource (an invalid one stops the view before anything is mounted) and treats images as enforced only when the document carries exactly the restrictions that policy derives (`establishImageEnforcement`). Without them every image stays inert, even from a listed host. This is evidence about the resource document, not about a host's own enforcement; the host fixture shows the effect with a host policy as broad as the spec describes. +- **Enforcement gate.** The view rebuilds the policy from the JSON embedded in its own resource (an invalid one stops the view before anything is mounted) and treats images as enforced only when the document carries exactly the restrictions that policy derives (`establishImageEnforcement`). Without them every image stays inert, even from a listed host. The host fixture shows the effect with a host policy as broad as the spec describes. - **Identity.** The episode view is served as `ui://views/view.<16 hex digits>.v1.html`: the hex digits are a digest of the embedded policy, and `v1` is the contract major of the view (`EPISODE_VIEW_MAJOR`), with the image origins and font origins as the only `resourceDomains`. A rebuild of the application code, CSS, fonts or artwork keeps the URI, so a host holding an earlier tool descriptor still reads a document that is served; a change to the policy is a new resource. A Strict policy never shares a URI with a Standard policy that declares the same origins, although both declare identical host metadata, because the embedded configuration differs. `createEpisodeServer` and `startEpisodeListener` take the policy explicitly (the listener defaults to Standard with no image host); the view receives its own policy and `HostLinks` through its `saga-app`, never through module state. - **Authored Play is separate.** The playback resource and its capability, origin and entitlement rules are unchanged; a content image host is never added to them, and choosing one does not change Play's authorization (`playback/content-policy-separation.test.ts`). -Limits worth knowing: +Implementation notes: -- Neither real host (ChatGPT web, Claude web) has been tried. Whether each keeps the document's second policy when it mounts the resource, forwards `ui/open-link` and applies `resourceDomains` as described is Captain acceptance work. A host that rewrote the document's policy away would make the view treat every image as inert, because the derived restrictions are missing (covered by the stripped-policy browser test). -- Image-only enforcement is demonstrated in the local host fixture's sandboxed iframe in Chromium only. The `securitypolicyviolation` directives and the absence of requests at the network boundary are the evidence. +- A host that rewrote the document's policy away makes the view treat every image as inert, because the derived restrictions are missing (covered by the stripped-policy browser test). +- Image-only enforcement is shown in the local host fixture's sandboxed iframe in Chromium, through the `securitypolicyviolation` directives and the absence of requests at the network boundary. Tests: `packages/components/src/faithful-card/policy.test.ts` (configuration failures, link and image decisions, derived restrictions, immutability, enforcement), `packages/components/src/faithful-card/markdown.test.ts` (order, preservation, inert HTML and unsupported syntax, the bound), `src/mcp/content-resource.test.ts` and `src/mcp/server.test.ts` (identity, declared origins, embedded configuration), `src/mcp/host-links.test.ts` (a real `App`/`AppBridge`: opened, denied, failed, no capability), and `e2e/description.spec.ts` (the built view in the sandboxed fixture with real `ui/open-link`: order and formatting, hostile input, hostname disclosure, keyboard activation, denial and missing capability without navigation, inert links, stale results, empty and exact-host allowlists, suffix/subdomain/credential/port/non-HTTPS images, redirect, the missing-restriction fallback, a no-restriction control proving scripts, styles, fonts and media would otherwise load, and invalid embedded configuration). @@ -633,13 +622,12 @@ Behavior (`packages/components/src/faithful-card/policy.ts` and `image-consent.t - **Blocked fragments.** Under Strict every link, image reference and piece of raw HTML is a blocked fragment. By default each appears as readable placeholder text in its original position (`[Link blocked]` after the link's label, `[Image blocked: alt]`, `[Markup blocked]`); surrounding prose, order and formatting are unchanged. With `inert-text` the source is shown from the start. In either case a keyboard-operable **Reveal source / Hide source** button switches between the two. It changes visibility only: nothing revealed can execute, navigate or fetch. Revealed source is reconstructed from the parsed fragment (`[label](destination)`, `![alt](source)`, the HTML's own characters), not copied byte for byte; `ParsedDescription.source` still holds the supplied string. A description with nothing to block gets no button. - **Links stay inert.** `decideLink` refuses every destination under Strict, valid HTTPS included, and image consent does not change that. - **Show images.** Before consent, including after source reveal, no image request is made (`decideImage` answers `consent-required` for an image that otherwise qualifies). **Show images** is enabled only when `offerImages` finds an image that would qualify now: an exactly listed HTTPS host and demonstrated enforcement, the same test as Standard. It cannot widen the allowlist or relax the document's restrictions, which Strict declares before mounting exactly as Standard does. When no image qualifies the button is disabled and a polite message says images cannot be shown; a description with no image has no button. Under Strict the images load eagerly once consented, rather than lazily, so consent and request are one observable step. **Hide images** removes them and revokes consent; it cannot undo a request already sent. -- **Consent is separate, revocable view state.** `createImageConsentScope()` creates one scope per view (`contentViewFromDocument`); a grant is live until revoked and is never restored, so a later Show images is a new grant. The description element revokes its grant on Hide images, when its description changes, when its view's scope is revoked, and on disconnection, and ignores image load results from before. The view revokes the scope before it applies any delivered tool result (a new result is a new selection even when its description is identical, which also means a repeated identical result needs fresh consent; the view is still live, so the reader can consent again, `mcp/view.ts`, `revokeImageConsent`). When the host asks it to tear down, and when its connection is disposed, the view retires the scope instead (`retireImageConsent`, `ImageConsentScope.retire()`): that is terminal, every later `grant()` is already dead, and the Show images control is disabled with a "view ended" notice. Show/Hide images controls are also bound to the description revision they were rendered for, so a control kept from a replaced description (or from before a disconnect) does nothing when invoked. A new view replacing an old one, or a second view, starts with no consent and cannot inherit it. This slice adds no retention TTL, account switching or focus/resume revalidation, and no observed identity or session signal exists in the public episode view; the scope's `revokeAll` is where such a signal would call once a lifetime design is approved. +- **Consent is separate, revocable view state.** `createImageConsentScope()` creates one scope per view (`contentViewFromDocument`); a grant is live until revoked and is never restored, so a later Show images is a new grant. The description element revokes its grant on Hide images, when its description changes, when its view's scope is revoked, and on disconnection, and ignores image load results from before. The view revokes the scope before it applies any delivered tool result (a new result is a new selection even when its description is identical, which also means a repeated identical result needs fresh consent; the view is still live, so the reader can consent again, `mcp/view.ts`, `revokeImageConsent`). When the host asks it to tear down, and when its connection is disposed, the view retires the scope instead (`retireImageConsent`, `ImageConsentScope.retire()`): that is terminal, every later `grant()` is already dead, and the Show images control is disabled with a "view ended" notice. Show/Hide images controls are also bound to the description revision they were rendered for, so a control kept from a replaced description (or from before a disconnect) does nothing when invoked. A new view replacing an old one, or a second view, starts with no consent and cannot inherit it. The scope's `revokeAll` revokes every grant in the view at once. - **Identity.** See POL1: the Strict configuration is part of the resource URI and is embedded in the document, so Standard and Strict resources are never confused. `npm run candidate:identity -- --level strict [--blocked-presentation inert-text] --image-host HOST` reports the Strict resource's URI, bytes and SHA-256. - **Authored Play is unchanged in both modes.** The player resource, capability, origin and entitlement rules are untouched; `playback/content-policy-separation.test.ts` runs its cases under Standard and Strict. -Limits worth knowing: +Dialect notes: -- Neither real host (ChatGPT web, Claude web) has been tried; the local fixture is not evidence of host enforcement. Final signoff and the integrated authenticated runs stay open acceptance work, as does integration with the separately held lifetime design (STATE1/STATE2). - Only raw HTML is treated as "unsupported markup". Other syntax the episode's CommonMark dialect does not support (strikethrough, tables) is ordinary text and stays visible under Strict; the answer dialect reads both. - HTML blocks follow CommonMark: a line that starts an HTML block keeps the rest of that block as raw source, so Markdown inside it is no longer interpreted (Standard shows it as text too). @@ -664,11 +652,11 @@ Evidence (this change; Node 26.7.0, Chromium 1248 through Playwright 1.64.0): - `apps/lit-host/e2e/trusted-assets.spec.ts` drives the built episode view in the sandboxed host fixture, in both modes and with empty and nonempty lists. The document carries exactly the derived `img-src` and `font-src`. A trusted data image and data font written into the served document load (measured natural size and font advance, no network request); a blob image and a blob font load in Standard and are blocked in Strict (security-policy violation, readable fallback text). Remote fonts: a declared origin and an explicit port load with CORS and without Show images consent; an unlisted origin, port, subdomain or scheme is blocked before any request; a response that denies this origin through CORS fails and falls back; a redirect to an undeclared origin is blocked and never requested; a declared font origin gives no image, script, stylesheet, media, frame or connection access, and an image host no font access. Hostile descriptions (data and blob images, raw `