@fabricorg/platform-host 7.2.2 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 7.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - adf8ff1: Operators can now requeue a dead-lettered outbox record without hand-written SQL. Before this, a record that exhausted the relay's `maxAttempts` stayed in `dead_letter`, and the only way back was an `UPDATE` that reset its attempt count, recorded no actor or reason, and could race another operator. ADR 0018 makes a governed requeue a prerequisite for adopting webhook delivery, because webhook fan-out depends on the relay and can exhaust it during a long receiver outage.
8
+
9
+ - `host.requeueOutboxDeadLetter({ outboxId, tenantId, spaceId, actorId, actorType, reason, availableAt? })` reads the record within the tenant and space and calls the new `PlatformHostAuthorization.authorizeOutboxRequeue` with the record's event type, classification, status and attempt count. The hook is called on the authorization object, so a class-based authorizer keeps `this`. The host then calls the store's compare-and-set with the lease token it read. It returns `{ status: "requeued", record, requeue }` or `{ status: "refused", refusal, currentStatus?, message }`, where `refusal` is `not_authorized`, `not_found`, `not_dead_letter` or `stale`.
10
+ - An authorization applies only to the dead letter it was made for. Every relay claim increments the record's lease token and a requeue never lowers it. A record that was requeued by someone else, retried and dead-lettered again while the authorizer ran therefore carries a different token, and the requeue is refused as `stale` instead of resetting the new dead letter's budget.
11
+ - Recovery semantics: a requeued record is `pending` with `attemptCount` 0, `availableAt` set to the host clock or the caller's later time, and no lease owner. Its lease token is kept, so a relay holding an older lease cannot checkpoint it. It keeps `lastError`. The relay republishes it under the same `eventId`, so delivery stays at least once and consumers keep deduplicating on that ID. The attempt budget starts over, so a record can be requeued and exhausted again.
12
+ - Audit evidence: the store writes the transition and one `OutboxRequeueRecord` in the same atomic step. The record holds the actor, reason, time, new availability, and the attempt count, last error, availability and lease token it replaced. Neither store has a method that updates or deletes evidence rows. `listOutboxRequeues({ tenantId, spaceId, outboxId? })` reads them. The memory store keeps the evidence in a non-public field and returns copies with their own Date objects, so editing returned evidence does not change stored history. This is not an action invocation: it writes no action invocation record and no policy decision record.
13
+ - Fail-closed behaviour: without `authorizeOutboxRequeue`, every requeue is refused as `not_authorized`. An authorizer that throws stops the requeue before anything is written, and any answer other than `true` is a deny. A refused actor gets `not_authorized` whether or not the record exists. A record under another tenant or space is `not_found`. A pending or published record is `not_dead_letter`. Of two concurrent requeues, one wins and the other gets `not_dead_letter`. A blank actor, scope or reason, a reason over 1,000 characters, or an invalid `availableAt` throws before the store is read.
14
+ - Store capability: the methods live on the new `OutboxRequeuePlatformHostStore` (`getOutboxRecord`, `requeueOutboxDeadLetter`, `listOutboxRequeues`), which extends `OutboxPlatformHostStore`. A custom outbox store written before this still satisfies `OutboxPlatformHostStore`; the host operation throws when the store lacks the new methods. `MemoryPlatformHostStore` and `PostgresPlatformHostStore` implement it, and one contract suite runs over both.
15
+ - PostgreSQL: migration 5 (`outbox_requeue`) creates `fabric_platform.event_outbox_requeues` with an index on tenant, space, record and time. It leaves `event_outbox` unchanged. The requeue is one statement that locks the row, rechecks `status='dead_letter'` and the expected lease token under the lock, updates the row and inserts the evidence. It was verified against PostgreSQL 17 locally, including a test where another transaction holds the row lock.
16
+ - Telemetry: `outbox.requeued` (`fabric.platform.outbox.requeued`) and `outbox.requeue_refused` (`fabric.platform.outbox.requeue_refused`) are added to `PLATFORM_HOST_METRIC_NAMES` and the telemetry event names.
17
+ - Compatibility: `GovernedActionHost` is unchanged, so an existing hand-written implementation still type-checks. The operation is on the new `OutboxRequeueHost` interface, and `createGovernedActionHost()` now declares the return type `GovernedActionHost & OutboxRequeueHost`. `PlatformHostAuthorization` gains only the optional `authorizeOutboxRequeue`. `cloneOutboxRecord` now copies the record's Date fields instead of sharing them, so memory-store outbox reads no longer hand out the stored Dates. The event payload and provenance remain shared references to the stored objects, as before.
18
+
19
+ - 8c7d2a1: Make policy resolution invocation-aware and record which policy snapshot was used.
20
+
21
+ - `@fabricorg/platform-host`: `resolvePolicies` now receives the new exported `ResolvePoliciesInput`, which extends `ActionExecutionAuthorizationInput` with `declaredPolicyIds`. It adds `actionInvocationId`, `invocation`, `executionReason`, and `parameters`. The `parameters` are the schema-parsed durable (post-redaction) parameters, the same value passed to `authorizeExecution`, not the caller's original input. Two invocations by the same actor for the same action can now be told apart. Existing resolvers keep working because the input only gained fields.
22
+ - `@fabricorg/platform`: new `PolicyAuthorityReference` (`snapshotId`, optional `contentHash`), an opaque consumer-defined reference that Platform does not interpret. It is an optional `authority` on `RuntimePolicyDefinition` and on `PolicyDispatchEvidence`. `evaluatePolicyDefinition` copies a definition's `authority` into the outcome's `dispatchEvidence` for code, data, and hybrid policies, including missing-evaluator blocks. Hosts persist it with the policy evaluation. Definitions without `authority` produce unchanged evidence.
23
+ - Host keeps the first persisted policy evaluation per invocation and policy, so a resolver must return the same snapshot for an `actionInvocationId` on every attempt (`initial`, `recovery`, `approval_resume`).
24
+
25
+ - 81bba1a: Outbox envelopes can now carry actor identity. It is opt-in, and the default is unchanged.
26
+
27
+ - **Default unchanged.** Without the new hook, `EnterpriseEventEnvelope` has no `actorId` or `actorType` keys, for every payload classification including `public` and `internal`. Existing bus publishers receive the same envelope as before. This differs from `includeProvenance`, whose default includes provenance for non-restricted events.
28
+ - **Opting in.** Add `outbox.includeActor(event, classification)` to `createGovernedActionHost()`. Only a return value of exactly `true` adds the canonical event's `actorId` and `actorType` to the envelope. A direct `appendEventWithOutbox(event, metadata)` caller opts in with `metadata.includeActor: true`. `toEnterpriseEventEnvelope()` follows the same rule.
29
+ - **Disclosure.** An included actor reaches every sink of every relay reading that outbox: buses, webhook fan-out, and anything else wired to `runOutboxRelayCycle()`. Opt in only when every one of those sinks may hold actor identifiers, or wrap the publisher so it strips `actorId`/`actorType` before handing the envelope to sinks that must not see them. Restricted events stay actorless unless your hook returns `true` for them.
30
+ - **Suppressed vs legacy.** Each outbox record now stores the decision as `OutboxRecord.actorDisclosure`: `"included"` or `"suppressed"`. `runOutboxRelayCycle()` passes it to the publisher as `context.actorDisclosure` (new exported `EnterpriseEventPublishContext`). A record written before this release has no stored decision, and the relay reports it as `"legacy"`. `suppressed` means the application chose not to share the actor. `legacy` means the record predates the decision. The memory and PostgreSQL stores read the actor back only from `included` records, so neither envelope carries an actor, and replaying the outbox does not add one to a legacy record. A consumer that needs the actor for a legacy record must read it from the canonical event.
31
+ - **PostgreSQL schema.** `ensureSchema()` applies migration 4 (`outbox_actor_disclosure`). It adds a nullable `event_outbox.actor_disclosure` column with no default, so rows written before the migration stay `NULL` and read as legacy. It also adds a `NOT VALID` check constraint, so existing rows are not scanned. The check accepts `NULL`: every non-`NULL` value written from now on must be `included` or `suppressed`. Writers on an older Host version keep inserting `NULL`, which reads as legacy. Outbox hydration reads `actorId`/`actorType` only from rows whose column is `included`.
32
+ - **Rollout order.** (1) Run `ensureSchema()` so migration 4 is applied; writers on this version insert into the new column and fail without it. (2) Upgrade every relay that reads the shared outbox. (3) Only then enable `includeActor`. A relay on an older Host version hydrates outbox rows without the actor fields and publishes with no `actorDisclosure` in the context, then acknowledges the record. A consumer behind that relay receives an actorless envelope that it reads as legacy, although the row says `included`. Because the record is then marked published, the actor is not delivered again for that event.
33
+ - **Custom outbox stores.** A third-party `OutboxPlatformHostStore` should persist `outboxActorDisclosure(metadata)` (newly exported) as the record's `actorDisclosure`. Until it does, the relay reports its records as `legacy`.
34
+ - `publish(event, context?)` keeps its signature. The context type gained the optional `actorDisclosure`, so existing publishers and callers compile unchanged.
35
+
36
+ - 8e631b0: Actions can now refuse a submission before Host records anything for it. Before this, `submitAction` persisted an invocation's parameters before anything validated them: the action's schema ran only at execution. An action whose parameters must be checked against trusted context, such as a secret reference the tenant's secret store must hold, could refuse at execution, but the refused value was already in the invocation record. `redactActionParameters` could strip values, but it is synchronous, gets no tenant or actor, and is an option the application has to remember to wire.
37
+
38
+ - **`@fabricorg/platform`: `ActionDefinition.admitSubmission`** (optional). It is an async check that receives `ActionAdmissionInput`: `actionId`, `tenantId`, `spaceId`, `actorId`, `actorType`, the submitted `parameters` and a `signal` typed as the new structural `ActionAdmissionSignal` (so the `actions` entry still compiles without DOM or Node type libraries; Host passes a real `AbortSignal`). It answers `ActionAdmissionDecision`, which is `{ admitted: true }` or `{ admitted: false, code }`. The code must be fixed text chosen by the action, never a submitted value. `ActionContext.admittedAt` (optional) is how a handler learns that the host admitted its invocation. Module and action registration now reject an `admitSubmission` that is not a function. A host that does not know the field ignores it, so a handler that depends on admission should refuse without `ctx.admittedAt`.
39
+ - **`@fabricorg/platform-host`: ordering.** `submitAction` runs the check on every submission of an action that declares it: inline, dispatched, offline replays, and re-submissions under an existing idempotency key. It runs after entitlement and `authorize`, so an unauthorized submitter never reaches it. It is the last await before `createActionInvocation`: for such an action, provenance normalization, `composition.resolveInitiatingReleaseDigest` and runtime evidence run before it, and redaction, the parameter digest and the authorization-binding check after it. Actions without the check keep the previous order (redaction, digest and binding check before provenance normalization and the composition callback). The check gets the submission's trusted scope and actor, never scope taken from the parameters, and a copy of the parameters made for it alone. For an action with the check, Host persists its own copy, taken when `submitAction` was called, so neither the caller nor the check holds a reference to what is stored (tests "the persisted parameters are the copy that was checked …" and "a parameter getter is read exactly once …" in `tests/submission-admission.test.ts`).
40
+ - **`@fabricorg/platform-host`: one snapshot of the submission's identity.** `submitAction` now reads the scalar fields of the caller's `SubmitActionInput` (`actionId`, `tenantId`, `spaceId`, `actorId`, `actorType`, `correlationId`, `causationId`, `executionReason`, `idempotencyKey`, `authorizationBindingId`) once, before its first await, and every later step uses those values (`snapshotSubmission`, `src/submission-snapshot.ts`). Before this, Host reread the caller's object after awaiting `checkEntitlement`, `authorize` and the composition callback, so a caller that changed `tenantId`, `actorId` or `actionId` during those awaits could get an invocation persisted and executed under values that were never authorized. This affects every action (tests "changing the caller's input while admission awaits …", "… while authorize awaits …" and "the identity snapshot still applies …"). A scalar field that holds an object, a function or a symbol, a read of one that throws, or an input that is not an object makes `submitAction` throw an `Error` with a fixed message and record nothing; in each case the field does not yield a value of its declared type.
41
+ - **Parameters, provenance and binding.** For an action that declares `admitSubmission`, Host reads each of the three once, before its first await, and keeps its own copies. Parameters are copied as JSON data: `null`, strings, booleans, finite numbers, dense arrays, and objects whose prototype is `Object.prototype` or `null`; each own enumerable string-keyed property is read once, `undefined` values are left out, and symbol keys and non-enumerable properties are skipped. Anything else is refused as `invalid_parameters` before the check runs: a Proxy, a function, a symbol, a bigint, `NaN` or `Infinity`, a Date, a Map, a class instance, an ArrayBuffer, a SharedArrayBuffer, a typed array or DataView (so no caller-held view can write to a checked value), an array hole or `undefined` element, a cycle, or a getter that throws. Provenance and binding are copied field by field, each declared field read once from an own or inherited property, so class instances are accepted and undeclared fields are not kept. For every other action nothing about these three fields changes: Host reads them from the caller's input at the same points and as often as before, and `redactActionParameters`, the digest, the store, the handler and `provenance.onTrace` get what they got before (tests under "an action without admitSubmission: everything but the identity snapshot is as before"). The composition callback, `composition.resolveInitiatingReleaseDigest`, now gets a view of the submission instead of the caller's object, for every action: its scalar fields are the snapshot's, so the release evidence it resolves is for the tenant, actor and action Host persists (before, a caller that changed `tenantId` while `authorize` awaited could have the callback resolve another tenant's release digest, which Host then persisted under the original tenant). For an action without the check, the view's `parameters`, `provenance` and `authorizationBinding` read and assign the caller's input, so the callback reaches the same objects as before; other properties of the caller's object are not on the view (test "scope, actor and action changed while authorize awaits do not reach the composition callback …").
42
+ - **Fail-closed behaviour.** Only `{ admitted: true }` admits, read as an own data property. `submitAction` throws the new `ActionAdmissionError` (`code: "ACTION_ADMISSION_REFUSED"`, `actionId`, `refusal`, and `refusalCode` for a refusal) in each of these cases: a refusal whose code matches `^[a-z][A-Za-z0-9_.:-]{0,127}$` (`refused`); a throw or a non-function check (`failed`); an answer that does not settle within the new `admissionTimeoutMs` option, default 10 000 (`timed_out`); any other answer (`invalid_decision`); and parameters that are not JSON data (`invalid_parameters`). The deadline is judged when the answer settles, on the monotonic clock (`performance.now()`), not by a race with a timer. An answer or throw that settles `admissionTimeoutMs` or more after the call is `timed_out` even when its handler runs before the timer, as when the check blocked the event loop past the deadline (test "an answer that settles after admissionTimeoutMs is refused even when the check blocked the event loop …"). The timer only ends the wait on a promise that never settles. Every `timed_out` refusal aborts the check's signal before `submitAction` throws. Host writes nothing for a refused submission: no invocation, no idempotency key (the key stays free), no event, no policy record and no error. The error message names the action, the refusal and the check's code only. It never contains a submitted value or anything the check threw. `admissionTimeoutMs` must be a whole number from 1 to 2 147 483 647, the largest delay `setTimeout` honours. Otherwise `createGovernedActionHost` throws: NaN, Infinity, 0, negatives, fractions and 2^31 are refused.
43
+ - **Durable evidence and execution.** An admitted invocation records `ActionInvocationRecord.admittedAt`, which is set on creation and never patched. Host passes it to the handler as `ActionContext.admittedAt`. Host refuses to execute an invocation of an action that declares `admitSubmission` when its record has no `admittedAt`, whether inline, from the worker or after an approval. The invocation fails with a fixed message and the handler does not run. Such a record comes from an older Host, a direct store write, or a custom store that does not persist `admittedAt`. Custom `PlatformHostStore` implementations should store and return `admittedAt`. Until they do, actions that declare `admitSubmission` fail at execution, and nothing else changes.
44
+ - **PostgreSQL.** `ensureSchema()` applies migration 6 (`invocation_admission`), which adds the nullable `action_invocations.admitted_at timestamptz` column with no default. Existing rows and rows of actions without the check stay `NULL`.
45
+ - **Rollout order.** The PostgreSQL store names `admitted_at` only when it inserts an admitted invocation, and every invocation read selects `*` and treats a missing column as no `admittedAt`. So actions without `admitSubmission` are submitted, read back and executed on a schema without migration 6, as before (live test "without migration 6, an action without admitSubmission is submitted …"). Before submitting an action that declares `admitSubmission`: (1) run `ensureSchema()`; without migration 6 such a submission fails with an error naming the migration and writes nothing. (2) Upgrade every Host process, workers included. An older worker executes such an invocation without checking `admittedAt`, and an older submitter persists its parameters unchecked. A handler that requires `ctx.admittedAt` still refuses to act under an older Host.
46
+ - **Telemetry.** `invocation.admission_refused` (`fabric.platform.invocation.admission_refused`) is emitted with the `refusal` attribute. A refused submission emits no `invocation.submitted`.
47
+ - **Compatibility.** Every addition is optional. Actions without `admitSubmission` run no check, record no `admittedAt`, get no new parameter refusal and see their parameters, provenance and binding handled as before; the one change for them is that the scalar fields are read once at submission, so a change the caller makes to one of them while `submitAction` awaits is no longer read, and a scalar field that does not yield a value of its declared type is refused with the fixed message above. Rejecting non-JSON parameters for every action would have refused Dates, Maps and class instances that existing callers may pass, a breaking change this minor does not make. On PostgreSQL, only the insert of an admitted invocation names the new `admitted_at` column, so migration 6 is needed only before the first action that declares `admitSubmission` is submitted, as the rollout order above says. `redactActionParameters` stays defense in depth. For an action with the check, it runs on Host's copy after admission.
48
+
49
+ ## 7.2.3
50
+
51
+ ### Patch Changes
52
+
53
+ - fd1f88f: Publish the current packaged documentation, product names and resolved workspace dependency minimums under new patch versions. The release audit found retained package versions whose consumer-visible metadata differs from their registry tarballs. Runtime source is unchanged apart from regenerated generator version stamps.
54
+
55
+ The release guard now retains nested manifest fields while canonicalizing key order, so dependency, peer and export changes cannot disappear from the comparison. Development-only dependencies remain excluded.
56
+
3
57
  ## 7.2.2
