Reads and mutations
The path from a screen to a governed host, and why the client never names a capability.
A rendered screen has to read data and submit actions. Both go through a gateway, and neither lets the client name what it wants directly.
The client names a tuple, never a capability
await session.invoke(fragmentId, "submit", parameters);
await session.read(fragmentId, "$data");The client sends (planId, fragmentId, eventName). It never sends a capability:// reference,
because a session that accepted one would walk past the grants promotion just established. The server
resolves the tuple against the plan it issued.
This is the same failure shape as a reference smuggled into an array prop: a position where a capability reference reaches the system without passing the check. Watch for it whenever you add an API.
What the server re-checks
For every call, regardless of what the client believes:
- The plan exists and has not expired
- The plan's scope matches the trusted request scope — tenant, space, actor and actor kind, so a stolen plan id cannot be replayed by a different actor
- The tuple is one the plan actually granted
- The resolved binding or intent is within that fragment's effective grants
- The plan belongs to this application's assembly
- The resolved action version matches the registered one
The render plan handed to the client carries no capability references at all; they are stripped, and a plan that still exposes one is refused before it leaves the server.
Why the gateway exists
PlatformHost and ProjectionHost cannot run in a browser — both import Node built-ins. The gateway
is the seam, and it is what keeps browser code away from host internals it could never reach anyway.
interface ExperienceGateway {
invoke(input): Promise<SubmitActionResult>;
read(input): Promise<{ data: unknown; evidence: … }>;
}Supply a server-side direct adapter or an authenticated HTTP adapter. Both sit behind the same interface and both re-check on every call.
experienceGatewayChecks certifies that mutations reach PlatformHost.submitAction and reads reach
ProjectionHost.project, by observing the hosts rather than inspecting the result. A replacement that
fabricates an invocation id and queries the database directly satisfies every other observable
contract.
Render states are part of the contract
Loading, empty, denied and failure are contract, not theme. A denied read has to render as denied rather than as empty, or the interface quietly lies about what the viewer was refused. Empty means there is nothing; denied means there is something and it is not yours. Collapsing them is a correctness bug that looks like a design choice.