@fabricorg/platform 1.7.0 → 1.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +214 -3
  3. package/dist/actions/index.cjs +6 -6
  4. package/dist/actions/index.d.cts +9 -2
  5. package/dist/actions/index.d.ts +9 -2
  6. package/dist/actions/index.js +1 -1
  7. package/dist/adapters/index.cjs +33 -9
  8. package/dist/adapters/index.d.cts +388 -1
  9. package/dist/adapters/index.d.ts +388 -1
  10. package/dist/adapters/index.js +1 -1
  11. package/dist/chunk-5FOCPRTR.js +511 -0
  12. package/dist/chunk-5FOCPRTR.js.map +1 -0
  13. package/dist/{chunk-USDLP4E7.cjs → chunk-5IVFPWK6.cjs} +4 -4
  14. package/dist/{chunk-USDLP4E7.cjs.map → chunk-5IVFPWK6.cjs.map} +1 -1
  15. package/dist/chunk-6HY35TKB.cjs +491 -0
  16. package/dist/chunk-6HY35TKB.cjs.map +1 -0
  17. package/dist/chunk-6UGEIDLC.cjs +26 -0
  18. package/dist/chunk-6UGEIDLC.cjs.map +1 -0
  19. package/dist/chunk-BJGZE3GE.js +20 -0
  20. package/dist/chunk-BJGZE3GE.js.map +1 -0
  21. package/dist/{chunk-X3GHYMAJ.cjs → chunk-CRZXTZ36.cjs} +2 -22
  22. package/dist/chunk-CRZXTZ36.cjs.map +1 -0
  23. package/dist/{chunk-DCC3UIUJ.js → chunk-GFU7BGEJ.js} +3 -3
  24. package/dist/{chunk-DCC3UIUJ.js.map → chunk-GFU7BGEJ.js.map} +1 -1
  25. package/dist/chunk-HEYMMVDP.js +480 -0
  26. package/dist/chunk-HEYMMVDP.js.map +1 -0
  27. package/dist/chunk-IF6NJORI.cjs +526 -0
  28. package/dist/chunk-IF6NJORI.cjs.map +1 -0
  29. package/dist/{chunk-6QX4I45I.js → chunk-PIDZQAEL.js} +2 -2
  30. package/dist/{chunk-6QX4I45I.js.map → chunk-PIDZQAEL.js.map} +1 -1
  31. package/dist/{chunk-ATWWZTRC.js → chunk-TYNIFC7J.js} +3 -18
  32. package/dist/chunk-TYNIFC7J.js.map +1 -0
  33. package/dist/{chunk-XCAOO6KG.cjs → chunk-UGWGHTNC.cjs} +2 -2
  34. package/dist/{chunk-XCAOO6KG.cjs.map → chunk-UGWGHTNC.cjs.map} +1 -1
  35. package/dist/evidence/index.cjs +45 -1
  36. package/dist/evidence/index.d.cts +187 -1
  37. package/dist/evidence/index.d.ts +187 -1
  38. package/dist/evidence/index.js +3 -1
  39. package/dist/governance/index.d.cts +1 -0
  40. package/dist/governance/index.d.ts +1 -0
  41. package/dist/index.cjs +124 -59
  42. package/dist/index.d.cts +2 -2
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.js +7 -6
  45. package/dist/privacy/index.cjs +19 -18
  46. package/dist/privacy/index.js +2 -1
  47. package/dist/testing/index.cjs +5 -5
  48. package/dist/testing/index.js +2 -2
  49. package/package.json +1 -1
  50. package/dist/chunk-ATWWZTRC.js.map +0 -1
  51. package/dist/chunk-FAG2RTPM.js +0 -120
  52. package/dist/chunk-FAG2RTPM.js.map +0 -1
  53. package/dist/chunk-OZLZHISS.js +0 -3
  54. package/dist/chunk-OZLZHISS.js.map +0 -1
  55. package/dist/chunk-PG5LYTUX.cjs +0 -4
  56. package/dist/chunk-PG5LYTUX.cjs.map +0 -1
  57. package/dist/chunk-X3GHYMAJ.cjs.map +0 -1
  58. package/dist/chunk-XZQZQ5SW.cjs +0 -129
  59. package/dist/chunk-XZQZQ5SW.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # @fabricorg/platform
2
2
 
