diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1fd6ba..d29e9e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,14 @@ # node-runtime @abap2ui5/node-runtime + the ABAP apps, transpiled in the job # (open-abap-core fetched with git, cached by its commit) # cap2ui5 a CAP project + @cap2ui5/cds-plugin + the JS apps -# plus abaplint over the ABAP apps. Each backend run takes seconds; the -# install dominates. +# plus abaplint over the ABAP apps, and the frontend suite against the two +# in-process frontends: the agent client and the Adaptive Cards renderer +# (renderers/adaptive-cards/, with its golden cards). Each backend run takes +# seconds; the install dominates. # The frontend job runs the frontend suite against the UI5 SPA of abap2UI5 # (the commit the spec is derived from, app/webapp only) in Chromium - the # test job skips that test, it has no browser and no checkout - and records -# the reports of the UI5 SPA and the agent client. +# the reports of the UI5 SPA, the agent client and the Adaptive Cards renderer. name: ci on: @@ -56,14 +58,16 @@ jobs: run: npm ci --prefix conformance/hosts/cap2ui5 --no-audit --no-fund - name: abaplint (ABAP conformance apps) run: npm run lint:abap - - name: npm test (both backends) + - name: npm test (both backends, agent client, Adaptive Cards renderer) run: npm test + - name: generated sections are up to date + run: npm run generate && git diff --exit-code - name: backend suite report - node-runtime if: ${{ !cancelled() }} - run: node scripts/run-conformance.mjs node-runtime --json report-node-runtime.json + run: node scripts/run-conformance.mjs node-runtime --json report-node-runtime.json --expected - name: backend suite report - cap2ui5 if: ${{ !cancelled() }} - run: node scripts/run-conformance.mjs cap2ui5 --json report-cap2ui5.json + run: node scripts/run-conformance.mjs cap2ui5 --json report-cap2ui5.json --expected - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() && matrix.node == '22' }} with: @@ -101,6 +105,9 @@ jobs: - name: frontend suite report - agent client if: ${{ !cancelled() }} run: node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter agent --json > report-frontend-agent.json || true + - name: frontend suite report - Adaptive Cards renderer + if: ${{ !cancelled() }} + run: node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter adaptive-cards --json > report-frontend-adaptive-cards.json || true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: diff --git a/AGENTS.md b/AGENTS.md index 3c62973..296c4fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,11 @@ down, and the spec records what the implementations do. `spec/README.md#sources`. A statement you cannot trace to code is a proposal - say so, or leave it out. - **Accidental behaviour is an implementation note, not a rule.** When the - reference does something odd (draft ids that repeat after a leave, the URL - reflected into a 500 body), write it under *Implementation note* - another - implementation must not have to copy it. + reference does something odd (draft ids that repeat after a leave), write + it under *Implementation note* - another implementation must not have to + copy it. When the maintainer turns such a note into a rule the reference + breaks (the URL reflected into a 500 body, revision 0.3), the rule gets its + check and the reference's failure is pinned (below). - **A rule a backend can break gets a check.** New MUST/SHOULD for backends -> a check in `conformance/backend/lib/checks/`, its id on the spec's *Checked by* line, `node scripts/gen-check-list.mjs`. `npm test` fails when @@ -48,7 +50,12 @@ down, and the spec records what the implementations do. `traffic/`; record again after changing a check or an app. - **Generated sections are generated.** `conformance/backend/README.md` (check list), `profiles/portable.md` (between the `portable:*` markers, - from `profiles/portable-v1.json`) - run `npm run generate`. + from `profiles/portable-v1.json`), `renderers/adaptive-cards/README.md` + (the mapping table, from `mapping.mjs`) - run `npm run generate`. +- **Known failures of a reference are pinned, not hidden.** A backend check + the reference backends fail goes into `test/lib/expected.mjs` with the + place of its fix; the tests fail when it starts to pass (unpin it, record + again) as well as when another check fails. - **`profiles/semantic.md` is the normative snapshot v1** (moved from abap2UI5/mcp-server `docs/agent-snapshot.md`). Changes to the snapshot shape are made here first; implementations follow. @@ -65,7 +72,8 @@ down, and the spec records what the implementations do. | `profiles/` | UI5, portable (v1 + `portable-v1.json` + coverage) and semantic profiles | | `schema/` | JSON Schemas 2020-12 | | `conformance/backend/` | the backend suite - the published package's entry (`index.mjs`, `bin/`) | -| `conformance/frontend/` | the frontend suite - `lib/` (scripted backend `mock.mjs`, runner, response builders, `checks/`), `adapters/` (`ui5` Playwright + the boot page, `agent` + the vendored mcp-server client, `webcomponent`, `headless` stub) | +| `conformance/frontend/` | the frontend suite - `lib/` (scripted backend `mock.mjs`, runner, response builders, `checks/`), `adapters/` (`ui5` Playwright + the boot page, `agent` + the vendored mcp-server client, `webcomponent`, `adaptive-cards` (in process, the renderer below), `headless` stub) | +| `renderers/adaptive-cards/` | a prototype portable renderer: response -> Adaptive Card 1.5 and `Action.Submit` payload -> request (`render.mjs`, `mapping.mjs` - the control table the README's mapping section is generated from, `submit.mjs`, `host.mjs`, `demo.mjs`), golden cards in `golden/` (`UPDATE_GOLDEN=1 node --test test/adaptive-cards.test.mjs` rewrites them) | | `conformance/apps/` | the conformance apps (ABAP + cap2UI5) and their abaplint config | | `conformance/hosts/` | `node-runtime` (build + serve) and `cap2ui5` (a CAP project) reference hosts | | `traffic/` | recorded traffic per backend: `suite.json`, `ui5-frontend.json`, `agent-client.json` | @@ -80,7 +88,8 @@ recorded traffic, cross-backend equality, docs links and anchors, the portable profile, the conformance apps' abapGit format, the CLI, the full backend suite against both reference backends (`PROTOCOL_SKIP_BACKENDS=1` skips those; the cap2UI5 run skips itself when its host is not installed), -and the frontend suite against the agent client (always) and the UI5 SPA +and the frontend suite against the agent client and the Adaptive Cards +renderer (always, golden cards included) and the UI5 SPA (when an abap2UI5 checkout and a Chromium are there; `PROTOCOL_SKIP_BROWSER=1` skips it, `PROTOCOL_REQUIRE_BROWSER=1` fails without them). CI runs the same on Node 22 and 24, and the UI5 SPA in a @@ -89,8 +98,8 @@ has browsers already - point `CHROMIUM_BIN` at one. ## Style -ES modules, Node 22+, no runtime dependencies in `conformance/backend/` -and `conformance/frontend/` (the browser adapters import `playwright-core` -lazily). English, ASCII in source files - the vendored copies under +ES modules, Node 22+, no runtime dependencies in `conformance/backend/`, +`conformance/frontend/` and `renderers/` (the browser adapters import +`playwright-core` lazily). English, ASCII in source files - the vendored copies under `conformance/frontend/adapters/vendor/` are byte-equal to upstream and exempt. Markdown wrapped at ~78 columns. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0706f27..fc9a1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ ## Unreleased +- **Specification revision 0.3 - the maintainer's decisions** on the ten + open questions ([spec/open-questions.md](spec/open-questions.md), each now + "Decided (revision 0.3)" with its rationale): + - NEST/NEST2 stay tolerated-not-rendered by portable renderers; portable + *apps* must not use them - the abap2UI5 linter's portable rule + (`profiles/portable.md` section 2). + - A backend MUST NOT reflect request data it did not validate into the + error body (`spec/errors.md`); new backend check **`error.no-reflection`** + (MUST). Both reference backends (abap2UI5 1.146.0 via + `@abap2ui5/node-runtime`, cap2UI5 on it) fail it - the URL is reflected + verbatim by [H] `request_context_info`; the fix is made in abap2UI5 + core. Pinned as an expected failure in `test/lib/expected.mjs`; the + suite traffic is recorded again (`counts.fail` 1 on both). + - Message box details shown expanded (the UI5 frontend is being fixed), + one roundtrip at a time with the client queueing (`spec/transport.md`), + `sap-contextid` kept by every HTTP frontend - as they were. + - **`portable-v1.json` separates the client API from the wire**: a new + `actions` object with `actions.api` (the `follow_up_action( )` names a + portable app may call) and `actions.wire` (what a renderer receives: + `VIEW_SLOTS`, `ROUTER` with its `routerOptions`, the names folded into + `ROUTER` - `foldedIntoRouter` - and the `T_CUSTOM` / `.eF` names). + Additive: `frontendActions` stays as it was (`allowed` = `actions.api`), + the profile stays version 1; consumers that copy the file (the Web + Components frontend) keep working and re-copy it to read the new key. + Schema, `scripts/render-portable.mjs` (a generated table in + `profiles/portable.md` section 6) and `scripts/gen-portable-profile.mjs` + follow. +- **Adaptive Cards renderer prototype** (`renderers/adaptive-cards/`, + export `@abap2ui5/protocol/renderers/adaptive-cards`): an abap2UI5 + response (portable profile) -> an Adaptive Card 1.5, and an + `Action.Submit` payload -> the next protocol request (event, arguments, + the model delta of the changed inputs, whose ids are binding paths). All + 65 controls of portable profile v1 mapped (the README's mapping table is + generated from `mapping.mjs`), unknown controls as placeholders listed in + `unsupported`, a minimal card host speaking the protocol over HTTP, a demo + for the designer, golden cards of recorded traffic. Pure Node, built on + the vendored mcp-server `viewxml` / `snapshot` modules. +- **Frontend adapter `adaptive-cards`** (in process): the renderer passes + every one of the 65 checks of the portable profile it can be driven + through (16 skipped: URL, DOM, focus, title, model edits; UI5 and semantic + profiles). Pinned in `test/frontend.test.mjs`; CI uploads its report. +- **Agent client re-vendored** at abap2UI5/mcp-server `a4d9f07` (PR #44): + it now follows every frontend rule that applies - 61 pass, 0 fail, 0 + warn, 20 skip (was 5 MUST failures and 3 warnings at `ea4e9fa`); the pin + in `test/frontend.test.mjs` follows. The Web Components frontend at its + main `410d607` measures 68 pass / 0 fail / 13 skip, with + `model.number-and-boolean` failing intermittently (RESULTS.md). +- The UI5 SPA pin accepts `portable.box-details` passing (its fix is under + way); CI checks that `npm run generate` leaves no diff. + - **Frontend conformance suite** (`abap2ui5-conformance frontend --adapter ui5|agent|webcomponent [--profile core|portable|ui5|semantic]`, library `runFrontendSuite`, also at `@abap2ui5/protocol/frontend`): a scripted diff --git a/README.md b/README.md index 3643108..4835287 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,12 @@ repository writes it down. backend the backend suite - plays the frontend over HTTP (implemented) frontend the frontend suite - plays the backend, adapters for (implemented) the UI5 SPA (Playwright), the agent client, the - Web Components frontend + Web Components frontend, the Adaptive Cards renderer apps the conformance apps: ABAP classes + cap2UI5 twins hosts the two reference backends, started with the apps deployed + renderers/ + adaptive-cards a portable renderer without a browser: response -> Adaptive + Card 1.5, Action.Submit -> request (prototype) traffic/ real traffic of both reference backends and three frontends ``` @@ -45,22 +48,28 @@ source file and method it was derived from ([spec/README.md](spec/README.md#sour ## Status -- **Protocol 2**, specification revision 0.2, derived from abap2UI5 1.146.0 - (commit `b812079`). -- **Backend suite: 75 checks** (61 MUST, 14 SHOULD; 68 core, 7 UI5 profile). - Green against both reference backends - `node-runtime` 75/75, - `cap2ui5` 74/75 with one SHOULD warning ([conformance/RESULTS.md](conformance/RESULTS.md)). -- **Schemas** validate all 334 recorded requests, 320 responses and 18 agent +- **Protocol 2**, specification revision 0.3, derived from abap2UI5 1.146.0 + (commit `b812079`); the maintainer decided the ten open questions + ([spec/open-questions.md](spec/open-questions.md)). +- **Backend suite: 76 checks** (62 MUST, 14 SHOULD; 69 core, 7 UI5 profile). + Both reference backends pass every check but `error.no-reflection`, new in + revision 0.3 (the request URL reflected into the 500 body; fixed in + abap2UI5 core, pinned until the runtime release carries it) - + `node-runtime` 75/76, `cap2ui5` 74/76 with one SHOULD warning + ([conformance/RESULTS.md](conformance/RESULTS.md)). +- **Schemas** validate all 336 recorded requests, 320 responses and 18 agent snapshots, checked by the shipped validator and by ajv. - **Portable profile v1** filled from a census of 247 core apps (73.7 % by controls, 64.8 % run unchanged). - **Frontend suite: 81 checks** (66 MUST, 15 SHOULD; 66 core, 8 portable, 4 UI5, 3 semantic), scripted from the recorded traffic. The official UI5 SPA passes every MUST but one - message box details stay empty on OpenUI5 - >= 1.120; the agent client fails 5 MUSTs (no PROTOCOL check, no - `sap-contextid`, overlapping acts, popups kept across an app change, error - markup stripped) ([conformance/RESULTS.md](conformance/RESULTS.md#frontend-suite)). -- Decisions still open for the maintainer: [spec/open-questions.md](spec/open-questions.md). + >= 1.120 (fix under way); the agent client (mcp-server `a4d9f07`) and the + Adaptive Cards renderer pass every check that applies to them (61 and 65); + the Web Components frontend 68 of 68, with one intermittent failure ([conformance/RESULTS.md](conformance/RESULTS.md#frontend-suite)). +- **Adaptive Cards renderer** (prototype, [renderers/adaptive-cards/](renderers/adaptive-cards/README.md)): + all 65 portable controls mapped onto Adaptive Cards 1.5, the way back from + an `Action.Submit` to the next request, golden cards of recorded traffic. - Not yet run against an ABAP system. ## Run the backend suite @@ -87,6 +96,7 @@ Options, the library API and the check list: ```bash npx abap2ui5-conformance frontend --adapter ui5 # the UI5 SPA (ABAP2UI5_HOME=) in Chromium npx abap2ui5-conformance frontend --adapter agent # the agent client of abap2UI5/mcp-server +npx abap2ui5-conformance frontend --adapter adaptive-cards # the Adaptive Cards renderer of this package ``` Adapters, options and the check list: @@ -96,11 +106,14 @@ Adapters, options and the check list: ```bash npm test # schemas, traffic, docs, portable profile, CLI, both backends, - # the frontend suite (agent client; UI5 SPA with a checkout + Chromium) + # the frontend suite (agent client, Adaptive Cards renderer; + # UI5 SPA with a checkout + Chromium), the golden cards # (PROTOCOL_SKIP_BACKENDS=1 / PROTOCOL_SKIP_BROWSER=1 skip the slow parts) npm run record # re-record traffic/ from both backends npm run lint:abap # abaplint over the ABAP conformance apps -npm run generate # regenerate the check list and the portable-profile sections +npm run generate # regenerate the check lists, the portable-profile sections and + # the Adaptive Cards mapping table +npm run demo:adaptive-cards -- slots.popup-destroy 0 1 # a recorded response as card JSON ``` Node 22 or later. The package has no runtime dependencies; the dev diff --git a/conformance/README.md b/conformance/README.md index f6866b8..7b16cb6 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -5,8 +5,8 @@ swapped independently: | Suite | Plays | Tests | Status | |---|---|---|---| -| [backend/](backend/README.md) | the frontend | a backend, over HTTP, against the conformance apps | implemented - 75 checks, `abap2ui5-conformance backend` | -| [frontend/](frontend/README.md) | the backend | a frontend, by scripted responses (recorded traffic + synthetic edge cases), through an adapter per frontend | implemented - 81 checks, `abap2ui5-conformance frontend --adapter ui5\|agent\|webcomponent` | +| [backend/](backend/README.md) | the frontend | a backend, over HTTP, against the conformance apps | implemented - 76 checks, `abap2ui5-conformance backend` | +| [frontend/](frontend/README.md) | the backend | a frontend, by scripted responses (recorded traffic + synthetic edge cases), through an adapter per frontend | implemented - 81 checks, `abap2ui5-conformance frontend --adapter ui5\|agent\|webcomponent\|adaptive-cards` | The **conformance apps** ([apps/](apps/README.md)) are what a backend serves for the backend suite: small apps with exactly specified behaviour, shipped diff --git a/conformance/RESULTS.md b/conformance/RESULTS.md index b7cc5a3..7456d9e 100644 --- a/conformance/RESULTS.md +++ b/conformance/RESULTS.md @@ -1,13 +1,15 @@ # Results -Backend suite, profile `ui5` (75 checks: 68 core, 7 UI5-profile; 61 MUST, +Backend suite, profile `ui5` (76 checks: 69 core, 7 UI5-profile; 62 MUST, 14 SHOULD), run 2026-10-03 with `npm run conformance:`. The traffic -of these runs is recorded in [`../traffic/`](../traffic/). +of these runs is recorded in [`../traffic/`](../traffic/). Revision 0.3 added +`error.no-reflection` (MUST, [open question 3](../spec/open-questions.md#3-the-request-url-reflected-into-the-error-body)); +both reference backends fail it - see [below](#the-request-url-reflected-into-the-error-body). | Backend | Version | Apps | Pass | Fail | Warn | Skip | Verdict | |---|---|---|---:|---:|---:|---:|---| -| `node-runtime` | `@abap2ui5/node-runtime` 1.146.0 (abap2UI5 1.146.0, transpiled) | the ABAP apps, transpiled with `@abaplint/transpiler-cli` 2.13.93 | 75 | 0 | 0 | 0 | conformant, `core` and `ui5` | -| `cap2ui5` | `@cap2ui5/cds-plugin` 0.4.0 on `@abap2ui5/node-runtime` 1.146.0, `@sap/cds` 10 | the JavaScript apps | 74 | 0 | 1 | 0 | conformant, `core` and `ui5` | +| `node-runtime` | `@abap2ui5/node-runtime` 1.146.0 (abap2UI5 1.146.0, transpiled) | the ABAP apps, transpiled with `@abaplint/transpiler-cli` 2.13.93 | 75 | 1 | 0 | 0 | one MUST failed: `error.no-reflection` (fix in abap2UI5 core, pinned) | +| `cap2ui5` | `@cap2ui5/cds-plugin` 0.4.0 on `@abap2ui5/node-runtime` 1.146.0, `@sap/cds` 10 | the JavaScript apps | 74 | 1 | 1 | 0 | one MUST failed: `error.no-reflection` (inherited from the runtime, pinned) | Per area (both backends identical except `errors`): @@ -22,13 +24,37 @@ Per area (both backends identical except `errors`): | action | 3 | 3 pass | 3 pass | | nav / route | 14 | 14 pass | 14 pass | | session | 5 | 5 pass | 5 pass | -| error | 4 | 4 pass | 3 pass, 1 warn (`error.details`) | +| error | 5 | 4 pass, 1 fail (`error.no-reflection`) | 3 pass, 1 fail (`error.no-reflection`), 1 warn (`error.details`) | | ui5 | 7 | 7 pass | 7 pass | The ABAP system itself (abap2UI5 on NetWeaver / ABAP Cloud) has not been run yet - it needs a system with the apps pulled in by abapGit; the transpiled runtime is the same framework source. +## The request URL reflected into the error body + +`error.no-reflection` (MUST since revision 0.3) starts an unknown app with +` `X`, `null`/`false` -> empty, @@ -99,13 +126,19 @@ perform. | Frontend | Version | Profile | Pass | Fail | Warn | Skip | Verdict | |---|---|---|---:|---:|---:|---:|---| | UI5 SPA (`ui5`) | abap2UI5 1.146.0 `b812079` `app/webapp` (identical at main `5d7e91f`, which CI pins), OpenUI5 1.144.0 (npm), Chromium 141 | ui5 | 76 | 1 | 0 | 4 | one MUST deviation: `portable.box-details` | -| agent client (`agent`) | abap2UI5/mcp-server `lib/appclient.mjs` @ `ea4e9fa` (vendored) | semantic | 53 | 5 | 3 | 20 | not conformant: 5 MUSTs | -| Web Components (`webcomponent`) | abap2UI5/frontend-webcomponent 0.1.0, `dist/` built from `6997c40` (in development) | portable | 63 | 4 | 1 | 13 | work in progress: router not implemented, error markup stripped | +| agent client (`agent`) | abap2UI5/mcp-server `lib/appclient.mjs` @ `a4d9f07` (main, PR #44; vendored) | semantic | 61 | 0 | 0 | 20 | conformant (semantic) - the five MUST deviations of `ea4e9fa` fixed | +| Adaptive Cards renderer (`adaptive-cards`) | [`renderers/adaptive-cards/`](../renderers/adaptive-cards/README.md) of this repository (prototype), Adaptive Cards 1.5 | portable | 65 | 0 | 0 | 16 | every check it can be driven through holds | +| Web Components (`webcomponent`) | abap2UI5/frontend-webcomponent 0.1.0, `dist/` built from main `410d607` (PR #2) | portable | 68 | 0 | 0 | 13 | conformant (portable) in 3 of 4 runs; `model.number-and-boolean` intermittent (below) | | headless ABAP simulator (`headless`) | - | - | - | - | - | - | not drivable: in-process, no HTTP seam ([frontend/README.md](frontend/README.md#adapters)) | The UI5 SPA run is stable (two consecutive runs, identical results) and -takes about two minutes; `test/frontend.test.mjs` pins both the UI5 and the -agent result check by check. +takes about two minutes; `test/frontend.test.mjs` pins the UI5, the agent +and the Adaptive Cards result check by check. The UI5 pin accepts +`portable.box-details` passing: the fix of the UI5 frontend is under way +(open question 7, decided: expanded), and a local run against an abap2UI5 +checkout that carries it (2026-10-03, abap2UI5 `ed8115b` on top of `833b5b8` "Show message box +details on UI5 1.120 and later", on its working branch, not on main yet) +passed it - every MUST held, 77 pass, 4 skip. ### Findings - the UI5 SPA @@ -154,42 +187,74 @@ error body shown verbatim as text. ### Findings - the agent client -All five MUST failures are *the client's*; filed for abap2UI5/mcp-server. - -1. **No PROTOCOL check** (`response.protocol-mismatch`): a `PROTOCOL: 3` - response is adopted and described. `lib/appclient.mjs` `post` checks - status, JSON and `S_FRONT` only. -2. **No stateful session id** (`transport.contextid-kept`): the - `sap-contextid` response header is never read or sent back; a stateful - ABAP app would meet a fresh work process on every act - ([open question 9](../spec/open-questions.md#9-stateful-sessions-and-frontends-without-a-browser)). -3. **Overlapping acts** (`transport.one-at-a-time`): a second `act` while - the first is in flight posts at once, continuing the same draft id - ([open question 8](../spec/open-questions.md#8-one-roundtrip-at-a-time-for-frontends-driven-by-a-program)). -4. **Popups survive an app change** (`slots.app-change`): - `lib/snapshot.mjs` `applyResponse` tears POPUP/POPOVER down only on a - MAIN display or a destroy; a popup app answering with another `APP` leaves - the previous app's popover in the snapshot. -5. **Error markup interpreted** (`error.as-text`): `errorText` extracts a - `
`, strips tags and decodes entities - written for the old HTML
-   error page; the body is `text/plain` now. `bold` arrives as
-   `bold`.
-
-Warnings: no CSRF token handshake (`transport.csrf-token`); edits made with
-an `act` while another is in flight are dropped, because `act` clears all
-pending edits of the model after its response, not only the ones it sent
-(`model.pending-survive-push`); the PROTOCOL message (follows from 1).
-Skipped: the URL, DOM and focus checks (no browser), a nested-table cell
-(the snapshot does not describe nested tables), a programmatic model edit.
-
-### Findings - the Web Components frontend (in development)
-
-Run against the build of its development branch at the time; recorded for
-its authors, not as a verdict. It passes every portable-profile check that
-applies, including the tolerance rule, the NEST placeholder rule and the
-box details. Failing: the `ROUTER` action and the URL hash are not
-implemented yet (`router.keep`, `router.back-restores`, `router.app-state`;
-`router.hash-sent` warns), and error markup is stripped (`error.as-text`) -
-it vendors the agent client's `errorText`. Table cells were not addressed
-(its DOM exposes control ids, not binding paths) and the box close is not
-wired in the adapter yet.
+Re-vendored at abap2UI5/mcp-server `a4d9f07` (main, PR #44): **every check
+that applies passes** - 61 pass, 0 fail, 0 warn, 20 skip, two runs
+identical. The five MUST deviations and three warnings measured at
+`ea4e9fa` (revision 0.2) are fixed in the client, as filed:
+
+1. PROTOCOL check - a `PROTOCOL: 3` response is refused naming both numbers
+   (`response.protocol-mismatch`, `-message`).
+2. `sap-contextid` read and sent back (`transport.contextid-kept`; open
+   question 9, decided: MUST).
+3. One roundtrip at a time - a second `act` on a session in flight is
+   queued (`transport.one-at-a-time`; open question 8, decided: the client
+   queues).
+4. POPUP/POPOVER torn down on an `APP` change (`slots.app-change`).
+5. The error body verbatim, no markup stripped (`error.as-text`).
+
+and the CSRF token handshake (`transport.csrf-token`) and edits made during
+a roundtrip kept (`model.pending-survive-push`). Skipped: the portable and
+UI5 profiles (not claimed), the URL, DOM and focus checks (no browser), a
+nested-table cell (the snapshot does not describe nested tables), a
+programmatic model edit. `traffic/*/agent-client.json` is still the
+recording of `ea4e9fa`; `semantic.recorded-snapshots` passes against it.
+
+### Findings - the Adaptive Cards renderer
+
+A renderer that is not a browser, run in process
+([../renderers/adaptive-cards/](../renderers/adaptive-cards/README.md)):
+the card host speaks the protocol to the scripted backend, each answer is
+rendered into an Adaptive Card 1.5 and the suite reads the card; a "press"
+submits what a card host submits - the action's data merged with every input
+value, ids being binding paths - and the renderer turns that back into the
+request. All 65 checks that apply pass, two runs identical: the envelope,
+ID continuation, raw event arguments (model arguments re-read after the
+edits of the same submit), every delta rule including nested `__delta`
+rows (the edits are found by comparing the submitted values with the model
+the card was rendered from), `sap-contextid`, the CSRF handshake, no retry
+of a 500, one roundtrip at a time (a submit in flight queues the next, open
+question 8), PROTOCOL 3 refused, the slot rules (popup modal, popover not:
+a press in MAIN closes it, as UI5 does), the NEST placeholder, toasts,
+boxes and their close event, START_TIMER, an unknown and an excluded
+follow-up action skipped and logged, the tolerance rule (an unknown control
+becomes a placeholder and an `unsupported` entry), and an error body shown
+verbatim (a `TextRun`, not markdown). Skipped: a URL (routing, Back, the
+app-state hash), a DOM (the sanitizer probe, the box-details text probe -
+the card shows the details expanded, as text), focus, a document title and
+programmatic model edits; the UI5 and semantic profiles are not claimed.
+Every golden card also validates with the Adaptive Cards JavaScript SDK
+3.0.6 (`AdaptiveCard.parse` + `validateProperties`, no issue; run by hand,
+not a dependency).
+
+Found on the way, *the spec held*: nothing in the portable profile needed
+a browser to be rendered; what a card cannot do (raise `change` events,
+show a URL) the profile already lets a renderer drop or the edit travels
+with the next action.
+
+### Findings - the Web Components frontend
+
+Run against `dist/` built from its main `410d607` (PR #2: `ROUTER` and the
+URL hash, verbatim error text, formatters, growing), in a separate worktree
+(`WC_FRONTEND_HOME`): 68 pass, 0 fail, 0 warn, 13 skip in three of four full
+runs - every portable-profile check that applies, now including the router
+checks and `error.as-text`. In the other run, and in one of three runs of
+the check alone, **`model.number-and-boolean` failed**: the edited
+`StepInput` (`/QTY`, 42) was missing from `MODEL` - the press reached the
+frontend before the `ui5-step-input` had committed the typed value.
+Intermittent, not yet attributed (the adapter fills the inner input,
+presses Enter and blurs, then waits 50 ms; a longer wait did not make it go
+away); filed for abap2UI5/frontend-webcomponent. Skipped: the UI5 and
+semantic profiles, `message.box-close-event` (the box close is not wired in
+the adapter), the focus and model-edit checks, and three table-cell checks
+(its DOM exposes control ids, not binding paths). Its vendored copy of the agent client is being
+re-pinned to `a4d9f07` there as well.
diff --git a/conformance/backend/README.md b/conformance/backend/README.md
index abd6a51..e7cbc2f 100644
--- a/conformance/backend/README.md
+++ b/conformance/backend/README.md
@@ -141,6 +141,7 @@ fails when it is out of date).
 | `error.details` | SHOULD | core | The error body names the app or the failure, so a developer can find the cause | [errors.md#the-error-response](../../spec/errors.md#the-error-response) |
 | `error.unknown-app` | MUST | core | An app start naming a class that is no app is refused with an error status | [errors.md#the-error-response](../../spec/errors.md#the-error-response) |
 | `error.not-sniffable` | SHOULD | core | An error body is served as text/plain with X-Content-Type-Options: nosniff | [errors.md#the-error-response](../../spec/errors.md#the-error-response) |
+| `error.no-reflection` | MUST | core | Request data the backend did not validate is not reflected into the error body | [errors.md#the-error-response](../../spec/errors.md#the-error-response) |
 | `ui5.page` | MUST | ui5 | GET of the endpoint answers the HTML page that boots the UI5 frontend | [ui5.md#the-page](../../profiles/ui5.md#the-page) |
 | `ui5.page-revalidation` | SHOULD | ui5 | The page carries an ETag and answers a matching If-None-Match with 304 | [ui5.md#the-page](../../profiles/ui5.md#the-page) |
 | `ui5.view-roots` | MUST | ui5 | MAIN and nested views are sap.ui.core.mvc.View XML, popups and popovers core:FragmentDefinition | [ui5.md#views](../../profiles/ui5.md#views) |
diff --git a/conformance/backend/bin/abap2ui5-conformance.mjs b/conformance/backend/bin/abap2ui5-conformance.mjs
index 165416d..3f6204f 100755
--- a/conformance/backend/bin/abap2ui5-conformance.mjs
+++ b/conformance/backend/bin/abap2ui5-conformance.mjs
@@ -5,7 +5,7 @@
  *   abap2ui5-conformance backend --url  [--profile core|ui5]
  *                                [--header "Name: value"]... [--only ]...
  *                                [--app KEY=CLASS]... [--json] [--verbose]
- *   abap2ui5-conformance frontend --adapter ui5|agent|webcomponent|headless
+ *   abap2ui5-conformance frontend --adapter ui5|agent|webcomponent|adaptive-cards|headless
  *                                [--profile core|portable|ui5|semantic]
  *                                [--only ]... [--json] [--verbose]
  *
@@ -32,7 +32,9 @@ The backend has to serve the conformance apps - conformance/apps/README.md.
 
 frontend - plays the backend (a scripted server) for a frontend:
   --adapter         ui5 (the UI5 SPA in Chromium), agent (mcp-server's agent
-                          client), webcomponent (frontend-webcomponent), headless (stub)
+                          client), webcomponent (frontend-webcomponent),
+                          adaptive-cards (the Adaptive Cards renderer of this
+                          package, in process), headless (stub)
   --profile      core | portable | ui5 | semantic (default: the adapter's widest)
   --only, --json, --verbose  as above
 
diff --git a/conformance/backend/lib/checks/errors.mjs b/conformance/backend/lib/checks/errors.mjs
index c4cc545..9764f0f 100644
--- a/conformance/backend/lib/checks/errors.mjs
+++ b/conformance/backend/lib/checks/errors.mjs
@@ -49,12 +49,38 @@ export default [
     profile: "core",
     spec: `${E}#the-error-response`,
     async run(t) {
-      // the reference reflects the request URL into the body verbatim - safe
-      // only because nothing renders the body as markup
+      // a body that names the request is still never to be read as markup -
+      // the headers say so even where a backend reflects more than it should
       const r = await t.client.start("Z2UI5_x");
       t.ok(r.status >= 400, `expected an error status, got ${r.status}`);
       t.ok(/^text\/plain\b/i.test(r.headers["content-type"] || ""), `Content-Type should be text/plain, is "${r.headers["content-type"]}"`);
       t.equal((r.headers["x-content-type-options"] || "").toLowerCase(), "nosniff", "X-Content-Type-Options");
     },
   },
+  {
+    id: "error.no-reflection",
+    title: "Request data the backend did not validate is not reflected into the error body",
+    level: "MUST",
+    profile: "core",
+    spec: `${E}#the-error-response`,
+    async run(t) {
+      // A crafted request (not a browser: location.search would arrive
+      // percent-encoded) whose URL carries markup and quotes next to an app
+      // start that fails. Whatever the body says about the URL must have been
+      // reduced to characters that cannot be read as markup (spec/errors.md;
+      // open question 3, decided in revision 0.3).
+      const r = await t.client.start("Z2UI5_CL_CONF_DOES_NOT_EXIST", {
+        search: `?app_start=Z2UI5_CL_CONF_DOES_NOT_EXIST&conformance=${REFLECTION_PROBE}`,
+      });
+      t.ok(r.status >= 400, `expected an error status, got ${r.status}`);
+      const hit = REFLECTED.filter((part) => r.text.includes(part));
+      const line = r.text.split("\n").find((l) => hit.some((part) => l.includes(part))) || "";
+      t.ok(!hit.length, `the error body reflects ${hit.map((h) => JSON.stringify(h)).join(", ")} from the request URL verbatim: ${line.slice(0, 200)}`);
+    },
+  },
 ];
+
+/** What error.no-reflection puts into the request URL, and the parts of it
+ *  an error body must not contain. */
+export const REFLECTION_PROBE = ``;
+const REFLECTED = ["", "` | the frontend: `ui5`, `agent`, `webcomponent`, `headless` (stub) |
+| `--adapter ` | the frontend: `ui5`, `agent`, `webcomponent`, `adaptive-cards`, `headless` (stub) |
 | `--profile core\|portable\|ui5\|semantic` | the checks to run: `core`; `portable` (core + portable); `ui5` (core + portable + UI5); `semantic` (core + semantic). Default: the widest profile the adapter claims |
 | `--only ` | run only checks whose id contains it (repeatable) |
 | `--json` | the report as JSON |
@@ -65,6 +67,7 @@ frontend cannot perform throws `Unsupported`.
 | `ui5` | the official UI5 SPA, abap2UI5 `app/webapp` | core, portable, ui5 | Chromium via Playwright: the page is the UI5 profile's boot page ([adapters/ui5-page.mjs](adapters/ui5-page.mjs)) at the run's endpoint, UI5 comes from the `@openui5/*` npm packages (no CDN), the frontend from the checkout's webapp folder. Interactions type into real `Input`s and click real `Button`s |
 | `agent` | abap2UI5/mcp-server `lib/appclient.mjs` | core, semantic | in process: `start` / `act`, the snapshot is the state. Vendored at the commit [adapters/vendor/mcp-server/source.json](adapters/vendor/mcp-server/source.json) names (`npm run vendor:agent` re-vendors; `MCP_SERVER_HOME` runs a checkout instead) |
 | `webcomponent` | abap2UI5/frontend-webcomponent `dist/abap2ui5-wc.js` | core, portable | Chromium: `` on its standalone page; needs a built checkout (`WC_FRONTEND_HOME`, else `../frontend-webcomponent`) |
+| `adaptive-cards` | the Adaptive Cards renderer prototype of this repository ([../../renderers/adaptive-cards/](../../renderers/adaptive-cards/README.md)) | core, portable | in process: its card host speaks HTTP to the scripted backend, every answer is rendered into an Adaptive Card 1.5 and the state is read from the card (a `Container` per slot, inputs with binding paths as ids). `fill` types into a card input, `press` submits what a card host submits for the action - its data and every input value |
 | `headless` | abap2UI5/headless-frontend (ABAP) | - | **not drivable yet** - see below |
 
 What the `ui5` adapter needs: an abap2UI5 checkout (`ABAP2UI5_HOME`, else
diff --git a/conformance/frontend/adapters/adaptive-cards.mjs b/conformance/frontend/adapters/adaptive-cards.mjs
new file mode 100644
index 0000000..5d80948
--- /dev/null
+++ b/conformance/frontend/adapters/adaptive-cards.mjs
@@ -0,0 +1,172 @@
+/*
+ * Adapter: the Adaptive Cards renderer prototype of this repository
+ * (renderers/adaptive-cards/) - an in-process frontend of the portable
+ * profile. Its host speaks the protocol over HTTP to the scripted backend;
+ * every response is rendered into an Adaptive Card 1.5, and the suite looks
+ * at that card:
+ *
+ *   state()      the card: a Container per slot (`slot-MAIN`, ...), its text,
+ *                the values of its inputs (ids are binding paths)
+ *   fill(t, v)   the user types into the card input whose id is t.path -
+ *                held as a typed value, as a card host holds it, until a
+ *                submit carries it
+ *   press(t)     the user presses the Action.Submit raising t.event (or
+ *                titled t.text): the host gets the action's data merged
+ *                with the values of every input of the card - exactly what
+ *                a card host submits - and builds the next request from it
+ *   closeBox(a)  the message box button `a`
+ *
+ * What a card cannot do is not claimed: no URL, no DOM, no focus, no
+ * document title, no programmatic model edit. It runs START_TIMER (the host
+ * does) and queues a submit while a roundtrip is in flight.
+ */
+import { Unsupported, emptyState } from "./base.mjs";
+import { createCardHost } from "../../../renderers/adaptive-cards/host.mjs";
+import { walk, cardText, liveSlots, modelKeyOf, LEAVE_EVENT } from "../../../renderers/adaptive-cards/render.mjs";
+import { coerce } from "../../../renderers/adaptive-cards/submit.mjs";
+import { getAt } from "./vendor/mcp-server/snapshot.mjs";
+
+const INPUT = /^Input\./;
+
+export function createAdaptiveCardsAdapter() {
+  let mock = null;
+  let host = null;
+  let typed = new Map();
+
+  const render = () => host.render({ typed });
+
+  /** The interactive layers of the card: the box, else the live slots. */
+  function liveItems(card) {
+    return liveSlots(host.state, host.messages)
+      .map((s) => card.body.find((e) => e.id === (s === "BOX" ? "message-box" : `slot-${s}`)))
+      .filter(Boolean);
+  }
+
+  function inputs(items) {
+    const out = [];
+    walk(items, (e) => {
+      if (INPUT.test(e.type) && e.id) out.push(e);
+    });
+    return out;
+  }
+
+  function actions(items) {
+    const out = [];
+    walk(items, (e) => {
+      if (e.type === "Action.Submit" && e.data) out.push(e);
+    });
+    return out;
+  }
+
+  /** What a card host submits for an action: its data and every input value. */
+  function payloadFor(action) {
+    const { card } = render();
+    const values = {};
+    for (const i of inputs(liveItems(card))) values[i.id] = i.value === undefined || i.value === null ? "" : String(i.value);
+    typed = new Map();
+    return { ...action.data, ...values };
+  }
+
+  function findAction(target) {
+    const all = actions(liveItems(render().card)).filter((a) => a.isEnabled !== false);
+    const event = target.nav ? LEAVE_EVENT : target.event;
+    return (event && all.find((a) => a.data.event === event)) || (target.text && all.find((a) => a.title === target.text)) || null;
+  }
+
+  const adapter = {
+    name: "adaptive-cards",
+    description: "the Adaptive Cards renderer prototype (renderers/adaptive-cards/), in process",
+    profiles: ["core", "portable"],
+    capabilities: new Set(["concurrent", "boxClose", "timers"]),
+
+    async open({ mock: m }) {
+      mock = m;
+      adapter.version = "renderers/adaptive-cards (this repository), Adaptive Cards 1.5";
+    },
+
+    async start(run, { app, search } = {}) {
+      if (!app && search === undefined) throw new Unsupported("the card host starts apps by class name");
+      typed = new Map();
+      host = createCardHost({ url: run.url, location: (cls) => ({ origin: mock.origin, pathname: run.path, search: `?app_start=${encodeURIComponent(cls)}` }) });
+      await host.start(app, search !== undefined ? { search } : {});
+    },
+
+    async fill(target, value) {
+      if (!host) throw new Unsupported("no card");
+      if (!target.path) throw new Unsupported("card inputs are addressed by binding path");
+      const input = inputs(liveItems(render().card)).find((i) => i.id === target.path);
+      if (!input) throw new Error(`no input "${target.path}" on the card (inputs: ${inputs(liveItems(render().card)).map((i) => i.id).join(", ") || "none"})`);
+      typed.set(input.id, typeof value === "boolean" ? String(value) : value);
+    },
+
+    async press(target, { wait = true } = {}) {
+      if (!host) throw new Unsupported("no card");
+      const action = findAction(target);
+      if (!action) throw new Error(`no action ${target.event || target.text} on the card`);
+      // built when it leaves the host's queue - the card it reads is the one
+      // on screen then
+      const p = host.submit(() => payloadFor(action));
+      if (wait) await p;
+      else p.catch(() => {});
+    },
+
+    async closeBox({ action, text }) {
+      const box = render().card.body.find((e) => e.id === "message-box");
+      const a = box && actions([box]).find((x) => x.data.box === action || x.title === text);
+      if (!a) throw new Unsupported("no message box with that action on the card");
+      await host.submit(() => payloadFor(a));
+    },
+
+    async back() {
+      throw new Unsupported("a card has no browser history");
+    },
+
+    async setModel() {
+      throw new Unsupported("a card host has no programmatic model access");
+    },
+
+    async settle() {
+      if (host) await host.settle();
+    },
+
+    async state() {
+      const s = emptyState();
+      if (!host) return s;
+      const { card, unsupported } = render();
+      const st = host.state;
+      s.started = Boolean(st.id) || Boolean(host.error);
+      s.app = st.app || null;
+      s.id = st.id || null;
+      s.card = card;
+      for (const slot of Object.keys(s.slots)) {
+        const c = card.body.find((e) => e.id === `slot-${slot}`);
+        if (c) s.slots[slot] = { open: true, text: cardText(c) };
+      }
+      for (const slot of liveSlots(st, host.messages)) {
+        const c = card.body.find((e) => e.id === `slot-${slot}`);
+        const m = st.models[modelKeyOf(slot)];
+        for (const i of inputs(c ? [c] : [])) {
+          if (!i.id.startsWith("/") || Object.prototype.hasOwnProperty.call(s.values, i.id)) continue;
+          s.values[i.id] = coerce(i.value === undefined ? "" : i.value, getAt((m && m.data) || {}, i.id));
+        }
+      }
+      for (const [k, m] of Object.entries(st.models)) s.models[k] = m.data;
+      s.messages = host.messages.map((m) => ({ kind: m.kind, text: m.text, ...(m.type ? { type: m.type } : {}) }));
+      s.error = host.error;
+      s.log = [...host.log, ...unsupported.map((u) => `unsupported: ${u.control}${u.id ? ` #${u.id}` : ""} (${u.slot}) - ${u.reason}`)];
+      s.text = cardText(card.body);
+      return s;
+    },
+
+    async stop() {
+      if (host) {
+        host.stop();
+        await host.settle().catch(() => {});
+      }
+      host = null;
+    },
+
+    async close() {},
+  };
+  return adapter;
+}
diff --git a/conformance/frontend/adapters/index.mjs b/conformance/frontend/adapters/index.mjs
index d6bf860..5979a3e 100644
--- a/conformance/frontend/adapters/index.mjs
+++ b/conformance/frontend/adapters/index.mjs
@@ -5,6 +5,7 @@ export const ADAPTERS = Object.freeze({
   ui5: { module: "./ui5.mjs", factory: "createUi5Adapter", description: "the UI5 SPA (abap2UI5 app/webapp) in Chromium via Playwright" },
   agent: { module: "./agent.mjs", factory: "createAgentAdapter", description: "the agent client of abap2UI5/mcp-server (lib/appclient.mjs)" },
   webcomponent: { module: "./webcomponent.mjs", factory: "createWebComponentAdapter", description: "the UI5 Web Components frontend (abap2UI5/frontend-webcomponent dist/abap2ui5-wc.js) in Chromium" },
+  "adaptive-cards": { module: "./adaptive-cards.mjs", factory: "createAdaptiveCardsAdapter", description: "the Adaptive Cards renderer prototype of this repository (renderers/adaptive-cards/), in process" },
   headless: { module: "./headless.mjs", factory: "createHeadlessAdapter", description: "the headless ABAP simulator (abap2UI5/headless-frontend) - not drivable yet" },
 });
 
diff --git a/conformance/frontend/adapters/vendor/mcp-server/appclient.mjs b/conformance/frontend/adapters/vendor/mcp-server/appclient.mjs
index 693b12c..f2c0e9f 100644
--- a/conformance/frontend/adapters/vendor/mcp-server/appclient.mjs
+++ b/conformance/frontend/adapters/vendor/mcp-server/appclient.mjs
@@ -1,5 +1,5 @@
 /*
- * VENDORED - do not edit. abap2UI5/mcp-server lib/appclient.mjs at commit ea4e9fa8f6eaeca8f9c975a1c4fbd532c44ff76c,
+ * VENDORED - do not edit. abap2UI5/mcp-server lib/appclient.mjs at commit a4d9f07659cd8a18d2e1f8ee4d2121695f40702b,
  * copied unchanged by scripts/vendor-agent-client.mjs. Change it upstream,
  * then re-vendor; test/frontend.test.mjs fails when this copy drifts.
  */
@@ -103,21 +103,34 @@ function deltaSteps(segs) {
   return null;
 }
 
-/** The backend's error page (a 500 renders the exception chain in a 
)
- *  as plain text. */
+/*
+ * The backend's error body as the refusal shows it: VERBATIM text
+ * (protocol spec/errors.md) - the body is text/plain, and what looks like a
+ * tag in it (a request URL the backend reflected into the first frame) is
+ * text, so nothing is stripped, decoded or otherwise interpreted. It is only
+ * shortened (the first ERROR_LINES lines, at most ERROR_CHARS characters)
+ * and its control characters other than tab and newline are shown as
+ * U+FFFD, so a body cannot move a terminal's cursor or hide text from
+ * whoever reads the refusal - neither is markup.
+ */
+const ERROR_LINES = 40;
+const ERROR_CHARS = 4000;
+const REPLACEMENT = String.fromCodePoint(0xfffd);
+
 export function errorText(status, body) {
-  const s = String(body || '');
-  const pre = /]*>([\s\S]*?)<\/pre>/i.exec(s);
-  const raw = pre ? pre[1] : s;
-  const text = raw
-    .replace(//gi, '\n')
-    .replace(/<[^>]+>/g, '')
-    .replace(/ /g, ' ')
-    .replace(/</g, '<')
-    .replace(/>/g, '>')
-    .replace(/"/g, '"')
-    .replace(/&/g, '&')
-    .split('\n').map((l) => l.trimEnd()).filter(Boolean).slice(0, 12).join('\n');
+  const all = String(body ?? '')
+    // eslint-disable-next-line no-control-regex
+    .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, REPLACEMENT)
+    .replace(/\r\n?/g, '\n')
+    .replace(/\s+$/, '');
+  const lines = all.split('\n');
+  let text = lines.slice(0, ERROR_LINES).join('\n');
+  let cut = lines.length > ERROR_LINES;
+  if (text.length > ERROR_CHARS) {
+    text = text.slice(0, ERROR_CHARS);
+    cut = true;
+  }
+  if (cut) text += `\n... (${all.length - text.length} more characters)`;
   return `HTTP ${status}${text ? `: ${text}` : ''}`;
 }
 
@@ -129,14 +142,33 @@ const listOf = (items) => {
 /** The hint after "the backend did not answer (...)" on the local backend. */
 export const LOCAL_BACKEND_HINT = 'is it running? backend { action: "status" } says';
 
-/** The transport over `fetch`: one POST of `body` to `baseUrl`. */
+/** The protocol number this client is written for (protocol
+ *  spec/versioning.md): a response declaring another one is refused. */
+export const PROTOCOL = 2;
+
+/** A response header, case-insensitively, a repeated one joined; '' when absent. */
+export function headerOf(headers, name) {
+  if (!headers) return '';
+  const want = String(name).toLowerCase();
+  for (const [k, v] of Object.entries(headers)) {
+    if (k.toLowerCase() === want) return (Array.isArray(v) ? v.join(', ') : String(v ?? '')).trim();
+  }
+  return '';
+}
+
+/** The frontend's rule for a stateful session id (core/Lib.js
+ *  isValidContextId): never empty, never the text `undefined`. */
+export const validContextId = (id) => typeof id === 'string' && id !== '' && id !== 'undefined';
+
+/** The transport over `fetch`: one POST of `body` to `baseUrl` - or, for the
+ *  CSRF token fetch, one HEAD without a body. */
 export function fetchTransport({ baseUrl, fetchImpl = globalThis.fetch }) {
-  return async ({ body, headers, signal }) => {
-    const res = await fetchImpl(baseUrl, { method: 'POST', headers, body, signal });
+  return async ({ method = 'POST', body, headers, signal }) => {
+    const res = await fetchImpl(baseUrl, method === 'HEAD' ? { method, headers, signal } : { method, headers, body, signal });
     return {
       status: res.status,
       headers: res.headers && typeof res.headers.entries === 'function' ? Object.fromEntries(res.headers.entries()) : {},
-      body: await res.text(),
+      body: method === 'HEAD' ? '' : await res.text(),
     };
   };
 }
@@ -150,12 +182,17 @@ export function fetchTransport({ baseUrl, fetchImpl = globalThis.fetch }) {
  *   baseUrl      the backend's root (http://127.0.0.1:/) - where the
  *                default transport POSTs and what the default location says
  *   fetchImpl    the default transport's fetch
- *   transport    ({ body, headers, signal, draftId }) => { status, headers?, body }:
- *                ONE roundtrip - `body` is the serialized JSON request,
- *                `headers` the two the frontend sends, `draftId` the
- *                S_FRONT.ID the request continues (null for an app start);
- *                a throw is "the backend did not answer". Replaces
- *                baseUrl/fetchImpl.
+ *   transport    ({ method, body, headers, signal, draftId }) => { status, headers?, body }:
+ *                ONE request, sent as given. `method` 'POST' is a roundtrip:
+ *                `body` the serialized JSON request, `headers` what the
+ *                frontend sends (content-type, sap-contextid-accept, and the
+ *                session's sap-contextid and the CSRF token once the backend
+ *                handed them out); `method` 'HEAD' is the CSRF token fetch
+ *                (no body). `draftId` is the S_FRONT.ID the request continues
+ *                (null for an app start). The answer's `headers` are read for
+ *                sap-contextid and x-csrf-token - the client does both
+ *                handshakes itself, so a transport must not. A throw is "the
+ *                backend did not answer". Replaces baseUrl/fetchImpl.
  *   location     (app) => { origin, pathname, search } (or a promise of
  *                it): the start request's ORIGIN/PATHNAME/SEARCH - the
  *                backend builds URLs out of them and keeps them with the
@@ -192,15 +229,58 @@ export function createAppClient({
   }));
   const currentGeneration = () => (generation ? generation() : null);
 
-  async function post(body) {
+  // the token a CSRF token layer in front of the backend handed out - one
+  // per backend, as the browser frontend keeps it per server
+  let csrfToken = '';
+
+  const requestHeaders = (session) => {
+    const headers = { 'content-type': 'application/json', 'sap-contextid-accept': 'header' };
+    if (session && validContextId(session.contextId)) headers['sap-contextid'] = session.contextId;
+    if (csrfToken) headers['x-csrf-token'] = csrfToken;
+    return headers;
+  };
+
+  /*
+   * A token layer's refusal (an approuter route with csrfProtection, a
+   * Gateway): 403 with `X-CSRF-Token: Required`. The backend's own CSRF gate
+   * answers a 403 WITHOUT it, and that one is final.
+   */
+  const csrfRequired = (res) => res && res.status === 403 && headerOf(res.headers, 'x-csrf-token').toLowerCase() === 'required';
+
+  /* HEAD with `X-CSRF-Token: Fetch`; the token comes back in the same
+   * header. Answers whether one arrived and never throws - without one the
+   * refusal that asked for it is reported as it is. */
+  async function fetchCsrfToken(session, signal, draftId) {
+    csrfToken = '';
+    try {
+      const headers = { 'x-csrf-token': 'Fetch' };
+      if (session && validContextId(session.contextId)) headers['sap-contextid'] = session.contextId;
+      const res = await roundtrip({ method: 'HEAD', headers, signal, draftId });
+      const token = headerOf(res && res.headers, 'x-csrf-token');
+      if (res && res.status >= 200 && res.status < 300 && token && !['required', 'fetch'].includes(token.toLowerCase())) csrfToken = token;
+    } catch {
+      // no token: the 403 below says why
+    }
+    return csrfToken !== '';
+  }
+
+  /*
+   * One roundtrip of `session` (null for an app start), with the browser
+   * frontend's handshakes (protocol spec/transport.md): the session's
+   * sap-contextid sent once the backend handed one out, and a token layer's
+   * 403 answered by a token fetch and ONE re-send of the same body. Answers
+   * the response and the sap-contextid it carried (null when none); the
+   * caller adopts both, or neither.
+   */
+  async function post(body, session) {
+    const draftId = body.S_FRONT && body.S_FRONT.ID ? String(body.S_FRONT.ID) : null;
+    const serialized = JSON.stringify({ value: body });
+    const signal = AbortSignal.timeout(timeoutMs);
+    const send = () => roundtrip({ method: 'POST', body: serialized, headers: requestHeaders(session), signal, draftId });
     let res;
     try {
-      res = await roundtrip({
-        body: JSON.stringify({ value: body }),
-        headers: { 'content-type': 'application/json', 'sap-contextid-accept': 'header' },
-        signal: AbortSignal.timeout(timeoutMs),
-        draftId: body.S_FRONT && body.S_FRONT.ID ? String(body.S_FRONT.ID) : null,
-      });
+      res = await send();
+      if (csrfRequired(res) && await fetchCsrfToken(session, signal, draftId)) res = await send();
     } catch (e) {
       throw new AgentError(`the backend did not answer (${(e && e.message) || e})${backendHint ? ` - ${backendHint}` : ''}`);
     }
@@ -213,7 +293,17 @@ export function createAppClient({
       throw new AgentError(`the backend answered no JSON: ${text.slice(0, 300)}`);
     }
     if (!json || !json.S_FRONT) throw new AgentError(`the backend answered without S_FRONT: ${text.slice(0, 300)}`);
-    return json;
+    // checked before anything of the response is read (spec/versioning.md):
+    // a present and different number is refused whole, its ID included;
+    // an absent one is let through (a backend older than the field)
+    const declared = json.S_FRONT.PROTOCOL;
+    if (declared !== undefined && declared !== null && Number(declared) !== PROTOCOL) {
+      const newer = Number(declared) > PROTOCOL;
+      throw new AgentError(`the backend answered protocol ${JSON.stringify(declared)}, this client speaks protocol ${PROTOCOL} - `
+        + `the ${newer ? 'client' : 'backend'} is older; nothing of the response was adopted (update the ${newer ? 'client' : 'backend'})`);
+    }
+    const contextId = headerOf(res.headers, 'sap-contextid');
+    return { json, contextId: validContextId(contextId) ? contextId : null };
   }
 
   function remember(session) {
@@ -224,7 +314,9 @@ export function createAppClient({
     }
   }
 
-  function adopt(session, response) {
+  function adopt(session, { json: response, contextId }) {
+    // a response without the header keeps the established session id
+    if (contextId) session.contextId = contextId;
     session.state = applyResponse(session.state, response);
     const id = session.state.id;
     if (id) {
@@ -675,6 +767,112 @@ export function createAppClient({
     });
   }
 
+  // ------------------------------------------------------------ act ----
+
+  async function actNow(session, { values, event, args, row, maxRows } = {}) {
+    let res = analyze(session, maxRows);
+    session.lastIndex = res.index;
+    // validate everything before anything changes
+    let entry = null;
+    if (event !== undefined && event !== null && event !== '') {
+      entry = findAction(event, row, res.snapshot, res.index);
+      if (!entry.action.enabled) throw new AgentError(`action ${entry.action.id} (${entry.action.label}) is disabled - ${actionHelp(res.snapshot)}`);
+    } else if (row !== undefined && row !== null) {
+      throw new AgentError('`row` belongs to an event - pass `event` too');
+    }
+    const savedPending = Object.fromEntries(Object.entries(session.pending).map(([k, m]) => [k, new Map(m)]));
+    const savedModels = JSON.stringify(session.state.models);
+    let body;
+    let sent;
+    let modelKey;
+    try {
+      applyValues(session, values, res.snapshot, res.index);
+      if (!entry) {
+        res = analyze(session, maxRows);
+        session.lastIndex = res.index;
+        return res.snapshot;
+      }
+      // the values may have changed what the args read: re-analyse first
+      res = analyze(session, maxRows);
+      session.lastIndex = res.index;
+      entry = res.index.actions.get(entry.action.id) || entry;
+      if (entry.frontend) {
+        // performed here, as the browser performs it: the slot closes, its
+        // unsent edits go with it, no roundtrip
+        const slot = entry.frontend;
+        const nextState = { ...session.state, slots: { ...session.state.slots }, models: { ...session.state.models }, custom: [] };
+        delete nextState.slots[slot];
+        delete nextState.models[slot];
+        session.state = nextState;
+        session.pending[slot] = new Map();
+        res = analyze(session, maxRows);
+        session.lastIndex = res.index;
+        return res.snapshot;
+      }
+      const picked = entry.pick ? applyPick(entry, row, session) : null;
+      const tArgs = eventArgs(entry, args, row, session, picked);
+      modelKey = entry.modelKey || 'MAIN';
+      // what goes out: the edits pending NOW - the ones made while the
+      // roundtrip is in flight are not part of it
+      sent = new Map(session.pending[modelKey] || []);
+      body = { S_FRONT: { ID: session.state.id, EVENT: entry.action.event } };
+      if (tArgs.length) body.S_FRONT.T_EVENT_ARG = tArgs;
+      if (sent.size && session.state.models[modelKey]) body.MODEL = buildDelta([...sent.keys()], session.state.models[modelKey].data);
+    } catch (e) {
+      // a refused act changes nothing: neither the pending edits nor the
+      // models (nothing ran in between - this part does not wait)
+      session.pending = savedPending;
+      session.state = { ...session.state, models: JSON.parse(savedModels) };
+      throw e;
+    }
+    const edits = editsSince(session, savedPending);
+    let response;
+    try {
+      response = await post(body, session);
+    } catch (e) {
+      rollBack(session, edits, savedPending, JSON.parse(savedModels));
+      throw e;
+    }
+    // only what the roundtrip carried is done with: an edit made while it
+    // was in flight (another value, or a path it did not carry) stays pending
+    const pendingNow = session.pending[modelKey];
+    if (pendingNow) {
+      for (const [p, v] of sent) if (pendingNow.has(p) && pendingNow.get(p) === v) pendingNow.delete(p);
+    }
+    adopt(session, response);
+    res = analyze(session, maxRows);
+    session.lastIndex = res.index;
+    return res.snapshot;
+  }
+
+  /* The edits this act made (its values, its pick): every pending path that
+   * differs from what was pending before it. */
+  function editsSince(session, savedPending) {
+    const edits = [];
+    for (const [key, map] of Object.entries(session.pending)) {
+      const before = savedPending[key];
+      for (const [p, v] of map) if (!(before && before.has(p) && before.get(p) === v)) edits.push({ key, p, v });
+    }
+    return edits;
+  }
+
+  /*
+   * A roundtrip that failed takes back the edits of ITS act - not the ones
+   * made while it was in flight: a path is restored only while it still
+   * holds what this act put there.
+   */
+  function rollBack(session, edits, savedPending, savedModels) {
+    for (const { key, p, v } of edits) {
+      const map = session.pending[key];
+      if (!map || map.get(p) !== v) continue;
+      const before = savedPending[key];
+      if (before && before.has(p)) map.set(p, before.get(p));
+      else map.delete(p);
+      const model = session.state.models[key];
+      if (model) setAt(model.data, p, savedModels[key] ? getAt(savedModels[key].data, p) : undefined);
+    }
+  }
+
   // --------------------------------------------------------- operations ----
 
   return {
@@ -683,10 +881,11 @@ export function createAppClient({
       const cls = String(app || '').trim();
       if (!cls) throw new AgentError('pass `app` - the class to start, e.g. z2ui5_cl_smp_app_009 (app_list names the built ones)');
       const where = await locate(cls);
-      const response = await post({ S_FRONT: { ORIGIN: where.origin, PATHNAME: where.pathname, SEARCH: where.search } });
+      const response = await post({ S_FRONT: { ORIGIN: where.origin, PATHNAME: where.pathname, SEARCH: where.search } }, null);
       const session = {
         state: emptyState(), ids: new Set(), pending: { MAIN: new Map(), POPUP: new Map(), POPOVER: new Map() },
         generation: currentGeneration(), maxRows: maxRows ?? DEFAULT_MAX_ROWS, lastIndex: null,
+        contextId: null, queue: Promise.resolve(),
       };
       adopt(session, response);
       remember(session);
@@ -708,64 +907,30 @@ export function createAppClient({
       return res.snapshot;
     },
 
-    /** app_act: validate, apply values, fire the event (or keep the values pending). */
-    async act(sessionId, { values, event, args, row, maxRows } = {}) {
+    /*
+     * app_act: validate, apply values, fire the event (or keep the values
+     * pending). One roundtrip at a time per session (protocol
+     * spec/transport.md "Client behaviour"): an act with an event while
+     * another is in flight waits for it and then runs on the screen and the
+     * draft id that one left - two overlapping requests would continue the
+     * same draft, and the later answer would drop what the earlier did. The
+     * session id is checked when the act is CALLED, so the queued act may
+     * name the draft the one in flight continues. Values without an event
+     * start no roundtrip and apply at once, as typing does while the browser
+     * waits; the act in flight leaves them pending.
+     */
+    async act(sessionId, opts = {}) {
       const session = find(sessionId);
-      let res = analyze(session, maxRows);
-      session.lastIndex = res.index;
-      // validate everything before anything changes
-      let entry = null;
-      if (event !== undefined && event !== null && event !== '') {
-        entry = findAction(event, row, res.snapshot, res.index);
-        if (!entry.action.enabled) throw new AgentError(`action ${entry.action.id} (${entry.action.label}) is disabled - ${actionHelp(res.snapshot)}`);
-      } else if (row !== undefined && row !== null) {
-        throw new AgentError('`row` belongs to an event - pass `event` too');
-      }
-      const savedPending = Object.fromEntries(Object.entries(session.pending).map(([k, m]) => [k, new Map(m)]));
-      const savedModels = JSON.stringify(session.state.models);
-      try {
-        applyValues(session, values, res.snapshot, res.index);
-        if (!entry) {
-          res = analyze(session, maxRows);
-          session.lastIndex = res.index;
-          return res.snapshot;
+      const { event } = opts;
+      if (event === undefined || event === null || event === '') return actNow(session, opts);
+      const run = session.queue.then(() => {
+        if (generation && session.generation !== generation()) {
+          throw new AgentError(`session '${sessionId}' was started on a backend that has since stopped or restarted - its drafts are gone; app_start ${session.state.app || 'the app'} again`);
         }
-        // the values may have changed what the args read: re-analyse first
-        res = analyze(session, maxRows);
-        session.lastIndex = res.index;
-        entry = res.index.actions.get(entry.action.id) || entry;
-        if (entry.frontend) {
-          // performed here, as the browser performs it: the slot closes, its
-          // unsent edits go with it, no roundtrip
-          const slot = entry.frontend;
-          const nextState = { ...session.state, slots: { ...session.state.slots }, models: { ...session.state.models }, custom: [] };
-          delete nextState.slots[slot];
-          delete nextState.models[slot];
-          session.state = nextState;
-          session.pending[slot] = new Map();
-          res = analyze(session, maxRows);
-          session.lastIndex = res.index;
-          return res.snapshot;
-        }
-        const picked = entry.pick ? applyPick(entry, row, session) : null;
-        const tArgs = eventArgs(entry, args, row, session, picked);
-        const modelKey = entry.modelKey || 'MAIN';
-        const sent = session.pending[modelKey] || new Map();
-        const body = { S_FRONT: { ID: session.state.id, EVENT: entry.action.event } };
-        if (tArgs.length) body.S_FRONT.T_EVENT_ARG = tArgs;
-        if (sent.size && session.state.models[modelKey]) body.MODEL = buildDelta([...sent.keys()], session.state.models[modelKey].data);
-        const response = await post(body);
-        session.pending[modelKey] = new Map();
-        adopt(session, response);
-      } catch (e) {
-        // a refused act changes nothing: neither the pending edits nor the models
-        session.pending = savedPending;
-        session.state = { ...session.state, models: JSON.parse(savedModels) };
-        throw e;
-      }
-      res = analyze(session, maxRows);
-      session.lastIndex = res.index;
-      return res.snapshot;
+        return actNow(session, opts);
+      });
+      session.queue = run.catch(() => {});
+      return run;
     },
 
     /** The open sessions (for diagnostics and the error texts). */
diff --git a/conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs b/conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs
index d5f9ca5..2d994d6 100644
--- a/conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs
+++ b/conformance/frontend/adapters/vendor/mcp-server/snapshot.mjs
@@ -1,5 +1,5 @@
 /*
- * VENDORED - do not edit. abap2UI5/mcp-server lib/snapshot.mjs at commit ea4e9fa8f6eaeca8f9c975a1c4fbd532c44ff76c,
+ * VENDORED - do not edit. abap2UI5/mcp-server lib/snapshot.mjs at commit a4d9f07659cd8a18d2e1f8ee4d2121695f40702b,
  * copied unchanged by scripts/vendor-agent-client.mjs. Change it upstream,
  * then re-vendor; test/frontend.test.mjs fails when this copy drifts.
  */
@@ -91,6 +91,10 @@ function destroySlot(next, slot) {
 
 /*
  * One response folded into the state, the way the frontend folds it:
+ *   - a response of another APP than the one that answered last tears the
+ *     popup and the popover down first (protocol spec/response.md "View
+ *     slots": the backend queues those destroys only for a hop between two
+ *     instances of the same class, which a frontend cannot see);
  *   - every VIEW_SLOTS action of T_SYSTEM in order (a MAIN display tears
  *     down the nested views, the popup and the popover - the backend sends
  *     no destroy for them next to a MAIN display);
@@ -107,6 +111,10 @@ export function applyResponse(state, response) {
   const prev = state || emptyState();
   const next = { app: prev.app, id: prev.id, slots: { ...prev.slots }, models: { ...prev.models }, custom: [] };
   const front = (response && response.S_FRONT) || {};
+  if (front.APP && prev.app && front.APP !== prev.app) {
+    destroySlot(next, 'POPUP');
+    destroySlot(next, 'POPOVER');
+  }
   if (front.APP) next.app = front.APP;
   if (front.ID) next.id = front.ID;
   const hasModel = response && response.MODEL !== undefined;
diff --git a/conformance/frontend/adapters/vendor/mcp-server/source.json b/conformance/frontend/adapters/vendor/mcp-server/source.json
index f7aee95..acd0155 100644
--- a/conformance/frontend/adapters/vendor/mcp-server/source.json
+++ b/conformance/frontend/adapters/vendor/mcp-server/source.json
@@ -1,18 +1,18 @@
 {
   "repository": "abap2UI5/mcp-server",
-  "commit": "ea4e9fa8f6eaeca8f9c975a1c4fbd532c44ff76c",
+  "commit": "a4d9f07659cd8a18d2e1f8ee4d2121695f40702b",
   "files": {
     "appclient.mjs": {
       "from": "lib/appclient.mjs",
-      "sha256": "05dade6a671a633e14beb5d456217f02da5c361ad8ff59ad6acdc22899af6430"
+      "sha256": "a49faf529040c37059f3abaff09e303bd8da629ebc3e699c2e1ddb4cfab328cf"
     },
     "snapshot.mjs": {
       "from": "lib/snapshot.mjs",
-      "sha256": "a00432a008f8801ebbb478ddf6c9dfc9e61f1642889d50af8a8ac53ec0ddc83e"
+      "sha256": "7b2a40ce90bece6fb9c04da075b4515e7cfedc8a592d7e5eedff87f4c471de48"
     },
     "viewxml.mjs": {
       "from": "lib/viewxml.mjs",
-      "sha256": "c096ca43aba72cd5fc184247d366bf03d5052ca823664a2b1228cee54fcbd66f"
+      "sha256": "c1c45b179f493a5eea7fe1b62a41a18e222000f0737f504e49fbb88c5d219e6e"
     }
   }
 }
diff --git a/conformance/frontend/adapters/vendor/mcp-server/viewxml.mjs b/conformance/frontend/adapters/vendor/mcp-server/viewxml.mjs
index 8d577f0..3786f0a 100644
--- a/conformance/frontend/adapters/vendor/mcp-server/viewxml.mjs
+++ b/conformance/frontend/adapters/vendor/mcp-server/viewxml.mjs
@@ -1,5 +1,5 @@
 /*
- * VENDORED - do not edit. abap2UI5/mcp-server lib/viewxml.mjs at commit ea4e9fa8f6eaeca8f9c975a1c4fbd532c44ff76c,
+ * VENDORED - do not edit. abap2UI5/mcp-server lib/viewxml.mjs at commit a4d9f07659cd8a18d2e1f8ee4d2121695f40702b,
  * copied unchanged by scripts/vendor-agent-client.mjs. Change it upstream,
  * then re-vendor; test/frontend.test.mjs fails when this copy drifts.
  */
diff --git a/package.json b/package.json
index b1a96c3..26076c5 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
     "./frontend": "./conformance/frontend/index.mjs",
     "./schema/*": "./schema/*",
     "./profiles/portable-v1.json": "./profiles/portable-v1.json",
+    "./renderers/adaptive-cards": "./renderers/adaptive-cards/index.mjs",
     "./package.json": "./package.json"
   },
   "bin": {
@@ -19,6 +20,7 @@
     "schema/",
     "spec/",
     "profiles/",
+    "renderers/",
     "traffic/node-runtime/",
     "README.md",
     "CHANGELOG.md",
@@ -31,12 +33,14 @@
     "conformance:frontend:ui5": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter ui5",
     "conformance:frontend:agent": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter agent",
     "conformance:frontend:webcomponent": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter webcomponent",
+    "conformance:frontend:adaptive-cards": "node conformance/backend/bin/abap2ui5-conformance.mjs frontend --adapter adaptive-cards",
+    "demo:adaptive-cards": "node renderers/adaptive-cards/demo.mjs",
     "build:node-runtime": "node conformance/hosts/node-runtime/build.mjs",
     "serve:node-runtime": "node conformance/hosts/node-runtime/serve.mjs --build",
     "serve:cap2ui5": "node conformance/hosts/cap2ui5/serve.mjs",
     "record": "node scripts/record-traffic.mjs node-runtime && node scripts/record-traffic.mjs cap2ui5",
     "lint:abap": "cd conformance/apps && abaplint abaplint.jsonc",
-    "generate": "node scripts/gen-check-list.mjs && node scripts/render-portable.mjs",
+    "generate": "node scripts/gen-check-list.mjs && node scripts/render-portable.mjs && node scripts/render-adaptive-cards.mjs",
     "vendor:agent": "node scripts/vendor-agent-client.mjs"
   },
   "engines": {
diff --git a/profiles/portable-v1.json b/profiles/portable-v1.json
index 1838502..995a272 100644
--- a/profiles/portable-v1.json
+++ b/profiles/portable-v1.json
@@ -4633,7 +4633,7 @@
       {
         "wire": ".eF('ACTION', arg...)",
         "from": "client->_event_client( ) / a wired follow_up_action( )",
-        "note": "only the frontend actions of frontendActions.allowed"
+        "note": "only the frontend actions of actions.wire.custom and actions.wire.customGlobals"
       },
       {
         "wire": ".eBP($event, true, ['EVENT'], arg...)",
@@ -4865,6 +4865,112 @@
       "CONTROL_GLOBAL FORMATTING"
     ]
   },
+  "actions": {
+    "note": "api: the follow_up_action( ) names (cs_event values) a portable app may call - frontendActions.allowed is the same list, kept for consumers of revision 0.2. wire: what a portable renderer actually receives - T_SYSTEM actions with their ROUTER options, and the T_CUSTOM / .eF names. The navigation family of api is folded by the backend into the one ROUTER system action (foldedIntoRouter) and never arrives under its own name.",
+    "api": [
+      "SET_FOCUS",
+      "START_TIMER",
+      "DOWNLOAD_B64_FILE",
+      "CLIPBOARD_COPY",
+      "URLHELPER",
+      "OPEN_NEW_TAB",
+      "SCROLL_TO",
+      "SCROLL_INTO_VIEW",
+      "SET_TITLE",
+      "SET_FAVICON",
+      "LOCATION_RELOAD",
+      "SYSTEM_LOGOUT",
+      "STORE_DATA",
+      "KEYBOARD_SHORTCUT",
+      "PLAY_AUDIO",
+      "SET_PUSH_STATE",
+      "HASH_REPLACE",
+      "HASH_BACK",
+      "HASH_ATTACH_CHANGED",
+      "SET_NAV_ROUTING",
+      "SET_APP_STATE_ACTIVE",
+      "SET_SIZE_LIMIT"
+    ],
+    "wire": {
+      "system": {
+        "VIEW_SLOTS": [
+          "display",
+          "destroy"
+        ],
+        "ROUTER": [
+          "sync"
+        ]
+      },
+      "routerOptions": [
+        "setNavRouting",
+        "checkNavAppCall",
+        "navAppCallPrevApp",
+        "navAppCallPrevId",
+        "setPushState",
+        "setHashReplace",
+        "setHashEvent",
+        "setAppStateActive"
+      ],
+      "foldedIntoRouter": {
+        "SET_NAV_ROUTING": "setNavRouting",
+        "SET_PUSH_STATE": "setPushState",
+        "HASH_REPLACE": "setHashReplace",
+        "HASH_ATTACH_CHANGED": "setHashEvent",
+        "SET_APP_STATE_ACTIVE": "setAppStateActive"
+      },
+      "custom": [
+        "SET_FOCUS",
+        "START_TIMER",
+        "DOWNLOAD_B64_FILE",
+        "CLIPBOARD_COPY",
+        "URLHELPER",
+        "OPEN_NEW_TAB",
+        "SCROLL_TO",
+        "SCROLL_INTO_VIEW",
+        "SET_TITLE",
+        "SET_FAVICON",
+        "LOCATION_RELOAD",
+        "SYSTEM_LOGOUT",
+        "STORE_DATA",
+        "KEYBOARD_SHORTCUT",
+        "PLAY_AUDIO",
+        "HASH_BACK",
+        "SET_SIZE_LIMIT"
+      ],
+      "customGlobals": {
+        "MESSAGE_TOAST": [
+          "show"
+        ],
+        "MESSAGE_BOX": [
+          "show",
+          "alert",
+          "confirm",
+          "information",
+          "warning",
+          "error",
+          "success"
+        ],
+        "BUSY_INDICATOR": [
+          "show",
+          "hide"
+        ],
+        "INVISIBLE_MESSAGE": [
+          "announce"
+        ],
+        "THEMING": [
+          "setTheme"
+        ],
+        "VIEW_SLOTS": [
+          "destroy"
+        ]
+      },
+      "noOpAllowed": [
+        "ROUTER",
+        "HASH_BACK",
+        "SET_SIZE_LIMIT"
+      ]
+    }
+  },
   "clientApi": {
     "allowed": [
       "view_display",
diff --git a/profiles/portable.md b/profiles/portable.md
index 89d937b..5982aca 100644
--- a/profiles/portable.md
+++ b/profiles/portable.md
@@ -69,11 +69,17 @@ unchanged ([section 10](#10-method-and-caveats), [portable-coverage.md](portable
 - **Slots.** A portable app uses the slots MAIN (`view_display`), POPUP
   (`popup_display`, `popup_destroy`) and POPOVER (`popover_display( xml
   by_id )`, `popover_destroy`). The nested slots NEST and NEST2 are not in v1
-  (6 core apps use them; [section 9](#9-v11-candidates-informative)): a
-  portable frontend that receives a NEST/NEST2 display MAY show a
-  placeholder for it, but MUST process the action (it is core protocol,
-  [../spec/response.md](../spec/response.md#view-slots)) without failing the
-  roundtrip.
+  (6 core apps use them; [section 9](#9-v11-candidates-informative)), and
+  the two sides of that are separate rules (decided in revision 0.3,
+  [open question 1](../spec/open-questions.md#1-the-nest-slots-in-portable-renderers)):
+  - A portable **app** MUST NOT use them (`nest_view_display`,
+    `nest2_view_display` and their destroys are outside the client API of
+    [section 6](#6-frontend-actions)). The abap2UI5 linter's portable rule
+    reports the calls; an app that needs nested views is a UI5-profile app.
+  - A portable **renderer** tolerates them: one that receives a NEST/NEST2
+    display MAY show a placeholder for it, but MUST process the action (it
+    is core protocol, [../spec/response.md](../spec/response.md#view-slots))
+    without failing the roundtrip. *Frontend check:* `slots.nest-processed`.
 - **Namespaces.** `sap.m`, `sap.ui.core`, `sap.ui.core.mvc`, `sap.ui.layout`,
   `sap.ui.layout.form`, `sap.tnt`.
 - **Elements.** An element whose local name starts with an upper-case letter
@@ -342,6 +348,58 @@ frontend MUST perform:
 | `SET_PUSH_STATE`, `HASH_REPLACE`, `HASH_BACK`, `HASH_ATTACH_CHANGED`, `SET_NAV_ROUTING`, `SET_APP_STATE_ACTIVE` | 1-3 each | URL hash handling ([../spec/navigation.md](../spec/navigation.md)); MAY be a no-op without a URL (native) |
 | `SET_SIZE_LIMIT` | 1 | a UI5 list limit - MAY be a no-op |
 
+**Names on the client API and on the wire are not the same list**
+(decided in revision 0.3,
+[open question 10](../spec/open-questions.md#10-navigation-actions-in-the-portable-action-list)).
+The table above names what a portable app may *call*. The navigation family
+among them is folded by the backend into the one `ROUTER` system action
+([../spec/actions.md](../spec/actions.md#vocabulary),
+[../spec/navigation.md](../spec/navigation.md#the-router-action), [CL]
+`follow_up_action`, [FE] `check_on_event`): a renderer never receives
+`SET_PUSH_STATE`, `HASH_REPLACE`, `HASH_ATTACH_CHANGED`, `SET_NAV_ROUTING`
+or `SET_APP_STATE_ACTIVE` - it receives `ROUTER` with options, and
+`HASH_BACK` as a follow-up action. `portable-v1.json` says both:
+`actions.api` (what an app may call; `frontendActions.allowed` is the same
+list, kept for readers of revision 0.2) and `actions.wire` (what a renderer
+implements):
+
+
+
+| Client API (`actions.api`) | What the renderer receives (`actions.wire`) |
+|---|---|
+| `SET_FOCUS` | `SET_FOCUS` (`T_CUSTOM`, `.eF`) |
+| `START_TIMER` | `START_TIMER` (`T_CUSTOM`, `.eF`) |
+| `DOWNLOAD_B64_FILE` | `DOWNLOAD_B64_FILE` (`T_CUSTOM`, `.eF`) |
+| `CLIPBOARD_COPY` | `CLIPBOARD_COPY` (`T_CUSTOM`, `.eF`) |
+| `URLHELPER` | `URLHELPER` (`T_CUSTOM`, `.eF`) |
+| `OPEN_NEW_TAB` | `OPEN_NEW_TAB` (`T_CUSTOM`, `.eF`) |
+| `SCROLL_TO` | `SCROLL_TO` (`T_CUSTOM`, `.eF`) |
+| `SCROLL_INTO_VIEW` | `SCROLL_INTO_VIEW` (`T_CUSTOM`, `.eF`) |
+| `SET_TITLE` | `SET_TITLE` (`T_CUSTOM`, `.eF`) |
+| `SET_FAVICON` | `SET_FAVICON` (`T_CUSTOM`, `.eF`) |
+| `LOCATION_RELOAD` | `LOCATION_RELOAD` (`T_CUSTOM`, `.eF`) |
+| `SYSTEM_LOGOUT` | `SYSTEM_LOGOUT` (`T_CUSTOM`, `.eF`) |
+| `STORE_DATA` | `STORE_DATA` (`T_CUSTOM`, `.eF`) |
+| `KEYBOARD_SHORTCUT` | `KEYBOARD_SHORTCUT` (`T_CUSTOM`, `.eF`) |
+| `PLAY_AUDIO` | `PLAY_AUDIO` (`T_CUSTOM`, `.eF`) |
+| `SET_PUSH_STATE` | the `ROUTER` option `setPushState` - never `SET_PUSH_STATE` itself |
+| `HASH_REPLACE` | the `ROUTER` option `setHashReplace` - never `HASH_REPLACE` itself |
+| `HASH_BACK` | `HASH_BACK` (`T_CUSTOM`, `.eF`) |
+| `HASH_ATTACH_CHANGED` | the `ROUTER` option `setHashEvent` - never `HASH_ATTACH_CHANGED` itself |
+| `SET_NAV_ROUTING` | the `ROUTER` option `setNavRouting` - never `SET_NAV_ROUTING` itself |
+| `SET_APP_STATE_ACTIVE` | the `ROUTER` option `setAppStateActive` - never `SET_APP_STATE_ACTIVE` itself |
+| `SET_SIZE_LIMIT` | `SET_SIZE_LIMIT` (`T_CUSTOM`, `.eF`) |
+| (`MESSAGE_TOAST`) | `MESSAGE_TOAST` show (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) |
+| (`MESSAGE_BOX`) | `MESSAGE_BOX` show / alert / confirm / information / warning / error / success (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) |
+| (`BUSY_INDICATOR`) | `BUSY_INDICATOR` show / hide (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) |
+| (`INVISIBLE_MESSAGE`) | `INVISIBLE_MESSAGE` announce (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) |
+| (`THEMING`) | `THEMING` setTheme (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) |
+| (`VIEW_SLOTS`) | `VIEW_SLOTS` destroy (`T_CUSTOM` or `.eF`, directly or as a `CONTROL_GLOBAL` target) |
+
+System actions (`T_SYSTEM`): `VIEW_SLOTS` display / destroy, `ROUTER` sync; the `ROUTER` options: `setNavRouting`, `checkNavAppCall`, `navAppCallPrevApp`, `navAppCallPrevId`, `setPushState`, `setHashReplace`, `setHashEvent`, `setAppStateActive`. MAY be a no-op where the platform has no counterpart: `ROUTER`, `HASH_BACK`, `SET_SIZE_LIMIT`.
+
+
+
 **Excluded:** `CONTROL_BY_ID` (open-ended UI5 method calls, 16 core apps;
 a closed whitelist is a v1.1 candidate), `BINDING_CALL` (v1.1 candidate),
 `BIND_ELEMENT`, `SET_ODATA_MODEL`, `SMART_VARIANT_INIT`,
diff --git a/renderers/adaptive-cards/README.md b/renderers/adaptive-cards/README.md
new file mode 100644
index 0000000..92c34b9
--- /dev/null
+++ b/renderers/adaptive-cards/README.md
@@ -0,0 +1,245 @@
+# Adaptive Cards renderer (prototype)
+
+A portable renderer for abap2UI5 that is not a browser: it turns an
+abap2UI5 response - the view XML of the portable profile and its JSON
+model ([../../profiles/portable.md](../../profiles/portable.md)) - into an
+[Adaptive Card](https://adaptivecards.io/) (schema 1.5), and an
+`Action.Submit` payload of that card back into the next protocol request.
+A bot, a chat integration or an Outlook actionable message could host an
+abap2UI5 app with it.
+
+Pure Node, no browser, no dependencies. The view XML is read by the
+modules this repository already vendors from abap2UI5/mcp-server
+([../../conformance/frontend/adapters/vendor/mcp-server/](../../conformance/frontend/adapters/vendor/mcp-server/source.json)):
+`viewxml.mjs` (namespaces, bindings, event wires, expression bindings
+without `eval`), `snapshot.mjs` (folding responses into slots and models,
+`applyResponse`) and `appclient.mjs` (the model delta, `buildDelta`).
+
+| File | |
+|---|---|
+| [render.mjs](render.mjs) | state -> card: `renderCard`, `cardText`, `liveSlots` |
+| [mapping.mjs](mapping.mjs) | the control table - one entry per portable control, its card element and render function; the table below is generated from it |
+| [submit.mjs](submit.mjs) | `Action.Submit` payload -> request: `submitToRequest`, `eventRequest`, `startRequest` |
+| [host.mjs](host.mjs) | `createCardHost`: a minimal card host that speaks the protocol over HTTP (the client rules of [spec/transport.md](../../spec/transport.md#client-behaviour)) |
+| [index.mjs](index.mjs) | the exports, `renderResponses` |
+| [demo.mjs](demo.mjs) | a recorded response as card JSON, for the [designer](https://adaptivecards.io/designer) |
+| [golden/](golden/) | golden cards of recorded responses ([../../traffic/](../../traffic/)) and of a synthetic sampler, held by `test/adaptive-cards.test.mjs` |
+
+## Use it
+
+```js
+import { renderResponses, submitToRequest, createCardHost } from "@abap2ui5/protocol/renderers/adaptive-cards";
+
+// pure: responses in, card out
+const { card, unsupported, state } = renderResponses([response]);
+
+// the way back: what the card host submitted -> the next request body
+const r = submitToRequest(state, { event: "SAVE", "/NAME": "Ada" });
+r.request;   // { S_FRONT: { ID, EVENT: "SAVE" }, MODEL: { NAME: "Ada" } } - post it as { value: r.request }
+
+// or let the host do the HTTP
+const host = createCardHost({ url: "https://host/sap/bc/z2ui5" });
+await host.start("Z2UI5_CL_MY_APP");
+host.render().card;
+await host.submit({ event: "SAVE", "/NAME": "Ada" });
+```
+
+```bash
+node renderers/adaptive-cards/demo.mjs                            # the BIND conformance app
+node renderers/adaptive-cards/demo.mjs slots.popup-destroy 0 1    # two responses of a recorded check, folded
+node renderers/adaptive-cards/demo.mjs --file response.json       # a response of your own
+```
+
+## How a response becomes a card
+
+- **Slots.** Each open slot is a `Container` with the id `slot-MAIN`,
+  `slot-POPUP`, `slot-POPOVER` (popup and popover above the page, style
+  `emphasis`). NEST/NEST2 are processed and shown as a placeholder - they are
+  not in portable profile v1 ([../../profiles/portable.md](../../profiles/portable.md#2-documents-slots-and-namespaces)).
+- **Layers.** A message box and a popup are modal: while one is open it is
+  the only interactive layer, and the layers below render read-only (texts,
+  no inputs, no actions). A popover is not modal: it and MAIN are live, and a
+  press in MAIN closes it first, as UI5 does.
+- **Inputs carry their binding path as id** (`/NAME`, `/T_ITEMS/1/TEXT` for
+  a cell of a table row). A card submits every input value with the
+  action's data; `submitToRequest` compares each with the model the card was
+  rendered from, writes the differing ones into the model in the type it
+  holds there (a card submits strings) and sends them as the delta the UI5
+  frontend builds - a scalar or a structure whole, table cells as `__delta`
+  rows ([spec/request.md](../../spec/request.md#the-model-delta)).
+- **Actions.** A backend event wire (`.eB(['SAVE'], ${/NAME}, 'x')`) is an
+  `Action.Submit` with `data: { event: "SAVE", args: ["Ada", "x"], refs:
+  ["/NAME"] }` - arguments resolved at render time, `refs` naming those read
+  from the model so the reverse step reads them again after the edits of the
+  same submit; an action of a popup or popover names its `slot`. A
+  frontend-only wire (`.eF(...)`) is `data: { client: [...] }` and runs in
+  the host without a roundtrip; the leave wire of a page's nav button is the
+  reserved `___ZZZ_NAL`. A row event (`itemPress` with `${ID}`) is a
+  `selectAction` per row, its arguments resolved against the row.
+- **Messages.** A toast is a subtle `TextBlock` (its `onClose` event is
+  raised by the host after the toast's duration); a message box a
+  `Container` (`message-box`) with its text, its details **expanded** as
+  plain text ([spec/actions.md](../../spec/actions.md#messages)) and one
+  `Action.Submit` per box action (`data: { box: "CANCEL" }`), which raises
+  the box's `onClose` event with the action as first argument.
+- **Errors** are shown verbatim in a `RichTextBlock` `TextRun` - not
+  markdown, so nothing of an error body is interpreted
+  ([spec/errors.md](../../spec/errors.md#what-a-frontend-does-with-it)).
+- **Tolerance.** An element outside the profile becomes a placeholder
+  `TextBlock` and an entry of `unsupported` (`{ slot, control, id?, reason
+  }`), as do event wires a card cannot raise (`change`, `liveChange`,
+  `selectionChange`: the edit travels with the next action instead) and
+  event arguments only a UI5 runtime can evaluate (`$event`,
+  `${$parameters>/...}`, sent as `null`). An unknown follow-up action is
+  skipped and logged by the host ([../../profiles/portable.md](../../profiles/portable.md#conformance)).
+- **Follow-up actions** the host knows from `actions.wire` of
+  [portable-v1.json](../../profiles/portable-v1.json): toasts, boxes,
+  `START_TIMER` (it fires the event), `VIEW_SLOTS destroy`; the rest of the
+  portable list has no counterpart in a card and is a logged no-op (focus,
+  scroll, title, downloads, the `ROUTER` action - a card has no URL).
+
+## Conformance
+
+The frontend suite drives it as the in-process adapter `adaptive-cards`
+(`npx abap2ui5-conformance frontend --adapter adaptive-cards`, profile
+`portable`): a fresh host per check against the scripted backend; "fill"
+types into the card input whose id is the path, "press" submits what a card
+host submits for the action (its data merged with every input value).
+Result in [../../conformance/RESULTS.md](../../conformance/RESULTS.md#frontend-suite):
+every MUST and SHOULD that applies holds; the checks that need a URL, a
+DOM, focus, a document title or a programmatic model edit are skipped.
+
+## Limits of the prototype
+
+- Events of inputs are not raised (cards have no change events in 1.5);
+  `submit`/`search` become an `inlineAction`.
+- Growing tables and lists show every row; tabs (IconTabBar) are stacked;
+  a panel is always expanded; layout properties (flex alignment, grid spans,
+  widths) are dropped.
+- `DatePicker` is an `Input.Date` only for ISO values (`yyyy-MM-dd`), other
+  formats stay text; `DateTimePicker` is text.
+- Typed bindings are shown raw (no number or date formatting); formatters
+  and `parts` bindings render empty.
+- No URL: routing (`ROUTER`, the hash) is ignored, as a frontend without a
+  URL may ([spec/navigation.md](../../spec/navigation.md#the-router-action)).
+
+## Mapping
+
+Generated from [mapping.mjs](mapping.mjs) (`node scripts/render-adaptive-cards.mjs`,
+part of `npm run generate`; `npm test` fails when it is out of date).
+
+
+
+65 of 65 controls of portable profile v1 mapped.
+
+### Layout & containers
+
+| Control | Adaptive Card | Notes |
+|---|---|---|
+| sap.m.Page | Container (flattened): title as heading TextBlock, nav button as Action.Submit | `showNavButton` + `navButtonPress` -> an Action.Submit "Back" that raises the wired event (the reserved `___ZZZ_NAL` for `_event_nav_app_leave`); header/footer bars render in place |
+| sap.m.Shell | (none) - its app renders in place | - |
+| sap.m.VBox | Container | flexbox alignment ignored |
+| sap.ui.layout.form.SimpleForm | Container: each Label becomes the `label` of the input after it | the grid layout properties are ignored; a Label not followed by an input is a bold TextBlock |
+| sap.m.HBox | ColumnSet (auto-width columns), or one ActionSet when every child is an action | - |
+| sap.m.Panel | Container (style emphasis), headerText as bold TextBlock | `expandable`/`expanded` ignored - the content is always shown; `expand` not raised |
+| sap.ui.layout.Grid | Container | spans ignored - children stack |
+| sap.m.FlexBox | Container (direction Column) or ColumnSet (Row, the default) | alignment, gaps and wrap ignored |
+| sap.m.ScrollContainer | Container | scrolling is the host's |
+| sap.m.IconTabFilter | Container with the tab text as heading | - |
+| sap.m.IconTabBar | Container: every tab stacked, each under its heading | `selectedKey` ignored - all tabs are shown; `select` not raised |
+| sap.ui.layout.VerticalLayout | Container | - |
+| sap.ui.core.Title | TextBlock (heading) | - |
+| sap.ui.layout.HorizontalLayout | ColumnSet / ActionSet (as HBox) | - |
+
+### Toolbars & bars
+
+| Control | Adaptive Card | Notes |
+|---|---|---|
+| sap.m.OverflowToolbar | ActionSet (all buttons) or ColumnSet | no overflow menu - everything is shown |
+| sap.m.ToolbarSpacer | (nothing) | - |
+| sap.m.Toolbar | ActionSet (all buttons) or ColumnSet | - |
+| sap.m.Bar | ActionSet or ColumnSet of contentLeft, contentMiddle, contentRight | - |
+| sap.m.OverflowToolbarButton | Action.Submit | as Button |
+
+### Display
+
+| Control | Adaptive Card | Notes |
+|---|---|---|
+| sap.m.Text | TextBlock (wrap) | `maxLines` -> maxLines |
+| sap.m.Label | TextBlock (bolder) - or the `label` of the input that follows | - |
+| sap.m.Title | TextBlock (heading, bolder, medium) | - |
+| sap.m.ObjectStatus | TextBlock "title: text", colored by `state` | `active` + `press` -> Action.Submit |
+| sap.m.Link | Action.Submit (press wire) or Action.OpenUrl (href), else TextBlock | - |
+| sap.m.ObjectIdentifier | TextBlock title (bolder) + TextBlock text (subtle) | `titleActive` + `titlePress` -> Action.Submit |
+| sap.ui.core.HTML | TextBlock with the text of the HTML | tags, `