@fabricorg/platform-host 8.9.0 → 8.10.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 +12 -0
- package/README.md +23 -2
- package/dist/index.cjs +391 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -72
- package/dist/index.d.ts +120 -72
- package/dist/index.js +390 -124
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @fabricorg/platform-host
|
|
2
2
|
|
|
3
|
+
## 8.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6147d14: Add read-only, principal-scoped historical submission receipts without rerunning submission checks. Preserve submission ordering, hide receipts belonging to other principals, and expose erased records without refusal evidence.
|
|
8
|
+
|
|
9
|
+
Store conformance checks verify each receipt field and report skipped optional refusal and erasure probes. Normalize absent PostgreSQL refusal metadata to `undefined` in the receipt projection.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- fbbc664: Treat an empty authorization binding id as absent for submission idempotency identity and receipt ownership. Normalize new submissions and match stored empty binding ids from older Hosts, including recorded refusals, while preserving conflicts with non-empty binding ids.
|
|
14
|
+
|
|
3
15
|
## 8.9.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -255,7 +255,9 @@ recovery within `recoveryMaxAttempts`. A handler error without eligible confirme
|
|
|
255
255
|
not an automatic retry or a claim that the current transaction committed.
|
|
256
256
|
|
|
257
257
|
New rows bind that key to the action version, actor/authority binding, and a versioned canonical
|
|
258
|
-
parameter digest.
|
|
258
|
+
parameter digest. An empty `authorizationBindingId` is treated as absent for submission
|
|
259
|
+
idempotency identity and receipt ownership, including stored empty IDs from older Hosts.
|
|
260
|
+
Host 3 defaults to `enforce`, which throws `IdempotencyConflictError`; configure
|
|
259
261
|
`idempotency.conflictMode` as `audit-only` for a preflight soak. Legacy null-digest rows skip only the
|
|
260
262
|
parameter comparison and call `onLegacyRecord`; actor, authority-binding, and action-version mismatches
|
|
261
263
|
still reject in enforce mode. See `MIGRATION-2-IDEMPOTENCY.md`.
|
|
@@ -1348,7 +1350,7 @@ these rows inert. The stored-record erasure operator can redact their payload fi
|
|
|
1348
1350
|
Host does not schedule automatic retention. See **Erasing stored records**.
|
|
1349
1351
|
|
|
1350
1352
|
Entitlement, authorization and admission checks still run on replay before Host tries an insert;
|
|
1351
|
-
there is no refusal lookup before the checks. A matching caller receives the stored refusal,
|
|
1353
|
+
there is no refusal lookup before the checks (use the separate `readSubmissionOutcome` receipt read below). A matching caller receives the stored refusal,
|
|
1352
1354
|
including after the checks start passing. Identity compares actor id and type, authorization
|
|
1353
1355
|
binding id, action version, and a parameter digest when the stored refusal has one. A refused
|
|
1354
1356
|
caller with a different identity receives its own unrecorded refusal. An admitted caller with a
|
|
@@ -1403,6 +1405,25 @@ submitted …"). Before submitting an action that declares `admitSubmission`: (1
|
|
|
1403
1405
|
migration 6, and nothing is written; (2) upgrade every worker, since a worker on an older Host would
|
|
1404
1406
|
execute such an invocation without checking `admittedAt`.
|
|
1405
1407
|
|
|
1408
|
+
### Read a historical submission receipt
|
|
1409
|
+
|
|
1410
|
+
`host.readSubmissionOutcome({ tenantId, spaceId, actionId, idempotencyKey, actorId,
|
|
1411
|
+
actorType, authorizationBindingId })` reads receipt metadata without rerunning mutation
|
|
1412
|
+
permission or admission checks. The embedding application must supply trusted, authenticated
|
|
1413
|
+
tenant, space and actor identity. The original principal can read its receipt after mutation
|
|
1414
|
+
permission is revoked. The key is exact, including the valid empty string.
|
|
1415
|
+
|
|
1416
|
+
A refusal error with `recorded: false` establishes nothing about key ownership: an admitted
|
|
1417
|
+
invocation or another principal's record may hold it. Use the receipt read to observe a
|
|
1418
|
+
visible durable outcome. `no_record` means no record visible to this principal at this
|
|
1419
|
+
snapshot; it does not establish that the key is free. A returned invocation status is an
|
|
1420
|
+
observation and may change. Refused receipts include only their validated descriptor;
|
|
1421
|
+
erased receipts include only identity, version and recorded status. No pending work is dispatched.
|
|
1422
|
+
|
|
1423
|
+
The operation is available independently of refusal recording and conflict mode. Custom
|
|
1424
|
+
stores implement optional `getSubmissionOutcomeByKey`; an unsupported store throws
|
|
1425
|
+
`SubmissionOutcomeUnsupportedError`. Storage errors propagate. See ADR 0022.
|
|
1426
|
+
|
|
1406
1427
|
## External mutation governance
|
|
1407
1428
|
|
|
1408
1429
|
Hosts can configure `resolveMutationGovernance` to persist an approved, provider-neutral
|
package/dist/index.cjs
CHANGED
|
@@ -4,6 +4,201 @@ let _fabricorg_platform = require("@fabricorg/platform");
|
|
|
4
4
|
let perf_hooks = require("perf_hooks");
|
|
5
5
|
let _fabricorg_assembly = require("@fabricorg/assembly");
|
|
6
6
|
let crypto = require("crypto");
|
|
7
|
+
//#region src/erasure-types.ts
|
|
8
|
+
const HOST_RECORDS_ERASED_EVENT = "HostRecordsErased";
|
|
9
|
+
/** Sentinel replacing required payload digests after stored-record erasure. */
|
|
10
|
+
const ERASED_DIGEST = "erased";
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/types.ts
|
|
13
|
+
/** Durable Host lifecycle generation, independent from the npm package version. */
|
|
14
|
+
const PLATFORM_HOST_CONTRACT_VERSION = 2;
|
|
15
|
+
var IdempotencyConflictError = class extends Error {
|
|
16
|
+
conflict;
|
|
17
|
+
code = "IDEMPOTENCY_CONFLICT";
|
|
18
|
+
constructor(conflict) {
|
|
19
|
+
super(`Idempotency key "${conflict.idempotencyKey}" conflicts with invocation ${conflict.existingInvocationId}: ${conflict.reasons.join(", ")}`);
|
|
20
|
+
this.conflict = conflict;
|
|
21
|
+
this.name = "IdempotencyConflictError";
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Thrown by `submitAction` when a declared admission check did not admit the
|
|
26
|
+
* submission. By default Host writes nothing for it. With recordRefusals
|
|
27
|
+
* enabled and an idempotency key, a definitive refusal can be recorded;
|
|
28
|
+
* `recorded` confirms persistence of the terminal refusal. The message holds
|
|
29
|
+
* the action id, the refusal and, for `refused`, the check's fixed code. It omits parameter
|
|
30
|
+
* payloads and caught callback error text.
|
|
31
|
+
*/
|
|
32
|
+
var ActionAdmissionError = class extends Error {
|
|
33
|
+
actionId;
|
|
34
|
+
refusal;
|
|
35
|
+
refusalCode;
|
|
36
|
+
recorded;
|
|
37
|
+
code = "ACTION_ADMISSION_REFUSED";
|
|
38
|
+
constructor(actionId, refusal, refusalCode, recorded = false) {
|
|
39
|
+
super(`Action ${actionId} submission was not admitted (${refusal}${refusalCode ? `: ${refusalCode}` : ""}); ${recorded ? "the refusal was recorded under the idempotency key" : "nothing was recorded"}.`);
|
|
40
|
+
this.actionId = actionId;
|
|
41
|
+
this.refusal = refusal;
|
|
42
|
+
this.refusalCode = refusalCode;
|
|
43
|
+
this.recorded = recorded;
|
|
44
|
+
this.name = "ActionAdmissionError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** A definitive authorization-boundary refusal before an invocation is created. */
|
|
48
|
+
var SubmissionRefusalError = class extends Error {
|
|
49
|
+
actionId;
|
|
50
|
+
refusal;
|
|
51
|
+
recorded;
|
|
52
|
+
code = "ACTION_SUBMISSION_REFUSED";
|
|
53
|
+
constructor(actionId, refusal, message, recorded = false) {
|
|
54
|
+
super(recorded ? `Action ${actionId} submission was refused (${refusal}); the refusal was recorded under the idempotency key.` : message);
|
|
55
|
+
this.actionId = actionId;
|
|
56
|
+
this.refusal = refusal;
|
|
57
|
+
this.recorded = recorded;
|
|
58
|
+
this.name = "SubmissionRefusalError";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Why a completion was not applied, typed so a webhook handler can answer
|
|
63
|
+
* the external system correctly: `retryable` means the invocation is still
|
|
64
|
+
* being finalized and the same callback will be accepted once it parks, so
|
|
65
|
+
* answer with something the sender retries; anything else is permanent and
|
|
66
|
+
* a retry will get the same refusal without a configuration/input change.
|
|
67
|
+
* `redaction_failed` means completion result or error-text redaction failed before writes;
|
|
68
|
+
* it is not retryable.
|
|
69
|
+
*/
|
|
70
|
+
var ExternalCompletionError = class extends Error {
|
|
71
|
+
refusal;
|
|
72
|
+
code = "EXTERNAL_COMPLETION_REFUSED";
|
|
73
|
+
constructor(refusal, message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.refusal = refusal;
|
|
76
|
+
this.name = "ExternalCompletionError";
|
|
77
|
+
}
|
|
78
|
+
get retryable() {
|
|
79
|
+
return this.refusal === "still_executing";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** Maximum serialized UTF-8 evidence size. */
|
|
83
|
+
const MAX_APPROVAL_EVIDENCE_BYTES = 16384;
|
|
84
|
+
const APPROVAL_REQUESTED_EVENT = "ApprovalRequested";
|
|
85
|
+
const APPROVAL_DECIDED_EVENT = "ApprovalDecided";
|
|
86
|
+
const APPROVAL_EXPIRED_EVENT = "ApprovalExpired";
|
|
87
|
+
const APPROVAL_DECISION_REFUSED_EVENT = "ApprovalDecisionRefused";
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/submission-refusal.ts
|
|
90
|
+
/** Validate and copy only the descriptor's declared fields, on both sides of storage. */
|
|
91
|
+
function submissionRefusalDescriptor(value) {
|
|
92
|
+
if (!value || typeof value !== "object") throw new Error("Invalid submission refusal descriptor.");
|
|
93
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
94
|
+
const data = (key) => fields[key]?.value;
|
|
95
|
+
const kind = data("class");
|
|
96
|
+
const keys = Reflect.ownKeys(value);
|
|
97
|
+
if ((kind === "entitlement" || kind === "authorization") && keys.length === 1) return { class: kind };
|
|
98
|
+
if (kind === "admission" && keys.every((key) => key === "class" || key === "refusal" || key === "refusalCode")) {
|
|
99
|
+
const refusal = data("refusal");
|
|
100
|
+
const code = data("refusalCode");
|
|
101
|
+
if (refusal === "invalid_parameters" && !Object.hasOwn(fields, "refusalCode")) return {
|
|
102
|
+
class: kind,
|
|
103
|
+
refusal
|
|
104
|
+
};
|
|
105
|
+
if (refusal === "refused" && (!Object.hasOwn(fields, "refusalCode") || typeof code === "string" && /^[a-z][A-Za-z0-9_.:-]{0,127}$/.test(code))) return {
|
|
106
|
+
class: kind,
|
|
107
|
+
refusal,
|
|
108
|
+
...code === void 0 ? {} : { refusalCode: code }
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
throw new Error("Invalid submission refusal descriptor.");
|
|
112
|
+
}
|
|
113
|
+
function validateRefusalRecord(record) {
|
|
114
|
+
if (record?.status === "submission_refused") submissionRefusalDescriptor(record.submissionRefusal);
|
|
115
|
+
return record;
|
|
116
|
+
}
|
|
117
|
+
/** Reconstruct errors from fixed validated evidence, without row error text or parameters. */
|
|
118
|
+
function recordedSubmissionRefusal(record) {
|
|
119
|
+
const descriptor = submissionRefusalDescriptor(record.submissionRefusal);
|
|
120
|
+
return descriptor.class === "admission" ? new ActionAdmissionError(record.actionId, descriptor.refusal, descriptor.refusalCode, true) : new SubmissionRefusalError(record.actionId, descriptor.class, "", true);
|
|
121
|
+
}
|
|
122
|
+
/** Refusals enter the ledger through the narrow insert, rather than generic status writes. */
|
|
123
|
+
function assertInvocationWriteStatus(status) {
|
|
124
|
+
if (status === "submission_refused") throw new Error("submission_refused can only be inserted by recordSubmissionRefusal.");
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/submission-outcome.ts
|
|
128
|
+
var SubmissionOutcomeUnsupportedError = class extends Error {
|
|
129
|
+
constructor() {
|
|
130
|
+
super("Store lacks getSubmissionOutcomeByKey.");
|
|
131
|
+
this.name = "SubmissionOutcomeUnsupportedError";
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
/** Copy receipt fields only, without exposing execution data. */
|
|
135
|
+
function submissionOutcomeProjection(row) {
|
|
136
|
+
return structuredClone({
|
|
137
|
+
tenantId: row.tenantId,
|
|
138
|
+
spaceId: row.spaceId,
|
|
139
|
+
actionId: row.actionId,
|
|
140
|
+
idempotencyKey: row.idempotencyKey,
|
|
141
|
+
id: row.id,
|
|
142
|
+
actionVersion: row.actionVersion,
|
|
143
|
+
actorId: row.actorId,
|
|
144
|
+
actorType: row.actorType,
|
|
145
|
+
authorizationBindingId: row.authorizationBindingId,
|
|
146
|
+
idempotencyActorId: row.idempotencyActorId,
|
|
147
|
+
idempotencyAuthorizationBindingId: row.idempotencyAuthorizationBindingId,
|
|
148
|
+
status: row.status,
|
|
149
|
+
submissionRefusal: row.submissionRefusal,
|
|
150
|
+
storedRecordsErased: row.storedRecordsErased
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function createSubmissionOutcomeReader(store) {
|
|
154
|
+
return { async readSubmissionOutcome(input) {
|
|
155
|
+
const snapshot = {
|
|
156
|
+
tenantId: input.tenantId,
|
|
157
|
+
spaceId: input.spaceId,
|
|
158
|
+
actionId: input.actionId,
|
|
159
|
+
idempotencyKey: input.idempotencyKey,
|
|
160
|
+
actorId: input.actorId,
|
|
161
|
+
actorType: input.actorType,
|
|
162
|
+
authorizationBindingId: input.authorizationBindingId
|
|
163
|
+
};
|
|
164
|
+
for (const field of [
|
|
165
|
+
"tenantId",
|
|
166
|
+
"spaceId",
|
|
167
|
+
"actionId",
|
|
168
|
+
"idempotencyKey",
|
|
169
|
+
"actorId",
|
|
170
|
+
"actorType"
|
|
171
|
+
]) if (typeof snapshot[field] !== "string") throw new TypeError(`Invalid ${field}.`);
|
|
172
|
+
if (!store.getSubmissionOutcomeByKey) throw new SubmissionOutcomeUnsupportedError();
|
|
173
|
+
const row = await store.getSubmissionOutcomeByKey({
|
|
174
|
+
tenantId: snapshot.tenantId,
|
|
175
|
+
spaceId: snapshot.spaceId,
|
|
176
|
+
actionId: snapshot.actionId,
|
|
177
|
+
idempotencyKey: snapshot.idempotencyKey
|
|
178
|
+
});
|
|
179
|
+
if (!row || (row.idempotencyActorId ?? row.actorId) !== snapshot.actorId || row.actorType !== snapshot.actorType || ((row.idempotencyAuthorizationBindingId ?? row.authorizationBindingId) || void 0) !== (snapshot.authorizationBindingId || void 0)) return { kind: "no_record" };
|
|
180
|
+
const identity = {
|
|
181
|
+
actionInvocationId: row.id,
|
|
182
|
+
actionVersion: row.actionVersion
|
|
183
|
+
};
|
|
184
|
+
if (row.storedRecordsErased) return {
|
|
185
|
+
...identity,
|
|
186
|
+
kind: "records_erased",
|
|
187
|
+
recordedStatus: row.status
|
|
188
|
+
};
|
|
189
|
+
if (row.status === "submission_refused") return {
|
|
190
|
+
...identity,
|
|
191
|
+
kind: "submission_refused",
|
|
192
|
+
submissionRefusal: submissionRefusalDescriptor(row.submissionRefusal)
|
|
193
|
+
};
|
|
194
|
+
return {
|
|
195
|
+
...identity,
|
|
196
|
+
kind: "invocation",
|
|
197
|
+
status: row.status
|
|
198
|
+
};
|
|
199
|
+
} };
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
7
202
|
//#region src/transaction-rollback.ts
|
|
8
203
|
/** Evidence from a transaction provider that rollback completed successfully. */
|
|
9
204
|
var ConfirmedTransactionRollback = class extends Error {
|
|
@@ -182,126 +377,6 @@ function createStoredRecordErasureHost(store, authorization) {
|
|
|
182
377
|
} };
|
|
183
378
|
}
|
|
184
379
|
//#endregion
|
|
185
|
-
//#region src/erasure-types.ts
|
|
186
|
-
const HOST_RECORDS_ERASED_EVENT = "HostRecordsErased";
|
|
187
|
-
/** Sentinel replacing required payload digests after stored-record erasure. */
|
|
188
|
-
const ERASED_DIGEST = "erased";
|
|
189
|
-
//#endregion
|
|
190
|
-
//#region src/types.ts
|
|
191
|
-
/** Durable Host lifecycle generation, independent from the npm package version. */
|
|
192
|
-
const PLATFORM_HOST_CONTRACT_VERSION = 2;
|
|
193
|
-
var IdempotencyConflictError = class extends Error {
|
|
194
|
-
conflict;
|
|
195
|
-
code = "IDEMPOTENCY_CONFLICT";
|
|
196
|
-
constructor(conflict) {
|
|
197
|
-
super(`Idempotency key "${conflict.idempotencyKey}" conflicts with invocation ${conflict.existingInvocationId}: ${conflict.reasons.join(", ")}`);
|
|
198
|
-
this.conflict = conflict;
|
|
199
|
-
this.name = "IdempotencyConflictError";
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
/**
|
|
203
|
-
* Thrown by `submitAction` when a declared admission check did not admit the
|
|
204
|
-
* submission. By default Host writes nothing for it. With recordRefusals
|
|
205
|
-
* enabled and an idempotency key, a definitive refusal can be recorded;
|
|
206
|
-
* `recorded` confirms persistence of the terminal refusal. The message holds
|
|
207
|
-
* the action id, the refusal and, for `refused`, the check's fixed code. It omits parameter
|
|
208
|
-
* payloads and caught callback error text.
|
|
209
|
-
*/
|
|
210
|
-
var ActionAdmissionError = class extends Error {
|
|
211
|
-
actionId;
|
|
212
|
-
refusal;
|
|
213
|
-
refusalCode;
|
|
214
|
-
recorded;
|
|
215
|
-
code = "ACTION_ADMISSION_REFUSED";
|
|
216
|
-
constructor(actionId, refusal, refusalCode, recorded = false) {
|
|
217
|
-
super(`Action ${actionId} submission was not admitted (${refusal}${refusalCode ? `: ${refusalCode}` : ""}); ${recorded ? "the refusal was recorded under the idempotency key" : "nothing was recorded"}.`);
|
|
218
|
-
this.actionId = actionId;
|
|
219
|
-
this.refusal = refusal;
|
|
220
|
-
this.refusalCode = refusalCode;
|
|
221
|
-
this.recorded = recorded;
|
|
222
|
-
this.name = "ActionAdmissionError";
|
|
223
|
-
}
|
|
224
|
-
};
|
|
225
|
-
/** A definitive authorization-boundary refusal before an invocation is created. */
|
|
226
|
-
var SubmissionRefusalError = class extends Error {
|
|
227
|
-
actionId;
|
|
228
|
-
refusal;
|
|
229
|
-
recorded;
|
|
230
|
-
code = "ACTION_SUBMISSION_REFUSED";
|
|
231
|
-
constructor(actionId, refusal, message, recorded = false) {
|
|
232
|
-
super(recorded ? `Action ${actionId} submission was refused (${refusal}); the refusal was recorded under the idempotency key.` : message);
|
|
233
|
-
this.actionId = actionId;
|
|
234
|
-
this.refusal = refusal;
|
|
235
|
-
this.recorded = recorded;
|
|
236
|
-
this.name = "SubmissionRefusalError";
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
/**
|
|
240
|
-
* Why a completion was not applied, typed so a webhook handler can answer
|
|
241
|
-
* the external system correctly: `retryable` means the invocation is still
|
|
242
|
-
* being finalized and the same callback will be accepted once it parks, so
|
|
243
|
-
* answer with something the sender retries; anything else is permanent and
|
|
244
|
-
* a retry will get the same refusal without a configuration/input change.
|
|
245
|
-
* `redaction_failed` means completion result or error-text redaction failed before writes;
|
|
246
|
-
* it is not retryable.
|
|
247
|
-
*/
|
|
248
|
-
var ExternalCompletionError = class extends Error {
|
|
249
|
-
refusal;
|
|
250
|
-
code = "EXTERNAL_COMPLETION_REFUSED";
|
|
251
|
-
constructor(refusal, message) {
|
|
252
|
-
super(message);
|
|
253
|
-
this.refusal = refusal;
|
|
254
|
-
this.name = "ExternalCompletionError";
|
|
255
|
-
}
|
|
256
|
-
get retryable() {
|
|
257
|
-
return this.refusal === "still_executing";
|
|
258
|
-
}
|
|
259
|
-
};
|
|
260
|
-
/** Maximum serialized UTF-8 evidence size. */
|
|
261
|
-
const MAX_APPROVAL_EVIDENCE_BYTES = 16384;
|
|
262
|
-
const APPROVAL_REQUESTED_EVENT = "ApprovalRequested";
|
|
263
|
-
const APPROVAL_DECIDED_EVENT = "ApprovalDecided";
|
|
264
|
-
const APPROVAL_EXPIRED_EVENT = "ApprovalExpired";
|
|
265
|
-
const APPROVAL_DECISION_REFUSED_EVENT = "ApprovalDecisionRefused";
|
|
266
|
-
//#endregion
|
|
267
|
-
//#region src/submission-refusal.ts
|
|
268
|
-
/** Validate and copy only the descriptor's declared fields, on both sides of storage. */
|
|
269
|
-
function submissionRefusalDescriptor(value) {
|
|
270
|
-
if (!value || typeof value !== "object") throw new Error("Invalid submission refusal descriptor.");
|
|
271
|
-
const fields = Object.getOwnPropertyDescriptors(value);
|
|
272
|
-
const data = (key) => fields[key]?.value;
|
|
273
|
-
const kind = data("class");
|
|
274
|
-
const keys = Reflect.ownKeys(value);
|
|
275
|
-
if ((kind === "entitlement" || kind === "authorization") && keys.length === 1) return { class: kind };
|
|
276
|
-
if (kind === "admission" && keys.every((key) => key === "class" || key === "refusal" || key === "refusalCode")) {
|
|
277
|
-
const refusal = data("refusal");
|
|
278
|
-
const code = data("refusalCode");
|
|
279
|
-
if (refusal === "invalid_parameters" && !Object.hasOwn(fields, "refusalCode")) return {
|
|
280
|
-
class: kind,
|
|
281
|
-
refusal
|
|
282
|
-
};
|
|
283
|
-
if (refusal === "refused" && (!Object.hasOwn(fields, "refusalCode") || typeof code === "string" && /^[a-z][A-Za-z0-9_.:-]{0,127}$/.test(code))) return {
|
|
284
|
-
class: kind,
|
|
285
|
-
refusal,
|
|
286
|
-
...code === void 0 ? {} : { refusalCode: code }
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
throw new Error("Invalid submission refusal descriptor.");
|
|
290
|
-
}
|
|
291
|
-
function validateRefusalRecord(record) {
|
|
292
|
-
if (record?.status === "submission_refused") submissionRefusalDescriptor(record.submissionRefusal);
|
|
293
|
-
return record;
|
|
294
|
-
}
|
|
295
|
-
/** Reconstruct errors from fixed validated evidence, without row error text or parameters. */
|
|
296
|
-
function recordedSubmissionRefusal(record) {
|
|
297
|
-
const descriptor = submissionRefusalDescriptor(record.submissionRefusal);
|
|
298
|
-
return descriptor.class === "admission" ? new ActionAdmissionError(record.actionId, descriptor.refusal, descriptor.refusalCode, true) : new SubmissionRefusalError(record.actionId, descriptor.class, "", true);
|
|
299
|
-
}
|
|
300
|
-
/** Refusals enter the ledger through the narrow insert, rather than generic status writes. */
|
|
301
|
-
function assertInvocationWriteStatus(status) {
|
|
302
|
-
if (status === "submission_refused") throw new Error("submission_refused can only be inserted by recordSubmissionRefusal.");
|
|
303
|
-
}
|
|
304
|
-
//#endregion
|
|
305
380
|
//#region src/action-finalization.ts
|
|
306
381
|
/** Remove private preparation and restore its compatible protocol on settlement. */
|
|
307
382
|
function finalizationSettlementPatch(record, patch) {
|
|
@@ -1393,7 +1468,7 @@ function snapshotSubmission(input) {
|
|
|
1393
1468
|
...causationId != null ? { causationId } : {},
|
|
1394
1469
|
...executionReason != null ? { executionReason } : {},
|
|
1395
1470
|
...idempotencyKey != null ? { idempotencyKey } : {},
|
|
1396
|
-
...authorizationBindingId != null ? { authorizationBindingId } : {},
|
|
1471
|
+
...authorizationBindingId != null && authorizationBindingId !== "" ? { authorizationBindingId } : {},
|
|
1397
1472
|
parametersArePrivate: false
|
|
1398
1473
|
};
|
|
1399
1474
|
Object.defineProperties(live, {
|
|
@@ -4961,6 +5036,7 @@ function createGovernedActionHost(options) {
|
|
|
4961
5036
|
}
|
|
4962
5037
|
return {
|
|
4963
5038
|
...createStoredRecordErasureHost(options.store, options.authorization),
|
|
5039
|
+
...createSubmissionOutcomeReader(options.store),
|
|
4964
5040
|
submitAction,
|
|
4965
5041
|
hasStagedInvocationActions: declaresStaging,
|
|
4966
5042
|
...stagedStore ? { dispatchStagedInvocations } : {},
|
|
@@ -5074,7 +5150,7 @@ function idempotencyConflict(existing, incoming) {
|
|
|
5074
5150
|
const reasons = [];
|
|
5075
5151
|
if (!existing.storedRecordsErased && digestParameters(existing.agentProposal ?? null) !== digestParameters(incoming.agentProposal ?? null)) reasons.push("agent_proposal");
|
|
5076
5152
|
if ((existing.idempotencyActorId ?? existing.actorId) !== incoming.actorId) reasons.push("actor");
|
|
5077
|
-
if ((existing.idempotencyAuthorizationBindingId ?? existing.authorizationBindingId) !== incoming.authorizationBindingId) reasons.push("authority_binding");
|
|
5153
|
+
if (((existing.idempotencyAuthorizationBindingId ?? existing.authorizationBindingId) || void 0) !== (incoming.authorizationBindingId || void 0)) reasons.push("authority_binding");
|
|
5078
5154
|
if (existing.actionVersion !== incoming.actionVersion) reasons.push("action_version");
|
|
5079
5155
|
if (!existing.storedRecordsErased && existing.parameterDigest !== incoming.parameterDigest) reasons.push("parameters");
|
|
5080
5156
|
return reasons.length ? {
|
|
@@ -5091,7 +5167,7 @@ function isKnownAdapterOutcome(outcome) {
|
|
|
5091
5167
|
function refusalIdentityConflict(row, submission, actionVersion, parameterDigest) {
|
|
5092
5168
|
const reasons = [];
|
|
5093
5169
|
if (row.actorId !== submission.actorId || row.actorType !== submission.actorType) reasons.push("actor");
|
|
5094
|
-
if (row.authorizationBindingId !== submission.authorizationBindingId) reasons.push("authority_binding");
|
|
5170
|
+
if ((row.authorizationBindingId || void 0) !== (submission.authorizationBindingId || void 0)) reasons.push("authority_binding");
|
|
5095
5171
|
if (row.actionVersion !== actionVersion) reasons.push("action_version");
|
|
5096
5172
|
if (!row.storedRecordsErased && row.parameterDigest !== void 0 && row.parameterDigest !== parameterDigest) reasons.push("parameters");
|
|
5097
5173
|
return reasons.length ? {
|
|
@@ -5716,6 +5792,13 @@ function emitRelayTelemetry(telemetry, name, occurredAt, workerId, attributes) {
|
|
|
5716
5792
|
//#region src/memory-store.ts
|
|
5717
5793
|
var MemoryPlatformHostStore = class {
|
|
5718
5794
|
db;
|
|
5795
|
+
async getSubmissionOutcomeByKey(key) {
|
|
5796
|
+
const row = this.invocations.find((row) => row.tenantId === key.tenantId && row.spaceId === key.spaceId && row.actionId === key.actionId && row.idempotencyKey !== void 0 && row.idempotencyKey === key.idempotencyKey);
|
|
5797
|
+
return row ? submissionOutcomeProjection({
|
|
5798
|
+
...row,
|
|
5799
|
+
idempotencyKey: row.idempotencyKey
|
|
5800
|
+
}) : void 0;
|
|
5801
|
+
}
|
|
5719
5802
|
transactionalOutbox;
|
|
5720
5803
|
transactionalApprovalLifecycle;
|
|
5721
5804
|
transactionalApprovalAuthorization = true;
|
|
@@ -7482,6 +7565,34 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
|
|
|
7482
7565
|
async transaction(run) {
|
|
7483
7566
|
return run(this.db);
|
|
7484
7567
|
}
|
|
7568
|
+
async getSubmissionOutcomeByKey(key) {
|
|
7569
|
+
const row = (await this.sql.query(`SELECT id,tenant_id,space_id,action_id,idempotency_key,action_version,actor_id,actor_type,
|
|
7570
|
+
authorization_binding_id,idempotency_actor_id,idempotency_authorization_binding_id,status,
|
|
7571
|
+
submission_refusal,records_erased_at FROM fabric_platform.action_invocations
|
|
7572
|
+
WHERE tenant_id=$1 AND space_id=$2 AND action_id=$3 AND idempotency_key=$4`, [
|
|
7573
|
+
key.tenantId,
|
|
7574
|
+
key.spaceId,
|
|
7575
|
+
key.actionId,
|
|
7576
|
+
key.idempotencyKey
|
|
7577
|
+
])).rows[0];
|
|
7578
|
+
if (!row) return void 0;
|
|
7579
|
+
return {
|
|
7580
|
+
tenantId: row.tenant_id,
|
|
7581
|
+
spaceId: row.space_id,
|
|
7582
|
+
actionId: row.action_id,
|
|
7583
|
+
idempotencyKey: row.idempotency_key,
|
|
7584
|
+
id: row.id,
|
|
7585
|
+
actionVersion: Number(row.action_version),
|
|
7586
|
+
actorId: row.actor_id,
|
|
7587
|
+
actorType: row.actor_type,
|
|
7588
|
+
authorizationBindingId: row.authorization_binding_id ?? void 0,
|
|
7589
|
+
idempotencyActorId: row.idempotency_actor_id ?? void 0,
|
|
7590
|
+
idempotencyAuthorizationBindingId: row.idempotency_authorization_binding_id ?? void 0,
|
|
7591
|
+
status: row.status,
|
|
7592
|
+
submissionRefusal: row.submission_refusal ?? void 0,
|
|
7593
|
+
storedRecordsErased: row.records_erased_at ? true : void 0
|
|
7594
|
+
};
|
|
7595
|
+
}
|
|
7485
7596
|
async createActionInvocation(input) {
|
|
7486
7597
|
assertInvocationWriteStatus(input.status);
|
|
7487
7598
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -14300,6 +14411,161 @@ async function runInvocationIdempotencyChecks(subject) {
|
|
|
14300
14411
|
};
|
|
14301
14412
|
}
|
|
14302
14413
|
//#endregion
|
|
14414
|
+
//#region src/submission-outcome-conformance.ts
|
|
14415
|
+
/**
|
|
14416
|
+
* Isolated destructive probe with fresh identities and timestamps from new Date().
|
|
14417
|
+
* Refusal and erasure sections run only with their respective store capabilities;
|
|
14418
|
+
* returned evidence identifies each unexercised optional section with `skipped:`.
|
|
14419
|
+
*/
|
|
14420
|
+
async function checkSubmissionOutcomeStore(store) {
|
|
14421
|
+
if (!store.getSubmissionOutcomeByKey) throw new Error("Missing getSubmissionOutcomeByKey");
|
|
14422
|
+
const evidence = ["verified: submission outcome metadata and exact key scope"];
|
|
14423
|
+
const id = globalThis.crypto.randomUUID();
|
|
14424
|
+
const input = {
|
|
14425
|
+
id,
|
|
14426
|
+
tenantId: `tenant-${id}`,
|
|
14427
|
+
spaceId: `space-${id}`,
|
|
14428
|
+
actionId: "probe.read",
|
|
14429
|
+
idempotencyKey: "",
|
|
14430
|
+
actionVersion: 7,
|
|
14431
|
+
actorId: "actor",
|
|
14432
|
+
actorType: "natural_person",
|
|
14433
|
+
authorizationBindingId: "authorization-binding",
|
|
14434
|
+
idempotencyActorId: "idempotency-actor",
|
|
14435
|
+
idempotencyAuthorizationBindingId: "idempotency-binding",
|
|
14436
|
+
status: "pending",
|
|
14437
|
+
parameters: {},
|
|
14438
|
+
result: {},
|
|
14439
|
+
correlationId: id
|
|
14440
|
+
};
|
|
14441
|
+
const key = {
|
|
14442
|
+
tenantId: input.tenantId,
|
|
14443
|
+
spaceId: input.spaceId,
|
|
14444
|
+
actionId: input.actionId,
|
|
14445
|
+
idempotencyKey: ""
|
|
14446
|
+
};
|
|
14447
|
+
const expected = {
|
|
14448
|
+
...key,
|
|
14449
|
+
id,
|
|
14450
|
+
actionVersion: input.actionVersion,
|
|
14451
|
+
actorId: input.actorId,
|
|
14452
|
+
actorType: input.actorType,
|
|
14453
|
+
authorizationBindingId: input.authorizationBindingId,
|
|
14454
|
+
idempotencyActorId: input.idempotencyActorId,
|
|
14455
|
+
idempotencyAuthorizationBindingId: input.idempotencyAuthorizationBindingId,
|
|
14456
|
+
status: input.status,
|
|
14457
|
+
submissionRefusal: void 0,
|
|
14458
|
+
storedRecordsErased: void 0
|
|
14459
|
+
};
|
|
14460
|
+
const assertProjection = (row, receipt) => {
|
|
14461
|
+
for (const field of Object.keys(receipt)) assertEqual(row?.[field], receipt[field], `receipt ${field}`);
|
|
14462
|
+
};
|
|
14463
|
+
assertEqual(await store.getSubmissionOutcomeByKey(key), void 0, "absence");
|
|
14464
|
+
await store.createActionInvocation(input);
|
|
14465
|
+
const before = structuredClone(await store.getActionInvocation(id, key.tenantId, key.spaceId));
|
|
14466
|
+
for (const field of [
|
|
14467
|
+
"tenantId",
|
|
14468
|
+
"spaceId",
|
|
14469
|
+
"actionId",
|
|
14470
|
+
"idempotencyKey"
|
|
14471
|
+
]) assertEqual(await store.getSubmissionOutcomeByKey({
|
|
14472
|
+
...key,
|
|
14473
|
+
[field]: "other.value"
|
|
14474
|
+
}), void 0, field);
|
|
14475
|
+
const spaced = {
|
|
14476
|
+
...input,
|
|
14477
|
+
id: `${id}-spaced`,
|
|
14478
|
+
idempotencyKey: " key "
|
|
14479
|
+
};
|
|
14480
|
+
await store.createActionInvocation(spaced);
|
|
14481
|
+
assertProjection(await store.getSubmissionOutcomeByKey(spaced), {
|
|
14482
|
+
...expected,
|
|
14483
|
+
id: spaced.id,
|
|
14484
|
+
idempotencyKey: spaced.idempotencyKey
|
|
14485
|
+
});
|
|
14486
|
+
assertEqual(await store.getSubmissionOutcomeByKey({
|
|
14487
|
+
...key,
|
|
14488
|
+
idempotencyKey: "key"
|
|
14489
|
+
}), void 0, "key is not trimmed");
|
|
14490
|
+
const unkeyed = {
|
|
14491
|
+
...input,
|
|
14492
|
+
id: `${id}-unkeyed`,
|
|
14493
|
+
actionId: "probe.unkeyed",
|
|
14494
|
+
idempotencyKey: void 0
|
|
14495
|
+
};
|
|
14496
|
+
await store.createActionInvocation(unkeyed);
|
|
14497
|
+
assertEqual(await store.getSubmissionOutcomeByKey({
|
|
14498
|
+
...key,
|
|
14499
|
+
actionId: unkeyed.actionId
|
|
14500
|
+
}), void 0, "null key cannot match empty key");
|
|
14501
|
+
const row = await store.getSubmissionOutcomeByKey(key);
|
|
14502
|
+
assertProjection(row, expected);
|
|
14503
|
+
if (row) row.actorId = "changed";
|
|
14504
|
+
assertProjection(await store.getSubmissionOutcomeByKey(key), expected);
|
|
14505
|
+
assertEqual(await store.getActionInvocation(id, key.tenantId, key.spaceId), before, "read writes nothing");
|
|
14506
|
+
const erasureReceipts = [{
|
|
14507
|
+
...expected,
|
|
14508
|
+
status: "completed"
|
|
14509
|
+
}];
|
|
14510
|
+
if (store.recordSubmissionRefusal) {
|
|
14511
|
+
const now = /* @__PURE__ */ new Date();
|
|
14512
|
+
const refused = {
|
|
14513
|
+
...key,
|
|
14514
|
+
id: `${id}-refused`,
|
|
14515
|
+
idempotencyKey: "refused",
|
|
14516
|
+
actionVersion: 8,
|
|
14517
|
+
actorId: "refused-actor",
|
|
14518
|
+
actorType: "agent",
|
|
14519
|
+
authorizationBindingId: "refused-binding",
|
|
14520
|
+
correlationId: id,
|
|
14521
|
+
submissionRefusal: { class: "authorization" },
|
|
14522
|
+
createdAt: now,
|
|
14523
|
+
updatedAt: now
|
|
14524
|
+
};
|
|
14525
|
+
await store.recordSubmissionRefusal(refused);
|
|
14526
|
+
const refusedReceipt = {
|
|
14527
|
+
...expected,
|
|
14528
|
+
id: refused.id,
|
|
14529
|
+
idempotencyKey: refused.idempotencyKey,
|
|
14530
|
+
actionVersion: refused.actionVersion,
|
|
14531
|
+
actorId: refused.actorId,
|
|
14532
|
+
actorType: refused.actorType,
|
|
14533
|
+
authorizationBindingId: refused.authorizationBindingId,
|
|
14534
|
+
idempotencyActorId: void 0,
|
|
14535
|
+
idempotencyAuthorizationBindingId: void 0,
|
|
14536
|
+
status: "submission_refused",
|
|
14537
|
+
submissionRefusal: refused.submissionRefusal
|
|
14538
|
+
};
|
|
14539
|
+
assertProjection(await store.getSubmissionOutcomeByKey(refused), refusedReceipt);
|
|
14540
|
+
erasureReceipts.push(refusedReceipt);
|
|
14541
|
+
evidence.push("verified: recorded refusal receipt");
|
|
14542
|
+
} else evidence.push("skipped: store lacks recordSubmissionRefusal");
|
|
14543
|
+
const erasable = store;
|
|
14544
|
+
if (erasable.eraseStoredRecords) {
|
|
14545
|
+
await store.updateActionInvocation(id, key.tenantId, key.spaceId, { status: "completed" });
|
|
14546
|
+
for (const receipt of erasureReceipts) {
|
|
14547
|
+
await erasable.eraseStoredRecords({
|
|
14548
|
+
tenantId: key.tenantId,
|
|
14549
|
+
spaceId: key.spaceId,
|
|
14550
|
+
actorId: "operator",
|
|
14551
|
+
actorType: "natural_person",
|
|
14552
|
+
requestId: globalThis.crypto.randomUUID(),
|
|
14553
|
+
targets: [{
|
|
14554
|
+
kind: "invocation",
|
|
14555
|
+
invocationId: receipt.id
|
|
14556
|
+
}],
|
|
14557
|
+
redact: (view) => view
|
|
14558
|
+
});
|
|
14559
|
+
assertProjection(await store.getSubmissionOutcomeByKey(receipt), {
|
|
14560
|
+
...receipt,
|
|
14561
|
+
storedRecordsErased: true
|
|
14562
|
+
});
|
|
14563
|
+
}
|
|
14564
|
+
evidence.push("verified: erased receipts for created probe rows");
|
|
14565
|
+
} else evidence.push("skipped: store lacks eraseStoredRecords");
|
|
14566
|
+
return evidence;
|
|
14567
|
+
}
|
|
14568
|
+
//#endregion
|
|
14303
14569
|
exports.APPROVAL_DECIDED_EVENT = APPROVAL_DECIDED_EVENT;
|
|
14304
14570
|
exports.APPROVAL_DECISION_REFUSED_EVENT = APPROVAL_DECISION_REFUSED_EVENT;
|
|
14305
14571
|
exports.APPROVAL_EXPIRED_EVENT = APPROVAL_EXPIRED_EVENT;
|
|
@@ -14327,6 +14593,7 @@ exports.STAGED_INVOCATION_KEY_PATTERN = STAGED_INVOCATION_KEY_PATTERN;
|
|
|
14327
14593
|
exports.STAGED_INVOCATION_PRINCIPAL_PATTERN = STAGED_INVOCATION_PRINCIPAL_PATTERN;
|
|
14328
14594
|
exports.StoredRecordErasureAuthorizationError = StoredRecordErasureAuthorizationError;
|
|
14329
14595
|
exports.StoredRecordsErasedError = StoredRecordsErasedError;
|
|
14596
|
+
exports.SubmissionOutcomeUnsupportedError = SubmissionOutcomeUnsupportedError;
|
|
14330
14597
|
exports.SubmissionRefusalError = SubmissionRefusalError;
|
|
14331
14598
|
exports.applyPostgresMigrations = applyPostgresMigrations;
|
|
14332
14599
|
exports.approvalChecks = approvalChecks;
|
|
@@ -14337,6 +14604,7 @@ Object.defineProperty(exports, "canonicalJson", {
|
|
|
14337
14604
|
return _fabricorg_platform.canonicalJson;
|
|
14338
14605
|
}
|
|
14339
14606
|
});
|
|
14607
|
+
exports.checkSubmissionOutcomeStore = checkSubmissionOutcomeStore;
|
|
14340
14608
|
exports.cloneOutboxRecord = cloneOutboxRecord;
|
|
14341
14609
|
exports.cloneStagedInvocationRecord = cloneStagedInvocationRecord;
|
|
14342
14610
|
exports.createDurableSagaParentLifecycle = createDurableSagaParentLifecycle;
|