Skip to content

Phase 2 step 3: the host-action foundation (H1, H2, H4) - #106

Merged
ryandmonk merged 8 commits into
mainfrom
claude/blocks-host-actions
Oct 16, 2026
Merged

ryandmonk merged 8 commits into
mainfrom
claude/blocks-host-actions

Conversation

@ryandmonk

@ryandmonk ryandmonk commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Step 3 of the Phase 2 release plan (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7), limited to H1, H2 and H4 and their tests. Composer still executes no action. No deploy, no Cloudflare change and no paid model call. Not merged: the owner decides.

Head: 5740707f300f013d3131958ff0cdda1cb8d14354, independently security-reviewed at this exact SHA (no blocker; four nits left for the owner, below).

What changes

H1: B0 proves the action allowlist at load (packages/composer-core/src/blocks/actions.ts, called from b0Issues).

  • Which nodes. The emitter gives a node an action only where the profile says so: a v1 surfacePlan.actionProp, or a v2 route from synthesized.action. On both references that means button and alert-dialog. It names the action with a slug of the node's id. Without an id, it falls back to an alert dialog's confirm label or the node's text, both of which a model can write, and then to the component's name.
  • Bounded ids. Every such node needs an id built only from literals and enum props, read directly, through a $case over an enum or boolean, or joined with $concat. That rules out a string the model fills, $index, and an optional enum without a default.
  • Exact names. Each id is evaluated as the expander evaluates it, over every combination of the enum and boolean props it reads, so a prop read twice holds one value.
    • An $if that tests a prop's truthiness, or its equality with a literal, narrows that prop in each branch.
    • A branch no call can take counts nothing.
    • Other conditions, and B1's cross-field constraints, are treated as able to go either way. The set may then include a name no call reaches, but it never misses one a call can emit.
  • Caps. An id may stand for at most 256 names. An id whose props give more than 4096 combinations is refused before any of them is evaluated.
  • Only the emitter adds action events.
    • A template value shaped like an action event (literal, or built by $map or $concat) is refused, and so is a call field named event.
    • H1 does not look for A2UI function calls or data bindings. On both references, none can reach a property the client evaluates (HOST-ACTIONS.md, §7).
  • Exact equality. The names a template can emit must equal its declared actions, exactly: nothing undeclared, nothing declared but unreachable, nothing declared twice.
  • On failure. Every failure is a B0 issue, which disables the block at load.
  • Shipped sidecars. Both pass, and their names equal the §7 table.

H2: the beta's Catalog export carries an action manifest (blocks/manifest.ts; withActionManifest in apps/composer/experimental/blocks-runtime.ts).

  • Where. In an experimental build, Preview's "export catalog" adds actionManifest (v1).
  • What each entry holds.
    • One entry per exported surface lists every action name its messages dispatch, recomputed at every export, so an edited surface reports what it dispatches now.
    • Each entry also carries messagesSha256: the lowercase hex sha256 of the UTF-8 bytes of JSON.stringify(messages), as exported. The recipe is in HOST-ACTIONS.md §3, with its Python equivalent. A host renders only the messages the entry describes.
  • When a surface binds a block. All of these must hold:
    • its provenance names an approved block of an approved, bundled sidecar that B0 (with H1) accepted;
    • the call passes B1 and the block's constraints;
    • the call re-expands to exactly the stored surface;
    • that expansion emits exactly the exported messages;
    • every action in them is declared, with an empty context.
  • What a binding carries. The block id, version, reference and base, intent, expansion identity (expansionDigest), declared actions and streaming policy, and declared: true on its actions.
  • Anything else. It binds nothing (block: null, every action declared: false). A surface name plus an action name grants nothing. Verification never throws, and an export without a manifest binds nothing.

The default export stays byte-identical.

  • The move. Preview's export builder moves, unchanged, to apps/composer/app/catalog-export.ts; the manifest is inserted only in the experimental branch.
  • Provenance access. Preview reads provenance through a new blockProvenanceFor on the composer context, which exportProject now shares. In a default build it returns {}.