4
58
 
5
59
  ### Patch Changes
package/README.md CHANGED
@@ -8,7 +8,7 @@ pnpm add @fabricorg/platform@^1.2.0 @fabricorg/platform-host@^6.0.0 @fabricorg/a
8
8
 
9
9
  ## AI agent integration boundary
10
10
 
11
- Application and vertical owners install this package. Hermes, Fabric Harness, and other external agent
11
+ Application and vertical owners install this package. Hermes, TechFabric Harness, and other external agent
12
12
  runtimes do not import Platform Host merely to call a deployed application. They connect to the
13
13
  application's authenticated REST or MCP gateway, discover only the actions allowed by their
14
14
  registration, and submit stable idempotent commands. The gateway derives tenant and actor identity and
@@ -16,7 +16,7 @@ calls `submitAction()`; an agent must never call handlers, adapters, workflow in
16
16
  writes directly.
17
17
 
18
18
  See the [Platform Host 6.x agent integration
19
- guide](https://platform.fabric.pro/docs/platform/reference/platform-host) for application wiring,
19
+ guide](https://platform.techfabric.com/docs/platform/reference/platform-host) for application wiring,
20
20
  execution-time authorization, the PostgreSQL transaction binder, recovery semantics, and the external
21
21
  gateway contract.
22
22
 
@@ -73,6 +73,17 @@ schema-parsed durable parameters, canonical invocation, opaque `authorizationBin
73
73
  `executionReason` of `initial`, `approval_resume`, or `recovery` immediately before policies and
74
74
  mutation code run.
75
75
 
76
+ `resolvePolicies` receives the same trusted execution input (`ResolvePoliciesInput`): `actionInvocationId`,
77
+ the canonical `invocation`, `executionReason`, and the schema-parsed durable `parameters` (after
78
+ `redactActionParameters`, not the caller's original values), plus `declaredPolicyIds`. Two invocations
79
+ by the same actor for the same action are therefore distinguishable, so a resolver can return the
80
+ policy snapshot pinned to that invocation. A returned `RuntimePolicyDefinition` may set
81
+ `authority: { snapshotId, contentHash? }`; Platform copies it into the persisted policy outcome's
82
+ `dispatchEvidence.authority` without interpreting it. Host keeps the first persisted evaluation per
83
+ invocation and policy, so a resolver must return the same snapshot for an `actionInvocationId` on
84
+ every attempt, including `recovery` and `approval_resume`. Consumers on an older `@fabricorg/platform`
85
+ that lacks `PolicyAuthorityReference` should upgrade before setting `authority`.
86
+
76
87
  `offline_replay` is the extensible evidence value for disconnected commands. Actions with execution
77
88
  semantics can declare whether capture, execution, or both govern authority. A denied governed replay
78
89
  returns structured `reconciliation_required` evidence.
@@ -85,7 +96,9 @@ checks it against current resource state.
85
96
  `InvocationProvenance` carries namespaced trace and audit attributes. Durable audit attributes fail
86
97
  closed when no allowlist is configured, pass through `redactAuditAttributes`, and remain bounded.
87
98
  Trace attributes go only to the configured trace callback. Restricted outbox payloads omit provenance
88
- by default; `outbox.includeProvenance` is the explicit classification-aware override.
99
+ by default; `outbox.includeProvenance` is the explicit classification-aware override. Outbox envelopes
100
+ leave out the actor for every classification unless `outbox.includeActor` opts in (see
101
+ [Durable outbox egress](#durable-outbox-egress)).
89
102
 
90
103
  ```ts
91
104
  const host = createGovernedActionHost({
@@ -194,6 +207,122 @@ and pass an opaque identifier instead; action parameters are intentionally durab
194
207
  Hosts should additionally configure `redactActionParameters` as a fail-safe allowlist for actions
195
208
  whose ingress is adjacent to secret material; redaction runs before the invocation row is created.
196
209
 
210
+ ## Submission snapshot
211
+
212
+ `submitAction` reads the scalar fields of the caller's `SubmitActionInput` once, before its first
213
+ await (`snapshotSubmission`, `src/submission-snapshot.ts`, called first in `submitAction`,
214
+ `src/host.ts`): `actionId`, `tenantId`, `spaceId`, `actorId`, `actorType`, `correlationId`,
215
+ `causationId`, `executionReason`, `idempotencyKey` and `authorizationBindingId`. Every later step
216
+ uses those values, and every hook, callback, store and dispatcher call Host makes for the submission
217
+ is given those values: entitlement, `authorize`, the composition callback, the admission check,
218
+ redaction, the binding check, telemetry, `createActionInvocation`, the idempotency comparison and
219
+ dispatch (test "scope, actor and action changed while authorize awaits do not reach the
220
+ composition callback …"). So a caller that changes one of them on its object while entitlement,
221
+ `authorize`, the composition callback or an admission check awaits does not change the action,
222
+ scope, actor, ids or keys Host authorizes, persists or executes. This holds for every action (tests
223
+ "changing the caller's input while admission awaits …", "… while authorize awaits …" and "the
224
+ identity snapshot still applies …" in `tests/submission-admission.test.ts`). A scalar field that
225
+ holds an object, a function or a symbol, a read that throws, or an input that is not an object makes
226
+ `submitAction` throw an `Error` with a fixed message and record nothing. In each case the field read
227
+ does not yield a value of its declared type.
228
+
229
+ What Host does with `parameters`, `provenance` and `authorizationBinding` depends on the action:
230
+
231
+ - **An action that declares `admitSubmission`** gets Host's own copies of all three, also taken
232
+ before the first await (`privateSubmission`). Each field is read once. The parameters are copied
233
+ as JSON data: `null`, strings, booleans, finite numbers, dense arrays, and objects whose prototype
234
+ is `Object.prototype` or `null`. Each own enumerable string-keyed property is read once, so a
235
+ getter runs once. A property whose value is `undefined` is left out, and symbol keys and
236
+ non-enumerable properties are skipped. Parameters that are not JSON data are refused as
237
+ `invalid_parameters` (next section): a Proxy, a function, a symbol, a bigint, `NaN` or `Infinity`,
238
+ a Date, a Map, a class instance, an ArrayBuffer, a SharedArrayBuffer, a typed array or DataView, an
239
+ array hole or `undefined` element, a cycle, or a getter that throws. `provenance` and
240
+ `authorizationBinding` are copied field by field: each declared field is read once, from an own or
241
+ inherited property, so a class instance is accepted, and undeclared fields are not copied. Neither
242
+ the caller, the admission check nor the composition callback holds a reference to the parameters,
243
+ provenance or binding Host persists for such an action; `redactActionParameters` gets Host's copy
244
+ (tests "the persisted parameters are the copy that was checked …", "a parameter getter is read
245
+ exactly once …" and "class-instance provenance and binding are read field by field once …").
246
+ - **Any other action** is handled as it was before the snapshot existed. Host does not copy these
247
+ three fields. It reads them from the caller's input at the same points, and as often, as it did
248
+ before. `redactActionParameters`, the parameter digest, the store and the handler get the caller's
249
+ parameters object, and `provenance.onTrace` gets what it got before. Redaction, the digest and the
250
+ binding check still run before provenance normalization and the composition callback. The
251
+ callback receives a view of the submission rather than the caller's object: its scalar fields
252
+ are the snapshot's, and its `parameters`, `provenance` and `authorizationBinding` read and assign
253
+ the caller's input (`liveSubmissionView`), so it reaches the same objects with the same reads as
254
+ before. So for these actions a change the caller makes to its parameters,
255
+ provenance or binding while Host awaits is seen exactly as before; only the scalar fields above are
256
+ fixed at submission (the tests under "an action without admitSubmission: everything but the
257
+ identity snapshot is as before" and "parameters, provenance and binding are read where they were
258
+ read before …").
259
+
260
+ ## Submission admission
261
+
262
+ An action can declare `admitSubmission` (Platform 1.7.0) when its parameters must be checked against
263
+ trusted context before they may be persisted at all, for example a secret reference the tenant's
264
+ secret store must hold. `submitAction` runs the check on every submission of that action, including
265
+ an offline replay and a re-submission under an existing idempotency key:
266
+
267
+ 1. after entitlement and `authorize`, so an unauthorized submitter never reaches it;
268
+ 2. with the snapshot's `tenantId`, `spaceId`, `actorId` and `actorType`, never scope taken from
269
+ the parameters, a copy of the snapshot's parameters made for the check alone, and an
270
+ `AbortSignal`;
271
+ 3. as the last await before `createActionInvocation`. Redaction, the parameter digest, the
272
+ authorization-binding check and the idempotency lookup all come after it. They run on the
273
+ snapshot's own copy, which is what Host persists. The check never holds a reference to that
274
+ copy, and neither does the caller. So the value persisted is the value checked, unless
275
+ `redactActionParameters` changes it (test "the persisted parameters are the copy that was
276
+ checked …").
277
+
278
+ Only JSON-data parameters can be admitted. Anything else is refused as `invalid_parameters`
279
+ directly after `authorize`, before the composition callback and the check run. A SharedArrayBuffer or a view over one is refused this way,
280
+ since the caller could still write to its memory (test "a parameter backed by a SharedArrayBuffer …").
281
+
282
+ Only `{ admitted: true }`, read as an own data property, admits. Each of these makes
283
+ `submitAction` throw `ActionAdmissionError` (`code: "ACTION_ADMISSION_REFUSED"`, `refusal`, and
284
+ `refusalCode` for `refused`):
285
+
286
+ - `{ admitted: false, code }` with a code matching `^[a-z][A-Za-z0-9_.:-]{0,127}$` (`refused`);
287
+ - a throw, or a check that is not a function (`failed`);
288
+ - an answer that did not settle within `admissionTimeoutMs` (`timed_out`);
289
+ - any other answer (`invalid_decision`);
290
+ - parameters that are not JSON data (`invalid_parameters`).
291
+
292
+ Host writes nothing for a refused submission: no invocation, idempotency key, event, policy record
293
+ or error, so the key stays free. The message names the action, the refusal and the check's code
294
+ only. Host emits `invocation.admission_refused` telemetry with the refusal.
295
+
296
+ The deadline is judged when the check's answer settles, not by which of the answer and a timer
297
+ wins a race. Host reads the monotonic clock (`performance.now()`) when the check is called and
298
+ again in the handler attached to the check's promise. An answer, or a throw, that settles
299
+ `admissionTimeoutMs` or more after the call is `timed_out`. That holds when the check blocked the
300
+ event loop past the deadline and its answer ran before the timer could (test "an answer that settles
301
+ after admissionTimeoutMs is refused even when the check blocked the event loop …"). The timer only
302
+ ends the wait on a promise that never settles. Every `timed_out` refusal aborts the check's signal
303
+ before `submitAction` throws. `admissionTimeoutMs` defaults to 10 000. It must be a whole number from
304
+ 1 to 2 147 483 647, the largest delay `setTimeout` honours. Otherwise `createGovernedActionHost`
305
+ throws.
306
+
307
+ Host stores when it admitted the submission as `admittedAt` on the invocation (PostgreSQL migration
308
+ 6, `admitted_at`) and passes it to the handler as `ActionContext.admittedAt`. It refuses to execute
309
+ an invocation of an action that declares `admitSubmission` when the record has no `admittedAt`,
310
+ whether executed inline, by the worker or after an approval. Such a record comes from a Host older
311
+ than 7.3.0, a direct store write, or a custom store that does not persist `admittedAt`. A handler
312
+ that depends on admission can also require `ctx.admittedAt`, so it refuses under an older Host,
313
+ which ignores `admitSubmission`. Actions that declare no check run no check and record no
314
+ `admittedAt`. Their only change is the scalar snapshot above.
315
+
316
+ Rollout: the PostgreSQL store names `admitted_at` only in the insert of an admitted invocation.
317
+ An invocation of an action without `admitSubmission` is written with the same columns as before,
318
+ and every invocation read selects `*` and treats a missing column as no `admittedAt`, so this
319
+ version submits, reads back and executes such actions on a schema without migration 6, as the
320
+ previous version did (live test "without migration 6, an action without admitSubmission is
321
+ submitted …"). Before submitting an action that declares `admitSubmission`: (1) run
322
+ `ensureSchema()` so migration 6 exists; without it that submission fails with an error naming
323
+ migration 6, and nothing is written; (2) upgrade every worker, since a worker on an older Host would
324
+ execute such an invocation without checking `admittedAt`.
325
+
197
326
  ## External mutation governance
198
327
 
199
328
  Hosts can configure `resolveMutationGovernance` to persist an approved, provider-neutral
@@ -362,7 +491,113 @@ already be audit-safe.
362
491
  Adapter and compliance lifecycle events remain canonical but are excluded from bus egress by default.
363
492
  Use `shouldPublish` to opt them in only when the application has made their payloads safe for that bus.
364
493
 
494
+ The envelope leaves out the actor by default, for every payload classification. `outbox.includeActor`
495
+ opts events in: when it returns `true`, the envelope carries the event's `actorId` and `actorType`.
496
+ With the hook absent, the envelope a publisher receives is unchanged and has no actor keys at all.
497
+ `includeActor` takes the same `(event, classification)` arguments as `includeProvenance`, but its
498
+ default is "never" rather than "unless restricted". Only a literal `true` opts an event in.
499
+
500
+ ```ts
501
+ outbox: {
502
+ producerModuleVersion,
503
+ classifyPayload,
504
+ includeActor: (event, classification) => classification !== "restricted",
505
+ }
506
+ ```
507
+
508
+ An included actor reaches every sink of every relay that reads this outbox, so opt in only when every
509
+ one of them may hold actor identifiers. To give the actor to one sink only, a composite publisher must
510
+ strip `actorId` and `actorType` before handing the envelope to the others.
511
+
512
+ Each outbox record stores the decision as `actorDisclosure` (`"included"` or `"suppressed"`), and
513
+ `runOutboxRelayCycle()` passes it to the publisher as `context.actorDisclosure`. A record written before
514
+ Host stored the decision has no `actorDisclosure`, and the relay reports it as `"legacy"`. Host's
515
+ stores read the actor back only from `included` records, so a legacy envelope carries no actor.
516
+ Replaying the outbox cannot add one, so a consumer that needs the actor must read it from the
517
+ canonical event. `suppressed` means the application chose not to share
518
+ the actor. On PostgreSQL the decision lives in the `event_outbox.actor_disclosure` column, added by
519
+ `ensureSchema()` migration 4. Rows that existed before that migration stay `NULL` and read as legacy.
520
+ The column's check accepts `NULL`; every non-`NULL` value must be `included` or `suppressed`.
521
+ Writers on an older Host version keep inserting `NULL` (legacy).
522
+
523
+ Roll out in this order:
524
+
525
+ 1. Run `ensureSchema()` so migration 4 is applied. Writers on this version insert into the new
526
+ column and fail without it.
527
+ 2. Upgrade every relay that reads the shared outbox.
528
+ 3. Only then enable `includeActor`.
529
+
530
+ The order matters because a relay on an older Host version hydrates outbox rows without the actor
531
+ fields, publishes with no `actorDisclosure` in the context, and acknowledges the record. A consumer
532
+ behind that relay receives an actorless envelope that it reads as legacy, although the row says
533
+ `included`, and the published record is not relayed again.
534
+
365
535
  For deterministic local tests, `MemoryPlatformHostStore` enables governed Host outbox egress only
366
536
  when its constructor receives a `MemoryPlatformHostTransactionProvider` with domain `snapshot` and
367
537
  `restore` functions. Without that explicit rollback seam, `transactionalOutbox` is false and Host
368
538
  construction rejects outbox configuration. PostgreSQL remains the production implementation.
539
+
540
+ ### Requeueing a dead letter
541
+
542
+ A record that exhausts the relay's `maxAttempts` moves to `dead_letter` and stays there. Once the
543
+ consumer that rejected it is fixed, an operator moves it back with `requeueOutboxDeadLetter`:
544
+
545
+ ```ts
546
+ const host = createGovernedActionHost({
547
+ store,
548
+ registry,
549
+ authorization: {
550
+ checkEntitlement,
551
+ authorize,
552
+ // Required: without it every requeue is refused as not_authorized.
553
+ authorizeOutboxRequeue: async ({ tenantId, actorId, record }) =>
554
+ operators.canReplay(tenantId, actorId, record?.eventType),
555
+ },
556
+ });
557
+
558
+ const result = await host.requeueOutboxDeadLetter({
559
+ outboxId: "evt_01J...",
560
+ tenantId,
561
+ spaceId,
562
+ actorId: session.actorId, // from the authenticated session, not the request body
563
+ actorType: "natural_person",
564
+ reason: "Receiver fixed in release 42",
565
+ });
566
+ if (result.status === "refused") {
567
+ // result.refusal is "not_authorized", "not_found", "not_dead_letter", or "stale"
568
+ }
569
+ ```
570
+
571
+ The host reads the record within the tenant and space, asks `authorizeOutboxRequeue` with the
572
+ record's event type, classification, status and attempt count, and then calls the store's
573
+ compare-and-set with the lease token it read. Every relay claim increments that token, so the
574
+ store accepts only the dead letter that was authorized, not one that was requeued, retried and
575
+ dead-lettered again while the authorizer ran. The store moves the record from `dead_letter` to
576
+ `pending`, resets
577
+ `attemptCount` to 0, sets `availableAt` (the host clock unless the caller gives a later time),
578
+ clears the lease owner and keeps the lease token increasing, so a stale relay cannot checkpoint
579
+ the record. In the same step it appends an `OutboxRequeueRecord` saying who requeued the record,
580
+ when and why, together with the attempt count, last error, availability and lease token that the
581
+ requeue replaced. The record keeps its `lastError`. The next relay cycle republishes it under the
582
+ same `eventId`, so consumers still deduplicate on it. `listOutboxRequeues` reads the evidence.
583
+
584
+ Refusals are returned, not thrown. An actor who is refused is told `not_authorized` whether or
585
+ not the record exists. A record in another tenant or space is `not_found`. A pending or published
586
+ record is `not_dead_letter`, and so is the losing side of two concurrent requeues, since only one
587
+ compare-and-set can see `dead_letter`. A record that is a dead letter again, but a later one than
588
+ the host read, is `stale`; read and authorize it again. An exception thrown by
589
+ `authorizeOutboxRequeue` propagates,
590
+ and nothing is requeued. Each requeue emits `outbox.requeued` (`fabric.platform.outbox.requeued`)
591
+ and each refusal emits `outbox.requeue_refused`. The health snapshot's dead-letter count drops
592
+ because the record is pending again. Requeue evidence the store returns is a copy, so editing a
593
+ result or a history entry does not change the stored history. A returned outbox record has its own
594
+ Date fields, but its event payload and provenance are shared with the stored record, as before.
595
+
596
+ The operation is on `OutboxRequeueHost`, not `GovernedActionHost`, so a hand-written
597
+ `GovernedActionHost` from 7.2 still satisfies that interface. `createGovernedActionHost()` returns
598
+ `GovernedActionHost & OutboxRequeueHost`.
599
+
600
+ This capability lives on `OutboxRequeuePlatformHostStore`, which extends `OutboxPlatformHostStore`.
601
+ `MemoryPlatformHostStore` and `PostgresPlatformHostStore` implement it, and a custom outbox store
602
+ without it makes the operation throw. PostgreSQL adds migration 5, the
603
+ `fabric_platform.event_outbox_requeues` table, which `ensureSchema()` creates.