FabricFabricPlatform
Composable application standard

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.

On this page