@dsh-enhanced/assistant-delivery 0.1.6 → 0.1.12
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/README.md +106 -7
- package/cordis.patch.yml +1 -0
- package/lib/agent-runtime.d.ts +23 -2
- package/lib/agent-runtime.d.ts.map +1 -1
- package/lib/agent-runtime.js +317 -2
- package/lib/agent-runtime.js.map +1 -1
- package/lib/canonical.d.ts +11 -1
- package/lib/canonical.d.ts.map +1 -1
- package/lib/canonical.js +107 -0
- package/lib/canonical.js.map +1 -1
- package/lib/coordinator.d.ts +3 -1
- package/lib/coordinator.d.ts.map +1 -1
- package/lib/coordinator.js +49 -21
- package/lib/coordinator.js.map +1 -1
- package/lib/feedback-command.d.ts +60 -0
- package/lib/feedback-command.d.ts.map +1 -0
- package/lib/feedback-command.js +338 -0
- package/lib/feedback-command.js.map +1 -0
- package/lib/index.d.ts +4 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +4 -3
- package/lib/index.js.map +1 -1
- package/lib/learning-command.d.ts +40 -0
- package/lib/learning-command.d.ts.map +1 -0
- package/lib/learning-command.js +57 -0
- package/lib/learning-command.js.map +1 -0
- package/lib/service.d.ts +149 -6
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +1945 -34
- package/lib/service.js.map +1 -1
- package/lib/session-commands.d.ts +14 -0
- package/lib/session-commands.d.ts.map +1 -1
- package/lib/session-commands.js +30 -0
- package/lib/session-commands.js.map +1 -1
- package/lib/sqlite.d.ts +1 -1
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +648 -20
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +375 -3
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2956 -74
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +407 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +9 -1
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.d.ts.map +1 -1
- package/lib/version.js +1 -1
- package/lib/version.js.map +1 -1
- package/lib/workflow-auto-producer.d.ts +37 -0
- package/lib/workflow-auto-producer.d.ts.map +1 -0
- package/lib/workflow-auto-producer.js +58 -0
- package/lib/workflow-auto-producer.js.map +1 -0
- package/lib/workflow-command.d.ts +16 -0
- package/lib/workflow-command.d.ts.map +1 -0
- package/lib/workflow-command.js +52 -0
- package/lib/workflow-command.js.map +1 -0
- package/package.json +23 -3
package/lib/store.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { isAbsolute } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { ASSISTANT_GROWTH_CONTRACT_VERSION, WORKFLOW_TRACE_SOURCE_ID, growthObjectDigest, validateResolvedWorkflowAutomationTemplate, validateWorkflowAutomationTemplate, validateWorkflowAutomationTemplateContent, validateWorkflowTraceRevision, workflowAutomationTemplateContentDigest, workflowArgumentShapeDigest, workflowScopeKey, workflowTraceRevisionDigest, } from '@dsh-enhanced/assistant-growth-contract';
|
|
4
|
+
import { bindingMatchesOwnerRoute, canonicalBackgroundSourceId, canonicalConversation, canonicalLocalOperatorId, canonicalOwnerRouteAuthority, canonicalPrincipal, canonicalTarget, externalPrincipalId, ownerRouteAuthorityHash, ownerRouteBindingSnapshot, } from './canonical.js';
|
|
5
|
+
import { feedbackDispatchRecoveryCode, isFeedbackDeliveryCommand, isFeedbackDispatchRecoveryCode, isExactDeliveryCommand, isLearningDeliveryCommand, isLearningDispatchRecoveryCode, isLearningRetryableFailureCode, isPermissionDeliveryCommand, isWorkflowDeliveryCommand, isWorkflowDispatchRecoveryCode, learningDispatchRecoveryCode, parseDeliveryCommand, permissionDispatchRecoveryCode, permissionDispatchRecoveryFromFailureCode, workflowDispatchRecoveryCode, } from './session-commands.js';
|
|
6
|
+
import { parseFeedbackCommand } from './feedback-command.js';
|
|
5
7
|
import { openDeliveryDatabase } from './sqlite.js';
|
|
8
|
+
import { deriveDeterministicallyDeidentifiedWorkflowTemplate, getDeterministicallyDeidentifiedWorkflowTemplate, } from './workflow-auto-producer.js';
|
|
6
9
|
export class DeliveryStoreError extends Error {
|
|
7
10
|
code;
|
|
8
11
|
constructor(code, message) {
|
|
@@ -11,6 +14,348 @@ export class DeliveryStoreError extends Error {
|
|
|
11
14
|
this.name = 'DeliveryStoreError';
|
|
12
15
|
}
|
|
13
16
|
}
|
|
17
|
+
function workflowTraceRevision(row) {
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(row.payload_json);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new DeliveryStoreError('conflict', 'workflow trace revision payload is corrupt');
|
|
24
|
+
}
|
|
25
|
+
const revision = validateWorkflowTraceRevision(parsed);
|
|
26
|
+
if (revision.subjectRef !== row.subject_ref || revision.version !== row.version
|
|
27
|
+
|| revision.source.sourceId !== WORKFLOW_TRACE_SOURCE_ID
|
|
28
|
+
|| revision.source.generation !== row.source_generation
|
|
29
|
+
|| revision.source.authorityDigest !== row.source_authority_digest
|
|
30
|
+
|| workflowScopeKey(revision.scope) !== row.scope_key
|
|
31
|
+
|| revision.scope.workspace !== row.workspace || revision.scope.preset !== row.preset
|
|
32
|
+
|| revision.disposition !== row.disposition || revision.digest !== row.digest) {
|
|
33
|
+
throw new DeliveryStoreError('conflict', 'workflow trace revision columns do not match payload');
|
|
34
|
+
}
|
|
35
|
+
return revision;
|
|
36
|
+
}
|
|
37
|
+
function workflowTraceOutboxEntry(row) {
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
revision: workflowTraceRevision(row),
|
|
40
|
+
status: row.status,
|
|
41
|
+
attemptCount: row.attempt_count,
|
|
42
|
+
nextAttemptAt: row.next_attempt_at,
|
|
43
|
+
...(row.failure_code === null ? {} : { failureCode: row.failure_code }),
|
|
44
|
+
updatedAt: row.updated_at,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function preferenceProjectionBatchKey(events) {
|
|
48
|
+
const identities = events.map(event => event.idempotencyKey);
|
|
49
|
+
return `delivery-preference-v1:${createHash('sha256')
|
|
50
|
+
.update('assistant-delivery-preference-projection-v1\0')
|
|
51
|
+
.update(JSON.stringify(identities))
|
|
52
|
+
.digest('hex')}`;
|
|
53
|
+
}
|
|
54
|
+
function exactPreferenceProjectionKeys(value, allowed, label) {
|
|
55
|
+
if (Object.keys(value).some(key => !allowed.includes(key))) {
|
|
56
|
+
throw new DeliveryStoreError('invalid-intent', `${label} contains unsupported fields`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function exactPreferenceProjectionText(value, label, max) {
|
|
60
|
+
if (typeof value !== 'string') {
|
|
61
|
+
throw new DeliveryStoreError('invalid-intent', `${label} must be a string`);
|
|
62
|
+
}
|
|
63
|
+
const normalized = validateBindingText(value, label, max);
|
|
64
|
+
if (normalized !== value) {
|
|
65
|
+
throw new DeliveryStoreError('invalid-intent', `${label} is not canonical`);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function canonicalPreferenceProjectionScope(value) {
|
|
70
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
71
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection scope is invalid');
|
|
72
|
+
}
|
|
73
|
+
const record = value;
|
|
74
|
+
exactPreferenceProjectionKeys(record, ['workspace', 'preset'], 'preference projection scope');
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
workspace: exactPreferenceProjectionText(record['workspace'], 'preference workspace', 4_096),
|
|
77
|
+
preset: exactPreferenceProjectionText(record['preset'], 'preference preset', 200),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function canonicalPreferenceCompletionIdentity(value) {
|
|
81
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
82
|
+
throw new DeliveryStoreError('invalid-intent', 'preference completion identity is invalid');
|
|
83
|
+
}
|
|
84
|
+
const record = value;
|
|
85
|
+
exactPreferenceProjectionKeys(record, [
|
|
86
|
+
'bindingId', 'bindingVersion', 'sessionId', 'sourceEventId', 'sourceInboxId', 'replyOutboxId',
|
|
87
|
+
], 'preference completion identity');
|
|
88
|
+
if (!Number.isSafeInteger(record['bindingVersion']) || record['bindingVersion'] < 1) {
|
|
89
|
+
throw new DeliveryStoreError('invalid-intent', 'preference binding version is invalid');
|
|
90
|
+
}
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
bindingId: exactPreferenceProjectionText(record['bindingId'], 'preference bindingId', 500),
|
|
93
|
+
bindingVersion: record['bindingVersion'],
|
|
94
|
+
sessionId: exactPreferenceProjectionText(record['sessionId'], 'preference sessionId', 500),
|
|
95
|
+
sourceEventId: exactPreferenceProjectionText(record['sourceEventId'], 'preference sourceEventId', 500),
|
|
96
|
+
sourceInboxId: exactPreferenceProjectionText(record['sourceInboxId'], 'preference sourceInboxId', 500),
|
|
97
|
+
replyOutboxId: exactPreferenceProjectionText(record['replyOutboxId'], 'preference replyOutboxId', 500),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function canonicalPreferencePrincipalLineage(value) {
|
|
101
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
102
|
+
throw new DeliveryStoreError('invalid-intent', 'preference principal lineage is invalid');
|
|
103
|
+
}
|
|
104
|
+
const record = value;
|
|
105
|
+
exactPreferenceProjectionKeys(record, ['principalRecordId', 'principalVersion'], 'preference principal lineage');
|
|
106
|
+
if (!Number.isSafeInteger(record['principalVersion']) || record['principalVersion'] < 1) {
|
|
107
|
+
throw new DeliveryStoreError('invalid-intent', 'preference principal lineage version is invalid');
|
|
108
|
+
}
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
principalRecordId: exactPreferenceProjectionText(record['principalRecordId'], 'preference principal record id', 500),
|
|
111
|
+
principalVersion: record['principalVersion'],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function canonicalPreferenceAdmissionCursor(value) {
|
|
115
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
116
|
+
throw new DeliveryStoreError('invalid-intent', 'preference admission cursor is invalid');
|
|
117
|
+
}
|
|
118
|
+
const record = value;
|
|
119
|
+
exactPreferenceProjectionKeys(record, ['epoch', 'sequence'], 'preference admission cursor');
|
|
120
|
+
if (typeof record['epoch'] !== 'string' || !/^[0-9a-f]{32}$/u.test(record['epoch'])
|
|
121
|
+
|| !Number.isSafeInteger(record['sequence']) || record['sequence'] < 1) {
|
|
122
|
+
throw new DeliveryStoreError('invalid-intent', 'preference admission cursor is invalid');
|
|
123
|
+
}
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
epoch: record['epoch'],
|
|
126
|
+
sequence: record['sequence'],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
function canonicalPreferenceProjectionEvent(value) {
|
|
130
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
131
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection event is invalid');
|
|
132
|
+
}
|
|
133
|
+
const record = value;
|
|
134
|
+
const source = record['source'];
|
|
135
|
+
const scope = canonicalPreferenceProjectionScope(record['scope']);
|
|
136
|
+
const occurredAt = record['occurredAt'];
|
|
137
|
+
if (!Number.isSafeInteger(occurredAt) || occurredAt < 0) {
|
|
138
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection occurredAt is invalid');
|
|
139
|
+
}
|
|
140
|
+
const idempotencyKey = exactPreferenceProjectionText(record['idempotencyKey'], 'preference event idempotencyKey', 200);
|
|
141
|
+
if (record['actorTrust'] !== 'owner-authenticated') {
|
|
142
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection actor is invalid');
|
|
143
|
+
}
|
|
144
|
+
const optionalAuthorityKeys = [
|
|
145
|
+
...(record['principalLineage'] === undefined ? [] : ['principalLineage']),
|
|
146
|
+
...(record['admissionCursor'] === undefined ? [] : ['admissionCursor']),
|
|
147
|
+
];
|
|
148
|
+
const admissionCursor = record['admissionCursor'] === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: canonicalPreferenceAdmissionCursor(record['admissionCursor']);
|
|
151
|
+
if (source === 'delivery-completion') {
|
|
152
|
+
exactPreferenceProjectionKeys(record, [
|
|
153
|
+
'scope', 'principalId', ...optionalAuthorityKeys, 'actorTrust', 'source',
|
|
154
|
+
'occurredAt', 'idempotencyKey', 'completion',
|
|
155
|
+
], 'preference completion');
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
scope,
|
|
158
|
+
principalId: exactPreferenceProjectionText(record['principalId'], 'preference principalId', 500),
|
|
159
|
+
...(record['principalLineage'] === undefined
|
|
160
|
+
? {}
|
|
161
|
+
: { principalLineage: canonicalPreferencePrincipalLineage(record['principalLineage']) }),
|
|
162
|
+
...(admissionCursor === undefined ? {} : { admissionCursor }),
|
|
163
|
+
actorTrust: 'owner-authenticated',
|
|
164
|
+
source,
|
|
165
|
+
occurredAt: occurredAt,
|
|
166
|
+
idempotencyKey,
|
|
167
|
+
completion: canonicalPreferenceCompletionIdentity(record['completion']),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (source === 'delivery-observation') {
|
|
171
|
+
exactPreferenceProjectionKeys(record, [
|
|
172
|
+
'scope', 'principalId', ...optionalAuthorityKeys, 'preferenceKey', 'candidateValue', 'stance',
|
|
173
|
+
'actorTrust', 'interpretationTrust', 'source', 'occurredAt', 'idempotencyKey', 'completion',
|
|
174
|
+
], 'preference observation');
|
|
175
|
+
if (record['preferenceKey'] !== 'response.language'
|
|
176
|
+
|| (record['candidateValue'] !== 'zh-CN' && record['candidateValue'] !== 'en')
|
|
177
|
+
|| record['stance'] !== 'support'
|
|
178
|
+
|| record['interpretationTrust'] !== 'behavioral-inference') {
|
|
179
|
+
throw new DeliveryStoreError('invalid-intent', 'preference observation selection is invalid');
|
|
180
|
+
}
|
|
181
|
+
return Object.freeze({
|
|
182
|
+
scope,
|
|
183
|
+
principalId: exactPreferenceProjectionText(record['principalId'], 'preference principalId', 500),
|
|
184
|
+
...(record['principalLineage'] === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { principalLineage: canonicalPreferencePrincipalLineage(record['principalLineage']) }),
|
|
187
|
+
...(admissionCursor === undefined ? {} : { admissionCursor }),
|
|
188
|
+
preferenceKey: 'response.language',
|
|
189
|
+
candidateValue: record['candidateValue'],
|
|
190
|
+
stance: 'support',
|
|
191
|
+
actorTrust: 'owner-authenticated',
|
|
192
|
+
interpretationTrust: 'behavioral-inference',
|
|
193
|
+
source,
|
|
194
|
+
occurredAt: occurredAt,
|
|
195
|
+
idempotencyKey,
|
|
196
|
+
completion: canonicalPreferenceCompletionIdentity(record['completion']),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (source !== 'direct-owner-feedback') {
|
|
200
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection source is invalid');
|
|
201
|
+
}
|
|
202
|
+
exactPreferenceProjectionKeys(record, [
|
|
203
|
+
'scope', 'principalId', ...optionalAuthorityKeys, 'preferenceKey', 'candidateValue', 'stance',
|
|
204
|
+
'actorTrust', 'interpretationTrust', 'source', 'occurredAt', 'idempotencyKey', 'exposureTarget',
|
|
205
|
+
], 'preference feedback');
|
|
206
|
+
if (record['stance'] !== 'support'
|
|
207
|
+
|| (record['interpretationTrust'] !== 'explicit-selection'
|
|
208
|
+
&& record['interpretationTrust'] !== 'typed-feedback')) {
|
|
209
|
+
throw new DeliveryStoreError('invalid-intent', 'preference feedback trust is invalid');
|
|
210
|
+
}
|
|
211
|
+
const key = record['preferenceKey'];
|
|
212
|
+
const candidate = record['candidateValue'];
|
|
213
|
+
const catalog = {
|
|
214
|
+
'feedback.response': [
|
|
215
|
+
'helpful', 'not-helpful', 'too-long', 'too-short', 'wrong-format',
|
|
216
|
+
'wrong-action', 'unwanted-reminder',
|
|
217
|
+
],
|
|
218
|
+
'recommendation.ranking': ['recency', 'familiarity', 'evidence'],
|
|
219
|
+
'response.explanation_depth': ['result-first', 'balanced', 'tutorial'],
|
|
220
|
+
'response.language': ['zh-CN', 'en'],
|
|
221
|
+
'response.structure': ['prose', 'bullets', 'mixed'],
|
|
222
|
+
'response.verbosity': ['concise', 'balanced', 'detailed'],
|
|
223
|
+
'suggestion.frequency': ['low', 'normal'],
|
|
224
|
+
};
|
|
225
|
+
if (typeof key !== 'string' || typeof candidate !== 'string'
|
|
226
|
+
|| !catalog[key]?.includes(candidate)) {
|
|
227
|
+
throw new DeliveryStoreError('invalid-intent', 'preference feedback selection is invalid');
|
|
228
|
+
}
|
|
229
|
+
let exposureTarget;
|
|
230
|
+
if (record['exposureTarget'] !== undefined) {
|
|
231
|
+
if (typeof record['exposureTarget'] !== 'object' || record['exposureTarget'] === null
|
|
232
|
+
|| Array.isArray(record['exposureTarget'])) {
|
|
233
|
+
throw new DeliveryStoreError('invalid-intent', 'preference exposure target is invalid');
|
|
234
|
+
}
|
|
235
|
+
const target = record['exposureTarget'];
|
|
236
|
+
exactPreferenceProjectionKeys(target, ['sourceInboxId', 'sourceOutboxId'], 'preference exposure target');
|
|
237
|
+
exposureTarget = Object.freeze({
|
|
238
|
+
sourceInboxId: exactPreferenceProjectionText(target['sourceInboxId'], 'preference exposure sourceInboxId', 500),
|
|
239
|
+
sourceOutboxId: exactPreferenceProjectionText(target['sourceOutboxId'], 'preference exposure sourceOutboxId', 500),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
scope,
|
|
244
|
+
principalId: exactPreferenceProjectionText(record['principalId'], 'preference principalId', 500),
|
|
245
|
+
...(record['principalLineage'] === undefined
|
|
246
|
+
? {}
|
|
247
|
+
: { principalLineage: canonicalPreferencePrincipalLineage(record['principalLineage']) }),
|
|
248
|
+
...(admissionCursor === undefined ? {} : { admissionCursor }),
|
|
249
|
+
preferenceKey: key,
|
|
250
|
+
candidateValue: candidate,
|
|
251
|
+
stance: 'support',
|
|
252
|
+
actorTrust: 'owner-authenticated',
|
|
253
|
+
interpretationTrust: record['interpretationTrust'],
|
|
254
|
+
source,
|
|
255
|
+
occurredAt: occurredAt,
|
|
256
|
+
idempotencyKey,
|
|
257
|
+
...(exposureTarget === undefined ? {} : { exposureTarget }),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function preferenceProjectionLane(events) {
|
|
261
|
+
const exact = events.map(event => event.principalLineage !== undefined
|
|
262
|
+
&& event.admissionCursor !== undefined);
|
|
263
|
+
if (exact.every(value => !value))
|
|
264
|
+
return undefined;
|
|
265
|
+
if (!exact.every(Boolean)) {
|
|
266
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection batch mixes exact and legacy owner authority');
|
|
267
|
+
}
|
|
268
|
+
const first = events[0];
|
|
269
|
+
const lineage = first.principalLineage;
|
|
270
|
+
const cursor = first.admissionCursor;
|
|
271
|
+
const lane = Object.freeze({
|
|
272
|
+
epoch: cursor.epoch,
|
|
273
|
+
workspace: first.scope.workspace,
|
|
274
|
+
preset: first.scope.preset,
|
|
275
|
+
principalRecordId: lineage.principalRecordId,
|
|
276
|
+
principalVersion: lineage.principalVersion,
|
|
277
|
+
admissionSequence: cursor.sequence,
|
|
278
|
+
});
|
|
279
|
+
if (events.some(event => event.admissionCursor.epoch !== lane.epoch
|
|
280
|
+
|| event.admissionCursor.sequence !== lane.admissionSequence
|
|
281
|
+
|| event.scope.workspace !== lane.workspace || event.scope.preset !== lane.preset
|
|
282
|
+
|| event.principalLineage.principalRecordId !== lane.principalRecordId
|
|
283
|
+
|| event.principalLineage.principalVersion !== lane.principalVersion)) {
|
|
284
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection batch spans multiple owner admission lanes');
|
|
285
|
+
}
|
|
286
|
+
return lane;
|
|
287
|
+
}
|
|
288
|
+
function preferenceProjectionPayload(events) {
|
|
289
|
+
if (!Array.isArray(events) || events.length < 1 || events.length > 16) {
|
|
290
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection batch must contain 1-16 events');
|
|
291
|
+
}
|
|
292
|
+
const canonicalEvents = Object.freeze(events.map(event => canonicalPreferenceProjectionEvent(event)));
|
|
293
|
+
const lane = preferenceProjectionLane(canonicalEvents);
|
|
294
|
+
const identities = canonicalEvents.map(event => event.idempotencyKey);
|
|
295
|
+
if (new Set(identities).size !== identities.length) {
|
|
296
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection batch contains duplicate events');
|
|
297
|
+
}
|
|
298
|
+
const json = JSON.stringify(canonicalEvents);
|
|
299
|
+
if (Buffer.byteLength(json, 'utf8') > 131_072) {
|
|
300
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection batch exceeds its byte cap');
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
batchKey: preferenceProjectionBatchKey(canonicalEvents),
|
|
304
|
+
digest: createHash('sha256').update(json).digest('hex'),
|
|
305
|
+
json,
|
|
306
|
+
events: canonicalEvents,
|
|
307
|
+
...(lane === undefined ? {} : { lane }),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function preferenceProjectionOutboxEntry(row) {
|
|
311
|
+
let parsed;
|
|
312
|
+
try {
|
|
313
|
+
parsed = JSON.parse(row.events_json);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
throw new DeliveryStoreError('conflict', 'preference projection payload is corrupt');
|
|
317
|
+
}
|
|
318
|
+
if (!Array.isArray(parsed)) {
|
|
319
|
+
throw new DeliveryStoreError('conflict', 'preference projection payload is not a batch');
|
|
320
|
+
}
|
|
321
|
+
const payload = preferenceProjectionPayload(parsed);
|
|
322
|
+
if (payload.batchKey !== row.batch_key || payload.digest !== row.payload_digest
|
|
323
|
+
|| payload.json !== row.events_json) {
|
|
324
|
+
throw new DeliveryStoreError('conflict', 'preference projection columns do not match payload');
|
|
325
|
+
}
|
|
326
|
+
const storedLaneColumns = [
|
|
327
|
+
row.lane_epoch,
|
|
328
|
+
row.lane_workspace,
|
|
329
|
+
row.lane_preset,
|
|
330
|
+
row.lane_principal_record_id,
|
|
331
|
+
row.lane_principal_version,
|
|
332
|
+
row.admission_sequence,
|
|
333
|
+
];
|
|
334
|
+
if (row.lane_kind === 'exact') {
|
|
335
|
+
if (storedLaneColumns.some(value => value === null) || payload.lane === undefined
|
|
336
|
+
|| row.lane_epoch !== payload.lane.epoch
|
|
337
|
+
|| row.lane_workspace !== payload.lane.workspace
|
|
338
|
+
|| row.lane_preset !== payload.lane.preset
|
|
339
|
+
|| row.lane_principal_record_id !== payload.lane.principalRecordId
|
|
340
|
+
|| row.lane_principal_version !== payload.lane.principalVersion
|
|
341
|
+
|| row.admission_sequence !== payload.lane.admissionSequence) {
|
|
342
|
+
throw new DeliveryStoreError('conflict', 'preference projection lane columns do not match payload');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else if (row.lane_kind === 'legacy'
|
|
346
|
+
&& (payload.lane !== undefined || storedLaneColumns.some(value => value !== null))) {
|
|
347
|
+
throw new DeliveryStoreError('conflict', 'legacy preference projection has exact lane authority');
|
|
348
|
+
}
|
|
349
|
+
return Object.freeze({
|
|
350
|
+
batchKey: row.batch_key,
|
|
351
|
+
payloadDigest: row.payload_digest,
|
|
352
|
+
events: payload.events,
|
|
353
|
+
...(payload.lane === undefined ? {} : { lane: payload.lane }),
|
|
354
|
+
attemptCount: row.attempt_count,
|
|
355
|
+
nextAttemptAt: row.next_attempt_at,
|
|
356
|
+
...(row.failure_code === null ? {} : { failureCode: row.failure_code }),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
14
359
|
const imageMediaTypes = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);
|
|
15
360
|
function invalidImageRef() {
|
|
16
361
|
throw new DeliveryStoreError('conflict', 'inbound image reference is invalid');
|
|
@@ -86,6 +431,19 @@ function attachmentFromRow(row) {
|
|
|
86
431
|
createdAt: row.created_at,
|
|
87
432
|
};
|
|
88
433
|
}
|
|
434
|
+
function deadLetterResolutionFromRow(row) {
|
|
435
|
+
return {
|
|
436
|
+
receiptVersion: row.receipt_version,
|
|
437
|
+
kind: row.kind,
|
|
438
|
+
id: row.message_id,
|
|
439
|
+
attemptCount: row.attempt_count,
|
|
440
|
+
resolution: row.resolution,
|
|
441
|
+
originalStatus: row.original_status,
|
|
442
|
+
...(row.original_failure_code === null ? {} : { originalFailureCode: row.original_failure_code }),
|
|
443
|
+
operatorId: row.operator_id,
|
|
444
|
+
createdAt: row.created_at,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
89
447
|
function digest(value) {
|
|
90
448
|
return createHash('sha256').update(value).digest('hex');
|
|
91
449
|
}
|
|
@@ -181,6 +539,10 @@ const bindingSelect = `
|
|
|
181
539
|
const inboxSelect = `
|
|
182
540
|
SELECT id, channel, account, event_id, envelope_hash, envelope_json, status, binding_id,
|
|
183
541
|
attempt_count, next_attempt_at, claimed_by, fencing_token, lease_until, failure_code,
|
|
542
|
+
(SELECT epoch FROM delivery_inbox_admissions WHERE inbox_id = inbox_messages.id)
|
|
543
|
+
AS admission_epoch,
|
|
544
|
+
(SELECT admission_sequence FROM delivery_inbox_admissions WHERE inbox_id = inbox_messages.id)
|
|
545
|
+
AS admission_sequence,
|
|
184
546
|
received_at, updated_at
|
|
185
547
|
FROM inbox_messages
|
|
186
548
|
`;
|
|
@@ -189,6 +551,12 @@ const outboxSelect = `
|
|
|
189
551
|
next_attempt_at, claimed_by, fencing_token, lease_until, failure_code, created_at, updated_at
|
|
190
552
|
FROM outbox_messages
|
|
191
553
|
`;
|
|
554
|
+
const deliveryPresentationSelect = `
|
|
555
|
+
SELECT presentation_key, original_outbox_idempotency_key, revision, payload_hash, payload_json,
|
|
556
|
+
status, attempt_count, presented_revision, next_attempt_at, claimed_by, fencing_token,
|
|
557
|
+
lease_until, provider_message_id, failure_code, created_at, updated_at
|
|
558
|
+
FROM delivery_presentations
|
|
559
|
+
`;
|
|
192
560
|
function validateBindingText(value, field, max) {
|
|
193
561
|
const normalized = value.trim();
|
|
194
562
|
const hasControl = [...normalized].some(character => {
|
|
@@ -200,6 +568,87 @@ function validateBindingText(value, field, max) {
|
|
|
200
568
|
}
|
|
201
569
|
return normalized;
|
|
202
570
|
}
|
|
571
|
+
function approvalRouteText(value, field, maxBytes) {
|
|
572
|
+
if (typeof value !== 'string') {
|
|
573
|
+
throw new DeliveryStoreError('invalid-intent', `approval route ${field} is invalid`);
|
|
574
|
+
}
|
|
575
|
+
const normalized = value.normalize('NFC').trim();
|
|
576
|
+
const hasControl = [...normalized].some(character => {
|
|
577
|
+
const code = character.codePointAt(0);
|
|
578
|
+
return code <= 0x1f || code === 0x7f;
|
|
579
|
+
});
|
|
580
|
+
if (normalized === '' || normalized !== value || Buffer.byteLength(normalized, 'utf8') > maxBytes || hasControl) {
|
|
581
|
+
throw new DeliveryStoreError('invalid-intent', `approval route ${field} is invalid`);
|
|
582
|
+
}
|
|
583
|
+
return normalized;
|
|
584
|
+
}
|
|
585
|
+
function canonicalApprovalDispatchRoute(input) {
|
|
586
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
587
|
+
throw new DeliveryStoreError('invalid-intent', 'approval route must be a v2 object');
|
|
588
|
+
}
|
|
589
|
+
const route = input;
|
|
590
|
+
const fields = [
|
|
591
|
+
'bindingGeneration', 'bindingId', 'bindingVersion', 'principal', 'principalRecordId',
|
|
592
|
+
'principalVersion', 'routeVersion', 'sourceId', 'workspace',
|
|
593
|
+
];
|
|
594
|
+
if (Object.keys(route).sort().join(',') !== fields.join(',') || route.routeVersion !== 2) {
|
|
595
|
+
throw new DeliveryStoreError('invalid-intent', 'approval route must be complete route version 2');
|
|
596
|
+
}
|
|
597
|
+
const sourceId = approvalRouteText(route.sourceId, 'sourceId', 256);
|
|
598
|
+
const bindingId = approvalRouteText(route.bindingId, 'bindingId', 512);
|
|
599
|
+
const workspace = approvalRouteText(route.workspace, 'workspace', 4_096);
|
|
600
|
+
const principal = approvalRouteText(route.principal, 'principal', 512);
|
|
601
|
+
const principalRecordId = approvalRouteText(route.principalRecordId, 'principalRecordId', 500);
|
|
602
|
+
if (!isAbsolute(workspace)
|
|
603
|
+
|| !Number.isSafeInteger(route.bindingVersion) || route.bindingVersion < 1
|
|
604
|
+
|| !Number.isSafeInteger(route.bindingGeneration) || route.bindingGeneration < 1
|
|
605
|
+
|| !Number.isSafeInteger(route.principalVersion) || route.principalVersion < 1) {
|
|
606
|
+
throw new DeliveryStoreError('invalid-intent', 'approval route fence is invalid');
|
|
607
|
+
}
|
|
608
|
+
return Object.freeze({
|
|
609
|
+
routeVersion: 2,
|
|
610
|
+
sourceId,
|
|
611
|
+
bindingId,
|
|
612
|
+
bindingVersion: route.bindingVersion,
|
|
613
|
+
bindingGeneration: route.bindingGeneration,
|
|
614
|
+
workspace,
|
|
615
|
+
principal,
|
|
616
|
+
principalRecordId,
|
|
617
|
+
principalVersion: route.principalVersion,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
function approvalRouteFromRow(row) {
|
|
621
|
+
return canonicalApprovalDispatchRoute({
|
|
622
|
+
routeVersion: row.route_version,
|
|
623
|
+
sourceId: row.source_id,
|
|
624
|
+
bindingId: row.binding_id,
|
|
625
|
+
bindingVersion: row.binding_version,
|
|
626
|
+
bindingGeneration: row.binding_generation,
|
|
627
|
+
workspace: row.workspace,
|
|
628
|
+
principal: row.principal,
|
|
629
|
+
principalRecordId: row.principal_record_id,
|
|
630
|
+
principalVersion: row.principal_version,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
function sameApprovalRoute(left, right) {
|
|
634
|
+
return left.routeVersion === right.routeVersion
|
|
635
|
+
&& left.sourceId === right.sourceId
|
|
636
|
+
&& left.bindingId === right.bindingId
|
|
637
|
+
&& left.bindingVersion === right.bindingVersion
|
|
638
|
+
&& left.bindingGeneration === right.bindingGeneration
|
|
639
|
+
&& left.workspace === right.workspace
|
|
640
|
+
&& left.principal === right.principal
|
|
641
|
+
&& left.principalRecordId === right.principalRecordId
|
|
642
|
+
&& left.principalVersion === right.principalVersion;
|
|
643
|
+
}
|
|
644
|
+
function resolutionOperatorId(value) {
|
|
645
|
+
try {
|
|
646
|
+
return canonicalLocalOperatorId(value);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
throw new DeliveryStoreError('conflict', 'dead-letter operator identity is invalid');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
203
652
|
function boundedMaintenanceLimit(value, fallback, label) {
|
|
204
653
|
const limit = value ?? fallback;
|
|
205
654
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
@@ -208,6 +657,10 @@ function boundedMaintenanceLimit(value, fallback, label) {
|
|
|
208
657
|
return limit;
|
|
209
658
|
}
|
|
210
659
|
function inboxFromRow(row) {
|
|
660
|
+
if (!/^[0-9a-f]{32}$/u.test(row.admission_epoch)
|
|
661
|
+
|| !Number.isSafeInteger(row.admission_sequence) || row.admission_sequence < 1) {
|
|
662
|
+
throw new DeliveryStoreError('conflict', 'Inbox admission cursor is missing or corrupt');
|
|
663
|
+
}
|
|
211
664
|
return {
|
|
212
665
|
id: row.id,
|
|
213
666
|
channel: row.channel,
|
|
@@ -223,6 +676,10 @@ function inboxFromRow(row) {
|
|
|
223
676
|
...(row.fencing_token === null ? {} : { fencingToken: row.fencing_token }),
|
|
224
677
|
...(row.lease_until === null ? {} : { leaseUntil: row.lease_until }),
|
|
225
678
|
...(row.failure_code === null ? {} : { failureCode: row.failure_code }),
|
|
679
|
+
admissionCursor: Object.freeze({
|
|
680
|
+
epoch: row.admission_epoch,
|
|
681
|
+
sequence: row.admission_sequence,
|
|
682
|
+
}),
|
|
226
683
|
receivedAt: row.received_at,
|
|
227
684
|
updatedAt: row.updated_at,
|
|
228
685
|
};
|
|
@@ -244,6 +701,137 @@ function outboxFromRow(row) {
|
|
|
244
701
|
updatedAt: row.updated_at,
|
|
245
702
|
};
|
|
246
703
|
}
|
|
704
|
+
function deliveryPresentationFromRow(row) {
|
|
705
|
+
let presentation;
|
|
706
|
+
try {
|
|
707
|
+
presentation = canonicalDeliveryPresentation(JSON.parse(row.payload_json));
|
|
708
|
+
const canonicalJson = JSON.stringify(presentation);
|
|
709
|
+
if (canonicalJson !== row.payload_json || digest(canonicalJson) !== row.payload_hash
|
|
710
|
+
|| !Number.isSafeInteger(row.revision) || row.revision < 1
|
|
711
|
+
|| !Number.isSafeInteger(row.attempt_count) || row.attempt_count < 0
|
|
712
|
+
|| !Number.isSafeInteger(row.presented_revision) || row.presented_revision < 0
|
|
713
|
+
|| row.presented_revision > row.revision
|
|
714
|
+
|| !Number.isSafeInteger(row.created_at) || !Number.isSafeInteger(row.updated_at)
|
|
715
|
+
|| (row.status === 'presented' && row.provider_message_id === null)) {
|
|
716
|
+
throw new Error('invalid stored presentation');
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
throw new DeliveryStoreError('invalid-intent', 'stored delivery presentation is invalid');
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
presentationKey: row.presentation_key,
|
|
724
|
+
originalOutboxIdempotencyKey: row.original_outbox_idempotency_key,
|
|
725
|
+
revision: row.revision,
|
|
726
|
+
presentation,
|
|
727
|
+
status: row.status,
|
|
728
|
+
attemptCount: row.attempt_count,
|
|
729
|
+
presentedRevision: row.presented_revision,
|
|
730
|
+
...(row.provider_message_id === null ? {} : { providerMessageId: row.provider_message_id }),
|
|
731
|
+
...(row.failure_code === null ? {} : { failureCode: row.failure_code }),
|
|
732
|
+
createdAt: row.created_at,
|
|
733
|
+
updatedAt: row.updated_at,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
function canonicalDeliveryPresentation(input) {
|
|
737
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
738
|
+
throw new DeliveryStoreError('invalid-intent', 'delivery presentation kind is invalid');
|
|
739
|
+
}
|
|
740
|
+
if (input.kind === 'automation-incident') {
|
|
741
|
+
const allowed = new Set([
|
|
742
|
+
'kind', 'incidentId', 'automationId', 'definitionHash', 'stage', 'state',
|
|
743
|
+
'failureClass', 'failurePhase', 'failureCode', 'sideEffectState', 'retryability',
|
|
744
|
+
'lifecycleGeneration', 'incidentRevision', 'openedAt', 'updatedAt', 'resolvedAt',
|
|
745
|
+
]);
|
|
746
|
+
if (Object.keys(input).some(key => !allowed.has(key))) {
|
|
747
|
+
throw new DeliveryStoreError('invalid-intent', 'automation incident presentation shape is invalid');
|
|
748
|
+
}
|
|
749
|
+
const incidentId = validateBindingText(input.incidentId, 'incidentId', 500);
|
|
750
|
+
const automationId = validateBindingText(input.automationId, 'automationId', 500);
|
|
751
|
+
const token = (value) => typeof value === 'string'
|
|
752
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/u.test(value);
|
|
753
|
+
if (!/^[a-f0-9]{64}$/u.test(input.definitionHash)
|
|
754
|
+
|| !['claim', 'materialize', 'terminal'].includes(input.stage)
|
|
755
|
+
|| !['open', 'recovering', 'resolved'].includes(input.state)
|
|
756
|
+
|| !['budget', 'cancelled', 'configuration', 'execution', 'infrastructure',
|
|
757
|
+
'policy', 'provider', 'timeout', 'unknown'].includes(input.failureClass)
|
|
758
|
+
|| !token(input.failurePhase) || !token(input.failureCode)
|
|
759
|
+
|| !['none', 'possible', 'unknown'].includes(input.sideEffectState)
|
|
760
|
+
|| !['after-intervention', 'safe', 'unsafe', 'unknown'].includes(input.retryability)
|
|
761
|
+
|| !Number.isSafeInteger(input.lifecycleGeneration) || input.lifecycleGeneration < 1
|
|
762
|
+
|| !Number.isSafeInteger(input.incidentRevision) || input.incidentRevision < 1
|
|
763
|
+
|| !Number.isSafeInteger(input.openedAt) || input.openedAt < 0
|
|
764
|
+
|| !Number.isSafeInteger(input.updatedAt) || input.updatedAt < input.openedAt
|
|
765
|
+
|| (input.state === 'resolved') !== (input.resolvedAt !== undefined)
|
|
766
|
+
|| (input.resolvedAt !== undefined
|
|
767
|
+
&& (!Number.isSafeInteger(input.resolvedAt) || input.resolvedAt < input.openedAt))) {
|
|
768
|
+
throw new DeliveryStoreError('invalid-intent', 'automation incident presentation is invalid');
|
|
769
|
+
}
|
|
770
|
+
return Object.freeze({
|
|
771
|
+
kind: 'automation-incident',
|
|
772
|
+
incidentId,
|
|
773
|
+
automationId,
|
|
774
|
+
definitionHash: input.definitionHash,
|
|
775
|
+
stage: input.stage,
|
|
776
|
+
state: input.state,
|
|
777
|
+
failureClass: input.failureClass,
|
|
778
|
+
failurePhase: input.failurePhase,
|
|
779
|
+
failureCode: input.failureCode,
|
|
780
|
+
sideEffectState: input.sideEffectState,
|
|
781
|
+
retryability: input.retryability,
|
|
782
|
+
lifecycleGeneration: input.lifecycleGeneration,
|
|
783
|
+
incidentRevision: input.incidentRevision,
|
|
784
|
+
openedAt: input.openedAt,
|
|
785
|
+
updatedAt: input.updatedAt,
|
|
786
|
+
...(input.resolvedAt === undefined ? {} : { resolvedAt: input.resolvedAt }),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (input.kind !== 'approval-application') {
|
|
790
|
+
throw new DeliveryStoreError('invalid-intent', 'delivery presentation kind is invalid');
|
|
791
|
+
}
|
|
792
|
+
const allowed = new Set([
|
|
793
|
+
'kind', 'policyProposalId', 'localProposalId', 'applicationStatus', 'operation',
|
|
794
|
+
'terminalAt', 'receiptDigest', 'ruleId', 'resultingRuleVersion', 'ruleStatus',
|
|
795
|
+
]);
|
|
796
|
+
if (Object.keys(input).some(key => !allowed.has(key))) {
|
|
797
|
+
throw new DeliveryStoreError('invalid-intent', 'delivery presentation shape is invalid');
|
|
798
|
+
}
|
|
799
|
+
const policyProposalId = validateBindingText(input.policyProposalId, 'policyProposalId', 200);
|
|
800
|
+
const localProposalId = validateBindingText(input.localProposalId, 'localProposalId', 200);
|
|
801
|
+
if (!['applied', 'conflicted', 'expired', 'rejected'].includes(input.applicationStatus)
|
|
802
|
+
|| !['adopt', 'owner-undo', 'retire'].includes(input.operation)
|
|
803
|
+
|| !Number.isSafeInteger(input.terminalAt) || input.terminalAt < 0
|
|
804
|
+
|| !/^[a-f0-9]{64}$/u.test(input.receiptDigest)) {
|
|
805
|
+
throw new DeliveryStoreError('invalid-intent', 'approval application presentation is invalid');
|
|
806
|
+
}
|
|
807
|
+
const ruleId = input.ruleId === undefined
|
|
808
|
+
? undefined
|
|
809
|
+
: validateBindingText(input.ruleId, 'ruleId', 200);
|
|
810
|
+
const resultingRuleVersion = input.resultingRuleVersion;
|
|
811
|
+
if (resultingRuleVersion !== undefined
|
|
812
|
+
&& (!Number.isSafeInteger(resultingRuleVersion) || resultingRuleVersion < 1)) {
|
|
813
|
+
throw new DeliveryStoreError('invalid-intent', 'presentation rule version is invalid');
|
|
814
|
+
}
|
|
815
|
+
if (input.ruleStatus !== undefined && input.ruleStatus !== 'active' && input.ruleStatus !== 'retired') {
|
|
816
|
+
throw new DeliveryStoreError('invalid-intent', 'presentation rule status is invalid');
|
|
817
|
+
}
|
|
818
|
+
if (input.applicationStatus === 'applied'
|
|
819
|
+
&& (ruleId === undefined || resultingRuleVersion === undefined || input.ruleStatus === undefined)) {
|
|
820
|
+
throw new DeliveryStoreError('invalid-intent', 'applied presentation requires exact rule terminal state');
|
|
821
|
+
}
|
|
822
|
+
return Object.freeze({
|
|
823
|
+
kind: 'approval-application',
|
|
824
|
+
policyProposalId,
|
|
825
|
+
localProposalId,
|
|
826
|
+
applicationStatus: input.applicationStatus,
|
|
827
|
+
operation: input.operation,
|
|
828
|
+
terminalAt: input.terminalAt,
|
|
829
|
+
receiptDigest: input.receiptDigest,
|
|
830
|
+
...(ruleId === undefined ? {} : { ruleId }),
|
|
831
|
+
...(resultingRuleVersion === undefined ? {} : { resultingRuleVersion }),
|
|
832
|
+
...(input.ruleStatus === undefined ? {} : { ruleStatus: input.ruleStatus }),
|
|
833
|
+
});
|
|
834
|
+
}
|
|
247
835
|
function invalidEnvelope(message) {
|
|
248
836
|
throw new DeliveryStoreError('invalid-envelope', message);
|
|
249
837
|
}
|
|
@@ -449,6 +1037,101 @@ function canonicalIntent(input, binding, maxTextBytes) {
|
|
|
449
1037
|
...(replyToEventId === undefined ? {} : { replyToEventId }),
|
|
450
1038
|
...(metadata === undefined ? {} : { metadata }) };
|
|
451
1039
|
}
|
|
1040
|
+
const ownerRouteReceiptKeys = [
|
|
1041
|
+
'dsh.route.authority',
|
|
1042
|
+
'dsh.route.authorityHash',
|
|
1043
|
+
'dsh.route.bindingVersion',
|
|
1044
|
+
'dsh.route.generation',
|
|
1045
|
+
'dsh.route.initialBindingId',
|
|
1046
|
+
'dsh.route.initialBindingVersion',
|
|
1047
|
+
'dsh.route.initialGeneration',
|
|
1048
|
+
'dsh.route.minimumGeneration',
|
|
1049
|
+
'dsh.route.receiptVersion',
|
|
1050
|
+
'dsh.route.sourceHash',
|
|
1051
|
+
'dsh.route.sourceId',
|
|
1052
|
+
];
|
|
1053
|
+
function exactPositiveIntegerText(value) {
|
|
1054
|
+
if (value === undefined || !/^[1-9][0-9]*$/u.test(value))
|
|
1055
|
+
return undefined;
|
|
1056
|
+
const parsed = Number(value);
|
|
1057
|
+
return Number.isSafeInteger(parsed) && String(parsed) === value ? parsed : undefined;
|
|
1058
|
+
}
|
|
1059
|
+
function parseOwnerRouteReceipt(record) {
|
|
1060
|
+
const metadata = record.intent.metadata;
|
|
1061
|
+
const authorityId = metadata?.['dsh.route.authority'];
|
|
1062
|
+
if (authorityId === undefined)
|
|
1063
|
+
return { kind: 'not-route' };
|
|
1064
|
+
if (metadata === undefined
|
|
1065
|
+
|| Object.keys(metadata).length !== ownerRouteReceiptKeys.length
|
|
1066
|
+
|| Object.keys(metadata).some(key => !ownerRouteReceiptKeys.includes(key))
|
|
1067
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(authorityId)
|
|
1068
|
+
|| !/^[a-f0-9]{64}$/u.test(metadata['dsh.route.authorityHash'] ?? '')
|
|
1069
|
+
|| !/^[a-f0-9]{64}$/u.test(metadata['dsh.route.sourceHash'] ?? '')
|
|
1070
|
+
|| metadata['dsh.route.receiptVersion'] !== '2'
|
|
1071
|
+
|| !['plain', 'markdown'].includes(record.intent.format ?? 'plain')
|
|
1072
|
+
|| record.intent.approval !== undefined
|
|
1073
|
+
|| record.intent.modelPicker !== undefined
|
|
1074
|
+
|| record.intent.permissionPicker !== undefined
|
|
1075
|
+
|| record.intent.replyToEventId !== undefined)
|
|
1076
|
+
return { kind: 'invalid' };
|
|
1077
|
+
let sourceId;
|
|
1078
|
+
let initialBindingId;
|
|
1079
|
+
try {
|
|
1080
|
+
sourceId = canonicalBackgroundSourceId(metadata['dsh.route.sourceId']);
|
|
1081
|
+
initialBindingId = validateBindingText(metadata['dsh.route.initialBindingId'], 'initialBindingId', 256);
|
|
1082
|
+
}
|
|
1083
|
+
catch {
|
|
1084
|
+
return { kind: 'invalid' };
|
|
1085
|
+
}
|
|
1086
|
+
const bindingVersion = exactPositiveIntegerText(metadata['dsh.route.bindingVersion']);
|
|
1087
|
+
const generation = exactPositiveIntegerText(metadata['dsh.route.generation']);
|
|
1088
|
+
const initialBindingVersion = exactPositiveIntegerText(metadata['dsh.route.initialBindingVersion']);
|
|
1089
|
+
const initialGeneration = exactPositiveIntegerText(metadata['dsh.route.initialGeneration']);
|
|
1090
|
+
const minimumGeneration = exactPositiveIntegerText(metadata['dsh.route.minimumGeneration']);
|
|
1091
|
+
const sourceHash = metadata['dsh.route.sourceHash'];
|
|
1092
|
+
if (bindingVersion === undefined || generation === undefined
|
|
1093
|
+
|| initialBindingVersion === undefined || initialGeneration === undefined
|
|
1094
|
+
|| minimumGeneration === undefined
|
|
1095
|
+
|| sourceId !== metadata['dsh.route.sourceId']
|
|
1096
|
+
|| digest(sourceId) !== sourceHash)
|
|
1097
|
+
return { kind: 'invalid' };
|
|
1098
|
+
return {
|
|
1099
|
+
kind: 'route',
|
|
1100
|
+
evidence: {
|
|
1101
|
+
authorityId,
|
|
1102
|
+
authorityHash: metadata['dsh.route.authorityHash'],
|
|
1103
|
+
bindingVersion,
|
|
1104
|
+
generation,
|
|
1105
|
+
initialBindingId,
|
|
1106
|
+
initialBindingVersion,
|
|
1107
|
+
initialGeneration,
|
|
1108
|
+
minimumGeneration,
|
|
1109
|
+
sourceHash,
|
|
1110
|
+
sourceId,
|
|
1111
|
+
},
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
function ownerRouteReceiptMetadata(input) {
|
|
1115
|
+
const snapshot = ownerRouteBindingSnapshot(input.authority, input.binding);
|
|
1116
|
+
const initial = input.initial ?? {
|
|
1117
|
+
initialBindingId: snapshot.bindingId,
|
|
1118
|
+
initialBindingVersion: snapshot.bindingVersion,
|
|
1119
|
+
initialGeneration: snapshot.generation,
|
|
1120
|
+
};
|
|
1121
|
+
return {
|
|
1122
|
+
'dsh.route.authority': snapshot.authorityId,
|
|
1123
|
+
'dsh.route.authorityHash': snapshot.authorityHash,
|
|
1124
|
+
'dsh.route.bindingVersion': String(snapshot.bindingVersion),
|
|
1125
|
+
'dsh.route.generation': String(snapshot.generation),
|
|
1126
|
+
'dsh.route.initialBindingId': initial.initialBindingId,
|
|
1127
|
+
'dsh.route.initialBindingVersion': String(initial.initialBindingVersion),
|
|
1128
|
+
'dsh.route.initialGeneration': String(initial.initialGeneration),
|
|
1129
|
+
'dsh.route.minimumGeneration': String(snapshot.minimumGeneration),
|
|
1130
|
+
'dsh.route.receiptVersion': String(snapshot.receiptVersion),
|
|
1131
|
+
'dsh.route.sourceHash': digest(input.sourceId),
|
|
1132
|
+
'dsh.route.sourceId': input.sourceId,
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
452
1135
|
function canonicalPermissionPicker(input, binding) {
|
|
453
1136
|
if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)
|
|
454
1137
|
|| Object.keys(input).some(field => ![
|
|
@@ -601,6 +1284,29 @@ export class DeliveryStore {
|
|
|
601
1284
|
throw new DeliveryStoreError('conflict', 'delivery database instance namespace is missing or invalid');
|
|
602
1285
|
}
|
|
603
1286
|
this.databaseInstanceId = instance.instance_id;
|
|
1287
|
+
const now = this.now();
|
|
1288
|
+
const authorityDigest = growthObjectDigest({
|
|
1289
|
+
contract: 'assistant-delivery-workflow-trace-source/v1',
|
|
1290
|
+
databaseInstanceId: this.databaseInstanceId,
|
|
1291
|
+
sourceId: WORKFLOW_TRACE_SOURCE_ID,
|
|
1292
|
+
});
|
|
1293
|
+
this.database.prepare(`
|
|
1294
|
+
INSERT INTO workflow_trace_source(
|
|
1295
|
+
singleton, contract_version, generation, authority_digest, created_at, updated_at
|
|
1296
|
+
) VALUES (1, 1, 1, ?, ?, ?)
|
|
1297
|
+
ON CONFLICT(singleton) DO NOTHING
|
|
1298
|
+
`).run(authorityDigest, now, now);
|
|
1299
|
+
const traceSource = this.database.prepare(`
|
|
1300
|
+
SELECT contract_version, generation, authority_digest
|
|
1301
|
+
FROM workflow_trace_source WHERE singleton = 1
|
|
1302
|
+
`).get();
|
|
1303
|
+
if (traceSource?.contract_version !== ASSISTANT_GROWTH_CONTRACT_VERSION
|
|
1304
|
+
|| traceSource.generation < 1 || !/^[a-f0-9]{64}$/u.test(traceSource.authority_digest)
|
|
1305
|
+
|| traceSource.authority_digest !== authorityDigest) {
|
|
1306
|
+
this.database.close();
|
|
1307
|
+
this.closed = true;
|
|
1308
|
+
throw new DeliveryStoreError('conflict', 'workflow trace source authority is missing or stale');
|
|
1309
|
+
}
|
|
604
1310
|
}
|
|
605
1311
|
instanceId() {
|
|
606
1312
|
this.assertOpen();
|
|
@@ -838,6 +1544,23 @@ export class DeliveryStore {
|
|
|
838
1544
|
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
839
1545
|
FROM delivery_principals WHERE id = ?
|
|
840
1546
|
`).get(replacement.id);
|
|
1547
|
+
// Projection authority is the exact durable owner row, not the external
|
|
1548
|
+
// principal string. In particular, A -> B -> A gives A a new row
|
|
1549
|
+
// version. Retire every batch that cannot belong to this exact version
|
|
1550
|
+
// in the same transaction as the handoff so restart/requeue can never
|
|
1551
|
+
// resurrect an older owner's evidence. Legacy and unclassified rows are
|
|
1552
|
+
// deliberately included because they cannot prove an exact lineage.
|
|
1553
|
+
this.database.prepare(`
|
|
1554
|
+
UPDATE delivery_preference_projection_outbox
|
|
1555
|
+
SET terminal_at = ?, failure_code = 'owner-lineage-retired',
|
|
1556
|
+
next_attempt_at = 9007199254740991, updated_at = ?
|
|
1557
|
+
WHERE terminal_at IS NULL AND status IN ('pending', 'retry_wait')
|
|
1558
|
+
AND NOT (
|
|
1559
|
+
lane_kind = 'exact'
|
|
1560
|
+
AND lane_principal_record_id = ?
|
|
1561
|
+
AND lane_principal_version = ?
|
|
1562
|
+
)
|
|
1563
|
+
`).run(now, now, result.id, result.version);
|
|
841
1564
|
return principalFromRow(result);
|
|
842
1565
|
});
|
|
843
1566
|
}
|
|
@@ -855,6 +1578,16 @@ export class DeliveryStore {
|
|
|
855
1578
|
`).get(principalHash(input));
|
|
856
1579
|
return row === undefined ? undefined : principalFromRow(row);
|
|
857
1580
|
}
|
|
1581
|
+
getBindingPrincipal(bindingId) {
|
|
1582
|
+
const row = this.database.prepare(`
|
|
1583
|
+
SELECT principal.id, principal.principal_json, principal.role, principal.status,
|
|
1584
|
+
principal.linked_to_id, principal.created_at, principal.updated_at, principal.version
|
|
1585
|
+
FROM conversation_bindings AS binding
|
|
1586
|
+
JOIN delivery_principals AS principal ON principal.id = binding.principal_id
|
|
1587
|
+
WHERE binding.id = ?
|
|
1588
|
+
`).get(bindingId);
|
|
1589
|
+
return row === undefined ? undefined : principalFromRow(row);
|
|
1590
|
+
}
|
|
858
1591
|
createBinding(input) {
|
|
859
1592
|
this.assertOpen();
|
|
860
1593
|
const target = canonicalTarget({ conversation: input.conversation, principal: input.principal });
|
|
@@ -942,11 +1675,133 @@ export class DeliveryStore {
|
|
|
942
1675
|
return this.database.prepare(`${bindingSelect} WHERE status = 'active' ORDER BY created_at, id`)
|
|
943
1676
|
.all().map(bindingFromRow);
|
|
944
1677
|
}
|
|
1678
|
+
/** Snapshot one exact active owner binding under the Delivery write fence. */
|
|
1679
|
+
resolveApprovalRouteByBinding(input) {
|
|
1680
|
+
this.assertOpen();
|
|
1681
|
+
let sourceId;
|
|
1682
|
+
let bindingId;
|
|
1683
|
+
let workspace;
|
|
1684
|
+
let preset;
|
|
1685
|
+
let principalId;
|
|
1686
|
+
try {
|
|
1687
|
+
sourceId = canonicalBackgroundSourceId(input.sourceId);
|
|
1688
|
+
bindingId = approvalRouteText(input.bindingId, 'bindingId', 512);
|
|
1689
|
+
workspace = approvalRouteText(input.scope.workspace, 'workspace', 4_096);
|
|
1690
|
+
preset = approvalRouteText(input.scope.preset, 'preset', 256);
|
|
1691
|
+
principalId = input.principalId === undefined
|
|
1692
|
+
? undefined
|
|
1693
|
+
: approvalRouteText(input.principalId, 'principalId', 512);
|
|
1694
|
+
}
|
|
1695
|
+
catch {
|
|
1696
|
+
throw new DeliveryStoreError('invalid-binding', 'approval route resolution input is invalid');
|
|
1697
|
+
}
|
|
1698
|
+
if (!isAbsolute(workspace)) {
|
|
1699
|
+
throw new DeliveryStoreError('invalid-binding', 'approval route workspace is invalid');
|
|
1700
|
+
}
|
|
1701
|
+
return this.transaction(() => {
|
|
1702
|
+
const binding = this.getBinding(bindingId);
|
|
1703
|
+
const owner = binding === undefined ? undefined : this.getBindingPrincipal(binding.id);
|
|
1704
|
+
const currentPrincipalId = binding === undefined ? undefined : externalPrincipalId(binding.principal);
|
|
1705
|
+
if (binding?.status !== 'active' || binding.workspace !== workspace || binding.agentPreset !== preset
|
|
1706
|
+
|| owner?.status !== 'active' || owner.role !== 'owner'
|
|
1707
|
+
|| JSON.stringify(owner.principal) !== JSON.stringify(binding.principal)
|
|
1708
|
+
|| currentPrincipalId === undefined
|
|
1709
|
+
|| (principalId !== undefined && currentPrincipalId !== principalId))
|
|
1710
|
+
return undefined;
|
|
1711
|
+
return Object.freeze({
|
|
1712
|
+
routeVersion: 2,
|
|
1713
|
+
sourceId,
|
|
1714
|
+
bindingId: binding.id,
|
|
1715
|
+
bindingVersion: binding.version,
|
|
1716
|
+
bindingGeneration: binding.generation,
|
|
1717
|
+
workspace: binding.workspace,
|
|
1718
|
+
principal: currentPrincipalId,
|
|
1719
|
+
principalRecordId: owner.id,
|
|
1720
|
+
principalVersion: owner.version,
|
|
1721
|
+
});
|
|
1722
|
+
});
|
|
1723
|
+
}
|
|
1724
|
+
/** Resolve one unique current owner from content-free Host authority evidence. */
|
|
1725
|
+
resolveOwnerApprovalForPreference(input) {
|
|
1726
|
+
this.assertOpen();
|
|
1727
|
+
let sourceId;
|
|
1728
|
+
let workspace;
|
|
1729
|
+
let preset;
|
|
1730
|
+
let principalId;
|
|
1731
|
+
let principalRecordId;
|
|
1732
|
+
try {
|
|
1733
|
+
sourceId = canonicalBackgroundSourceId(input.sourceId);
|
|
1734
|
+
workspace = approvalRouteText(input.scope.workspace, 'workspace', 4_096);
|
|
1735
|
+
preset = approvalRouteText(input.scope.preset, 'preset', 256);
|
|
1736
|
+
principalId = approvalRouteText(input.principalId, 'principalId', 512);
|
|
1737
|
+
principalRecordId = approvalRouteText(input.principalLineage.principalRecordId, 'principalRecordId', 500);
|
|
1738
|
+
}
|
|
1739
|
+
catch {
|
|
1740
|
+
throw new DeliveryStoreError('invalid-binding', 'owner approval authority input is invalid');
|
|
1741
|
+
}
|
|
1742
|
+
if (!isAbsolute(workspace)
|
|
1743
|
+
|| !Number.isSafeInteger(input.principalLineage.principalVersion)
|
|
1744
|
+
|| input.principalLineage.principalVersion < 1
|
|
1745
|
+
|| !Number.isSafeInteger(input.ownerGeneration) || input.ownerGeneration < 1) {
|
|
1746
|
+
throw new DeliveryStoreError('invalid-binding', 'owner approval authority fence is invalid');
|
|
1747
|
+
}
|
|
1748
|
+
return this.transaction(() => {
|
|
1749
|
+
const rows = this.database.prepare(`
|
|
1750
|
+
SELECT binding.id, binding.conversation_json, binding.principal_json, binding.workspace,
|
|
1751
|
+
binding.agent_preset, binding.session_id, binding.generation, binding.policy_ref,
|
|
1752
|
+
binding.status, binding.created_at, binding.updated_at, binding.version
|
|
1753
|
+
FROM conversation_bindings AS binding
|
|
1754
|
+
JOIN delivery_principals AS principal ON principal.id = binding.principal_id
|
|
1755
|
+
WHERE binding.status = 'active' AND binding.workspace = ? AND binding.agent_preset = ?
|
|
1756
|
+
AND principal.id = ? AND principal.version = ?
|
|
1757
|
+
AND principal.status = 'active' AND principal.role = 'owner'
|
|
1758
|
+
ORDER BY binding.id LIMIT 2
|
|
1759
|
+
`).all(workspace, preset, principalRecordId, input.principalLineage.principalVersion);
|
|
1760
|
+
if (rows.length !== 1)
|
|
1761
|
+
return undefined;
|
|
1762
|
+
const binding = bindingFromRow(rows[0]);
|
|
1763
|
+
const owner = this.getBindingPrincipal(binding.id);
|
|
1764
|
+
if (externalPrincipalId(binding.principal) !== principalId
|
|
1765
|
+
|| binding.generation !== input.ownerGeneration
|
|
1766
|
+
|| owner?.status !== 'active' || owner.role !== 'owner'
|
|
1767
|
+
|| owner.id !== principalRecordId || owner.version !== input.principalLineage.principalVersion
|
|
1768
|
+
|| JSON.stringify(owner.principal) !== JSON.stringify(binding.principal))
|
|
1769
|
+
return undefined;
|
|
1770
|
+
return Object.freeze({
|
|
1771
|
+
routeVersion: 2,
|
|
1772
|
+
sourceId,
|
|
1773
|
+
bindingId: binding.id,
|
|
1774
|
+
bindingVersion: binding.version,
|
|
1775
|
+
bindingGeneration: binding.generation,
|
|
1776
|
+
workspace: binding.workspace,
|
|
1777
|
+
principal: principalId,
|
|
1778
|
+
principalRecordId,
|
|
1779
|
+
principalVersion: input.principalLineage.principalVersion,
|
|
1780
|
+
});
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
945
1783
|
getBindingBySession(sessionId) {
|
|
946
1784
|
this.assertOpen();
|
|
947
1785
|
const row = this.database.prepare(`${bindingSelect} WHERE session_id = ?`).get(sessionId);
|
|
948
1786
|
return row === undefined ? undefined : bindingFromRow(row);
|
|
949
1787
|
}
|
|
1788
|
+
/** Read one exact Agent owner attestation under a single SQLite snapshot. */
|
|
1789
|
+
getPreferencePrincipalForSession(input) {
|
|
1790
|
+
this.assertOpen();
|
|
1791
|
+
if (typeof input.sessionId !== 'string' || typeof input.workspace !== 'string'
|
|
1792
|
+
|| typeof input.preset !== 'string')
|
|
1793
|
+
return undefined;
|
|
1794
|
+
return this.transaction(() => {
|
|
1795
|
+
const binding = this.getBindingBySession(input.sessionId);
|
|
1796
|
+
const principal = binding === undefined ? undefined : this.getBindingPrincipal(binding.id);
|
|
1797
|
+
if (binding?.status !== 'active' || binding.sessionId !== input.sessionId
|
|
1798
|
+
|| binding.workspace !== input.workspace || binding.agentPreset !== input.preset
|
|
1799
|
+
|| principal?.status !== 'active' || principal.role !== 'owner'
|
|
1800
|
+
|| JSON.stringify(principal.principal) !== JSON.stringify(binding.principal))
|
|
1801
|
+
return undefined;
|
|
1802
|
+
return Object.freeze({ binding, principal });
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
950
1805
|
getModelSelection(input) {
|
|
951
1806
|
this.assertOpen();
|
|
952
1807
|
const conversation = canonicalConversation(input);
|
|
@@ -1362,20 +2217,26 @@ export class DeliveryStore {
|
|
|
1362
2217
|
const bindingPrincipalJson = principalJson(binding.principal);
|
|
1363
2218
|
const now = this.now();
|
|
1364
2219
|
return this.transaction(() => {
|
|
1365
|
-
const boundaryRow = this.database.prepare(
|
|
1366
|
-
|
|
2220
|
+
const boundaryRow = this.database.prepare(`
|
|
2221
|
+
SELECT admission.admission_sequence
|
|
2222
|
+
FROM inbox_messages AS message
|
|
2223
|
+
JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
|
|
2224
|
+
WHERE message.id = ?
|
|
2225
|
+
`).get(beforeInboxId);
|
|
1367
2226
|
if (boundaryRow === undefined) {
|
|
1368
2227
|
throw new DeliveryStoreError('not-found', 'inbox cancellation boundary was not found');
|
|
1369
2228
|
}
|
|
1370
2229
|
const dispatchingRows = this.database.prepare(`
|
|
1371
|
-
SELECT id, envelope_json FROM inbox_messages
|
|
1372
|
-
|
|
2230
|
+
SELECT message.id, message.envelope_json FROM inbox_messages AS message
|
|
2231
|
+
JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
|
|
2232
|
+
WHERE admission.admission_sequence < ?
|
|
2233
|
+
AND message.status = 'claimed' AND message.failure_code = 'dispatch-started'
|
|
1373
2234
|
AND (binding_id = ? OR (
|
|
1374
2235
|
json_extract(envelope_json, '$.conversation') = json(?)
|
|
1375
2236
|
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1376
2237
|
))
|
|
1377
|
-
ORDER BY
|
|
1378
|
-
`).all(boundaryRow.
|
|
2238
|
+
ORDER BY admission.admission_sequence
|
|
2239
|
+
`).all(boundaryRow.admission_sequence, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1379
2240
|
for (const row of dispatchingRows) {
|
|
1380
2241
|
let permission = false;
|
|
1381
2242
|
try {
|
|
@@ -1393,36 +2254,44 @@ export class DeliveryStore {
|
|
|
1393
2254
|
}
|
|
1394
2255
|
}
|
|
1395
2256
|
const claimedRows = this.database.prepare(`
|
|
1396
|
-
SELECT id FROM inbox_messages
|
|
1397
|
-
|
|
2257
|
+
SELECT message.id FROM inbox_messages AS message
|
|
2258
|
+
JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
|
|
2259
|
+
WHERE admission.admission_sequence < ?
|
|
2260
|
+
AND message.status = 'claimed' AND message.failure_code IS NULL
|
|
1398
2261
|
AND (binding_id = ? OR (
|
|
1399
2262
|
json_extract(envelope_json, '$.conversation') = json(?)
|
|
1400
2263
|
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1401
2264
|
))
|
|
1402
|
-
ORDER BY
|
|
1403
|
-
`).all(boundaryRow.
|
|
2265
|
+
ORDER BY admission.admission_sequence
|
|
2266
|
+
`).all(boundaryRow.admission_sequence, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1404
2267
|
this.database.prepare(`
|
|
1405
2268
|
UPDATE inbox_attempts SET status = 'dead_letter', failure_code = ?, finished_at = ?
|
|
1406
2269
|
WHERE status = 'claimed' AND inbox_id IN (
|
|
1407
|
-
SELECT id FROM inbox_messages
|
|
1408
|
-
|
|
2270
|
+
SELECT message.id FROM inbox_messages AS message
|
|
2271
|
+
JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
|
|
2272
|
+
WHERE admission.admission_sequence < ?
|
|
2273
|
+
AND message.status = 'claimed' AND message.failure_code IS NULL
|
|
1409
2274
|
AND (binding_id = ? OR (
|
|
1410
2275
|
json_extract(envelope_json, '$.conversation') = json(?)
|
|
1411
2276
|
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1412
2277
|
))
|
|
1413
2278
|
)
|
|
1414
|
-
`).run(failureCode, now, boundaryRow.
|
|
2279
|
+
`).run(failureCode, now, boundaryRow.admission_sequence, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1415
2280
|
const cancelled = this.database.prepare(`
|
|
1416
2281
|
UPDATE inbox_messages SET status = 'dead_letter', failure_code = ?, next_attempt_at = NULL,
|
|
1417
2282
|
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
|
|
1418
|
-
WHERE
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
2283
|
+
WHERE id IN (
|
|
2284
|
+
SELECT message.id FROM inbox_messages AS message
|
|
2285
|
+
JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
|
|
2286
|
+
WHERE admission.admission_sequence < ?
|
|
2287
|
+
AND (message.binding_id = ? OR (
|
|
2288
|
+
json_extract(message.envelope_json, '$.conversation') = json(?)
|
|
2289
|
+
AND json_extract(message.envelope_json, '$.principal') = json(?)
|
|
2290
|
+
))
|
|
2291
|
+
AND (message.status IN ('received', 'authorized', 'queued', 'retry_wait')
|
|
2292
|
+
OR (message.status = 'claimed' AND message.failure_code IS NULL))
|
|
2293
|
+
)
|
|
2294
|
+
`).run(failureCode, now, boundaryRow.admission_sequence, bindingId, bindingConversationJson, bindingPrincipalJson);
|
|
1426
2295
|
const cancelledCount = Number(cancelled.changes);
|
|
1427
2296
|
if (!Number.isSafeInteger(cancelledCount)) {
|
|
1428
2297
|
throw new DeliveryStoreError('conflict', 'inbox cancellation count is outside the safe integer range');
|
|
@@ -1451,36 +2320,106 @@ export class DeliveryStore {
|
|
|
1451
2320
|
this.assertOpen();
|
|
1452
2321
|
const target = canonicalTarget({ conversation: input.conversation, principal: input.principal });
|
|
1453
2322
|
const beforeInboxId = validateBindingText(input.beforeInboxId, 'beforeInboxId', 256);
|
|
1454
|
-
const boundary = this.database.prepare(
|
|
1455
|
-
|
|
2323
|
+
const boundary = this.database.prepare(`
|
|
2324
|
+
SELECT admission.admission_sequence
|
|
2325
|
+
FROM inbox_messages AS message
|
|
2326
|
+
JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
|
|
2327
|
+
WHERE message.id = ?
|
|
2328
|
+
`).get(beforeInboxId);
|
|
1456
2329
|
if (boundary === undefined)
|
|
1457
2330
|
throw new DeliveryStoreError('not-found', 'inbound recovery boundary Inbox was not found');
|
|
1458
2331
|
const row = this.database.prepare(`${inboxSelect}
|
|
1459
|
-
|
|
2332
|
+
JOIN delivery_inbox_admissions AS pending_admission ON pending_admission.inbox_id = inbox_messages.id
|
|
2333
|
+
WHERE pending_admission.admission_sequence < ? AND status IN ('received', 'authorized')
|
|
1460
2334
|
AND json_extract(envelope_json, '$.conversation') = json(?)
|
|
1461
2335
|
AND json_extract(envelope_json, '$.principal') = json(?)
|
|
1462
|
-
ORDER BY
|
|
1463
|
-
`).get(boundary.
|
|
2336
|
+
ORDER BY pending_admission.admission_sequence LIMIT 1
|
|
2337
|
+
`).get(boundary.admission_sequence, conversationJson(target.conversation), principalJson(target.principal));
|
|
1464
2338
|
return row === undefined ? undefined : inboxFromRow(row);
|
|
1465
2339
|
}
|
|
1466
2340
|
listInbox(input = {}) {
|
|
1467
2341
|
this.assertOpen();
|
|
1468
2342
|
const limit = Math.max(1, Math.min(100, input.limit ?? 20));
|
|
1469
2343
|
const rows = input.bindingId === undefined
|
|
1470
|
-
? this.database.prepare(`${inboxSelect} ORDER BY received_at DESC,
|
|
1471
|
-
: this.database.prepare(`${inboxSelect} WHERE binding_id = ? ORDER BY received_at DESC,
|
|
2344
|
+
? this.database.prepare(`${inboxSelect} ORDER BY received_at DESC, admission_sequence DESC LIMIT ?`).all(limit)
|
|
2345
|
+
: this.database.prepare(`${inboxSelect} WHERE binding_id = ? ORDER BY received_at DESC, admission_sequence DESC LIMIT ?`)
|
|
1472
2346
|
.all(input.bindingId, limit);
|
|
1473
2347
|
return rows.map(inboxFromRow);
|
|
1474
2348
|
}
|
|
2349
|
+
getDeadLetterResolution(input) {
|
|
2350
|
+
this.assertOpen();
|
|
2351
|
+
if (!['inbox', 'outbox'].includes(input.kind)
|
|
2352
|
+
|| !Number.isSafeInteger(input.attemptCount) || input.attemptCount < 0) {
|
|
2353
|
+
throw new DeliveryStoreError('conflict', 'dead-letter resolution identity is invalid');
|
|
2354
|
+
}
|
|
2355
|
+
const id = validateBindingText(input.id, 'messageId', 256);
|
|
2356
|
+
const row = this.database.prepare(`
|
|
2357
|
+
SELECT kind, message_id, attempt_count, receipt_version, resolution, original_status,
|
|
2358
|
+
original_failure_code, operator_id, created_at
|
|
2359
|
+
FROM dead_letter_resolutions
|
|
2360
|
+
WHERE kind = ? AND message_id = ? AND attempt_count = ?
|
|
2361
|
+
`).get(input.kind, id, input.attemptCount);
|
|
2362
|
+
return row === undefined ? undefined : deadLetterResolutionFromRow(row);
|
|
2363
|
+
}
|
|
1475
2364
|
health() {
|
|
1476
2365
|
this.assertOpen();
|
|
1477
2366
|
const scalar = (sql) => this.database.prepare(sql).get().count;
|
|
1478
2367
|
return {
|
|
1479
2368
|
pendingInbox: scalar("SELECT COUNT(*) AS count FROM inbox_messages WHERE status IN ('received', 'authorized', 'queued', 'claimed', 'retry_wait')"),
|
|
1480
2369
|
deadLetterInbox: scalar("SELECT COUNT(*) AS count FROM inbox_messages WHERE status = 'dead_letter'"),
|
|
2370
|
+
actionableDeadLetterInbox: scalar(`
|
|
2371
|
+
SELECT COUNT(*) AS count FROM inbox_messages AS message
|
|
2372
|
+
WHERE message.status = 'dead_letter' AND NOT EXISTS (
|
|
2373
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
2374
|
+
WHERE resolution.kind = 'inbox' AND resolution.message_id = message.id
|
|
2375
|
+
AND resolution.attempt_count = message.attempt_count AND resolution.resolution = 'cancel'
|
|
2376
|
+
)
|
|
2377
|
+
`),
|
|
2378
|
+
resolvedDeadLetterInbox: scalar(`
|
|
2379
|
+
SELECT COUNT(*) AS count FROM inbox_messages AS message
|
|
2380
|
+
WHERE message.status = 'dead_letter' AND EXISTS (
|
|
2381
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
2382
|
+
WHERE resolution.kind = 'inbox' AND resolution.message_id = message.id
|
|
2383
|
+
AND resolution.attempt_count = message.attempt_count AND resolution.resolution = 'cancel'
|
|
2384
|
+
)
|
|
2385
|
+
`),
|
|
1481
2386
|
pendingOutbox: scalar("SELECT COUNT(*) AS count FROM outbox_messages WHERE status IN ('pending', 'attempting', 'retry_wait')"),
|
|
1482
2387
|
deadLetterOutbox: scalar("SELECT COUNT(*) AS count FROM outbox_messages WHERE status = 'dead'"),
|
|
2388
|
+
actionableDeadLetterOutbox: scalar(`
|
|
2389
|
+
SELECT COUNT(*) AS count FROM outbox_messages AS message
|
|
2390
|
+
WHERE message.status = 'dead' AND NOT EXISTS (
|
|
2391
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
2392
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = message.id
|
|
2393
|
+
AND resolution.attempt_count = message.attempt_count AND resolution.resolution = 'cancel'
|
|
2394
|
+
)
|
|
2395
|
+
`),
|
|
2396
|
+
resolvedDeadLetterOutbox: scalar(`
|
|
2397
|
+
SELECT COUNT(*) AS count FROM outbox_messages AS message
|
|
2398
|
+
WHERE message.status = 'dead' AND EXISTS (
|
|
2399
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
2400
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = message.id
|
|
2401
|
+
AND resolution.attempt_count = message.attempt_count AND resolution.resolution = 'cancel'
|
|
2402
|
+
)
|
|
2403
|
+
`),
|
|
1483
2404
|
unknownOutbox: scalar("SELECT COUNT(*) AS count FROM outbox_messages WHERE status = 'unknown_after_send'"),
|
|
2405
|
+
actionableUnknownOutbox: scalar(`
|
|
2406
|
+
SELECT COUNT(*) AS count FROM outbox_messages AS message
|
|
2407
|
+
WHERE message.status = 'unknown_after_send' AND NOT EXISTS (
|
|
2408
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
2409
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = message.id
|
|
2410
|
+
AND resolution.attempt_count = message.attempt_count AND resolution.resolution = 'cancel'
|
|
2411
|
+
)
|
|
2412
|
+
`),
|
|
2413
|
+
resolvedUnknownOutbox: scalar(`
|
|
2414
|
+
SELECT COUNT(*) AS count FROM outbox_messages AS message
|
|
2415
|
+
WHERE message.status = 'unknown_after_send' AND EXISTS (
|
|
2416
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
2417
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = message.id
|
|
2418
|
+
AND resolution.attempt_count = message.attempt_count AND resolution.resolution = 'cancel'
|
|
2419
|
+
)
|
|
2420
|
+
`),
|
|
2421
|
+
pendingPresentations: scalar("SELECT COUNT(*) AS count FROM delivery_presentations WHERE status IN ('pending', 'attempting', 'retry_wait')"),
|
|
2422
|
+
deadPresentations: scalar("SELECT COUNT(*) AS count FROM delivery_presentations WHERE status = 'dead'"),
|
|
1484
2423
|
};
|
|
1485
2424
|
}
|
|
1486
2425
|
getApprovalDispatchCursor() {
|
|
@@ -1571,38 +2510,62 @@ export class DeliveryStore {
|
|
|
1571
2510
|
this.database.prepare(`
|
|
1572
2511
|
UPDATE inbox_messages SET status = 'dead_letter', failure_code = 'attempts-exhausted',
|
|
1573
2512
|
next_attempt_at = NULL, updated_at = ?
|
|
1574
|
-
WHERE
|
|
1575
|
-
SELECT
|
|
1576
|
-
|
|
1577
|
-
|
|
2513
|
+
WHERE id IN (
|
|
2514
|
+
SELECT candidate.id FROM inbox_messages AS candidate
|
|
2515
|
+
JOIN delivery_inbox_admissions AS candidate_admission
|
|
2516
|
+
ON candidate_admission.inbox_id = candidate.id
|
|
2517
|
+
WHERE candidate.status = 'retry_wait' AND candidate.next_attempt_at <= ?
|
|
2518
|
+
AND candidate.attempt_count >= ?
|
|
2519
|
+
AND (candidate.failure_code IS NULL OR candidate.failure_code NOT IN (
|
|
1578
2520
|
'permission-dispatch-recovery',
|
|
1579
2521
|
'permission-cancelled-recovery',
|
|
1580
|
-
'permission-failure-notice-recovery'
|
|
2522
|
+
'permission-failure-notice-recovery',
|
|
2523
|
+
'feedback-dispatch-recovery',
|
|
2524
|
+
'learning-dispatch-recovery',
|
|
2525
|
+
'workflow-dispatch-recovery'
|
|
1581
2526
|
))
|
|
1582
|
-
ORDER BY
|
|
2527
|
+
ORDER BY candidate_admission.admission_sequence LIMIT ?
|
|
1583
2528
|
)
|
|
1584
2529
|
`).run(now, now, input.maxAttempts, maintenanceLimit);
|
|
1585
2530
|
const candidates = this.database.prepare(`
|
|
1586
2531
|
SELECT candidate.id FROM inbox_messages AS candidate
|
|
2532
|
+
JOIN conversation_bindings AS candidate_binding
|
|
2533
|
+
ON candidate_binding.id = candidate.binding_id
|
|
2534
|
+
JOIN delivery_inbox_admissions AS candidate_admission
|
|
2535
|
+
ON candidate_admission.inbox_id = candidate.id
|
|
1587
2536
|
WHERE candidate.status IN ('queued', 'retry_wait')
|
|
1588
2537
|
AND (candidate.next_attempt_at IS NULL OR candidate.next_attempt_at <= ?)
|
|
1589
2538
|
AND (candidate.status = 'queued' OR candidate.attempt_count < ? OR candidate.failure_code IN (
|
|
1590
2539
|
'permission-dispatch-recovery',
|
|
1591
2540
|
'permission-cancelled-recovery',
|
|
1592
|
-
'permission-failure-notice-recovery'
|
|
2541
|
+
'permission-failure-notice-recovery',
|
|
2542
|
+
'feedback-dispatch-recovery',
|
|
2543
|
+
'learning-dispatch-recovery',
|
|
2544
|
+
'workflow-dispatch-recovery'
|
|
1593
2545
|
))
|
|
1594
2546
|
AND candidate.binding_id IS NOT NULL
|
|
1595
2547
|
AND NOT EXISTS (
|
|
1596
2548
|
SELECT 1 FROM inbox_messages AS earlier
|
|
1597
|
-
|
|
1598
|
-
|
|
2549
|
+
JOIN conversation_bindings AS earlier_binding
|
|
2550
|
+
ON earlier_binding.id = earlier.binding_id
|
|
2551
|
+
JOIN delivery_inbox_admissions AS earlier_admission
|
|
2552
|
+
ON earlier_admission.inbox_id = earlier.id
|
|
2553
|
+
WHERE earlier_binding.workspace = candidate_binding.workspace
|
|
2554
|
+
AND earlier_binding.agent_preset = candidate_binding.agent_preset
|
|
2555
|
+
AND earlier_binding.principal_id = candidate_binding.principal_id
|
|
2556
|
+
AND earlier_admission.admission_sequence < candidate_admission.admission_sequence
|
|
1599
2557
|
AND earlier.status NOT IN ('processed', 'dead_letter')
|
|
1600
2558
|
)
|
|
1601
2559
|
AND NOT EXISTS (
|
|
1602
2560
|
SELECT 1 FROM inbox_messages AS active
|
|
1603
|
-
|
|
2561
|
+
JOIN conversation_bindings AS active_binding
|
|
2562
|
+
ON active_binding.id = active.binding_id
|
|
2563
|
+
WHERE active_binding.workspace = candidate_binding.workspace
|
|
2564
|
+
AND active_binding.agent_preset = candidate_binding.agent_preset
|
|
2565
|
+
AND active_binding.principal_id = candidate_binding.principal_id
|
|
2566
|
+
AND active.status = 'claimed'
|
|
1604
2567
|
)
|
|
1605
|
-
ORDER BY
|
|
2568
|
+
ORDER BY candidate_admission.admission_sequence LIMIT ?
|
|
1606
2569
|
`).all(now, input.maxAttempts, input.limit);
|
|
1607
2570
|
for (const candidate of candidates) {
|
|
1608
2571
|
const current = this.getInbox(candidate.id);
|
|
@@ -1613,7 +2576,10 @@ export class DeliveryStore {
|
|
|
1613
2576
|
failure_code = CASE WHEN failure_code IN (
|
|
1614
2577
|
'permission-dispatch-recovery',
|
|
1615
2578
|
'permission-cancelled-recovery',
|
|
1616
|
-
'permission-failure-notice-recovery'
|
|
2579
|
+
'permission-failure-notice-recovery',
|
|
2580
|
+
'feedback-dispatch-recovery',
|
|
2581
|
+
'learning-dispatch-recovery',
|
|
2582
|
+
'workflow-dispatch-recovery'
|
|
1617
2583
|
) THEN failure_code ELSE NULL END,
|
|
1618
2584
|
updated_at = ?
|
|
1619
2585
|
WHERE id = ? AND status IN ('queued', 'retry_wait')
|
|
@@ -1675,7 +2641,10 @@ export class DeliveryStore {
|
|
|
1675
2641
|
UPDATE inbox_messages AS inbox SET failure_code = CASE WHEN failure_code IN (
|
|
1676
2642
|
'permission-dispatch-recovery',
|
|
1677
2643
|
'permission-cancelled-recovery',
|
|
1678
|
-
'permission-failure-notice-recovery'
|
|
2644
|
+
'permission-failure-notice-recovery',
|
|
2645
|
+
'feedback-dispatch-recovery',
|
|
2646
|
+
'learning-dispatch-recovery',
|
|
2647
|
+
'workflow-dispatch-recovery'
|
|
1679
2648
|
) THEN failure_code ELSE 'dispatch-started' END, updated_at = ?
|
|
1680
2649
|
WHERE inbox.id = ? AND inbox.status = 'claimed' AND inbox.claimed_by = ?
|
|
1681
2650
|
AND inbox.fencing_token = ? AND inbox.lease_until > ? AND inbox.binding_id = ?
|
|
@@ -1726,18 +2695,34 @@ export class DeliveryStore {
|
|
|
1726
2695
|
const recovered = [];
|
|
1727
2696
|
this.transaction(() => {
|
|
1728
2697
|
const rows = this.database.prepare(`
|
|
1729
|
-
SELECT id, event_id, binding_id, attempt_count,
|
|
1730
|
-
|
|
2698
|
+
SELECT message.id, message.event_id, message.binding_id, message.attempt_count,
|
|
2699
|
+
message.failure_code, message.envelope_json
|
|
2700
|
+
FROM inbox_messages AS message
|
|
2701
|
+
JOIN delivery_inbox_admissions AS admission ON admission.inbox_id = message.id
|
|
2702
|
+
WHERE message.status = 'claimed' AND message.lease_until <= ?
|
|
2703
|
+
ORDER BY admission.admission_sequence LIMIT ?
|
|
1731
2704
|
`).all(now, limit);
|
|
1732
2705
|
for (const row of rows) {
|
|
1733
2706
|
let interruptedPermission = false;
|
|
2707
|
+
let interruptedFeedback = false;
|
|
2708
|
+
let interruptedLearning = false;
|
|
2709
|
+
let interruptedWorkflow = false;
|
|
1734
2710
|
if (row.failure_code === 'dispatch-started') {
|
|
1735
2711
|
try {
|
|
1736
|
-
|
|
2712
|
+
const envelope = JSON.parse(row.envelope_json);
|
|
2713
|
+
interruptedPermission = isPermissionDeliveryCommand(envelope);
|
|
2714
|
+
interruptedFeedback = isFeedbackDeliveryCommand(envelope);
|
|
2715
|
+
interruptedLearning = isLearningDeliveryCommand(envelope);
|
|
2716
|
+
interruptedWorkflow = isWorkflowDeliveryCommand(envelope);
|
|
1737
2717
|
}
|
|
1738
2718
|
catch { }
|
|
1739
2719
|
}
|
|
1740
2720
|
const existingPermissionRecovery = permissionDispatchRecoveryFromFailureCode(row.failure_code ?? undefined);
|
|
2721
|
+
const feedbackRecovery = interruptedFeedback || isFeedbackDispatchRecoveryCode(row.failure_code ?? undefined);
|
|
2722
|
+
const learningRecovery = interruptedLearning
|
|
2723
|
+
|| isLearningDispatchRecoveryCode(row.failure_code ?? undefined)
|
|
2724
|
+
|| isLearningRetryableFailureCode(row.failure_code ?? undefined);
|
|
2725
|
+
const workflowRecovery = interruptedWorkflow || isWorkflowDispatchRecoveryCode(row.failure_code ?? undefined);
|
|
1741
2726
|
const permissionCommand = interruptedPermission || existingPermissionRecovery !== undefined;
|
|
1742
2727
|
const terminalOutbox = !permissionCommand || row.binding_id === null
|
|
1743
2728
|
? undefined
|
|
@@ -1767,13 +2752,20 @@ export class DeliveryStore {
|
|
|
1767
2752
|
: existingPermissionRecovery === undefined
|
|
1768
2753
|
? undefined
|
|
1769
2754
|
: permissionDispatchRecoveryCode(existingPermissionRecovery);
|
|
1770
|
-
const ambiguous = row.failure_code === 'dispatch-started'
|
|
1771
|
-
|
|
2755
|
+
const ambiguous = row.failure_code === 'dispatch-started'
|
|
2756
|
+
&& permissionRecovery === undefined && !feedbackRecovery && !learningRecovery && !workflowRecovery;
|
|
2757
|
+
const exhausted = row.attempt_count >= input.maxAttempts
|
|
2758
|
+
&& permissionRecovery === undefined && !feedbackRecovery && !learningRecovery && !workflowRecovery;
|
|
1772
2759
|
const changed = this.database.prepare(`
|
|
1773
2760
|
UPDATE inbox_messages SET status = ?, next_attempt_at = ?, claimed_by = NULL,
|
|
1774
2761
|
fencing_token = NULL, lease_until = NULL, failure_code = ?, updated_at = ?
|
|
1775
2762
|
WHERE id = ? AND status = 'claimed' AND lease_until <= ?
|
|
1776
|
-
`).run(ambiguous || exhausted ? 'dead_letter' : 'retry_wait', ambiguous || exhausted ? null : now, ambiguous ? 'dispatch-ambiguous'
|
|
2763
|
+
`).run(ambiguous || exhausted ? 'dead_letter' : 'retry_wait', ambiguous || exhausted ? null : now, ambiguous ? 'dispatch-ambiguous'
|
|
2764
|
+
: permissionRecovery ?? (feedbackRecovery
|
|
2765
|
+
? feedbackDispatchRecoveryCode
|
|
2766
|
+
: learningRecovery
|
|
2767
|
+
? learningDispatchRecoveryCode
|
|
2768
|
+
: workflowRecovery ? workflowDispatchRecoveryCode : 'lease-expired'), now, row.id, now);
|
|
1777
2769
|
if (changed.changes !== 1)
|
|
1778
2770
|
continue;
|
|
1779
2771
|
this.database.prepare(`
|
|
@@ -1786,12 +2778,18 @@ export class DeliveryStore {
|
|
|
1786
2778
|
return recovered.map(id => this.getInbox(id));
|
|
1787
2779
|
}
|
|
1788
2780
|
enqueue(input) {
|
|
2781
|
+
return this.enqueueIntent(input, false);
|
|
2782
|
+
}
|
|
2783
|
+
enqueueIntent(input, approvalRouteValidated) {
|
|
1789
2784
|
this.assertOpen();
|
|
1790
2785
|
const binding = this.getBinding(input.bindingId);
|
|
1791
2786
|
if (binding === undefined || binding.status !== 'active') {
|
|
1792
2787
|
throw new DeliveryStoreError('invalid-intent', 'outbound intent requires an active binding');
|
|
1793
2788
|
}
|
|
1794
2789
|
const intent = canonicalIntent(input, binding, this.maxTextBytes);
|
|
2790
|
+
if (intent.format === 'approval' && !approvalRouteValidated) {
|
|
2791
|
+
throw new DeliveryStoreError('invalid-intent', 'approval Outbox requires an exact v2 route fence');
|
|
2792
|
+
}
|
|
1795
2793
|
if (intent.idempotencyKey.startsWith('inbound:') && intent.idempotencyKey.endsWith(':reply')) {
|
|
1796
2794
|
const replyEventId = intent.replyToEventId;
|
|
1797
2795
|
const inbox = replyEventId === undefined
|
|
@@ -1836,14 +2834,333 @@ export class DeliveryStore {
|
|
|
1836
2834
|
}
|
|
1837
2835
|
return this.getOutbox(id);
|
|
1838
2836
|
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Atomically revalidate one immutable Policy route and insert its approval
|
|
2839
|
+
* Outbox. Legacy routes are deliberately not accepted by this boundary.
|
|
2840
|
+
*/
|
|
2841
|
+
enqueueApprovalRoute(input) {
|
|
2842
|
+
this.assertOpen();
|
|
2843
|
+
const route = canonicalApprovalDispatchRoute(input.route);
|
|
2844
|
+
return this.transaction(() => {
|
|
2845
|
+
const binding = this.getBinding(route.bindingId);
|
|
2846
|
+
const owner = binding === undefined ? undefined : this.getBindingPrincipal(binding.id);
|
|
2847
|
+
if (binding === undefined || binding.status !== 'active'
|
|
2848
|
+
|| binding.version !== route.bindingVersion
|
|
2849
|
+
|| binding.generation !== route.bindingGeneration
|
|
2850
|
+
|| binding.workspace !== route.workspace
|
|
2851
|
+
|| externalPrincipalId(binding.principal) !== route.principal
|
|
2852
|
+
|| owner?.status !== 'active' || owner.role !== 'owner'
|
|
2853
|
+
|| owner.id !== route.principalRecordId || owner.version !== route.principalVersion
|
|
2854
|
+
|| externalPrincipalId(owner.principal) !== route.principal) {
|
|
2855
|
+
throw new DeliveryStoreError('invalid-binding', 'approval route exact owner fence changed');
|
|
2856
|
+
}
|
|
2857
|
+
const intent = canonicalIntent({
|
|
2858
|
+
idempotencyKey: input.intent.idempotencyKey,
|
|
2859
|
+
bindingId: binding.id,
|
|
2860
|
+
target: { conversation: binding.conversation, principal: binding.principal },
|
|
2861
|
+
text: input.intent.text,
|
|
2862
|
+
format: 'approval',
|
|
2863
|
+
approval: input.intent.approval,
|
|
2864
|
+
}, binding, this.maxTextBytes);
|
|
2865
|
+
const intentHash = digest(JSON.stringify(intent));
|
|
2866
|
+
const winnerRow = this.database.prepare(`${outboxSelect} WHERE idempotency_key = ?`)
|
|
2867
|
+
.get(intent.idempotencyKey);
|
|
2868
|
+
if (winnerRow !== undefined) {
|
|
2869
|
+
const receiptRow = this.database.prepare(`
|
|
2870
|
+
SELECT outbox_id, route_version, source_id, binding_id, binding_version, binding_generation,
|
|
2871
|
+
workspace, principal, principal_record_id, principal_version
|
|
2872
|
+
FROM approval_outbox_routes WHERE outbox_id = ?
|
|
2873
|
+
`).get(winnerRow.id);
|
|
2874
|
+
let receipt;
|
|
2875
|
+
try {
|
|
2876
|
+
receipt = receiptRow === undefined ? undefined : approvalRouteFromRow(receiptRow);
|
|
2877
|
+
}
|
|
2878
|
+
catch { }
|
|
2879
|
+
if (winnerRow.intent_hash !== intentHash || receipt === undefined || !sameApprovalRoute(receipt, route)) {
|
|
2880
|
+
throw new DeliveryStoreError('idempotency-conflict', 'approval Outbox winner does not match the exact immutable v2 route and intent');
|
|
2881
|
+
}
|
|
2882
|
+
return outboxFromRow(winnerRow);
|
|
2883
|
+
}
|
|
2884
|
+
const outbox = this.enqueueIntent(intent, true);
|
|
2885
|
+
this.database.prepare(`
|
|
2886
|
+
INSERT INTO approval_outbox_routes(
|
|
2887
|
+
outbox_id, route_version, source_id, binding_id, binding_version, binding_generation,
|
|
2888
|
+
workspace, principal, principal_record_id, principal_version
|
|
2889
|
+
) VALUES (?, 2, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2890
|
+
`).run(outbox.id, route.sourceId, route.bindingId, route.bindingVersion, route.bindingGeneration, route.workspace, route.principal, route.principalRecordId, route.principalVersion);
|
|
2891
|
+
return outbox;
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
/**
|
|
2895
|
+
* Resolve and enqueue one Host-owned stable owner route under the same
|
|
2896
|
+
* `BEGIN IMMEDIATE` fence as Outbox insertion.
|
|
2897
|
+
*
|
|
2898
|
+
* This closes the `/new` race between an earlier read and insertion. An
|
|
2899
|
+
* existing idempotency winner is returned without mutation (including
|
|
2900
|
+
* `unknown_after_send`) only after both the current route and the winner's
|
|
2901
|
+
* immutable historical route receipt are revalidated.
|
|
2902
|
+
*/
|
|
2903
|
+
enqueueOwnerRoute(input) {
|
|
2904
|
+
this.assertOpen();
|
|
2905
|
+
const authority = canonicalOwnerRouteAuthority(input.authority);
|
|
2906
|
+
let sourceId;
|
|
2907
|
+
try {
|
|
2908
|
+
sourceId = canonicalBackgroundSourceId(input.sourceId);
|
|
2909
|
+
}
|
|
2910
|
+
catch {
|
|
2911
|
+
throw new DeliveryStoreError('invalid-intent', 'owner route source id is invalid');
|
|
2912
|
+
}
|
|
2913
|
+
if (typeof input.sourceHash !== 'string' || !/^[a-f0-9]{64}$/u.test(input.sourceHash)
|
|
2914
|
+
|| digest(sourceId) !== input.sourceHash) {
|
|
2915
|
+
throw new DeliveryStoreError('invalid-intent', 'owner route source hash is invalid');
|
|
2916
|
+
}
|
|
2917
|
+
let idempotencyKey;
|
|
2918
|
+
try {
|
|
2919
|
+
idempotencyKey = validateBindingText(input.idempotencyKey, 'idempotencyKey', 512);
|
|
2920
|
+
}
|
|
2921
|
+
catch {
|
|
2922
|
+
throw new DeliveryStoreError('invalid-intent', 'owner route idempotency key is invalid');
|
|
2923
|
+
}
|
|
2924
|
+
return this.transaction(() => {
|
|
2925
|
+
const current = this.getActiveBinding(authority.conversation);
|
|
2926
|
+
const owner = current === undefined ? undefined : this.getPrincipal(current.principal);
|
|
2927
|
+
if (current === undefined || current.status !== 'active'
|
|
2928
|
+
|| !bindingMatchesOwnerRoute(current, authority)
|
|
2929
|
+
|| owner?.status !== 'active' || owner.role !== 'owner') {
|
|
2930
|
+
throw new DeliveryStoreError('invalid-binding', 'active binding does not match owner route authority');
|
|
2931
|
+
}
|
|
2932
|
+
const winnerRow = this.database.prepare(`${outboxSelect} WHERE idempotency_key = ?`)
|
|
2933
|
+
.get(idempotencyKey);
|
|
2934
|
+
const winner = winnerRow === undefined ? undefined : outboxFromRow(winnerRow);
|
|
2935
|
+
const binding = winner === undefined ? current : this.getBinding(winner.intent.bindingId);
|
|
2936
|
+
if (binding === undefined || !bindingMatchesOwnerRoute(binding, authority)) {
|
|
2937
|
+
throw new DeliveryStoreError('idempotency-conflict', 'owner route idempotency winner has another lineage');
|
|
2938
|
+
}
|
|
2939
|
+
let receiptMetadata;
|
|
2940
|
+
if (winner === undefined) {
|
|
2941
|
+
receiptMetadata = ownerRouteReceiptMetadata({ authority, binding, sourceId });
|
|
2942
|
+
}
|
|
2943
|
+
else {
|
|
2944
|
+
const parsed = parseOwnerRouteReceipt(winner);
|
|
2945
|
+
const evidence = parsed.kind === 'route' ? parsed.evidence : undefined;
|
|
2946
|
+
const initialBinding = evidence === undefined ? undefined : this.getBinding(evidence.initialBindingId);
|
|
2947
|
+
const exactReceipt = evidence !== undefined
|
|
2948
|
+
&& evidence.authorityId === authority.id
|
|
2949
|
+
&& evidence.authorityHash === ownerRouteAuthorityHash(authority)
|
|
2950
|
+
&& evidence.bindingVersion === binding.version - (binding.status === 'revoked' ? 1 : 0)
|
|
2951
|
+
&& evidence.generation === binding.generation
|
|
2952
|
+
&& evidence.minimumGeneration === authority.minimumGeneration
|
|
2953
|
+
&& evidence.sourceId === sourceId
|
|
2954
|
+
&& evidence.sourceHash === input.sourceHash
|
|
2955
|
+
&& initialBinding !== undefined
|
|
2956
|
+
&& bindingMatchesOwnerRoute(initialBinding, authority)
|
|
2957
|
+
&& evidence.initialBindingVersion
|
|
2958
|
+
=== initialBinding.version - (initialBinding.status === 'revoked' ? 1 : 0)
|
|
2959
|
+
&& evidence.initialGeneration === initialBinding.generation;
|
|
2960
|
+
if (!exactReceipt || winner.intent.metadata === undefined) {
|
|
2961
|
+
throw new DeliveryStoreError('idempotency-conflict', 'owner route idempotency winner has an invalid immutable route receipt');
|
|
2962
|
+
}
|
|
2963
|
+
receiptMetadata = winner.intent.metadata;
|
|
2964
|
+
}
|
|
2965
|
+
const expected = canonicalIntent({
|
|
2966
|
+
idempotencyKey,
|
|
2967
|
+
bindingId: binding.id,
|
|
2968
|
+
target: { conversation: binding.conversation, principal: binding.principal },
|
|
2969
|
+
text: input.text,
|
|
2970
|
+
format: input.format ?? 'plain',
|
|
2971
|
+
metadata: receiptMetadata,
|
|
2972
|
+
}, binding, this.maxTextBytes);
|
|
2973
|
+
if (winner !== undefined) {
|
|
2974
|
+
if (winner.intentHash !== digest(JSON.stringify(expected))) {
|
|
2975
|
+
throw new DeliveryStoreError('idempotency-conflict', 'owner route idempotency key was reused with a different immutable intent');
|
|
2976
|
+
}
|
|
2977
|
+
return winner;
|
|
2978
|
+
}
|
|
2979
|
+
return this.enqueue(expected);
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
1839
2982
|
getOutbox(id) {
|
|
1840
2983
|
this.assertOpen();
|
|
1841
2984
|
const row = this.database.prepare(`${outboxSelect} WHERE id = ?`).get(id);
|
|
1842
2985
|
return row === undefined ? undefined : outboxFromRow(row);
|
|
1843
2986
|
}
|
|
1844
|
-
|
|
2987
|
+
getOutboxByIdempotencyKey(idempotencyKeyInput) {
|
|
1845
2988
|
this.assertOpen();
|
|
1846
|
-
const
|
|
2989
|
+
const idempotencyKey = validateBindingText(idempotencyKeyInput, 'idempotencyKey', 200);
|
|
2990
|
+
const row = this.database.prepare(`${outboxSelect} WHERE idempotency_key = ?`)
|
|
2991
|
+
.get(idempotencyKey);
|
|
2992
|
+
return row === undefined ? undefined : outboxFromRow(row);
|
|
2993
|
+
}
|
|
2994
|
+
/** Persist the latest desired replacement for one exact durable message. */
|
|
2995
|
+
publishDeliveryPresentation(input) {
|
|
2996
|
+
this.assertOpen();
|
|
2997
|
+
const presentationKey = validateBindingText(input.presentationKey, 'presentationKey', 500);
|
|
2998
|
+
const originalOutboxIdempotencyKey = validateBindingText(input.originalOutboxIdempotencyKey, 'originalOutboxIdempotencyKey', 200);
|
|
2999
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1
|
|
3000
|
+
|| input.revision > Number.MAX_SAFE_INTEGER) {
|
|
3001
|
+
throw new DeliveryStoreError('invalid-intent', 'presentation revision is invalid');
|
|
3002
|
+
}
|
|
3003
|
+
const presentation = canonicalDeliveryPresentation(input.presentation);
|
|
3004
|
+
const payloadJson = JSON.stringify(presentation);
|
|
3005
|
+
const payloadHash = digest(payloadJson);
|
|
3006
|
+
const now = this.now();
|
|
3007
|
+
return this.transaction(() => {
|
|
3008
|
+
const current = this.database.prepare(`${deliveryPresentationSelect} WHERE presentation_key = ?`)
|
|
3009
|
+
.get(presentationKey);
|
|
3010
|
+
if (current !== undefined) {
|
|
3011
|
+
if (input.revision < current.revision) {
|
|
3012
|
+
throw new DeliveryStoreError('version-conflict', 'presentation revision is older than durable desired state');
|
|
3013
|
+
}
|
|
3014
|
+
if (input.revision === current.revision) {
|
|
3015
|
+
if (current.original_outbox_idempotency_key !== originalOutboxIdempotencyKey
|
|
3016
|
+
|| current.payload_hash !== payloadHash || current.payload_json !== payloadJson) {
|
|
3017
|
+
throw new DeliveryStoreError('idempotency-conflict', 'presentation revision was reused with different content');
|
|
3018
|
+
}
|
|
3019
|
+
return deliveryPresentationFromRow(current);
|
|
3020
|
+
}
|
|
3021
|
+
if (current.original_outbox_idempotency_key !== originalOutboxIdempotencyKey) {
|
|
3022
|
+
throw new DeliveryStoreError('idempotency-conflict', 'presentation lifecycle was retargeted to another durable message');
|
|
3023
|
+
}
|
|
3024
|
+
this.database.prepare(`
|
|
3025
|
+
UPDATE delivery_presentations
|
|
3026
|
+
SET revision = ?, payload_hash = ?, payload_json = ?, status = 'pending',
|
|
3027
|
+
next_attempt_at = NULL, claimed_by = NULL, fencing_token = NULL, lease_until = NULL,
|
|
3028
|
+
failure_code = NULL, updated_at = ?
|
|
3029
|
+
WHERE presentation_key = ? AND revision = ?
|
|
3030
|
+
`).run(input.revision, payloadHash, payloadJson, now, presentationKey, current.revision);
|
|
3031
|
+
}
|
|
3032
|
+
else {
|
|
3033
|
+
this.database.prepare(`
|
|
3034
|
+
INSERT INTO delivery_presentations(
|
|
3035
|
+
presentation_key, original_outbox_idempotency_key, revision, payload_hash, payload_json,
|
|
3036
|
+
status, attempt_count, presented_revision, created_at, updated_at)
|
|
3037
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, 0, ?, ?)
|
|
3038
|
+
`).run(presentationKey, originalOutboxIdempotencyKey, input.revision, payloadHash, payloadJson, now, now);
|
|
3039
|
+
}
|
|
3040
|
+
const saved = this.database.prepare(`${deliveryPresentationSelect} WHERE presentation_key = ?`)
|
|
3041
|
+
.get(presentationKey);
|
|
3042
|
+
return deliveryPresentationFromRow(saved);
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
getDeliveryPresentation(presentationKeyInput) {
|
|
3046
|
+
this.assertOpen();
|
|
3047
|
+
const presentationKey = validateBindingText(presentationKeyInput, 'presentationKey', 500);
|
|
3048
|
+
const row = this.database.prepare(`${deliveryPresentationSelect} WHERE presentation_key = ?`)
|
|
3049
|
+
.get(presentationKey);
|
|
3050
|
+
return row === undefined ? undefined : deliveryPresentationFromRow(row);
|
|
3051
|
+
}
|
|
3052
|
+
claimDeliveryPresentation(input) {
|
|
3053
|
+
this.assertOpen();
|
|
3054
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 500);
|
|
3055
|
+
if (!Number.isSafeInteger(input.leaseMs) || input.leaseMs < 1) {
|
|
3056
|
+
throw new DeliveryStoreError('conflict', 'presentation lease is invalid');
|
|
3057
|
+
}
|
|
3058
|
+
if ((input.excludePresentationKeys?.length ?? 0) > 100) {
|
|
3059
|
+
throw new DeliveryStoreError('conflict', 'presentation exclusions exceed their bound');
|
|
3060
|
+
}
|
|
3061
|
+
const excluded = input.excludePresentationKeys?.map(key => validateBindingText(key, 'presentationKey', 500)) ?? [];
|
|
3062
|
+
const exclusionClause = excluded.length === 0
|
|
3063
|
+
? ''
|
|
3064
|
+
: `AND presentation_key NOT IN (${excluded.map(() => '?').join(', ')})`;
|
|
3065
|
+
const now = this.now();
|
|
3066
|
+
return this.transaction(() => {
|
|
3067
|
+
// Quarantine bounded poison rows in-place before claiming a later valid
|
|
3068
|
+
// lifecycle. A malformed earliest row must never starve the queue.
|
|
3069
|
+
for (let inspected = 0; inspected < 100; inspected += 1) {
|
|
3070
|
+
const current = this.database.prepare(`${deliveryPresentationSelect}
|
|
3071
|
+
WHERE (status = 'pending'
|
|
3072
|
+
OR (status = 'retry_wait' AND next_attempt_at <= ?)
|
|
3073
|
+
OR (status = 'attempting' AND lease_until <= ?))
|
|
3074
|
+
${exclusionClause}
|
|
3075
|
+
ORDER BY created_at, presentation_key LIMIT 1`)
|
|
3076
|
+
.get(now, now, ...excluded);
|
|
3077
|
+
if (current === undefined)
|
|
3078
|
+
return undefined;
|
|
3079
|
+
try {
|
|
3080
|
+
deliveryPresentationFromRow(current);
|
|
3081
|
+
}
|
|
3082
|
+
catch {
|
|
3083
|
+
this.database.prepare(`
|
|
3084
|
+
UPDATE delivery_presentations
|
|
3085
|
+
SET status = 'dead', next_attempt_at = NULL, claimed_by = NULL,
|
|
3086
|
+
lease_until = NULL, failure_code = 'presentation-poison-row', updated_at = ?
|
|
3087
|
+
WHERE presentation_key = ? AND revision = ?
|
|
3088
|
+
`).run(now, current.presentation_key, current.revision);
|
|
3089
|
+
continue;
|
|
3090
|
+
}
|
|
3091
|
+
const fencingToken = (current.fencing_token ?? 0) + 1;
|
|
3092
|
+
const changed = this.database.prepare(`
|
|
3093
|
+
UPDATE delivery_presentations
|
|
3094
|
+
SET status = 'attempting', attempt_count = attempt_count + 1,
|
|
3095
|
+
claimed_by = ?, fencing_token = ?, lease_until = ?, next_attempt_at = NULL,
|
|
3096
|
+
failure_code = NULL, updated_at = ?
|
|
3097
|
+
WHERE presentation_key = ? AND revision = ?
|
|
3098
|
+
AND COALESCE(fencing_token, 0) = ?
|
|
3099
|
+
AND (status = 'pending'
|
|
3100
|
+
OR (status = 'retry_wait' AND next_attempt_at <= ?)
|
|
3101
|
+
OR (status = 'attempting' AND lease_until <= ?))
|
|
3102
|
+
`).run(ownerId, fencingToken, now + input.leaseMs, now, current.presentation_key, current.revision, current.fencing_token ?? 0, now, now);
|
|
3103
|
+
if (changed.changes !== 1)
|
|
3104
|
+
throw new DeliveryStoreError('stale-fence', 'presentation claim changed');
|
|
3105
|
+
const claimed = this.database.prepare(`${deliveryPresentationSelect} WHERE presentation_key = ?`)
|
|
3106
|
+
.get(current.presentation_key);
|
|
3107
|
+
return { presentation: deliveryPresentationFromRow(claimed), fencingToken };
|
|
3108
|
+
}
|
|
3109
|
+
return undefined;
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
finishDeliveryPresentation(input) {
|
|
3113
|
+
this.assertOpen();
|
|
3114
|
+
const presentationKey = validateBindingText(input.presentationKey, 'presentationKey', 500);
|
|
3115
|
+
const ownerId = validateBindingText(input.ownerId, 'ownerId', 500);
|
|
3116
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1
|
|
3117
|
+
|| !Number.isSafeInteger(input.fencingToken) || input.fencingToken < 1) {
|
|
3118
|
+
throw new DeliveryStoreError('stale-fence', 'presentation completion fence is invalid');
|
|
3119
|
+
}
|
|
3120
|
+
const providerMessageId = input.providerMessageId === undefined
|
|
3121
|
+
? undefined
|
|
3122
|
+
: validateBindingText(input.providerMessageId, 'providerMessageId', 512);
|
|
3123
|
+
const failureCode = input.failureCode === undefined
|
|
3124
|
+
? undefined
|
|
3125
|
+
: validateBindingText(input.failureCode, 'failureCode', 500);
|
|
3126
|
+
if (input.outcome === 'presented' && providerMessageId === undefined) {
|
|
3127
|
+
throw new DeliveryStoreError('conflict', 'presented replacement requires provider message identity');
|
|
3128
|
+
}
|
|
3129
|
+
if (input.outcome !== 'presented' && failureCode === undefined) {
|
|
3130
|
+
throw new DeliveryStoreError('conflict', 'failed replacement requires a failure code');
|
|
3131
|
+
}
|
|
3132
|
+
if (input.outcome === 'retry_wait'
|
|
3133
|
+
&& (!Number.isSafeInteger(input.nextAttemptAt) || input.nextAttemptAt < 0)) {
|
|
3134
|
+
throw new DeliveryStoreError('conflict', 'replacement retry time is invalid');
|
|
3135
|
+
}
|
|
3136
|
+
const now = this.now();
|
|
3137
|
+
const changed = this.database.prepare(`
|
|
3138
|
+
UPDATE delivery_presentations
|
|
3139
|
+
SET status = ?, presented_revision = CASE WHEN ? = 'presented' THEN revision ELSE presented_revision END,
|
|
3140
|
+
provider_message_id = COALESCE(?, provider_message_id), failure_code = ?, next_attempt_at = ?,
|
|
3141
|
+
claimed_by = NULL, lease_until = NULL, updated_at = ?
|
|
3142
|
+
WHERE presentation_key = ? AND revision = ? AND status = 'attempting'
|
|
3143
|
+
AND claimed_by = ? AND fencing_token = ?
|
|
3144
|
+
`).run(input.outcome, input.outcome, providerMessageId ?? null, failureCode ?? null, input.outcome === 'retry_wait' ? input.nextAttemptAt : null, now, presentationKey, input.revision, ownerId, input.fencingToken);
|
|
3145
|
+
if (changed.changes !== 1) {
|
|
3146
|
+
throw new DeliveryStoreError('stale-fence', 'presentation completion lost its claim or revision');
|
|
3147
|
+
}
|
|
3148
|
+
return this.getDeliveryPresentation(presentationKey);
|
|
3149
|
+
}
|
|
3150
|
+
/** Exact provider-addressed lookup used only to bind authenticated replies. */
|
|
3151
|
+
getOutboxByProviderMessage(channelInput, accountInput, providerMessageIdInput) {
|
|
3152
|
+
this.assertOpen();
|
|
3153
|
+
const channel = validateBindingText(channelInput, 'channel', 256);
|
|
3154
|
+
const account = validateBindingText(accountInput, 'account', 256);
|
|
3155
|
+
const providerMessageId = validateBindingText(providerMessageIdInput, 'providerMessageId', 512);
|
|
3156
|
+
const row = this.database.prepare(`${outboxSelect}
|
|
3157
|
+
WHERE channel = ? AND account = ? AND provider_message_id = ?`)
|
|
3158
|
+
.get(channel, account, providerMessageId);
|
|
3159
|
+
return row === undefined ? undefined : outboxFromRow(row);
|
|
3160
|
+
}
|
|
3161
|
+
getApprovalIntent(operationId, bindingId) {
|
|
3162
|
+
this.assertOpen();
|
|
3163
|
+
const operation = validateBindingText(operationId, 'operationId', 256);
|
|
1847
3164
|
const binding = validateBindingText(bindingId, 'bindingId', 256);
|
|
1848
3165
|
const row = this.database.prepare(`${outboxSelect}
|
|
1849
3166
|
WHERE json_extract(intent_json, '$.bindingId') = ?
|
|
@@ -1973,6 +3290,177 @@ export class DeliveryStore {
|
|
|
1973
3290
|
.all(input.bindingId, limit);
|
|
1974
3291
|
return rows.map(outboxFromRow);
|
|
1975
3292
|
}
|
|
3293
|
+
/** Recheck a claimed route immediately before provider I/O. */
|
|
3294
|
+
validateClaimedOwnerRoute(recordInput, guard) {
|
|
3295
|
+
this.assertOpen();
|
|
3296
|
+
const record = this.getOutbox(recordInput.id);
|
|
3297
|
+
if (record === undefined || record.status !== 'attempting'
|
|
3298
|
+
|| record.fencingToken !== recordInput.fencingToken
|
|
3299
|
+
|| record.claimedBy !== recordInput.claimedBy) {
|
|
3300
|
+
return { kind: 'denied', failureCode: 'owner-route-claim-changed' };
|
|
3301
|
+
}
|
|
3302
|
+
const inspected = this.inspectOwnerRouteDispatch(record, guard);
|
|
3303
|
+
return inspected.kind === 'authorized'
|
|
3304
|
+
? { kind: 'authorized' }
|
|
3305
|
+
: inspected;
|
|
3306
|
+
}
|
|
3307
|
+
/** Recheck a durable owner route before mutating its exact provider message. */
|
|
3308
|
+
validatePresentationOwnerRoute(recordInput, guard) {
|
|
3309
|
+
this.assertOpen();
|
|
3310
|
+
const record = this.getOutbox(recordInput.id);
|
|
3311
|
+
if (record === undefined || record.intentHash !== recordInput.intentHash
|
|
3312
|
+
|| record.intent.idempotencyKey !== recordInput.intent.idempotencyKey
|
|
3313
|
+
|| record.providerMessageId !== recordInput.providerMessageId) {
|
|
3314
|
+
return { kind: 'denied', failureCode: 'owner-route-message-changed' };
|
|
3315
|
+
}
|
|
3316
|
+
const inspected = this.inspectOwnerRouteDispatch(record, guard);
|
|
3317
|
+
return inspected.kind === 'authorized'
|
|
3318
|
+
? { kind: 'authorized' }
|
|
3319
|
+
: inspected;
|
|
3320
|
+
}
|
|
3321
|
+
inspectOwnerRouteDispatch(record, guard) {
|
|
3322
|
+
const parsed = parseOwnerRouteReceipt(record);
|
|
3323
|
+
if (parsed.kind === 'not-route')
|
|
3324
|
+
return parsed;
|
|
3325
|
+
if (parsed.kind === 'invalid') {
|
|
3326
|
+
return { kind: 'denied', failureCode: 'owner-route-receipt-invalid' };
|
|
3327
|
+
}
|
|
3328
|
+
const evidence = parsed.evidence;
|
|
3329
|
+
const authorities = new Map();
|
|
3330
|
+
try {
|
|
3331
|
+
for (const input of guard.ownerRoutes) {
|
|
3332
|
+
const authority = canonicalOwnerRouteAuthority(input);
|
|
3333
|
+
if (authorities.has(authority.id)) {
|
|
3334
|
+
return { kind: 'denied', failureCode: 'owner-route-authority-invalid' };
|
|
3335
|
+
}
|
|
3336
|
+
authorities.set(authority.id, authority);
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
catch {
|
|
3340
|
+
return { kind: 'denied', failureCode: 'owner-route-authority-invalid' };
|
|
3341
|
+
}
|
|
3342
|
+
const authority = authorities.get(evidence.authorityId);
|
|
3343
|
+
if (authority === undefined) {
|
|
3344
|
+
return { kind: 'denied', failureCode: 'owner-route-authority-revoked' };
|
|
3345
|
+
}
|
|
3346
|
+
if (ownerRouteAuthorityHash(authority) !== evidence.authorityHash
|
|
3347
|
+
|| authority.minimumGeneration !== evidence.minimumGeneration) {
|
|
3348
|
+
return { kind: 'denied', failureCode: 'owner-route-authority-changed' };
|
|
3349
|
+
}
|
|
3350
|
+
const binding = this.getBinding(record.intent.bindingId);
|
|
3351
|
+
const initialBinding = this.getBinding(evidence.initialBindingId);
|
|
3352
|
+
if (binding === undefined || initialBinding === undefined) {
|
|
3353
|
+
return { kind: 'denied', failureCode: 'owner-route-receipt-invalid' };
|
|
3354
|
+
}
|
|
3355
|
+
try {
|
|
3356
|
+
const canonical = canonicalIntent(record.intent, binding, this.maxTextBytes);
|
|
3357
|
+
if (digest(JSON.stringify(canonical)) !== record.intentHash
|
|
3358
|
+
|| !bindingMatchesOwnerRoute(binding, authority)
|
|
3359
|
+
|| !bindingMatchesOwnerRoute(initialBinding, authority)
|
|
3360
|
+
|| evidence.bindingVersion !== binding.version - (binding.status === 'revoked' ? 1 : 0)
|
|
3361
|
+
|| evidence.generation !== binding.generation
|
|
3362
|
+
|| evidence.initialBindingVersion
|
|
3363
|
+
!== initialBinding.version - (initialBinding.status === 'revoked' ? 1 : 0)
|
|
3364
|
+
|| evidence.initialGeneration !== initialBinding.generation) {
|
|
3365
|
+
return { kind: 'denied', failureCode: 'owner-route-receipt-invalid' };
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
catch {
|
|
3369
|
+
return { kind: 'denied', failureCode: 'owner-route-receipt-invalid' };
|
|
3370
|
+
}
|
|
3371
|
+
const current = this.getActiveBinding(authority.conversation);
|
|
3372
|
+
if (current === undefined) {
|
|
3373
|
+
return { kind: 'denied', failureCode: 'owner-route-binding-missing' };
|
|
3374
|
+
}
|
|
3375
|
+
const owner = this.getPrincipal(current.principal);
|
|
3376
|
+
if (!bindingMatchesOwnerRoute(current, authority)
|
|
3377
|
+
|| owner?.status !== 'active' || owner.role !== 'owner') {
|
|
3378
|
+
return { kind: 'denied', failureCode: 'owner-route-binding-drift' };
|
|
3379
|
+
}
|
|
3380
|
+
if (current.generation < evidence.generation
|
|
3381
|
+
|| current.generation < authority.minimumGeneration) {
|
|
3382
|
+
return { kind: 'denied', failureCode: 'owner-route-generation-below-floor' };
|
|
3383
|
+
}
|
|
3384
|
+
try {
|
|
3385
|
+
if (!guard.authorize({ authority, sourceId: evidence.sourceId,
|
|
3386
|
+
idempotencyKey: record.intent.idempotencyKey })) {
|
|
3387
|
+
return { kind: 'denied', failureCode: 'owner-route-policy-revoked' };
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
catch {
|
|
3391
|
+
return { kind: 'deferred', failureCode: 'owner-route-policy-check-failed' };
|
|
3392
|
+
}
|
|
3393
|
+
return { kind: 'authorized', authority, binding: current, evidence };
|
|
3394
|
+
}
|
|
3395
|
+
ownerRouteIsProvablyUnsent(record) {
|
|
3396
|
+
if (!['pending', 'retry_wait'].includes(record.status)
|
|
3397
|
+
|| record.claimedBy !== undefined || record.providerMessageId !== undefined)
|
|
3398
|
+
return false;
|
|
3399
|
+
const row = this.database.prepare(`
|
|
3400
|
+
SELECT COUNT(*) AS count FROM outbox_attempts
|
|
3401
|
+
WHERE outbox_id = ? AND status IN ('accepted', 'unknown_after_send', 'reconciled', 'attempting')
|
|
3402
|
+
`).get(record.id);
|
|
3403
|
+
return row.count === 0;
|
|
3404
|
+
}
|
|
3405
|
+
rebindOwnerRouteBeforeClaim(record, inspected) {
|
|
3406
|
+
if (record.intent.bindingId === inspected.binding.id)
|
|
3407
|
+
return true;
|
|
3408
|
+
const metadata = ownerRouteReceiptMetadata({
|
|
3409
|
+
authority: inspected.authority,
|
|
3410
|
+
binding: inspected.binding,
|
|
3411
|
+
sourceId: inspected.evidence.sourceId,
|
|
3412
|
+
initial: inspected.evidence,
|
|
3413
|
+
});
|
|
3414
|
+
let intent;
|
|
3415
|
+
try {
|
|
3416
|
+
intent = canonicalIntent({
|
|
3417
|
+
...record.intent,
|
|
3418
|
+
bindingId: inspected.binding.id,
|
|
3419
|
+
target: { conversation: inspected.binding.conversation, principal: inspected.binding.principal },
|
|
3420
|
+
metadata,
|
|
3421
|
+
}, inspected.binding, this.maxTextBytes);
|
|
3422
|
+
}
|
|
3423
|
+
catch {
|
|
3424
|
+
return false;
|
|
3425
|
+
}
|
|
3426
|
+
const intentJson = JSON.stringify(intent);
|
|
3427
|
+
const intentHash = digest(intentJson);
|
|
3428
|
+
return this.database.prepare(`
|
|
3429
|
+
UPDATE outbox_messages
|
|
3430
|
+
SET binding_id = ?, intent_hash = ?, intent_json = ?, channel = ?, account = ?, lane_hash = ?, updated_at = ?
|
|
3431
|
+
WHERE id = ? AND status = ? AND claimed_by IS NULL AND intent_hash = ?
|
|
3432
|
+
`).run(inspected.binding.id, intentHash, intentJson, inspected.binding.conversation.channel, inspected.binding.conversation.account, conversationHash(inspected.binding.conversation), this.now(), record.id, record.status, record.intentHash).changes === 1;
|
|
3433
|
+
}
|
|
3434
|
+
deadLetterOwnerRouteBeforeClaim(record, ownerId, failureCode, now) {
|
|
3435
|
+
if (!['pending', 'retry_wait'].includes(record.status)
|
|
3436
|
+
|| !Number.isSafeInteger(record.attemptCount + 1))
|
|
3437
|
+
return false;
|
|
3438
|
+
const attemptNumber = record.attemptCount + 1;
|
|
3439
|
+
const changed = this.database.prepare(`
|
|
3440
|
+
UPDATE outbox_messages
|
|
3441
|
+
SET status = 'dead', attempt_count = attempt_count + 1, next_attempt_at = NULL,
|
|
3442
|
+
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, failure_code = ?, updated_at = ?
|
|
3443
|
+
WHERE id = ? AND status = ? AND claimed_by IS NULL AND intent_hash = ?
|
|
3444
|
+
`).run(failureCode, now, record.id, record.status, record.intentHash);
|
|
3445
|
+
if (changed.changes !== 1)
|
|
3446
|
+
return false;
|
|
3447
|
+
this.database.prepare(`
|
|
3448
|
+
INSERT INTO outbox_attempts (
|
|
3449
|
+
id, outbox_id, attempt_number, owner_id, fencing_token, operation, status,
|
|
3450
|
+
failure_code, created_at, finished_at
|
|
3451
|
+
) VALUES (?, ?, ?, ?, ?, 'send', 'dead', ?, ?, ?)
|
|
3452
|
+
`).run(`outbox_attempt_${randomUUID()}`, record.id, attemptNumber, ownerId, attemptNumber, failureCode, now, now);
|
|
3453
|
+
return true;
|
|
3454
|
+
}
|
|
3455
|
+
deferOwnerRouteBeforeClaim(record, failureCode, now, retryAt) {
|
|
3456
|
+
if (!['pending', 'retry_wait'].includes(record.status))
|
|
3457
|
+
return false;
|
|
3458
|
+
return this.database.prepare(`
|
|
3459
|
+
UPDATE outbox_messages
|
|
3460
|
+
SET status = 'retry_wait', next_attempt_at = ?, failure_code = ?, updated_at = ?
|
|
3461
|
+
WHERE id = ? AND status = ? AND claimed_by IS NULL AND intent_hash = ?
|
|
3462
|
+
`).run(retryAt, failureCode, now, record.id, record.status, record.intentHash).changes === 1;
|
|
3463
|
+
}
|
|
1976
3464
|
claimOutbox(input) {
|
|
1977
3465
|
this.assertOpen();
|
|
1978
3466
|
const ownerId = validateBindingText(input.ownerId, 'ownerId', 256);
|
|
@@ -2005,6 +3493,7 @@ export class DeliveryStore {
|
|
|
2005
3493
|
const routeParameters = routes?.flatMap(route => [route.channel, route.account]) ?? [];
|
|
2006
3494
|
const now = this.now();
|
|
2007
3495
|
const claims = [];
|
|
3496
|
+
const validatedOwnerRouteIds = [];
|
|
2008
3497
|
this.transaction(() => {
|
|
2009
3498
|
this.database.prepare(`
|
|
2010
3499
|
UPDATE outbox_messages SET status = 'dead', failure_code = 'attempts-exhausted',
|
|
@@ -2025,6 +3514,11 @@ export class DeliveryStore {
|
|
|
2025
3514
|
WHERE rowid IN (
|
|
2026
3515
|
SELECT candidate.rowid FROM outbox_messages AS candidate
|
|
2027
3516
|
WHERE candidate.status = 'unknown_after_send'
|
|
3517
|
+
AND NOT EXISTS (
|
|
3518
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
3519
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
|
|
3520
|
+
AND resolution.attempt_count = candidate.attempt_count AND resolution.resolution = 'cancel'
|
|
3521
|
+
)
|
|
2028
3522
|
AND (
|
|
2029
3523
|
SELECT COUNT(*) FROM outbox_attempts AS history
|
|
2030
3524
|
WHERE history.outbox_id = candidate.id AND history.operation = 'reconcile'
|
|
@@ -2032,6 +3526,40 @@ export class DeliveryStore {
|
|
|
2032
3526
|
ORDER BY candidate.rowid LIMIT ?
|
|
2033
3527
|
)
|
|
2034
3528
|
`).run(now, input.maxAttempts, maintenanceLimit);
|
|
3529
|
+
const ownerRouteRows = this.database.prepare(`
|
|
3530
|
+
SELECT id FROM outbox_messages
|
|
3531
|
+
WHERE status IN ('pending', 'retry_wait') AND claimed_by IS NULL
|
|
3532
|
+
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
|
3533
|
+
AND json_type(intent_json, '$.metadata."dsh.route.authority"') IS NOT NULL
|
|
3534
|
+
ORDER BY rowid LIMIT ?
|
|
3535
|
+
`).all(now, maintenanceLimit);
|
|
3536
|
+
const ownerRouteGuard = input.ownerRouteGuard ?? { ownerRoutes: [], authorize: () => false };
|
|
3537
|
+
for (const row of ownerRouteRows) {
|
|
3538
|
+
const record = this.getOutbox(row.id);
|
|
3539
|
+
if (record === undefined)
|
|
3540
|
+
continue;
|
|
3541
|
+
const inspected = this.inspectOwnerRouteDispatch(record, ownerRouteGuard);
|
|
3542
|
+
if (inspected.kind === 'deferred') {
|
|
3543
|
+
this.deferOwnerRouteBeforeClaim(record, inspected.failureCode, now, now + Math.max(1, Math.min(input.leaseMs, 30_000)));
|
|
3544
|
+
continue;
|
|
3545
|
+
}
|
|
3546
|
+
if (inspected.kind !== 'authorized') {
|
|
3547
|
+
const failureCode = inspected.kind === 'denied'
|
|
3548
|
+
? inspected.failureCode
|
|
3549
|
+
: 'owner-route-receipt-invalid';
|
|
3550
|
+
this.deadLetterOwnerRouteBeforeClaim(record, ownerId, failureCode, now);
|
|
3551
|
+
continue;
|
|
3552
|
+
}
|
|
3553
|
+
if (!this.ownerRouteIsProvablyUnsent(record)) {
|
|
3554
|
+
this.deadLetterOwnerRouteBeforeClaim(record, ownerId, 'owner-route-send-proof-missing', now);
|
|
3555
|
+
continue;
|
|
3556
|
+
}
|
|
3557
|
+
if (!this.rebindOwnerRouteBeforeClaim(record, inspected)) {
|
|
3558
|
+
this.deadLetterOwnerRouteBeforeClaim(record, ownerId, 'owner-route-rebind-conflict', now);
|
|
3559
|
+
continue;
|
|
3560
|
+
}
|
|
3561
|
+
validatedOwnerRouteIds.push(record.id);
|
|
3562
|
+
}
|
|
2035
3563
|
this.database.prepare(`
|
|
2036
3564
|
UPDATE outbox_messages SET status = 'dead', failure_code = CASE
|
|
2037
3565
|
WHEN status = 'unknown_after_send' THEN 'binding-revoked-unknown'
|
|
@@ -2041,6 +3569,12 @@ export class DeliveryStore {
|
|
|
2041
3569
|
WHERE rowid IN (
|
|
2042
3570
|
SELECT candidate.rowid FROM outbox_messages AS candidate
|
|
2043
3571
|
WHERE candidate.status IN ('pending', 'retry_wait', 'unknown_after_send')
|
|
3572
|
+
AND json_type(candidate.intent_json, '$.metadata."dsh.route.authority"') IS NULL
|
|
3573
|
+
AND NOT EXISTS (
|
|
3574
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
3575
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
|
|
3576
|
+
AND resolution.attempt_count = candidate.attempt_count AND resolution.resolution = 'cancel'
|
|
3577
|
+
)
|
|
2044
3578
|
AND NOT EXISTS (
|
|
2045
3579
|
SELECT 1 FROM conversation_bindings AS binding
|
|
2046
3580
|
WHERE binding.id = candidate.binding_id AND binding.status = 'active'
|
|
@@ -2048,10 +3582,25 @@ export class DeliveryStore {
|
|
|
2048
3582
|
ORDER BY candidate.rowid LIMIT ?
|
|
2049
3583
|
)
|
|
2050
3584
|
`).run(now, maintenanceLimit);
|
|
3585
|
+
const ownerRouteClaimClause = validatedOwnerRouteIds.length === 0
|
|
3586
|
+
? `AND (
|
|
3587
|
+
json_type(candidate.intent_json, '$.metadata."dsh.route.authority"') IS NULL
|
|
3588
|
+
OR candidate.status = 'unknown_after_send'
|
|
3589
|
+
)`
|
|
3590
|
+
: `AND (
|
|
3591
|
+
json_type(candidate.intent_json, '$.metadata."dsh.route.authority"') IS NULL
|
|
3592
|
+
OR candidate.status = 'unknown_after_send'
|
|
3593
|
+
OR candidate.id IN (${validatedOwnerRouteIds.map(() => '?').join(', ')})
|
|
3594
|
+
)`;
|
|
2051
3595
|
const candidates = this.database.prepare(`
|
|
2052
3596
|
SELECT candidate.id, candidate.status FROM outbox_messages AS candidate
|
|
2053
3597
|
WHERE candidate.status IN ('pending', 'retry_wait', 'unknown_after_send')
|
|
2054
3598
|
AND candidate.claimed_by IS NULL
|
|
3599
|
+
AND NOT EXISTS (
|
|
3600
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
3601
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = candidate.id
|
|
3602
|
+
AND resolution.attempt_count = candidate.attempt_count AND resolution.resolution = 'cancel'
|
|
3603
|
+
)
|
|
2055
3604
|
AND (candidate.next_attempt_at IS NULL OR candidate.next_attempt_at <= ?)
|
|
2056
3605
|
AND (
|
|
2057
3606
|
candidate.status = 'pending'
|
|
@@ -2064,10 +3613,16 @@ export class DeliveryStore {
|
|
|
2064
3613
|
WHERE history.outbox_id = candidate.id AND history.operation = 'reconcile'
|
|
2065
3614
|
) < ?)
|
|
2066
3615
|
)
|
|
2067
|
-
AND
|
|
2068
|
-
|
|
2069
|
-
|
|
3616
|
+
AND (
|
|
3617
|
+
EXISTS (
|
|
3618
|
+
SELECT 1 FROM conversation_bindings AS binding
|
|
3619
|
+
WHERE binding.id = candidate.binding_id AND binding.status = 'active'
|
|
3620
|
+
) OR (
|
|
3621
|
+
candidate.status = 'unknown_after_send'
|
|
3622
|
+
AND json_type(candidate.intent_json, '$.metadata."dsh.route.authority"') IS NOT NULL
|
|
3623
|
+
)
|
|
2070
3624
|
)
|
|
3625
|
+
${ownerRouteClaimClause}
|
|
2071
3626
|
${routeClause}
|
|
2072
3627
|
${excludeClause}
|
|
2073
3628
|
AND NOT EXISTS (
|
|
@@ -2075,6 +3630,11 @@ export class DeliveryStore {
|
|
|
2075
3630
|
WHERE earlier.lane_hash = candidate.lane_hash
|
|
2076
3631
|
AND earlier.rowid < candidate.rowid
|
|
2077
3632
|
AND earlier.status NOT IN ('accepted', 'delivered', 'read', 'dead')
|
|
3633
|
+
AND NOT EXISTS (
|
|
3634
|
+
SELECT 1 FROM dead_letter_resolutions AS resolution
|
|
3635
|
+
WHERE resolution.kind = 'outbox' AND resolution.message_id = earlier.id
|
|
3636
|
+
AND resolution.attempt_count = earlier.attempt_count AND resolution.resolution = 'cancel'
|
|
3637
|
+
)
|
|
2078
3638
|
)
|
|
2079
3639
|
AND NOT EXISTS (
|
|
2080
3640
|
SELECT 1 FROM outbox_messages AS active
|
|
@@ -2082,7 +3642,7 @@ export class DeliveryStore {
|
|
|
2082
3642
|
)
|
|
2083
3643
|
ORDER BY candidate.rowid
|
|
2084
3644
|
LIMIT ?
|
|
2085
|
-
`).all(now, input.maxAttempts, input.maxAttempts, ...routeParameters, ...excludeIds, input.limit);
|
|
3645
|
+
`).all(now, input.maxAttempts, input.maxAttempts, ...validatedOwnerRouteIds, ...routeParameters, ...excludeIds, input.limit);
|
|
2086
3646
|
for (const candidate of candidates) {
|
|
2087
3647
|
const current = this.getOutbox(candidate.id);
|
|
2088
3648
|
const mode = candidate.status === 'unknown_after_send' ? 'reconcile' : 'send';
|
|
@@ -2235,7 +3795,12 @@ export class DeliveryStore {
|
|
|
2235
3795
|
const current = this.getOutbox(row.id);
|
|
2236
3796
|
const rank = { accepted: 1, delivered: 2, read: 3 };
|
|
2237
3797
|
if ((rank[input.status] ?? 0) > (rank[current.status] ?? 0)) {
|
|
2238
|
-
this.database.prepare(
|
|
3798
|
+
this.database.prepare(`
|
|
3799
|
+
UPDATE outbox_messages
|
|
3800
|
+
SET status = ?, failure_code = NULL, next_attempt_at = NULL,
|
|
3801
|
+
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
|
|
3802
|
+
WHERE id = ?
|
|
3803
|
+
`)
|
|
2239
3804
|
.run(input.status, now, row.id);
|
|
2240
3805
|
}
|
|
2241
3806
|
});
|
|
@@ -2289,47 +3854,110 @@ export class DeliveryStore {
|
|
|
2289
3854
|
}
|
|
2290
3855
|
resolveInbox(input) {
|
|
2291
3856
|
this.assertOpen();
|
|
2292
|
-
const
|
|
2293
|
-
if (
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
if (input.resolution === 'retry') {
|
|
2297
|
-
const binding = current.bindingId === undefined ? undefined : this.getBinding(current.bindingId);
|
|
2298
|
-
if (binding?.status !== 'active')
|
|
2299
|
-
throw new DeliveryStoreError('conflict', 'dead-letter inbox has no active binding');
|
|
3857
|
+
const operatorId = resolutionOperatorId(input.operatorId);
|
|
3858
|
+
if (!['cancel', 'retry'].includes(input.resolution)
|
|
3859
|
+
|| !Number.isSafeInteger(input.expectedAttemptCount) || input.expectedAttemptCount < 0) {
|
|
3860
|
+
throw new DeliveryStoreError('version-conflict', 'dead-letter inbox resolution identity is invalid');
|
|
2300
3861
|
}
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
3862
|
+
return this.transaction(() => {
|
|
3863
|
+
const current = this.getInbox(input.inboxId);
|
|
3864
|
+
const existing = this.getDeadLetterResolution({ kind: 'inbox', id: input.inboxId,
|
|
3865
|
+
attemptCount: input.expectedAttemptCount });
|
|
3866
|
+
if (existing !== undefined) {
|
|
3867
|
+
if (current !== undefined && existing.resolution === input.resolution
|
|
3868
|
+
&& existing.operatorId === operatorId) {
|
|
3869
|
+
return { record: current, receipt: existing, replayed: true };
|
|
3870
|
+
}
|
|
3871
|
+
throw new DeliveryStoreError('version-conflict', 'dead-letter inbox decision already settled');
|
|
3872
|
+
}
|
|
3873
|
+
if (current?.status !== 'dead_letter' || current.attemptCount !== input.expectedAttemptCount) {
|
|
3874
|
+
throw new DeliveryStoreError('version-conflict', 'dead-letter inbox attempt count or state changed');
|
|
3875
|
+
}
|
|
3876
|
+
if (input.resolution === 'retry') {
|
|
3877
|
+
const binding = current.bindingId === undefined ? undefined : this.getBinding(current.bindingId);
|
|
3878
|
+
if (binding?.status !== 'active') {
|
|
3879
|
+
throw new DeliveryStoreError('conflict', 'dead-letter inbox has no active binding');
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
const now = this.now();
|
|
3883
|
+
this.database.prepare(`
|
|
3884
|
+
INSERT INTO dead_letter_resolutions (
|
|
3885
|
+
kind, message_id, attempt_count, receipt_version, resolution, original_status,
|
|
3886
|
+
original_failure_code, operator_id, created_at
|
|
3887
|
+
) VALUES ('inbox', ?, ?, 1, ?, 'dead_letter', ?, ?, ?)
|
|
3888
|
+
`).run(input.inboxId, input.expectedAttemptCount, input.resolution, current.failureCode ?? null, operatorId, now);
|
|
3889
|
+
const changed = this.database.prepare(`
|
|
3890
|
+
UPDATE inbox_messages SET status = ?, failure_code = ?, next_attempt_at = NULL, updated_at = ?
|
|
3891
|
+
WHERE id = ? AND status = 'dead_letter' AND attempt_count = ?
|
|
3892
|
+
`).run(input.resolution === 'retry' ? 'queued' : 'dead_letter', input.resolution === 'retry' ? null : 'operator-cancelled', now, input.inboxId, input.expectedAttemptCount);
|
|
3893
|
+
if (changed.changes !== 1)
|
|
3894
|
+
throw new DeliveryStoreError('version-conflict', 'dead-letter inbox changed');
|
|
3895
|
+
return {
|
|
3896
|
+
record: this.getInbox(input.inboxId),
|
|
3897
|
+
receipt: this.getDeadLetterResolution({ kind: 'inbox', id: input.inboxId,
|
|
3898
|
+
attemptCount: input.expectedAttemptCount }),
|
|
3899
|
+
replayed: false,
|
|
3900
|
+
};
|
|
3901
|
+
});
|
|
2309
3902
|
}
|
|
2310
3903
|
resolveOutbox(input) {
|
|
2311
3904
|
this.assertOpen();
|
|
3905
|
+
const operatorId = resolutionOperatorId(input.operatorId);
|
|
3906
|
+
if (!['cancel', 'retry'].includes(input.resolution)
|
|
3907
|
+
|| !Number.isSafeInteger(input.expectedAttemptCount) || input.expectedAttemptCount < 0) {
|
|
3908
|
+
throw new DeliveryStoreError('version-conflict', 'outbox resolution identity is invalid');
|
|
3909
|
+
}
|
|
2312
3910
|
return this.transaction(() => {
|
|
2313
3911
|
const current = this.getOutbox(input.outboxId);
|
|
3912
|
+
const existing = this.getDeadLetterResolution({ kind: 'outbox', id: input.outboxId,
|
|
3913
|
+
attemptCount: input.expectedAttemptCount });
|
|
3914
|
+
if (existing !== undefined) {
|
|
3915
|
+
if (current !== undefined && existing.resolution === input.resolution
|
|
3916
|
+
&& existing.operatorId === operatorId) {
|
|
3917
|
+
return { record: current, receipt: existing, replayed: true };
|
|
3918
|
+
}
|
|
3919
|
+
throw new DeliveryStoreError('version-conflict', 'outbox decision already settled');
|
|
3920
|
+
}
|
|
2314
3921
|
if (current === undefined || !['dead', 'unknown_after_send'].includes(current.status)
|
|
2315
3922
|
|| current.attemptCount !== input.expectedAttemptCount) {
|
|
2316
3923
|
throw new DeliveryStoreError('version-conflict', 'outbox attempt count or resolvable state changed');
|
|
2317
3924
|
}
|
|
2318
3925
|
if (input.resolution === 'retry') {
|
|
3926
|
+
if (current.status === 'unknown_after_send'
|
|
3927
|
+
&& parseOwnerRouteReceipt(current).kind !== 'not-route') {
|
|
3928
|
+
throw new DeliveryStoreError('conflict', 'unknown owner route sends cannot move lineage or be replayed');
|
|
3929
|
+
}
|
|
2319
3930
|
const binding = this.getBinding(current.intent.bindingId);
|
|
2320
3931
|
if (binding?.status !== 'active') {
|
|
2321
3932
|
throw new DeliveryStoreError('conflict', 'resolvable outbox has no active binding');
|
|
2322
3933
|
}
|
|
2323
3934
|
}
|
|
2324
3935
|
const now = this.now();
|
|
3936
|
+
this.database.prepare(`
|
|
3937
|
+
INSERT INTO dead_letter_resolutions (
|
|
3938
|
+
kind, message_id, attempt_count, receipt_version, resolution, original_status,
|
|
3939
|
+
original_failure_code, operator_id, created_at
|
|
3940
|
+
) VALUES ('outbox', ?, ?, 1, ?, ?, ?, ?, ?)
|
|
3941
|
+
`).run(input.outboxId, input.expectedAttemptCount, input.resolution, current.status, current.failureCode ?? null, operatorId, now);
|
|
3942
|
+
const targetStatus = input.resolution === 'retry'
|
|
3943
|
+
? 'pending'
|
|
3944
|
+
: current.status;
|
|
3945
|
+
const targetFailure = input.resolution === 'retry'
|
|
3946
|
+
? null
|
|
3947
|
+
: current.status === 'unknown_after_send' ? 'operator-cancelled-unknown' : 'operator-cancelled';
|
|
2325
3948
|
const changed = this.database.prepare(`
|
|
2326
3949
|
UPDATE outbox_messages SET status = ?, failure_code = ?, next_attempt_at = NULL,
|
|
2327
3950
|
claimed_by = NULL, fencing_token = NULL, lease_until = NULL, updated_at = ?
|
|
2328
3951
|
WHERE id = ? AND status = ? AND attempt_count = ?
|
|
2329
|
-
`).run(
|
|
3952
|
+
`).run(targetStatus, targetFailure, now, input.outboxId, current.status, input.expectedAttemptCount);
|
|
2330
3953
|
if (changed.changes !== 1)
|
|
2331
3954
|
throw new DeliveryStoreError('version-conflict', 'resolvable outbox changed');
|
|
2332
|
-
return
|
|
3955
|
+
return {
|
|
3956
|
+
record: this.getOutbox(input.outboxId),
|
|
3957
|
+
receipt: this.getDeadLetterResolution({ kind: 'outbox', id: input.outboxId,
|
|
3958
|
+
attemptCount: input.expectedAttemptCount }),
|
|
3959
|
+
replayed: false,
|
|
3960
|
+
};
|
|
2333
3961
|
});
|
|
2334
3962
|
}
|
|
2335
3963
|
beginApprovalSettlement(input) {
|
|
@@ -2626,6 +4254,1260 @@ export class DeliveryStore {
|
|
|
2626
4254
|
return JSON.parse(completedResultJson);
|
|
2627
4255
|
});
|
|
2628
4256
|
}
|
|
4257
|
+
workflowTraceSourceAttestation() {
|
|
4258
|
+
this.assertOpen();
|
|
4259
|
+
const row = this.database.prepare(`
|
|
4260
|
+
SELECT generation, authority_digest FROM workflow_trace_source WHERE singleton = 1
|
|
4261
|
+
`).get();
|
|
4262
|
+
if (row === undefined || !Number.isSafeInteger(row.generation) || row.generation < 1
|
|
4263
|
+
|| !/^[a-f0-9]{64}$/u.test(row.authority_digest)) {
|
|
4264
|
+
throw new DeliveryStoreError('conflict', 'workflow trace source authority is corrupt');
|
|
4265
|
+
}
|
|
4266
|
+
return Object.freeze({
|
|
4267
|
+
sourceId: WORKFLOW_TRACE_SOURCE_ID,
|
|
4268
|
+
generation: row.generation,
|
|
4269
|
+
authorityDigest: row.authority_digest,
|
|
4270
|
+
});
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* Apply one already-authenticated source revision. First version may be any
|
|
4274
|
+
* positive integer and gaps are allowed; lower versions and same-version
|
|
4275
|
+
* content changes fail closed.
|
|
4276
|
+
*/
|
|
4277
|
+
recordWorkflowTraceRevision(input) {
|
|
4278
|
+
this.assertOpen();
|
|
4279
|
+
const revision = validateWorkflowTraceRevision(input);
|
|
4280
|
+
return this.transaction(() => this.insertWorkflowTraceRevisionInTransaction(revision));
|
|
4281
|
+
}
|
|
4282
|
+
/**
|
|
4283
|
+
* Atomic owner command ledger + private template review + source revision.
|
|
4284
|
+
* Prompt text is stored only in workflow_template_registry.content_json; the
|
|
4285
|
+
* command receipt and Growth trace carry only references and digests.
|
|
4286
|
+
*/
|
|
4287
|
+
commitOwnerWorkflowTraceCommand(input) {
|
|
4288
|
+
this.assertOpen();
|
|
4289
|
+
const operationId = validateBindingText(input.operationId, 'workflow operationId', 512);
|
|
4290
|
+
if (!/^[a-f0-9]{64}$/u.test(input.payloadDigest)
|
|
4291
|
+
|| !/^[a-f0-9]{64}$/u.test(input.subjectRef)
|
|
4292
|
+
|| !Number.isSafeInteger(input.occurredAt) || input.occurredAt < 0
|
|
4293
|
+
|| (input.action === 'upsert') !== (input.templateContent !== undefined
|
|
4294
|
+
&& input.steps !== undefined && input.taskRef !== undefined)
|
|
4295
|
+
|| (input.taskRef !== undefined && !/^[a-f0-9]{64}$/u.test(input.taskRef))) {
|
|
4296
|
+
throw new DeliveryStoreError('conflict', 'workflow command tuple is invalid');
|
|
4297
|
+
}
|
|
4298
|
+
return this.transaction(() => {
|
|
4299
|
+
const prior = this.database.prepare(`
|
|
4300
|
+
SELECT payload_digest, result_json FROM workflow_trace_commands WHERE operation_id = ?
|
|
4301
|
+
`).get(operationId);
|
|
4302
|
+
if (prior !== undefined) {
|
|
4303
|
+
if (prior.payload_digest !== input.payloadDigest) {
|
|
4304
|
+
throw new DeliveryStoreError('idempotency-conflict', 'workflow command was reused with different input');
|
|
4305
|
+
}
|
|
4306
|
+
let result;
|
|
4307
|
+
try {
|
|
4308
|
+
result = JSON.parse(prior.result_json);
|
|
4309
|
+
}
|
|
4310
|
+
catch {
|
|
4311
|
+
throw new DeliveryStoreError('conflict', 'workflow command receipt is corrupt');
|
|
4312
|
+
}
|
|
4313
|
+
return Object.freeze({
|
|
4314
|
+
revision: validateWorkflowTraceRevision(result.revision),
|
|
4315
|
+
...(result.template === undefined
|
|
4316
|
+
? {}
|
|
4317
|
+
: { template: validateWorkflowAutomationTemplate(result.template) }),
|
|
4318
|
+
replayed: true,
|
|
4319
|
+
});
|
|
4320
|
+
}
|
|
4321
|
+
const binding = this.getBinding(input.binding.id);
|
|
4322
|
+
const owner = binding === undefined ? undefined : this.getPrincipal(binding.principal);
|
|
4323
|
+
if (binding === undefined || binding.status !== 'active'
|
|
4324
|
+
|| binding.version !== input.binding.version || binding.generation !== input.binding.generation
|
|
4325
|
+
|| JSON.stringify(binding.conversation) !== JSON.stringify(input.binding.conversation)
|
|
4326
|
+
|| JSON.stringify(binding.principal) !== JSON.stringify(input.binding.principal)
|
|
4327
|
+
|| binding.workspace !== input.binding.workspace || binding.agentPreset !== input.binding.agentPreset
|
|
4328
|
+
|| owner?.status !== 'active' || owner.role !== 'owner') {
|
|
4329
|
+
throw new DeliveryStoreError('unauthorized-principal', 'workflow command owner binding changed');
|
|
4330
|
+
}
|
|
4331
|
+
const reviewInbox = this.getInbox(input.reviewInboxId);
|
|
4332
|
+
const sourceInbox = this.getInbox(input.sourceInboxId);
|
|
4333
|
+
const sourceOutbox = this.getOutbox(input.sourceOutboxId);
|
|
4334
|
+
if (reviewInbox?.status !== 'claimed' || reviewInbox.bindingId !== binding.id
|
|
4335
|
+
|| sourceInbox?.status !== 'processed' || sourceInbox.bindingId !== binding.id
|
|
4336
|
+
|| sourceOutbox === undefined || sourceOutbox.intent.bindingId !== binding.id
|
|
4337
|
+
|| sourceOutbox.intent.replyToEventId !== sourceInbox.envelope.eventId
|
|
4338
|
+
|| sourceOutbox.providerMessageId === undefined
|
|
4339
|
+
|| !['accepted', 'delivered', 'read'].includes(sourceOutbox.status)
|
|
4340
|
+
|| JSON.stringify(sourceInbox.envelope.conversation) !== JSON.stringify(binding.conversation)
|
|
4341
|
+
|| JSON.stringify(sourceInbox.envelope.principal) !== JSON.stringify(binding.principal)
|
|
4342
|
+
|| JSON.stringify(sourceOutbox.intent.target.conversation) !== JSON.stringify(binding.conversation)
|
|
4343
|
+
|| JSON.stringify(sourceOutbox.intent.target.principal) !== JSON.stringify(binding.principal)) {
|
|
4344
|
+
throw new DeliveryStoreError('conflict', 'workflow command does not target one completed owner task');
|
|
4345
|
+
}
|
|
4346
|
+
const currentRow = this.database.prepare(`
|
|
4347
|
+
SELECT revision.* FROM workflow_trace_current AS current
|
|
4348
|
+
JOIN workflow_trace_revisions AS revision
|
|
4349
|
+
ON revision.subject_ref = current.subject_ref AND revision.version = current.version
|
|
4350
|
+
WHERE current.subject_ref = ?
|
|
4351
|
+
`).get(input.subjectRef);
|
|
4352
|
+
const current = currentRow === undefined ? undefined : workflowTraceRevision(currentRow);
|
|
4353
|
+
const version = current === undefined ? 1 : current.version + 1;
|
|
4354
|
+
if (!Number.isSafeInteger(version)) {
|
|
4355
|
+
throw new DeliveryStoreError('version-conflict', 'workflow trace version overflow');
|
|
4356
|
+
}
|
|
4357
|
+
let template;
|
|
4358
|
+
if (input.action === 'upsert') {
|
|
4359
|
+
const content = validateWorkflowAutomationTemplateContent({
|
|
4360
|
+
...input.templateContent,
|
|
4361
|
+
principalId: externalPrincipalId(binding.principal),
|
|
4362
|
+
});
|
|
4363
|
+
if (content.scope.workspace !== binding.workspace || content.scope.preset !== binding.agentPreset
|
|
4364
|
+
|| content.ownerBindingId !== binding.id || content.deliveryBindingId !== binding.id) {
|
|
4365
|
+
throw new DeliveryStoreError('invalid-binding', 'workflow template does not match the owner binding');
|
|
4366
|
+
}
|
|
4367
|
+
const templateDigest = workflowAutomationTemplateContentDigest(content);
|
|
4368
|
+
const reviewReceipt = Object.freeze({
|
|
4369
|
+
contractVersion: 1,
|
|
4370
|
+
kind: 'owner-explicit-template-review',
|
|
4371
|
+
limitation: 'deidentification-unproven',
|
|
4372
|
+
templateDigest,
|
|
4373
|
+
bindingId: binding.id,
|
|
4374
|
+
bindingVersion: binding.version,
|
|
4375
|
+
bindingGeneration: binding.generation,
|
|
4376
|
+
principalId: owner.id,
|
|
4377
|
+
reviewInboxId: reviewInbox.id,
|
|
4378
|
+
sourceInboxId: sourceInbox.id,
|
|
4379
|
+
sourceOutboxId: sourceOutbox.id,
|
|
4380
|
+
});
|
|
4381
|
+
const attestationDigest = growthObjectDigest({
|
|
4382
|
+
contract: 'assistant-delivery-workflow-template-review/v1',
|
|
4383
|
+
receipt: reviewReceipt,
|
|
4384
|
+
});
|
|
4385
|
+
const templateRef = `workflow-template:${growthObjectDigest({
|
|
4386
|
+
contract: 'assistant-delivery-workflow-template-ref/v1',
|
|
4387
|
+
templateDigest,
|
|
4388
|
+
reviewInboxId: reviewInbox.id,
|
|
4389
|
+
})}`;
|
|
4390
|
+
const attestationId = `workflow-review:${attestationDigest}`;
|
|
4391
|
+
template = validateWorkflowAutomationTemplate({
|
|
4392
|
+
templateRef,
|
|
4393
|
+
templateDigest,
|
|
4394
|
+
privacyAttestation: {
|
|
4395
|
+
kind: 'owner-explicit',
|
|
4396
|
+
limitation: 'deidentification-unproven',
|
|
4397
|
+
attestationId,
|
|
4398
|
+
attestationDigest,
|
|
4399
|
+
},
|
|
4400
|
+
});
|
|
4401
|
+
this.revokeCurrentWorkflowTemplateInTransaction(current, this.now());
|
|
4402
|
+
const now = this.now();
|
|
4403
|
+
this.database.prepare(`
|
|
4404
|
+
INSERT INTO workflow_template_registry(
|
|
4405
|
+
template_ref, template_digest, scope_key, workspace, preset, owner_binding_id,
|
|
4406
|
+
content_json, privacy_kind, privacy_attestation_id, privacy_attestation_digest,
|
|
4407
|
+
review_receipt_json, status, review_inbox_id, source_inbox_id, source_outbox_id,
|
|
4408
|
+
created_at, updated_at, version
|
|
4409
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'owner-explicit', ?, ?, ?, 'active', ?, ?, ?, ?, ?, 1)
|
|
4410
|
+
`).run(template.templateRef, template.templateDigest, workflowScopeKey(content.scope), content.scope.workspace, content.scope.preset, binding.id, JSON.stringify(content), template.privacyAttestation.attestationId, template.privacyAttestation.attestationDigest, JSON.stringify(reviewReceipt), reviewInbox.id, sourceInbox.id, sourceOutbox.id, now, now);
|
|
4411
|
+
}
|
|
4412
|
+
else {
|
|
4413
|
+
this.revokeCurrentWorkflowTemplateInTransaction(current, this.now());
|
|
4414
|
+
}
|
|
4415
|
+
const payload = Object.freeze({
|
|
4416
|
+
source: this.workflowTraceSourceAttestation(),
|
|
4417
|
+
scope: Object.freeze({ workspace: binding.workspace, preset: binding.agentPreset }),
|
|
4418
|
+
subjectRef: input.subjectRef,
|
|
4419
|
+
version,
|
|
4420
|
+
disposition: input.action,
|
|
4421
|
+
...(input.action === 'retract' ? {} : {
|
|
4422
|
+
evidence: Object.freeze({
|
|
4423
|
+
occurredAt: input.occurredAt,
|
|
4424
|
+
signal: 'owner-explicit',
|
|
4425
|
+
objectiveStatus: 'unknown',
|
|
4426
|
+
ownerBindingId: binding.id,
|
|
4427
|
+
taskRef: input.taskRef,
|
|
4428
|
+
template: template,
|
|
4429
|
+
steps: input.steps,
|
|
4430
|
+
}),
|
|
4431
|
+
}),
|
|
4432
|
+
});
|
|
4433
|
+
const revision = validateWorkflowTraceRevision({
|
|
4434
|
+
...payload,
|
|
4435
|
+
digest: workflowTraceRevisionDigest(payload),
|
|
4436
|
+
});
|
|
4437
|
+
const recorded = this.insertWorkflowTraceRevisionInTransaction(revision);
|
|
4438
|
+
if (recorded.replayed) {
|
|
4439
|
+
throw new DeliveryStoreError('version-conflict', 'new workflow command unexpectedly replayed a trace version');
|
|
4440
|
+
}
|
|
4441
|
+
const resultJson = JSON.stringify({ revision, ...(template === undefined ? {} : { template }) });
|
|
4442
|
+
this.database.prepare(`
|
|
4443
|
+
INSERT INTO workflow_trace_commands(operation_id, payload_digest, result_json, created_at)
|
|
4444
|
+
VALUES (?, ?, ?, ?)
|
|
4445
|
+
`).run(operationId, input.payloadDigest, resultJson, this.now());
|
|
4446
|
+
return Object.freeze({ revision, ...(template === undefined ? {} : { template }), replayed: false });
|
|
4447
|
+
});
|
|
4448
|
+
}
|
|
4449
|
+
/**
|
|
4450
|
+
* Atomically record an authenticated owner objective judgement about one
|
|
4451
|
+
* ordinary completed Agent turn and, only when a closed deterministic
|
|
4452
|
+
* deidentification catalog recognizes the source, emit its verified trace.
|
|
4453
|
+
*
|
|
4454
|
+
* The caller may not supply a prompt, task reference, template, or proof:
|
|
4455
|
+
* all of them are rebuilt below from the exact durable Inbox/Outbox fence.
|
|
4456
|
+
* A normal durable reply alone is intentionally insufficient; the owner
|
|
4457
|
+
* must have replied `/feedback achieved` to that exact provider message.
|
|
4458
|
+
*/
|
|
4459
|
+
commitVerifiedWorkflowTraceFeedback(input) {
|
|
4460
|
+
this.assertOpen();
|
|
4461
|
+
if (typeof input !== 'object' || input === null
|
|
4462
|
+
|| !['achieved', 'partial', 'not-achieved'].includes(input.objectiveStatus)
|
|
4463
|
+
|| typeof input.feedbackInboxId !== 'string' || typeof input.sourceInboxId !== 'string'
|
|
4464
|
+
|| typeof input.sourceOutboxId !== 'string') {
|
|
4465
|
+
throw new DeliveryStoreError('conflict', 'verified workflow feedback tuple is invalid');
|
|
4466
|
+
}
|
|
4467
|
+
const feedbackInboxId = validateBindingText(input.feedbackInboxId, 'workflow feedback inbox id', 256);
|
|
4468
|
+
const sourceInboxId = validateBindingText(input.sourceInboxId, 'workflow source inbox id', 256);
|
|
4469
|
+
const sourceOutboxId = validateBindingText(input.sourceOutboxId, 'workflow source outbox id', 256);
|
|
4470
|
+
return this.transaction(() => {
|
|
4471
|
+
const binding = this.getBinding(input.binding.id);
|
|
4472
|
+
const owner = binding === undefined ? undefined : this.getPrincipal(binding.principal);
|
|
4473
|
+
if (binding === undefined || binding.status !== 'active' || binding.conversation.kind !== 'dm'
|
|
4474
|
+
|| binding.version !== input.binding.version || binding.generation !== input.binding.generation
|
|
4475
|
+
|| binding.sessionId !== input.binding.sessionId
|
|
4476
|
+
|| JSON.stringify(binding.conversation) !== JSON.stringify(input.binding.conversation)
|
|
4477
|
+
|| JSON.stringify(binding.principal) !== JSON.stringify(input.binding.principal)
|
|
4478
|
+
|| binding.workspace !== input.binding.workspace || binding.agentPreset !== input.binding.agentPreset
|
|
4479
|
+
|| owner?.status !== 'active' || owner.role !== 'owner') {
|
|
4480
|
+
throw new DeliveryStoreError('unauthorized-principal', 'verified workflow owner binding changed');
|
|
4481
|
+
}
|
|
4482
|
+
const feedbackInbox = this.getInbox(feedbackInboxId);
|
|
4483
|
+
const sourceInbox = this.getInbox(sourceInboxId);
|
|
4484
|
+
const sourceOutbox = this.getOutbox(sourceOutboxId);
|
|
4485
|
+
const feedbackCommand = feedbackInbox === undefined || feedbackInbox.envelope.kind !== 'command'
|
|
4486
|
+
? undefined
|
|
4487
|
+
: parseDeliveryCommand(feedbackInbox.envelope);
|
|
4488
|
+
const feedback = feedbackCommand?.name !== 'feedback'
|
|
4489
|
+
? undefined
|
|
4490
|
+
: parseFeedbackCommand(feedbackCommand.rawInput);
|
|
4491
|
+
if (feedbackInbox?.status !== 'claimed' || feedbackInbox.bindingId !== binding.id
|
|
4492
|
+
|| (feedbackInbox.envelope.attachments?.length ?? 0) !== 0
|
|
4493
|
+
|| feedback?.kind !== 'objective' || feedback.objectiveStatus !== input.objectiveStatus
|
|
4494
|
+
|| sourceInbox?.status !== 'processed' || sourceInbox.bindingId !== binding.id
|
|
4495
|
+
|| sourceInbox.envelope.kind !== 'text' || (sourceInbox.envelope.attachments?.length ?? 0) !== 0
|
|
4496
|
+
|| sourceOutbox === undefined || sourceOutbox.intent.bindingId !== binding.id
|
|
4497
|
+
|| sourceOutbox.intent.idempotencyKey !== `inbound:${sourceInbox.id}:reply`
|
|
4498
|
+
|| sourceOutbox.intent.replyToEventId !== sourceInbox.envelope.eventId
|
|
4499
|
+
|| !['accepted', 'delivered', 'read'].includes(sourceOutbox.status)
|
|
4500
|
+
|| sourceOutbox.providerMessageId === undefined
|
|
4501
|
+
|| (sourceOutbox.intent.format !== 'markdown' && sourceOutbox.intent.format !== 'plain')
|
|
4502
|
+
|| sourceOutbox.intent.metadata !== undefined
|
|
4503
|
+
|| sourceOutbox.intent.modelPicker !== undefined
|
|
4504
|
+
|| sourceOutbox.intent.permissionPicker !== undefined
|
|
4505
|
+
|| sourceOutbox.intent.approval !== undefined
|
|
4506
|
+
|| feedbackInbox.envelope.metadata?.replyToProviderMessageId !== sourceOutbox.providerMessageId
|
|
4507
|
+
|| JSON.stringify(feedbackInbox.envelope.conversation) !== JSON.stringify(binding.conversation)
|
|
4508
|
+
|| JSON.stringify(feedbackInbox.envelope.principal) !== JSON.stringify(binding.principal)
|
|
4509
|
+
|| JSON.stringify(sourceInbox.envelope.conversation) !== JSON.stringify(binding.conversation)
|
|
4510
|
+
|| JSON.stringify(sourceInbox.envelope.principal) !== JSON.stringify(binding.principal)
|
|
4511
|
+
|| JSON.stringify(sourceOutbox.intent.target.conversation) !== JSON.stringify(binding.conversation)
|
|
4512
|
+
|| JSON.stringify(sourceOutbox.intent.target.principal) !== JSON.stringify(binding.principal)) {
|
|
4513
|
+
throw new DeliveryStoreError('conflict', 'verified workflow feedback does not target one completed owner turn');
|
|
4514
|
+
}
|
|
4515
|
+
const taskRef = growthObjectDigest({
|
|
4516
|
+
contract: 'assistant-delivery-verified-workflow-task-ref/v1',
|
|
4517
|
+
bindingId: binding.id,
|
|
4518
|
+
sourceInboxId: sourceInbox.id,
|
|
4519
|
+
sourceInboxEnvelopeHash: sourceInbox.envelopeHash,
|
|
4520
|
+
sourceOutboxId: sourceOutbox.id,
|
|
4521
|
+
sourceOutboxIntentHash: sourceOutbox.intentHash,
|
|
4522
|
+
});
|
|
4523
|
+
const taskEvidenceDigest = growthObjectDigest({
|
|
4524
|
+
contract: 'assistant-delivery-verified-workflow-task-evidence/v1',
|
|
4525
|
+
binding: {
|
|
4526
|
+
id: binding.id,
|
|
4527
|
+
version: binding.version,
|
|
4528
|
+
generation: binding.generation,
|
|
4529
|
+
workspace: binding.workspace,
|
|
4530
|
+
preset: binding.agentPreset,
|
|
4531
|
+
},
|
|
4532
|
+
principal: { recordId: owner.id, version: owner.version },
|
|
4533
|
+
source: {
|
|
4534
|
+
inboxId: sourceInbox.id,
|
|
4535
|
+
envelopeHash: sourceInbox.envelopeHash,
|
|
4536
|
+
outboxId: sourceOutbox.id,
|
|
4537
|
+
intentHash: sourceOutbox.intentHash,
|
|
4538
|
+
providerMessageId: sourceOutbox.providerMessageId,
|
|
4539
|
+
},
|
|
4540
|
+
feedback: {
|
|
4541
|
+
inboxId: feedbackInbox.id,
|
|
4542
|
+
envelopeHash: feedbackInbox.envelopeHash,
|
|
4543
|
+
objectiveStatus: input.objectiveStatus,
|
|
4544
|
+
},
|
|
4545
|
+
});
|
|
4546
|
+
const prior = this.database.prepare(`
|
|
4547
|
+
SELECT * FROM workflow_verified_task_feedback WHERE source_outbox_id = ?
|
|
4548
|
+
`).get(sourceOutbox.id);
|
|
4549
|
+
if (prior !== undefined) {
|
|
4550
|
+
return this.replayVerifiedWorkflowTraceFeedback({
|
|
4551
|
+
row: prior,
|
|
4552
|
+
binding,
|
|
4553
|
+
ownerId: owner.id,
|
|
4554
|
+
feedbackInboxId: feedbackInbox.id,
|
|
4555
|
+
sourceInboxId: sourceInbox.id,
|
|
4556
|
+
sourceOutboxId: sourceOutbox.id,
|
|
4557
|
+
objectiveStatus: input.objectiveStatus,
|
|
4558
|
+
taskRef,
|
|
4559
|
+
taskEvidenceDigest,
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
const descriptor = input.objectiveStatus === 'achieved'
|
|
4563
|
+
? deriveDeterministicallyDeidentifiedWorkflowTemplate(sourceInbox.envelope.text)
|
|
4564
|
+
: undefined;
|
|
4565
|
+
if (descriptor === undefined) {
|
|
4566
|
+
this.insertVerifiedWorkflowTaskFeedback({
|
|
4567
|
+
sourceOutboxId: sourceOutbox.id,
|
|
4568
|
+
sourceInboxId: sourceInbox.id,
|
|
4569
|
+
feedbackInboxId: feedbackInbox.id,
|
|
4570
|
+
binding,
|
|
4571
|
+
ownerId: owner.id,
|
|
4572
|
+
objectiveStatus: input.objectiveStatus,
|
|
4573
|
+
taskRef,
|
|
4574
|
+
taskEvidenceDigest,
|
|
4575
|
+
});
|
|
4576
|
+
return Object.freeze({
|
|
4577
|
+
outcome: 'no-trace',
|
|
4578
|
+
reason: input.objectiveStatus === 'achieved' ? 'privacy-abstained' : 'objective-not-achieved',
|
|
4579
|
+
replayed: false,
|
|
4580
|
+
});
|
|
4581
|
+
}
|
|
4582
|
+
const content = validateWorkflowAutomationTemplateContent({
|
|
4583
|
+
scope: { workspace: binding.workspace, preset: binding.agentPreset },
|
|
4584
|
+
ownerBindingId: binding.id,
|
|
4585
|
+
principalId: externalPrincipalId(binding.principal),
|
|
4586
|
+
name: descriptor.name,
|
|
4587
|
+
prompt: descriptor.prompt,
|
|
4588
|
+
schedule: descriptor.schedule,
|
|
4589
|
+
timeoutMs: descriptor.timeoutMs,
|
|
4590
|
+
toolCatalogIds: descriptor.toolCatalogIds,
|
|
4591
|
+
deliveryBindingId: binding.id,
|
|
4592
|
+
});
|
|
4593
|
+
const templateDigest = workflowAutomationTemplateContentDigest(content);
|
|
4594
|
+
const privacyReceipt = Object.freeze({
|
|
4595
|
+
contractVersion: 1,
|
|
4596
|
+
kind: 'deterministic-deidentification-template-attestation',
|
|
4597
|
+
method: 'assistant-delivery-redaction-v1',
|
|
4598
|
+
catalogId: descriptor.catalogId,
|
|
4599
|
+
templateDigest,
|
|
4600
|
+
bindingId: binding.id,
|
|
4601
|
+
bindingVersion: binding.version,
|
|
4602
|
+
bindingGeneration: binding.generation,
|
|
4603
|
+
principalId: owner.id,
|
|
4604
|
+
});
|
|
4605
|
+
const attestationDigest = growthObjectDigest({
|
|
4606
|
+
contract: 'assistant-delivery-workflow-template-deidentification/v1',
|
|
4607
|
+
receipt: privacyReceipt,
|
|
4608
|
+
});
|
|
4609
|
+
const template = validateWorkflowAutomationTemplate({
|
|
4610
|
+
templateRef: `workflow-template:${growthObjectDigest({
|
|
4611
|
+
contract: 'assistant-delivery-workflow-template-ref/v2', templateDigest,
|
|
4612
|
+
})}`,
|
|
4613
|
+
templateDigest,
|
|
4614
|
+
privacyAttestation: {
|
|
4615
|
+
kind: 'deterministic-deidentification',
|
|
4616
|
+
method: 'assistant-delivery-redaction-v1',
|
|
4617
|
+
attestationId: `workflow-deidentification:${attestationDigest}`,
|
|
4618
|
+
attestationDigest,
|
|
4619
|
+
},
|
|
4620
|
+
});
|
|
4621
|
+
const existingTemplate = this.getWorkflowAutomationTemplate(template);
|
|
4622
|
+
if (existingTemplate !== undefined) {
|
|
4623
|
+
if (existingTemplate.status !== 'active'
|
|
4624
|
+
|| JSON.stringify(existingTemplate.resolved) !== JSON.stringify({ contractVersion: 1, template, ...content })
|
|
4625
|
+
|| existingTemplate.review.bindingId !== binding.id
|
|
4626
|
+
|| existingTemplate.review.bindingVersion !== binding.version
|
|
4627
|
+
|| existingTemplate.review.bindingGeneration !== binding.generation
|
|
4628
|
+
|| existingTemplate.review.principalId !== owner.id) {
|
|
4629
|
+
throw new DeliveryStoreError('receipt-mismatch', 'deterministic workflow template is stale');
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
else {
|
|
4633
|
+
const now = this.now();
|
|
4634
|
+
this.database.prepare(`
|
|
4635
|
+
INSERT INTO workflow_template_registry(
|
|
4636
|
+
template_ref, template_digest, scope_key, workspace, preset, owner_binding_id,
|
|
4637
|
+
content_json, privacy_kind, privacy_attestation_id, privacy_attestation_digest,
|
|
4638
|
+
review_receipt_json, status, review_inbox_id, source_inbox_id, source_outbox_id,
|
|
4639
|
+
created_at, updated_at, version
|
|
4640
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'deterministic-deidentification', ?, ?, ?,
|
|
4641
|
+
'active', ?, ?, ?, ?, ?, 1)
|
|
4642
|
+
`).run(template.templateRef, template.templateDigest, workflowScopeKey(content.scope), content.scope.workspace, content.scope.preset, binding.id, JSON.stringify(content), template.privacyAttestation.attestationId, template.privacyAttestation.attestationDigest, JSON.stringify(privacyReceipt), feedbackInbox.id, sourceInbox.id, sourceOutbox.id, now, now);
|
|
4643
|
+
}
|
|
4644
|
+
const subjectRef = growthObjectDigest({
|
|
4645
|
+
contract: 'assistant-delivery-verified-workflow-subject/v1',
|
|
4646
|
+
bindingId: binding.id,
|
|
4647
|
+
sourceInboxId: sourceInbox.id,
|
|
4648
|
+
sourceOutboxId: sourceOutbox.id,
|
|
4649
|
+
});
|
|
4650
|
+
const payload = Object.freeze({
|
|
4651
|
+
source: this.workflowTraceSourceAttestation(),
|
|
4652
|
+
scope: Object.freeze({ workspace: binding.workspace, preset: binding.agentPreset }),
|
|
4653
|
+
subjectRef,
|
|
4654
|
+
version: 1,
|
|
4655
|
+
disposition: 'upsert',
|
|
4656
|
+
evidence: Object.freeze({
|
|
4657
|
+
occurredAt: feedbackInbox.receivedAt,
|
|
4658
|
+
signal: 'verified-repetition',
|
|
4659
|
+
objectiveStatus: 'achieved',
|
|
4660
|
+
ownerBindingId: binding.id,
|
|
4661
|
+
taskRef,
|
|
4662
|
+
taskEvidenceDigest,
|
|
4663
|
+
template,
|
|
4664
|
+
steps: Object.freeze([Object.freeze({
|
|
4665
|
+
catalogId: 'assistant.agent-turn',
|
|
4666
|
+
argumentSchemaDigest: workflowArgumentShapeDigest({ prompt: descriptor.prompt }),
|
|
4667
|
+
})]),
|
|
4668
|
+
}),
|
|
4669
|
+
});
|
|
4670
|
+
const revision = validateWorkflowTraceRevision({
|
|
4671
|
+
...payload,
|
|
4672
|
+
digest: workflowTraceRevisionDigest(payload),
|
|
4673
|
+
});
|
|
4674
|
+
const recorded = this.insertWorkflowTraceRevisionInTransaction(revision);
|
|
4675
|
+
if (recorded.replayed) {
|
|
4676
|
+
throw new DeliveryStoreError('version-conflict', 'new verified workflow feedback unexpectedly replayed');
|
|
4677
|
+
}
|
|
4678
|
+
this.insertVerifiedWorkflowTaskFeedback({
|
|
4679
|
+
sourceOutboxId: sourceOutbox.id,
|
|
4680
|
+
sourceInboxId: sourceInbox.id,
|
|
4681
|
+
feedbackInboxId: feedbackInbox.id,
|
|
4682
|
+
binding,
|
|
4683
|
+
ownerId: owner.id,
|
|
4684
|
+
objectiveStatus: input.objectiveStatus,
|
|
4685
|
+
taskRef,
|
|
4686
|
+
taskEvidenceDigest,
|
|
4687
|
+
revision,
|
|
4688
|
+
template,
|
|
4689
|
+
});
|
|
4690
|
+
return Object.freeze({ outcome: 'trace-recorded', revision, template, replayed: false });
|
|
4691
|
+
});
|
|
4692
|
+
}
|
|
4693
|
+
insertVerifiedWorkflowTaskFeedback(input) {
|
|
4694
|
+
if ((input.revision === undefined) !== (input.template === undefined)) {
|
|
4695
|
+
throw new DeliveryStoreError('conflict', 'verified workflow trace and template must agree');
|
|
4696
|
+
}
|
|
4697
|
+
if (input.revision !== undefined && (input.revision.disposition !== 'upsert'
|
|
4698
|
+
|| input.revision.evidence?.signal !== 'verified-repetition'
|
|
4699
|
+
|| input.revision.evidence.objectiveStatus !== 'achieved'
|
|
4700
|
+
|| input.revision.evidence.taskRef !== input.taskRef
|
|
4701
|
+
|| input.revision.evidence.taskEvidenceDigest !== input.taskEvidenceDigest
|
|
4702
|
+
|| JSON.stringify(input.revision.evidence.template) !== JSON.stringify(input.template))) {
|
|
4703
|
+
throw new DeliveryStoreError('receipt-mismatch', 'verified workflow trace is not bound to its task receipt');
|
|
4704
|
+
}
|
|
4705
|
+
this.database.prepare(`
|
|
4706
|
+
INSERT INTO workflow_verified_task_feedback(
|
|
4707
|
+
source_outbox_id, source_inbox_id, feedback_inbox_id, binding_id, binding_version,
|
|
4708
|
+
binding_generation, principal_record_id, objective_status, task_ref, task_evidence_digest,
|
|
4709
|
+
trace_subject_ref, trace_version, trace_digest, template_ref, created_at
|
|
4710
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4711
|
+
`).run(input.sourceOutboxId, input.sourceInboxId, input.feedbackInboxId, input.binding.id, input.binding.version, input.binding.generation, input.ownerId, input.objectiveStatus, input.taskRef, input.taskEvidenceDigest, input.revision?.subjectRef ?? null, input.revision?.version ?? null, input.revision?.digest ?? null, input.template?.templateRef ?? null, this.now());
|
|
4712
|
+
}
|
|
4713
|
+
replayVerifiedWorkflowTraceFeedback(input) {
|
|
4714
|
+
const row = input.row;
|
|
4715
|
+
if (row.source_outbox_id !== input.sourceOutboxId || row.source_inbox_id !== input.sourceInboxId
|
|
4716
|
+
|| row.feedback_inbox_id !== input.feedbackInboxId || row.binding_id !== input.binding.id
|
|
4717
|
+
|| row.binding_version !== input.binding.version || row.binding_generation !== input.binding.generation
|
|
4718
|
+
|| row.principal_record_id !== input.ownerId || row.objective_status !== input.objectiveStatus
|
|
4719
|
+
|| row.task_ref !== input.taskRef || row.task_evidence_digest !== input.taskEvidenceDigest) {
|
|
4720
|
+
throw new DeliveryStoreError('idempotency-conflict', 'verified workflow task already has a different owner judgement');
|
|
4721
|
+
}
|
|
4722
|
+
const traceFields = [row.trace_subject_ref, row.trace_version, row.trace_digest, row.template_ref];
|
|
4723
|
+
const traceFieldCount = traceFields.filter(value => value !== null).length;
|
|
4724
|
+
if (traceFieldCount === 0) {
|
|
4725
|
+
return Object.freeze({
|
|
4726
|
+
outcome: 'no-trace',
|
|
4727
|
+
reason: row.objective_status === 'achieved' ? 'privacy-abstained' : 'objective-not-achieved',
|
|
4728
|
+
replayed: true,
|
|
4729
|
+
});
|
|
4730
|
+
}
|
|
4731
|
+
if (traceFieldCount !== traceFields.length || row.trace_subject_ref === null || row.trace_version === null
|
|
4732
|
+
|| row.trace_digest === null || row.template_ref === null
|
|
4733
|
+
|| !Number.isSafeInteger(row.trace_version) || row.trace_version < 1
|
|
4734
|
+
|| !/^[a-f0-9]{64}$/u.test(row.trace_subject_ref)
|
|
4735
|
+
|| !/^[a-f0-9]{64}$/u.test(row.trace_digest)) {
|
|
4736
|
+
throw new DeliveryStoreError('conflict', 'verified workflow task receipt is corrupt');
|
|
4737
|
+
}
|
|
4738
|
+
const traceRow = this.database.prepare(`
|
|
4739
|
+
SELECT * FROM workflow_trace_revisions WHERE subject_ref = ? AND version = ?
|
|
4740
|
+
`).get(row.trace_subject_ref, row.trace_version);
|
|
4741
|
+
if (traceRow === undefined) {
|
|
4742
|
+
throw new DeliveryStoreError('conflict', 'verified workflow task trace is missing');
|
|
4743
|
+
}
|
|
4744
|
+
const revision = workflowTraceRevision(traceRow);
|
|
4745
|
+
const template = revision.disposition === 'upsert' ? revision.evidence?.template : undefined;
|
|
4746
|
+
if (revision.digest !== row.trace_digest || revision.disposition !== 'upsert'
|
|
4747
|
+
|| revision.evidence?.signal !== 'verified-repetition'
|
|
4748
|
+
|| revision.evidence.objectiveStatus !== 'achieved'
|
|
4749
|
+
|| revision.evidence.ownerBindingId !== input.binding.id
|
|
4750
|
+
|| revision.evidence.taskRef !== input.taskRef
|
|
4751
|
+
|| revision.evidence.taskEvidenceDigest !== input.taskEvidenceDigest
|
|
4752
|
+
|| template === undefined || template.templateRef !== row.template_ref) {
|
|
4753
|
+
throw new DeliveryStoreError('receipt-mismatch', 'verified workflow task trace changed identity');
|
|
4754
|
+
}
|
|
4755
|
+
const storedTemplate = this.getWorkflowAutomationTemplate(template);
|
|
4756
|
+
if (storedTemplate === undefined
|
|
4757
|
+
|| JSON.stringify(storedTemplate.resolved.template) !== JSON.stringify(template)) {
|
|
4758
|
+
throw new DeliveryStoreError('receipt-mismatch', 'verified workflow task template is missing');
|
|
4759
|
+
}
|
|
4760
|
+
return Object.freeze({ outcome: 'trace-recorded', revision, template, replayed: true });
|
|
4761
|
+
}
|
|
4762
|
+
getWorkflowAutomationTemplate(templateInput) {
|
|
4763
|
+
this.assertOpen();
|
|
4764
|
+
const template = validateWorkflowAutomationTemplate(templateInput);
|
|
4765
|
+
const row = this.database.prepare(`
|
|
4766
|
+
SELECT * FROM workflow_template_registry WHERE template_ref = ?
|
|
4767
|
+
`).get(template.templateRef);
|
|
4768
|
+
if (row === undefined)
|
|
4769
|
+
return undefined;
|
|
4770
|
+
let contentValue;
|
|
4771
|
+
let reviewValue;
|
|
4772
|
+
try {
|
|
4773
|
+
contentValue = JSON.parse(row.content_json);
|
|
4774
|
+
reviewValue = JSON.parse(row.review_receipt_json);
|
|
4775
|
+
}
|
|
4776
|
+
catch {
|
|
4777
|
+
throw new DeliveryStoreError('conflict', 'workflow template registry payload is corrupt');
|
|
4778
|
+
}
|
|
4779
|
+
if (typeof reviewValue !== 'object' || reviewValue === null || Array.isArray(reviewValue)) {
|
|
4780
|
+
throw new DeliveryStoreError('conflict', 'workflow template review receipt is corrupt');
|
|
4781
|
+
}
|
|
4782
|
+
const review = reviewValue;
|
|
4783
|
+
const content = validateWorkflowAutomationTemplateContent(contentValue);
|
|
4784
|
+
const receiptBindingVersion = review['bindingVersion'];
|
|
4785
|
+
const receiptBindingGeneration = review['bindingGeneration'];
|
|
4786
|
+
let attestationDigest;
|
|
4787
|
+
if (row.privacy_kind === 'owner-explicit') {
|
|
4788
|
+
const exactReviewKeys = [
|
|
4789
|
+
'contractVersion', 'kind', 'limitation', 'templateDigest', 'bindingId', 'bindingVersion',
|
|
4790
|
+
'bindingGeneration', 'principalId', 'reviewInboxId', 'sourceInboxId', 'sourceOutboxId',
|
|
4791
|
+
].sort();
|
|
4792
|
+
if (Object.keys(review).sort().some((key, index) => key !== exactReviewKeys[index])
|
|
4793
|
+
|| Object.keys(review).length !== exactReviewKeys.length
|
|
4794
|
+
|| review['contractVersion'] !== 1 || review['kind'] !== 'owner-explicit-template-review'
|
|
4795
|
+
|| review['limitation'] !== 'deidentification-unproven'
|
|
4796
|
+
|| review['templateDigest'] !== row.template_digest
|
|
4797
|
+
|| review['bindingId'] !== row.owner_binding_id
|
|
4798
|
+
|| review['reviewInboxId'] !== row.review_inbox_id
|
|
4799
|
+
|| review['sourceInboxId'] !== row.source_inbox_id
|
|
4800
|
+
|| review['sourceOutboxId'] !== row.source_outbox_id
|
|
4801
|
+
|| typeof review['principalId'] !== 'string'
|
|
4802
|
+
|| typeof receiptBindingVersion !== 'number' || !Number.isSafeInteger(receiptBindingVersion)
|
|
4803
|
+
|| receiptBindingVersion < 1
|
|
4804
|
+
|| typeof receiptBindingGeneration !== 'number' || !Number.isSafeInteger(receiptBindingGeneration)
|
|
4805
|
+
|| receiptBindingGeneration < 1) {
|
|
4806
|
+
throw new DeliveryStoreError('conflict', 'workflow template review receipt columns do not match');
|
|
4807
|
+
}
|
|
4808
|
+
attestationDigest = growthObjectDigest({
|
|
4809
|
+
contract: 'assistant-delivery-workflow-template-review/v1',
|
|
4810
|
+
receipt: review,
|
|
4811
|
+
});
|
|
4812
|
+
}
|
|
4813
|
+
else if (row.privacy_kind === 'deterministic-deidentification') {
|
|
4814
|
+
const exactReceiptKeys = [
|
|
4815
|
+
'contractVersion', 'kind', 'method', 'catalogId', 'templateDigest', 'bindingId',
|
|
4816
|
+
'bindingVersion', 'bindingGeneration', 'principalId',
|
|
4817
|
+
].sort();
|
|
4818
|
+
const catalogId = review['catalogId'];
|
|
4819
|
+
const descriptor = typeof catalogId === 'string'
|
|
4820
|
+
? getDeterministicallyDeidentifiedWorkflowTemplate(catalogId)
|
|
4821
|
+
: undefined;
|
|
4822
|
+
if (Object.keys(review).sort().some((key, index) => key !== exactReceiptKeys[index])
|
|
4823
|
+
|| Object.keys(review).length !== exactReceiptKeys.length
|
|
4824
|
+
|| review['contractVersion'] !== 1
|
|
4825
|
+
|| review['kind'] !== 'deterministic-deidentification-template-attestation'
|
|
4826
|
+
|| review['method'] !== 'assistant-delivery-redaction-v1'
|
|
4827
|
+
|| descriptor === undefined
|
|
4828
|
+
|| review['templateDigest'] !== row.template_digest
|
|
4829
|
+
|| review['bindingId'] !== row.owner_binding_id
|
|
4830
|
+
|| typeof review['principalId'] !== 'string'
|
|
4831
|
+
|| typeof receiptBindingVersion !== 'number' || !Number.isSafeInteger(receiptBindingVersion)
|
|
4832
|
+
|| receiptBindingVersion < 1
|
|
4833
|
+
|| typeof receiptBindingGeneration !== 'number' || !Number.isSafeInteger(receiptBindingGeneration)
|
|
4834
|
+
|| receiptBindingGeneration < 1
|
|
4835
|
+
|| content.name !== descriptor.name || content.prompt !== descriptor.prompt
|
|
4836
|
+
|| content.schedule.kind !== descriptor.schedule.kind
|
|
4837
|
+
|| content.schedule.expression !== descriptor.schedule.expression
|
|
4838
|
+
|| content.schedule.timezone !== descriptor.schedule.timezone
|
|
4839
|
+
|| content.timeoutMs !== descriptor.timeoutMs
|
|
4840
|
+
|| JSON.stringify(content.toolCatalogIds) !== JSON.stringify(descriptor.toolCatalogIds)) {
|
|
4841
|
+
throw new DeliveryStoreError('conflict', 'deterministic workflow deidentification receipt is invalid');
|
|
4842
|
+
}
|
|
4843
|
+
attestationDigest = growthObjectDigest({
|
|
4844
|
+
contract: 'assistant-delivery-workflow-template-deidentification/v1',
|
|
4845
|
+
receipt: review,
|
|
4846
|
+
});
|
|
4847
|
+
}
|
|
4848
|
+
else {
|
|
4849
|
+
throw new DeliveryStoreError('conflict', 'workflow template privacy kind is invalid');
|
|
4850
|
+
}
|
|
4851
|
+
const storedTemplate = validateWorkflowAutomationTemplate({
|
|
4852
|
+
templateRef: row.template_ref,
|
|
4853
|
+
templateDigest: row.template_digest,
|
|
4854
|
+
privacyAttestation: row.privacy_kind === 'owner-explicit'
|
|
4855
|
+
? {
|
|
4856
|
+
kind: 'owner-explicit', limitation: 'deidentification-unproven',
|
|
4857
|
+
attestationId: row.privacy_attestation_id,
|
|
4858
|
+
attestationDigest: row.privacy_attestation_digest,
|
|
4859
|
+
}
|
|
4860
|
+
: {
|
|
4861
|
+
kind: 'deterministic-deidentification', method: 'assistant-delivery-redaction-v1',
|
|
4862
|
+
attestationId: row.privacy_attestation_id,
|
|
4863
|
+
attestationDigest: row.privacy_attestation_digest,
|
|
4864
|
+
},
|
|
4865
|
+
});
|
|
4866
|
+
if (JSON.stringify(storedTemplate) !== JSON.stringify(template)
|
|
4867
|
+
|| attestationDigest !== row.privacy_attestation_digest
|
|
4868
|
+
|| storedTemplate.privacyAttestation.attestationId !== (row.privacy_kind === 'owner-explicit'
|
|
4869
|
+
? `workflow-review:${attestationDigest}`
|
|
4870
|
+
: `workflow-deidentification:${attestationDigest}`)) {
|
|
4871
|
+
throw new DeliveryStoreError('receipt-mismatch', 'workflow template attestation is stale');
|
|
4872
|
+
}
|
|
4873
|
+
const contentBinding = this.getBinding(row.owner_binding_id);
|
|
4874
|
+
const resolved = validateResolvedWorkflowAutomationTemplate({
|
|
4875
|
+
contractVersion: 1,
|
|
4876
|
+
template: storedTemplate,
|
|
4877
|
+
...content,
|
|
4878
|
+
});
|
|
4879
|
+
if (workflowScopeKey(content.scope) !== row.scope_key
|
|
4880
|
+
|| content.scope.workspace !== row.workspace || content.scope.preset !== row.preset
|
|
4881
|
+
|| content.ownerBindingId !== row.owner_binding_id
|
|
4882
|
+
|| contentBinding === undefined
|
|
4883
|
+
|| content.principalId !== externalPrincipalId(contentBinding.principal)
|
|
4884
|
+
|| workflowAutomationTemplateContentDigest(content) !== row.template_digest) {
|
|
4885
|
+
throw new DeliveryStoreError('receipt-mismatch', 'workflow template content columns do not match');
|
|
4886
|
+
}
|
|
4887
|
+
return Object.freeze({
|
|
4888
|
+
resolved,
|
|
4889
|
+
review: Object.freeze({
|
|
4890
|
+
bindingId: row.owner_binding_id,
|
|
4891
|
+
bindingVersion: review['bindingVersion'],
|
|
4892
|
+
bindingGeneration: review['bindingGeneration'],
|
|
4893
|
+
principalId: review['principalId'],
|
|
4894
|
+
reviewInboxId: row.review_inbox_id,
|
|
4895
|
+
sourceInboxId: row.source_inbox_id,
|
|
4896
|
+
sourceOutboxId: row.source_outbox_id,
|
|
4897
|
+
}),
|
|
4898
|
+
status: row.status,
|
|
4899
|
+
version: row.version,
|
|
4900
|
+
});
|
|
4901
|
+
}
|
|
4902
|
+
requeueCurrentWorkflowTraces() {
|
|
4903
|
+
this.assertOpen();
|
|
4904
|
+
const now = this.now();
|
|
4905
|
+
return this.transaction(() => {
|
|
4906
|
+
this.database.prepare(`
|
|
4907
|
+
UPDATE workflow_trace_outbox
|
|
4908
|
+
SET status = 'delivered', failure_code = 'superseded-revision', updated_at = ?
|
|
4909
|
+
WHERE status IN ('pending', 'retry_wait') AND NOT EXISTS (
|
|
4910
|
+
SELECT 1 FROM workflow_trace_current AS current
|
|
4911
|
+
WHERE current.subject_ref = workflow_trace_outbox.subject_ref
|
|
4912
|
+
AND current.version = workflow_trace_outbox.version
|
|
4913
|
+
)
|
|
4914
|
+
`).run(now);
|
|
4915
|
+
return Number(this.database.prepare(`
|
|
4916
|
+
UPDATE workflow_trace_outbox
|
|
4917
|
+
SET status = 'pending', attempt_count = 0, next_attempt_at = ?, failure_code = NULL, updated_at = ?
|
|
4918
|
+
WHERE (subject_ref, version) IN (
|
|
4919
|
+
SELECT subject_ref, version FROM workflow_trace_current
|
|
4920
|
+
)
|
|
4921
|
+
`).run(now, now).changes);
|
|
4922
|
+
});
|
|
4923
|
+
}
|
|
4924
|
+
enqueuePreferenceProjection(events) {
|
|
4925
|
+
this.assertOpen();
|
|
4926
|
+
const payload = preferenceProjectionPayload(events);
|
|
4927
|
+
return this.transaction(() => this.insertPreferenceProjection(payload));
|
|
4928
|
+
}
|
|
4929
|
+
/**
|
|
4930
|
+
* Persist an Agent reply and its content-free learning projection under one
|
|
4931
|
+
* SQLite commit. A process crash can therefore expose neither row or both,
|
|
4932
|
+
* but never a durable reply that permanently lost its completion signal.
|
|
4933
|
+
*/
|
|
4934
|
+
enqueueReplyWithPreferenceProjection(input, eventsForReply) {
|
|
4935
|
+
this.assertOpen();
|
|
4936
|
+
if (typeof eventsForReply !== 'function') {
|
|
4937
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection builder is invalid');
|
|
4938
|
+
}
|
|
4939
|
+
return this.transaction(() => {
|
|
4940
|
+
const reply = this.enqueue(input);
|
|
4941
|
+
const events = eventsForReply(reply);
|
|
4942
|
+
if (events === undefined)
|
|
4943
|
+
return Object.freeze({ reply });
|
|
4944
|
+
const projection = this.insertPreferenceProjection(preferenceProjectionPayload(events));
|
|
4945
|
+
return Object.freeze({ reply, projection });
|
|
4946
|
+
});
|
|
4947
|
+
}
|
|
4948
|
+
listPendingPreferenceProjections(limitInput = 100, nowInput = this.now()) {
|
|
4949
|
+
this.assertOpen();
|
|
4950
|
+
if (!Number.isSafeInteger(limitInput) || limitInput < 1 || limitInput > 1_000
|
|
4951
|
+
|| !Number.isSafeInteger(nowInput) || nowInput < 0) {
|
|
4952
|
+
throw new DeliveryStoreError('conflict', 'preference projection outbox bounds are invalid');
|
|
4953
|
+
}
|
|
4954
|
+
return this.transaction(() => {
|
|
4955
|
+
this.normalizePreferenceProjectionLanes(nowInput);
|
|
4956
|
+
const legacy = this.database.prepare(`
|
|
4957
|
+
SELECT * FROM delivery_preference_projection_outbox
|
|
4958
|
+
WHERE terminal_at IS NULL AND lane_kind <> 'exact'
|
|
4959
|
+
AND status IN ('pending', 'retry_wait')
|
|
4960
|
+
ORDER BY created_at, batch_key
|
|
4961
|
+
LIMIT 1
|
|
4962
|
+
`).get();
|
|
4963
|
+
if (legacy !== undefined) {
|
|
4964
|
+
if (legacy.next_attempt_at > nowInput)
|
|
4965
|
+
return [];
|
|
4966
|
+
return this.validPreferenceProjectionEntries([legacy], nowInput);
|
|
4967
|
+
}
|
|
4968
|
+
const rows = this.database.prepare(`
|
|
4969
|
+
SELECT candidate.*
|
|
4970
|
+
FROM delivery_preference_projection_outbox AS candidate
|
|
4971
|
+
WHERE candidate.terminal_at IS NULL AND candidate.lane_kind = 'exact'
|
|
4972
|
+
AND candidate.status IN ('pending', 'retry_wait')
|
|
4973
|
+
AND candidate.next_attempt_at <= ?
|
|
4974
|
+
AND NOT EXISTS (
|
|
4975
|
+
SELECT 1
|
|
4976
|
+
FROM delivery_preference_projection_outbox AS earlier
|
|
4977
|
+
WHERE earlier.terminal_at IS NULL AND earlier.lane_kind = 'exact'
|
|
4978
|
+
AND earlier.status IN ('pending', 'retry_wait')
|
|
4979
|
+
AND earlier.lane_epoch = candidate.lane_epoch
|
|
4980
|
+
AND earlier.lane_workspace = candidate.lane_workspace
|
|
4981
|
+
AND earlier.lane_preset = candidate.lane_preset
|
|
4982
|
+
AND earlier.lane_principal_record_id = candidate.lane_principal_record_id
|
|
4983
|
+
AND earlier.lane_principal_version = candidate.lane_principal_version
|
|
4984
|
+
AND (
|
|
4985
|
+
earlier.admission_sequence < candidate.admission_sequence
|
|
4986
|
+
OR (
|
|
4987
|
+
earlier.admission_sequence = candidate.admission_sequence
|
|
4988
|
+
AND (
|
|
4989
|
+
earlier.created_at < candidate.created_at
|
|
4990
|
+
OR (earlier.created_at = candidate.created_at
|
|
4991
|
+
AND earlier.batch_key < candidate.batch_key)
|
|
4992
|
+
)
|
|
4993
|
+
)
|
|
4994
|
+
)
|
|
4995
|
+
)
|
|
4996
|
+
ORDER BY candidate.next_attempt_at, candidate.admission_sequence,
|
|
4997
|
+
candidate.created_at, candidate.batch_key
|
|
4998
|
+
LIMIT ?
|
|
4999
|
+
`).all(nowInput, limitInput);
|
|
5000
|
+
return this.validPreferenceProjectionEntries(rows, nowInput);
|
|
5001
|
+
});
|
|
5002
|
+
}
|
|
5003
|
+
/** Earliest retry time among global legacy or exact owner-lane heads. */
|
|
5004
|
+
nextPreferenceProjectionAttemptAt(nowInput = this.now()) {
|
|
5005
|
+
this.assertOpen();
|
|
5006
|
+
if (!Number.isSafeInteger(nowInput) || nowInput < 0) {
|
|
5007
|
+
throw new DeliveryStoreError('conflict', 'preference projection retry clock is invalid');
|
|
5008
|
+
}
|
|
5009
|
+
return this.transaction(() => {
|
|
5010
|
+
this.normalizePreferenceProjectionLanes(nowInput);
|
|
5011
|
+
const legacy = this.database.prepare(`
|
|
5012
|
+
SELECT next_attempt_at FROM delivery_preference_projection_outbox
|
|
5013
|
+
WHERE terminal_at IS NULL AND lane_kind <> 'exact'
|
|
5014
|
+
AND status IN ('pending', 'retry_wait')
|
|
5015
|
+
ORDER BY created_at, batch_key
|
|
5016
|
+
LIMIT 1
|
|
5017
|
+
`).get();
|
|
5018
|
+
if (legacy !== undefined)
|
|
5019
|
+
return legacy.next_attempt_at;
|
|
5020
|
+
const row = this.database.prepare(`
|
|
5021
|
+
SELECT candidate.next_attempt_at
|
|
5022
|
+
FROM delivery_preference_projection_outbox AS candidate
|
|
5023
|
+
WHERE candidate.terminal_at IS NULL AND candidate.lane_kind = 'exact'
|
|
5024
|
+
AND candidate.status IN ('pending', 'retry_wait')
|
|
5025
|
+
AND NOT EXISTS (
|
|
5026
|
+
SELECT 1
|
|
5027
|
+
FROM delivery_preference_projection_outbox AS earlier
|
|
5028
|
+
WHERE earlier.terminal_at IS NULL AND earlier.lane_kind = 'exact'
|
|
5029
|
+
AND earlier.status IN ('pending', 'retry_wait')
|
|
5030
|
+
AND earlier.lane_epoch = candidate.lane_epoch
|
|
5031
|
+
AND earlier.lane_workspace = candidate.lane_workspace
|
|
5032
|
+
AND earlier.lane_preset = candidate.lane_preset
|
|
5033
|
+
AND earlier.lane_principal_record_id = candidate.lane_principal_record_id
|
|
5034
|
+
AND earlier.lane_principal_version = candidate.lane_principal_version
|
|
5035
|
+
AND (
|
|
5036
|
+
earlier.admission_sequence < candidate.admission_sequence
|
|
5037
|
+
OR (
|
|
5038
|
+
earlier.admission_sequence = candidate.admission_sequence
|
|
5039
|
+
AND (
|
|
5040
|
+
earlier.created_at < candidate.created_at
|
|
5041
|
+
OR (earlier.created_at = candidate.created_at
|
|
5042
|
+
AND earlier.batch_key < candidate.batch_key)
|
|
5043
|
+
)
|
|
5044
|
+
)
|
|
5045
|
+
)
|
|
5046
|
+
)
|
|
5047
|
+
ORDER BY candidate.next_attempt_at
|
|
5048
|
+
LIMIT 1
|
|
5049
|
+
`).get();
|
|
5050
|
+
return row?.next_attempt_at;
|
|
5051
|
+
});
|
|
5052
|
+
}
|
|
5053
|
+
/**
|
|
5054
|
+
* A direct control may run only after every earlier projection in its exact
|
|
5055
|
+
* owner lane is terminal. Valid legacy rows are a global upgrade barrier
|
|
5056
|
+
* because their missing cursor makes a narrower comparison impossible.
|
|
5057
|
+
*/
|
|
5058
|
+
hasBlockingPreferenceProjectionBefore(input) {
|
|
5059
|
+
this.assertOpen();
|
|
5060
|
+
const workspace = validateBindingText(input.scope.workspace, 'preference workspace', 4_096);
|
|
5061
|
+
const preset = validateBindingText(input.scope.preset, 'preference preset', 200);
|
|
5062
|
+
const principalRecordId = validateBindingText(input.principalLineage.principalRecordId, 'preference principal record id', 500);
|
|
5063
|
+
if (!Number.isSafeInteger(input.principalLineage.principalVersion)
|
|
5064
|
+
|| input.principalLineage.principalVersion < 1
|
|
5065
|
+
|| !/^[0-9a-f]{32}$/u.test(input.admissionCursor.epoch)
|
|
5066
|
+
|| !Number.isSafeInteger(input.admissionCursor.sequence)
|
|
5067
|
+
|| input.admissionCursor.sequence < 1) {
|
|
5068
|
+
throw new DeliveryStoreError('conflict', 'preference control barrier authority is invalid');
|
|
5069
|
+
}
|
|
5070
|
+
const now = this.now();
|
|
5071
|
+
return this.transaction(() => {
|
|
5072
|
+
this.normalizePreferenceProjectionLanes(now);
|
|
5073
|
+
const blocker = this.database.prepare(`
|
|
5074
|
+
SELECT 1 AS present
|
|
5075
|
+
FROM delivery_preference_projection_outbox
|
|
5076
|
+
WHERE terminal_at IS NULL AND status IN ('pending', 'retry_wait')
|
|
5077
|
+
AND (
|
|
5078
|
+
lane_kind <> 'exact'
|
|
5079
|
+
OR (
|
|
5080
|
+
lane_epoch = ? AND lane_workspace = ? AND lane_preset = ?
|
|
5081
|
+
AND lane_principal_record_id = ? AND lane_principal_version = ?
|
|
5082
|
+
AND admission_sequence < ?
|
|
5083
|
+
)
|
|
5084
|
+
)
|
|
5085
|
+
LIMIT 1
|
|
5086
|
+
`).get(input.admissionCursor.epoch, workspace, preset, principalRecordId, input.principalLineage.principalVersion, input.admissionCursor.sequence);
|
|
5087
|
+
return blocker !== undefined;
|
|
5088
|
+
});
|
|
5089
|
+
}
|
|
5090
|
+
completePreferenceProjection(input) {
|
|
5091
|
+
this.assertOpen();
|
|
5092
|
+
const changed = this.database.prepare(`
|
|
5093
|
+
DELETE FROM delivery_preference_projection_outbox
|
|
5094
|
+
WHERE batch_key = ? AND payload_digest = ? AND terminal_at IS NULL
|
|
5095
|
+
`).run(input.batchKey, input.payloadDigest);
|
|
5096
|
+
if (changed.changes === 1)
|
|
5097
|
+
return;
|
|
5098
|
+
const current = this.database.prepare(`
|
|
5099
|
+
SELECT payload_digest, terminal_at
|
|
5100
|
+
FROM delivery_preference_projection_outbox WHERE batch_key = ?
|
|
5101
|
+
`).get(input.batchKey);
|
|
5102
|
+
// Another Host can append the same idempotent batch and delete it first.
|
|
5103
|
+
// Absence is therefore the peer-completed terminal, not a local failure.
|
|
5104
|
+
if (current === undefined)
|
|
5105
|
+
return;
|
|
5106
|
+
if (current.payload_digest !== input.payloadDigest) {
|
|
5107
|
+
throw new DeliveryStoreError('receipt-mismatch', 'preference projection completion changed identity');
|
|
5108
|
+
}
|
|
5109
|
+
// A concurrent owner handoff terminally quarantines this exact batch. Its
|
|
5110
|
+
// late completion is an ACK-ignore terminal, never a reason to revive or
|
|
5111
|
+
// retry evidence from the retired lineage.
|
|
5112
|
+
if (current.terminal_at !== null)
|
|
5113
|
+
return;
|
|
5114
|
+
throw new DeliveryStoreError('version-conflict', 'preference projection completion lost its active batch');
|
|
5115
|
+
}
|
|
5116
|
+
deferPreferenceProjection(input) {
|
|
5117
|
+
this.assertOpen();
|
|
5118
|
+
const failureCode = validateBindingText(input.failureCode, 'preference projection failureCode', 64);
|
|
5119
|
+
if (!Number.isSafeInteger(input.now) || !Number.isSafeInteger(input.retryAt)
|
|
5120
|
+
|| input.retryAt <= input.now) {
|
|
5121
|
+
throw new DeliveryStoreError('conflict', 'preference projection retry time is invalid');
|
|
5122
|
+
}
|
|
5123
|
+
const changed = this.database.prepare(`
|
|
5124
|
+
UPDATE delivery_preference_projection_outbox
|
|
5125
|
+
SET status = 'retry_wait', attempt_count = attempt_count + 1,
|
|
5126
|
+
next_attempt_at = ?, failure_code = ?, updated_at = ?
|
|
5127
|
+
WHERE batch_key = ? AND payload_digest = ? AND terminal_at IS NULL
|
|
5128
|
+
`).run(input.retryAt, failureCode, input.now, input.batchKey, input.payloadDigest);
|
|
5129
|
+
if (changed.changes === 1)
|
|
5130
|
+
return;
|
|
5131
|
+
const current = this.database.prepare(`
|
|
5132
|
+
SELECT payload_digest, terminal_at
|
|
5133
|
+
FROM delivery_preference_projection_outbox WHERE batch_key = ?
|
|
5134
|
+
`).get(input.batchKey);
|
|
5135
|
+
// If a peer received the authoritative receipt and removed this row while
|
|
5136
|
+
// our append was failing, its exact downstream commit is already enough.
|
|
5137
|
+
if (current === undefined)
|
|
5138
|
+
return;
|
|
5139
|
+
if (current.payload_digest !== input.payloadDigest) {
|
|
5140
|
+
throw new DeliveryStoreError('receipt-mismatch', 'preference projection defer changed identity');
|
|
5141
|
+
}
|
|
5142
|
+
if (current.terminal_at !== null)
|
|
5143
|
+
return;
|
|
5144
|
+
throw new DeliveryStoreError('version-conflict', 'preference projection defer lost its active batch');
|
|
5145
|
+
}
|
|
5146
|
+
/**
|
|
5147
|
+
* Revalidate one previously-read batch against the live Delivery owner.
|
|
5148
|
+
* Stale, legacy, poison, already-terminal, and peer-completed rows are
|
|
5149
|
+
* acknowledged without invoking a downstream sink.
|
|
5150
|
+
*/
|
|
5151
|
+
preferenceProjectionHasCurrentOwner(input) {
|
|
5152
|
+
this.assertOpen();
|
|
5153
|
+
return this.transaction(() => this.currentPreferenceProjectionInTransaction(input) !== undefined);
|
|
5154
|
+
}
|
|
5155
|
+
/**
|
|
5156
|
+
* Linearizable projection for a process-local synchronous Preference writer.
|
|
5157
|
+
* Delivery's BEGIN IMMEDIATE remains held while Preference commits, giving
|
|
5158
|
+
* all cooperating processes the fixed Delivery -> Preference lock order.
|
|
5159
|
+
* A handoff therefore linearizes wholly before the callback (which is then
|
|
5160
|
+
* skipped) or wholly after the Preference commit and Delivery ACK.
|
|
5161
|
+
*/
|
|
5162
|
+
projectPreferenceProjectionUnderOwnerFence(input, project) {
|
|
5163
|
+
this.assertOpen();
|
|
5164
|
+
if (typeof project !== 'function') {
|
|
5165
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection writer is invalid');
|
|
5166
|
+
}
|
|
5167
|
+
return this.transaction(() => {
|
|
5168
|
+
const row = this.database.prepare(`
|
|
5169
|
+
SELECT * FROM delivery_preference_projection_outbox WHERE batch_key = ?
|
|
5170
|
+
`).get(input.batchKey);
|
|
5171
|
+
if (row === undefined)
|
|
5172
|
+
return 'missing';
|
|
5173
|
+
if (row.payload_digest !== input.payloadDigest) {
|
|
5174
|
+
throw new DeliveryStoreError('receipt-mismatch', 'preference projection fence changed identity');
|
|
5175
|
+
}
|
|
5176
|
+
const entry = this.currentPreferenceProjectionInTransaction(input, row);
|
|
5177
|
+
if (entry === undefined)
|
|
5178
|
+
return 'ignored';
|
|
5179
|
+
const result = project(entry);
|
|
5180
|
+
if (typeof result === 'object' && result !== null
|
|
5181
|
+
&& typeof result.then === 'function') {
|
|
5182
|
+
throw new DeliveryStoreError('invalid-intent', 'preference projection fenced writer must complete synchronously');
|
|
5183
|
+
}
|
|
5184
|
+
const deleted = this.database.prepare(`
|
|
5185
|
+
DELETE FROM delivery_preference_projection_outbox
|
|
5186
|
+
WHERE batch_key = ? AND payload_digest = ? AND terminal_at IS NULL
|
|
5187
|
+
`).run(entry.batchKey, entry.payloadDigest);
|
|
5188
|
+
if (deleted.changes !== 1) {
|
|
5189
|
+
throw new DeliveryStoreError('version-conflict', 'preference projection fenced ACK lost its batch');
|
|
5190
|
+
}
|
|
5191
|
+
return 'completed';
|
|
5192
|
+
});
|
|
5193
|
+
}
|
|
5194
|
+
requeuePreferenceProjections() {
|
|
5195
|
+
this.assertOpen();
|
|
5196
|
+
const now = this.now();
|
|
5197
|
+
return Number(this.database.prepare(`
|
|
5198
|
+
UPDATE delivery_preference_projection_outbox
|
|
5199
|
+
SET status = 'pending', next_attempt_at = ?, failure_code = NULL, updated_at = ?
|
|
5200
|
+
WHERE terminal_at IS NULL
|
|
5201
|
+
`).run(now, now).changes);
|
|
5202
|
+
}
|
|
5203
|
+
normalizePreferenceProjectionLanes(now) {
|
|
5204
|
+
const rows = this.database.prepare(`
|
|
5205
|
+
SELECT * FROM delivery_preference_projection_outbox
|
|
5206
|
+
WHERE terminal_at IS NULL
|
|
5207
|
+
ORDER BY created_at, batch_key
|
|
5208
|
+
`).all();
|
|
5209
|
+
for (const row of rows) {
|
|
5210
|
+
try {
|
|
5211
|
+
const entry = preferenceProjectionOutboxEntry(row);
|
|
5212
|
+
if (row.lane_kind !== 'unclassified')
|
|
5213
|
+
continue;
|
|
5214
|
+
const changed = entry.lane === undefined
|
|
5215
|
+
? this.database.prepare(`
|
|
5216
|
+
UPDATE delivery_preference_projection_outbox
|
|
5217
|
+
SET lane_kind = 'legacy', lane_epoch = NULL, lane_workspace = NULL,
|
|
5218
|
+
lane_preset = NULL, lane_principal_record_id = NULL,
|
|
5219
|
+
lane_principal_version = NULL, admission_sequence = NULL,
|
|
5220
|
+
updated_at = ?
|
|
5221
|
+
WHERE batch_key = ? AND payload_digest = ?
|
|
5222
|
+
AND terminal_at IS NULL AND lane_kind = 'unclassified'
|
|
5223
|
+
`).run(now, entry.batchKey, entry.payloadDigest)
|
|
5224
|
+
: this.database.prepare(`
|
|
5225
|
+
UPDATE delivery_preference_projection_outbox
|
|
5226
|
+
SET lane_kind = 'exact', lane_epoch = ?, lane_workspace = ?,
|
|
5227
|
+
lane_preset = ?, lane_principal_record_id = ?,
|
|
5228
|
+
lane_principal_version = ?, admission_sequence = ?, updated_at = ?
|
|
5229
|
+
WHERE batch_key = ? AND payload_digest = ?
|
|
5230
|
+
AND terminal_at IS NULL AND lane_kind = 'unclassified'
|
|
5231
|
+
`).run(entry.lane.epoch, entry.lane.workspace, entry.lane.preset, entry.lane.principalRecordId, entry.lane.principalVersion, entry.lane.admissionSequence, now, entry.batchKey, entry.payloadDigest);
|
|
5232
|
+
if (changed.changes !== 1) {
|
|
5233
|
+
throw new DeliveryStoreError('version-conflict', 'preference projection lane normalization lost its exact batch');
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
catch {
|
|
5237
|
+
this.database.prepare(`
|
|
5238
|
+
UPDATE delivery_preference_projection_outbox
|
|
5239
|
+
SET terminal_at = ?, failure_code = 'projection-poison-row',
|
|
5240
|
+
attempt_count = attempt_count + 1, next_attempt_at = 9007199254740991,
|
|
5241
|
+
updated_at = ?
|
|
5242
|
+
WHERE batch_key = ? AND terminal_at IS NULL
|
|
5243
|
+
`).run(now, now, row.batch_key);
|
|
5244
|
+
}
|
|
5245
|
+
}
|
|
5246
|
+
}
|
|
5247
|
+
currentPreferenceProjectionInTransaction(input, selected) {
|
|
5248
|
+
const row = selected ?? this.database.prepare(`
|
|
5249
|
+
SELECT * FROM delivery_preference_projection_outbox WHERE batch_key = ?
|
|
5250
|
+
`).get(input.batchKey);
|
|
5251
|
+
if (row === undefined)
|
|
5252
|
+
return undefined;
|
|
5253
|
+
if (row.payload_digest !== input.payloadDigest) {
|
|
5254
|
+
throw new DeliveryStoreError('receipt-mismatch', 'preference projection owner fence changed identity');
|
|
5255
|
+
}
|
|
5256
|
+
if (row.terminal_at !== null)
|
|
5257
|
+
return undefined;
|
|
5258
|
+
let entry;
|
|
5259
|
+
try {
|
|
5260
|
+
entry = preferenceProjectionOutboxEntry(row);
|
|
5261
|
+
}
|
|
5262
|
+
catch {
|
|
5263
|
+
this.terminalizePreferenceProjectionInTransaction(row.batch_key, 'projection-poison-row');
|
|
5264
|
+
return undefined;
|
|
5265
|
+
}
|
|
5266
|
+
const owners = this.database.prepare(`
|
|
5267
|
+
SELECT id, principal_json, role, status, linked_to_id, created_at, updated_at, version
|
|
5268
|
+
FROM delivery_principals WHERE role = 'owner' AND status = 'active'
|
|
5269
|
+
ORDER BY id LIMIT 2
|
|
5270
|
+
`).all();
|
|
5271
|
+
let currentPrincipalId;
|
|
5272
|
+
try {
|
|
5273
|
+
currentPrincipalId = owners.length === 1
|
|
5274
|
+
? externalPrincipalId(JSON.parse(owners[0].principal_json))
|
|
5275
|
+
: undefined;
|
|
5276
|
+
}
|
|
5277
|
+
catch {
|
|
5278
|
+
currentPrincipalId = undefined;
|
|
5279
|
+
}
|
|
5280
|
+
const owner = owners.length === 1 ? owners[0] : undefined;
|
|
5281
|
+
if (row.lane_kind !== 'exact' || entry.lane === undefined || owner === undefined
|
|
5282
|
+
|| entry.lane.principalRecordId !== owner.id
|
|
5283
|
+
|| entry.lane.principalVersion !== owner.version
|
|
5284
|
+
|| entry.events.some(event => event.principalId !== currentPrincipalId)) {
|
|
5285
|
+
this.terminalizePreferenceProjectionInTransaction(row.batch_key, 'owner-lineage-retired');
|
|
5286
|
+
return undefined;
|
|
5287
|
+
}
|
|
5288
|
+
return entry;
|
|
5289
|
+
}
|
|
5290
|
+
terminalizePreferenceProjectionInTransaction(batchKey, failureCode) {
|
|
5291
|
+
const now = this.now();
|
|
5292
|
+
this.database.prepare(`
|
|
5293
|
+
UPDATE delivery_preference_projection_outbox
|
|
5294
|
+
SET terminal_at = ?, failure_code = ?, next_attempt_at = 9007199254740991,
|
|
5295
|
+
updated_at = ?
|
|
5296
|
+
WHERE batch_key = ? AND terminal_at IS NULL
|
|
5297
|
+
`).run(now, failureCode, now, batchKey);
|
|
5298
|
+
}
|
|
5299
|
+
validPreferenceProjectionEntries(rows, now) {
|
|
5300
|
+
const result = [];
|
|
5301
|
+
for (const row of rows) {
|
|
5302
|
+
try {
|
|
5303
|
+
result.push(preferenceProjectionOutboxEntry(row));
|
|
5304
|
+
}
|
|
5305
|
+
catch {
|
|
5306
|
+
this.database.prepare(`
|
|
5307
|
+
UPDATE delivery_preference_projection_outbox
|
|
5308
|
+
SET terminal_at = ?, failure_code = 'projection-poison-row',
|
|
5309
|
+
attempt_count = attempt_count + 1, next_attempt_at = 9007199254740991,
|
|
5310
|
+
updated_at = ?
|
|
5311
|
+
WHERE batch_key = ? AND terminal_at IS NULL
|
|
5312
|
+
`).run(now, now, row.batch_key);
|
|
5313
|
+
}
|
|
5314
|
+
}
|
|
5315
|
+
return result;
|
|
5316
|
+
}
|
|
5317
|
+
insertPreferenceProjection(payload) {
|
|
5318
|
+
const existing = this.database.prepare(`
|
|
5319
|
+
SELECT payload_digest, events_json
|
|
5320
|
+
FROM delivery_preference_projection_outbox WHERE batch_key = ?
|
|
5321
|
+
`).get(payload.batchKey);
|
|
5322
|
+
if (existing !== undefined) {
|
|
5323
|
+
if (existing.payload_digest !== payload.digest || existing.events_json !== payload.json) {
|
|
5324
|
+
throw new DeliveryStoreError('idempotency-conflict', 'preference projection identity was reused with different events');
|
|
5325
|
+
}
|
|
5326
|
+
return Object.freeze({
|
|
5327
|
+
batchKey: payload.batchKey,
|
|
5328
|
+
payloadDigest: payload.digest,
|
|
5329
|
+
replayed: true,
|
|
5330
|
+
});
|
|
5331
|
+
}
|
|
5332
|
+
const now = this.now();
|
|
5333
|
+
this.database.prepare(`
|
|
5334
|
+
INSERT INTO delivery_preference_projection_outbox(
|
|
5335
|
+
batch_key, payload_digest, events_json, status, attempt_count,
|
|
5336
|
+
next_attempt_at, failure_code, lane_kind, lane_epoch, lane_workspace,
|
|
5337
|
+
lane_preset, lane_principal_record_id, lane_principal_version,
|
|
5338
|
+
admission_sequence, terminal_at, created_at, updated_at
|
|
5339
|
+
) VALUES (?, ?, ?, 'pending', 0, ?, NULL, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
5340
|
+
`).run(payload.batchKey, payload.digest, payload.json, now, payload.lane === undefined ? 'legacy' : 'exact', payload.lane?.epoch ?? null, payload.lane?.workspace ?? null, payload.lane?.preset ?? null, payload.lane?.principalRecordId ?? null, payload.lane?.principalVersion ?? null, payload.lane?.admissionSequence ?? null, now, now);
|
|
5341
|
+
return Object.freeze({
|
|
5342
|
+
batchKey: payload.batchKey,
|
|
5343
|
+
payloadDigest: payload.digest,
|
|
5344
|
+
replayed: false,
|
|
5345
|
+
});
|
|
5346
|
+
}
|
|
5347
|
+
listPendingWorkflowTraceRevisions(limitInput = 100, nowInput = this.now()) {
|
|
5348
|
+
this.assertOpen();
|
|
5349
|
+
if (!Number.isSafeInteger(limitInput) || limitInput < 1 || limitInput > 1_000
|
|
5350
|
+
|| !Number.isSafeInteger(nowInput) || nowInput < 0) {
|
|
5351
|
+
throw new DeliveryStoreError('conflict', 'workflow trace outbox bounds are invalid');
|
|
5352
|
+
}
|
|
5353
|
+
const rows = this.database.prepare(`
|
|
5354
|
+
SELECT revision.*, outbox.status, outbox.attempt_count, outbox.next_attempt_at,
|
|
5355
|
+
outbox.failure_code, outbox.updated_at
|
|
5356
|
+
FROM workflow_trace_outbox AS outbox
|
|
5357
|
+
JOIN workflow_trace_revisions AS revision
|
|
5358
|
+
ON revision.subject_ref = outbox.subject_ref AND revision.version = outbox.version
|
|
5359
|
+
JOIN workflow_trace_current AS current
|
|
5360
|
+
ON current.subject_ref = outbox.subject_ref AND current.version = outbox.version
|
|
5361
|
+
WHERE outbox.status IN ('pending', 'retry_wait') AND outbox.next_attempt_at <= ?
|
|
5362
|
+
ORDER BY outbox.next_attempt_at, outbox.updated_at, outbox.subject_ref, outbox.version
|
|
5363
|
+
LIMIT ?
|
|
5364
|
+
`).all(nowInput, limitInput);
|
|
5365
|
+
const result = [];
|
|
5366
|
+
for (const row of rows) {
|
|
5367
|
+
try {
|
|
5368
|
+
result.push(workflowTraceOutboxEntry(row));
|
|
5369
|
+
}
|
|
5370
|
+
catch {
|
|
5371
|
+
this.deferWorkflowTraceRevision({
|
|
5372
|
+
subjectRef: row.subject_ref,
|
|
5373
|
+
version: row.version,
|
|
5374
|
+
digest: row.digest,
|
|
5375
|
+
now: nowInput,
|
|
5376
|
+
retryAt: nowInput + 86_400_000,
|
|
5377
|
+
failureCode: 'trace-poison-row',
|
|
5378
|
+
});
|
|
5379
|
+
}
|
|
5380
|
+
}
|
|
5381
|
+
return result;
|
|
5382
|
+
}
|
|
5383
|
+
completeWorkflowTraceRevision(input) {
|
|
5384
|
+
this.assertOpen();
|
|
5385
|
+
const now = input.now ?? this.now();
|
|
5386
|
+
const changed = this.database.prepare(`
|
|
5387
|
+
UPDATE workflow_trace_outbox
|
|
5388
|
+
SET status = 'delivered', failure_code = NULL, next_attempt_at = ?, updated_at = ?
|
|
5389
|
+
WHERE subject_ref = ? AND version = ? AND status IN ('pending', 'retry_wait')
|
|
5390
|
+
AND EXISTS (
|
|
5391
|
+
SELECT 1 FROM workflow_trace_revisions AS revision
|
|
5392
|
+
WHERE revision.subject_ref = workflow_trace_outbox.subject_ref
|
|
5393
|
+
AND revision.version = workflow_trace_outbox.version AND revision.digest = ?
|
|
5394
|
+
)
|
|
5395
|
+
`).run(now, now, input.subjectRef, input.version, input.digest);
|
|
5396
|
+
if (changed.changes !== 1) {
|
|
5397
|
+
const current = this.database.prepare(`
|
|
5398
|
+
SELECT outbox.status, revision.digest FROM workflow_trace_outbox AS outbox
|
|
5399
|
+
JOIN workflow_trace_revisions AS revision
|
|
5400
|
+
ON revision.subject_ref = outbox.subject_ref AND revision.version = outbox.version
|
|
5401
|
+
WHERE outbox.subject_ref = ? AND outbox.version = ?
|
|
5402
|
+
`).get(input.subjectRef, input.version);
|
|
5403
|
+
if (current?.status !== 'delivered' || current.digest !== input.digest) {
|
|
5404
|
+
throw new DeliveryStoreError('version-conflict', 'workflow trace completion lost its exact revision');
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
deferWorkflowTraceRevision(input) {
|
|
5409
|
+
this.assertOpen();
|
|
5410
|
+
const failureCode = validateBindingText(input.failureCode, 'workflow trace failureCode', 64);
|
|
5411
|
+
if (!Number.isSafeInteger(input.now) || !Number.isSafeInteger(input.retryAt)
|
|
5412
|
+
|| input.retryAt <= input.now)
|
|
5413
|
+
throw new DeliveryStoreError('conflict', 'workflow trace retry time is invalid');
|
|
5414
|
+
const changed = this.database.prepare(`
|
|
5415
|
+
UPDATE workflow_trace_outbox
|
|
5416
|
+
SET status = 'retry_wait', attempt_count = attempt_count + 1,
|
|
5417
|
+
next_attempt_at = ?, failure_code = ?, updated_at = ?
|
|
5418
|
+
WHERE subject_ref = ? AND version = ? AND status IN ('pending', 'retry_wait')
|
|
5419
|
+
AND EXISTS (
|
|
5420
|
+
SELECT 1 FROM workflow_trace_revisions AS revision
|
|
5421
|
+
WHERE revision.subject_ref = workflow_trace_outbox.subject_ref
|
|
5422
|
+
AND revision.version = workflow_trace_outbox.version AND revision.digest = ?
|
|
5423
|
+
)
|
|
5424
|
+
`).run(input.retryAt, failureCode, input.now, input.subjectRef, input.version, input.digest);
|
|
5425
|
+
if (changed.changes !== 1) {
|
|
5426
|
+
throw new DeliveryStoreError('version-conflict', 'workflow trace defer lost its exact revision');
|
|
5427
|
+
}
|
|
5428
|
+
}
|
|
5429
|
+
insertWorkflowTraceRevisionInTransaction(input) {
|
|
5430
|
+
const revision = validateWorkflowTraceRevision(input);
|
|
5431
|
+
const source = this.workflowTraceSourceAttestation();
|
|
5432
|
+
if (revision.source.sourceId !== source.sourceId
|
|
5433
|
+
|| revision.source.generation !== source.generation
|
|
5434
|
+
|| revision.source.authorityDigest !== source.authorityDigest) {
|
|
5435
|
+
throw new DeliveryStoreError('unauthorized-principal', 'workflow trace source authority changed');
|
|
5436
|
+
}
|
|
5437
|
+
const existingRow = this.database.prepare(`
|
|
5438
|
+
SELECT * FROM workflow_trace_revisions WHERE subject_ref = ? AND version = ?
|
|
5439
|
+
`).get(revision.subjectRef, revision.version);
|
|
5440
|
+
if (existingRow !== undefined) {
|
|
5441
|
+
const existing = workflowTraceRevision(existingRow);
|
|
5442
|
+
if (existing.digest !== revision.digest
|
|
5443
|
+
|| JSON.stringify(existing) !== JSON.stringify(revision)) {
|
|
5444
|
+
throw new DeliveryStoreError('idempotency-conflict', 'workflow trace version was reused with different content');
|
|
5445
|
+
}
|
|
5446
|
+
const current = this.database.prepare(`
|
|
5447
|
+
SELECT version, digest FROM workflow_trace_current WHERE subject_ref = ?
|
|
5448
|
+
`).get(revision.subjectRef);
|
|
5449
|
+
if (current === undefined || current.version < revision.version
|
|
5450
|
+
|| (current.version === revision.version && current.digest !== revision.digest)) {
|
|
5451
|
+
throw new DeliveryStoreError('conflict', 'workflow trace current projection is inconsistent');
|
|
5452
|
+
}
|
|
5453
|
+
return Object.freeze({ revision: existing, replayed: true });
|
|
5454
|
+
}
|
|
5455
|
+
const current = this.database.prepare(`
|
|
5456
|
+
SELECT version, digest FROM workflow_trace_current WHERE subject_ref = ?
|
|
5457
|
+
`).get(revision.subjectRef);
|
|
5458
|
+
if (current !== undefined && revision.version <= current.version) {
|
|
5459
|
+
throw new DeliveryStoreError('version-conflict', 'workflow trace version is lower than the current projection');
|
|
5460
|
+
}
|
|
5461
|
+
const now = this.now();
|
|
5462
|
+
this.database.prepare(`
|
|
5463
|
+
INSERT INTO workflow_trace_revisions(
|
|
5464
|
+
subject_ref, version, source_generation, source_authority_digest, scope_key,
|
|
5465
|
+
workspace, preset, disposition, digest, payload_json, created_at
|
|
5466
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5467
|
+
`).run(revision.subjectRef, revision.version, revision.source.generation, revision.source.authorityDigest, workflowScopeKey(revision.scope), revision.scope.workspace, revision.scope.preset, revision.disposition, revision.digest, JSON.stringify(revision), now);
|
|
5468
|
+
this.database.prepare(`
|
|
5469
|
+
INSERT INTO workflow_trace_current(
|
|
5470
|
+
subject_ref, version, digest, disposition, payload_json, updated_at
|
|
5471
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
5472
|
+
ON CONFLICT(subject_ref) DO UPDATE SET
|
|
5473
|
+
version = excluded.version,
|
|
5474
|
+
digest = excluded.digest,
|
|
5475
|
+
disposition = excluded.disposition,
|
|
5476
|
+
payload_json = excluded.payload_json,
|
|
5477
|
+
updated_at = excluded.updated_at
|
|
5478
|
+
WHERE excluded.version > workflow_trace_current.version
|
|
5479
|
+
`).run(revision.subjectRef, revision.version, revision.digest, revision.disposition, JSON.stringify(revision), now);
|
|
5480
|
+
this.database.prepare(`
|
|
5481
|
+
INSERT INTO workflow_trace_outbox(
|
|
5482
|
+
subject_ref, version, status, attempt_count, next_attempt_at,
|
|
5483
|
+
failure_code, created_at, updated_at
|
|
5484
|
+
) VALUES (?, ?, 'pending', 0, ?, NULL, ?, ?)
|
|
5485
|
+
`).run(revision.subjectRef, revision.version, now, now, now);
|
|
5486
|
+
return Object.freeze({ revision, replayed: false });
|
|
5487
|
+
}
|
|
5488
|
+
revokeCurrentWorkflowTemplateInTransaction(current, now) {
|
|
5489
|
+
const template = current?.disposition === 'upsert' ? current.evidence?.template : undefined;
|
|
5490
|
+
if (template === undefined)
|
|
5491
|
+
return;
|
|
5492
|
+
const row = this.database.prepare(`
|
|
5493
|
+
SELECT template_digest, status FROM workflow_template_registry WHERE template_ref = ?
|
|
5494
|
+
`).get(template.templateRef);
|
|
5495
|
+
if (row === undefined)
|
|
5496
|
+
return;
|
|
5497
|
+
if (row.template_digest !== template.templateDigest) {
|
|
5498
|
+
throw new DeliveryStoreError('receipt-mismatch', 'current workflow template identity is corrupt');
|
|
5499
|
+
}
|
|
5500
|
+
if (row.status === 'revoked')
|
|
5501
|
+
return;
|
|
5502
|
+
const changed = this.database.prepare(`
|
|
5503
|
+
UPDATE workflow_template_registry
|
|
5504
|
+
SET status = 'revoked', updated_at = ?, version = version + 1
|
|
5505
|
+
WHERE template_ref = ? AND template_digest = ? AND status = 'active'
|
|
5506
|
+
`).run(now, template.templateRef, template.templateDigest);
|
|
5507
|
+
if (changed.changes !== 1) {
|
|
5508
|
+
throw new DeliveryStoreError('version-conflict', 'workflow template revocation lost its exact version');
|
|
5509
|
+
}
|
|
5510
|
+
}
|
|
2629
5511
|
close() {
|
|
2630
5512
|
if (this.closed)
|
|
2631
5513
|
return;
|