H4: docs/blocks/HOST-ACTIONS.md.

  • §2 what a surface dispatches and how H1 counts names.
  • §3 the manifest format, field by field, the messagesSha256 recipe, and how a binding is earned.
  • §4 the host's rules:
    • names are not capabilities;
    • look a surface up by surface, never by the shared surfaceId;
    • check messagesSha256 on the messages as exported, then render them under a surfaceId of your own;
    • authorize every effect on the server;
    • key idempotency by an operation id the server issues (a rendering's surfaceId can serve), in a bounded store;
    • the streaming policy;
    • every string as untrusted model content;
    • the auth-form rule that credentials and entered values never enter an action context.
  • §5 a threat table, §6 where each property is tested, §7 the limits, including what H1 does not look for.

Docs. A status line in the release plan and a pointer in docs/BLOCKS.md.

Not in this PR

  • No host handlers, no catalog or provenance UI (H3, step 4), no receipts, no accessibility changes, no shadcn/ui change, no Stage 4.
  • No deployment or Cloudflare change. check-default-artifact.mjs, check-beta-deploy.mjs, both wrangler configs and every workflow are untouched.
  • The new actionManifest key is added only to the composer smoke suite's chunk scan, which is test-only.

Reviews

Copilot.

  • It reviewed 6eb9d72 and raised three findings. All three were confirmed and fixed, with the tests written first (aa4e3f7, then 60c80ae):
    • a prop read twice was treated as two independent choices;
    • the 256-name cap applied only inside $concat;
    • $if branches no call can take were counted.
  • Its re-review of 47958c0 marks those resolved and raises one low-severity finding on host rule 4: a key made only of the session, the effect and its target would answer a second, intended operation with the first one's result. This is fixed in 5740707.
  • All four threads are answered and resolved.

Independent security review. A separate read-only reviewer ran mutation and differential checks at each head.

  • At 6eb9d72: no blocker. It made four minor findings and three nits:

    • a misreadable surfaceId and declaredActions;
    • no tie between the manifest and the exported messages;
    • event-shaped template values outside H1;
    • untested branches;
    • the cap;
    • the "nothing throws" wording;
    • doc wording.

    The cap was fixed in 60c80ae; the rest were tested first in 97073d2 and fixed in 47958c0.

  • At 47958c0: no blocker.

    • It ran a differential fuzz of H1 against the real expander and emitter: 2,098 accepted random templates, every B1-valid call of each enumerated, and 0 under-counts.
    • Every mutation it tried is caught.
    • One minor finding. The messagesSha256 recipe was under-specified, and rule 2 told hosts to render under their own surfaceId, which changes the hashed bytes.
    • Three nits:
      • H1's reach was overstated: function calls and data bindings are not refused, though neither can reach a client-evaluated property;
      • the token-edit wording;
      • small doc gaps.

    Where testable, these were tested first in 5f8431f; all were fixed in 5740707.

  • At 5740707, this head: no blocker.

    • It re-checked every changed sentence. That included every template-writable target on both references (22 on Astryx, 43 on shadcn/ui), resolved against the emitted catalogs: none can hold a function call or data binding.
    • It checked the recipe against Python.
    • It found nothing in 47958c0..5740707 that weakens an earlier property.

Open nits at this head. These are the owner's call. They are left unfixed so the reviewed head does not move.

  1. Host rule 4. Say explicitly that the server accepts an event only under a surfaceId it issued to that session and has not retired. Also say that a repeat under the same id with other parameters is refused, not answered with the first result. Rule 2 already binds actions only to surfaces the server rendered.
  2. §2, the id-form sentence.
    • A standalone id must be a string; numbers appear only inside $concat.
    • "An enum prop's value" includes the loop variable of an enum array.
  3. §3, the recipe.
    • The JavaScript one-liner gives the string; its UTF-8 encoding is the bytes.
    • i is the surface whose name is the entry's surface.
    • Optionally: a host that cannot reproduce the bytes refuses to render.
  4. The recipe test. Its last three assertions are true by construction. They are an illustration and harmless.

Evidence

Fail-first.

  • At cad7954: composer-core 64 of 66 new tests fail.
    • App level: catalog-export and the runtime tests fail to load (modules missing), and the ratchet fails 20 of 22.
    • The four that pass describe unchanged behavior: an id-less button named from its text, a badge staying allowed, and the ratchet's two corrupted-template probes.
    • e2e: the new beta-export test fails against main's experimental build (no actionManifest).
  • At aa4e3f7: 10 new H1 tests fail against 6eb9d72's actions.ts. They include a brute-force check that H1's names equal what the real expander and emitter dispatch over every call.
  • At 97073d2: 7 new tests fail before 47958c0: three event-shape refusals and four messagesSha256 checks.
  • At 5f8431f: 1 of 85 fails before 5740707: the missing-id issue must name the confirm label.
    • The new recipe test parses a pretty-printed export with non-ASCII text and recomputes messagesSha256 as the guide says. It is a guard, so it passes before and after.
    • By mutation, it is the only test that catches hashing ASCII-escaped JSON.
  • The default-export checks are regression guards, so they pass before and after.

Mutation check.

  • Removing each verification step in manifest.ts fails at least one test: messages equality, stored-surface digest, recorded digest, declared names and empty context, unique surface name, approved block, approved sidecar, trusted provenance, own-property lookup, B0-usable only.
  • Removing only the empty-context check fails manifest-context.test.ts. That test makes the emitter write a context, so only the guard can refuse it.
  • The single-surfaceId gate is defense in depth: messages that name two surfaces can never equal one expansion's emission.

At 5740707, run locally. These are CI's steps, in order. They were run at 47958c0 and again at this head, with the same results apart from the one test round 3 adds. Playwright ran the repo's configs through local wrappers that point at this container's Chromium.

  • pnpm test.
    • composer-core 398 of 398 (397 at 47958c0), composer 420 of 420, block-bench 268 of 268.
    • Every other package is green apart from the known local apps/agent rediscover > restoredConflict 5 s timeout (5615 ms here). That test is untouched, and it passes on GitHub.
  • Static checks. pnpm -r typecheck is clean, and the contracts check:sync passes.
  • Builds and guards.
    • the default build plus check-default-artifact (29 files clean of 7 markers);
    • the experimental build plus check-beta-deploy.mjs (configs and artifact pass);
    • the web build.
  • Playwright.
    • composer smoke 37 of 37;
    • composer agent 49 of 49;
    • composer experimental 25 of 25;
    • the Studio exhibit 106 passed, with its 4 live-model and benchmark specs skipped by design.

CI.

  • At 5740707: test passed (run 38939609613) and Workers Builds passed; GitHub reports the PR as mergeable (clean).
  • At 47958c0: test passed too (run 38935608343).
  • At 6eb9d72: test failed once on the known apps/agent timeout, which is not this PR's (see the comment below). It passed on the one re-run.

Default artifact. Main and this branch were built at the same path.

  • The app module. Its structural diff, with mangled names set aside, is exactly the intended change:
    • blockProvenanceFor folds to () => ({});
    • exportProject calls it;
    • the export builds the same {a2uiCatalog, surfaces, exportedAt} and downloads it with the same code;
    • no import() and no experimental branch remain.
  • Everything else.
    • The page chunk differs only in the lazy chunk's id and module id.
    • The webpack runtime differs only in its chunk map and mangled local names.
    • The HTML differs only in per-build ids.
  • Marker scan. No manifest or block code anywhere.
  • At 47958c0 and at 5740707. The default build is byte-identical, in all 26 JS, CSS and media files, to the artifact analysed above. Its HTML and TXT files differ only in the build id and Next's per-build random key. So none of the review-round changes reaches the default bundle.

Security notes

  • Model-controlled names. A model can never name an action. H1 refuses any action id it could influence, and any other route for an action event into a surface. B2 still checks every expansion.
  • Proof, not claim. Provenance is a claim; re-expansion is the proof. Forged, misfiled or stale provenance, edited surfaces, undeclared actions and non-empty contexts all bind nothing. A binding certifies structure, not who wrote the copy.
  • auth-form. Its actions carry context: {}, tested for every example and for the largest call on both references. Its fields carry no data-model binding, so nothing is synced when an action fires.
  • The manifest is not a grant. It is not signed and is meant as reviewed configuration. The host authorizes every effect on its own server (HOST-ACTIONS.md, §4).

🤖 Generated with Claude Code

https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi

…l-first

Tests for Phase 2 step 3 (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7), before
the code they test:
- H1 at load: an action node needs an id built only from literals and enum
  props, and a template's possible names must equal its declared actions,
  or B0 disables the block (actions.test.ts, the ratchet).
- Every example and boundary call dispatches only declared names, each with
  an empty context; auth-form's largest call too, with no field bound to a
  data model.
- The beta's action manifest binds actions by surface and name; forged,
  misfiled or stale provenance, edited surfaces, undeclared actions and
  non-empty contexts bind nothing (manifest.test.ts, the runtime tests).
- The default Catalog export stays byte-identical to main's (catalog-export
  test and the composer smoke suite), and the beta's export carries the
  manifest end to end (composer-blocks e2e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi
…host guide

Phase 2 step 3 (docs/blocks/PHASE-2-RELEASE-PLAN.md, §7), limited to H1, H2
and H4. Composer still executes no action.
- H1 (composer-core blocks/actions.ts, in B0): every node the profile gives
  an action has an id built only from literals and enum props, and the names
  a template can emit equal its declared actions, exactly. Either failure
  disables the block at load.
- H2 (blocks/manifest.ts; the runtime's withActionManifest): in an
  experimental build, Preview's "export catalog" adds an actionManifest that
  binds each surface's actions by surface and name. A surface gets a block
  binding (id, version, expansion identity, declared actions, streaming
  policy) only when its provenance re-expands, under an approved bundled
  sidecar, to exactly the stored surface and the exported messages.
- The default export: Preview's builder moves to app/catalog-export.ts,
  unchanged; the default build's export is byte-identical and carries no
  manifest code.
- H4: docs/blocks/HOST-ACTIONS.md, with server-side authorization,
  idempotency, streaming, untrusted model content and the auth-form rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi
Copilot AI balanced review requested due to automatic review settings October 16, 2026 16:29

Copy link
Copy Markdown
Contributor Author

CI test failed on 6eb9d72 in the Unit tests step (run 38932404740). The only failure is apps/agent › src/project.test.ts › rediscover › restoredConflict, which timed out at 5008 ms against vitest's 5000 ms default.

This failure is not caused by this PR:

  • No overlap with the diff. The PR changes nothing in apps/agent or in anything that test loads. Its changes are composer-core src/blocks/*, the composer app, docs and e2e specs.
  • The same test passes on main. On main's CI for 3edd589 an hour earlier, it took 3057 ms.
  • This runner was slower overall. The neighbouring rediscover tests ran about 1.5× slower too: 3309 ms against 2090 ms, and 2529 ms against 1716 ms.
  • Everything this PR touches passed in the same job. composer 420 of 420, composer-core 377 of 377 and block-bench 268 of 268, every new test included.

No fix exists yet. The owner has asked that this timing-sensitive test be left unaltered. I am re-running the failed job once.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

H1’s name enumeration and limit enforcement do not yet guarantee the promised bounded, exactly reachable action declarations.

3 open findings
What changed in this PR

Adds the experimental host-action foundation without executing actions or changing default Catalog exports.

Changes:

  • Validates block action names at load.
  • Adds verified action manifests to experimental Catalog exports.
  • Documents host responsibilities and adds regression coverage.
File Description
packages/​composer-core/​src/​blocks/​view.ts Exposes action-bearing components.
packages/​composer-core/​src/​blocks/​provenance.ts Exposes provenance validation.
packages/​composer-core/​src/​blocks/​manifest.ts Builds verified action manifests.
packages/​composer-core/​src/​blocks/​manifest.test.ts Tests manifest binding safeguards.
packages/​composer-core/​src/​blocks/​load.ts Integrates action validation into B0.
packages/​composer-core/​src/​blocks/​index.ts Exports action and manifest APIs.
packages/​composer-core/​src/​blocks/​actions.ts Analyzes action names and events.
packages/​composer-core/​src/​blocks/​actions.test.ts Tests action validation and contexts.
e2e/​composer-prod-smoke.spec.ts Guards default exports and bundle isolation.
e2e/​composer-blocks.spec.ts Tests experimental manifest downloads.
docs/​blocks/​PHASE-2-RELEASE-PLAN.md Records step 3 status.
docs/​blocks/​HOST-ACTIONS.md Defines host authorization responsibilities.
docs/​BLOCKS.md Summarizes the host-action foundation.
apps/​composer/​lib/​blocks-ratchet.test.mjs Checks declarations and empty contexts.
apps/​composer/​experimental/​blocks-runtime.ts Adds manifests to experimental exports.
apps/​composer/​experimental/​blocks-runtime.test.ts Tests export integration.
apps/​composer/​app/​views/​preview-view.tsx Connects Preview to manifest exports.
apps/​composer/​app/​state.tsx Shares project provenance access.
apps/​composer/​app/​catalog-export.ts Extracts existing export helpers.
apps/​composer/​app/​catalog-export.test.ts Guards default export byte identity.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/composer-core/src/blocks/actions.ts Outdated
Comment thread packages/composer-core/src/blocks/actions.ts Outdated
Comment thread packages/composer-core/src/blocks/actions.ts Outdated
Review round 1 (Copilot, #106). Tests that the
previous analysis fails:
- a name read twice holds one value, so `mode-mode` cannot emit `reset-sign-in`;
- an $if no call can take counts nothing, and an $if on an enum or a boolean
  narrows it in each branch, the default included;
- an optional enum may name an action only where an $if guarantees it;
- an id read straight from an enum or through a $case is capped at 256 names,
  and one with more than 4096 combinations is refused;
- on narrowing templates and on the shipped auth-form, H1's names equal what
  the real expander and emitter dispatch over every call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi
Each action id is evaluated as the expander evaluates it, over every
combination of the enum and boolean props it reads, so a name read twice holds
one value. An $if that tests a prop's truthiness or its equality with a
literal narrows that prop in each branch, and a branch no call can take counts
nothing. The 256-name cap now holds for every id, read straight, through a
$case or in a $concat, and an id with more than 4096 combinations is refused.
Conditions and cross-field constraints beyond that reading still count, so H1
may count a name no call reaches, never miss one a call can emit.
HOST-ACTIONS.md says how the names are counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi
… guard, fail-first

Review round 2 (the independent security review of 6eb9d72). Tests the code
before this change fails, or that pin what nothing tested:
- H1 refuses a template value shaped like an action event, literal or built
  by a $map (Astryx passes table data through verbatim), and a signature field
  named event, which a call could fill;
- an optional $case with a default names its default where the prop is absent;
- each manifest entry carries messagesSha256, the sha256 of its exported
  messages, end to end in the beta's export;
- the manifest's empty-context guard, alone: the emitter is made to write a
  context, so only the guard can refuse the binding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi
…pen the host guide

- H1 refuses a template value shaped like an action event, which a
  passthrough prop would carry into the messages, and a signature field named
  event, so the load-time proof holds under B2's own reading of an action.
- Each manifest entry carries messagesSha256, the sha256 of the messages it
  describes, so a host can tell them from altered ones.
- HOST-ACTIONS.md: look entries up by surface, never by surfaceId (the intent's
  slug, shared); "declared for that surface" means the entry's own actions with
  declared true; render only messages matching messagesSha256; derive
  idempotency keys on the server and bound the store; every string in a call is
  untrusted; a binding certifies structure, not authorship; an export without a
  manifest binds nothing; edited tokens or profiles bind nothing. The slug and
  the confirm-label fallback are stated exactly.
- The release plan's status note says where the Catalog export is built now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The security-sensitive action validation and binding logic warrant the pending independent human security review.

1 open finding
3 resolved since last review

🧠 Review effort: Balanced

Comment thread docs/blocks/HOST-ACTIONS.md Outdated
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
dspack-studio-composer 5740707 Oct 16 2026, 05:26 PM

…pe, fail-first

The B0 issue for an action node without an id now has to name every
fallback the emitter would use, an alert dialog's confirm label first;
it fails against the current message. A guard pins the recipe the host
guide is about to state: a host that parses the pretty-printed download
and hashes the UTF-8 bytes of JSON.stringify(messages) gets the entry's
messagesSha256 as lowercase hex, with non-ASCII text unescaped, and an
escaping serializer gets another hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi
…n ids, and H1's exact reach

The host guide's two rendering rules conflicted: a host that renders under
its own surfaceId changes the bytes messagesSha256 names. It now says to
check the hash on the messages exactly as exported, then substitute, and
states the recipe: lowercase hex sha256 of the UTF-8 bytes of
JSON.stringify(messages), non-ASCII unescaped, with the Python equivalent.

Idempotency: a key made only of the session, the effect and its target
would answer a second, intended operation with the first one's result. The
key is now an operation id the server issued (the surfaceId it renders
under can serve), bound to the session, effect, target and parameters.

H1's reach is stated exactly: it refuses action events in template props
and text and call fields named event, not A2UI function calls, data
bindings or object node ids, none of which reaches a client-evaluated
property on either packaged reference (now a limit in §7 and a threat row).
Also: ids may use a boolean $case, the 4096-combination refusal, which
edits drop a binding (a profile edit, or color.primary on shadcn/ui), the
missing-id issue names the confirm label, and the release plan's status
note names actionEvents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R6Ax8qvfN3gYUC85oSGnoi
@ryandmonk
ryandmonk merged commit c25dc92 into main Oct 16, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants