GoogleCloudPlatform / GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK

RFC: MCP Apps (SEP-1865) host support in ADK Python — stable-subset host seam: dual-era advertisement, catalog policy, render bootstrap, governed app invocations

Open
#406 46 comments 1 reaction 0 assignees View on GitHub
enhancement
Dominant language
Python
Stars
47
Forks
21
Avg merge
2d 13h
Merged PRs (30d)
33

Description

> **Revision 4.19 (2026-08-13) — upstream-draft revision, resolving the four P0 blockers and four P1 corrections from the [Revision 4.18 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5277329397).** Changes: every operation gets a **progress-delivery gate** (terminal response atomically closes intake and **drains admitted progress callbacks before result approval, ledger freeze, Event append, and Runner-loop release**); batch discovery becomes a **class-neutral deterministic two-class algorithm** (app-safe listing first; explicit same-name disagreement rule; UI/non-UI disagreement fails closed — never silent reuse of a stale class); the refresh record becomes the exact **`RefreshInvocationRecord`** — a purpose-separated record inside the same opaque sidecar envelope reached by `sealedResultRef`, with immutable-create keys, envelope-before-append write ordering, crash recovery, GC, and incarnation-cascade deletion; and **export gains its matrix row, a through-release permit (streaming prohibited in v1), and a named audit-store contract** in the host profile (a sixth deployment contract), with the durable row honestly described as an authorization/release-attempt record, not delivery proof. P1 corrections: the superseded §1 cleanup paragraph is replaced by the lease-token model, the byte-identity claim is scoped to non-UI paths, ratification 7(k)'s labels now match the adopted per-Session default, and the fixture version, PR contents, and provider count are synchronized. **Per the 4.18 review's presentation guidance, this issue is the durable design ledger — the upstream google/adk-python submission will be a ~15K-character decision-oriented distillation in the #6357 shape, not a copy of this body.**
>
> *(Prior revision note, retained for context:)* **Revision 4.18** — resolving the seven P0 blockers and four P1 tightenings from the [Revision 4.17 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5277166316). Changes: the mixed-batch rejection Event is **core-owned and skips the ordinary event callback** (resolving 4.17's runs-and-forbids contradiction); **user callbacks never execute on the registry thread** — model-path progress (and filters, header providers, trace, cancellation) marshal back to the owning operation/Runner loop, with transport I/O alone on the registry loop; Runner cleanup releases a **Runner-scoped registry lease token** (an argument-free shared `proxy.close()` cannot identify its caller); **legacy/stateful generation keys include the Session incarnation by default** (cross-Session sharing only by explicit profile opt-in with a declared server-handle contract); cold refresh gains a **host-private durable refresh-invocation record** (native tool, canonical argument bytes/locator, schema/catalog generation, classification revision — never public); batch admission becomes **two-phase** (an immutable batch-wide classification snapshot before any task, catching stale UI/non-UI metadata); and **export joins the governed-operation matrices** with an incarnation-bound permit, a final pre-release recheck, and **durable-before-release audit** (fail closed on audit failure). P1s: the `run()` wording is reconciled (pre-spawn rejection applies to same-session *reentry* only), production binding **rejects** fence-less backends (reduced durability is dev-only with its own acceptance matrix), the modern logical-binding claim is 7(f)-qualified everywhere including the success sentence, and id-order precedence is scoped to state deltas only. This remains a **draft for google/adk-python maintainers**; ratification items are in open question 7.
>
> *(Prior revision note, retained for context:)* **Revision 4.17** — resolving the six P0 blockers and three P1 tightenings from the [Revision 4.16 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5276961136). Changes: the **registry owns a dedicated long-lived event loop for app-safe generations** (every listing/call/read/bootstrap/close marshals onto it, so a synchronous `run()`'s short-lived worker loop can no longer destroy the connection its View needs); app-safe generations are keyed by a **host-derived opaque security-domain key** (no legacy/stateful generation is ever shared across resolved principals); Session creation gains a **true write fence** (`create_if_fence_current` — a co-located fence row updated in the same transaction as the Session row, so a retired creator's late commit *fails*, not merely gets detected); mixed-batch rejection gains a **complete model-visible response contract** (one persisted Event with a terminal error FunctionResponse per original call id); cold **refresh is separated from rerun** (one-click refresh only for profile-classified replay-safe tools; otherwise an explicit "Run tool again" flow with confirmation and separate attribution); and cold non-View access moves to a **dedicated purpose-bound export resolver** (the View-bound resolver is unchanged). Stale 4.15 batch language is purged; registry-vs-Runner cleanup ownership is defined (non-owning proxies, reference release on `Runner.close()`); and the modern logical-binding reading of the same-connection rule is explicitly ratification-gated. This remains a **draft for google/adk-python maintainers**; ratification items are in open question 7. Earlier context from the Revision 4.16 note remains accurate below.
>
> *(Prior revision note, retained for context:)* **Revision 4.16** — resolving the six P0 blockers and four P1 tightenings from the [Revision 4.15 review](https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/406#issuecomment-5276791815). Changes: connection affinity gains an **era-and-lifecycle matrix** (legacy generations are concrete client/session/process bindings; **modern stateless HTTP generations are host-side logical bindings — the sessionless 2026-07-28 core makes backend-instance affinity impossible by design**; cold replay after restart/eviction is defined as **explicit refresh/re-execution, not replay**, with sealed records remaining resolvable for authorized non-View consumption); the model's tool declaration is **pinned to the app-safe execution generation via a connection-bound tool handle** with pre-call re-listing; Session creation gains a **durable creation fence/receipt** persisted in the same transaction as the Session row (plus a distinct adoption receipt for migration); **mixed UI/non-UI parallel batches are rejected before fan-out** (replacing the backend-dependent non-UI narrowing — ledgering-all and batch-receipt designs are ratification 7(h)); interactive app-call results of UI-associated tools get an **Event-level sealed-result map** (`sealedResultRefs`, keyed by function-call id) alongside the per-widget reference; and readiness rebind becomes **monotonic** — the old proxy drives `REVOKING → CLOSED` and the registry atomically installs a newly constructed proxy under a new binding generation. This RFC remains a **draft for google/adk-python maintainers**; ratification items are in open question 7. The body is fully self-contained.

## Summary

ADK Python agents can *detect* an MCP App tool — `McpTool` parses `_meta.ui.resourceUri` / `_meta.ui.visibility` and emits a `UiWidget` event action correlated to its FunctionResponse — but cannot *host* one under the stable MCP Apps contract ([SEP-1865, `2026-01-26`](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). ADK advertises the `io.modelcontextprotocol/ui` extension on neither protocol era, enforces no visibility or catalog policy, cannot complete a modern-core multi-round request, persists a widget payload that is not a self-contained render reference, feeds View-only result fields into model context, has no governed, coordinated, or audience-isolated path for app-originated calls or reads, and its stock API server has no trusted identity or authorization source.

This RFC proposes a narrowly scoped Python host seam — the **stable Apps subset required by BQAA** — sufficient for the `agent_events` dashboard (#396) to render and stay interactive through ADK. Four pillars: **registry-owned capability binding** with accurate dual-era advertisement, a defined host profile, readiness-gated binding with revocable readiness, and a complete proxy lifecycle; **unconditional visibility and catalog composition policy** with connection-generation admission; a **minimal persisted descriptor** carrying an exact sealed-result reference schema, resolved through credential-aware connection-affine binding, a profile-conditional confidential sidecar with an honest recovery contract, and a single logical render read; and **governed app-originated tool calls and resource reads** under a loop-neutral coordinator — MRTR-aware, admission-gated at request, response, and post-queue execution, audience-isolated with batch-scoped effect authority, mutation-safe across every callback surface, reentry-safe, incarnation-safe from creation through deletion, and honest about effects, durability, recovery, and telemetry delivery.

**Behavioral changes for existing deployments are exactly three, all intentional:** unconditional visibility enforcement; the unconditional UI-result **projection and batch authority** (model-facing projection everywhere; under a bound profile, **mixed UI/non-UI parallel batches are rejected before fan-out with a typed error prompting sequential calls**; all-non-UI batches and non-UI standalone calls are byte-identical, while profile-bound UI calls intentionally gain descriptors, references, projection, and sealing — pipeline-defined, not byte-identical); and additive provenance stamping. Everything else — including the `SealedResultStore` and `SessionLifecycleStore` — is inert without a bound profile.

**Target reviewed:** `google/adk-python` `main` pinned at `a3088489`, verified through **`18903cad`** (2026-08-13; no pin drift at review time). Apps wire types verified against the released SDK (`@modelcontextprotocol/ext-apps` v1.7.5, spec commit `92f46a57`); MCP core `2026-07-28@b25c0874`; MCP Python SDK v2.0.0. Interacting ADK commits cited inline (`beb66ee1` credential redaction, `9cd59753` tools/list caching, `04b8b727` BQAA delivery/termination observability, `a61d8ecf` config-stdio rejection, reserved-MCP-tool-name rejection). Downstream consumer: #396 (three-tool fixture + interactive-read amendment v6), supplemented by a connection-stateful conformance fixture (below).

## Protocol baseline

Stable MCP Apps `2026-01-26` **combined with** MCP core `2026-07-28` through MCP Python SDK v2. The core revision [retired `initialize`/`initialized` and `Mcp-Session-Id`](https://blog.modelcontextprotocol.io/posts/2026-07-28/); modern requests carry capabilities in per-request `_meta`; legacy connections still use `initialize`. [SDK **v2.0.0** (stable GA)](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v2.0.0) abstracts both eras. Advertisement **must carry the settings map — `mimeTypes` is REQUIRED**:

```python
advertise(
"io.modelcontextprotocol/ui",
{"mimeTypes": ["text/html;profile=mcp-app"]},
)
```

The complete settings map participates in the profile fingerprint and both-era negotiation tests. ADK pins `mcp>=1.24,<2`, so **SDK v2.0.0 migration is the stated prerequisite** (delivery step 0), with three step-0 traps: **alias serialization** (ADK dumps without `by_alias=True`; its telemetry detector keys on `isError` — dump and detector change together); **MRTR** (modern core permits `InputRequiredResult` on any `tools/call` or `resources/read`; ADK's low-level call site rejects intermediates without `allow_input_required=True`); and **construction-time capability derivation** (legacy `initialize` capabilities are derived from the client's constructor callback table — sampling/elicitation/roots are advertised iff their callbacks are configured, and an initialized connection cannot retract its advertisement in place).

**The stable visibility contract is connection-scoped:** an `"app"` tool is "callable by the app **from the same server connection only**", and cross-server calls are always blocked for app-only tools. Any pooling design must preserve exact connection affinity between the View and its app calls (§6).

Servers **SHOULD** check client capabilities. Stated precisely: *ADK is not interoperable with capability-gated servers and cannot receive capability-adapted behavior.*

## What ADK already has (verified at the pins)

- `McpTool.mcp_app_resource_uri` / `McpTool.visibility` — `_meta.ui` parsing, nothing consuming visibility. **Result correlation exists** (widget + FunctionResponse share one persisted event). ADK now **rejects framework-reserved MCP tool names** (`adk_request_credential`, `adk_request_confirmation`, `adk_request_input`, `transfer_to_agent`) at registration and skips them in model toolset construction.
- **`McpTool` calls `session.call_tool` directly and dumps the entire result** into the persisted, model-visible FunctionResponse; it evaluates callable `require_confirmation` inside the tool with the live mutable `ToolContext` injected. Spec best practices: `content` for model; `structuredContent`/`_meta` not for model; `is_error` "so the LLM can see and self-correct"; `result_type` is a real discriminator — a terminal tool result must remain `complete`.
- **ADK executes parallel function calls concurrently** — one task and one `ToolContext` per call — waits for all, and **merges all FunctionResponses and UI widgets into one Event** before Runner invokes the event callback. **Each state write mutates the live value immediately as well as the per-context delta.**
- **Declared Event fields are publicly serialized** (Session API returns the complete `Session`; SSE/WebSocket `model_dump_json` whole events).
- **`after_tool_callback` is a documented result-replacement seam**; **`on_event_callback` is a documented event-modification seam** whose ABI passes a full `InvocationContext` exposing live session/service handles; the Runner merge accepts wholesale replacement of any non-identity field; the plugin chain early-exits and raises on first exception; some Runner appends bypass the plugin merge; `rewind_async` creates no `InvocationContext`; the database session service persists before delegating — **and commits Session creation in its own transaction**.
- **`ToolContext` is the full mutable `Context`**; `ReadonlyContext` is not deep-read-only; Session state is `dict[str, Any]`.
- **MCP-owned callback surfaces reach live context today** (progress factory → state-mutable `CallbackContext`; `header_provider` → shallow `ReadonlyContext`; callable `tool_filter` likewise); **SDK v2 owns three input-request callbacks (sampling, elicitation, `list_roots_callback`)** dispatched by both eras, plus `logging_callback` and a general message handler — **all fixed at `ClientSession` construction, with legacy capability advertisement derived from them**. **ADK creates one callback-configured session manager per toolset and pools sessions by effective headers**; **a stdio client launches a subprocess per managed session** — two audience managers would launch two server processes.
- **First-party Session HTTP APIs mutate directly** (delete → `session_service.delete_session`; patch and create-with-events append directly). **ADK permits client-supplied Session ids, allows an id again after deletion, and `Session` has no generation field.**
- **The released Apps capability surface**: `McpUiHostCapabilities` includes `serverTools`, `serverResources`, `message` and `updateModelContext` (modality granularity), plus `openLinks`, `downloadFile`, `logging`, `sandbox`, and View-initiated `ui/notifications/request-teardown`. **The released `ui/message` request schema is strict** (`additionalProperties: false`; `role` + `content` only).
- **View-originated `resources/read` is in the standard subset**; modern reads return a `contents` array whose items each carry their own URI. **ADK sessions are scoped by `(app_name, user_id, session_id)`.**
- **Raw-event consumers beyond the content builder:** both compaction modes, the summarizer, `Runner._find_agent_to_run`, memory ingestion. `Event` is mutable with `extra='ignore'`. `_find_active_task_scope` treats the newest unclosed non-empty `isolation_scope` as the active paused task. MCP request ids are correlation ids; independent Views commonly both start at id `1`. Public Runner session mutators: `run_async`; `run_debug`; `run()` (worker thread, own loop); `run_live`; `rewind_async`.
- **BQAA's `on_event_callback` records `STATE_DELTA` from persisted actions**; before-tool telemetry predates any Event; ADK has no audit-observer callback today. State and artifact effects apply immediately; rewind excludes `app:`/`user:` state and user artifacts. `EventActions` spans fourteen fields. `McpToolset.__init__` constructs its `MCPSessionManager`. `tool_name_prefix` exists. Runner cleanup walks `agent.tools`. `from_config` rejects config-supplied stdio servers by default. The stock API server documents itself as unauthenticated. Credential redaction must be preserved. adk-python#3190 was closed on the basis that `UiWidget` emission already provides support.

## Why the current surface is not enough

1. **No capability advertisement on either era, no MRTR handling, and no legacy-implementable per-audience callback policy** that also preserves the same-connection rule.
2. **No visibility or catalog enforcement** — and no connection-generation admission for app calls.
3. **No safe result contract** — View-only leakage; no survival of replacement, mutation, structural corruption, parallel batching, or restart; no declared reference schema for multiple sealed results.
4. **No governed app-originated operation path** — no readiness-gated (and readiness-revocable) construction seam; no coordination covering all mutators including first-party Session HTTP mutation; no crash-safe incarnation lifecycle from creation through deletion; no batch-scoped effect authority; no non-recursive fallback; no honest recovery/telemetry; no admission gate on replay; no response-side policy; no key/storage lifecycle.
5. **Resource reads and semantics break on compliant servers** — unlisted UI resources; replacement-state context; multi-round reads; multi-content responses.
6. **No trusted identity or authorization source** in the stock API server — and nothing stops or retracts Apps advertisement when readiness is absent or withdrawn.

Related: #3190 (closed), google/adk-python#3449 (tracking), `adk-web` renderer work, #396 (consumer).

## Proposed contract (names directional)

### 1. Registry-owned capability binding + the host profile + revocable readiness + complete proxy lifecycle

**`McpAppsHostProfile` — the named, immutable, load-bearing contract:**

| Field group | Contents |
|---|---|
| Extension settings | The complete advertisement map (`mimeTypes`, …) and the four `HostCapabilities` with exact modalities |
| Renderer registration | The attached conforming renderer identity |
| Identity & authorization | `PrincipalResolver` and `SessionAuthorizer` references — binding prerequisites |
| Storage & keys | `SealedResultStore` reference; **`SessionLifecycleStore` reference**; **`ExportAuditStore` reference**; binding-revision key-provider reference |
| Replay classification | The **replay-safe/read-only classification authority (source and revision)** consumed by the cold refresh-vs-rerun split and recorded in every `RefreshInvocationRecord` |
| Callback & connection policy | The §4 MCP-callback matrix selections and the **connection-affinity policy** (§6) |
| Resource policy | Policy entries (exact / prefix-inclusive) + per-scheme matchers, with a policy revision |
| Limits | Sealing size/depth, register caps, MRTR round/time caps, discovery bounds, delivery limits — with a limits revision |
| Fingerprint subset | Extension settings + modalities + callback/connection policy + policy revision + limits revision + **sealed-store namespace/revision + lifecycle-store namespace/revision** — baked into `binding_revision`, client construction, pool keys, discovery, and the tools/list cache key. **Ephemeral generations (binding/readiness generation, connection generation) are deliberately *not* in the persisted digest — they are checked independently at every barrier** so descriptors survive reconnects and provider replacement while stale Views, leases, and cache entries cannot (P1.2). |

**Readiness-gated, readiness-revocable binding — with a monotonic rebind (P0.6).** "Successfully bound" requires the complete host service — renderer, `PrincipalResolver`, `SessionAuthorizer`, `SealedResultStore`, `SessionLifecycleStore`, **`ExportAuditStore`**, key provider, and the replay-classification authority — at registry materialization; the store namespaces/revisions and the classification revision participate in the fingerprint and persistence-compatibility validation. Missing providers leave the toolset `MODEL_ONLY`; the Apps client extension is never advertised. The local-development adapter installs explicit loopback-only providers. **Withdrawal:** the proxy lifecycle's `REVOKING` transition is **one-way to `CLOSED`**. On withdrawal: (1) bump the **binding generation** and revoke Views, leases, queued permits, and pending result commits; (2) drain or cancel in-flight work; (3) **close every Apps-advertising client pool** — an initialized MCP connection cannot retract its advertisement in place, so the connections themselves must go; (4) drive the old proxy `REVOKING → CLOSED`. **Rebind is never a same-object transition:** the registry **atomically installs a newly constructed proxy under a new binding generation** — the at-most-one-construction invariant holds per proxy object, and construction across a rebind is generation-scoped at the registry. `MODEL_ONLY` remains terminal for its proxy. Model-only traffic continues uninterrupted through the replacement; old Views and cached declarations cannot reach the new proxy (generation checks at every barrier). Conformance: successful and failed rebind; rebind racing `get_tools`/call/close; model-only traffic during replacement; provider replacement while old clients drain; registry lookup before/after the swap; old-View/old-declaration isolation from the new proxy. Conformance: missing resolver/authorizer; renderer detached with an idle reused connection; loss during initial UI tool execution; loss after seal but before Event append; loss during bootstrap; reconnect after loss; provider replacement while old leases exist; loopback configuration; no Apps advertisement or `HostCapabilities` from an unready host — **including on live pooled connections after revocation**.

- **`McpToolsetSpec`** — lossless, connection-only (connection params, auth references, tool filter, name prefix, cache TTL, confirmation, resource, progress, sampling, elicitation, roots, dynamic-header configuration). Config-loaded specs apply the stdio rejection gate; code-constructed specs are trusted-code-only.
- **`HostBoundMcpToolset(BaseToolset)`** — lifecycle `UNMATERIALIZED → (MATERIALIZING) → PROFILE_BOUND | MODEL_ONLY → (REVOKING) → CLOSED`, latch-linearized, **at most one** construction; close-before-materialize → zero constructions; materialize-then-close → close waits, closes exactly once; close idempotent; post-`CLOSED` use typed-error; late binding after `MODEL_ONLY` rejected. Filter/prefix applied by the runtime toolset only. Race conformance asserts the losing operation, construction count, close count, post-close use.
- **`McpAppBindingRegistry`** — from configuration at startup: `stable toolset_id → spec → live toolset(s) → profile → server`. Colliding registrations rejected; missing binding → typed `McpAppBindingUnavailable`; restart rebuilds from configuration. Direct construction stays permanently model-only; second-profile bindings rejected; overrides only narrow; registry shutdown closes what it materialized. No path mutates a toolset after its client exists. **Apps-enabled routing is decided at App/Runner/host configuration** — never by inspecting a not-yet-created session. **Cleanup ownership:** the proxy handed to an Agent is **non-owning**, and release is governed solely by the **Runner-scoped registry lease tokens of §6** — `Runner.close()` releases its own token directly from its side table; the argument-free shared `proxy.close()` releases nothing and never infers caller identity; the registry closes a generation at last lease release or registry shutdown and owns token-idempotent retries of timed-out releases.

### 2. Unconditional visibility + catalog composition + URI canonicalization

Visibility normalized centrally: absent → `["model", "app"]`; **malformed → deny-all** (both catalogs, logged). Enforcement regardless of renderer capability:

- **model catalog** = developer tool selection ∩ `"model"` visibility — ADK-prefixed names; framework-reserved MCP tool names rejected/skipped;
- **app catalog** = app-call policy ∩ `"app"` visibility ∩ **originating server connection generation** — native names; default: the originating connection's app-visible tools, narrowable to deny-all; framework-reserved names excluded here too; **admission is connection-generation-scoped (P0.1)**: the stable configured server identity is retained for recovery, but an app call is admitted only against the exact live connection generation bound to its View;
- **resource-read policy** = deny by default except the descriptor's declared widget URI, unless the profile expands it (exact-match or segment-bounded prefix-inclusive).

**One URI canonicalization contract everywhere** — scheme-aware (lowercase scheme/host with IDNA, default-port removal, unreserved-only percent-decoding, dot-segment rejection, fragment stripped, order-sensitive query), used identically by policy matching, returned-item validation, operation identity, descriptor matching, and bootstrap comparison; pluggable per scheme. Adversarial conformance: dot-segment, encoded-slash, authority-swap, boundary-splice, case/port/IDNA; reserved-name cases in both catalogs, with and without prefixes.

**Authorization freshness with bounded discovery:** fully paginated fresh `tools/list` at admission — **on the View's bound connection** — bounded by pages/items/bytes/time with repeated-cursor detection; violations deny without consulting the partial catalog; listing failure denies; the TTL cache serves model catalogs only. Cross-server calls raise `McpAppVisibilityError`. The legacy opt-out exists only in Apps-disabled compatibility mode, documented non-conformant.

### 3. Descriptor, sidecar, lifecycle store, keys, recovery, and bootstrap

**Persisted descriptor** (stable references and one opaque locator):

```python
McpAppWidgetDescriptor(
schema_version=1,
resource_uri="ui://bqaa/dashboard.html",
correlation={"invocation_id": ..., "function_call_id": ...,
"agent_path": ..., "branch": ...},
server_id=..., toolset_id=..., # stable, configured, opaque
binding_revision=..., # versioned keyed digest + key id
sealed_result_ref=None, # optional {recordId, schemaVersion} — profile-bound only
)
```

**Exact sealed-result reference schema — two placements (P0.4 + interactive results).** For **render-producing** calls, `sealed_result_ref` (wire alias `sealedResultRef`) lives **on each profile-bound widget descriptor**, cardinality **0..1 per widget**; the widget's immutable function-call correlation supplies the deterministic reference→FunctionResponse map, including after restart. For **interactive, non-rendering** results — a View-originated call to a UI-associated tool whose nested widget emission is suppressed (a normal path: the stable spec expressly permits app-visible UI-associated follow-up tools) — the audit Event carries an **Event-level map**: `sealed_result_refs` (wire alias `sealedResultRefs`), keyed by immutable function-call id, one entry per sealed non-rendering result. A new descriptor + widget-level reference is emitted only when a nested View is independently negotiated. Rules for both placements: profile-less events omit them; two widgets sharing one resource URI each get their **own** record and reference; at append validation, a reference without a matching widget/FunctionResponse — or, under a bound profile with successful sealing, a UI result without its reference in the correct placement — fails the Event; duplicates rejected. **An opaque locator is not authority and not result bytes** — the grant and the content digest stay private; public serialization is safe by construction; the descriptor invariant restated: *no result bytes, no security metadata, no authority — an opaque locator is none of these*. Legacy descriptors carry no reference; the resolver returns a typed legacy error → read-only render; missing/corrupt references likewise. Conformance: one and several references in each placement; a suppressed-nested interactive call; the same call with an independently negotiated nested View; repeated calls from one View; reordered FunctionResponse parts; duplicate/missing ids; two widgets sharing one URI; legacy descriptor; profile-less Event; public Session/SSE serialization; restart mapping; deletion/teardown before resolution.

**`binding_revision` key lifecycle:** produced under the profile's key provider; envelope carries key id + digest version; the active key survives restart and is shared by sticky-compatible workers; rotation is a bounded verify-old/sign-new window (or explicit descriptor invalidation); key loss fails closed. Conformance: same-key restart, missing-key restart, rotation, retired-key, two-worker consistency.

**"Read-only render", defined:** a **host-owned error placeholder** — no server access, no sealed-result release, no View grant.

**The `SealedResultStore` sidecar (profile-conditional).** Key scope `(app_name, user_id, session_id, incarnation, event_id, function_call_id, record_id)`; public reference `{record_id, schema_version}` with `record_id` random and opaque (≥128 bits); content digest store-private, verified at resolve. **Threat boundary (declared):** trusted backend — confidentiality is application-surface isolation; at-rest encryption delegated; corruption detected by the store-private digest; hostile-store authenticated encryption out of scope. **Resolver rule:** the gated resolver loads the authoritative persisted Event and verifies reference, session scope, incarnation, function call, widget, and View binding before releasing bytes. **Operation-instance identity:** every admitted outer request mints a unique operation-instance id, distinct from the replay key; internal retries reuse it; identical no-key calls never collide. **State machine:** immutable-create keyed by operation instance; same-instance same-bytes retry succeeds; same-instance different-bytes → typed conflict; GC touches only expired, unreferenced pending records after checking the authoritative Event and active-operation registry; append timeout with late success → event + seal stand as durable history, cached outcome remains the timeout error, no re-execution within the retained boundary. **Honest crash recovery:** v1 keeps in-memory idempotency — after a crash the Event and seal remain mutually resolvable, but a client retry may re-execute (a store row is not an idempotency record; the durable alternative is ratification 7(d)). **Persistence compatibility:** binding validates durable immutable create, tenant-scoped lookup, digest verification, retention/deletion, and a stable namespace/revision (an empty swapped sidecar cannot silently issue valid-looking descriptors); restart guarantees are qualified to durable backend pairs. Conformance: tenant/incarnation scoping; reference swapping; crash after store-write; crash after append; retry before/after GC; same-instance/different-bytes conflict; append-timeout-late-commit; restart with/without replay key; deletion cascade; digest-equality on public serialization; namespace-mismatch rejection; missing/corrupt typed and read-only.

**The `SessionLifecycleStore` — crash-safe incarnations from creation through deletion (P0.2).** A deployment-supplied durable store (a binding/readiness prerequisite; it may share a backend with the sealed-result store under a **distinct stable namespace/revision**), keyed by `(app_name, user_id, session_id)`, holding the **monotonic incarnation number**, lifecycle state, and cascade progress, with durable compare-and-set:

- **States:** `ABSENT → CREATING(n) → ACTIVE(n) → DELETING(n) → DELETED(n) → CREATING(n+1) → …`
- **Creation, with a durable fence (P0.3):** a unique creation-operation id and the expected prior state (`ABSENT` or `DELETED(n)`) accompany the durable CAS to `CREATING(n)`. The Session row is then created **together with an internal creation receipt — operation id, incarnation, and finalized-import digest/revision — persisted in the same transaction as the Session row** (the session-service creation-receipt contract; the database service's independent creation transaction is exactly why the fence must ride inside it). Imported events are finalized (protected fields stripped/stamped) before the receipt digest is computed. **The CAS to `ACTIVE(n)` compares the receipt** — a Session row without the matching receipt is never adopted by recovery, so a row created by a different or late operation cannot be activated under the wrong incarnation. **`CREATING` denies app admission; no operation observes `ACTIVE` until the receipted Session row and its finalized imported events are durable.**
- **Crash recovery, with a true write fence (P0.3):** a receipt alone *detects* a stale creator; it does not *prevent* the stale commit (creator A stalls in `CREATING`, reclaim retires A, creator B reserves — A's independent Session transaction could still land, and a pre-commit read of the other store is a TOCTOU). The contract therefore requires a **write fence at the Session store**: a **co-located fence row** in the Session backend, updated through a Session-service **`create_if_fence_current`** primitive — the Session row, its creation receipt, and the fence check commit **in one transaction** against the fence row's expected value. Reclaim advances the fence row (retiring A) *before* B reserves, so **A's late commit fails inside its own transaction** — it does not merely get detected later. Crash after a fenced commit but before `ACTIVE` → recovery verifies the receipt (operation id, incarnation, import digest — count alone is insufficient) and completes the transition; abandoned-`CREATING` recovery is idempotent. **A production profile binding rejects a Session backend that cannot host the transactional fence.** The reduced-durability mode (e.g., in-memory backends, orphan-row quarantine: mismatched rows quarantined and deleted, B retries) exists **only for the explicit local-development configuration**, carries its own weaker acceptance matrix, and none of this document's crash-safety or cannot-commit claims apply to it. **Migration uses a distinct adoption receipt** — an existing row is adopted as `ACTIVE(1)` with an adoption marker, never disguised as a fresh creation. Conformance: A-stalls/reclaim/B-reserves/A-commits (fails in-transaction), then the reverse commit order; crash during orphan cleanup; imported-event rows partially committed under a retired receipt.
- **Recreation:** `DELETED(n) → CREATING(n+1)`; old incarnations' leases, permits, tokens, sidecar records, and resolver requests always fail typed (chosen over permanent id unavailability — ratification 7(g)).
- **Migration:** a preexisting Session with no lifecycle row is migrated lazily on first app-enabled touch — a verified CAS creates `ACTIVE(1)`.
- **Deletion:** durable CAS to `DELETING` (new admission fails) → phase-1 revoke (Views, leases, permits, queued operations) → acquire coordination → delete/mark the Session row → cascade (sidecar, registers, idempotency, host state) → durable mark complete; **partial cascade retries idempotently after restart**, driven by the lifecycle record.

Conformance: crash before/after lifecycle reservation; before/after Session commit; before/after imported-event finalization; concurrent duplicate create; recreate racing deletion completion; abandoned-`CREATING` recovery; migration of an existing Session; crash at every deletion phase; delete-then-recreate; late remote result and late append from an old incarnation; old sealed reference vs the new incarnation; create racing `DELETING`/`DELETED`.

**Canonicalization:** **ADK Canonical JSON v1** — RFC 8785 with the I-JSON number domain (|int| > 2^53−1 rejected per purpose; negative zero normalized), byte size on final canonical UTF-8, depth post-serialization, defaults 8 MiB / depth 64 (profile-configurable), non-finite rejected; purpose-tagged envelopes (`sealed-result`, `confirmation-arguments`, `binding-revision`, `replay-identity`); the binding-revision input is a declared serializable identity subset; cross-language golden vectors. Over-limit results fail through the tool-error path, only under a bound profile.

**Credential-aware, connection-affine resolution:** every operation derives its MCP session through the same context-derived header and credential-keyed pooling path as model calls — **within the connection policy of §6** — never a singleton privileged client.

**Render bootstrap — explicit order:** (1) authorize principal/session/widget/channel via `SessionAuthorizer`; (2) reserve the View generation server-side, **bound to the originating connection generation**; (3) resolve the persisted Event and seal into host-private memory (pre-lease resolver rule — bootstrap's named exception; interactive replay uses the post-lease rule); (4) perform the one logical `resources/read` on the bound connection (terminal; state-only MRTR rounds in v1); (5) exact-one content selection (canonical-URI equality with the descriptor; Apps MIME; zero/multiple → typed error) and encoding/size validation; (6) activate and return the host-held grant only on success; revoke the reservation on every failure. The `RenderDocument` carries the exact HTML + matching `_meta.ui` from the same response, resolved input/full result, scoped grant. ADK never synthesizes or widens `csp`/`permissions`. Grant secrecy: never in HTML, `hostContext`, or iframe-visible data.

**Compatibility matrix:**

| Situation | Behavior |
|---|---|
| No Apps profile bound | Legacy `{resource_uri, tool, tool_args}` payload emitted unchanged; no sidecar or lifecycle-store dependency; no sealing-limit failures — byte-identical except the three declared exceptions |
| Apps profile bound | Versioned `McpAppWidgetDescriptor` with `sealedResultRef` (+ Event-level `sealedResultRefs` for interactive results); sidecar sealing active; **mixed UI/non-UI parallel batches rejected before fan-out (§4/§5)** |
| Replaying old persisted events | New renderer dual-reads the legacy form (window: open question 3) |
| Old consumers reading new events | Unaffected unless a profile is knowingly bound |

### 4. Audience, protected fields, effects, callbacks, and the event pipeline

**Durable discriminator.** `Event.audience: Optional[Literal["app_audit"]] = None` — declared, persisted, protected, default-`None`, distinct from `invocation_origin`. Audit events carry no `isolation_scope` (originating scope preserved as provenance metadata), keep agent path and branch, and remain fully persisted, exported, telemetry-visible.

**Central audience predicate.** `app_audit` events are non-model-context **and** non-control-plane across content construction, both compaction modes, agent routing/rehydration, and model-feeding memory ingestion. **Accepted `ui/update-model-context` is the only direct conversational-context injection path from app state.**

**Exhaustive `EventActions` classification (audit events):** `state_delta`/`artifact_delta` allowed under the batch ledger; `transfer_to_agent`/`escalate`/`route`/`agent_state`/`end_of_agent`/`rewind_before_invocation_id` stripped with typed policy error; `compaction`/`set_model_response` core-owned, stripped/rejected; `skip_summarization` normalized away; `render_ui_widgets` under the nested-widget rule; `requested_auth_configs`/`requested_tool_confirmations` never persisted on audit events.

**Applied-effect model.**

1. **Sealed one-shot confirmation permit** (declared app-path compatibility difference): evaluated exactly once against **`ReadonlyConfirmationContext`** (deep-independent snapshot; no live Session/InvocationContext/credentials/actions/artifact service); `ToolContext`-requiring predicates fail closed; the permit binds tool identity, server identity, binding revision, and the canonical arguments digest; sealed arguments execute; callbacks receive deep-independent argument copies; no re-evaluation in `McpTool`. Conformance: call-counting and toggling predicates; all four mutation classes; post-approval argument mutation.
2. **`EventBatchEffects` — the batch-scoped effect authority (P0.3).** ADK fans out one task and one `ToolContext` per parallel call, then merges one Event, so effect authority is **batch-scoped, created before fan-out**:
- one `EventBatchEffects` record per merged Event, containing a **child ledger per bound function-call id** plus an **event-policy ledger**;
- **every UI-associated call in the batch is bound to a child ledger** — its restricted context's delta-aware operations atomically record-and-apply into that child;
- the batch stays **OPEN** until all tool callbacks and the single event-policy callback finish, then **freezes once**; merged deltas, the audit Event, reconciliation, fallback, and telemetry derive only from the frozen batch snapshot;
- **deterministic conflict semantics — id-order precedence (P1.2):** when ledgered siblings write the same state key, the winner at freeze is chosen by **deterministic function-call-id order (lexicographic byte-order comparator)** — not wall-clock commit order; each child's writes are preserved in the ledger for audit. For artifact collisions, **freeze records which already-applied write won and does not rewrite bytes** — the audit order and the stored artifact contents agree by construction because the recorded winner is the applied one;
- **mixed batches are rejected before fan-out, with a complete response contract (P0.4):** under a bound profile, a parallel batch mixing UI-associated and non-UI calls is rejected **before any task starts** — no tool, callback, confirmation, widget, ledger, or remote work begins. The 4.15 narrowing (unledgered non-UI siblings surviving a dropped Event) created backend-dependent phantom state; rejection eliminates it without restricting non-UI model tools elsewhere. **The rejection is one persisted, model-visible Event containing exactly one terminal error `FunctionResponse` for every call in the rejected batch** — each original function-call id preserved (ADK populates missing ids per its existing conventions), deterministic order (the model's emission order) — so every provider-side correlation is satisfied and the Runner **automatically continues to the model through the standard function-response flow**, enabling the promised sequential retry. **The rejection Event is core-owned and skips the ordinary event callback entirely (P0.1 of the 4.17 review):** a live-ABI callback could mutate services, remove or duplicate responses, or change call ids on an Event whose whole purpose is exact per-call correlation with zero effects — so no plugin or agent callback observes it before persistence; the finalizer stamps protected fields; the error responses project through compaction as ordinary function responses. (A restricted rejection-policy callback on a deep-independent copy is the named alternative.) All-UI and all-non-UI batches proceed. Full sibling ledgering and a durable batch receipt are the alternatives — ratification 7(h), an implementation gate. **Batch classification is a two-phase admission with class-neutral discovery (P0.6 of the 4.17 review; P0.2 of the 4.18 review):** "resolve on its candidate class" was circular — selecting the app-safe class requires the very UI classification fresh discovery is meant to establish, and the stable spec expressly permits capability-adapted tool variants that differ between client profiles. Discovery is therefore a **deterministic two-class algorithm**: (1) boundedly list the **app-safe profile first**; (2) list the fully-callbacked profile for the remaining/non-UI declarations; (3) apply the explicit same-name disagreement rule — **a UI/non-UI disagreement between classes fails closed (or forces a fresh model catalog/turn); a stale class is never silently reused**; (4) select and snapshot the exact execution handles into one **immutable batch-wide classification snapshot**; schemas, credentials, and generations validate against that snapshot; then the whole batch either rejects as mixed or fans out **against those exact handles**. The same algorithm runs at model-catalog construction and again at batch admission. The promise is stated precisely: **no tool execution, callback, confirmation, widget, ledger, or effect** — fresh listing is bounded remote discovery and is permitted. Conformance: two- and three-call batches; reordered names; missing ids populated; provider request validation on the following turn; compaction/replay; **an adversarial plugin attempting to edit the rejection Event (removal, duplication, id change, state mutation, direct append/delete, mutate-then-raise — all without effect)**; metadata changes in both directions between the model turn and execution; a tool present on only one class; tool removal; schema/resource-URI/visibility changes; same-name UI/non-UI disagreement failing closed; one listing failing or overflowing while the other succeeds; credential/generation change between the two listings; connection loss between snapshot and fan-out; proof that no tool or callback ran.
- Defined interactions: effect-then-replacement (effect stands; replacement merges per §5); effect-then-exception (effect stands; failure path runs on the frozen batch); artifact-save-then-merge-rejection (save stands; reconciled); cancellation mid-write (atomic: recorded-and-applied or neither); **write after freeze → typed error**.
- Conformance: two UI siblings writing the same key; **mixed-batch rejection (no task, no callback, no effect — asserted against in-memory and database services alike)**; artifact-name collision; one callback failing while another writes; cancellation during fan-out; event-policy effect after all child callbacks; reconciliation ordering after restart; the delta-map adversaries; out-of-API mutation failing closed.
3. **The event-policy seam is restricted (direct-app audit events):** the ordinary event callback receives a **restricted invocation-context facade** — identifiers, a read-only state snapshot, and the batch's event-policy ledger APIs — with no live `Session`, `SessionService`, `ArtifactService`, `CredentialService`, `Runner`, or mutable actions; unavailable access raises typed errors mapping to the callback's terminal outcome; the guarantee is bounded to supplied APIs. Normal events keep the existing ABI. Conformance: the eight facade cases (facade mutation ledgered; nested mutation; direct service append/delete; artifact save; credential access; same-session reentry; mutate-then-return; mutate-then-raise).
4. **Governed MCP-owned callback matrix (v1):**

| MCP callback | Model-originated calls | Direct app calls / reads / bootstrap | Context (app paths) | Mutation | Reentry |
|---|---|---|---|---|---|
| `tool_filter` (callable) | Current behavior | Runs at catalog composition | Deep-read-only snapshot | Fails closed | Rejected |
| `header_provider` | Current behavior | Runs at session derivation | Deep-read-only snapshot | Fails closed | Rejected |
| Progress callback / factory | Current behavior | Notification-only | Read-only | Fails closed | Rejected |
| Sampling / Elicitation / `list_roots_callback` | Per the **connection policy (§6)**: available only on connections that advertise them; **UI-associated calls run on app-safe connections where all three are absent** | **Disabled** — input-request-bearing rounds fail closed; capabilities never advertised on app-safe connections | — | — | — |
| SDK `logging_callback` / general message handler | Internal defaults | Internal defaults — not retained configuration surfaces in v1 | — | — | — |

Conformance: one mutation and one reentry test per enabled family; ListRoots MRTR negative; no-input-capability-advertisement assertions on app-safe connections.

**"Deep-read-only snapshot" — closed value contract.** Structural transcription over a defined domain — `str`, `bool`, `None`, `int`/`float` under the ADK Canonical JSON v1 numeric rules (I-JSON range; non-finite and unsupported numeric subclasses rejected), `list`, and `dict` with string keys only (coercion and post-coercion collisions rejected); tuples rejected; cycles rejected; profile size/depth limits; never `copy.deepcopy`. One shared golden property table across the filter, header, confirmation, observer, and event-policy facades. Credential needs met purpose-bound via the existing auth-config path.

5. **Uniform reconciliation + terminal-outcome matrix:**

| Terminal path | Wire response | Durable record | Cached outcome |
|---|---|---|---|
| Success | Terminal `CallToolResult` | Full audit event (frozen-batch deltas + `sealedResultRef`s when profile-bound) | Terminal result |
| Confirmation denied / timeout (sealed preflight — before callbacks; no effects by construction) | JSON-RPC error | None | Error |
| Remote/tool error | JSON-RPC error or `isError` result | Audit event (frozen-batch deltas + error) | Error/result |
| Cancellation / disconnect / teardown | No late response; `tool-cancelled` where channel survives | Reconciliation record | Cancellation |
| Tool-callback or MCP-callback failure | JSON-RPC error | Reconciliation record | Error |
| `on_event` failure in the audit pipeline | JSON-RPC error | Core-only fallback from the frozen batch | Error |
| Short-circuit (valid) | Converted `CallToolResult` | Audit event | Terminal result |
| Mixed allowed-delta + forbidden-control | Typed policy error | Audit event (deltas committed; controls stripped) | Error |

Conformance: before-tool state mutation and artifact save followed by cancellation, disconnect, remote error, after-tool failure, and event-pipeline failure (denial/timeout as no-callback/no-effect paths) — five-surface assertions.

**Scope-aware rewind (core rewind unchanged).** **Core event finalizer** at the pre-persistence seam of every `SessionService.append_event` on every backend, with per-path origin sources (turns/early exits → execution context; `ui/message` user event → permit; host-service ops → operation context; rewind/compaction → `system`; first-party Session create/import/patch → `system` with `emitter="session-api"`); core-selected `audience` enforced; protected fields snapshotted and reasserted on every exit. Stored-row conformance on every backend.

**Canonical audit append pipeline** — construct + pre-sanitize; ordinary plugin chain exactly once on the restricted facade + deep-independent copy; merge allowed changes; finalize (protected fields; frozen-batch restore; sanitizer re-run); append; observer phase. Pipeline/append failure → the **core-only fallback built from the frozen batch**, appended once, recursion-guarded (exactly one fallback per operation). **Honest durability:** conditional on successful session append; backend rejection after bounded retry → cached error + critical redacted log/metric (outbox/WAL out of scope). Conformance: permanently-throwing plugin (one fallback, no loop); backend-failure escalation.

**Audit observer phase** — `on_audit_event_callback(readonly_context, event)`: non-replacing; `ReadonlyAuditContext` excludes mutable state/actions and mutation access; deep-independent immutable snapshot input (never the `session.events` object; nested-mutation-after-append tests on both backend classes); after append, audit/reconciliation events only, serial registration order, per-observer failure isolation. **Single-phase routing:** BQAA's ordinary callback skips state-delta telemetry for `app_audit` events; the observer hook owns it. **Delivery is best-effort; the durable record is authoritative** — zero-or-one BQAA rows per audit event; acceptance asserts the durable session event directly; a backfill adapter is named future work. Conformance: crash-after-append/before-observer; observer exception; emit-then-crash; restart; the four chain orderings.

### 5. Result pipeline — one validated authority, event-batch-aware, sealed, confidential

For results of **UI-associated tools** (whatever the caller):

1. **Callback approval:** before/after/error callback returns are honored per documented replacement semantics; short-circuit conversion and validation apply (a complete `CallToolResult` needs meaningful nonempty `content`; ordinary values get a meaningful JSON text block with `outputSchema` adding validated `structuredContent`, never replacing `content`; validation failure → `on_tool_error_callback` chain → JSON-RPC error); accidental in-place mutation is discarded. Per UI-associated call: a **candidate result record keyed by the immutable function-call correlation**, held operation-private.
2. **Event-batch semantics:** mixed UI/non-UI parallel batches are **rejected before fan-out** (§4 — typed error; the model retries sequentially); for all-UI batches: per-call candidates; **one public Event projection** over all sibling responses; ordinary event policy invoked **exactly once** per Event (restricted facade on direct-app audit events); **every UI correlation validated independently** (exactly one matching FunctionResponse; removal, duplication, id/name/correlation changes, or a non-terminal discriminator rejects that candidate); each candidate's approved public fields (`content`, `isError`, `resultType`) merged with its hidden fields; each complete merged `CallToolResult` **parsed and re-validated against the negotiated era** (meaningful nonempty `content`, valid blocks, boolean `isError`, terminal `resultType`); **whole-Event atomicity** — any candidate's failure fails the append before any reference becomes resolvable, with sibling effects reconciled from the frozen batch; on success, **all** candidates sealed and their references attached per the §3 two-placement schema (widget-level `sealedResultRef` for render-producing calls; Event-level `sealedResultRefs` for suppressed interactive results), appended once, with deterministic post-restart mapping by function-call id.
3. **Model projection:** wire keys `_meta` and `structuredContent` removed; `content`, `isError`, `resultType` preserved; LLM requests and both compaction modes consume exactly this projection.

**Profile-conditional split and compatibility wording:** the model projection and callback-approved authority are unconditional; **under a bound profile, mixed UI/non-UI parallel batches are rejected before fan-out** (typed error; sequential retry); all-non-UI batches and non-UI standalone calls are byte-identical to current behavior, while profile-bound UI calls intentionally gain descriptors, references, projection, and sealing (pipeline-defined, not byte-identical); sidecar sealing, the reference schema, render replay, and sealing limits exist only under a bound profile — profile-less deployments emit the legacy widget form with no store dependency (both negative paths tested). View delivery, bootstrap, and **live** replay derive from the sealed records through the gated resolver; **cold access after restart follows the §6 matrix** — sealed records stay resolvable for authorized non-View consumption, and the user-visible affordance is explicit refresh/re-execution. The SDK v2 migration adopts the canonical dump and fixes the `isError` telemetry detector as one change.

Conformance: exact-key projection tests (v2 results and replayed v1 events, incl. the telemetry detector); the error-flag case; redacting/replacing/mutate-then-return/mutate-then-raise/short-circuit; returned-replacement and in-place `on_event` redaction on both origins; the nine single-result structural rejections; the eight batch cases; `on_event` failure before any resolvable record; model/View/seal/restart-replay/telemetry agreement.

### 6. Governed operations, connection affinity, coordination, sessions, and teardown

**Connection affinity (P0.1) — the same-connection rule, made era-coherent.** The stable contract scopes `"app"` tools to **the same server connection** that produced the View, and blocks cross-server calls outright. Split audience pools violate this (and for stdio would split the server across processes). The v1 policy (alternatives are ratification 7(f)):

- **UI-associated initial tool calls — including model-originated ones — route to an app-safe connection**: constructed with all three input-request callbacks omitted, so neither era ever advertises input capabilities the app path won't fulfill. **Declared consequence:** a model-originated call of a UI-associated tool cannot use sampling, elicitation, or roots (their MRTR rounds fail closed, exactly as on direct app calls). Non-UI model calls keep their fully-callbacked connections and current behavior.
- **Era-and-lifecycle matrix — what "connection generation" means:**

| Era / phase | Meaning of the bound generation |
|---|---|
| Legacy stdio / SSE / stateful HTTP | The **concrete client/session/process**. Transport-level affinity is real; the View dies when the connection dies. |
| Modern stateless HTTP (2026-07-28) | A **host-side logical binding** — the configured endpoint plus the credential/capability (app-safe) profile and security domain. The sessionless core makes backend-instance affinity **impossible by design** (any request may land on any backend instance); server-side application state must travel as **explicit server handles** (`requestState`, tool-returned handles), which ADK echoes opaquely. **Interpretation flag (P1.3):** reading the same-connection rule as satisfied at the logical-binding level is an *extension interpretation*, not stable-spec wording — it is **ratification-gated (7(f))** and should be confirmed with the MCP Apps/core maintainers upstream; the round-robin and explicit-handle tests prove the selected behavior, not the extension authors' intent. Modern same-connection conformance is not claimed settled. |
| Live replay | Interactive Views operate only while their bound generation is live. |
| **Cold replay (restart / eviction)** | **Defined as explicit refresh/re-execution, not replay — with rerun safety and a durable invocation record.** Cold activation needs exact inputs the public descriptor deliberately omits: therefore every render-producing call also writes a **`RefreshInvocationRecord`** — a **purpose-separated record inside the same opaque sidecar envelope reached by `sealedResultRef`** (one public locator; purpose separation lives inside the confidential envelope, so no second public field exists). The record binds server/toolset/native tool identity, canonical argument bytes (or a protected locator plus digest), schema/catalog generation, binding revision, the replay-classification **source and revision** (both now named in the host-profile table), initiating origin, and the immutable correlation — never exposed on public Event/Session/SSE surfaces; nested Views created by direct app calls get the same record. **Exact lifecycle:** immutable-create keyed by the operation instance and correlation, written **in the same envelope create as the sealed result — before Event append, before outer success** (extending the store-write→append→success chain); crash before the envelope create → the append fails per the write ordering (no Event advertises a cold refresh it cannot honor); crash after create/before append → the orphan-GC protocol collects it; later corruption or loss → the Event's cold affordance **fails closed to the placeholder-only state**; retention and deletion follow the incarnation cascade; the envelope namespace revision participates in persistence-compatibility validation. At activation the host reauthorizes and revalidates current tool existence, visibility, schema, arguments, classification (against the current classification-source revision), and confirmation against live state; missing or mismatched → placeholder. Conformance: crash before/after refresh-record create, seal create, and Event append; append timeout with late success; restart and GC; deletion/recreation; argument-locator swap; classification-source loss. Re-execution repeats the initial tool, and an arbitrary UI-associated tool may have side effects (a purchase, a deployment, a write) — explicit activation and fresh authorization do not disclose that the effect will repeat. Therefore: a **one-click refresh** affordance is permitted **only when host policy or profile-declared tool metadata classifies the initial call as replay-safe/read-only** and current arguments pass fresh validation; **otherwise** the cold state is the error placeholder with an explicit **"Run tool again"** action that names the tool, warns that effects may repeat, runs the normal confirmation policy, creates a **separately attributed** invocation, and never reuses the old idempotency outcome. Either path creates a new Event, descriptor, and View — never a resurrection of the old binding. The old sealed record remains accessible **only through the purpose-bound export resolver (§7)** — no interactive grant, template fetch, or View is minted from it (no HTML is ever persisted; persisted-template cold rendering is a named out-of-scope alternative). |

- **The descriptor's widget and the View generation bind to that exact (era-appropriate) generation.** App `tools/call`, `resources/read`, catalog listing, and bootstrap reads run on the bound generation; admission is generation-scoped (§2).
- **Reconnect or pool eviction invalidates the dependent View generations** — typed errors; re-render is the cold-replay path above. No silent migration. The stable configured server identity is retained for recovery and descriptor validation.
- **Connection ownership, the registry loop, and multi-View lifetime:** the **registry owns and pins** each app-safe generation, reference-counted by live Views, reservations, and in-flight operations; idle eviction, maximum lifetime, and credential-expiry policies come from the profile; one View's teardown releases only its reference (siblings unaffected); the last release makes the generation evictable; readiness revocation, credential rotation, and transport failure fan out invalidation to every dependent View. **The registry owns a dedicated long-lived event loop for app-safe generations:** anyio/MCP clients are loop-bound and ADK's session manager replaces a session touched from a different loop, while a synchronous `Runner.run()` executes on a short-lived worker loop that dies with the run — so **every app-safe listing, call, read, bootstrap, and close marshals onto the registry loop** via thread-safe futures, and the generation survives any Runner loop's death. `Runner.run()` therefore works normally for app-enabled sessions (its matrix row stands; the pre-spawn rejection elsewhere in this document applies **only to same-session reentry from within callbacks**); rejecting `run()` entirely is the named alternative (7(j)). **User code never executes on the registry thread (P0.2 of the 4.17 review):** the SDK schedules progress callbacks in the connection dispatcher's task group — under this design, the registry loop — while ADK's documented progress-factory contract hands out a live, state-mutating callback context. Therefore **transport I/O alone lives on the registry loop; model-path progress delivery is marshaled back to the owning operation/Runner loop before user code is invoked**, and the same rule governs tool filters, header providers, trace-context propagation, and cancellation — none may receive a live Runner context on the registry thread. **Progress-delivery gate (P0.1 of the 4.18 review):** the SDK spawns each progress handler as an independent task and resolves the response waiter without draining them — marshaling fixes thread affinity, not terminal ordering. Every operation therefore owns a progress gate: when the terminal response arrives, **intake closes atomically, and every already-admitted progress callback is drained — on the owning loop — before result approval, effect-ledger freeze, Event append, and Runner-loop release** (required by the existing state-mutating factory contract; narrowing progress to an immutable notification-only contract, which would permit cancel/drop instead, is the ratifiable alternative). Defined semantics: a failing callback is logged and never affects the result; cancellation during drain is bounded; the delivery queue is bounded with drop-newest-plus-log backpressure; context variables are captured at operation admission; teardown and registry shutdown drain boundedly, then cancel. Conformance: initial UI execution through `run()`; bootstrap while the worker is live; bootstrap after the generator finishes; an app call from the host loop; worker-loop shutdown — each asserting connection-generation survival; a progress factory using Runner-loop state and an `asyncio.Lock`; **a progress notification admitted before the terminal response whose handler deliberately blocks until after the response (drained before freeze/append)**; callback raise; cancellation during drain; late progress after the terminal result (rejected at the closed gate); `run()` and `run_async` variants; registry shutdown during delivery.
- **Runner-scoped registry leases (P0.3 of the 4.17 review):** `BaseToolset.close()` takes no caller argument and Runner cleanup deduplicates toolset objects in a set — an object-global close cannot distinguish one Runner's idempotent retry from another Runner's first release when two Runners share one Agent/proxy. Therefore **Runner binding acquires a Runner-scoped lease token from the registry, held in a Runner-owned side table; `Runner.close()` releases that token directly**; `proxy.close()` on a non-owning proxy releases nothing and never infers caller identity; the registry closes a generation at last lease release or registry shutdown, **owns retries of timed-out releases** (token-idempotent), and the closes-exactly-once invariant applies to the final registry-driven close. Conformance: the same Agent and proxy object in two Runners, both close orders; duplicate close on either Runner; timeout/cancellation with registry-side retry; one Runner closing while both have Views; final release; registry shutdown.
- **Security-domain keying of app-safe generations:** ADK's current pools key HTTP sessions by effective header values and stdio by a per-manager constant — a static service-account header would share one stateful connection across users. App-safe generations are therefore additionally keyed by a **host-derived opaque security-domain key** (an HMAC of the resolved principal identity under a host key — never raw principal data in pool keys or logs): **a legacy/stateful generation is never shared across resolved security principals.** **Within one principal, legacy/stateful generation keys additionally include the Session incarnation by default (P0.4 of the 4.17 review)** — two conversations of one principal must not alias implicit state inside one MCP process or HTTP session, and legacy servers have no modern handle rule to lean on. **Cross-Session sharing within a principal exists only as an explicit profile opt-in whose server-handle contract is declared in the profile, carried on the generation, and checked at admission.** (Per-principal-only keying is the ratifiable alternative — 7(k).) Conformance: two principals with identical HTTP headers, and the stdio constant-key path — each initial call writes distinct connection state; each View calls an app-only follow-up; neither observes the other's state; **two Sessions of one principal each setting a different connection nonce, with app-only follow-ups isolated by default**; two simultaneous Views; Session teardown/recreation; credential rotation; principal remapping; the explicit-share opt-in path with its handle contract.
- **Initial-UI-tool retry policy (P1.3):** ADK's automatic once-retry-with-fresh-session is **disabled for UI-producing initial calls** — the remote effect may have committed before the response was lost, and a silent retry would both risk duplication and cross generations. The failure surfaces to the model; any second attempt is a separately attributed new call; the View binds only to the generation that returned the accepted result.

Conformance: a stdio server minting a per-process nonce in the initial UI tool and requiring it in an app-only follow-up; the equivalent legacy stateful-HTTP session; **modern round-robin across backend instances behind one logical binding; an explicit-server-handle flow**; an app call after pool eviction/reconnect (typed invalidation, then refresh rebinds); two configured connections to the same server; restart-after-result and restart-before-bootstrap (cold-replay affordance asserted); two Views on one generation with single-View teardown and last-View release; credential rotation and transport-failure fan-out; the retry-disabled initial-call cases; the full #396 model-tool → View-tool path.

**Connection-bound tool handles (P0.2) — the model's declaration is pinned to the execution generation.** Current ADK lists tools and binds each `McpTool` to the same session manager that will execute it; 4.15's per-tool routing broke that unity. The contract restores it: the **model catalog for UI-associated tools is listed on the app-safe client class** — the declaration shown to the model comes from the class that will execute the call — while non-UI tools list on the fully-callbacked class; when the same native name differs between classes, the app-safe declaration wins for UI-associated tools and the divergence is logged. Every catalog entry is a **`BoundToolHandle`**: the exact raw declaration, catalog generation, callback-profile class, credential context, and execution connection class. **Before a UI-associated initial call, ADK re-lists on the target app-safe generation and fails closed** if name, input/output schema, visibility, or resource metadata no longer matches the declaration supplied to the model; connection loss forces a new model catalog/turn — never name-only dispatch; transport retries cannot cross generations. Conformance: capability-adapted metadata differing by client capabilities; the same name with different schemas/resource URIs on the two classes; removal between listing and call; credential change; a transport retry that would cross generations.

**`tools/call`.** One outer pending request; one terminal response while live (standard `CallToolResult` or JSON-RPC error). **Cancellation is not an error response** — no late response; `ui/notifications/tool-cancelled` where the channel survives. Sealed confirmation preflight; resume/cancel host-only; approved → final result; denied/timeout → JSON-RPC error; disconnect → cancellation with atomic first-terminal-wins.

**`resources/read`** (backs `serverResources`): same admission gate; request URI checked against §2 policy; byte/rate/time limits; one outer read across MRTR legs **on the bound connection**; terminal `ReadResourceResult` only — after **response-side policy validation** (every returned item's canonical URI passes the matcher; per-item and aggregate limits; **mixed-policy responses rejected whole**). Conformance: terminal read; state-only MRTR; input-request-bearing round fails closed; revoked lease; out-of-policy request URI; extra out-of-policy item; prefix-inclusive subresources; duplicate/mixed contents; aggregate overflow; teardown during suspension.

**Post-queue freshness barrier — every governed operation, including register commits.** Structural/rate preflight only before queueing; after the coordinator grants ownership and immediately before any callback, remote call, cache delivery, bootstrap read, or register commit: principal/session authorization, channel/View generation, **connection generation**, live lease, binding revision **and binding/readiness generation**, current tool visibility or resource policy (or advertised modality), **Session incarnation**, and limits. The out-of-lock paginated `tools/list` binds to a short-lived admission permit whose revocation inputs are rechecked inside. Conformance: queued-call and queued-read races against lease revocation, visibility removal, policy narrowing, binding change, connection loss, and teardown.

**`ui/update-model-context` — permit-gated atomic commit.** Channel/View-bound permit revalidated after acquisition and immediately before commit (authorization, View generation, lease, binding revision, advertised modality, size limits, teardown state, incarnation); register write + monotonic commit-sequence allocation are one atomic step; stale updates change neither. Conformance: queued-update races against lease revocation, View teardown (both orders), binding change, modality withdrawal, limit shrink, and a newer accepted update — no stale register reaches a resumed or internal model turn.

**MRTR contract.** ADK drives the loop internally at the session level (`allow_input_required=True`) while the outer request stays pending until terminal: `requestState` opaque, echoed exactly; fresh MCP id per leg, legs internal to one operation record; round and total-time caps; no result before a terminal result; **intermediates modern-only, legacy single-round** (negative-tested). Suspension: direct operations and bootstrap release during waits and re-acquire with the full barrier; a model-originated MCP tool nested in a Runner turn keeps the turn's lock (bounded by caps) with out-of-band completion required — same-session reentrant completion → typed error. On app-safe connections, all input-request rounds fail closed.

**Coordinator-wide reentry rule.** Ownership tracked in loop/thread-neutral execution context; any same-session invocation of a public Runner mutator or host-service acquisition from within **any** callback — plugin/agent tool callbacks, agent callbacks during a `ui/message` turn (including before-run), the event chain, audit observers, MRTR completions, every enabled MCP-owned callback — is rejected with a typed error before it waits. **A same-session reentrant invocation of synchronous `run()` from within a callback** is rejected caller-side, before the worker thread spawns — ordinary top-level `run()` on an app-enabled session works normally via the registry loop. Ownership tokens operation-scoped and cleared on release — outliving callback tasks are not falsely rejected. Cross-session calls proceed. Conformance: one reentry test per family; reentrant sync-`run()` pre-spawn rejection (with ordinary top-level `run()` succeeding); outliving-task; nested-elicitation-while-waiting.

**Live JSON-RPC id scope.** In-flight registry per channel and View generation: a second live equal id on the same channel is a protocol error (no exceptions); different channels proceed; post-completion reuse is legal. Conformance: same-id/same-channel rejection and different-channel success for calls, reads, and messages.

**Method-aware operation identity and the idempotency boundary.** Identity: `tools/call` — method + server/toolset + tool + canonical arguments digest; `resources/read` — method + server/toolset + canonical URI/parameters; namespace binds principal, View, binding, identity, incarnation, **connection generation**, and — for reads — the current resource-policy and delivery-limit revision. **Key wire schema (calls and reads only):** `_meta["com.google.adk/idempotency-key"]` — opaque, 1–128 chars, `[A-Za-z0-9._-]`; malformed → typed rejection; same key + same identity → the one retained terminal outcome; conflicts rejected; legacy/modern wire tests. `ui/message` has no replay key in v1. After TTL/eviction/process loss or with no key, execution may repeat; ADK-owned effects dedupe only within the retained-operation boundary; remote effects retain server semantics; for reads, retention never exceeds a server `ttlMs`, and `cacheScope: "private"` binds records to the credential context. MRTR legs never create a second record; the operation-instance id keeps internal retries from colliding with independent identical calls. **Fresh admission (the post-queue barrier) precedes any retained-outcome lookup**, including re-running the item-level matcher and delivery limits against cached reads. Conformance: cached-replay denial after authorizer denial, lease revocation, visibility removal, binding change, teardown, policy removal/expansion change, returned-child-only policy change, limit shrink, TTL expiry, private-scope mismatch, **connection-generation change**; cross-method and cross-URI same-key conflicts; no-key, expired-key, reconnect, restart, MRTR-leg cases.

**Coordination matrix (app-enabled sessions; Apps routing decided at host configuration):**

| Entrypoint | v1 contract |
|---|---|
| `run_async` (and `run_debug`) | Acquires; covers message append and the entire turn |
| `run()` | Same primitive; same-session reentry rejected caller-side pre-spawn |
| Governed app `tools/call` / `resources/read` | Acquires; post-queue barrier; suspension rules; bound connection |
| **Export (purpose-bound resolver)** | **Acquires; incarnation-bound permit held through the release commit; final recheck immediately before release; streaming prohibited in v1 (single materialized response under held authority); covered by the freshness barrier, teardown races, and reentry rule** |
| Context-update ingestion | Acquires; permit revalidated immediately before the atomic commit |
| Teardown | Two-phase; phase 2 acquires |
| `run_live` | Rejected, typed fail-closed, before any session read/callback/append |
| `rewind_async` | Acquires around its full read/compute/write sequence |
| First-party Session create/import (with events) | Routed through the coordinator, the **lifecycle-store creation states**, and the core finalizer — forged protected fields stripped/stamped |
| First-party Session patch (append) | Routed through the coordinator and finalizer — same stamping |
| First-party Session delete | The incarnation-safe deletion contract (§3) |
| Direct external `SessionService.append_event` | Outside the coordinated contract — documented unsupported |

Conformance races: live-vs-app-call; rewind-vs-app-delta; update-vs-turn; update-vs-revocation/teardown (both orders); queued-waiter cancellation; cross-loop `run()` vs `run_async`; message-append no-interleave; both `ui/message`-vs-teardown orders; patch-vs-turn and patch-vs-app-call; forged-field import; create racing `DELETING`/`DELETED`.

**`ui/message` — Runner-owned composite; correlation-only ids; no v1 replay.** Host validates/authorizes without acquiring; delegates once to `Runner.run_async(new_message=...)` with a protected permit (lease, View identity, binding revision, confirmation outcome, canonical payload, incarnation); after acquisition and before any user-message callback or append, the permit is revalidated — stale → terminal JSON-RPC error, nothing appended; teardown atomically invalidates queued permits. Request ids: live correlation only, channel-scoped; no terminal-response cache; post-completion reuse starts a new governed turn; extra request fields rejected by schema upstream. Confirmation by default as host policy (host attestation or explicit ADK confirmation), configurable; exactly one governed turn per accepted message. Conformance: same-id different-message after completion → new turn; same-id same-channel while live → protocol error.

**Two-phase teardown with an honest effect boundary.** Phase 1 — out-of-band terminal signal (no lock, no session mutation): revokes the View generation, cancels active/queued tokens including context-update permits. Phase 2 — under the coordinator: clears registers, revokes grants, invalidates queued permits, removes persisted host state, reconciles interrupted operations per §4. After callbacks began: partial-commit — teardown suppresses the terminal result and forbidden controls only; frozen-batch effects reconciled, never rolled back or silently lost. Remote side-effect cancellation best-effort. **`ui/notifications/request-teardown`** is channel/View-bound, routes to the host's teardown decision, never directly triggers phase 1; malformed/duplicate notifications ignored idempotently with logging; races with host-initiated teardown resolve into the same two-phase path. Conformance: teardown after before-tool state mutation, after artifact save, and around an on-event exception (five-surface, both orders); request-teardown authorization/malformed/duplicate/race.

**Execution ownership and callbacks.** Plugin tool phases and the originating agent's canonical chains run (replacement semantics per §5); model and agent-lifecycle callbacks do not; MCP-owned callbacks per the §4 matrix; auth, sealed confirmation preflight, cancellation, retry are model-call-equivalent. Nested `UiWidget` emission suppressed unless independently negotiated. Missing originating agent → typed error, read-only render.

### 7. `McpAppHostService` — durable vs process-local state, and the export resolver

`PrincipalResolver`, `SessionAuthorizer`, the key provider, the `SealedResultStore`, and the `SessionLifecycleStore` are deployment-supplied contracts **referenced by the host profile and required for binding**. The HTTP adapter owns Origin/CSRF/cookie/bearer mechanics; denials are terminal and audit-logged (secrets/content excluded); stock `adk web` routes are local-development only with explicit loopback providers. Leases: short-lived, bound to principal + session + incarnation + widget + `view_instance_id` + toolset + server + binding revision + **connection generation**, with expiry, phase-1 revocation, replay protection, limits, cross-server rejection. **State classes are explicit: the sealed-result and session-lifecycle records are durable v1 binding prerequisites; leases, registers, coordinator state, pending confirmations, and idempotency records are process-local in the v1 reference adapter** — single process or sticky sessions required; a shared store for the *process-local* class is the horizontal-scaling question (open question 5).

**The purpose-bound export resolver (P0.6).** The View-bound resolver requires a live View binding — which cannot exist cold — so cold non-View access is a **separate, dedicated export resolver**, never a weakening of the View rule:

- **authorization:** a named `export` purpose on `SessionAuthorizer` (a distinct capability a deployment must grant explicitly), evaluated for the **current** principal at call time — old credential contexts and revoked principals are denied;
- **checks:** tenant scope, Session, incarnation, Event, and function-call correlation all verified against the authoritative persisted Event — cross-tenant locator swapping, deleted/recreated Sessions (old incarnations), and expired binding keys fail typed;
- **output:** the sealed result bytes under a configurable export projection; **never HTML, never a View grant, never a template fetch** — export cannot bootstrap a View;
- **limits and audit:** byte/rate/retention limits; **the redacted audit record (principal, target identifiers, decision, status — no content) is persisted durably *before* any bytes are released, and export fails closed if the audit write fails.** The audit sink is a **named `ExportAuditStore` contract in `McpAppsHostProfile` readiness — the sixth deployment contract** — with defined record keys and idempotency (operation-instance-keyed), a status vocabulary (`authorized`, `released`, `denied`, `failed`), redaction rules, retention, write-failure behavior (deny), and incarnation-cascade deletion. **The durable row is an authorization/release-attempt record, not proof of client receipt** — a crash after audit and before network delivery is an expected state the record vocabulary represents; the record is never model-visible;
- **linearization (P0.7 of the 4.17 review):** export is a **governed operation in the coordination and lifecycle matrices** — it acquires an incarnation-bound, purpose-specific permit, and **immediately before release** rechecks principal authorization, lifecycle state and incarnation, the binding key, locator correlation, the projection, and limits, so a revoke, deletion/recreation, key rotation, or limit change that wins the race denies the export; teardown and deletion races cover queued exports.

Conformance: cross-tenant locator swapping; deleted/recreated Sessions; principal revocation; expired binding keys; old credential context; bulk-export limits; observer/log redaction; **revoke/delete/recreate/key-rotation/limit-shrink after store load but before handoff; teardown while queued and during handoff; duplicate retries (idempotent by operation instance); audit write success followed by transport failure (record shows `released`, no delivery claim); audit-store outage (export denied); crash before/after the audit append and before/after response release; streaming rejection; the record never entering model context**; proof that export cannot mint a View or fetch a template.

### 8. Conversation, lifecycle, and limits

- **`ui/update-model-context`** — replacement-state registers per View instance: retained until overwritten by that View or its teardown; every turn snapshots and **reads** them ("consumed" ≠ cleared); mid-turn updates apply from the next turn; permit-gated atomic commits; host-assigned monotonic commit sequence (never View-supplied); highest live sequence wins per widget; winner-teardown promotes the next. **LLM projection:** a single dedicated **`user`-role context block**, delimited and provenance-labeled per widget (`[MCP App context — widget , view ]`), widgets ordered by commit sequence; `structuredContent` serialized as ADK Canonical JSON v1 text; explicit empty update clears that View's register. **Budget:** per-register cap (over-cap updates rejected at commit — the winner is never silently truncated) and aggregate cap (whole-register eviction, oldest commit sequence first, explicit marker listing evicted widget ids — never byte-level truncation). Included on every model turn while live, including resumed and internal model-only turns. Untrusted data, never system instruction. Conformance: concurrent updates; winner teardown; prompt-injection sentinel; deterministic order; per-register rejection; whole-register eviction with marker.
- **`ui/message`** — per §6.
- **Teardown** — frontend handles `ui/resource-teardown` ack/timeout; `request-teardown` per §6; backend follows the two-phase contract.
- **Limits** — queue depth, rate, byte, and content limits on every View-originated operation; MRTR round/time caps; discovery bounds; register caps.

### v1 scope — stable Apps subset, with accurate capability advertisement

```jsonc
{
"serverTools": {},
"serverResources": {},
"message": { "text": {} },
"updateModelContext": { "text": {}, "structuredContent": {} }
}
```

Unsupported members and modalities omitted; input-request capabilities never advertised on app-safe connections. Conformance: a capability-gated View enables all four; undeclared modalities rejected. Cross-repo baseline: `ui/initialize` → `McpUiInitializeResult` (capabilities above) → `ui/notifications/initialized`; `ping`; complete `ui/notifications/tool-input` (once, post-initialization, before any result); initial and interactive `ui/notifications/tool-result` (terminal only); `ui/notifications/tool-cancelled`; `ui/notifications/size-changed`; `ui/resource-teardown` with acknowledgment (+ `request-teardown` routing); the sandbox-proxy sequence `ui/notifications/sandbox-proxy-ready` / `ui/notifications/sandbox-resource-ready`; initialization negative tests (malformed/missing `appCapabilities`; unsupported View protocol versions). **Deferred:** `tool-input-partial`, preloading, `ui/open-link`, `ui/download-file`, display-mode changes, logging, theming, `listChanged`, app-path input requests. **`run_live` is out of v1 scope for app-enabled sessions.**

### Provenance: ingress-only `invocation_origin`, derived immediate caller, session-API authorship

`invocation_origin = user | mcp_app | system` (ingress; `model` is not a value — the caller dimension is the derived read-only `immediate_caller` (`model | app | host`) on `ToolContext`).

| Event / operation | `invocation_origin` | `immediate_caller` / `emitter` |
|---|---|---|
| Normal user turn (all events) | `user` | nested tools: `model` |
| Accepted `ui/message` — all its events | `mcp_app` | nested tools: `model` |
| Direct app `tools/call` / `resources/read` (audit events) | `mcp_app` | `app` |
| `bootstrap_render` | `mcp_app` | `host` |
| Rewind, compaction | `system` | — |
| Reconciliation records | the interrupted operation's ingress | `emitter="system"` |
| First-party Session create/import/patch events | `system` | `emitter="session-api"` (declared author preserved on imported content; forged protected fields stripped/stamped) |
| Deletion intent / cascade completion / cascade failure | — | Recorded in the durable lifecycle record + critical redacted log/metric — never as session events |

Set once at operation admission on the execution context; read-only via `ToolContext`; finalizer-stamped on every append path; not settable from app input, `custom_metadata`, replacement, or mutation. **Stamping on normal sessions is the third declared compatibility exception.** The BQAA fixture asserts both dimensions at before-tool time and on persisted events, including first-party patched rows.

### Draft-watch, conformance reproduction, and non-goals

**Draft-watch (non-normative):** listing-level `_meta.ui` fallback with content-item precedence (adopted proactively), app-provided tools, expanded `ui/initialize` exchange — out of v1; the design must not preclude them.

**Conformance reproduction (for the upstream issue; motivated by #3190's closure):** a capability-gated server withholding `_meta.ui` from non-advertising clients; an unlisted `ui://` resource the name-addressed helper cannot read; a modern server returning `InputRequiredResult` the current call site cannot complete; a View-originated `resources/read` the current surface cannot serve despite `serverResources`; a multi-content read the request-URI check cannot bound; an app-only tool exposed to the model; a UI-tool result leaking `_meta`/`structuredContent` into model context and public Session/SSE surfaces; an ungoverned app `tools/call`; **an app call routed to a different connection than its View's** (the same-connection rule).

**Non-goals:** frontend sandbox/CSP/postMessage enforcement (cross-repo); A2A widget propagation; Agent Engine managed rendering; app-provided tools; display-mode semantics; `ui/download-file`; `run_live` with bound profiles; core rewind changes; staged/transactional state-artifact machinery; outbox/WAL; a durable observer cursor or BQAA backfill adapter (future work); a `ui/message` replay extension; app-path input-request governance (follow-up); hostile-store authenticated encryption (future); a privileged pre-policy diagnostics record; full non-UI sibling ledgering (ratification 7(h)); BQAA dashboard logic.

## Delivery stack

0. **Prerequisite:** MCP Python SDK v2.0.0 migration (canonical dump + `isError` detector + session-level MRTR adoption, as one change).
1. **PR 1 — visibility/catalog + URI canonicalization + reserved-name classification + direct URI reads + bounded paginated admission + the unconditional projection.**
2. **PR 2 — profile/spec/proxy/registry/descriptor/stores:** the named `McpAppsHostProfile` with readiness-gated, **revocable** binding, proxy lifecycle including `REVOKING`, registry-owned construction, key-provider lifecycle, the `SealedResultStore` state machine with operation-instance identity and honest recovery, the **`RefreshInvocationRecord` envelope lifecycle**, the **`SessionLifecycleStore` with crash-safe creation and incarnation states**, persistence-compatibility validation, ADK Canonical JSON v1 with golden vectors, **connection-affine pool construction**, the **exact `sealedResultRef` schema**, the ordered `bootstrap_render`, compatibility matrix.
3. **PR 3 — audience, finalizer, batch ledger, callbacks, result authority:** discriminator, action matrix, terminal-outcome matrix, sealed confirmation permit, the **`EventBatchEffects` batch ledger with child ledgers, two-phase batch admission, and the core-owned rejection Event**, restricted app-call and event-policy contexts, the governed MCP-callback matrix, the closed snapshot contract, the **batch-aware validated merge pipeline with reference attachment**, core-only fallback, honest durability, observer API, core event finalizer, provenance, adversarial conformance.
4. **PR 4 — governed operations and sessions:** MRTR-aware calls/reads on **bound connections**, the post-queue barrier with connection-generation and incarnation checks, channel-scoped ids, coordinator-wide reentry, method-aware idempotency, the extended mutation matrix with first-party create/patch/delete through the lifecycle store, Runner-owned `ui/message`, two-phase teardown with `request-teardown` routing, register budgets.
5. **PR 5 — host service:** the **six** deployment contracts referenced from the profile (identity, authorization, key provider, sealed-result store, lifecycle store, export audit store), leases + grant secrecy + phase-1 revocation, the export permit/audit pipeline, per-View semantics, teardown, limits, single-process reference FastAPI adapter (dev-only, loopback providers).
6. **Cross-repo milestone — `adk-web`:** authentication story, sandbox renderer, full View-lifecycle baseline with four-capability advertisement, conformance fixtures, #396 acceptance.

## Definition of done — split

### ADK Python conformance (provable in-repo)

- **Advertisement/binding/profile/readiness:** settings map on both eras passing `client_supports_apps`; the profile field table with its fingerprint subset driving `binding_revision` and all keys, and ephemeral generations checked independently at every barrier; readiness cases including **revocation on live pooled connections** (idle reused connection; loss during initial UI execution; loss after seal before append; loss during bootstrap; reconnect after loss; provider replacement with old leases); direct construction never advertises; proxy lifecycle invariants including `REVOKING`; four-capability advertisement with modality rejection; no input-capability advertisement on app-safe connections.
- **Visibility/catalog/URI/connection:** the full case list including malformed→deny-all, adversarial URIs, bounded-discovery violations, reserved-name classification in both catalogs, and **connection-generation admission** (the stdio per-process-nonce fixture; the legacy stateful-HTTP equivalent; app call after eviction/reconnect; two configured connections to one server; the #396 model-tool → View-tool path).
- **Descriptor/reference/stores/bootstrap:** no sealed bytes on any public surface; the Event-verifying gated resolver; the **`sealedResultRef` schema cases** (one and several references; reordered parts; duplicate/missing ids; two widgets sharing one URI; legacy descriptor; profile-less Event; public serialization; restart mapping); tenant/incarnation scoping and reference swapping; the sidecar state machine and recovery cases; identical-no-key independence and same-replay-key convergence; persistence-compatibility validation; the **lifecycle-store creation/recreation/migration/deletion cases** (crash before/after reservation, Session commit, and import finalization; concurrent duplicate create; recreate racing deletion; abandoned-`CREATING` recovery; migration; crash at every deletion phase; delete-then-recreate; late work from old incarnations; old references vs new incarnations); key-provider cases; the ordered bootstrap; exact-one selection; read-only render as the defined placeholder; canonicalization I-JSON cases and golden vectors; credential redaction.
- **Profile-conditional behavior:** both profile-less negative paths; profile-bound sealing/replay/limits active; **mixed-batch pre-fan-out rejection with the complete per-call response Event tested; all-non-UI batches and non-UI standalone calls byte-identical to current ADK; profile-bound UI calls intentionally gain descriptors, references, and the projection — their behavior is defined by this pipeline, not claimed byte-identical**.
- **Audience/finalizer/batch ledger/callbacks:** stored-row discriminator round-trips; sentinel non-leak; routing non-influence; the action and terminal-outcome matrices with physically possible histories; finalizer stamping on plugin-merged, plugin-free, and first-party appends; **the batch cases (same-key sibling writes under id-order precedence; mixed-batch rejection with no task/callback/effect on both backend classes and the complete per-call response Event; artifact collision with the applied-winner rule; callback failure during sibling writes; cancellation during fan-out; post-children event-policy effects; restart reconciliation ordering)** plus the delta-map adversaries and out-of-API failures; the restricted event-policy facade's eight cases; the MCP-callback matrix with the ListRoots negative; the shared snapshot golden property table; scope-aware rewind unchanged.
- **Pipeline/fallback/durability/observers:** one core-only frozen-batch fallback; backend-failure escalation; observer deep-snapshot input; zero-or-one BQAA rows across crash/exception/restart; acceptance asserts the durable session event directly.
- **Sealed confirmation:** exactly-one evaluation; all mutation classes defeated; fail-closed `ToolContext` predicates; sealed arguments execute.
- **Result authority (batch-aware):** the nine single-result structural rejections; the eight batch cases including whole-Event atomicity and deterministic post-restart mapping; valid replacement and in-place redaction on both origins; hidden fields unreachable; post-merge re-validation enforces terminal `resultType`; model/View/seal/restart-replay/telemetry agreement.
- **Reentry:** typed rejection from every callback family; reentrant sync-`run()` pre-spawn rejection with ordinary top-level `run()` succeeding via the registry loop; outliving-task no-false-rejection.
- **Operations/sessions:** the full operation case lists; post-queue barrier races including connection loss; queued-update races with atomic commit-or-reject; channel-scoped id cases; method-aware idempotency and replay-admission cases (including connection-generation change) under the honest recovery contract; the extended coordination matrix; `ui/message` contract; teardown five-surface outcomes; request-teardown cases.
- **Registers:** ordering, promotion, snapshot-read, the specified projection with sentinel and deterministic order, per-register rejection, whole-register eviction with markers.
- **Provenance:** the expected-value table including session-API rows; reconciliation ingress + `emitter`; deletion progress in the lifecycle record; protected everywhere.
- **Authorization:** binding-gated providers; denials audit-logged; grants never iframe-visible; no authority survives persistence, replay, an old incarnation, or an old connection generation.
- **Compatibility:** with no profile bound, behavior identical to the pin except the three declared exceptions.

### Cross-repo product acceptance

- A conforming `adk-web` renderer registered before advertisement, with a real authentication story for non-local deployments.
- The View lifecycle baseline (four-capability initialize handshake, `ping`, tool-input/result/cancelled, size-changed, teardown ack/timeout + `request-teardown` routing, sandbox-proxy sequence, initialization negative tests) plus separate-origin sandboxing, message source/origin validation, initialization ordering, domain partitioning, CSP/Permissions-Policy enforcement.
- **An independent connection-stateful conformance server (P1.4):** per-connection state and a per-process nonce, exercising same-connection affinity, reconnect invalidation, audience policy, MRTR-bearing tools (model path), a state-only MRTR interactive read, a multi-content read, a capability-gated View using `message`/`updateModelContext`, and parallel UI tool calls through the batch pipeline. **#396 remains the product-acceptance fixture** (its BigQuery operations are naturally stateless and cannot detect cross-pool state loss); the conformance server owns the connection-affinity and crash-creation contracts.
- #396 proves the BQAA flow with the three-tool fixture plus interactive-read amendment v6 — asserting visibility, provenance per the table, the batch-aware merged-authority sealed projection, **the defined cold behavior (one-click refresh permitted because `show_agent_dashboard` is profile-classified read-only — not because every App tool is refreshable — with old sealed records accessible only through the export resolver)**, and zero-or-one observer telemetry asserted against the durable session event.

## Resolved design decisions (revisions 1–4.19 reviews)

1. Host-owned, accurate, readiness-gated **and readiness-revocable** named-profile capability binding (the `REVOKING` transition closes every Apps-advertising pool); registry-owned construction; direct construction model-only; Apps routing decided at host configuration.
2. Profile-conditional confidential `SealedResultStore` (tenant- and incarnation-scoped; complete state machine; operation-instance identity; trusted-backend boundary; honest recovery; persistence-compatibility validation) **plus the `SessionLifecycleStore` with crash-safe creation, migration, and deletion incarnation states**; key-provider lifecycle; ADK Canonical JSON v1 with golden vectors; the ordered bootstrap; "read-only render" = error placeholder; **the exact 0..1-per-widget `sealedResultRef` schema with append-time cross-validation**.
3. **Connection-affine governed operations:** UI-associated initial calls route to app-safe connections; Views bind to exact connection generations; reconnect invalidates and re-render rebinds; admission, idempotency, and leases are connection-generation-scoped; two governed View operations plus permit-gated register commits; response-side scheme-aware policy; the post-queue barrier everywhere; channel-scoped live ids; coordinator-wide reentry with reentrant-`run()` pre-spawn rejection (top-level `run()` served by the registry loop); Runner-scoped registry lease tokens released by `Runner.close()`; first-party Session create/patch/delete through the lifecycle store; `ui/message` Runner-owned with no v1 replay; two-phase teardown with `request-teardown` routed.
4. Audit isolation: protected `audience`, exhaustive action matrix, the **batch-scoped `EventBatchEffects` ledger** (child ledgers per bound call; one freeze; id-order precedence for state deltas with artifact outcomes following the applied-write order; mixed batches rejected via the core-owned per-call response Event after two-phase admission), restricted app-call and event-policy facades bounded to supplied APIs, the governed MCP-callback matrix, the closed shared snapshot contract, sealed confirmation permit, core-only fallback, append-conditional durability, best-effort observer delivery with the durable record authoritative, core event finalizer.
5. One validated, event-batch-aware result authority with whole-Event atomicity and the exact reference schema; the projection unconditional; the sidecar profile-conditional; **mixed-batch fate declared as part of the second compatibility exception**.
6. Visibility unconditional; malformed deny-all; reserved names excluded from both catalogs; bounded paginated admission on the bound connection; method-aware identity and full-response-contract admission precede any retained-outcome lookup; the idempotency key for calls and reads only.
7. SDK v2.0.0 migration prerequisite; connection-affine callback policy (input capabilities absent on app-safe connections, with the declared model-path consequence for UI-associated calls); `ui/message` confirmation as host policy; provenance ingress-only + derived immediate caller — the third compatibility exception; the register projection fully specified.
8. v1 = stable Apps subset with accurate advertisement; identity, authorization, key provider, sealed-result store, and lifecycle store are profile-referenced binding prerequisites; durable-vs-process-local state classes explicit; single-process/sticky reference adapter.

## Open questions for maintainers

1. Should the governed tool-only entrypoint be public API or internal to the host service for v1?
2. What shape should the `PrincipalResolver` / `SessionAuthorizer` / key-provider / store contracts take, and do they live in ADK core or the host adapter package?
3. How long a dual-read window for the legacy `UiWidget` payload before descriptor-only?
4. Is subset-level cross-repo acceptance sufficient for the first upstream merge, or must complete stable-contract coverage precede any default advertisement?
5. **Horizontal scaling of the process-local state class only** (leases, registers, coordinator state, pending confirmations, idempotency records): v2 concern, or required before the reference adapter leaves dev-only status? (The sealed-result and lifecycle stores are durable v1 prerequisites and are not part of this question.)
6. Should the audience machinery, finalizer, batch ledger, restricted contexts, observer API, stores, and two-view projection be general-purpose core mechanisms or Apps-scoped until a second consumer appears?
7. **Ratification of proposed defaults:** (a) profile-conditional sealing vs a universal store dependency; (b) the v1 MCP-callback narrowing; (c) the merged single-authority result pipeline with post-merge re-validation; (d) in-memory idempotency with may-re-execute recovery vs a durable terminal-operation record; (e) the restricted event-policy facade vs a dedicated result/event-policy callback; (f) **connection-affinity policy: app-safe routing with the era-and-lifecycle matrix (adopted) vs one all-input-disabled pool vs a correlation-safe dispatcher — including confirmation from the MCP Apps/core maintainers that the modern logical-binding reading of the same-connection rule is acceptable**; (g) recreation-with-new-incarnation vs permanent id unavailability; (h) **mixed-batch handling: pre-fan-out rejection with the per-call response Event (adopted) vs full sibling ledgering vs a durable batch receipt** — an implementation gate; (i) **cold-replay behavior: refresh-vs-rerun split with profile-declared replay-safe classification (adopted) vs an error-placeholder-only cold state vs persisted-template cold rendering**; (j) **the registry-owned app-loop (adopted) vs rejecting synchronous `run()` for app-enabled sessions**; (k) **the security-domain generation key: per-principal × per-Session-incarnation isolation (adopted) vs per-principal-only keying with intra-principal Session sharing as the default**.

## Definition of success

An ADK deployment with a readiness-complete registry-bound `McpAppsHostProfile` completes the stable-subset Apps loop — the full View lifecycle with accurate four-capability advertisement, both governed View operations on their **bound generations**, and permit-gated register commits — against an unmodified compliant server: the server accepts the advertised capability from a host that can actually produce a View, never receives input-capability promises the serving connection cannot fulfill, and **never sees an app call arrive outside the View's bound generation — concrete client/session/process affinity on legacy transports; on the sessionless modern core, the logical endpoint-and-profile binding with explicit server handles, **an interpretation pending ratification 7(f)**; the model's declarations are pinned to the app-safe execution generation through connection-bound tool handles with pre-call re-listing; catalogs (reserved names excluded) and the scheme-aware resource policy hold on request, on every returned content item, and again at post-queue execution with incarnation and generation checks, with fresh method-aware admission before any cached replay; a tool call — alone or in an all-UI parallel batch, mixed batches being rejected before fan-out with a complete per-call response Event — persists a minimal descriptor carrying its reference per the two-placement schema and, under a bound profile, confidential, tenant- and incarnation-scoped sealed records of every structurally validated merged result, appended atomically as one Event and **accessible after restart only through the purpose-bound export resolver, with the refresh-vs-rerun split (replay-safe classification, else explicit confirmed re-execution) as the defined cold affordance**; every model turn receives the exact-key projection with error semantics intact, and model and View always agree on the callback- and event-policy-approved public fields; confirmation decisions are sealed and evaluated exactly once; applied effects live in a batch-scoped ledger that freezes once — id-order precedence for state deltas, artifact outcomes following the applied-write order — with out-of-API mutation failing closed on every callback surface; sessions are incarnation-safe **from receipt-fenced crash-safe creation through idempotent cascade deletion**, and late work from an old incarnation, an old connection generation, or a retired creation operation always fails; recovery is honest; reconciliation flows through a non-recursive core-only fallback with append-conditional durability and best-effort observer telemetry whose durable record is authoritative; every public Runner mutator and first-party Session mutation path is coordinated, finalized, or fail-closed; readiness withdrawal revokes advertisement by closing the connections that carry it, and **rebind installs a new proxy under a new generation rather than resurrecting the old one**; `ui/message` uses channel-scoped correlation-only ids; registers reach the model only as a delimited, budgeted, whole-register-evicted untrusted block; teardown leaves no live authority; and **no result bytes, security metadata, or authority ever enter the widget descriptor — an opaque locator being none of these** — with the #396 dashboard (product) and the connection-stateful conformance server (legacy-transport protocol affinity) as the working proof.

Contributor guide

Open the contributing guide

Research direction

Read the Protocol baseline and the referenced google/adk-python main commit, starting with the stated MCP Python SDK v2.0.0 migration prerequisite. Then trace the existing McpTool and UiWidget behavior described in the Summary and review downstream consumer #396. Done means the stable Apps host seam, dual-era advertisement, policy, rendering, and governed app interactions are implemented with the specified tests and contracts.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend-api-design
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.