@fabricorg/platform 1.6.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.
- package/CHANGELOG.md +79 -0
- package/README.md +216 -3
- package/dist/actions/index.cjs +6 -6
- package/dist/actions/index.d.cts +85 -3
- package/dist/actions/index.d.ts +85 -3
- package/dist/actions/index.js +1 -1
- package/dist/adapters/index.cjs +33 -9
- package/dist/adapters/index.d.cts +388 -1
- package/dist/adapters/index.d.ts +388 -1
- package/dist/adapters/index.js +1 -1
- package/dist/certification/index.cjs +6 -6
- package/dist/certification/index.js +3 -3
- package/dist/{chunk-VZ6C4LXC.cjs → chunk-45OKKLNY.cjs} +17 -9
- package/dist/chunk-45OKKLNY.cjs.map +1 -0
- package/dist/chunk-5FOCPRTR.js +511 -0
- package/dist/chunk-5FOCPRTR.js.map +1 -0
- package/dist/{chunk-USDLP4E7.cjs → chunk-5IVFPWK6.cjs} +4 -4
- package/dist/{chunk-USDLP4E7.cjs.map → chunk-5IVFPWK6.cjs.map} +1 -1
- package/dist/chunk-6HY35TKB.cjs +491 -0
- package/dist/chunk-6HY35TKB.cjs.map +1 -0
- package/dist/chunk-6UGEIDLC.cjs +26 -0
- package/dist/chunk-6UGEIDLC.cjs.map +1 -0
- package/dist/chunk-BJGZE3GE.js +20 -0
- package/dist/chunk-BJGZE3GE.js.map +1 -0
- package/dist/{chunk-X3GHYMAJ.cjs → chunk-CRZXTZ36.cjs} +2 -22
- package/dist/chunk-CRZXTZ36.cjs.map +1 -0
- package/dist/{chunk-TMMP4U2D.cjs → chunk-DBTAUI6C.cjs} +5 -2
- package/dist/chunk-DBTAUI6C.cjs.map +1 -0
- package/dist/{chunk-UYCQV65U.js → chunk-EMSAFNPX.js} +3 -3
- package/dist/{chunk-UYCQV65U.js.map → chunk-EMSAFNPX.js.map} +1 -1
- package/dist/{chunk-DCC3UIUJ.js → chunk-GFU7BGEJ.js} +3 -3
- package/dist/{chunk-DCC3UIUJ.js.map → chunk-GFU7BGEJ.js.map} +1 -1
- package/dist/{chunk-JFVEPAED.js → chunk-GU3LZWJD.js} +17 -9
- package/dist/chunk-GU3LZWJD.js.map +1 -0
- package/dist/chunk-HEYMMVDP.js +480 -0
- package/dist/chunk-HEYMMVDP.js.map +1 -0
- package/dist/chunk-IF6NJORI.cjs +526 -0
- package/dist/chunk-IF6NJORI.cjs.map +1 -0
- package/dist/{chunk-XS7EUS4T.cjs → chunk-MCWSUGB3.cjs} +4 -4
- package/dist/{chunk-XS7EUS4T.cjs.map → chunk-MCWSUGB3.cjs.map} +1 -1
- package/dist/{chunk-QHV6LOS6.js → chunk-PDN5DOLQ.js} +3 -3
- package/dist/{chunk-QHV6LOS6.js.map → chunk-PDN5DOLQ.js.map} +1 -1
- package/dist/{chunk-OZWUNGJE.js → chunk-PIDZQAEL.js} +5 -2
- package/dist/chunk-PIDZQAEL.js.map +1 -0
- package/dist/{chunk-ATWWZTRC.js → chunk-TYNIFC7J.js} +3 -18
- package/dist/chunk-TYNIFC7J.js.map +1 -0
- package/dist/{chunk-DDX5FKOM.cjs → chunk-UGWGHTNC.cjs} +5 -2
- package/dist/chunk-UGWGHTNC.cjs.map +1 -0
- package/dist/{chunk-ZF5HDNSU.js → chunk-WNZP53GI.js} +5 -2
- package/dist/chunk-WNZP53GI.js.map +1 -0
- package/dist/{chunk-2URHPZXL.cjs → chunk-WQ3BYI7T.cjs} +6 -6
- package/dist/{chunk-2URHPZXL.cjs.map → chunk-WQ3BYI7T.cjs.map} +1 -1
- package/dist/evidence/index.cjs +45 -1
- package/dist/evidence/index.d.cts +187 -1
- package/dist/evidence/index.d.ts +187 -1
- package/dist/evidence/index.js +3 -1
- package/dist/governance/index.d.cts +1 -0
- package/dist/governance/index.d.ts +1 -0
- package/dist/index.cjs +153 -88
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +11 -10
- package/dist/manifest/index.cjs +8 -8
- package/dist/manifest/index.js +2 -2
- package/dist/modules/index.cjs +9 -9
- package/dist/modules/index.js +1 -1
- package/dist/policies/index.cjs +9 -9
- package/dist/policies/index.d.cts +14 -1
- package/dist/policies/index.d.ts +14 -1
- package/dist/policies/index.js +1 -1
- package/dist/privacy/index.cjs +19 -18
- package/dist/privacy/index.js +2 -1
- package/dist/testing/index.cjs +5 -5
- package/dist/testing/index.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-ATWWZTRC.js.map +0 -1
- package/dist/chunk-DDX5FKOM.cjs.map +0 -1
- package/dist/chunk-FAG2RTPM.js +0 -120
- package/dist/chunk-FAG2RTPM.js.map +0 -1
- package/dist/chunk-JFVEPAED.js.map +0 -1
- package/dist/chunk-OZLZHISS.js +0 -3
- package/dist/chunk-OZLZHISS.js.map +0 -1
- package/dist/chunk-OZWUNGJE.js.map +0 -1
- package/dist/chunk-PG5LYTUX.cjs +0 -4
- package/dist/chunk-PG5LYTUX.cjs.map +0 -1
- package/dist/chunk-TMMP4U2D.cjs.map +0 -1
- package/dist/chunk-VZ6C4LXC.cjs.map +0 -1
- package/dist/chunk-X3GHYMAJ.cjs.map +0 -1
- package/dist/chunk-XZQZQ5SW.cjs +0 -129
- package/dist/chunk-XZQZQ5SW.cjs.map +0 -1
- package/dist/chunk-ZF5HDNSU.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
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
|
+
|
|
59
|
+
## 1.7.0
|
|
60
|
+
|
|
61
|
+
### Minor Changes
|
|
62
|
+
|
|
63
|
+
- 8c7d2a1: Make policy resolution invocation-aware and record which policy snapshot was used.
|
|
64
|
+
|
|
65
|
+
- `@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.
|
|
66
|
+
- `@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.
|
|
67
|
+
- 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`).
|
|
68
|
+
|
|
69
|
+
- 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.
|
|
70
|
+
|
|
71
|
+
- **`@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`.
|
|
72
|
+
- **`@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`).
|
|
73
|
+
- **`@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.
|
|
74
|
+
- **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 …").
|
|
75
|
+
- **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.
|
|
76
|
+
- **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.
|
|
77
|
+
- **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`.
|
|
78
|
+
- **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.
|
|
79
|
+
- **Telemetry.** `invocation.admission_refused` (`fabric.platform.invocation.admission_refused`) is emitted with the `refusal` attribute. A refused submission emits no `invocation.submitted`.
|
|
80
|
+
- **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.
|
|
81
|
+
|
|
3
82
|
## 1.6.0
|
|
4
83
|
|
|
5
84
|
### 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`,
|
|
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 |
|
|
@@ -103,6 +103,8 @@ const json = JSON.stringify(manifest, null, 2);
|
|
|
103
103
|
|
|
104
104
|
An entity schema does not validate arbitrary writes through `TDb`. Persisted-record validation requires an explicit Host/database adapter. When an action declares `resultSchema`, Platform Host validates the public result after the handler and before events or adapters; failure rolls back domain writes only when the Host store has a transaction provider that includes them.
|
|
105
105
|
|
|
106
|
+
An action may also declare `admitSubmission`, an async check that decides whether a submission may be recorded at all. It receives the trusted `tenantId`, `spaceId`, `actorId` and `actorType`, the submitted parameters and a `signal` (`ActionAdmissionSignal`, a structural subset of `AbortSignal`; Host passes a real one), and answers `{ admitted: true }` or `{ admitted: false, code }` with a fixed code. `@fabricorg/platform-host` 7.3.0 and later runs it after authorizing the submitter and before writing anything for the submission, refuses on a throw, timeout or any other answer, and passes the admission time to the handler as `ActionContext.admittedAt`. An older host ignores the field, so a handler that relies on it should refuse without `ctx.admittedAt`. Registration rejects an `admitSubmission` that is not a function.
|
|
107
|
+
|
|
106
108
|
## The action lifecycle
|
|
107
109
|
|
|
108
110
|
Every `invokeAction(id, params)` call goes through:
|
|
@@ -110,12 +112,223 @@ Every `invokeAction(id, params)` call goes through:
|
|
|
110
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.
|
|
111
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.
|
|
112
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).
|
|
113
|
-
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.
|
|
114
116
|
5. **Events** — domain events are appended; projections derive read models on next replay.
|
|
115
117
|
6. **Completion** — invocation status is marked, telemetry emitted.
|
|
116
118
|
|
|
117
119
|
`validateActionParameters` is also exposed for preview/dry-run paths but is **not** part of the execute pipeline.
|
|
118
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
|
+
|
|
119
332
|
## Saga actions
|
|
120
333
|
|
|
121
334
|
Multi-step orchestrations declare `kind: "saga"` on the `ActionDefinition` and ship a `SagaImplementation`:
|
package/dist/actions/index.cjs
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
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
|
|
9
|
+
get: function () { return chunkUGWGHTNC_cjs.ACTOR_TYPES; }
|
|
10
10
|
});
|
|
11
11
|
Object.defineProperty(exports, "assertActionId", {
|
|
12
12
|
enumerable: true,
|
|
13
|
-
get: function () { return
|
|
13
|
+
get: function () { return chunkUGWGHTNC_cjs.assertActionId; }
|
|
14
14
|
});
|
|
15
15
|
Object.defineProperty(exports, "assertActorType", {
|
|
16
16
|
enumerable: true,
|
|
17
|
-
get: function () { return
|
|
17
|
+
get: function () { return chunkUGWGHTNC_cjs.assertActorType; }
|
|
18
18
|
});
|
|
19
19
|
Object.defineProperty(exports, "createActionRegistry", {
|
|
20
20
|
enumerable: true,
|
|
21
|
-
get: function () { return
|
|
21
|
+
get: function () { return chunkUGWGHTNC_cjs.createActionRegistry; }
|
|
22
22
|
});
|
|
23
23
|
Object.defineProperty(exports, "isActorType", {
|
|
24
24
|
enumerable: true,
|
|
25
|
-
get: function () { return
|
|
25
|
+
get: function () { return chunkUGWGHTNC_cjs.isActorType; }
|
|
26
26
|
});
|
|
27
27
|
//# sourceMappingURL=index.cjs.map
|
|
28
28
|
//# sourceMappingURL=index.cjs.map
|
package/dist/actions/index.d.cts
CHANGED
|
@@ -40,7 +40,72 @@ interface ActionContext<TDb = unknown> {
|
|
|
40
40
|
};
|
|
41
41
|
db: TDb;
|
|
42
42
|
services?: FabricRuntimeServices;
|
|
43
|
+
/**
|
|
44
|
+
* When the host admitted this invocation's submission through the
|
|
45
|
+
* action's `admitSubmission` check, read from the durable invocation
|
|
46
|
+
* record. Absent when the action declares no check, or when the runtime
|
|
47
|
+
* that built this context does not run declared admission checks (a host
|
|
48
|
+
* older than the one that introduced them). A handler whose action
|
|
49
|
+
* declares `admitSubmission` can refuse to run without it.
|
|
50
|
+
*/
|
|
51
|
+
admittedAt?: Date;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Trusted facts a host gives an action's `admitSubmission` check. Scope and
|
|
55
|
+
* actor come from the host's authenticated submission, never from the
|
|
56
|
+
* parameters.
|
|
57
|
+
*/
|
|
58
|
+
interface ActionAdmissionInput {
|
|
59
|
+
readonly actionId: ActionId;
|
|
60
|
+
readonly tenantId: string;
|
|
61
|
+
readonly spaceId: string;
|
|
62
|
+
readonly actorId: string;
|
|
63
|
+
readonly actorType: ActorType;
|
|
64
|
+
/**
|
|
65
|
+
* The submitted parameters, unvalidated, as a copy made for this check.
|
|
66
|
+
* `@fabricorg/platform-host` records its own copy of the same value, taken
|
|
67
|
+
* before the check ran, and only if the check admits it; changing this
|
|
68
|
+
* copy changes nothing the host records.
|
|
69
|
+
*/
|
|
70
|
+
readonly parameters: Readonly<Record<string, unknown>>;
|
|
71
|
+
/**
|
|
72
|
+
* Aborted when the host's admission deadline passes. Hosts pass an
|
|
73
|
+
* `AbortSignal`. `@fabricorg/platform-host` refuses an answer that settles
|
|
74
|
+
* at or after the deadline, even one that settles before the abort.
|
|
75
|
+
*/
|
|
76
|
+
readonly signal: ActionAdmissionSignal;
|
|
43
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The part of an `AbortSignal` an admission check can rely on. Declared
|
|
80
|
+
* structurally so this module compiles without DOM or Node type libraries; a
|
|
81
|
+
* host passes a real `AbortSignal`, which satisfies it.
|
|
82
|
+
*/
|
|
83
|
+
interface ActionAdmissionSignal {
|
|
84
|
+
readonly aborted: boolean;
|
|
85
|
+
addEventListener(type: "abort", listener: () => void, options?: {
|
|
86
|
+
once?: boolean;
|
|
87
|
+
}): void;
|
|
88
|
+
removeEventListener(type: "abort", listener: () => void): void;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* An admission check's answer. Only `{ admitted: true }` admits. A refusal's
|
|
92
|
+
* `code` must be a fixed code chosen by the action (lower-case first letter,
|
|
93
|
+
* then letters, digits, `_`, `.`, `:` or `-`, at most 128 characters), never
|
|
94
|
+
* a submitted value: the host puts it in the error it throws.
|
|
95
|
+
*/
|
|
96
|
+
type ActionAdmissionDecision = {
|
|
97
|
+
readonly admitted: true;
|
|
98
|
+
} | {
|
|
99
|
+
readonly admitted: false;
|
|
100
|
+
readonly code: string;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Decides whether a submission may be recorded at all. A host that supports
|
|
104
|
+
* it runs the check after authorizing the submitter and before it writes
|
|
105
|
+
* anything durable for the submission; a refusal, a throw, a timeout or any
|
|
106
|
+
* answer other than `{ admitted: true }` refuses the submission.
|
|
107
|
+
*/
|
|
108
|
+
type ActionAdmission = (input: ActionAdmissionInput) => Promise<ActionAdmissionDecision>;
|
|
44
109
|
interface ActionResult {
|
|
45
110
|
success: boolean;
|
|
46
111
|
data?: Record<string, unknown>;
|
|
@@ -70,6 +135,16 @@ interface ActionDefinition<TDb = unknown> extends SurfaceDeclaration {
|
|
|
70
135
|
namespace: string;
|
|
71
136
|
version: number;
|
|
72
137
|
schema: SchemaValidator;
|
|
138
|
+
/**
|
|
139
|
+
* Optional check a host runs on every submission before it records
|
|
140
|
+
* anything durable for it (invocation, idempotency key, events, errors).
|
|
141
|
+
* Use it when parameters must be validated against trusted context, for
|
|
142
|
+
* example a secret reference the tenant's secret store must hold, before
|
|
143
|
+
* they may be persisted. `@fabricorg/platform-host` runs it from the
|
|
144
|
+
* release that introduced it; an older host ignores it, so a handler that
|
|
145
|
+
* relies on it should require `ActionContext.admittedAt`.
|
|
146
|
+
*/
|
|
147
|
+
admitSubmission?: ActionAdmission;
|
|
73
148
|
/** Optional validation contract for the public handler result data. */
|
|
74
149
|
resultSchema?: SchemaValidator;
|
|
75
150
|
handler?: ActionHandler<TDb>;
|
|
@@ -130,8 +205,15 @@ interface SagaContext {
|
|
|
130
205
|
* Wait for a named external signal carrying a payload of `T`, with a timeout.
|
|
131
206
|
*
|
|
132
207
|
* Resolves with the payload of the first signal received, or `null` if the timeout
|
|
133
|
-
* fires first. Subsequent signals with the same name received before
|
|
134
|
-
* `awaitSignal` call are buffered
|
|
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.
|
|
135
217
|
*
|
|
136
218
|
* Intended for agent-driven and webhook-driven coordination: replace polling loops
|
|
137
219
|
* with push-based wakeups while preserving the saga's deterministic replay
|
|
@@ -184,4 +266,4 @@ interface ActionRegistry<TDb = unknown> {
|
|
|
184
266
|
}
|
|
185
267
|
declare function createActionRegistry<TDb = unknown>(definitions?: readonly ActionDefinition<TDb>[]): ActionRegistry<TDb>;
|
|
186
268
|
|
|
187
|
-
export { ACTOR_TYPES, type ActionCompletionMode, type ActionConnectivity, type ActionConsistency, type ActionContext, type ActionDataSensitivity, type ActionDefinition, type ActionExecutionContract, type ActionHandler, type ActionId, type ActionIdempotencyStance, type ActionRegistry, type ActionResult, type ActionStateMachineBinding, type ActionStatus, type ActorType, type AdapterStep, type AdapterStepRetryPolicy, type AuthorityMoment, type FabricRuntimeServices, type SagaChildActionInput, type SagaCompletion, type SagaCompletionEvent, type SagaContext, type SagaImplementation, type SagaRunChild, type SchemaParseIssue, type SchemaParseResult, type SchemaValidator, assertActionId, assertActorType, createActionRegistry, isActorType };
|
|
269
|
+
export { ACTOR_TYPES, type ActionAdmission, type ActionAdmissionDecision, type ActionAdmissionInput, type ActionAdmissionSignal, type ActionCompletionMode, type ActionConnectivity, type ActionConsistency, type ActionContext, type ActionDataSensitivity, type ActionDefinition, type ActionExecutionContract, type ActionHandler, type ActionId, type ActionIdempotencyStance, type ActionRegistry, type ActionResult, type ActionStateMachineBinding, type ActionStatus, type ActorType, type AdapterStep, type AdapterStepRetryPolicy, type AuthorityMoment, type FabricRuntimeServices, type SagaChildActionInput, type SagaCompletion, type SagaCompletionEvent, type SagaContext, type SagaImplementation, type SagaRunChild, type SchemaParseIssue, type SchemaParseResult, type SchemaValidator, assertActionId, assertActorType, createActionRegistry, isActorType };
|
package/dist/actions/index.d.ts
CHANGED
|
@@ -40,7 +40,72 @@ interface ActionContext<TDb = unknown> {
|
|
|
40
40
|
};
|
|
41
41
|
db: TDb;
|
|
42
42
|
services?: FabricRuntimeServices;
|
|
43
|
+
/**
|
|
44
|
+
* When the host admitted this invocation's submission through the
|
|
45
|
+
* action's `admitSubmission` check, read from the durable invocation
|
|
46
|
+
* record. Absent when the action declares no check, or when the runtime
|
|
47
|
+
* that built this context does not run declared admission checks (a host
|
|
48
|
+
* older than the one that introduced them). A handler whose action
|
|
49
|
+
* declares `admitSubmission` can refuse to run without it.
|
|
50
|
+
*/
|
|
51
|
+
admittedAt?: Date;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Trusted facts a host gives an action's `admitSubmission` check. Scope and
|
|
55
|
+
* actor come from the host's authenticated submission, never from the
|
|
56
|
+
* parameters.
|
|
57
|
+
*/
|
|
58
|
+
interface ActionAdmissionInput {
|
|
59
|
+
readonly actionId: ActionId;
|
|
60
|
+
readonly tenantId: string;
|
|
61
|
+
readonly spaceId: string;
|
|
62
|
+
readonly actorId: string;
|
|
63
|
+
readonly actorType: ActorType;
|
|
64
|
+
/**
|
|
65
|
+
* The submitted parameters, unvalidated, as a copy made for this check.
|
|
66
|
+
* `@fabricorg/platform-host` records its own copy of the same value, taken
|
|
67
|
+
* before the check ran, and only if the check admits it; changing this
|
|
68
|
+
* copy changes nothing the host records.
|
|
69
|
+
*/
|
|
70
|
+
readonly parameters: Readonly<Record<string, unknown>>;
|
|
71
|
+
/**
|
|
72
|
+
* Aborted when the host's admission deadline passes. Hosts pass an
|
|
73
|
+
* `AbortSignal`. `@fabricorg/platform-host` refuses an answer that settles
|
|
74
|
+
* at or after the deadline, even one that settles before the abort.
|
|
75
|
+
*/
|
|
76
|
+
readonly signal: ActionAdmissionSignal;
|
|
43
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The part of an `AbortSignal` an admission check can rely on. Declared
|
|
80
|
+
* structurally so this module compiles without DOM or Node type libraries; a
|
|
81
|
+
* host passes a real `AbortSignal`, which satisfies it.
|
|
82
|
+
*/
|
|
83
|
+
interface ActionAdmissionSignal {
|
|
84
|
+
readonly aborted: boolean;
|
|
85
|
+
addEventListener(type: "abort", listener: () => void, options?: {
|
|
86
|
+
once?: boolean;
|
|
87
|
+
}): void;
|
|
88
|
+
removeEventListener(type: "abort", listener: () => void): void;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* An admission check's answer. Only `{ admitted: true }` admits. A refusal's
|
|
92
|
+
* `code` must be a fixed code chosen by the action (lower-case first letter,
|
|
93
|
+
* then letters, digits, `_`, `.`, `:` or `-`, at most 128 characters), never
|
|
94
|
+
* a submitted value: the host puts it in the error it throws.
|
|
95
|
+
*/
|
|
96
|
+
type ActionAdmissionDecision = {
|
|
97
|
+
readonly admitted: true;
|
|
98
|
+
} | {
|
|
99
|
+
readonly admitted: false;
|
|
100
|
+
readonly code: string;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Decides whether a submission may be recorded at all. A host that supports
|
|
104
|
+
* it runs the check after authorizing the submitter and before it writes
|
|
105
|
+
* anything durable for the submission; a refusal, a throw, a timeout or any
|
|
106
|
+
* answer other than `{ admitted: true }` refuses the submission.
|
|
107
|
+
*/
|
|
108
|
+
type ActionAdmission = (input: ActionAdmissionInput) => Promise<ActionAdmissionDecision>;
|
|
44
109
|
interface ActionResult {
|
|
45
110
|
success: boolean;
|
|
46
111
|
data?: Record<string, unknown>;
|
|
@@ -70,6 +135,16 @@ interface ActionDefinition<TDb = unknown> extends SurfaceDeclaration {
|
|
|
70
135
|
namespace: string;
|
|
71
136
|
version: number;
|
|
72
137
|
schema: SchemaValidator;
|
|
138
|
+
/**
|
|
139
|
+
* Optional check a host runs on every submission before it records
|
|
140
|
+
* anything durable for it (invocation, idempotency key, events, errors).
|
|
141
|
+
* Use it when parameters must be validated against trusted context, for
|
|
142
|
+
* example a secret reference the tenant's secret store must hold, before
|
|
143
|
+
* they may be persisted. `@fabricorg/platform-host` runs it from the
|
|
144
|
+
* release that introduced it; an older host ignores it, so a handler that
|
|
145
|
+
* relies on it should require `ActionContext.admittedAt`.
|
|
146
|
+
*/
|
|
147
|
+
admitSubmission?: ActionAdmission;
|
|
73
148
|
/** Optional validation contract for the public handler result data. */
|
|
74
149
|
resultSchema?: SchemaValidator;
|
|
75
150
|
handler?: ActionHandler<TDb>;
|
|
@@ -130,8 +205,15 @@ interface SagaContext {
|
|
|
130
205
|
* Wait for a named external signal carrying a payload of `T`, with a timeout.
|
|
131
206
|
*
|
|
132
207
|
* Resolves with the payload of the first signal received, or `null` if the timeout
|
|
133
|
-
* fires first. Subsequent signals with the same name received before
|
|
134
|
-
* `awaitSignal` call are buffered
|
|
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.
|
|
135
217
|
*
|
|
136
218
|
* Intended for agent-driven and webhook-driven coordination: replace polling loops
|
|
137
219
|
* with push-based wakeups while preserving the saga's deterministic replay
|
|
@@ -184,4 +266,4 @@ interface ActionRegistry<TDb = unknown> {
|
|
|
184
266
|
}
|
|
185
267
|
declare function createActionRegistry<TDb = unknown>(definitions?: readonly ActionDefinition<TDb>[]): ActionRegistry<TDb>;
|
|
186
268
|
|
|
187
|
-
export { ACTOR_TYPES, type ActionCompletionMode, type ActionConnectivity, type ActionConsistency, type ActionContext, type ActionDataSensitivity, type ActionDefinition, type ActionExecutionContract, type ActionHandler, type ActionId, type ActionIdempotencyStance, type ActionRegistry, type ActionResult, type ActionStateMachineBinding, type ActionStatus, type ActorType, type AdapterStep, type AdapterStepRetryPolicy, type AuthorityMoment, type FabricRuntimeServices, type SagaChildActionInput, type SagaCompletion, type SagaCompletionEvent, type SagaContext, type SagaImplementation, type SagaRunChild, type SchemaParseIssue, type SchemaParseResult, type SchemaValidator, assertActionId, assertActorType, createActionRegistry, isActorType };
|
|
269
|
+
export { ACTOR_TYPES, type ActionAdmission, type ActionAdmissionDecision, type ActionAdmissionInput, type ActionAdmissionSignal, type ActionCompletionMode, type ActionConnectivity, type ActionConsistency, type ActionContext, type ActionDataSensitivity, type ActionDefinition, type ActionExecutionContract, type ActionHandler, type ActionId, type ActionIdempotencyStance, type ActionRegistry, type ActionResult, type ActionStateMachineBinding, type ActionStatus, type ActorType, type AdapterStep, type AdapterStepRetryPolicy, type AuthorityMoment, type FabricRuntimeServices, type SagaChildActionInput, type SagaCompletion, type SagaCompletionEvent, type SagaContext, type SagaImplementation, type SagaRunChild, type SchemaParseIssue, type SchemaParseResult, type SchemaValidator, assertActionId, assertActorType, createActionRegistry, isActorType };
|