TechFabricTechFabricPlatform
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.

Accepted actions complete later

A render job, a financing decision, an order acknowledgment from an ERP: the adapter hands the work to a system that will finish it in its own time and call back. An action that does this declares completion: "accepted" or "long-running", and its adapter returns the reference the external system was given:

return { success: true, acceptedExternalOperation: { externalReference: job.id } };

The host records the handoff the moment it is accepted, before the adapter step is marked done, so a worker that dies between the two leaves the handoff and not the marker: on re-execution the step is skipped and the handoff kept. Once the invocation finalizes it is running with no lease. It is not stuck, it is somebody else's turn, so the recovery worker leaves it alone and a direct re-execution returns it as it stands. Domain events go out at that point, because the handler's writes committed; what is pending is the external outcome. The callback reaches the host through one method:

await host.completeExternalInvocation(actionInvocationId, tenantId, spaceId, {
  externalReference: job.id,
  outcome: "completed",
  result: { renderUrl },
  observedAt: new Date(),
});

Matching is by reference, never by guesswork. A completion naming a reference the invocation is not waiting on is refused, because completing the wrong invocation is worse than dropping a callback. A repeated identical completion is absorbed, since callbacks arrive at least once; identical means the same provider, reference, outcome, result, error, and evidence, with the observation time left out because a redelivery carries a new one. A contradictory one, success after failure or the reverse, or the same outcome with different evidence, moves the invocation to reconciliation_required, leaves the first outcome on record, logs the second for reconciliation, and emits ExternalOperationContradicted so consumers that already heard the first outcome learn it was retracted. Where the store can lock, the read and the write happen under one lock, so two callbacks racing cannot both find nothing recorded. The result a callback carries is validated against the action's result schema and stripped of private fields, as the handler's own result is.

An invocation that ended for another reason while a handoff was pending, because a later adapter step failed or a contract was violated, keeps its status. The callback is still recorded and its event still emitted: it is evidence of what happened out there, not a verdict on the invocation.

A refused completion throws ExternalCompletionError, and the webhook handler that receives the callback has to answer the sender by it. One refusal is retryable: still_executing, when the callback arrived before the invocation parked, because a worker still held it, an inline execution was still running the steps after the handoff, or the invocation was dispatched and not yet run, so the same callback is accepted once it parks. Answer that one with a status the sender will retry. Every other refusal is permanent, an invocation that does not exist, a reference or provider the invocation is not waiting on, an invocation that ended without handing off, a result the action's schema rejects, and a retry gets the same answer, so answer those with a status the sender will not retry. The class carries refusal and retryable, so the mapping is a lookup and never a string match:

try {
  await host.completeExternalInvocation(id, tenantId, spaceId, completion);
  return respond(200);
} catch (error) {
  if (error instanceof ExternalCompletionError) return respond(error.retryable ? 503 : 409);
  throw error;
}

A worker whose lease has expired but was never reclaimed leaves its invocation refusing with still_executing until a worker's claim loop picks it up. That is a fleet that is down, and the health snapshot's expired-lease count is where it shows.

Declare completionDeadlineMs on a long-running action. The worker sweeps handoffs past it into reconciliation_required so a callback that never comes is a finding rather than a silent wait, and the handoff stays recorded so a late completion can still land. Without a deadline nothing sweeps; an immediate action cannot declare one, since nothing external completes it.

An adapter that hands off under an immediate contract, or hands off twice in one invocation, is routed to reconciliation with the reference in hand rather than failed cleanly, since the external effect is already in flight. Multi-step sagas across several systems remain the Temporal binding's job; this ingress is for one operation finishing elsewhere.

Certifying the contracts

The execution contract is optional on the base action type, and sensitivity is optional inside it, because making either mandatory would break every manifest that predates them. A deployment that wants the guarantee applies a profile at certification instead. STRICT_EXECUTION_CONTRACT_PROFILE fails any action that declares no contract, any action that declares no sensitivity, and any action that declares accepted or long-running completion without a completionDeadlineMs, since without a deadline nothing sweeps a callback that never comes.

STRICT_EXECUTION_CONTRACT_PROFILE.assert(proposalModule);
compileManifestBundle(bundle, { conformanceAdapters: [STRICT_EXECUTION_CONTRACT_PROFILE.adapter] });

executionContractProfile() builds a relaxed variant under its own id for a pilot that is not there yet. Either way the rule is a seam the deployment applies, and the platform's base types stay where existing manifests can still load.

On this page