@victframework/runtime 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/agent-governance.d.ts +285 -0
- package/dist/agent-governance.js +438 -0
- package/dist/agent-governance.js.map +1 -0
- package/dist/agent-registry.d.ts +103 -0
- package/dist/agent-registry.js +758 -0
- package/dist/agent-registry.js.map +1 -0
- package/dist/agent-types.d.ts +433 -0
- package/dist/agent-types.js +480 -0
- package/dist/agent-types.js.map +1 -0
- package/dist/authority.d.ts +83 -0
- package/dist/authority.js +170 -0
- package/dist/authority.js.map +1 -0
- package/dist/boundary-conformance.d.ts +51 -0
- package/dist/boundary-conformance.js +364 -0
- package/dist/boundary-conformance.js.map +1 -0
- package/dist/control-conformance.d.ts +68 -0
- package/dist/control-conformance.js +1131 -0
- package/dist/control-conformance.js.map +1 -0
- package/dist/control-in-memory.d.ts +257 -0
- package/dist/control-in-memory.js +886 -0
- package/dist/control-in-memory.js.map +1 -0
- package/dist/control-types.d.ts +1017 -0
- package/dist/control-types.js +779 -0
- package/dist/control-types.js.map +1 -0
- package/dist/durable-run.d.ts +73 -0
- package/dist/durable-run.js +262 -0
- package/dist/durable-run.js.map +1 -0
- package/dist/effect-policy.d.ts +22 -0
- package/dist/effect-policy.js +45 -0
- package/dist/effect-policy.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +71 -0
- package/dist/errors.js.map +1 -0
- package/dist/in-memory-stores.d.ts +16 -0
- package/dist/in-memory-stores.js +522 -0
- package/dist/in-memory-stores.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/operator-config.d.ts +141 -0
- package/dist/operator-config.js +289 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/orchestration-activation.d.ts +116 -0
- package/dist/orchestration-activation.js +223 -0
- package/dist/orchestration-activation.js.map +1 -0
- package/dist/orchestration-canary-conformance.d.ts +10 -0
- package/dist/orchestration-canary-conformance.js +726 -0
- package/dist/orchestration-canary-conformance.js.map +1 -0
- package/dist/orchestration-commands.d.ts +47 -0
- package/dist/orchestration-commands.js +538 -0
- package/dist/orchestration-commands.js.map +1 -0
- package/dist/orchestration-conformance.d.ts +59 -0
- package/dist/orchestration-conformance.js +594 -0
- package/dist/orchestration-conformance.js.map +1 -0
- package/dist/orchestration-driver-types.d.ts +163 -0
- package/dist/orchestration-driver-types.js +21 -0
- package/dist/orchestration-driver-types.js.map +1 -0
- package/dist/orchestration-driver.d.ts +48 -0
- package/dist/orchestration-driver.js +1030 -0
- package/dist/orchestration-driver.js.map +1 -0
- package/dist/orchestration-in-memory.d.ts +18 -0
- package/dist/orchestration-in-memory.js +1313 -0
- package/dist/orchestration-in-memory.js.map +1 -0
- package/dist/orchestration-join-conformance.d.ts +20 -0
- package/dist/orchestration-join-conformance.js +542 -0
- package/dist/orchestration-join-conformance.js.map +1 -0
- package/dist/orchestration-plan.d.ts +52 -0
- package/dist/orchestration-plan.js +265 -0
- package/dist/orchestration-plan.js.map +1 -0
- package/dist/orchestration-race-conformance.d.ts +35 -0
- package/dist/orchestration-race-conformance.js +1265 -0
- package/dist/orchestration-race-conformance.js.map +1 -0
- package/dist/orchestration-remediation-conformance.d.ts +54 -0
- package/dist/orchestration-remediation-conformance.js +632 -0
- package/dist/orchestration-remediation-conformance.js.map +1 -0
- package/dist/orchestration-store-types.d.ts +568 -0
- package/dist/orchestration-store-types.js +2 -0
- package/dist/orchestration-store-types.js.map +1 -0
- package/dist/pack-conformance.d.ts +59 -0
- package/dist/pack-conformance.js +222 -0
- package/dist/pack-conformance.js.map +1 -0
- package/dist/pack-install.d.ts +44 -0
- package/dist/pack-install.js +126 -0
- package/dist/pack-install.js.map +1 -0
- package/dist/registry.d.ts +107 -0
- package/dist/registry.js +481 -0
- package/dist/registry.js.map +1 -0
- package/dist/runtime.d.ts +158 -0
- package/dist/runtime.js +1341 -0
- package/dist/runtime.js.map +1 -0
- package/dist/serialization.d.ts +26 -0
- package/dist/serialization.js +162 -0
- package/dist/serialization.js.map +1 -0
- package/dist/store-conformance.d.ts +45 -0
- package/dist/store-conformance.js +876 -0
- package/dist/store-conformance.js.map +1 -0
- package/dist/store-errors.d.ts +49 -0
- package/dist/store-errors.js +48 -0
- package/dist/store-errors.js.map +1 -0
- package/dist/store-types.d.ts +331 -0
- package/dist/store-types.js +14 -0
- package/dist/store-types.js.map +1 -0
- package/dist/store-validation.d.ts +63 -0
- package/dist/store-validation.js +164 -0
- package/dist/store-validation.js.map +1 -0
- package/dist/stream-hub.d.ts +151 -0
- package/dist/stream-hub.js +402 -0
- package/dist/stream-hub.js.map +1 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +19 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { validateAgentStreamEvent, } from '@victframework/contracts';
|
|
3
|
+
import { toCanonicalJson } from './serialization.js';
|
|
4
|
+
/**
|
|
5
|
+
* Stage 06B — neutral control-plane and governed-remote-execution ports.
|
|
6
|
+
*
|
|
7
|
+
* This module defines the durable records, closed state machines, and store
|
|
8
|
+
* ports for:
|
|
9
|
+
*
|
|
10
|
+
* - authenticated actors, roles, and scopes (SEC-001/SEC-002; default deny);
|
|
11
|
+
* - ChangeSets with immutable content identity, evidence, approvals,
|
|
12
|
+
* expiry, and forward-only status (CTRL-001..CTRL-007);
|
|
13
|
+
* - Application Release publish/select/rollback over immutable versions;
|
|
14
|
+
* - audit events for every attributable transition;
|
|
15
|
+
* - durable agent turns, tool invocations (durable-before-invocation), and
|
|
16
|
+
* VICT-authoritative approval records (AI-006/AI-007/MSTR-005);
|
|
17
|
+
* - the durable agent-stream ledger (per-stream monotonic sequences,
|
|
18
|
+
* durable milestones, transient deltas — AI-009, `vict.agent-stream@1`).
|
|
19
|
+
*
|
|
20
|
+
* In-memory reference implementations live in `control-in-memory.ts`; the
|
|
21
|
+
* SQLite adapter implements the same ports in `@victframework/store-sqlite` and both
|
|
22
|
+
* pass the shared conformance suite (`control-conformance.ts`).
|
|
23
|
+
*
|
|
24
|
+
* These are NEUTRAL ports: no Mastra, HTTP, transport, or provider type
|
|
25
|
+
* appears here.
|
|
26
|
+
*/
|
|
27
|
+
// ---- Identifier and hashing primitives -------------------------------------
|
|
28
|
+
/** Bounded namespace identifier for control-plane and agent records. */
|
|
29
|
+
export const CONTROL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/;
|
|
30
|
+
/** Validate one bounded namespace identifier (throws a stable error). */
|
|
31
|
+
export function assertControlId(value, field) {
|
|
32
|
+
if (typeof value !== 'string' || !CONTROL_ID_PATTERN.test(value)) {
|
|
33
|
+
throw new VictControlError('VICT_CONTROL_ID_INVALID', `${field} must be a bounded namespace identifier (at most 128 characters; letters, digits, '.', '_', ':', '@', '-').`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Stable bounded safe-integer validation. */
|
|
37
|
+
export function assertControlTimestamp(value, field) {
|
|
38
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
39
|
+
throw new VictControlError('VICT_CONTROL_TIMESTAMP_INVALID', `${field} must be a finite epoch-ms integer.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Stable content identity over strict canonical data. The canonicalizer is
|
|
44
|
+
* the Stage 01 verified canonical JSON; identity MUST NOT be derived from
|
|
45
|
+
* function text, timestamps of capture, or mutable objects.
|
|
46
|
+
*/
|
|
47
|
+
export function controlContentHash(payload) {
|
|
48
|
+
// A versioned prefix keeps content identities domain-scoped.
|
|
49
|
+
return `v1_${sha256Hex(`vict.control-content@1\u0000${toCanonicalJson(payload)}`)}`;
|
|
50
|
+
}
|
|
51
|
+
/** Bounded safe string field (rationales, summaries, reason codes). */
|
|
52
|
+
export function assertBoundedString(value, field, max, allowEmpty = false) {
|
|
53
|
+
if (typeof value !== 'string' ||
|
|
54
|
+
(value.length === 0 && !allowEmpty) ||
|
|
55
|
+
value.length > max ||
|
|
56
|
+
// Control characters and other unsafe content are rejected; the value
|
|
57
|
+
// itself is never echoed into diagnostics.
|
|
58
|
+
// eslint-disable-next-line no-control-regex
|
|
59
|
+
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value)) {
|
|
60
|
+
throw new Error(`VICT_CONTROL_FIELD_INVALID: ${field} must be a bounded string of at most ${max} characters without control characters.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function sha256Hex(payload) {
|
|
64
|
+
return createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
65
|
+
}
|
|
66
|
+
// ---- Actors, roles, and scopes ---------------------------------------------
|
|
67
|
+
/** The closed reference role vocabulary (system reference §14.2). */
|
|
68
|
+
export const ACTOR_ROLES = [
|
|
69
|
+
'viewer',
|
|
70
|
+
'developer',
|
|
71
|
+
'operator',
|
|
72
|
+
'approver',
|
|
73
|
+
'administrator',
|
|
74
|
+
];
|
|
75
|
+
/** The closed scope vocabulary enforced below the HTTP/CLI/UI layers. */
|
|
76
|
+
export const ACTOR_SCOPES = [
|
|
77
|
+
'changeset.propose',
|
|
78
|
+
'changeset.revise',
|
|
79
|
+
'changeset.approve',
|
|
80
|
+
'changeset.commit',
|
|
81
|
+
'changeset.read',
|
|
82
|
+
'release.publish',
|
|
83
|
+
'release.select',
|
|
84
|
+
'release.read',
|
|
85
|
+
'activation.select',
|
|
86
|
+
'activation.read',
|
|
87
|
+
'run.read',
|
|
88
|
+
'run.cancel',
|
|
89
|
+
'operator.resolve',
|
|
90
|
+
'agent.turn.start',
|
|
91
|
+
'agent.turn.cancel',
|
|
92
|
+
'agent.tool.approve',
|
|
93
|
+
'agent.tool.decline',
|
|
94
|
+
'agent.stream.read',
|
|
95
|
+
'app.data.read',
|
|
96
|
+
'app.data.write',
|
|
97
|
+
'conversation.read',
|
|
98
|
+
'conversation.delete',
|
|
99
|
+
'audit.read',
|
|
100
|
+
];
|
|
101
|
+
/** Deterministic role → scope policy (default policy is denial). */
|
|
102
|
+
export const ROLE_SCOPES = {
|
|
103
|
+
viewer: ['changeset.read', 'release.read', 'activation.read', 'run.read', 'audit.read'],
|
|
104
|
+
developer: [
|
|
105
|
+
'changeset.read',
|
|
106
|
+
'changeset.propose',
|
|
107
|
+
'changeset.revise',
|
|
108
|
+
'release.read',
|
|
109
|
+
'activation.read',
|
|
110
|
+
'run.read',
|
|
111
|
+
'agent.turn.start',
|
|
112
|
+
'agent.turn.cancel',
|
|
113
|
+
'agent.stream.read',
|
|
114
|
+
'app.data.read',
|
|
115
|
+
'audit.read',
|
|
116
|
+
],
|
|
117
|
+
operator: [
|
|
118
|
+
'changeset.read',
|
|
119
|
+
'release.read',
|
|
120
|
+
'release.select',
|
|
121
|
+
'activation.read',
|
|
122
|
+
'activation.select',
|
|
123
|
+
'run.read',
|
|
124
|
+
'run.cancel',
|
|
125
|
+
'operator.resolve',
|
|
126
|
+
'agent.turn.cancel',
|
|
127
|
+
'agent.stream.read',
|
|
128
|
+
'app.data.read',
|
|
129
|
+
'app.data.write',
|
|
130
|
+
'conversation.read',
|
|
131
|
+
'conversation.delete',
|
|
132
|
+
'audit.read',
|
|
133
|
+
],
|
|
134
|
+
approver: [
|
|
135
|
+
'changeset.read',
|
|
136
|
+
'changeset.approve',
|
|
137
|
+
'agent.tool.approve',
|
|
138
|
+
'agent.tool.decline',
|
|
139
|
+
'release.read',
|
|
140
|
+
'activation.read',
|
|
141
|
+
'run.read',
|
|
142
|
+
'audit.read',
|
|
143
|
+
],
|
|
144
|
+
administrator: [...ACTOR_SCOPES],
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Derive the permitted scopes of one actor record. Unknown, disabled, and
|
|
148
|
+
* malformed actors fail closed: no scopes are derived. Roles outside the
|
|
149
|
+
* closed vocabulary are ignored (fail closed, never widened).
|
|
150
|
+
*/
|
|
151
|
+
export function authoritativeScopes(actor) {
|
|
152
|
+
if (actor === undefined || actor.status !== 'active') {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const scopes = new Set();
|
|
156
|
+
for (const role of actor.roles) {
|
|
157
|
+
if (!ACTOR_ROLES.includes(role)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const scope of ROLE_SCOPES[role]) {
|
|
161
|
+
scopes.add(scope);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return [...scopes].sort();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Derive the authoritative server context from a resolved actor record.
|
|
168
|
+
* Unknown, disabled, malformed, or mismatched actors fail closed.
|
|
169
|
+
*/
|
|
170
|
+
export function authenticatedActorContext(actor, requestedActorId) {
|
|
171
|
+
// Mismatch between the authenticated identity and any requested actor id
|
|
172
|
+
// is denied: the authenticated record is the only authority.
|
|
173
|
+
if (actor === undefined || actor.status !== 'active' || actor.actorId !== requestedActorId) {
|
|
174
|
+
throw new VictControlError('VICT_ACTOR_UNAUTHENTICATED', 'VICT_ACTOR_UNAUTHENTICATED: the actor could not be authenticated.');
|
|
175
|
+
}
|
|
176
|
+
assertControlId(actor.actorId, 'actorId');
|
|
177
|
+
return {
|
|
178
|
+
actorId: actor.actorId,
|
|
179
|
+
roles: [...actor.roles],
|
|
180
|
+
scopes: authoritativeScopes(actor),
|
|
181
|
+
mastraResourceId: `vict-actor-${actor.actorId}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Stable authorization denial (never echoes the attempted operation's payload). */
|
|
185
|
+
export class ActorScopeDeniedError extends Error {
|
|
186
|
+
code = 'VICT_ACTOR_SCOPE_DENIED';
|
|
187
|
+
scope;
|
|
188
|
+
constructor(scope) {
|
|
189
|
+
super(`VICT_ACTOR_SCOPE_DENIED: the authenticated actor does not hold the '${scope}' scope.`);
|
|
190
|
+
this.name = 'ActorScopeDeniedError';
|
|
191
|
+
this.scope = scope;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Enforce one scope (fail closed; stable non-echoing denial). */
|
|
195
|
+
export function assertActorScope(context, scope) {
|
|
196
|
+
if (!context.scopes.includes(scope)) {
|
|
197
|
+
throw new ActorScopeDeniedError(scope);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** In-memory ActorDirectory (tests and local compositions). */
|
|
201
|
+
export class InMemoryActorDirectory {
|
|
202
|
+
#actors = new Map();
|
|
203
|
+
async get(actorId) {
|
|
204
|
+
const record = this.#actors.get(actorId);
|
|
205
|
+
return record === undefined ? undefined : { ...record, roles: [...record.roles] };
|
|
206
|
+
}
|
|
207
|
+
async list() {
|
|
208
|
+
return [...this.#actors.values()]
|
|
209
|
+
.sort((a, b) => (a.actorId < b.actorId ? -1 : 1))
|
|
210
|
+
.map((record) => ({ ...record, roles: [...record.roles] }));
|
|
211
|
+
}
|
|
212
|
+
async upsert(record) {
|
|
213
|
+
assertControlId(record.actorId, 'actorId');
|
|
214
|
+
if (record.status !== 'active' && record.status !== 'disabled') {
|
|
215
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'actor status must be active or disabled.');
|
|
216
|
+
}
|
|
217
|
+
if (!Array.isArray(record.roles)) {
|
|
218
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'actor roles must be an array.');
|
|
219
|
+
}
|
|
220
|
+
for (const role of record.roles) {
|
|
221
|
+
if (!ACTOR_ROLES.includes(role)) {
|
|
222
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'actor roles must use the closed role vocabulary.');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
assertControlTimestamp(record.createdAt, 'actor.createdAt');
|
|
226
|
+
this.#actors.set(record.actorId, { ...record, roles: [...record.roles] });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ---- ChangeSets --------------------------------------------------------------
|
|
230
|
+
/** The versioned ChangeSet schema marker. */
|
|
231
|
+
export const CHANGESET_SCHEMA = 'vict.changeset@1';
|
|
232
|
+
/** The sentinel base version meaning 'no version is currently selected'. */
|
|
233
|
+
export const CHANGESET_BASE_NONE = 'none';
|
|
234
|
+
/** Derive the stable operation identity for one ChangeSet operation. */
|
|
235
|
+
export function changeSetOperationIdentity(input) {
|
|
236
|
+
return controlContentHash({
|
|
237
|
+
domain: 'vict.changeset-operation@1',
|
|
238
|
+
changesetId: input.changesetId,
|
|
239
|
+
contentHash: input.contentHash,
|
|
240
|
+
operationIndex: input.operationIndex,
|
|
241
|
+
operation: input.operation,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The CLOSED structural vocabulary of one ChangeSet operation kind: every
|
|
246
|
+
* kind declares its EXACT required own members. A declaration that misses
|
|
247
|
+
* a member, adds a member, or mistypes a member is malformed and can
|
|
248
|
+
* never reach a content hash or a durable record.
|
|
249
|
+
*/
|
|
250
|
+
const CHANGESET_OPERATION_FIELDS = {
|
|
251
|
+
'select-activation': ['kind', 'graphId', 'activationVersion'],
|
|
252
|
+
'rollback-activation': ['kind', 'graphId', 'targetActivationVersion'],
|
|
253
|
+
'publish-and-select-release': ['kind', 'release'],
|
|
254
|
+
'select-release': ['kind', 'applicationId', 'releaseVersion'],
|
|
255
|
+
'rollback-release': ['kind', 'applicationId', 'targetReleaseVersion'],
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* The exact declared members of the ChangeSet base (closed structure).
|
|
259
|
+
*/
|
|
260
|
+
const CHANGESET_BASE_FIELDS = ['kind', 'subjectId', 'expectedVersion'];
|
|
261
|
+
/**
|
|
262
|
+
* Capture a CLOSED plain-data record without ever invoking caller code or
|
|
263
|
+
* echoing captured values:
|
|
264
|
+
*
|
|
265
|
+
* - the value must be a non-null, non-array object whose prototype is
|
|
266
|
+
* exactly `Object.prototype` (or null) — class instances, exotic
|
|
267
|
+
* prototypes, and structs smuggled through exotic prototypes fail;
|
|
268
|
+
* - property enumeration, `Reflect.ownKeys`, and descriptor reads are
|
|
269
|
+
* guarded — a hostile proxy that throws is rejected without a raw
|
|
270
|
+
* exception crossing the boundary;
|
|
271
|
+
* - OWN string-keyed ENUMERABLE data properties only: accessors
|
|
272
|
+
* (getters/setters), inherited members, non-enumerable fields, and
|
|
273
|
+
* symbol keys are rejected, and getters are NEVER invoked (values are
|
|
274
|
+
* read exclusively through the captured property descriptor).
|
|
275
|
+
* - the field set must be EXACTLY the declared closed set.
|
|
276
|
+
*
|
|
277
|
+
* The returned capture is a freshly allocated plain object owned by VICT
|
|
278
|
+
* (the caller's object is never retained, frozen, or mutated).
|
|
279
|
+
*/
|
|
280
|
+
export function captureClosedControlRecord(raw, field, allowed) {
|
|
281
|
+
// Every inspection primitive — including `Array.isArray`, which THROWS on
|
|
282
|
+
// a revoked Proxy — is guarded: no raw exception ever crosses the boundary.
|
|
283
|
+
let arrayLike;
|
|
284
|
+
try {
|
|
285
|
+
arrayLike = Array.isArray(raw);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
289
|
+
}
|
|
290
|
+
if (typeof raw !== 'object' || raw === null || arrayLike) {
|
|
291
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} must be a plain object with a closed field set.`);
|
|
292
|
+
}
|
|
293
|
+
let prototype;
|
|
294
|
+
try {
|
|
295
|
+
prototype = Object.getPrototypeOf(raw);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
299
|
+
}
|
|
300
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
301
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} must be a plain data record; exotic prototypes are rejected.`);
|
|
302
|
+
}
|
|
303
|
+
let ownKeys;
|
|
304
|
+
try {
|
|
305
|
+
ownKeys = Reflect.ownKeys(raw);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be enumerated; hostile containers are rejected.`);
|
|
309
|
+
}
|
|
310
|
+
const capture = {};
|
|
311
|
+
for (const key of ownKeys) {
|
|
312
|
+
if (typeof key !== 'string') {
|
|
313
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} declares a symbol key; only plain data properties are accepted.`);
|
|
314
|
+
}
|
|
315
|
+
if (allowed !== undefined && !allowed.includes(key)) {
|
|
316
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} declares a field outside the closed structure.`);
|
|
317
|
+
}
|
|
318
|
+
let descriptor;
|
|
319
|
+
try {
|
|
320
|
+
descriptor = Object.getOwnPropertyDescriptor(raw, key);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
324
|
+
}
|
|
325
|
+
if (descriptor === undefined ||
|
|
326
|
+
descriptor.get !== undefined ||
|
|
327
|
+
descriptor.set !== undefined ||
|
|
328
|
+
descriptor.enumerable !== true) {
|
|
329
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} declares an accessor, inherited, or non-enumerable member; only own enumerable data properties are accepted.`);
|
|
330
|
+
}
|
|
331
|
+
capture[key] = descriptor.value;
|
|
332
|
+
}
|
|
333
|
+
for (const required of allowed ?? []) {
|
|
334
|
+
if (!(required in capture)) {
|
|
335
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} is missing a required member of the closed structure.`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return capture;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Capture a CLOSED dense array without ever invoking caller code:
|
|
342
|
+
*
|
|
343
|
+
* - the value must be a REAL array (`Array.isArray` under a guard — a
|
|
344
|
+
* revoked Proxy throws there — whose prototype is exactly
|
|
345
|
+
* `Array.prototype`);
|
|
346
|
+
* - the `length` is read through its OWN DESCRIPTOR under a guard (never a
|
|
347
|
+
* `get`, which would be a caller-controlled trap): it must be an own
|
|
348
|
+
* DATA property carrying a safe integer within [1, maxLength];
|
|
349
|
+
* - `Reflect.ownKeys` (guarded) must declare EXACTLY the length and the
|
|
350
|
+
* dense index keys: extra string or symbol properties, sparse holes,
|
|
351
|
+
* non-enumerable indices, accessors, and hostile enumeration/descriptor
|
|
352
|
+
* traps are ALL rejected;
|
|
353
|
+
* - elements are captured exclusively through their guarded own property
|
|
354
|
+
* DESCRIPTORS (enumerable data properties only) — caller `.map()`,
|
|
355
|
+
* iterators, getters, and index `get` traps are never consulted.
|
|
356
|
+
*
|
|
357
|
+
* The returned array is freshly allocated and owned by VICT (the caller's
|
|
358
|
+
* array is never retained, frozen, or aliased).
|
|
359
|
+
*/
|
|
360
|
+
export function captureClosedControlArray(raw, field, maxLength) {
|
|
361
|
+
let arrayLike;
|
|
362
|
+
try {
|
|
363
|
+
arrayLike = Array.isArray(raw);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
367
|
+
}
|
|
368
|
+
if (!arrayLike) {
|
|
369
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} must be an array with a closed dense structure.`);
|
|
370
|
+
}
|
|
371
|
+
let prototype;
|
|
372
|
+
try {
|
|
373
|
+
prototype = Object.getPrototypeOf(raw);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
377
|
+
}
|
|
378
|
+
if (prototype !== Array.prototype) {
|
|
379
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} must be a plain array; exotic prototypes are rejected.`);
|
|
380
|
+
}
|
|
381
|
+
// The length is inspected through its guarded own DESCRIPTOR — never a
|
|
382
|
+
// caller-controlled `get`.
|
|
383
|
+
let lengthDescriptor;
|
|
384
|
+
try {
|
|
385
|
+
lengthDescriptor = Object.getOwnPropertyDescriptor(raw, 'length');
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
389
|
+
}
|
|
390
|
+
if (lengthDescriptor === undefined ||
|
|
391
|
+
lengthDescriptor.get !== undefined ||
|
|
392
|
+
lengthDescriptor.set !== undefined ||
|
|
393
|
+
typeof lengthDescriptor.value !== 'number' ||
|
|
394
|
+
!Number.isSafeInteger(lengthDescriptor.value) ||
|
|
395
|
+
lengthDescriptor.value < 1 ||
|
|
396
|
+
lengthDescriptor.value > maxLength) {
|
|
397
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} length is outside the closed bounded structure.`);
|
|
398
|
+
}
|
|
399
|
+
const length = lengthDescriptor.value;
|
|
400
|
+
// The declared key set must be EXACTLY the dense index set plus length.
|
|
401
|
+
let ownKeys;
|
|
402
|
+
try {
|
|
403
|
+
ownKeys = Reflect.ownKeys(raw);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be enumerated; hostile containers are rejected.`);
|
|
407
|
+
}
|
|
408
|
+
const expectedKeys = new Set(['length']);
|
|
409
|
+
for (let index = 0; index < length; index += 1) {
|
|
410
|
+
expectedKeys.add(String(index));
|
|
411
|
+
}
|
|
412
|
+
if (ownKeys.length !== expectedKeys.size) {
|
|
413
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} declares members outside the closed dense structure.`);
|
|
414
|
+
}
|
|
415
|
+
for (const key of ownKeys) {
|
|
416
|
+
if (!expectedKeys.has(key)) {
|
|
417
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} declares a member outside the closed dense structure.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Dense capture: every index must be an OWN ENUMERABLE DATA property read
|
|
421
|
+
// through its guarded descriptor (accessors, holes, and non-enumerable
|
|
422
|
+
// indices are rejected; caller getters are never invoked).
|
|
423
|
+
const capture = new Array(length);
|
|
424
|
+
for (let index = 0; index < length; index += 1) {
|
|
425
|
+
let descriptor;
|
|
426
|
+
try {
|
|
427
|
+
descriptor = Object.getOwnPropertyDescriptor(raw, String(index));
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} could not be inspected; hostile containers are rejected.`);
|
|
431
|
+
}
|
|
432
|
+
if (descriptor === undefined ||
|
|
433
|
+
descriptor.get !== undefined ||
|
|
434
|
+
descriptor.set !== undefined ||
|
|
435
|
+
descriptor.enumerable !== true) {
|
|
436
|
+
throw new VictControlError('VICT_CONTROL_STRUCTURE_INVALID', `The ${field} must be a dense array of own enumerable data elements.`);
|
|
437
|
+
}
|
|
438
|
+
capture[index] = descriptor.value;
|
|
439
|
+
}
|
|
440
|
+
return capture;
|
|
441
|
+
}
|
|
442
|
+
/** Validate a required bounded string identifier member (exact type). */
|
|
443
|
+
function requireBoundedMember(capture, field) {
|
|
444
|
+
const value = capture[field];
|
|
445
|
+
if (typeof value !== 'string') {
|
|
446
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', `The member '${field}' must be a string.`);
|
|
447
|
+
}
|
|
448
|
+
assertControlId(value, field);
|
|
449
|
+
return value;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Validate the structural shape of one ChangeSet operation (fail closed).
|
|
453
|
+
*
|
|
454
|
+
* EVERY required member of the operation kind is enforced with its exact
|
|
455
|
+
* runtime type, non-empty bounded identifier form, and the closed own-field
|
|
456
|
+
* set. Accessors, inherited/non-enumerable members, symbols, exotic
|
|
457
|
+
* prototypes, and hostile proxies are rejected WITHOUT invoking getters or
|
|
458
|
+
* echoing captured values. Only the validated VICT-owned canonical capture
|
|
459
|
+
* is returned/hashed — never the original caller object.
|
|
460
|
+
*/
|
|
461
|
+
export function validateChangeSetOperation(operation) {
|
|
462
|
+
// Structural capture FIRST (closed plain-data discipline, no field-set
|
|
463
|
+
// restriction yet) to read the kind identity WITHOUT invoking getters.
|
|
464
|
+
const structure = captureClosedControlRecord(operation, 'ChangeSet operation', undefined);
|
|
465
|
+
if (typeof structure.kind !== 'string' || !(structure.kind in CHANGESET_OPERATION_FIELDS)) {
|
|
466
|
+
throw new VictControlError('VICT_CONTROL_OPERATION_INVALID', 'The ChangeSet operation kind is outside the closed vocabulary.');
|
|
467
|
+
}
|
|
468
|
+
const kindCapture = structure.kind;
|
|
469
|
+
const allowed = CHANGESET_OPERATION_FIELDS[kindCapture];
|
|
470
|
+
// Re-capture with the EXACT closed field set for the resolved kind.
|
|
471
|
+
const capture = captureClosedControlRecord(operation, 'ChangeSet operation', allowed);
|
|
472
|
+
if (capture.kind !== kindCapture) {
|
|
473
|
+
throw new VictControlError('VICT_CONTROL_OPERATION_INVALID', 'The ChangeSet operation kind must be a stable string identity.');
|
|
474
|
+
}
|
|
475
|
+
switch (kindCapture) {
|
|
476
|
+
case 'select-activation':
|
|
477
|
+
return {
|
|
478
|
+
kind: 'select-activation',
|
|
479
|
+
graphId: requireBoundedMember(capture, 'graphId'),
|
|
480
|
+
activationVersion: requireBoundedMember(capture, 'activationVersion'),
|
|
481
|
+
};
|
|
482
|
+
case 'rollback-activation':
|
|
483
|
+
return {
|
|
484
|
+
kind: 'rollback-activation',
|
|
485
|
+
graphId: requireBoundedMember(capture, 'graphId'),
|
|
486
|
+
targetActivationVersion: requireBoundedMember(capture, 'targetActivationVersion'),
|
|
487
|
+
};
|
|
488
|
+
case 'publish-and-select-release':
|
|
489
|
+
return {
|
|
490
|
+
kind: 'publish-and-select-release',
|
|
491
|
+
release: validateApplicationReleaseContent(capture.release),
|
|
492
|
+
};
|
|
493
|
+
case 'select-release':
|
|
494
|
+
return {
|
|
495
|
+
kind: 'select-release',
|
|
496
|
+
applicationId: requireBoundedMember(capture, 'applicationId'),
|
|
497
|
+
releaseVersion: requireBoundedMember(capture, 'releaseVersion'),
|
|
498
|
+
};
|
|
499
|
+
case 'rollback-release':
|
|
500
|
+
return {
|
|
501
|
+
kind: 'rollback-release',
|
|
502
|
+
applicationId: requireBoundedMember(capture, 'applicationId'),
|
|
503
|
+
targetReleaseVersion: requireBoundedMember(capture, 'targetReleaseVersion'),
|
|
504
|
+
};
|
|
505
|
+
default:
|
|
506
|
+
throw new VictControlError('VICT_CONTROL_OPERATION_INVALID', 'The ChangeSet operation kind is outside the closed vocabulary.');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** The closed immutable release content fields. */
|
|
510
|
+
const RELEASE_CONTENT_FIELDS = [
|
|
511
|
+
'releaseVersion',
|
|
512
|
+
'applicationId',
|
|
513
|
+
'applicationVersion',
|
|
514
|
+
'rendererIdentity',
|
|
515
|
+
'componentRegistryIdentity',
|
|
516
|
+
'dataAdapterIdentity',
|
|
517
|
+
'activationBinding',
|
|
518
|
+
];
|
|
519
|
+
/**
|
|
520
|
+
* Validate one immutable Application Release content record (fail closed).
|
|
521
|
+
* The content is captured as a CLOSED plain-data structure (exact member
|
|
522
|
+
* set, exact string types, no accessors/symbols/hostile containers) and
|
|
523
|
+
* the returned record is a fresh VICT-owned capture.
|
|
524
|
+
*/
|
|
525
|
+
export function validateApplicationReleaseContent(content) {
|
|
526
|
+
const capture = captureClosedControlRecord(content, 'release content', RELEASE_CONTENT_FIELDS);
|
|
527
|
+
for (const field of RELEASE_CONTENT_FIELDS) {
|
|
528
|
+
const value = capture[field];
|
|
529
|
+
if (typeof value !== 'string') {
|
|
530
|
+
throw new VictControlError('VICT_CONTROL_RELEASE_INVALID', `The release member '${field}' must be a string.`);
|
|
531
|
+
}
|
|
532
|
+
assertControlId(value, `release.${field}`);
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
releaseVersion: capture.releaseVersion,
|
|
536
|
+
applicationId: capture.applicationId,
|
|
537
|
+
applicationVersion: capture.applicationVersion,
|
|
538
|
+
rendererIdentity: capture.rendererIdentity,
|
|
539
|
+
componentRegistryIdentity: capture.componentRegistryIdentity,
|
|
540
|
+
dataAdapterIdentity: capture.dataAdapterIdentity,
|
|
541
|
+
activationBinding: capture.activationBinding,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/** The exact declared members of the ChangeSet authoring input envelope. */
|
|
545
|
+
const CHANGESET_CONTENT_INPUT_FIELDS = [
|
|
546
|
+
'changesetId',
|
|
547
|
+
'authorActorId',
|
|
548
|
+
'createdAt',
|
|
549
|
+
'base',
|
|
550
|
+
'operations',
|
|
551
|
+
'rationale',
|
|
552
|
+
'riskClass',
|
|
553
|
+
'requiredApproverCount',
|
|
554
|
+
'expiresAt',
|
|
555
|
+
];
|
|
556
|
+
/** Validate a full ChangeSet authoring input and derive its content hash.
|
|
557
|
+
*
|
|
558
|
+
* The COMPLETE untrusted runtime input is CAPTURED at this boundary before
|
|
559
|
+
* any member is read: the outer envelope must be a plain object with the
|
|
560
|
+
* EXACT closed field set, and `base`, the operation list, and every
|
|
561
|
+
* operation are captured through guarded descriptors (no caller getter,
|
|
562
|
+
* iterator, `.map()`, or other caller-controlled behavior is ever
|
|
563
|
+
* consulted; hostile/revoked proxies, sparse arrays, accessors, symbols,
|
|
564
|
+
* non-enumerable members, and exotic prototypes are all rejected with ONE
|
|
565
|
+
* stable, non-echoing error). Only VICT-owned validated captures are
|
|
566
|
+
* hashed and returned; caller objects are never retained, frozen, or aliased.
|
|
567
|
+
*/
|
|
568
|
+
export function validateChangeSetContent(untrustedInput) {
|
|
569
|
+
// ---- 1. CAPTURE the complete outer envelope (closed field set) BEFORE
|
|
570
|
+
// any semantic inspection. Scalar members are captured by descriptor
|
|
571
|
+
// value too, so caller getters never run (a getter on ANY outer field —
|
|
572
|
+
// not only `base` — is rejected without being invoked).
|
|
573
|
+
const input = captureClosedControlRecord(untrustedInput, 'ChangeSet authoring input', CHANGESET_CONTENT_INPUT_FIELDS);
|
|
574
|
+
if (typeof input.changesetId !== 'string') {
|
|
575
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'the ChangeSet id is malformed.');
|
|
576
|
+
}
|
|
577
|
+
if (typeof input.authorActorId !== 'string') {
|
|
578
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'the ChangeSet author is malformed.');
|
|
579
|
+
}
|
|
580
|
+
if (typeof input.createdAt !== 'number' ||
|
|
581
|
+
!Number.isSafeInteger(input.createdAt) ||
|
|
582
|
+
typeof input.expiresAt !== 'number' ||
|
|
583
|
+
!Number.isSafeInteger(input.expiresAt)) {
|
|
584
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'the ChangeSet timestamps are malformed.');
|
|
585
|
+
}
|
|
586
|
+
assertControlId(input.changesetId, 'changesetId');
|
|
587
|
+
assertControlId(input.authorActorId, 'authorActorId');
|
|
588
|
+
assertControlTimestamp(input.createdAt, 'createdAt');
|
|
589
|
+
assertControlTimestamp(input.expiresAt, 'expiresAt');
|
|
590
|
+
if (input.expiresAt <= input.createdAt) {
|
|
591
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'a ChangeSet must expire after its creation.');
|
|
592
|
+
}
|
|
593
|
+
// ---- 2. The base is CAPTURED (closed plain-data structure: exactly its
|
|
594
|
+
// declared members, exact string types, no accessors/symbols/hostile
|
|
595
|
+
// containers) before semantic inspection — a hostile `base.kind` getter
|
|
596
|
+
// is never invoked and a revoked proxy never escapes a raw TypeError.
|
|
597
|
+
const baseCapture = captureClosedControlRecord(input.base, 'ChangeSet base', CHANGESET_BASE_FIELDS);
|
|
598
|
+
if (baseCapture.kind !== 'activation' && baseCapture.kind !== 'release') {
|
|
599
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'the ChangeSet base is malformed.');
|
|
600
|
+
}
|
|
601
|
+
if (typeof baseCapture.subjectId !== 'string' ||
|
|
602
|
+
typeof baseCapture.expectedVersion !== 'string') {
|
|
603
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'the ChangeSet base is malformed.');
|
|
604
|
+
}
|
|
605
|
+
assertControlId(baseCapture.subjectId, 'base.subjectId');
|
|
606
|
+
assertControlId(baseCapture.expectedVersion, 'base.expectedVersion');
|
|
607
|
+
const base = {
|
|
608
|
+
kind: baseCapture.kind,
|
|
609
|
+
subjectId: baseCapture.subjectId,
|
|
610
|
+
expectedVersion: baseCapture.expectedVersion,
|
|
611
|
+
};
|
|
612
|
+
// ---- 3. The operation list is CAPTURED as a closed dense array through
|
|
613
|
+
// guarded descriptors (never caller `.map()`/iterators); sparse arrays,
|
|
614
|
+
// extra/symbol properties, accessor or non-enumerable indices, exotic
|
|
615
|
+
// prototypes, and revoked/hostile proxies all fail with ONE stable error.
|
|
616
|
+
const operationsCapture = captureClosedControlArray(input.operations, 'ChangeSet operation list', 64);
|
|
617
|
+
const operations = [];
|
|
618
|
+
for (const operation of operationsCapture) {
|
|
619
|
+
operations.push(validateChangeSetOperation(operation));
|
|
620
|
+
}
|
|
621
|
+
if (typeof input.rationale !== 'string') {
|
|
622
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'the rationale is malformed.');
|
|
623
|
+
}
|
|
624
|
+
assertBoundedString(input.rationale, 'rationale', 2000);
|
|
625
|
+
if (input.riskClass !== 'low' && input.riskClass !== 'medium' && input.riskClass !== 'high') {
|
|
626
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'riskClass must be low, medium, or high.');
|
|
627
|
+
}
|
|
628
|
+
if (typeof input.requiredApproverCount !== 'number' ||
|
|
629
|
+
!Number.isSafeInteger(input.requiredApproverCount) ||
|
|
630
|
+
input.requiredApproverCount < 1 ||
|
|
631
|
+
input.requiredApproverCount > 8) {
|
|
632
|
+
throw new VictControlError('VICT_CONTROL_FIELD_INVALID', 'requiredApproverCount must be a safe integer between 1 and 8.');
|
|
633
|
+
}
|
|
634
|
+
// The content identity is derived from the VALIDATED VICT-owned captures
|
|
635
|
+
// (the closed base structure and the canonical operation set) — never from
|
|
636
|
+
// the original caller objects (which could carry accessors, symbols, or
|
|
637
|
+
// exotic prototypes and are never retained).
|
|
638
|
+
const contentHash = controlContentHash({
|
|
639
|
+
schema: CHANGESET_SCHEMA,
|
|
640
|
+
authorActorId: input.authorActorId,
|
|
641
|
+
base,
|
|
642
|
+
operations,
|
|
643
|
+
rationale: input.rationale,
|
|
644
|
+
riskClass: input.riskClass,
|
|
645
|
+
requiredApproverCount: input.requiredApproverCount,
|
|
646
|
+
expiresAt: input.expiresAt,
|
|
647
|
+
});
|
|
648
|
+
return {
|
|
649
|
+
changesetId: input.changesetId,
|
|
650
|
+
base,
|
|
651
|
+
operations,
|
|
652
|
+
rationale: input.rationale,
|
|
653
|
+
riskClass: input.riskClass,
|
|
654
|
+
requiredApproverCount: input.requiredApproverCount,
|
|
655
|
+
expiresAt: input.expiresAt,
|
|
656
|
+
contentHash,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
void undefined;
|
|
660
|
+
/** The closed audit action vocabulary. */
|
|
661
|
+
export const CONTROL_AUDIT_ACTIONS = [
|
|
662
|
+
'changeset.proposed',
|
|
663
|
+
'changeset.revised',
|
|
664
|
+
'changeset.evidence-attached',
|
|
665
|
+
'changeset.approved',
|
|
666
|
+
'changeset.declined',
|
|
667
|
+
'changeset.committed',
|
|
668
|
+
'changeset.expired',
|
|
669
|
+
'release.published',
|
|
670
|
+
'release.selected',
|
|
671
|
+
'release.rolled-back',
|
|
672
|
+
'activation.selected',
|
|
673
|
+
'activation.rolled-back',
|
|
674
|
+
'actor.recorded',
|
|
675
|
+
'run.cancelled',
|
|
676
|
+
'turn.cancelled',
|
|
677
|
+
'approval.decided',
|
|
678
|
+
'approval.expired',
|
|
679
|
+
'operator.intervened',
|
|
680
|
+
];
|
|
681
|
+
// ---- Agent turns ------------------------------------------------------------
|
|
682
|
+
export const AGENT_TURN_SCHEMA = 'vict.agent-turn@1';
|
|
683
|
+
/** Durable kinds that a ledger row persists (transient kinds never persist). */
|
|
684
|
+
export function isDurableStreamKind(kind) {
|
|
685
|
+
return kind !== 'text.delta';
|
|
686
|
+
}
|
|
687
|
+
// ---- The composed control-plane store set -------------------------------------
|
|
688
|
+
// ---- Durable command idempotency -----------------------------------------
|
|
689
|
+
/** The closed, bounded idempotency-key format (HTTP + CLI surface). */
|
|
690
|
+
export const COMMAND_IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
691
|
+
/** Stable structured conflict code thrown when a settlement fence mismatches. */
|
|
692
|
+
export const VICT_IDEMPOTENCY_FENCE_CONFLICT = 'VICT_IDEMPOTENCY_FENCE_CONFLICT';
|
|
693
|
+
/**
|
|
694
|
+
* Derive the deterministic settlement fence token for one claim
|
|
695
|
+
* generation. The token binds the namespace, the lease owner, and the
|
|
696
|
+
* attempt generation: a lease takeover always produces a NEW token, so a
|
|
697
|
+
* stale owner can never settle a claim it no longer owns.
|
|
698
|
+
*/
|
|
699
|
+
export function commandIdempotencyFenceToken(input) {
|
|
700
|
+
return ('vict-fence-' +
|
|
701
|
+
createHash('sha256')
|
|
702
|
+
.update(`${input.actorId}\u0000${input.command}\u0000${input.idempotencyKey}\u0000${input.owner}\u0000${input.attempts}`, 'utf8')
|
|
703
|
+
.digest('hex'));
|
|
704
|
+
}
|
|
705
|
+
/** Validation error for any control-plane structural violation. */
|
|
706
|
+
export class VictControlError extends Error {
|
|
707
|
+
code;
|
|
708
|
+
constructor(code, message) {
|
|
709
|
+
super(message);
|
|
710
|
+
this.name = 'VictControlError';
|
|
711
|
+
this.code = code;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/** Canonical JSON of one stream event's safe payload fields (context minus stream identity). */
|
|
715
|
+
export function streamEventPayloadOf(event) {
|
|
716
|
+
const payload = {};
|
|
717
|
+
for (const [key, value] of Object.entries(event)) {
|
|
718
|
+
if (key === 'streamId' || key === 'seq' || key === 'createdAt') {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
payload[key] = value;
|
|
722
|
+
}
|
|
723
|
+
return toCanonicalJson(payload);
|
|
724
|
+
}
|
|
725
|
+
// ---- The stream-ledger append gate (store-boundary schema enforcement) ------
|
|
726
|
+
/**
|
|
727
|
+
* Validate ONE append against the final `vict.agent-stream@1` schema at
|
|
728
|
+
* the STORE boundary — before any sequence state is incremented or any
|
|
729
|
+
* storage mutated. The RAW ledger ports are the last line of defense:
|
|
730
|
+
* plain-JavaScript callers cannot bypass the schema through them.
|
|
731
|
+
*
|
|
732
|
+
* Enforced here:
|
|
733
|
+
* - the payload is a JSON object in CANONICAL form (non-canonical JSON is
|
|
734
|
+
* rejected, not silently re-serialized);
|
|
735
|
+
* - the declared kind is inside the closed vocabulary and matches the
|
|
736
|
+
* payload's `kind` member;
|
|
737
|
+
* - the reconstructed event (payload + stream identity + the sequence that
|
|
738
|
+
* WOULD be assigned) passes the complete field-level schema (unknown
|
|
739
|
+
* kinds/fields, malformed correlation IDs, unsafe codes, raw text in
|
|
740
|
+
* `content.completed`, invalid usage — all fail closed).
|
|
741
|
+
*
|
|
742
|
+
* Errors are stable and non-echoing: `VICT_STREAM_EVENT_INVALID` carries
|
|
743
|
+
* schema issue CODES only, never the rejected values.
|
|
744
|
+
*/
|
|
745
|
+
export function validateStreamLedgerAppend(command) {
|
|
746
|
+
assertControlId(command.streamId, 'streamId');
|
|
747
|
+
let parsed;
|
|
748
|
+
try {
|
|
749
|
+
parsed = JSON.parse(command.payload);
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
throw new VictControlError('VICT_STREAM_EVENT_INVALID', 'The stream event payload is not valid JSON; the ledger rejects it without persisting anything.');
|
|
753
|
+
}
|
|
754
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
755
|
+
throw new VictControlError('VICT_STREAM_EVENT_INVALID', 'The stream event payload must be a JSON object.');
|
|
756
|
+
}
|
|
757
|
+
// Canonical-form enforcement: the stored payload must be the canonical
|
|
758
|
+
// serialization of its own content (no key-order drift, no whitespace).
|
|
759
|
+
if (toCanonicalJson(parsed) !== command.payload) {
|
|
760
|
+
throw new VictControlError('VICT_STREAM_EVENT_INVALID', 'The stream event payload is not canonical JSON; persisting it would break byte-stable replay.');
|
|
761
|
+
}
|
|
762
|
+
const candidate = parsed;
|
|
763
|
+
if (candidate['kind'] !== command.kind) {
|
|
764
|
+
throw new VictControlError('VICT_STREAM_EVENT_INVALID', 'The payload kind does not match the declared event kind.');
|
|
765
|
+
}
|
|
766
|
+
const event = {
|
|
767
|
+
...candidate,
|
|
768
|
+
streamId: command.streamId,
|
|
769
|
+
seq: command.assignedSeq,
|
|
770
|
+
};
|
|
771
|
+
const validation = validateAgentStreamEvent(event);
|
|
772
|
+
if (!validation.ok) {
|
|
773
|
+
throw new VictControlError('VICT_STREAM_EVENT_INVALID', `The event is not a valid vict.agent-stream@1 event (${validation.issues
|
|
774
|
+
.map((issue) => issue.code)
|
|
775
|
+
.join(', ')}).`);
|
|
776
|
+
}
|
|
777
|
+
return event;
|
|
778
|
+
}
|
|
779
|
+
//# sourceMappingURL=control-types.js.map
|