@fabricorg/platform-host
v7.2.3
Published
Canonical governed mutation host for @fabricorg/platform: durable invocation, policy, state-machine, handler, event, and adapter orchestration behind persistence ports.
Readme
@fabricorg/platform-host
The canonical host for the @fabricorg/platform mutation pipeline.
pnpm add @fabricorg/platform@^1.2.0 @fabricorg/platform-host@^6.0.0 @fabricorg/assembly@^0.3.0AI agent integration boundary
Application and vertical owners install this package. Hermes, TechFabric Harness, and other external agent
runtimes do not import Platform Host merely to call a deployed application. They connect to the
application's authenticated REST or MCP gateway, discover only the actions allowed by their
registration, and submit stable idempotent commands. The gateway derives tenant and actor identity and
calls submitAction(); an agent must never call handlers, adapters, workflow internals, or database
writes directly.
See the Platform Host 6.x agent integration guide for application wiring, execution-time authorization, the PostgreSQL transaction binder, recovery semantics, and the external gateway contract.
Vertical packages register FabricModule definitions. Host applications provide tenant authorization,
module entitlements, persistence, projections, and an optional durable dispatcher. The package owns the
ordering and lifecycle invariant:
Actor → ActionInvocation → Schema → Agent HITL → PolicyEvaluation → StateMachine → Handler → AssetEvent → AdapterInvocation → ProjectionsubmitAction() creates the durable ActionInvocation before dispatch. executeInvocation() is the
worker entry point. With no dispatcher the host executes inline, which is intended for tests and local
development only.
Applications construct a createModuleRegistry() per runtime and pass it as registry; a narrow
resolveAction function remains available for adapters. No process-wide platform registry exists. A custom extractEvents implementation can pair with
eventResultFields so its event carrier is removed from the durable invocation result. Domain events
without an explicit eventSchemaVersion inherit the action version, while host lifecycle events stay
at version 1. Emitted domain events must appear in the action's emitsEvents declaration. The
injected now clock is also passed to ordinary policy evaluation.
Production polling workers can use createStoreBackedActionDispatcher() plus
runPlatformActionWorker(). The pending invocation row is the durable queue item; workers claim
bounded batches with an atomic, renewable lease and FOR UPDATE SKIP LOCKED. Every claim advances a
fencing token. A heartbeat may renew only a lease that has not expired; after expiry, the work must be
reclaimed so its token advances. An expired worker cannot finalize after another worker takes ownership. Pass a stable
idempotencyKey to submitAction() so retries resolve
to the original tenant-scoped invocation instead of creating another mutation.
New rows bind that key to the action version, actor/authority binding, and a versioned canonical
parameter digest. Host 3 defaults to enforce, which throws IdempotencyConflictError; configure
idempotency.conflictMode as audit-only for a preflight soak. Legacy null-digest rows skip only the
parameter comparison and call onLegacyRecord; actor, authority-binding, and action-version mismatches
still reject in enforce mode. See MIGRATION-2-IDEMPOTENCY.md.
Lifecycle records use deterministic checkpoint ids. Recovered idempotent actions do not repeat an
adapter that already reached succeeded, and event, policy, and adapter writes are append-safe. A
stale action that was not declared idempotent fails terminally for manual reconciliation instead of
silently rerunning unknown side effects.
Adapters may classify outcomes as success, transient_failure, permanent_failure, timeout,
or ambiguous. The retry layer retries only transient idempotent outcomes; permanent failures,
timeouts, and ambiguous results are never retried. An ambiguous outcome — where the external effect
may or may not have been applied — is routed to reconciliation_required with durable
ExternalReconciliation evidence and an AdapterInvocationAmbiguous lifecycle event rather than
blindly retrying an unsafe effect.
Execution authorization
Submission authorization proves that a caller may create an invocation. Applications that delegate
resource-scoped authority should also configure authorizeExecution. The Host calls it with the
schema-parsed durable parameters, canonical invocation, opaque authorizationBindingId, and an
executionReason of initial, approval_resume, or recovery immediately before policies and
mutation code run.
offline_replay is the extensible evidence value for disconnected commands. Actions with execution
semantics can declare whether capture, execution, or both govern authority. A denied governed replay
returns structured reconciliation_required evidence.
For execution and both, the Host rejects an expired AuthorizationBinding before calling mutation
code and returns authorization_expired reconciliation evidence. resourceScope remains an opaque,
audit-safe scope identifier; the application-owned authorizeExecution implementation resolves and
checks it against current resource state.
InvocationProvenance carries namespaced trace and audit attributes. Durable audit attributes fail
closed when no allowlist is configured, pass through redactAuditAttributes, and remain bounded.
Trace attributes go only to the configured trace callback. Restricted outbox payloads omit provenance
by default; outbox.includeProvenance is the explicit classification-aware override.
const host = createGovernedActionHost({
store,
registry,
authorization: {
checkEntitlement,
authorize: authorizeSubmission,
authorizeExecution: async ({ invocation, parameters, executionReason }) =>
admissionStore.authorize({
admissionId: invocation.authorizationBindingId,
parameters,
executionReason,
}),
},
});When authorizeExecution is absent, the Host reuses authorize at the execution boundary. A
completed idempotent replay returns its existing result without creating or executing a new
mutation.
External gateways should persist a compact admission record bound to the tenant, registration, actor,
action, canonical parameter hash, resource scope, and expiry, then pass its opaque non-secret identifier
as authorizationBindingId to submitAction(). Credentials and signed principal proofs do not belong
in action parameters or workflow history.
Atomic mutation unit of work
Production stores can implement AtomicMutationPlatformHostStore.transactionWithEvents. The
transaction-scoped db, event sequence, event append, event listing, and invocation update methods
must all use the same database transaction.
PostgresPlatformHostStore enables this capability when constructed with a
PostgresPlatformHostTransactionProvider. The application owns that narrow binder because only the
application knows how its TDb is rebound to the transaction's SQL client.
before_adapters: handler domain writes and declared domain events commit or roll back together.after_adapters: completion events and invocation completion commit together. A finalization failure leaves the invocation recoverable; succeeded adapter checkpoints are not repeated.
Stores without this additive capability retain the legacy boundary for compatibility and must not claim atomic domain-write/event persistence.
createDurableSagaParentLifecycle() requires this capability. Each parent lifecycle transition uses a
stable lifecycleId as its causation identity, appends its audit event, and updates the parent invocation
in the same transaction. The transaction locks the parent invocation row before checking prior
evidence, so concurrent transitions serialize. Retrying the same identity and evidence is a no-op;
reusing it with contradictory evidence fails closed, and a distinct transition cannot rewrite a
terminal parent. createSagaParentActivities() supplies deterministic identities for Temporal callers.
Actions can optionally declare resultSchema. The Host validates public handler result data after
the handler and before canonical event append or adapter execution. Invalid results fail the invocation,
emit no domain/completion event, invoke no adapter, and persist only a sanitized validation failure.
Domain writes roll back only when the configured store transaction provider includes those writes.
This validates the handler result contract; it does not observe arbitrary writes through an
application-owned TDb.
Agent HITL
Hosts can inject a vertical-owned hitlEvaluator. It runs only for actorType: "agent", after schema
validation and before ordinary policies. Omitting it preserves the pre-0.4 behavior. The host owns the
durable lifecycle; the vertical continues to own the rules and risk classification.
const host = createGovernedActionHost({
store,
authorization,
hitlPolicyVersion: "gtm-rules.v7",
hitlEvaluator: async (context) => ({
route: context.actionId === "gtm.send_message" ? "needs-approval" : "auto-execute",
riskTier: context.actionId === "gtm.send_message" ? "high" : "low",
reason: "Vertical-owned prospect-touching rule",
}),
});The evaluator returns auto-execute, needs-approval, escalate, or rejected:
auto-executecontinues through policies, state validation, the handler, events, and adapters.needs-approvalandescalatepersist the route and risk evidence, clear any worker lease, and park the invocation aswaiting_for_approval. Polling workers never claim that status.rejectedterminally fails before policies and mutation code run.
An approval workflow calls resumeApprovedInvocation() instead of invoking the handler directly. The
host authorizes the approver (using authorizeApproval when provided), then atomically persists the
decision and changes waiting_for_approval to either leased running or terminal failed. This keeps
the decision in the mutation ledger and prevents an approval/worker race.
await host.resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, {
approved: true,
approverId: reviewer.id,
approverType: "natural_person",
reason: "Reviewed prospect-facing draft",
editsReference: "draft-revision-2",
});Custom stores remain source-compatible when HITL is unused. To enable HITL they must implement the
additive ApprovalPlatformHostStore capability. Both built-in stores implement it; Postgres users must
call ensureSchema() so the HITL evidence and approval-decision columns are added.
Sensitive values must not be passed as action parameters. Stage them in tenant-bound encrypted storage
and pass an opaque identifier instead; action parameters are intentionally durable audit evidence.
Hosts should additionally configure redactActionParameters as a fail-safe allowlist for actions
whose ingress is adjacent to secret material; redaction runs before the invocation row is created.
External mutation governance
Hosts can configure resolveMutationGovernance to persist an approved, provider-neutral
MutationFootprint and ExecutionPrincipal after schema validation. resolvePolicyObligations
makes required controls durable. extractExecutionAttestation converts successful adapter output
and the already-redacted adapter input into audit-safe external evidence and can satisfy named obligations. Required obligations that are
still pending or failed prevent completion.
The built-in stores persist footprints, delegation, obligations, attestations, and reconciliation
observations. Provider-specific authentication and clients do not belong here. For Databricks, use
the optional Platform integration exported by @fabric-harness/databricks.
Every newly submitted invocation also records runtimeEvidence. The host always supplies the
portable governance and host contract generations; applications should add the
deployed host package version, policy ruleset version, and provider bridge
identity. A trusted composition configuration attaches the verified assembly
digest and can resolve the promoted SDUI release that initiated each submission.
Neither identity is accepted directly from caller input. This makes an audit
record explain which composition, release, contract, and provider adapter
governed a mutation after dependencies have moved on.
Composition-bound hosts require an explicit module registry. Every registered
module must carry its published version and compiler manifestDigest; Host
compares those identities directly with the integrity-checked assembly lockfile
before accepting any submission.
const host = createGovernedActionHost({
// ...
runtimeEvidence: {
hostPackageVersion: "3.0.0",
policyRulesetVersion: "gtm-rules.v8",
providerBridge: { name: "@fabric-harness/databricks", version: "1" },
},
composition: {
assembly: assemblyLockfile,
resolveInitiatingReleaseDigest: (submission) =>
releaseRegistry.resolveTrustedDigest(submission.provenance),
},
});MemoryPlatformHostStore is for tests and local demos. Production control planes use
PostgresPlatformHostStore with Databricks Lakebase (or standard Postgres), run ensureSchema() in a
controlled deployment migration step, and hydrate projections from listEvents(). ensureSchema()
uses ordered immutable checksums, a migration ledger, and a transaction-scoped advisory lock. It
remains safe to call at startup, but deployment-owned migrations are preferred.
State-machine transition checks run inside the Host mutation unit of work. Transaction-capable
stores should expose transaction-scoped getEntityState() so the guard reads the same database
snapshot used by the handler and event append. Existing custom stores without that optional
transaction method fall back to the store-level authoritative read.
External completion
An action declaring completion: "accepted" or "long-running" may hand its work to an external
system. The adapter returns acceptedExternalOperation: { externalReference }; the Host parks the
invocation as running with no lease, emits ExternalOperationAccepted, and leaves it for the
external system to finish. The recovery worker never claims a parked invocation, and re-executing it
returns it as it stands.
await host.completeExternalInvocation(actionInvocationId, tenantId, spaceId, {
externalReference: "vendor-op-123",
outcome: "completed",
result: { receipt: "r-1" },
observedAt: new Date(),
});Matching is by reference: a completion naming a reference the invocation is not waiting on is
refused. A repeated identical completion is absorbed, identity being a digest of provider,
reference, outcome, result, error, and evidence. A contradictory one, a flipped outcome or the same
outcome with different evidence, moves the invocation to
reconciliation_required, keeps the first outcome on record, logs the second, and emits
ExternalOperationContradicted. Under an atomic store the read and write happen under one lock. The
callback's result is validated against the action's result schema and stripped of private fields.
An invocation that ended for another reason while a handoff was pending keeps its status and records
the callback as evidence. Declare completionDeadlineMs on long-running actions;
reconcileOverdueCompletions() moves overdue handoffs to reconciliation while keeping the handoff so
a late completion can still land, and the worker cycle runs that sweep before it claims.
The handoff is persisted 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. A custom store has four
obligations here, documented on ActionInvocationPatch: a patch setting pendingCompletion with
status: "running" parks the invocation and clears its lease; one setting it without a status keeps
the lease; pendingCompletion: undefined clears it; and the claim query must never claim a running
invocation without a lease. The recovery worker's guarantee of never re-running a handoff rests on
those. A callback that arrives before the invocation has parked, whether a worker still holds the lease
or an inline execution is still running the steps after the handoff, is refused with
ExternalCompletionError whose refusal is still_executing and whose retryable is true, since
settling mid-execution would report completion before the remaining steps ran; the handoff carries a
parkedAt set at finalization, and that is what the ingress checks; every other refusal
is permanent and typed the same way, so a webhook handler maps them to a retryable or a final
response without matching strings. An expired lease a dead worker left behind refuses the same way
until the claim loop reclaims the row; the overdue sweep also leaves leased rows alone, so both defer
to the worker fleet and the health snapshot's expired-lease count is where a stalled fleet shows.
The contradiction path and the overdue sweep refuse without a governance-capable store, and the
worker's sweep can be turned off with sweepOverdueCompletions: false for such a store; a refused
handoff keeps its reference on the invocation even when the reconciliation log is unavailable, and
says so in its error. Unsatisfied policy obligations after a handoff route to reconciliation rather
than failure, since a recovered handoff cannot attest what died with its worker. PostgresPlatformHostStore
migration 3 adds the pending_completion, external_completion, and completion_due_at columns and
a partial index on the due instant the overdue sweep uses; the due instant is a real column because a
cast in an index expression is not immutable and PostgreSQL refuses it.
Tenant isolation
tenantIsolationChecks() and runTenantIsolationChecks(subject) prove that scope is a key on every
read, write, claim, and completion: an invocation is invisible, inert, and uncompletable under another
tenant's scope; the same idempotency key in two tenants is two invocations; event streams carry only
their own tenant's events; a tenant-scoped worker claims nothing of another's; and a governed read
the subject lends shows the other tenant none of ours. The subject supplies a host, a recoverable
store, a registered action, and two scopes in different tenants.
Runtime observability and health
Platform Host exposes vendor-neutral lifecycle telemetry through the optional telemetry sink on
createGovernedActionHost(), runPlatformActionWorkerCycle(), and runOutboxRelayCycle(). A sink can
be a callback or { record }; it receives stable event and metric names from
PLATFORM_HOST_METRIC_NAMES. Telemetry is best effort and never changes mutation or checkpoint
behavior.
import {
getPlatformHostHealthSnapshot,
PLATFORM_HOST_METRIC_NAMES,
} from "@fabricorg/platform-host";
const health = await getPlatformHostHealthSnapshot({
store,
tenantId,
spaceId,
workers: [{ lastHeartbeatAt, staleAfterMs: 30_000 }],
telemetry: (record) => { metrics.record(record); },
});The snapshot contains only scoped aggregate counts for invocation backlog, running and expired
leases, approval waits, reconciliation-required work, outbox backlog, expired leases, and dead
letters. It never returns invocation parameters, results, actor data, payloads, or errors. An
unavailable health source is reported as unhealthy and not ready; stale workers and operational
lease/dead-letter/reconciliation findings are represented by readiness reason codes.
Dispatcher submission failures leave the durable invocation pending. Re-submit the same
idempotency key to retry the stable workflow ID, or use a store-backed worker. The Host never marks
a logical mutation failed merely because a queue or workflow service was temporarily unavailable.
Saga actions require a durable workflow dispatcher and a worker-side SagaImplementation mapping.
Calling executeInvocation() for a saga through the atomic Host path fails closed. Interrupted
inline actions that are not declared idempotent also fail closed and require manual reconciliation.
CI certifies concurrent migrations, command idempotency, and schema behavior against PostgreSQL 16. Lakebase failover, backup, and restore remain adopter certification gates and are not implied by the PostgreSQL CI result.
Durable outbox egress
Configure outbox on createGovernedActionHost() only with an OutboxPlatformHostStore.
The Host then uses appendEventWithOutbox so the canonical event and delivery record share
the proven event transaction. runOutboxRelayCycle() uses renewable fenced leases, a bounded
publisher deadline, retry, and dead-letter handling. Delivery is at least once: consumers deduplicate on immutable
eventId. Payload classification is metadata, not a redaction mechanism; event payloads must
already be audit-safe.
Adapter and compliance lifecycle events remain canonical but are excluded from bus egress by default.
Use shouldPublish to opt them in only when the application has made their payloads safe for that bus.
For deterministic local tests, MemoryPlatformHostStore enables governed Host outbox egress only
when its constructor receives a MemoryPlatformHostTransactionProvider with domain snapshot and
restore functions. Without that explicit rollback seam, transactionalOutbox is false and Host
construction rejects outbox configuration. PostgreSQL remains the production implementation.
