@victframework/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-remote.d.ts +36 -0
- package/dist/app-remote.js +108 -0
- package/dist/app-remote.js.map +1 -0
- package/dist/auth.d.ts +53 -0
- package/dist/auth.js +44 -0
- package/dist/auth.js.map +1 -0
- package/dist/commands.d.ts +192 -0
- package/dist/commands.js +1084 -0
- package/dist/commands.js.map +1 -0
- package/dist/http.d.ts +71 -0
- package/dist/http.js +715 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { COMMAND_IDEMPOTENCY_KEY_PATTERN, toCanonicalJson, VictControlError, VICT_IDEMPOTENCY_FENCE_CONFLICT, commandIdempotencyFenceToken, } from '@victframework/runtime';
|
|
3
|
+
/**
|
|
4
|
+
* Stage 06B — the transport-free, versioned command service (API-002).
|
|
5
|
+
*
|
|
6
|
+
* The HTTP transport and the CLI consume the SAME typed dispatcher; no
|
|
7
|
+
* caller bypasses governance. Properties enforced HERE (below the
|
|
8
|
+
* transport, fail closed):
|
|
9
|
+
*
|
|
10
|
+
* - ONE command registry: authorization scope, closed payload field set,
|
|
11
|
+
* and the mutation/idempotency policy are declared TOGETHER per command,
|
|
12
|
+
* so the command list, the scope matrix, and the mutation list cannot
|
|
13
|
+
* silently diverge;
|
|
14
|
+
* - AUTHORIZATION MATRIX: every command's required scope is asserted from
|
|
15
|
+
* the closed scope vocabulary BEFORE any store access;
|
|
16
|
+
* - CLOSED PAYLOAD SCHEMAS: unknown fields and non-object payloads are
|
|
17
|
+
* rejected (never silently converted to `{}`); payloads are canonicalized
|
|
18
|
+
* ONCE into plain data (own enumerable properties only — accessors,
|
|
19
|
+
* proxies that throw, and enumeration traps fail with a stable,
|
|
20
|
+
* non-echoing error) and that SAME canonical form is used for the
|
|
21
|
+
* request digest and for execution;
|
|
22
|
+
* - DURABLE COMMAND IDEMPOTENCY: every state-changing command requires a
|
|
23
|
+
* bounded `Idempotency-Key`; receipts are NAMESPACED by (authenticated
|
|
24
|
+
* actor, command, key) and bind the canonical request digest. A pending
|
|
25
|
+
* receipt carries a durable LEASE (owner + expiry): concurrent
|
|
26
|
+
* duplicates answer `IN_PROGRESS`, a crashed claim's expired lease is
|
|
27
|
+
* taken over on retry (fenced re-execution through the domain's own
|
|
28
|
+
* idempotency), deterministic failures settle `failed` and replay, and
|
|
29
|
+
* retryable infrastructure failures RELEASE the claim instead of being
|
|
30
|
+
* permanently confused with command failure;
|
|
31
|
+
* - SAFE RECEIPT RETENTION: receipts store a per-command SAFE replay
|
|
32
|
+
* projection (stable codes, identifiers, content references) — never the
|
|
33
|
+
* full command response, rationale, application rows, model content, or
|
|
34
|
+
* tool data. An authorized replay result is reconstructed from its
|
|
35
|
+
* authoritative domain when necessary;
|
|
36
|
+
* - stable, structured, non-echoing errors.
|
|
37
|
+
*/
|
|
38
|
+
/** The versioned command envelope marker. */
|
|
39
|
+
export const VICT_COMMAND_SCHEMA = 'vict.command@1';
|
|
40
|
+
/** Closed command names (version 1). */
|
|
41
|
+
export const VICT_COMMANDS = [
|
|
42
|
+
'health.inspect',
|
|
43
|
+
'compatibility.inspect',
|
|
44
|
+
'actor.whoami',
|
|
45
|
+
'changeset.propose',
|
|
46
|
+
'changeset.revise',
|
|
47
|
+
'changeset.execute-check',
|
|
48
|
+
'changeset.attach-evidence',
|
|
49
|
+
'changeset.decide',
|
|
50
|
+
'changeset.commit',
|
|
51
|
+
'changeset.get',
|
|
52
|
+
'changeset.list',
|
|
53
|
+
'release.publish',
|
|
54
|
+
'release.select',
|
|
55
|
+
'release.rollback',
|
|
56
|
+
'release.get-selected',
|
|
57
|
+
'activation.select',
|
|
58
|
+
'run.cancel',
|
|
59
|
+
'agent.turn.start',
|
|
60
|
+
'agent.turn.cancel',
|
|
61
|
+
'agent.turn.get',
|
|
62
|
+
'agent.tool.approve',
|
|
63
|
+
'agent.tool.decline',
|
|
64
|
+
'stream.inspect',
|
|
65
|
+
'app.data.query',
|
|
66
|
+
'app.data.mutate',
|
|
67
|
+
'app.data.action',
|
|
68
|
+
];
|
|
69
|
+
const COMMAND_REGISTRY = {
|
|
70
|
+
'health.inspect': { scope: '*', fields: [], mutation: false },
|
|
71
|
+
'compatibility.inspect': { scope: '*', fields: [], mutation: false },
|
|
72
|
+
'actor.whoami': { scope: '*', fields: [], mutation: false },
|
|
73
|
+
'changeset.propose': {
|
|
74
|
+
scope: 'changeset.propose',
|
|
75
|
+
fields: [
|
|
76
|
+
'changesetId',
|
|
77
|
+
'base',
|
|
78
|
+
'operations',
|
|
79
|
+
'rationale',
|
|
80
|
+
'riskClass',
|
|
81
|
+
'requiredApproverCount',
|
|
82
|
+
'expiresAt',
|
|
83
|
+
],
|
|
84
|
+
mutation: true,
|
|
85
|
+
},
|
|
86
|
+
'changeset.revise': {
|
|
87
|
+
scope: 'changeset.revise',
|
|
88
|
+
fields: [
|
|
89
|
+
'changesetId',
|
|
90
|
+
'operations',
|
|
91
|
+
'rationale',
|
|
92
|
+
'riskClass',
|
|
93
|
+
'requiredApproverCount',
|
|
94
|
+
'expiresAt',
|
|
95
|
+
],
|
|
96
|
+
mutation: true,
|
|
97
|
+
},
|
|
98
|
+
// Creates a durable control run: mutation-governed.
|
|
99
|
+
'changeset.execute-check': {
|
|
100
|
+
scope: 'changeset.propose',
|
|
101
|
+
fields: ['changesetId', 'kind'],
|
|
102
|
+
mutation: true,
|
|
103
|
+
},
|
|
104
|
+
'changeset.attach-evidence': {
|
|
105
|
+
scope: 'changeset.propose',
|
|
106
|
+
fields: ['changesetId', 'kind', 'runId'],
|
|
107
|
+
mutation: true,
|
|
108
|
+
},
|
|
109
|
+
'changeset.decide': {
|
|
110
|
+
scope: 'changeset.approve',
|
|
111
|
+
fields: ['changesetId', 'decision', 'reason'],
|
|
112
|
+
mutation: true,
|
|
113
|
+
},
|
|
114
|
+
'changeset.commit': { scope: 'changeset.commit', fields: ['changesetId'], mutation: true },
|
|
115
|
+
'changeset.get': { scope: 'changeset.read', fields: ['changesetId'], mutation: false },
|
|
116
|
+
'changeset.list': { scope: 'changeset.read', fields: [], mutation: false },
|
|
117
|
+
'release.publish': {
|
|
118
|
+
scope: 'release.publish',
|
|
119
|
+
fields: [
|
|
120
|
+
'releaseVersion',
|
|
121
|
+
'applicationId',
|
|
122
|
+
'applicationVersion',
|
|
123
|
+
'rendererIdentity',
|
|
124
|
+
'componentRegistryIdentity',
|
|
125
|
+
'dataAdapterIdentity',
|
|
126
|
+
'activationBinding',
|
|
127
|
+
],
|
|
128
|
+
mutation: true,
|
|
129
|
+
},
|
|
130
|
+
'release.select': {
|
|
131
|
+
scope: 'release.select',
|
|
132
|
+
fields: ['applicationId', 'releaseVersion'],
|
|
133
|
+
mutation: true,
|
|
134
|
+
},
|
|
135
|
+
'release.rollback': {
|
|
136
|
+
scope: 'release.select',
|
|
137
|
+
fields: ['applicationId', 'targetReleaseVersion'],
|
|
138
|
+
mutation: true,
|
|
139
|
+
},
|
|
140
|
+
'release.get-selected': { scope: 'release.read', fields: ['applicationId'], mutation: false },
|
|
141
|
+
'activation.select': {
|
|
142
|
+
scope: 'activation.select',
|
|
143
|
+
fields: ['graphId', 'activationVersion'],
|
|
144
|
+
mutation: true,
|
|
145
|
+
},
|
|
146
|
+
'run.cancel': { scope: 'run.cancel', fields: ['runId', 'reasonCode'], mutation: true },
|
|
147
|
+
'agent.turn.start': {
|
|
148
|
+
scope: 'agent.turn.start',
|
|
149
|
+
fields: ['threadId', 'input', 'applicationReleaseVersion'],
|
|
150
|
+
mutation: true,
|
|
151
|
+
},
|
|
152
|
+
'agent.turn.cancel': {
|
|
153
|
+
scope: 'agent.turn.cancel',
|
|
154
|
+
fields: ['turnId', 'reasonCode'],
|
|
155
|
+
mutation: true,
|
|
156
|
+
},
|
|
157
|
+
'agent.turn.get': { scope: 'run.read', fields: ['turnId'], mutation: false },
|
|
158
|
+
'agent.tool.approve': {
|
|
159
|
+
scope: 'agent.tool.approve',
|
|
160
|
+
fields: ['approvalId', 'reason', 'decision'],
|
|
161
|
+
mutation: true,
|
|
162
|
+
},
|
|
163
|
+
'agent.tool.decline': {
|
|
164
|
+
scope: 'agent.tool.decline',
|
|
165
|
+
fields: ['approvalId', 'reason', 'decision'],
|
|
166
|
+
mutation: true,
|
|
167
|
+
},
|
|
168
|
+
'stream.inspect': { scope: 'agent.stream.read', fields: ['streamId'], mutation: false },
|
|
169
|
+
'app.data.query': {
|
|
170
|
+
scope: 'app.data.read',
|
|
171
|
+
fields: ['resourceId', 'releaseVersion', 'filters'],
|
|
172
|
+
mutation: false,
|
|
173
|
+
},
|
|
174
|
+
'app.data.mutate': {
|
|
175
|
+
scope: 'app.data.write',
|
|
176
|
+
fields: ['resourceId', 'releaseVersion', 'expectedRevision', 'actionKind'],
|
|
177
|
+
mutation: true,
|
|
178
|
+
},
|
|
179
|
+
'app.data.action': {
|
|
180
|
+
scope: 'app.data.write',
|
|
181
|
+
fields: ['resourceId', 'releaseVersion', 'expectedRevision', 'actionKind'],
|
|
182
|
+
mutation: true,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* True when the command mutates durable state (idempotency-governed).
|
|
187
|
+
* Derived from the ONE registry — the mutation policy can never silently
|
|
188
|
+
* diverge from the command list.
|
|
189
|
+
*/
|
|
190
|
+
export function isMutationCommand(command) {
|
|
191
|
+
const spec = COMMAND_REGISTRY[command];
|
|
192
|
+
return spec?.mutation === true;
|
|
193
|
+
}
|
|
194
|
+
/** Bounded field validation helpers (closed schemas; fail closed). */
|
|
195
|
+
function boundedId(value, field) {
|
|
196
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 128) {
|
|
197
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', `${field} must be a bounded identifier.`);
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
function boundedString(value, field, max) {
|
|
202
|
+
if (typeof value !== 'string' || value.length > max) {
|
|
203
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', `${field} must be a bounded string.`);
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
/** Canonical digest over the EXACT canonical request payload. */
|
|
208
|
+
function requestDigest(canonicalPayload) {
|
|
209
|
+
return createHash('sha256')
|
|
210
|
+
.update(`vict.command@1\u0000${toCanonicalJson(canonicalPayload)}`, 'utf8')
|
|
211
|
+
.digest('hex');
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Canonicalize one command payload into PLAIN data, exactly once, before
|
|
215
|
+
* any digest or execution:
|
|
216
|
+
*
|
|
217
|
+
* - only OWN ENUMERABLE properties cross the boundary (inherited fields
|
|
218
|
+
* and non-enumerable state are dropped);
|
|
219
|
+
* - accessor properties (getters/setters) are REJECTED — reading them
|
|
220
|
+
* would invoke hostile code and could leak or mutate;
|
|
221
|
+
* - property enumeration or reads that THROW (proxies, traps) fail with a
|
|
222
|
+
* stable non-echoing error instead of a raw exception;
|
|
223
|
+
* - nested values must be plain objects, arrays, or JSON scalars.
|
|
224
|
+
*/
|
|
225
|
+
function canonicalPlainPayload(raw, depth = 0) {
|
|
226
|
+
if (depth > 8) {
|
|
227
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', 'The command payload nests too deeply.');
|
|
228
|
+
}
|
|
229
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
230
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', 'The command payload must be a plain object; non-object payloads are never silently converted.');
|
|
231
|
+
}
|
|
232
|
+
let keys;
|
|
233
|
+
try {
|
|
234
|
+
keys = Object.keys(raw);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', 'The command payload could not be enumerated; hostile containers are rejected.');
|
|
238
|
+
}
|
|
239
|
+
const result = {};
|
|
240
|
+
for (const key of keys) {
|
|
241
|
+
let descriptor;
|
|
242
|
+
let value;
|
|
243
|
+
try {
|
|
244
|
+
descriptor = Object.getOwnPropertyDescriptor(raw, key);
|
|
245
|
+
if (descriptor === undefined) {
|
|
246
|
+
throw new Error('own descriptor missing');
|
|
247
|
+
}
|
|
248
|
+
if (descriptor.get !== undefined || descriptor.set !== undefined) {
|
|
249
|
+
throw new Error('accessor property');
|
|
250
|
+
}
|
|
251
|
+
value = descriptor.value;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', 'The command payload declares an accessor or unreadable property; hostile containers are rejected.');
|
|
255
|
+
}
|
|
256
|
+
result[key] = canonicalPlainValue(value, depth);
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
function canonicalPlainValue(value, depth) {
|
|
261
|
+
if (value === null ||
|
|
262
|
+
typeof value === 'string' ||
|
|
263
|
+
typeof value === 'boolean' ||
|
|
264
|
+
typeof value === 'number') {
|
|
265
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
266
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', 'The command payload contains a non-finite number.');
|
|
267
|
+
}
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
if (Array.isArray(value)) {
|
|
271
|
+
const out = [];
|
|
272
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
273
|
+
let item;
|
|
274
|
+
try {
|
|
275
|
+
item = value[index];
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', 'The command payload contains an unreadable array entry.');
|
|
279
|
+
}
|
|
280
|
+
out.push(canonicalPlainValue(item, depth + 1));
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
return canonicalPlainPayload(value, depth + 1);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Capture the COMPLETE command request envelope as closed, VICT-owned
|
|
288
|
+
* plain data BEFORE any individual field is read:
|
|
289
|
+
*
|
|
290
|
+
* - only OWN, ENUMERABLE, STRING-KEYED data properties cross the
|
|
291
|
+
* boundary — accessors (getters/setters), inherited members,
|
|
292
|
+
* non-enumerable fields, and symbol keys are rejected (getters are
|
|
293
|
+
* NEVER invoked — reading `request.command` off a hostile object before
|
|
294
|
+
* validation would execute caller code);
|
|
295
|
+
* - exotic prototypes (class instances, hostile proxies over them) are
|
|
296
|
+
* rejected; enumeration and descriptor reads that THROW (hostile
|
|
297
|
+
* proxies, revoked Proxies, traps) fail with the stable structured
|
|
298
|
+
* error instead of a raw exception;
|
|
299
|
+
* - the top-level field set is CLOSED: exactly `command`, `payload` and
|
|
300
|
+
* `idempotencyKey` as declared by `vict.command@1` — unknown fields
|
|
301
|
+
* fail;
|
|
302
|
+
* - `payload` is captured recursively as plain data (same discipline)
|
|
303
|
+
* and the captured VICT-owned request is the ONLY thing used for
|
|
304
|
+
* authorization, digesting, and execution.
|
|
305
|
+
*/
|
|
306
|
+
const VICT_COMMAND_ENVELOPE_FIELDS = ['command', 'payload', 'idempotencyKey'];
|
|
307
|
+
function captureCommandEnvelope(raw) {
|
|
308
|
+
// `command` and `payload` are always required; `idempotencyKey` is
|
|
309
|
+
// conditionally required (state-changing commands enforce it below).
|
|
310
|
+
const capture = captureClosedRecord(raw, 'command request', VICT_COMMAND_ENVELOPE_FIELDS, [
|
|
311
|
+
'command',
|
|
312
|
+
'payload',
|
|
313
|
+
]);
|
|
314
|
+
return {
|
|
315
|
+
command: capture.command,
|
|
316
|
+
payload: capture.payload,
|
|
317
|
+
idempotencyKey: capture.idempotencyKey,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Capture one closed plain-data record (the shared structural discipline
|
|
322
|
+
* for the direct dispatcher and HTTP boundaries).
|
|
323
|
+
*/
|
|
324
|
+
function captureClosedRecord(raw, field, allowed, required = allowed) {
|
|
325
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
326
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} must be a plain object with the closed vict.command@1 field set.`);
|
|
327
|
+
}
|
|
328
|
+
let prototype;
|
|
329
|
+
try {
|
|
330
|
+
prototype = Object.getPrototypeOf(raw);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
334
|
+
}
|
|
335
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
336
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} must be a plain data record; exotic prototypes are rejected.`);
|
|
337
|
+
}
|
|
338
|
+
let ownKeys;
|
|
339
|
+
try {
|
|
340
|
+
ownKeys = Reflect.ownKeys(raw);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} could not be enumerated; hostile containers are rejected.`);
|
|
344
|
+
}
|
|
345
|
+
const capture = {};
|
|
346
|
+
for (const key of ownKeys) {
|
|
347
|
+
if (typeof key !== 'string') {
|
|
348
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} declares a symbol key; only plain data properties are accepted.`);
|
|
349
|
+
}
|
|
350
|
+
if (!allowed.includes(key)) {
|
|
351
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} declares a field outside the closed vict.command@1 envelope.`);
|
|
352
|
+
}
|
|
353
|
+
let descriptor;
|
|
354
|
+
try {
|
|
355
|
+
descriptor = Object.getOwnPropertyDescriptor(raw, key);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
359
|
+
}
|
|
360
|
+
if (descriptor === undefined ||
|
|
361
|
+
descriptor.get !== undefined ||
|
|
362
|
+
descriptor.set !== undefined ||
|
|
363
|
+
descriptor.enumerable !== true) {
|
|
364
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} declares an accessor, inherited, or non-enumerable member; only own enumerable data properties are accepted.`);
|
|
365
|
+
}
|
|
366
|
+
capture[key] = descriptor.value;
|
|
367
|
+
}
|
|
368
|
+
for (const member of required) {
|
|
369
|
+
if (!(member in capture)) {
|
|
370
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', `The ${field} is missing a required member of the closed envelope.`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return capture;
|
|
374
|
+
}
|
|
375
|
+
/** Deterministic owner token for this service instance (lease holder). */
|
|
376
|
+
let serviceInstanceCounter = 0;
|
|
377
|
+
/**
|
|
378
|
+
* The versioned command dispatcher: transport-free and shared by HTTP and
|
|
379
|
+
* the CLI. Every command re-derives its authorization from the
|
|
380
|
+
* authenticated server context (never from the payload) and mutates only
|
|
381
|
+
* through the durable idempotency policy.
|
|
382
|
+
*/
|
|
383
|
+
export class VictCommandService {
|
|
384
|
+
#options;
|
|
385
|
+
#owner;
|
|
386
|
+
#leaseMs;
|
|
387
|
+
constructor(options) {
|
|
388
|
+
this.#options = options;
|
|
389
|
+
this.#leaseMs = options.idempotencyLeaseMs ?? 60_000;
|
|
390
|
+
serviceInstanceCounter += 1;
|
|
391
|
+
this.#owner = options.idempotencyOwner ?? `svc-${process.pid}-${serviceInstanceCounter}`;
|
|
392
|
+
}
|
|
393
|
+
/** Dispatch one command (closed schemas; durable idempotency; safe errors). */
|
|
394
|
+
async dispatch(actor, request) {
|
|
395
|
+
// ---- Closed envelope capture (BEFORE any field read) ----------------
|
|
396
|
+
// The COMPLETE request envelope is validated and captured as VICT-owned
|
|
397
|
+
// plain data first: a throwing getter on `command` (or any other
|
|
398
|
+
// member) never executes; unknown top-level fields never enter. The
|
|
399
|
+
// captured request is the ONLY thing used below (authorization,
|
|
400
|
+
// digesting, execution) — the caller's object is never retained.
|
|
401
|
+
let captured;
|
|
402
|
+
try {
|
|
403
|
+
captured = captureCommandEnvelope(request);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (error instanceof VictControlError) {
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
throw new VictControlError('VICT_COMMAND_REQUEST_INVALID', 'The command request could not be captured; hostile containers are rejected.');
|
|
410
|
+
}
|
|
411
|
+
const commandName = captured.command;
|
|
412
|
+
if (typeof commandName !== 'string' ||
|
|
413
|
+
!VICT_COMMANDS.includes(commandName)) {
|
|
414
|
+
return { ok: false, code: 'VICT_COMMAND_UNKNOWN' };
|
|
415
|
+
}
|
|
416
|
+
const command = commandName;
|
|
417
|
+
// ---- Canonical plain payload (ONE form for digest AND execution) ----
|
|
418
|
+
// Hostile direct callers (getters, proxies, enumeration traps) fail
|
|
419
|
+
// with the stable structured error, never a raw exception.
|
|
420
|
+
let payload;
|
|
421
|
+
try {
|
|
422
|
+
payload = canonicalPlainPayload(captured.payload);
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
if (error instanceof VictControlError) {
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', 'The command payload could not be canonicalized; hostile containers are rejected.');
|
|
429
|
+
}
|
|
430
|
+
// ---- Closed payload schema (fail closed on unknown fields) ----------
|
|
431
|
+
this.#assertPayloadFields(command, payload);
|
|
432
|
+
// ---- Authorization matrix (BELOW the transport; default deny) -------
|
|
433
|
+
const spec = COMMAND_REGISTRY[command];
|
|
434
|
+
if (spec.scope !== '*') {
|
|
435
|
+
assertCommandScope(actor, spec.scope);
|
|
436
|
+
}
|
|
437
|
+
// ---- Durable idempotency policy for state-changing commands ---------
|
|
438
|
+
if (spec.mutation) {
|
|
439
|
+
return this.#dispatchIdempotent(actor, command, payload, captured.idempotencyKey);
|
|
440
|
+
}
|
|
441
|
+
return this.#execute(actor, command, payload, captured.idempotencyKey);
|
|
442
|
+
}
|
|
443
|
+
/** Validate the payload against the command's closed field set. */
|
|
444
|
+
#assertPayloadFields(command, payload) {
|
|
445
|
+
const allowed = COMMAND_REGISTRY[command].fields;
|
|
446
|
+
for (const key of Object.keys(payload)) {
|
|
447
|
+
if (!allowed.includes(key)) {
|
|
448
|
+
throw new VictControlError('VICT_COMMAND_PAYLOAD_INVALID', `The command payload declares an unknown field for '${command}'.`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The durable idempotency boundary for one state-changing command:
|
|
454
|
+
* namespaced claim (actor + command + key) with a durable lease →
|
|
455
|
+
* execute → settle. Deterministic failures settle `failed` (replayed);
|
|
456
|
+
* retryable infrastructure failures RELEASE the claim. A crashed
|
|
457
|
+
* claimer's expired lease is taken over on retry; the re-execution is
|
|
458
|
+
* FENCED by the domain's own idempotency (turn intents, commit saga
|
|
459
|
+
* receipts, selection operation identities, content-identity guards).
|
|
460
|
+
*/
|
|
461
|
+
async #dispatchIdempotent(actor, command, payload, idempotencyKey) {
|
|
462
|
+
if (typeof idempotencyKey !== 'string' ||
|
|
463
|
+
!COMMAND_IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
|
|
464
|
+
throw new VictControlError('VICT_COMMAND_IDEMPOTENCY_KEY_INVALID', 'A state-changing command requires a bounded Idempotency-Key (letters, digits, ".", "_", ":", "-"; at most 128 characters).');
|
|
465
|
+
}
|
|
466
|
+
const digest = requestDigest(payload);
|
|
467
|
+
const store = this.#options.stores.commandIdempotency;
|
|
468
|
+
const now = this.#options.clock ?? (() => Date.now());
|
|
469
|
+
const namespace = { actorId: actor.actorId, command, idempotencyKey };
|
|
470
|
+
// The settlement fence token of the claim generation THIS execution
|
|
471
|
+
// owns: completion, deterministic failure, and release all present
|
|
472
|
+
// exactly this token. A stale owner's settlement fails with a stable
|
|
473
|
+
// conflict and never mutates the current claim.
|
|
474
|
+
let fenceToken;
|
|
475
|
+
const existing = await store.getReceipt(namespace);
|
|
476
|
+
if (existing !== undefined) {
|
|
477
|
+
// The receipt binds actor + command kind + request digest.
|
|
478
|
+
if (existing.actorId !== actor.actorId ||
|
|
479
|
+
existing.command !== command ||
|
|
480
|
+
existing.requestDigest !== digest) {
|
|
481
|
+
return { ok: false, code: 'VICT_COMMAND_IDEMPOTENCY_CONFLICT' };
|
|
482
|
+
}
|
|
483
|
+
if (existing.status === 'pending') {
|
|
484
|
+
// Crash recovery: an EXPIRED lease may be taken over; a live lease
|
|
485
|
+
// answers the stable in-progress conflict.
|
|
486
|
+
const takeover = await store.takeOverExpiredLease({
|
|
487
|
+
actorId: actor.actorId,
|
|
488
|
+
command,
|
|
489
|
+
idempotencyKey,
|
|
490
|
+
owner: this.#owner,
|
|
491
|
+
leaseUntil: now() + this.#leaseMs,
|
|
492
|
+
at: now(),
|
|
493
|
+
});
|
|
494
|
+
if (takeover.outcome === 'not-expired') {
|
|
495
|
+
return { ok: false, code: 'VICT_COMMAND_IDEMPOTENCY_IN_PROGRESS' };
|
|
496
|
+
}
|
|
497
|
+
// `taken`: fall through to fenced re-execution as the new owner
|
|
498
|
+
// (with the takeover's NEW settlement fence token).
|
|
499
|
+
fenceToken = takeover.outcome === 'taken' ? takeover.fenceToken : undefined;
|
|
500
|
+
}
|
|
501
|
+
else if (existing.status === 'failed') {
|
|
502
|
+
return { ok: false, code: existing.responseCode ?? 'VICT_COMMAND_FAILED' };
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
return this.#replayResult(actor, command, existing);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
// Cross-command key reuse: the same actor reusing ONE Idempotency-Key
|
|
510
|
+
// for a DIFFERENT command is a client bug and a stable conflict — the
|
|
511
|
+
// key is not silently re-namespaced into a second logical request.
|
|
512
|
+
const reused = await store.findReceiptByActorKey({ actorId: actor.actorId, idempotencyKey });
|
|
513
|
+
if (reused !== undefined && reused.command !== command) {
|
|
514
|
+
return { ok: false, code: 'VICT_COMMAND_IDEMPOTENCY_CONFLICT' };
|
|
515
|
+
}
|
|
516
|
+
const claimFenceToken = commandIdempotencyFenceToken({
|
|
517
|
+
actorId: actor.actorId,
|
|
518
|
+
command,
|
|
519
|
+
idempotencyKey,
|
|
520
|
+
owner: this.#owner,
|
|
521
|
+
attempts: 1,
|
|
522
|
+
});
|
|
523
|
+
const claim = await store.claimReceipt({
|
|
524
|
+
idempotencyKey,
|
|
525
|
+
actorId: actor.actorId,
|
|
526
|
+
command,
|
|
527
|
+
requestDigest: digest,
|
|
528
|
+
status: 'pending',
|
|
529
|
+
responseCode: undefined,
|
|
530
|
+
resultJson: undefined,
|
|
531
|
+
createdAt: now(),
|
|
532
|
+
settledAt: undefined,
|
|
533
|
+
owner: this.#owner,
|
|
534
|
+
leaseUntil: now() + this.#leaseMs,
|
|
535
|
+
attempts: 1,
|
|
536
|
+
fenceToken: claimFenceToken,
|
|
537
|
+
});
|
|
538
|
+
if (claim === 'exists') {
|
|
539
|
+
// Lost the concurrent race: re-read for the truthful disposition.
|
|
540
|
+
const raced = await store.getReceipt(namespace);
|
|
541
|
+
if (raced !== undefined &&
|
|
542
|
+
(raced.actorId !== actor.actorId ||
|
|
543
|
+
raced.command !== command ||
|
|
544
|
+
raced.requestDigest !== digest)) {
|
|
545
|
+
return { ok: false, code: 'VICT_COMMAND_IDEMPOTENCY_CONFLICT' };
|
|
546
|
+
}
|
|
547
|
+
if (raced !== undefined && raced.status === 'completed') {
|
|
548
|
+
return this.#replayResult(actor, command, raced);
|
|
549
|
+
}
|
|
550
|
+
if (raced !== undefined && raced.status === 'failed') {
|
|
551
|
+
return { ok: false, code: raced.responseCode ?? 'VICT_COMMAND_FAILED' };
|
|
552
|
+
}
|
|
553
|
+
return { ok: false, code: 'VICT_COMMAND_IDEMPOTENCY_IN_PROGRESS' };
|
|
554
|
+
}
|
|
555
|
+
// The fresh claim's settlement fence token.
|
|
556
|
+
fenceToken = claimFenceToken;
|
|
557
|
+
}
|
|
558
|
+
if (fenceToken === undefined) {
|
|
559
|
+
// Unreachable: every path that reaches execution owns a claim
|
|
560
|
+
// generation. Fail closed rather than settle unfenced.
|
|
561
|
+
throw new VictControlError('VICT_IDEMPOTENCY_FENCE_CONFLICT', 'No settlement fence token was allocated for this command execution.');
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const outcome = await this.#execute(actor, command, payload, idempotencyKey);
|
|
565
|
+
if (outcome.ok) {
|
|
566
|
+
await store.completeReceipt({
|
|
567
|
+
actorId: actor.actorId,
|
|
568
|
+
command,
|
|
569
|
+
idempotencyKey,
|
|
570
|
+
resultJson: JSON.stringify(safeResultProjection(command, outcome.data)),
|
|
571
|
+
at: now(),
|
|
572
|
+
fenceToken,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
await store.failReceipt({
|
|
577
|
+
actorId: actor.actorId,
|
|
578
|
+
command,
|
|
579
|
+
idempotencyKey,
|
|
580
|
+
responseCode: outcome.code,
|
|
581
|
+
at: now(),
|
|
582
|
+
fenceToken,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
return outcome;
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
if (error instanceof VictControlError) {
|
|
589
|
+
// Deterministic command failure: durable, stable, replayed. A
|
|
590
|
+
// fence conflict (a stale owner racing a takeover) is NEVER
|
|
591
|
+
// recorded as this command's disposition — the record belongs to
|
|
592
|
+
// the current claim owner.
|
|
593
|
+
if (error.code !== VICT_IDEMPOTENCY_FENCE_CONFLICT) {
|
|
594
|
+
await store
|
|
595
|
+
.failReceipt({
|
|
596
|
+
actorId: actor.actorId,
|
|
597
|
+
command,
|
|
598
|
+
idempotencyKey,
|
|
599
|
+
responseCode: error.code,
|
|
600
|
+
at: now(),
|
|
601
|
+
fenceToken,
|
|
602
|
+
})
|
|
603
|
+
.catch(() => undefined);
|
|
604
|
+
}
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
// RETRYABLE infrastructure failure: release the claim so a retry can
|
|
608
|
+
// re-execute truthfully — never permanently confused with a
|
|
609
|
+
// deterministic command failure. A fence conflict here means the
|
|
610
|
+
// claim changed owners mid-flight; the release is skipped (never
|
|
611
|
+
// touches the current owner's live claim).
|
|
612
|
+
await store
|
|
613
|
+
.releaseReceipt({ actorId: actor.actorId, command, idempotencyKey, at: now(), fenceToken })
|
|
614
|
+
.catch(() => undefined);
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Reconstruct an authorized replay result from its authoritative domain
|
|
620
|
+
* where possible, using the SAFE projection stored in the receipt. The
|
|
621
|
+
* projection itself never carries payloads; a replayed result that
|
|
622
|
+
* requires the full record is re-derived under the CURRENT actor's
|
|
623
|
+
* authorization (actor-scoped reads — never a data leak).
|
|
624
|
+
*/
|
|
625
|
+
async #replayResult(actor, command, receipt) {
|
|
626
|
+
let projection;
|
|
627
|
+
try {
|
|
628
|
+
projection =
|
|
629
|
+
receipt.resultJson === undefined
|
|
630
|
+
? {}
|
|
631
|
+
: JSON.parse(receipt.resultJson);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
projection = {};
|
|
635
|
+
}
|
|
636
|
+
const changesetId = projection['changesetId'];
|
|
637
|
+
if (typeof changesetId === 'string' &&
|
|
638
|
+
(await this.#authorizedChangeset(actor, changesetId)) !== undefined) {
|
|
639
|
+
const record = await this.#authorizedChangeset(actor, changesetId);
|
|
640
|
+
if (record !== undefined) {
|
|
641
|
+
return { ok: true, data: { changeset: record } };
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const releaseVersion = projection['releaseVersion'];
|
|
645
|
+
if (typeof releaseVersion === 'string' &&
|
|
646
|
+
typeof projection['applicationId'] === 'string' &&
|
|
647
|
+
(command === 'release.select' || command === 'release.rollback')) {
|
|
648
|
+
const release = await this.#options.stores.control.getRelease(releaseVersion);
|
|
649
|
+
if (release !== undefined) {
|
|
650
|
+
return {
|
|
651
|
+
ok: true,
|
|
652
|
+
data: {
|
|
653
|
+
selection: {
|
|
654
|
+
applicationId: projection['applicationId'],
|
|
655
|
+
releaseVersion,
|
|
656
|
+
selectionRevision: projection['selectionRevision'],
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Generic safe-projection replay (identifiers and stable codes only).
|
|
663
|
+
return { ok: true, data: projection };
|
|
664
|
+
}
|
|
665
|
+
/** Actor-scoped ChangeSet read for replay reconstruction. */
|
|
666
|
+
async #authorizedChangeset(actor, changesetId) {
|
|
667
|
+
const get = this.#options.controlPlane.get;
|
|
668
|
+
if (get === undefined) {
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
return (await get.call(this.#options.controlPlane, actor, changesetId));
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return undefined;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/** Execute one command (pre-validated payload; authorization asserted). */
|
|
679
|
+
async #execute(actor, command, payload, idempotencyKey) {
|
|
680
|
+
switch (command) {
|
|
681
|
+
case 'health.inspect':
|
|
682
|
+
return ok({
|
|
683
|
+
healthy: true,
|
|
684
|
+
commandSchema: VICT_COMMAND_SCHEMA,
|
|
685
|
+
turnExecutorComposed: this.#options.turnService !== undefined,
|
|
686
|
+
});
|
|
687
|
+
case 'compatibility.inspect':
|
|
688
|
+
return ok({
|
|
689
|
+
commandSchema: VICT_COMMAND_SCHEMA,
|
|
690
|
+
streamSchema: 'vict.agent-stream@1',
|
|
691
|
+
changesetSchema: 'vict.changeset@1',
|
|
692
|
+
turnSchema: 'vict.agent-turn@1',
|
|
693
|
+
});
|
|
694
|
+
case 'actor.whoami':
|
|
695
|
+
return ok({
|
|
696
|
+
actorId: actor.actorId,
|
|
697
|
+
roles: [...actor.roles],
|
|
698
|
+
scopes: [...actor.scopes],
|
|
699
|
+
mastraResourceId: actor.mastraResourceId,
|
|
700
|
+
});
|
|
701
|
+
case 'changeset.propose': {
|
|
702
|
+
const record = await this.#options.controlPlane.propose(actor, {
|
|
703
|
+
changesetId: boundedId(payload.changesetId, 'changesetId'),
|
|
704
|
+
base: validateBase(payload.base),
|
|
705
|
+
operations: requireArray(payload.operations, 'operations', 1, 64),
|
|
706
|
+
rationale: boundedString(payload.rationale ?? '', 'rationale', 2000),
|
|
707
|
+
riskClass: riskClass(payload.riskClass),
|
|
708
|
+
requiredApproverCount: approverCount(payload.requiredApproverCount),
|
|
709
|
+
expiresAt: boundedTimestamp(payload.expiresAt, 'expiresAt'),
|
|
710
|
+
});
|
|
711
|
+
return ok({ changeset: record });
|
|
712
|
+
}
|
|
713
|
+
case 'changeset.revise':
|
|
714
|
+
return ok({
|
|
715
|
+
changeset: (await this.#options.controlPlane.revise(actor, {
|
|
716
|
+
changesetId: boundedId(payload.changesetId, 'changesetId'),
|
|
717
|
+
operations: requireArray(payload.operations, 'operations', 1, 64),
|
|
718
|
+
rationale: boundedString(payload.rationale ?? '', 'rationale', 2000),
|
|
719
|
+
riskClass: riskClass(payload.riskClass),
|
|
720
|
+
requiredApproverCount: approverCount(payload.requiredApproverCount),
|
|
721
|
+
expiresAt: boundedTimestamp(payload.expiresAt, 'expiresAt'),
|
|
722
|
+
})),
|
|
723
|
+
});
|
|
724
|
+
case 'changeset.execute-check': {
|
|
725
|
+
const kind = payload.kind === 'validation' || payload.kind === 'simulation' ? payload.kind : undefined;
|
|
726
|
+
if (kind === undefined) {
|
|
727
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', "kind must be 'validation' or 'simulation'.");
|
|
728
|
+
}
|
|
729
|
+
const run = await this.#options.controlPlane.executeChangeSetCheck(actor, {
|
|
730
|
+
changesetId: boundedId(payload.changesetId, 'changesetId'),
|
|
731
|
+
kind,
|
|
732
|
+
});
|
|
733
|
+
return ok({ run: run });
|
|
734
|
+
}
|
|
735
|
+
case 'changeset.attach-evidence': {
|
|
736
|
+
if (payload.kind !== 'validation' && payload.kind !== 'simulation') {
|
|
737
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', "kind must be 'validation' or 'simulation'.");
|
|
738
|
+
}
|
|
739
|
+
// The caller supplies ONLY the identity of an EXECUTED run; every
|
|
740
|
+
// evidence field (outcome, timestamps, content hash, base binding,
|
|
741
|
+
// runner profile, actor) is derived server-side from the durable
|
|
742
|
+
// run record. Fabricated evidence cannot pass this boundary.
|
|
743
|
+
const attach = payload.kind === 'validation'
|
|
744
|
+
? this.#options.controlPlane.attachValidationEvidence
|
|
745
|
+
: this.#options.controlPlane.attachSimulationEvidence;
|
|
746
|
+
return ok({
|
|
747
|
+
changeset: (await attach.call(this.#options.controlPlane, actor, {
|
|
748
|
+
changesetId: boundedId(payload.changesetId, 'changesetId'),
|
|
749
|
+
runId: boundedId(payload.runId, 'runId'),
|
|
750
|
+
})),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
case 'changeset.decide':
|
|
754
|
+
return ok({
|
|
755
|
+
result: (await this.#options.controlPlane.decide(actor, {
|
|
756
|
+
changesetId: boundedId(payload.changesetId, 'changesetId'),
|
|
757
|
+
decision: decisionOf(payload.decision),
|
|
758
|
+
})),
|
|
759
|
+
});
|
|
760
|
+
case 'changeset.commit':
|
|
761
|
+
return ok({
|
|
762
|
+
result: (await this.#options.controlPlane.commit(actor, {
|
|
763
|
+
changesetId: boundedId(payload.changesetId, 'changesetId'),
|
|
764
|
+
})),
|
|
765
|
+
});
|
|
766
|
+
case 'changeset.get': {
|
|
767
|
+
const record = await this.#options.controlPlane.get(actor, boundedId(payload.changesetId, 'changesetId'));
|
|
768
|
+
if (record === undefined) {
|
|
769
|
+
return { ok: false, code: 'VICT_CONTROL_CHANGESET_MISSING' };
|
|
770
|
+
}
|
|
771
|
+
return ok({ changeset: record });
|
|
772
|
+
}
|
|
773
|
+
case 'changeset.list':
|
|
774
|
+
return ok({ changesets: await this.#options.controlPlane.list(actor) });
|
|
775
|
+
case 'release.publish':
|
|
776
|
+
return ok({
|
|
777
|
+
release: (await this.#options.controlPlane.publishRelease(actor, {
|
|
778
|
+
releaseVersion: boundedId(payload.releaseVersion, 'releaseVersion'),
|
|
779
|
+
applicationId: boundedId(payload.applicationId, 'applicationId'),
|
|
780
|
+
applicationVersion: boundedId(payload.applicationVersion, 'applicationVersion'),
|
|
781
|
+
rendererIdentity: boundedId(payload.rendererIdentity, 'rendererIdentity'),
|
|
782
|
+
componentRegistryIdentity: boundedId(payload.componentRegistryIdentity, 'componentRegistryIdentity'),
|
|
783
|
+
dataAdapterIdentity: boundedId(payload.dataAdapterIdentity, 'dataAdapterIdentity'),
|
|
784
|
+
activationBinding: boundedId(payload.activationBinding, 'activationBinding'),
|
|
785
|
+
})),
|
|
786
|
+
});
|
|
787
|
+
case 'release.select':
|
|
788
|
+
return ok({
|
|
789
|
+
selection: (await this.#options.controlPlane.selectRelease(actor, {
|
|
790
|
+
applicationId: boundedId(payload.applicationId, 'applicationId'),
|
|
791
|
+
releaseVersion: boundedId(payload.releaseVersion, 'releaseVersion'),
|
|
792
|
+
})),
|
|
793
|
+
});
|
|
794
|
+
case 'release.rollback':
|
|
795
|
+
return ok({
|
|
796
|
+
selection: (await this.#options.controlPlane.rollbackRelease(actor, {
|
|
797
|
+
applicationId: boundedId(payload.applicationId, 'applicationId'),
|
|
798
|
+
targetReleaseVersion: boundedId(payload.targetReleaseVersion, 'targetReleaseVersion'),
|
|
799
|
+
})),
|
|
800
|
+
});
|
|
801
|
+
case 'release.get-selected': {
|
|
802
|
+
const release = await this.#options.controlPlane.getSelectedRelease(actor, boundedId(payload.applicationId, 'applicationId'));
|
|
803
|
+
if (release === undefined) {
|
|
804
|
+
return { ok: false, code: 'VICT_CONTROL_RELEASE_MISSING' };
|
|
805
|
+
}
|
|
806
|
+
return ok({ release: release });
|
|
807
|
+
}
|
|
808
|
+
case 'activation.select': {
|
|
809
|
+
// Direct activation selection (operator path): the authenticated
|
|
810
|
+
// actor is asserted (activation.select scope) upstream and is the
|
|
811
|
+
// ATTRIBUTED identity — never a synthetic "system" actor.
|
|
812
|
+
const graphId = boundedId(payload.graphId, 'graphId');
|
|
813
|
+
const activationVersion = boundedId(payload.activationVersion, 'activationVersion');
|
|
814
|
+
const selection = await this.#options.controlPlane.selectActivation(actor, {
|
|
815
|
+
graphId,
|
|
816
|
+
activationVersion,
|
|
817
|
+
});
|
|
818
|
+
return ok({ selection: selection });
|
|
819
|
+
}
|
|
820
|
+
case 'run.cancel':
|
|
821
|
+
return ok(await this.#cancelRun(actor, payload, idempotencyKey));
|
|
822
|
+
case 'agent.turn.start': {
|
|
823
|
+
const turnService = requireTurnService(this.#options.turnService);
|
|
824
|
+
const result = (await turnService.startTurn(actor, {
|
|
825
|
+
threadId: boundedId(payload.threadId, 'threadId'),
|
|
826
|
+
input: boundedString(payload.input, 'input', 8000),
|
|
827
|
+
}));
|
|
828
|
+
return ok({ turnId: result.turn.turnId, streamId: result.turn.streamId });
|
|
829
|
+
}
|
|
830
|
+
case 'agent.turn.cancel':
|
|
831
|
+
return ok({
|
|
832
|
+
result: (await requireTurnService(this.#options.turnService).cancelTurn(actor, {
|
|
833
|
+
turnId: boundedId(payload.turnId, 'turnId'),
|
|
834
|
+
...(typeof payload.reasonCode === 'string' ? { reasonCode: payload.reasonCode } : {}),
|
|
835
|
+
})),
|
|
836
|
+
});
|
|
837
|
+
case 'agent.turn.get':
|
|
838
|
+
return ok({
|
|
839
|
+
turn: (await requireTurnService(this.#options.turnService).getTurn(actor, boundedId(payload.turnId, 'turnId'))),
|
|
840
|
+
});
|
|
841
|
+
case 'agent.tool.approve':
|
|
842
|
+
case 'agent.tool.decline': {
|
|
843
|
+
const decision = command === 'agent.tool.approve' ? 'approved' : 'declined';
|
|
844
|
+
return ok({
|
|
845
|
+
approval: (await requireTurnService(this.#options.turnService).decideToolApproval(actor, {
|
|
846
|
+
approvalId: boundedId(payload.approvalId, 'approvalId'),
|
|
847
|
+
decision,
|
|
848
|
+
...(typeof payload.reason === 'string' && payload.reason.length > 0
|
|
849
|
+
? { reason: boundedString(payload.reason, 'reason', 200) }
|
|
850
|
+
: {}),
|
|
851
|
+
})),
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
case 'stream.inspect': {
|
|
855
|
+
// ACTOR-SCOPED stream inspection: identifiers of other actors are
|
|
856
|
+
// never disclosed; privileged `operator.resolve` holders see all.
|
|
857
|
+
const streamId = boundedId(payload.streamId, 'streamId');
|
|
858
|
+
const ledger = this.#options.stores.streamLedger;
|
|
859
|
+
const privileged = actor.scopes.includes('operator.resolve');
|
|
860
|
+
const turnRows = await this.#options.stores.turns.listTurns();
|
|
861
|
+
const ownStreamIds = new Set(turnRows
|
|
862
|
+
.filter((turn) => privileged || turn.actorId === actor.actorId)
|
|
863
|
+
.map((turn) => turn.streamId));
|
|
864
|
+
const owned = ownStreamIds.has(streamId);
|
|
865
|
+
if (!owned) {
|
|
866
|
+
// Existence is not disclosed across the actor boundary.
|
|
867
|
+
return { ok: false, code: 'VICT_STREAM_ACTOR_MISMATCH' };
|
|
868
|
+
}
|
|
869
|
+
return ok({
|
|
870
|
+
streamId,
|
|
871
|
+
latestSeq: await ledger.latestSeq(streamId),
|
|
872
|
+
streams: [...ownStreamIds].sort(),
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
case 'app.data.query':
|
|
876
|
+
return ok({ result: await requireAppData(this.#options.appData).query(actor, payload) });
|
|
877
|
+
case 'app.data.mutate':
|
|
878
|
+
return ok({ result: await requireAppData(this.#options.appData).mutate(actor, payload) });
|
|
879
|
+
case 'app.data.action':
|
|
880
|
+
return ok({ result: await requireAppData(this.#options.appData).mutate(actor, payload) });
|
|
881
|
+
default:
|
|
882
|
+
return { ok: false, code: 'VICT_COMMAND_UNKNOWN' };
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/** Durable, actor-authorized run cancellation over the composed store. */
|
|
886
|
+
async #cancelRun(actor, payload, idempotencyKey) {
|
|
887
|
+
const cancelRun = this.#options.controlPlane.cancelRun;
|
|
888
|
+
if (cancelRun === undefined) {
|
|
889
|
+
throw new VictControlError('VICT_RUN_STORE_UNAVAILABLE', 'No orchestration store is composed in this deployment; run cancellation is unavailable.');
|
|
890
|
+
}
|
|
891
|
+
return (await cancelRun({
|
|
892
|
+
runId: boundedId(payload.runId, 'runId'),
|
|
893
|
+
actorId: actor.actorId,
|
|
894
|
+
requestId: boundedId(typeof payload.reasonCode === 'string' &&
|
|
895
|
+
payload.reasonCode.length > 0 &&
|
|
896
|
+
idempotencyKey === undefined
|
|
897
|
+
? payload.reasonCode
|
|
898
|
+
: idempotencyKey, 'requestId'),
|
|
899
|
+
reasonCode: typeof payload.reasonCode === 'string' ? payload.reasonCode : 'operator',
|
|
900
|
+
}));
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* The SAFE per-command replay projection stored in the durable receipt.
|
|
905
|
+
* Only stable codes, safe identifiers, and content references are
|
|
906
|
+
* retained — NEVER full command responses, rationale text, application
|
|
907
|
+
* rows, model content, tool data, or unrestricted payloads. An authorized
|
|
908
|
+
* replay that needs the full record re-derives it from the authoritative
|
|
909
|
+
* domain under the current actor's authorization.
|
|
910
|
+
*/
|
|
911
|
+
function safeResultProjection(command, data) {
|
|
912
|
+
const pick = (source, fields) => {
|
|
913
|
+
const out = {};
|
|
914
|
+
for (const field of fields) {
|
|
915
|
+
const value = source[field];
|
|
916
|
+
if (value !== undefined &&
|
|
917
|
+
(typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) {
|
|
918
|
+
out[field] = value;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return out;
|
|
922
|
+
};
|
|
923
|
+
switch (command) {
|
|
924
|
+
case 'changeset.propose':
|
|
925
|
+
case 'changeset.revise': {
|
|
926
|
+
const changeset = (data['changeset'] ?? {});
|
|
927
|
+
return {
|
|
928
|
+
command,
|
|
929
|
+
...pick(changeset, ['changesetId', 'contentHash', 'status']),
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
case 'changeset.execute-check': {
|
|
933
|
+
const run = (data['run'] ?? {});
|
|
934
|
+
return { command, ...pick(run, ['runId', 'kind', 'outcome']) };
|
|
935
|
+
}
|
|
936
|
+
case 'changeset.attach-evidence': {
|
|
937
|
+
const changeset = (data['changeset'] ?? {});
|
|
938
|
+
const validation = (changeset['validation'] ?? undefined);
|
|
939
|
+
const simulation = (changeset['simulation'] ?? undefined);
|
|
940
|
+
return {
|
|
941
|
+
command,
|
|
942
|
+
changesetId: changeset['changesetId'],
|
|
943
|
+
...(validation !== undefined && typeof validation === 'object'
|
|
944
|
+
? { validationOutcome: validation['outcome'] }
|
|
945
|
+
: {}),
|
|
946
|
+
...(simulation !== undefined && typeof simulation === 'object'
|
|
947
|
+
? { simulationOutcome: simulation['outcome'] }
|
|
948
|
+
: {}),
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
case 'changeset.decide': {
|
|
952
|
+
const result = (data['result'] ?? {});
|
|
953
|
+
const decision = (result['decision'] ?? undefined);
|
|
954
|
+
return {
|
|
955
|
+
command,
|
|
956
|
+
changesetId: result['changesetId'],
|
|
957
|
+
...(decision !== undefined && typeof decision === 'object'
|
|
958
|
+
? pick(decision, ['decision'])
|
|
959
|
+
: {}),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
case 'changeset.commit': {
|
|
963
|
+
const result = (data['result'] ?? {});
|
|
964
|
+
const record = (result['record'] ?? {});
|
|
965
|
+
const applied = Array.isArray(result['applied'])
|
|
966
|
+
? result['applied'].length
|
|
967
|
+
: undefined;
|
|
968
|
+
return {
|
|
969
|
+
command,
|
|
970
|
+
changesetId: record['changesetId'],
|
|
971
|
+
status: record['status'],
|
|
972
|
+
...(applied !== undefined ? { appliedCount: applied } : {}),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
case 'release.publish': {
|
|
976
|
+
const release = (data['release'] ?? {});
|
|
977
|
+
return { command, ...pick(release, ['releaseVersion', 'applicationId', 'contentHash']) };
|
|
978
|
+
}
|
|
979
|
+
case 'release.select':
|
|
980
|
+
case 'release.rollback': {
|
|
981
|
+
const selection = (data['selection'] ?? {});
|
|
982
|
+
return {
|
|
983
|
+
command,
|
|
984
|
+
...pick(selection, ['applicationId', 'releaseVersion', 'selectionRevision']),
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
case 'activation.select': {
|
|
988
|
+
const selection = (data['selection'] ?? {});
|
|
989
|
+
return { command, ...pick(selection, ['graphId', 'activationVersion', 'selectionRevision']) };
|
|
990
|
+
}
|
|
991
|
+
case 'run.cancel': {
|
|
992
|
+
return { command, ...pick(data, ['runId', 'status']) };
|
|
993
|
+
}
|
|
994
|
+
case 'agent.turn.start':
|
|
995
|
+
return { command, ...pick(data, ['turnId', 'streamId']) };
|
|
996
|
+
case 'agent.turn.cancel': {
|
|
997
|
+
const result = (data['result'] ?? {});
|
|
998
|
+
return { command, ...pick(result, ['turnId', 'status']) };
|
|
999
|
+
}
|
|
1000
|
+
case 'agent.tool.approve':
|
|
1001
|
+
case 'agent.tool.decline': {
|
|
1002
|
+
const approval = (data['approval'] ?? {});
|
|
1003
|
+
return { command, ...pick(approval, ['approvalId', 'status']) };
|
|
1004
|
+
}
|
|
1005
|
+
case 'app.data.mutate':
|
|
1006
|
+
case 'app.data.action':
|
|
1007
|
+
// Application mutation results live in the AUTHORITATIVE application
|
|
1008
|
+
// domain: only the requested identities are referenced here.
|
|
1009
|
+
return {
|
|
1010
|
+
command,
|
|
1011
|
+
...pick(data, ['resourceId', 'releaseVersion']),
|
|
1012
|
+
};
|
|
1013
|
+
default:
|
|
1014
|
+
return { command };
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/** Enforce one matrix scope (fail closed; stable non-echoing denial). */
|
|
1018
|
+
function assertCommandScope(actor, scope) {
|
|
1019
|
+
if (!actor.scopes.includes(scope)) {
|
|
1020
|
+
throw new VictControlError('VICT_ACTOR_SCOPE_DENIED', `the required scope is not held.`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/** Dispatch-level health of the composed turn service. */
|
|
1024
|
+
function requireTurnService(turnService) {
|
|
1025
|
+
if (turnService === undefined) {
|
|
1026
|
+
throw new VictControlError('VICT_TURN_EXECUTOR_UNAVAILABLE', 'No agent-turn service is composed in this deployment; turn commands are unavailable.');
|
|
1027
|
+
}
|
|
1028
|
+
return turnService;
|
|
1029
|
+
}
|
|
1030
|
+
function requireAppData(appData) {
|
|
1031
|
+
if (appData === undefined) {
|
|
1032
|
+
throw new VictControlError('VICT_APPDATA_UNAVAILABLE', 'No Application data adapter is composed in this deployment.');
|
|
1033
|
+
}
|
|
1034
|
+
return appData;
|
|
1035
|
+
}
|
|
1036
|
+
// ---- Closed payload validation helpers -------------------------------------
|
|
1037
|
+
function validateBase(base) {
|
|
1038
|
+
if (typeof base !== 'object' || base === null) {
|
|
1039
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', 'base must be an object.');
|
|
1040
|
+
}
|
|
1041
|
+
const candidate = base;
|
|
1042
|
+
if (candidate.kind !== 'activation' && candidate.kind !== 'release') {
|
|
1043
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', 'base.kind must be activation or release.');
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
kind: candidate.kind,
|
|
1047
|
+
subjectId: boundedId(candidate.subjectId, 'base.subjectId'),
|
|
1048
|
+
expectedVersion: boundedId(candidate.expectedVersion, 'base.expectedVersion'),
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
function requireArray(value, field, min, max) {
|
|
1052
|
+
if (!Array.isArray(value) || value.length < min || value.length > max) {
|
|
1053
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', `${field} must be an array of ${min}..${max} items.`);
|
|
1054
|
+
}
|
|
1055
|
+
return value;
|
|
1056
|
+
}
|
|
1057
|
+
function riskClass(value) {
|
|
1058
|
+
if (value !== 'low' && value !== 'medium' && value !== 'high') {
|
|
1059
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', 'riskClass must be low, medium, or high.');
|
|
1060
|
+
}
|
|
1061
|
+
return value;
|
|
1062
|
+
}
|
|
1063
|
+
function approverCount(value) {
|
|
1064
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > 8) {
|
|
1065
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', 'requiredApproverCount must be 1..8.');
|
|
1066
|
+
}
|
|
1067
|
+
return value;
|
|
1068
|
+
}
|
|
1069
|
+
function boundedTimestamp(value, field) {
|
|
1070
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
1071
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', `${field} must be an epoch-ms integer.`);
|
|
1072
|
+
}
|
|
1073
|
+
return value;
|
|
1074
|
+
}
|
|
1075
|
+
function decisionOf(value) {
|
|
1076
|
+
if (value !== 'approved' && value !== 'declined') {
|
|
1077
|
+
throw new VictControlError('VICT_COMMAND_FIELD_INVALID', 'decision must be approved or declined.');
|
|
1078
|
+
}
|
|
1079
|
+
return value;
|
|
1080
|
+
}
|
|
1081
|
+
function ok(data) {
|
|
1082
|
+
return { ok: true, data };
|
|
1083
|
+
}
|
|
1084
|
+
//# sourceMappingURL=commands.js.map
|