@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.
@@ -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