3
+ ## 1.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - eac74a9: Adapter circuit breakers now work. `AdapterImplementation.circuitBreaker` was declared and documented, but nothing read it. Platform now implements the breaker, and Host enforces it (issue #22).
8
+
9
+ **Release note: new failure semantics.** An adapter that declares `circuitBreaker: { mode: "enforce", ... }` can now have calls refused. Before this release that config had no effect. Review every adapter that already declares `mode: "enforce"` before upgrading, or switch it to `"monitor"` to observe first.
10
+
11
+ - **`@fabricorg/platform` (adapters subpath and root).**
12
+ - New exports: `InMemoryAdapterCircuitBreaker`, `AdapterCircuitBreakerRegistry`, `AdapterCircuitOpenError`, `isAdapterCircuitOpenError`, `classifyCircuitBreakerOutcome`, `assertAdapterCircuitBreakerConfig`, and the types `AdapterCircuitBreaker`, `AdapterCircuitBreakerProvider`, `AdapterCircuitState`, `AdapterCircuitOutcome`, `AdapterCircuitAdmission`, `AdapterCircuitBreakerEvent` and `AdapterCircuitBreakerManualOverride`. `AdapterCircuitAdmission.generation` identifies the epoch an admission was granted in.
13
+ - `AdapterCircuitBreakerConfig` gains the optional `manualOverride` (`"force_open"` or `"force_closed"`) and `probeLeaseMs` fields. No existing field changed.
14
+ - The breaker is per process: state is in memory, is not shared between replicas and is lost on restart.
15
+ - It goes closed → open → half-open (one probe) → closed, over a rolling `windowMs` and a `cooldownMs`. At most one half-open probe is outstanding until it records or its lease expires. The lease is the new optional `probeLeaseMs` (default `max(cooldownMs, windowMs)`), independent of the cooldown. `check()` is a side-effect-free preview.
16
+ - Only outcomes from the current epoch change state. The breaker's epoch advances on every state change, manual-override change and probe grant, and each admission carries it as `generation`. An outcome admitted in an earlier epoch is dropped: it never closes or reopens the breaker, never resolves or frees the probe slot, and is not counted toward a later trip. For example, a replaced probe that fails after its replacement closed the breaker does not reopen it. A direct `record()` call without an admission, or with one that has no `generation`, is treated as current.
17
+ - `failureThreshold` is a failure count, matching the .NET port. Once `minimumSamples` (default `failureThreshold`) outcomes are in the window, the breaker opens on `failureThreshold` consecutive failures, or on a failure ratio of at least `failureThreshold / minimumSamples`.
18
+ - Outcomes `transient_failure`, `timeout`, `ambiguous` and unclassified thrown errors count as failures. `permanent_failure` is ignored.
19
+ - **`executeWithAdapterRetry`** takes an optional `circuitBreaker`. Without it, behaviour is unchanged.
20
+ - With it, admission is checked immediately before every attempt, with no await between admission and `execute`, and the outcome of every attempt that reached `execute` is recorded.
21
+ - New optional `prepareAttempt(attempt, maxAttempts)` hook, awaited before admission. If it throws, no admission is taken and no outcome is recorded; the error is classified and retried or rethrown like an error thrown by `execute`.
22
+ - In `enforce` mode, a refused attempt is not executed and `AdapterCircuitOpenError` is thrown (`code: "ADAPTER_CIRCUIT_OPEN"`, with `adapterKey`, `attempt` and `retryAfterMs` when known).
23
+ - When the breaker opens between attempts, retrying stops and the same error is thrown.
24
+ - `monitor` mode never blocks. It reports `would_reject` telemetry through `onEvent`, and the callback's errors are swallowed. Events are delivered after the breaker operation that produced them commits, never during a decision or a record, so a callback that calls back into the breaker (for example to force it open) sees committed state, and its change applies from the next decision on. A callback never revokes an admission that is already committed; the README lists the two edge cases this leaves (a breaker call made from inside a callback, and `cooldownMs: 0`).
25
+ - **`@fabricorg/platform-host`.**
26
+ - Each Host instance keeps one breaker per `adapterType:operation`. A breaker is created on the adapter's first call, and only for adapters that declare `circuitBreaker`.
27
+ - Host now writes each attempt number to the adapter invocation record in `prepareAttempt`, before breaker admission, instead of inside `execute`. A store failure there is classified and retried as before, and is not counted against the breaker.
28
+ - The new `adapterCircuitBreakers` option supplies a shared `AdapterCircuitBreakerRegistry` or a custom provider (shared breakers, telemetry, manual override).
29
+ - A circuit-open refusal means the adapter never ran. Host marks the adapter invocation `failed` and appends `AdapterInvocationFailed` with the additive payload fields `reason: "circuit_open"` and `retryAfterMs`. The invocation then fails with status `failed`, the same path as an unsuccessful adapter result.
30
+ - A refusal is never routed to `reconciliation_required`, and it is never classified as a timeout. Domain events already appended in the `before_adapters` phase are kept.
31
+ - `createGovernedActionHost()` now throws at construction if an adapter declares an invalid `circuitBreaker` config, for example a `failureThreshold` below 1. Such a config was previously ignored.
32
+ - **Peer range:** Host now requires `@fabricorg/platform` `^1.8.0` (was `^1.2.0`), because it imports the new breaker exports at runtime. The manifest declares it as `workspace:^`, like the other workspace packages that peer on Platform, so the published range is the Platform version released with Host. A literal `^1.8.0` would fail the packed-consumer check (`pnpm verify:packed`) until the version PR publishes 1.8.0. A side effect: each later Host release will require at least the Platform version that is current when that Host is released.
33
+
34
+ - e0f7ca7: Platform can now build evidence packets. `@fabricorg/platform/evidence` previously declared only the envelope types, so applications had to write their own generator (issue #24).
35
+
36
+ **Release note: new redaction and tenant-isolation semantics for applications that adopt the generator.** Nothing changes for code that does not call it.
37
+
38
+ - **`generateEvidencePacket(input, context, options)`** builds a `sealed` `EvidencePacketEnvelope` for one subject through an application-supplied read port. It persists nothing; storing the packet is the application's job.
39
+ - Subjects: the primary subject first, then the related subjects from `resolveRelatedSubjects`, de-duplicated and sorted by `subjectType` then `subjectId`. `includeRelated: false`, or a port without the resolver, uses only the primary subject, and `payload.includeRelated` records whether the lookup ran.
40
+ - It reads events, then the action invocations, policy evaluations and adapter invocations those events reference. The three dependent readers are optional; an absent one yields an empty section, and none is called when no event references an action invocation.
41
+ - **Fails closed on scope.** A record whose `tenantId` or `spaceId` differs from the generation context throws `EvidencePacketGenerationError` (`code: "EVIDENCE_SCOPE_MISMATCH"`) instead of being included or dropped. The check runs on the record as returned and again after normalization. A dependent record the generator did not ask for throws `EVIDENCE_UNREQUESTED_RECORD`. Errors thrown by the port propagate and no packet is returned.
42
+ - What the generator enforces on port data is tenant/space scope, requested invocation ids and record shape. It does not check an event's subject against the requested subjects; choosing the events (for example including events of the primary subject's own action invocations) is the port's responsibility.
43
+ - The payload is plain JSON: every `Date` becomes an ISO string, and values JSON cannot represent faithfully (NaN, Infinity, invalid `Date` objects, bigint, Maps, class instances without `toJSON`, an own `__proto__` key) throw `EVIDENCE_INVALID_RECORD` instead of being changed or dropped. A cyclic value is not detected and fails with a `RangeError` instead; no packet is produced. Fields inherited from a prototype are not read. Events are sorted by the `occurredAt` instant, the `recordedAt` instant, `sequence`, then `id`; the other sections by `id`.
44
+ - Event `occurredAt`/`recordedAt` strings must be ISO 8601 or PostgreSQL ISO date-times with an explicit zone (`Z`, `+HH`, `+HH:MM` or `+HHMM`, offset hours up to 14, optionally after a single space), for example `2026-05-01 10:20:30.123456+00`. They are parsed field by field rather than with `new Date(string)`: a zone-less string, any other format, or an out-of-range field (February 30, hour 24, offset `+15`) throws `EVIDENCE_INVALID_RECORD` instead of being guessed or rolled over, so the payload and checksum do not depend on the host's time zone or on the JavaScript engine's fallback parser. Timestamps are kept to millisecond precision (the fraction is truncated).
45
+ - **Redaction uses the existing privacy engine.** `options.anonymizationPolicy` (required; pass `{ fields: [] }` to redact nothing) runs through `AnonymizationEngine` with data class `"evidence_packet"`, before hashing. The resulting `AnonymizationMetadata` is stored as `payload.redaction`. Its `jobId` defaults to the packet id and its `reason` to `"evidence_packet_generation"`. Envelope subjects and reference metadata are read from the redacted payload.
46
+ - **`_privacy` values are validated.** The engine passes any `_privacy` key through without applying the policy, because it stores its own metadata there. The generator accepts a `_privacy` value from the port, at any depth, only when it has exactly the engine's `AnonymizationMetadata` shape (as on an event anonymized with `anonymizeAssetEventPayload`); any other `_privacy` value throws `EVIDENCE_INVALID_RECORD`. `redactedAt` must be an exact `toISOString()` value and `beforeChecksum` a SHA-256 hex digest. Residual risk: the free-text fields (`reason`, `jobId`, `policyVersion`, `fieldPath`, and the `preservedSemantics` entries) are type-checked as strings but not inspected, so sensitive text a port writes into them reaches the packet. The data-class and redaction-method lists the validator uses fail type-checking if they drift from `PlatformDataClass` or `RedactionMethod`.
47
+ - Record `id`s must pass through redaction unchanged; a policy that removes or changes one throws `EVIDENCE_IDENTIFIER_REDACTED`. Subject ids may be transformed (for example tokenized) but must remain non-empty strings. The engine scopes tokens to their field path and array position, so a tokenized value differs in each place it appears and cannot be used to match records or look packets up; tokenizing `actionInvocationId` likewise breaks the links to action invocations, undetected. Like `beforeChecksum`, `tokenize` and `hash` outputs let a low-entropy value be guessed.
48
+ - Not redacted: values used as object keys (the policy matches field names and paths), and envelope fields copied from the context or input (`generatedBy`, `correlationId`, `causationId`, `policyVersion`).
49
+ - References: one per action invocation, asset event, policy evaluation and adapter invocation, with identifier-only metadata (for example `eventType`, subject ids, `adapterType`, `operation`, `status`).
50
+ - **New read-port and record types:** `EvidencePacketReadPort`, `EvidenceEventQuery`, `EvidenceScope`, `EvidenceEventRecord` (satisfied by `AssetEventEnvelope` and `AssetEventLike`), `EvidenceRecord`, `EvidenceInvocationRecord`, `EvidenceJsonRecord`, `EvidenceTimestamp`, `EvidencePacketPayload`, `GenerateEvidencePacketInput` and `GenerateEvidencePacketOptions`, plus `EvidencePacketGenerationError` and its code type.
51
+ - **Checksums.** `checksum` is the hex SHA-256 of `canonicalJson(payload)`. The new constant `EVIDENCE_PACKET_CHECKSUM_ALGORITHM` (`"fabric-canonical-json-sha256-v1"`, the same identifier as Host's parameter digest because the algorithm is the same) is recorded in the new optional `EvidencePacketEnvelope.checksumAlgorithm` field. No existing field changed. `verifyEvidencePacketChecksum(envelope)` recomputes it and returns `false`, without throwing, on a mismatch, a missing checksum or an unknown algorithm. `computeEvidencePacketChecksum(payload)` is exported too. The checksum covers the payload only, not the other envelope fields.
52
+ - **`canonicalJson`** is now exported from `@fabricorg/platform/evidence` and the root. It moved from `@fabricorg/platform-host` unchanged, byte for byte, including its treatment of `Date` as `{}`.
53
+ - `createEvidencePacketRenderer(options)` wraps the generator as an `EvidenceRenderer`. Other new constants: `EVIDENCE_PACKET_SCHEMA_VERSION`, `DEFAULT_EVIDENCE_PACKET_TYPE` (`"compliance_evidence"`) and `DEFAULT_EVIDENCE_REDACTION_REASON`.
54
+
55
+ ### Patch Changes
56
+
57
+ - d5fa7c2: Documentation only: the `SagaContext.awaitSignal` contract now says that a runtime drops a signal whose stable signal ID it has already received for the saga, and that FIFO buffering applies among distinct signals. Signals without an ID are not deduplicated. No runtime in this package implements `awaitSignal`; `@fabricorg/platform-temporal` ships `createSagaSignalInbox()` for Temporal workflows (issue #23).
58
+
3
59
  ## 1.7.0
4
60
 
5
61
  ### Minor Changes
package/README.md CHANGED
@@ -48,12 +48,12 @@ That's what this package gives you. Nothing more.
48
48
  | `@fabricorg/platform/state-machines` | `StateMachineDefinition`, structural and guard-aware transition evaluation |
49
49
  | `@fabricorg/platform/events` | `AssetEventEnvelope`, `EventTypeRegistry` |
50
50
  | `@fabricorg/platform/projections` | Replay/snapshot framework |
51
- | `@fabricorg/platform/adapters` | `AdapterRegistry`, retry + circuit-breaker |
51
+ | `@fabricorg/platform/adapters` | `AdapterRegistry`, `executeWithAdapterRetry`, in-process circuit breaker (`InMemoryAdapterCircuitBreaker`, `AdapterCircuitBreakerRegistry`, `AdapterCircuitOpenError`) |
52
52
  | `@fabricorg/platform/modules` | `FabricModule<TDb>`, `createModuleRegistry` |
53
53
  | `@fabricorg/platform/manifest` | `exportModuleManifest`, `exportModuleManifestBundle`, `ModuleManifest` |
54
54
  | `@fabricorg/platform/ids` | `createFabricId`, `FABRIC_ID_PREFIXES` |
55
55
  | `@fabricorg/platform/privacy` | Redaction + data classification |
56
- | `@fabricorg/platform/evidence` | Compliance evidence packets |
56
+ | `@fabricorg/platform/evidence` | Compliance evidence packets: `generateEvidencePacket`, `verifyEvidencePacketChecksum`, `canonicalJson` (see [Evidence packets](#evidence-packets)) |
57
57
  | `@fabricorg/platform/governance` | External footprints, delegated principals, obligations, attestations, and reconciliation |
58
58
  | `@fabricorg/platform/objects` | Shared object-type contracts |
59
59
  | `@fabricorg/platform/testing` | In-memory adapter registry, fake `ActionContext`, deterministic IDs |
@@ -112,12 +112,223 @@ Every `invokeAction(id, params)` call goes through:
112
112
  1. **`evaluatePolicies`** — every policy registered for the action runs (code/data/hybrid). A single `block` halts the invocation. Policies may return `guidance` (`factsUsed` + typed `correctiveActions`) so hosts can show exactly why a block happened and what the operator can do next. Corrective actions declare `kind: "invoke_action" | "navigate" | "manual"` so UIs do not guess whether a remediation is executable.
113
113
  2. **State-machine validation** — if the action's target entity is bound to a state machine, the Host reads authoritative state at the mutation boundary, validates the requested transition, and executes its guard with trusted entity, actor, parameter, invocation, and database context. A false or throwing guard fails closed.
114
114
  3. **Handler** — `actionDef.handler(ctx, params)` runs inside a single `TDb` transaction. Handler parses its own input via its `schema` (single source of truth).
115
- 4. **Adapters** — for each `actionDef.adapterSteps`, the registered implementation runs via `executeWithAdapterRetry` (idempotent retry, exponential backoff, circuit breaker). Adapters may classify outcomes as `success`, `transient_failure`, `permanent_failure`, `timeout`, or `ambiguous`; only transient idempotent outcomes are retried. Ambiguous outcomes are routed to `reconciliation_required` with durable evidence.
115
+ 4. **Adapters** — for each `actionDef.adapterSteps`, the registered implementation runs via `executeWithAdapterRetry` (idempotent retry, exponential backoff, and an optional per-adapter circuit breaker; see [Adapter circuit breakers](#adapter-circuit-breakers)). Adapters may classify outcomes as `success`, `transient_failure`, `permanent_failure`, `timeout`, or `ambiguous`; only transient idempotent outcomes are retried. Ambiguous outcomes are routed to `reconciliation_required` with durable evidence.
116
116
  5. **Events** — domain events are appended; projections derive read models on next replay.
117
117
  6. **Completion** — invocation status is marked, telemetry emitted.
118
118
 
119
119
  `validateActionParameters` is also exposed for preview/dry-run paths but is **not** part of the execute pipeline.
120
120
 
121
+ ## Adapter circuit breakers
122
+
123
+ `AdapterImplementation.circuitBreaker` configures a breaker for one adapter operation:
124
+
125
+ ```ts
126
+ const sendAdapter: AdapterImplementation = {
127
+ adapterType: "email",
128
+ operation: "send",
129
+ vendor: "mailco",
130
+ idempotent: true,
131
+ circuitBreaker: { mode: "enforce", failureThreshold: 5, windowMs: 60_000, cooldownMs: 30_000 },
132
+ execute: async (input, context) => ({ success: true }),
133
+ };
134
+ ```
135
+
136
+ `AdapterCircuitBreakerRegistry` creates one `InMemoryAdapterCircuitBreaker` per `adapterType:operation`
137
+ from each adapter's own config, on first use, and none for an adapter without a config.
138
+ `executeWithAdapterRetry({ ..., circuitBreaker })` asks it for admission immediately before every
139
+ attempt, with no await between admission and the `execute` call, and records the outcome of every
140
+ attempt that reached `execute`. Put any awaited work an attempt needs first (for example, writing
141
+ the attempt number) in the optional `prepareAttempt(attempt, maxAttempts)` hook, which runs before
142
+ admission. If `prepareAttempt` throws, no admission is taken and no outcome is recorded; the error is
143
+ classified and retried or rethrown like an error thrown by `execute`. Without `circuitBreaker` and
144
+ `prepareAttempt` the retry loop is unchanged.
145
+ `@fabricorg/platform-host` 7.4.0 and later wires this up for every adapter step.
146
+
147
+ **Scope.** The breaker is in-process memory. It is not distributed: each process, replica or Host
148
+ instance with its own registry trips on the failures it observes itself, and state is lost on restart.
149
+ To share a breaker across processes, implement the `AdapterCircuitBreaker` interface over shared
150
+ storage and supply it through an `AdapterCircuitBreakerProvider`.
151
+
152
+ **States.** `closed` → `open` → (after `cooldownMs`) `half_open` → `closed` or back to `open`.
153
+ Half-open admits one probe call. A probe success closes the breaker and clears its window, and a
154
+ probe failure reopens it and restarts the cooldown. In `enforce` mode, other calls are refused while
155
+ the probe is in flight. The probe holds its slot until it records an outcome or its lease of
156
+ `probeLeaseMs` expires (default `max(cooldownMs, windowMs)`, independent of the cooldown, so
157
+ `cooldownMs: 0` still admits one probe at a time). After the lease expires the next call becomes the
158
+ probe. `check()` is a side-effect-free preview: it does not commit an elapsed cooldown, take the
159
+ probe, change the epoch or emit telemetry.
160
+
161
+ **Stale outcomes.** An outcome may change the breaker's state only if the call was admitted in the
162
+ breaker's current epoch. The epoch advances on every state change (including the one an elapsed
163
+ cooldown commits), every manual-override change, and every probe grant. Each admission returned by
164
+ `acquire()` carries its epoch as `generation`, and `executeWithAdapterRetry` passes it back to
165
+ `record()`. An outcome from an earlier epoch is dropped: it does not close or reopen the breaker,
166
+ does not resolve or free the probe slot, and is not added to the rolling window, so it cannot help
167
+ trip the breaker later. This covers a replaced probe that reports after its replacement closed the
168
+ breaker, and a call admitted while closed whose failure arrives after the breaker opened. Outcomes
169
+ from the current epoch count exactly as described under Thresholds. A direct caller that records
170
+ without an admission, or with one that has no `generation`, is treated as current: the outcome is
171
+ sampled, can trip a closed breaker and resolves an outstanding probe.
172
+
173
+ **Thresholds.** `failureThreshold` is a failure count, with the same rule as the .NET port. Let
174
+ `M = minimumSamples ?? failureThreshold`. Once at least `M` outcomes fall within the last `windowMs`,
175
+ a recorded failure opens the breaker when either:
176
+
177
+ - the most recent `failureThreshold` outcomes are all failures, or
178
+ - the failure ratio in the window is at least `failureThreshold / M`.
179
+
180
+ Without `minimumSamples` this means "`failureThreshold` consecutive failures". With
181
+ `failureThreshold: 10, minimumSamples: 20` the breaker also opens at a 50% failure rate over at least
182
+ 20 calls. `assertAdapterCircuitBreakerConfig` rejects a non-positive or fractional `failureThreshold`
183
+ or `minimumSamples`, a non-positive `windowMs`, and a negative `cooldownMs`.
184
+
185
+ **Modes.** `monitor` runs the same state machine and telemetry but never blocks a call. Calls that
186
+ `enforce` mode would have refused are reported as `would_reject`. `enforce` refuses calls while
187
+ the breaker is open: `execute` is not called, and `executeWithAdapterRetry` throws
188
+ `AdapterCircuitOpenError` (`code: "ADAPTER_CIRCUIT_OPEN"`, `adapterKey`, `state`, `attempt`,
189
+ `retryAfterMs` when known). If the breaker opens between attempts, retrying stops and the same error
190
+ is thrown. Use `isAdapterCircuitOpenError(error)` to recognise it, which also works across duplicate
191
+ copies of this package.
192
+
193
+ **Outcome mapping** (`classifyCircuitBreakerOutcome`):
194
+
195
+ | Adapter outcome | Counts as |
196
+ |---|---|
197
+ | `success` | success |
198
+ | `transient_failure` | failure |
199
+ | `timeout` | failure |
200
+ | `ambiguous` | failure (no definite answer is a health signal, even though Host routes it to reconciliation) |
201
+ | `permanent_failure` | ignored (the dependency answered and rejected the request; not a dependency failure) |
202
+ | thrown error | the kind `classifyThrownError` returns, `transient_failure` by default, mapped as above |
203
+
204
+ **Manual override.** `manualOverride: "force_open" | "force_closed"` in the config, or
205
+ `breaker.setManualOverride(...)` at runtime (`undefined` clears it). An override pins the reported
206
+ state and suspends automatic transitions. Outcomes of calls admitted under the override keep being
207
+ recorded; setting or clearing the override starts a new epoch, so outcomes of calls admitted before
208
+ the change are dropped as stale. Like an automatic open, `force_open` blocks only in `enforce` mode.
209
+ Clearing returns the breaker to `closed` with its window intact.
210
+
211
+ **Telemetry.** Pass `onEvent` to the breaker or registry to receive `state_change` (with a
212
+ `reason`), `rejected` and `would_reject` events. Errors the callback throws, and promises it
213
+ rejects, are swallowed so telemetry cannot change a call's outcome. Events are delivered after the
214
+ operation that produced them commits, never part-way through a decision or a record. Committing an
215
+ elapsed cooldown is its own step, delivered before `acquire()` decides, so a callback that forces the
216
+ breaker open on `cooldown_elapsed` refuses that call. A callback may call back into the breaker: it
217
+ sees committed state, its changes apply from the next decision on, and the events they produce are
218
+ delivered after it returns, in order. A callback never revokes an admission that is already
219
+ committed, so there are two exceptions to the cooldown rule above: a breaker call made from inside a
220
+ callback decides before its own `cooldown_elapsed` is delivered, and with `cooldownMs: 0` a cooldown
221
+ that falls due again during a decision (because a callback reopened the breaker) is admitted before
222
+ its event is delivered. Use a non-zero `cooldownMs` and avoid calling `acquire()` from a callback if
223
+ an override set from telemetry must always stop the next call.
224
+
225
+ ## Evidence packets
226
+
227
+ `generateEvidencePacket` builds a sealed, redacted, checksummed evidence packet for one subject. Your
228
+ application supplies a read port over its own store; the generator persists nothing, so storing the
229
+ returned envelope is up to you.
230
+
231
+ ```ts
232
+ import {
233
+ generateEvidencePacket,
234
+ verifyEvidencePacketChecksum,
235
+ type EvidencePacketReadPort,
236
+ } from "@fabricorg/platform/evidence";
237
+
238
+ const port: EvidencePacketReadPort = {
239
+ listEvents: ({ tenantId, spaceId, subjects }) => store.eventsFor(tenantId, spaceId, subjects),
240
+ // Optional. Each absent reader leaves its section empty.
241
+ resolveRelatedSubjects: (subject, scope) => store.relatedSubjects(subject, scope),
242
+ listActionInvocations: (scope, ids) => store.actionInvocations(scope, ids),
243
+ listPolicyEvaluations: (scope, invocationIds) => store.policyEvaluations(scope, invocationIds),
244
+ listAdapterInvocations: (scope, invocationIds) => store.adapterInvocations(scope, invocationIds),
245
+ };
246
+
247
+ const packet = await generateEvidencePacket(
248
+ { subject: { subjectType: "Case", subjectId: caseId }, policyVersion: "2026-06" },
249
+ { tenantId, spaceId, actorId, correlationId, db, now: new Date() },
250
+ {
251
+ port,
252
+ anonymizationPolicy: { fields: [{ match: "key", value: "email", method: "tokenize" }] },
253
+ },
254
+ );
255
+ await store.savePacket(packet);
256
+ verifyEvidencePacketChecksum(packet); // true until the payload is altered
257
+ ```
258
+
259
+ What the tests in `evidence/generator.test.ts` cover:
260
+
261
+ - **Scope fails closed.** A record from the port whose `tenantId` or `spaceId` differs from the context
262
+ throws `EvidencePacketGenerationError` with code `EVIDENCE_SCOPE_MISMATCH`, in every section. The
263
+ check runs on the record as returned and again after normalization. A dependent record for an action
264
+ invocation the generator did not request throws `EVIDENCE_UNREQUESTED_RECORD`. An error thrown by the
265
+ port propagates, and no packet is returned.
266
+ - **What the port decides.** The generator does not check an event's subject against the requested
267
+ subjects: a port may return events recorded against the primary subject's own action invocations
268
+ under another subject. Selecting the right events is the port's job.
269
+ - **Record shape.** Records must have a non-empty `id`, and events their documented fields.
270
+ `occurredAt` and `recordedAt` must be a `Date` or an ISO 8601 / PostgreSQL ISO date-time string
271
+ with an explicit zone: `YYYY-MM-DD` (or a signed 4–6 digit year), `T` or a space,
272
+ `HH:MM[:SS[.fraction]]`, an optional single space, then `Z`, `+HH`, `+HH:MM` or `+HHMM` with offset
273
+ hours up to 14 (for example `2026-05-01 10:20:30.123456+00`). The string is parsed field by
274
+ field without `Date`'s lenient parser, and an out-of-range field (February 30, hour 24, offset
275
+ `+15`) or any other format throws `EVIDENCE_INVALID_RECORD` instead of being rolled over or
276
+ guessed. A zone-less string such as `"2026-05-01 00:00:00"` throws too, because reading it would
277
+ depend on the host's time zone. Timestamps are kept to millisecond precision, truncated
278
+ (`.123999+00` becomes `.123Z`). Values JSON cannot represent faithfully (NaN, Infinity, invalid
279
+ `Date` objects, bigint, Maps, class instances without `toJSON`, an own `__proto__` key) throw
280
+ `EVIDENCE_INVALID_RECORD` instead of being changed or dropped. A cyclic value (including a `toJSON`
281
+ that returns itself) is not detected: it fails with a `RangeError` rather than an
282
+ `EvidencePacketGenerationError`, and no packet is produced. Fields inherited from a prototype are not read.
283
+ - **Subjects.** The primary subject comes first, then the related subjects, de-duplicated and sorted.
284
+ Related subjects are used only when the port has `resolveRelatedSubjects` and `includeRelated` is not
285
+ `false`; `payload.includeRelated` records whether that lookup ran.
286
+ - **Deterministic payload.** Events are sorted by the `occurredAt` instant, then the `recordedAt`
287
+ instant, `sequence` and `id`; the other sections by `id`. Every `Date` becomes an ISO string, and
288
+ event timestamps given as zoned strings are rewritten as the ISO string of the same instant; other
289
+ strings are kept as given. So with the same `now`, packet id and options, the same records give the
290
+ same payload and checksum when they come back in a different order, with keys in a different order,
291
+ or with an event timestamp as a `Date` or as a zoned string (the tests use `Z`, `+00`, `+00:00` and
292
+ `+0200`) for the same millisecond.
293
+ - **Redaction before hashing.** The payload runs through the privacy `AnonymizationEngine` with data
294
+ class `"evidence_packet"`, and its `AnonymizationMetadata` is stored as `payload.redaction` (`jobId`
295
+ defaults to the packet id, `reason` to `"evidence_packet_generation"`, `redactedAt` to the generation
296
+ time). In the tests, a redacted value does not appear anywhere in the serialized envelope, because the
297
+ envelope's subjects and reference metadata are read from the redacted payload.
298
+ - The engine passes any `_privacy` key through without applying the policy, because that is where it
299
+ keeps its own metadata. The generator therefore accepts a `_privacy` value from the port, at any
300
+ depth, only when it has exactly the `AnonymizationMetadata` shape the engine writes (for example on
301
+ an event already anonymized with `anonymizeAssetEventPayload`); any other `_privacy` value throws
302
+ `EVIDENCE_INVALID_RECORD`. Fixed-format fields are pinned: `redactedAt` (top level and per field)
303
+ must be an exact `toISOString()` value and `beforeChecksum` 64 lowercase hex characters. The
304
+ free-text fields (`reason`, `jobId`, `policyVersion`, each `fieldPath`, and the `preservedSemantics`
305
+ entries) are only type-checked as strings, not inspected, so a port that writes sensitive text into
306
+ them would carry it into the packet.
307
+ - Record `id`s must come through redaction unchanged; a policy that removes or changes one (for
308
+ example `mask` on `id`) throws `EVIDENCE_IDENTIFIER_REDACTED`. Subject ids may be transformed, for
309
+ example tokenized, but must remain non-empty strings.
310
+ - The engine scopes each token to its field path, including array positions. The same raw value
311
+ therefore gets a different token in each place it appears, within one packet and across packets
312
+ (a related subject's token changes when its position in `subjects` does). Tokenized values cannot
313
+ be used to match records or to look packets up; index the raw subject outside the packet if you
314
+ need that. For the same reason, tokenizing `actionInvocationId` breaks the link between records
315
+ and their action invocations, and the generator does not detect it.
316
+ - The policy matches field names and paths, not values used as object keys, so a sensitive value
317
+ stored as a key (`{ byEmail: { "jane@example.com": 1 } }`) is not redacted.
318
+ - Envelope fields copied from the context (`generatedBy`, `correlationId`, `causationId`) and from
319
+ the input (`policyVersion`) are not redacted.
320
+ - The engine's field metadata keeps a SHA-256 `beforeChecksum` of each redacted value, as it does
321
+ elsewhere. `hash` output is an unsalted SHA-256, and every input to a `tokenize` token (tenant,
322
+ space, field path, policy version) is in the packet. So a low-entropy value can be guessed from
323
+ any of the three; keep such values out of the store or use opaque references.
324
+ - **Checksum.** `checksum` is the hex SHA-256 of `canonicalJson(payload)`, recorded with
325
+ `checksumAlgorithm: EVIDENCE_PACKET_CHECKSUM_ALGORITHM`. `verifyEvidencePacketChecksum` returns `false`
326
+ for a changed payload, a missing checksum or an unknown algorithm. The checksum covers the payload,
327
+ not the other envelope fields.
328
+
329
+ `createEvidencePacketRenderer(options)` wraps the generator as an `EvidenceRenderer` for one packet
330
+ type.
331
+
121
332
  ## Saga actions
122
333
 
123
334
  Multi-step orchestrations declare `kind: "saga"` on the `ActionDefinition` and ship a `SagaImplementation`:
@@ -1,28 +1,28 @@
1
1
  'use strict';
2
2
 
3
- var chunkXCAOO6KG_cjs = require('../chunk-XCAOO6KG.cjs');
3
+ var chunkUGWGHTNC_cjs = require('../chunk-UGWGHTNC.cjs');
4
4
 
5
5
 
6
6
 
7
7
  Object.defineProperty(exports, "ACTOR_TYPES", {
8
8
  enumerable: true,
9
- get: function () { return chunkXCAOO6KG_cjs.ACTOR_TYPES; }
9
+ get: function () { return chunkUGWGHTNC_cjs.ACTOR_TYPES; }
10
10
  });
11
11
  Object.defineProperty(exports, "assertActionId", {
12
12
  enumerable: true,
13
- get: function () { return chunkXCAOO6KG_cjs.assertActionId; }
13
+ get: function () { return chunkUGWGHTNC_cjs.assertActionId; }
14
14
  });
15
15
  Object.defineProperty(exports, "assertActorType", {
16
16
  enumerable: true,
17
- get: function () { return chunkXCAOO6KG_cjs.assertActorType; }
17
+ get: function () { return chunkUGWGHTNC_cjs.assertActorType; }
18
18
  });
19
19
  Object.defineProperty(exports, "createActionRegistry", {
20
20
  enumerable: true,
21
- get: function () { return chunkXCAOO6KG_cjs.createActionRegistry; }
21
+ get: function () { return chunkUGWGHTNC_cjs.createActionRegistry; }
22
22
  });
23
23
  Object.defineProperty(exports, "isActorType", {
24
24
  enumerable: true,
25
- get: function () { return chunkXCAOO6KG_cjs.isActorType; }
25
+ get: function () { return chunkUGWGHTNC_cjs.isActorType; }
26
26
  });
27
27
  //# sourceMappingURL=index.cjs.map
28
28
  //# sourceMappingURL=index.cjs.map
@@ -205,8 +205,15 @@ interface SagaContext {
205
205
  * Wait for a named external signal carrying a payload of `T`, with a timeout.
206
206
  *
207
207
  * Resolves with the payload of the first signal received, or `null` if the timeout
208
- * fires first. Subsequent signals with the same name received before another
209
- * `awaitSignal` call are buffered (FIFO) so no signal is lost.
208
+ * fires first. Subsequent distinct signals with the same name received before
209
+ * another `awaitSignal` call are buffered in FIFO order so none is lost.
210
+ *
211
+ * A signal may carry a stable signal ID that the sender keeps the same on each redelivery
212
+ * (for example a webhook delivery ID or an approval decision ID). A runtime drops
213
+ * a signal whose ID it has already received for this saga, so a sender retrying
214
+ * after a timeout does not wake the saga twice. Signals without an ID are not
215
+ * deduplicated. `@fabricorg/platform-temporal` provides `createSagaSignalInbox()`
216
+ * for Temporal runtimes.
210
217
  *
211
218
  * Intended for agent-driven and webhook-driven coordination: replace polling loops
212
219
  * with push-based wakeups while preserving the saga's deterministic replay
@@ -205,8 +205,15 @@ interface SagaContext {
205
205
  * Wait for a named external signal carrying a payload of `T`, with a timeout.
206
206
  *
207
207
  * Resolves with the payload of the first signal received, or `null` if the timeout
208
- * fires first. Subsequent signals with the same name received before another
209
- * `awaitSignal` call are buffered (FIFO) so no signal is lost.
208
+ * fires first. Subsequent distinct signals with the same name received before
209
+ * another `awaitSignal` call are buffered in FIFO order so none is lost.
210
+ *
211
+ * A signal may carry a stable signal ID that the sender keeps the same on each redelivery
212
+ * (for example a webhook delivery ID or an approval decision ID). A runtime drops
213
+ * a signal whose ID it has already received for this saga, so a sender retrying
214
+ * after a timeout does not wake the saga twice. Signals without an ID are not
215
+ * deduplicated. `@fabricorg/platform-temporal` provides `createSagaSignalInbox()`
216
+ * for Temporal runtimes.
210
217
  *
211
218
  * Intended for agent-driven and webhook-driven coordination: replace polling loops
212
219
  * with push-based wakeups while preserving the saga's deterministic replay
@@ -1,3 +1,3 @@
1
- export { ACTOR_TYPES, assertActionId, assertActorType, createActionRegistry, isActorType } from '../chunk-6QX4I45I.js';
1
+ export { ACTOR_TYPES, assertActionId, assertActorType, createActionRegistry, isActorType } from '../chunk-PIDZQAEL.js';
2
2
  //# sourceMappingURL=index.js.map
3
3
  //# sourceMappingURL=index.js.map
@@ -1,40 +1,64 @@
1
1
  'use strict';
2
2
 
3
- var chunkXZQZQ5SW_cjs = require('../chunk-XZQZQ5SW.cjs');
3
+ var chunkIF6NJORI_cjs = require('../chunk-IF6NJORI.cjs');
4
4
 
5
5
 
6
6
 
7
+ Object.defineProperty(exports, "AdapterCircuitBreakerRegistry", {
8
+ enumerable: true,
9
+ get: function () { return chunkIF6NJORI_cjs.AdapterCircuitBreakerRegistry; }
10
+ });
11
+ Object.defineProperty(exports, "AdapterCircuitOpenError", {
12
+ enumerable: true,
13
+ get: function () { return chunkIF6NJORI_cjs.AdapterCircuitOpenError; }
14
+ });
7
15
  Object.defineProperty(exports, "AdapterRegistry", {
8
16
  enumerable: true,
9
- get: function () { return chunkXZQZQ5SW_cjs.AdapterRegistry; }
17
+ get: function () { return chunkIF6NJORI_cjs.AdapterRegistry; }
18
+ });
19
+ Object.defineProperty(exports, "InMemoryAdapterCircuitBreaker", {
20
+ enumerable: true,
21
+ get: function () { return chunkIF6NJORI_cjs.InMemoryAdapterCircuitBreaker; }
10
22
  });
11
23
  Object.defineProperty(exports, "MissingAdapterRegistrationError", {
12
24
  enumerable: true,
13
- get: function () { return chunkXZQZQ5SW_cjs.MissingAdapterRegistrationError; }
25
+ get: function () { return chunkIF6NJORI_cjs.MissingAdapterRegistrationError; }
26
+ });
27
+ Object.defineProperty(exports, "assertAdapterCircuitBreakerConfig", {
28
+ enumerable: true,
29
+ get: function () { return chunkIF6NJORI_cjs.assertAdapterCircuitBreakerConfig; }
14
30
  });
15
31
  Object.defineProperty(exports, "assertAdapterOutcomeConsistency", {
16
32
  enumerable: true,
17
- get: function () { return chunkXZQZQ5SW_cjs.assertAdapterOutcomeConsistency; }
33
+ get: function () { return chunkIF6NJORI_cjs.assertAdapterOutcomeConsistency; }
18
34
  });
19
35
  Object.defineProperty(exports, "classifyAdapterOutcome", {
20
36
  enumerable: true,
21
- get: function () { return chunkXZQZQ5SW_cjs.classifyAdapterOutcome; }
37
+ get: function () { return chunkIF6NJORI_cjs.classifyAdapterOutcome; }
38
+ });
39
+ Object.defineProperty(exports, "classifyCircuitBreakerOutcome", {
40
+ enumerable: true,
41
+ get: function () { return chunkIF6NJORI_cjs.classifyCircuitBreakerOutcome; }
22
42
  });
23
43
  Object.defineProperty(exports, "executeWithAdapterRetry", {
24
44
  enumerable: true,
25
- get: function () { return chunkXZQZQ5SW_cjs.executeWithAdapterRetry; }
45
+ get: function () { return chunkIF6NJORI_cjs.executeWithAdapterRetry; }
26
46
  });
27
47
  Object.defineProperty(exports, "getRetryDelayMs", {
28
48
  enumerable: true,
29
- get: function () { return chunkXZQZQ5SW_cjs.getRetryDelayMs; }
49
+ get: function () { return chunkIF6NJORI_cjs.getRetryDelayMs; }
50
+ });
51
+ Object.defineProperty(exports, "isAdapterCircuitOpenError", {
52
+ enumerable: true,
53
+ get: function () { return chunkIF6NJORI_cjs.isAdapterCircuitOpenError; }
30
54
  });
31
55
  Object.defineProperty(exports, "resolveAdapterRetryPolicy", {
32
56
  enumerable: true,
33
- get: function () { return chunkXZQZQ5SW_cjs.resolveAdapterRetryPolicy; }
57
+ get: function () { return chunkIF6NJORI_cjs.resolveAdapterRetryPolicy; }
34
58
  });
35
59
  Object.defineProperty(exports, "shouldRetryAdapterOutcome", {
36
60
  enumerable: true,
37
- get: function () { return chunkXZQZQ5SW_cjs.shouldRetryAdapterOutcome; }
61
+ get: function () { return chunkIF6NJORI_cjs.shouldRetryAdapterOutcome; }
38
62
  });
39
63
  //# sourceMappingURL=index.cjs.map
40
64
  //# sourceMappingURL=index.cjs.map