ai-data-workflow-logic 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/autonomous-control.ts +1375 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AI_DATA_CONTROL_SCHEMA_VERSION,
|
|
3
|
+
type AIDataControlAdmission,
|
|
4
|
+
type AIDataControlBudget,
|
|
5
|
+
type AIDataControlBudgetProjection,
|
|
6
|
+
type AIDataControlCapability,
|
|
7
|
+
type AIDataControlCapabilityCatalog,
|
|
8
|
+
type AIDataControlDecision,
|
|
9
|
+
type AIDataControlFeedback,
|
|
10
|
+
type AIDataControlFeedbackCode,
|
|
11
|
+
type AIDataControlGoal,
|
|
12
|
+
type AIDataControlInputBinding,
|
|
13
|
+
type AIDataControlNodeContract,
|
|
14
|
+
type AIDataControlObservation,
|
|
15
|
+
type AIDataControlObservedNode,
|
|
16
|
+
type AIDataControlRevisionOperation,
|
|
17
|
+
type AIDataControlVerifierFact,
|
|
18
|
+
type AIDataWorkflowGraphPatch,
|
|
19
|
+
type AIDataWorkflowGraphPatchOperation,
|
|
20
|
+
type AIDataWorkflowNodeConfig,
|
|
21
|
+
type AIDataWorkflowNodeInputs,
|
|
22
|
+
type AIDataWorkflowRunGraph,
|
|
23
|
+
type AIDataWorkflowRunNode,
|
|
24
|
+
} from 'ai-data-workflow-contract';
|
|
25
|
+
import type { EagerDataFlowValue } from 'eager-data-flow-contract';
|
|
26
|
+
|
|
27
|
+
export const AI_DATA_CONTROL_CAPABILITY_ID_CONFIG_KEY = '__aiDataControlCapabilityId' as const;
|
|
28
|
+
export const AI_DATA_CONTROL_CATALOG_DIGEST_CONFIG_KEY = '__aiDataControlCatalogDigest' as const;
|
|
29
|
+
|
|
30
|
+
export class AIDataControlProtocolError extends Error {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly code: string,
|
|
33
|
+
message: string,
|
|
34
|
+
) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'AIDataControlProtocolError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AIDataControlRuntime {
|
|
41
|
+
readonly stableDigest: (value: unknown) => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FreezeAIDataControlCapabilityCatalogInput {
|
|
45
|
+
readonly schemaVersion: typeof AI_DATA_CONTROL_SCHEMA_VERSION;
|
|
46
|
+
readonly catalogId: string;
|
|
47
|
+
readonly foundationNodes: Readonly<Record<string, AIDataControlNodeContract>>;
|
|
48
|
+
readonly capabilities: Readonly<Record<string, AIDataControlCapability>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ProjectAIDataControlObservationInput {
|
|
52
|
+
readonly goal: AIDataControlGoal;
|
|
53
|
+
readonly budget: AIDataControlBudget;
|
|
54
|
+
readonly catalog: AIDataControlCapabilityCatalog;
|
|
55
|
+
readonly graph: AIDataWorkflowRunGraph;
|
|
56
|
+
readonly verifier: AIDataControlVerifierFact;
|
|
57
|
+
readonly feedback: readonly AIDataControlFeedback[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ProjectAIDataControlObservationConfig {
|
|
61
|
+
readonly maxObservedNodes: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AdmitAIDataControlDecisionInput {
|
|
65
|
+
readonly goal: AIDataControlGoal;
|
|
66
|
+
readonly budget: AIDataControlBudget;
|
|
67
|
+
readonly catalog: AIDataControlCapabilityCatalog;
|
|
68
|
+
readonly graph: AIDataWorkflowRunGraph;
|
|
69
|
+
/** Host-owned current verifier fact; observation.verifier is only its projection. */
|
|
70
|
+
readonly verifier: AIDataControlVerifierFact;
|
|
71
|
+
readonly observation: AIDataControlObservation;
|
|
72
|
+
readonly decision: AIDataControlDecision;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AIDataControlDecisionLimits {
|
|
76
|
+
readonly maxOperations: number;
|
|
77
|
+
readonly maxDepth: number;
|
|
78
|
+
readonly maxNodes: number;
|
|
79
|
+
readonly maxArrayLength: number;
|
|
80
|
+
readonly maxObjectKeys: number;
|
|
81
|
+
readonly maxStringLength: number;
|
|
82
|
+
readonly maxTotalStringLength: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type NormalizeAIDataControlDecisionConfig = Partial<AIDataControlDecisionLimits>;
|
|
86
|
+
|
|
87
|
+
export interface AdmitAIDataControlDecisionConfig {
|
|
88
|
+
readonly decisionLimits?: NormalizeAIDataControlDecisionConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const AI_DATA_CONTROL_DEFAULT_DECISION_LIMITS = Object.freeze({
|
|
92
|
+
maxOperations: 64,
|
|
93
|
+
maxDepth: 32,
|
|
94
|
+
maxNodes: 4_096,
|
|
95
|
+
maxArrayLength: 256,
|
|
96
|
+
maxObjectKeys: 128,
|
|
97
|
+
maxStringLength: 32_768,
|
|
98
|
+
maxTotalStringLength: 131_072,
|
|
99
|
+
} satisfies AIDataControlDecisionLimits);
|
|
100
|
+
|
|
101
|
+
function fail(code: string, message: string): never {
|
|
102
|
+
throw new AIDataControlProtocolError(code, message);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function assertNonEmptyString(value: unknown, location: string): asserts value is string {
|
|
106
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
107
|
+
fail('AI_DATA_CONTROL_INVALID_STRING', `${location} must be a non-empty string.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function assertNonNegativeInteger(value: unknown, location: string): asserts value is number {
|
|
112
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
113
|
+
fail('AI_DATA_CONTROL_INVALID_INTEGER', `${location} must be a non-negative safe integer.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertPositiveInteger(value: unknown, location: string): asserts value is number {
|
|
118
|
+
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
|
119
|
+
fail('AI_DATA_CONTROL_INVALID_INTEGER', `${location} must be a positive safe integer.`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
124
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
125
|
+
const prototype = Object.getPrototypeOf(value);
|
|
126
|
+
return prototype === Object.prototype || prototype === null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stableJson(value: unknown, seen = new Set<object>(), location = '$'): string {
|
|
130
|
+
if (value === null) return 'null';
|
|
131
|
+
if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
|
|
132
|
+
if (typeof value === 'number') {
|
|
133
|
+
if (!Number.isFinite(value)) fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location} must be a finite number.`);
|
|
134
|
+
return JSON.stringify(value);
|
|
135
|
+
}
|
|
136
|
+
if (typeof value !== 'object') {
|
|
137
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location} contains a non-serializable ${typeof value} value.`);
|
|
138
|
+
}
|
|
139
|
+
if (seen.has(value)) fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location} contains a cycle.`);
|
|
140
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
141
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location} must not contain symbol properties.`);
|
|
142
|
+
}
|
|
143
|
+
seen.add(value);
|
|
144
|
+
try {
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
const extraKeys = Object.keys(value).filter((key) => !/^(0|[1-9][0-9]*)$/.test(key));
|
|
147
|
+
if (extraKeys.length > 0) {
|
|
148
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location} must not contain named array properties.`);
|
|
149
|
+
}
|
|
150
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
151
|
+
if (!Object.prototype.hasOwnProperty.call(value, index)) {
|
|
152
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location} contains a sparse array.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return `[${value.map((item, index) => stableJson(item, seen, `${location}[${index}]`)).join(',')}]`;
|
|
156
|
+
}
|
|
157
|
+
if (!isPlainRecord(value)) {
|
|
158
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location} must contain only plain records.`);
|
|
159
|
+
}
|
|
160
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
161
|
+
const keys = Object.keys(descriptors).sort(compareCodeUnits);
|
|
162
|
+
const parts: string[] = [];
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
const descriptor = descriptors[key]!;
|
|
165
|
+
if (!descriptor.enumerable) {
|
|
166
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location}.${key} must be enumerable.`);
|
|
167
|
+
}
|
|
168
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
169
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${location}.${key} must be a data property.`);
|
|
170
|
+
}
|
|
171
|
+
parts.push(`${JSON.stringify(key)}:${stableJson(descriptor.value, seen, `${location}.${key}`)}`);
|
|
172
|
+
}
|
|
173
|
+
return `{${parts.join(',')}}`;
|
|
174
|
+
} finally {
|
|
175
|
+
seen.delete(value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
180
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function fnv1a64(value: string): string {
|
|
184
|
+
let hash = 0xcbf29ce484222325n;
|
|
185
|
+
const prime = 0x100000001b3n;
|
|
186
|
+
const mask = 0xffffffffffffffffn;
|
|
187
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
188
|
+
hash ^= BigInt(value.charCodeAt(index));
|
|
189
|
+
hash = (hash * prime) & mask;
|
|
190
|
+
}
|
|
191
|
+
return hash.toString(16).padStart(16, '0');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function defaultStableDigest(value: unknown): string {
|
|
195
|
+
return `fnv1a64:${fnv1a64(stableJson(value))}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createAIDataControlRuntime(): AIDataControlRuntime {
|
|
199
|
+
return Object.freeze({ stableDigest: defaultStableDigest });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function cloneJson<T>(runtime: AIDataControlRuntime, value: T): T {
|
|
203
|
+
runtime.stableDigest(value);
|
|
204
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function deepFreeze<T>(value: T): T {
|
|
208
|
+
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
209
|
+
for (const item of Object.values(value as Record<string, unknown>)) deepFreeze(item);
|
|
210
|
+
Object.freeze(value);
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function assertSchemaVersion(value: unknown, location: string): void {
|
|
216
|
+
if (value !== AI_DATA_CONTROL_SCHEMA_VERSION) {
|
|
217
|
+
fail('AI_DATA_CONTROL_SCHEMA_VERSION', `${location}.schemaVersion must equal ${AI_DATA_CONTROL_SCHEMA_VERSION}.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertSchemaMap(value: unknown, location: string): asserts value is Readonly<Record<string, string>> {
|
|
222
|
+
if (!isPlainRecord(value)) fail('AI_DATA_CONTROL_SCHEMA_MAP', `${location} must be a plain record.`);
|
|
223
|
+
for (const [port, schemaRef] of Object.entries(value)) {
|
|
224
|
+
assertNonEmptyString(port, `${location} port`);
|
|
225
|
+
assertNonEmptyString(schemaRef, `${location}.${port}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function assertNodeContract(value: AIDataControlNodeContract, location: string): void {
|
|
230
|
+
if (typeof value.protected !== 'boolean') fail('AI_DATA_CONTROL_NODE_CONTRACT', `${location}.protected must be boolean.`);
|
|
231
|
+
assertSchemaMap(value.inputSchemaRefs, `${location}.inputSchemaRefs`);
|
|
232
|
+
assertSchemaMap(value.outputSchemaRefs, `${location}.outputSchemaRefs`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertResourceRef(value: unknown, location: string): void {
|
|
236
|
+
assertNonEmptyString(value, location);
|
|
237
|
+
if (!value.startsWith('resource://') || value.length === 'resource://'.length) {
|
|
238
|
+
fail('AI_DATA_CONTROL_RESOURCE_REF', `${location} must be an exact resource:// reference.`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertCapability(capability: AIDataControlCapability, key: string): void {
|
|
243
|
+
assertNonEmptyString(capability.capabilityId, `capabilities.${key}.capabilityId`);
|
|
244
|
+
if (capability.capabilityId !== key) {
|
|
245
|
+
fail('AI_DATA_CONTROL_CAPABILITY_ID', `Capability key ${key} must equal capabilityId ${capability.capabilityId}.`);
|
|
246
|
+
}
|
|
247
|
+
if (capability.tag !== 'TransformNode' && capability.tag !== 'SinkNode') {
|
|
248
|
+
fail('AI_DATA_CONTROL_CAPABILITY_TAG', `Capability ${key} must use TransformNode or SinkNode.`);
|
|
249
|
+
}
|
|
250
|
+
assertNonEmptyString(capability.nodeType, `capabilities.${key}.nodeType`);
|
|
251
|
+
assertSchemaMap(capability.inputSchemaRefs, `capabilities.${key}.inputSchemaRefs`);
|
|
252
|
+
assertSchemaMap(capability.outputSchemaRefs, `capabilities.${key}.outputSchemaRefs`);
|
|
253
|
+
if (!isPlainRecord(capability.fixedConfig)) {
|
|
254
|
+
fail('AI_DATA_CONTROL_CAPABILITY_CONFIG', `Capability ${key} fixedConfig must be a plain record.`);
|
|
255
|
+
}
|
|
256
|
+
if (
|
|
257
|
+
Object.prototype.hasOwnProperty.call(capability.fixedConfig, AI_DATA_CONTROL_CAPABILITY_ID_CONFIG_KEY)
|
|
258
|
+
|| Object.prototype.hasOwnProperty.call(capability.fixedConfig, AI_DATA_CONTROL_CATALOG_DIGEST_CONFIG_KEY)
|
|
259
|
+
) {
|
|
260
|
+
fail('AI_DATA_CONTROL_CAPABILITY_CONFIG', `Capability ${key} fixedConfig uses reserved control keys.`);
|
|
261
|
+
}
|
|
262
|
+
if (capability.implementation.kind === 'code') {
|
|
263
|
+
assertResourceRef(capability.implementation.codeRef, `capabilities.${key}.implementation.codeRef`);
|
|
264
|
+
} else if (capability.implementation.kind === 'agent') {
|
|
265
|
+
assertResourceRef(capability.implementation.agentDefinitionRef, `capabilities.${key}.implementation.agentDefinitionRef`);
|
|
266
|
+
assertResourceRef(capability.implementation.taskProofRef, `capabilities.${key}.implementation.taskProofRef`);
|
|
267
|
+
} else {
|
|
268
|
+
fail('AI_DATA_CONTROL_CAPABILITY_IMPLEMENTATION', `Capability ${key} has an unknown implementation kind.`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function freezeAIDataControlCapabilityCatalog(
|
|
273
|
+
runtime: AIDataControlRuntime,
|
|
274
|
+
input: FreezeAIDataControlCapabilityCatalogInput,
|
|
275
|
+
_config: Readonly<Record<string, never>>,
|
|
276
|
+
): AIDataControlCapabilityCatalog {
|
|
277
|
+
assertSchemaVersion(input.schemaVersion, 'catalog');
|
|
278
|
+
assertNonEmptyString(input.catalogId, 'catalog.catalogId');
|
|
279
|
+
if (!isPlainRecord(input.foundationNodes) || !isPlainRecord(input.capabilities)) {
|
|
280
|
+
fail('AI_DATA_CONTROL_CATALOG', 'Catalog node and capability indexes must be plain records.');
|
|
281
|
+
}
|
|
282
|
+
for (const [nodeId, contract] of Object.entries(input.foundationNodes)) {
|
|
283
|
+
assertNonEmptyString(nodeId, 'catalog foundation node id');
|
|
284
|
+
assertNodeContract(contract, `foundationNodes.${nodeId}`);
|
|
285
|
+
}
|
|
286
|
+
for (const [key, capability] of Object.entries(input.capabilities)) assertCapability(capability, key);
|
|
287
|
+
const normalized = cloneJson(runtime, input);
|
|
288
|
+
const catalog: AIDataControlCapabilityCatalog = {
|
|
289
|
+
...normalized,
|
|
290
|
+
digest: runtime.stableDigest(normalized),
|
|
291
|
+
};
|
|
292
|
+
return deepFreeze(catalog);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function exactRecord(
|
|
296
|
+
value: unknown,
|
|
297
|
+
required: readonly string[],
|
|
298
|
+
optional: readonly string[],
|
|
299
|
+
location: string,
|
|
300
|
+
): Record<string, unknown> {
|
|
301
|
+
if (!isPlainRecord(value)) fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location} must be a plain record.`);
|
|
302
|
+
const allowed = new Set([...required, ...optional]);
|
|
303
|
+
for (const key of Object.keys(value)) {
|
|
304
|
+
if (!allowed.has(key)) fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location}.${key} is not allowed.`);
|
|
305
|
+
}
|
|
306
|
+
for (const key of required) {
|
|
307
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
308
|
+
fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location}.${key} is required.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resolveDecisionLimits(
|
|
315
|
+
config: NormalizeAIDataControlDecisionConfig,
|
|
316
|
+
): AIDataControlDecisionLimits {
|
|
317
|
+
const limits = { ...AI_DATA_CONTROL_DEFAULT_DECISION_LIMITS, ...config };
|
|
318
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
319
|
+
assertPositiveInteger(value, `decisionLimits.${name}`);
|
|
320
|
+
}
|
|
321
|
+
return limits;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function assertBoundedDecisionValue(
|
|
325
|
+
value: unknown,
|
|
326
|
+
limits: AIDataControlDecisionLimits,
|
|
327
|
+
): void {
|
|
328
|
+
if (isPlainRecord(value)) {
|
|
329
|
+
const rootDescriptors = Object.getOwnPropertyDescriptors(value);
|
|
330
|
+
const kind = rootDescriptors.kind?.value;
|
|
331
|
+
const operations = rootDescriptors.operations?.value;
|
|
332
|
+
if (kind === 'revise' && Array.isArray(operations) && operations.length > limits.maxOperations) {
|
|
333
|
+
fail(
|
|
334
|
+
'AI_DATA_CONTROL_DECISION_LIMIT',
|
|
335
|
+
`decision.operations exceeds the normalization limit ${limits.maxOperations}.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const pending: Array<{ readonly value: unknown; readonly depth: number; readonly location: string }> = [
|
|
341
|
+
{ value, depth: 0, location: 'decision' },
|
|
342
|
+
];
|
|
343
|
+
const seen = new WeakSet<object>();
|
|
344
|
+
let nodeCount = 0;
|
|
345
|
+
let totalStringLength = 0;
|
|
346
|
+
|
|
347
|
+
while (pending.length > 0) {
|
|
348
|
+
const current = pending.pop()!;
|
|
349
|
+
nodeCount += 1;
|
|
350
|
+
if (nodeCount > limits.maxNodes) {
|
|
351
|
+
fail('AI_DATA_CONTROL_DECISION_LIMIT', `decision exceeds ${limits.maxNodes} JSON values.`);
|
|
352
|
+
}
|
|
353
|
+
if (current.depth > limits.maxDepth) {
|
|
354
|
+
fail('AI_DATA_CONTROL_DECISION_LIMIT', `decision exceeds depth ${limits.maxDepth}.`);
|
|
355
|
+
}
|
|
356
|
+
if (typeof current.value === 'string') {
|
|
357
|
+
if (current.value.length > limits.maxStringLength) {
|
|
358
|
+
fail('AI_DATA_CONTROL_DECISION_LIMIT', `${current.location} exceeds ${limits.maxStringLength} characters.`);
|
|
359
|
+
}
|
|
360
|
+
totalStringLength += current.value.length;
|
|
361
|
+
if (totalStringLength > limits.maxTotalStringLength) {
|
|
362
|
+
fail(
|
|
363
|
+
'AI_DATA_CONTROL_DECISION_LIMIT',
|
|
364
|
+
`decision exceeds ${limits.maxTotalStringLength} total string characters.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (current.value === null || typeof current.value === 'boolean') continue;
|
|
370
|
+
if (typeof current.value === 'number') {
|
|
371
|
+
if (!Number.isFinite(current.value)) {
|
|
372
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${current.location} must be a finite number.`);
|
|
373
|
+
}
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (typeof current.value !== 'object') {
|
|
377
|
+
fail(
|
|
378
|
+
'AI_DATA_CONTROL_NON_SERIALIZABLE',
|
|
379
|
+
`${current.location} contains a non-serializable ${typeof current.value} value.`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (seen.has(current.value)) {
|
|
383
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${current.location} contains a cycle or shared object.`);
|
|
384
|
+
}
|
|
385
|
+
seen.add(current.value);
|
|
386
|
+
if (Object.getOwnPropertySymbols(current.value).length > 0) {
|
|
387
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${current.location} must not contain symbol properties.`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const descriptors = Object.getOwnPropertyDescriptors(current.value);
|
|
391
|
+
if (Array.isArray(current.value)) {
|
|
392
|
+
if (current.value.length > limits.maxArrayLength) {
|
|
393
|
+
fail(
|
|
394
|
+
'AI_DATA_CONTROL_DECISION_LIMIT',
|
|
395
|
+
`${current.location} exceeds array length ${limits.maxArrayLength}.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
for (let index = 0; index < current.value.length; index += 1) {
|
|
399
|
+
const descriptor = descriptors[index];
|
|
400
|
+
if (!descriptor || !descriptor.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
401
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${current.location}[${index}] must be a dense data property.`);
|
|
402
|
+
}
|
|
403
|
+
pending.push({
|
|
404
|
+
value: descriptor.value,
|
|
405
|
+
depth: current.depth + 1,
|
|
406
|
+
location: `${current.location}[${index}]`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const namedKeys = Object.keys(descriptors).filter((key) => key !== 'length' && !/^(0|[1-9][0-9]*)$/.test(key));
|
|
410
|
+
if (namedKeys.length > 0) {
|
|
411
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${current.location} contains named array properties.`);
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!isPlainRecord(current.value)) {
|
|
417
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${current.location} must contain only plain records.`);
|
|
418
|
+
}
|
|
419
|
+
const keys = Object.keys(descriptors);
|
|
420
|
+
if (keys.length > limits.maxObjectKeys) {
|
|
421
|
+
fail(
|
|
422
|
+
'AI_DATA_CONTROL_DECISION_LIMIT',
|
|
423
|
+
`${current.location} exceeds ${limits.maxObjectKeys} object keys.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
for (const key of keys) {
|
|
427
|
+
const descriptor = descriptors[key]!;
|
|
428
|
+
if (!descriptor.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
429
|
+
fail('AI_DATA_CONTROL_NON_SERIALIZABLE', `${current.location}.${key} must be an enumerable data property.`);
|
|
430
|
+
}
|
|
431
|
+
totalStringLength += key.length;
|
|
432
|
+
if (key.length > limits.maxStringLength || totalStringLength > limits.maxTotalStringLength) {
|
|
433
|
+
fail('AI_DATA_CONTROL_DECISION_LIMIT', `${current.location} contains oversized property names.`);
|
|
434
|
+
}
|
|
435
|
+
pending.push({
|
|
436
|
+
value: descriptor.value,
|
|
437
|
+
depth: current.depth + 1,
|
|
438
|
+
location: `${current.location}.${key}`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function stringField(record: Record<string, unknown>, key: string, location: string): string {
|
|
445
|
+
assertNonEmptyString(record[key], `${location}.${key}`);
|
|
446
|
+
return record[key];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function normalizeDependsOn(value: unknown, location: string): readonly string[] | undefined {
|
|
450
|
+
if (value === undefined) return undefined;
|
|
451
|
+
if (!Array.isArray(value)) fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location} must be an array.`);
|
|
452
|
+
return value.map((item, index) => {
|
|
453
|
+
assertNonEmptyString(item, `${location}[${index}]`);
|
|
454
|
+
return item;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function normalizeInputBinding(
|
|
459
|
+
runtime: AIDataControlRuntime,
|
|
460
|
+
value: unknown,
|
|
461
|
+
location: string,
|
|
462
|
+
): AIDataControlInputBinding {
|
|
463
|
+
if (!isPlainRecord(value)) fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location} must be a plain record.`);
|
|
464
|
+
if (value.kind === 'port') {
|
|
465
|
+
const record = exactRecord(value, ['kind', 'nodeId', 'port', 'schemaRef'], [], location);
|
|
466
|
+
return {
|
|
467
|
+
kind: 'port',
|
|
468
|
+
nodeId: stringField(record, 'nodeId', location),
|
|
469
|
+
port: stringField(record, 'port', location),
|
|
470
|
+
schemaRef: stringField(record, 'schemaRef', location),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (value.kind === 'literal') {
|
|
474
|
+
const record = exactRecord(value, ['kind', 'schemaRef', 'value'], [], location);
|
|
475
|
+
runtime.stableDigest(record.value);
|
|
476
|
+
return {
|
|
477
|
+
kind: 'literal',
|
|
478
|
+
schemaRef: stringField(record, 'schemaRef', location),
|
|
479
|
+
value: cloneLiteral(record.value as EagerDataFlowValue),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location}.kind must be port or literal.`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function normalizeInputs(
|
|
486
|
+
runtime: AIDataControlRuntime,
|
|
487
|
+
value: unknown,
|
|
488
|
+
location: string,
|
|
489
|
+
): Readonly<Record<string, AIDataControlInputBinding>> {
|
|
490
|
+
if (!isPlainRecord(value)) fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location} must be a plain record.`);
|
|
491
|
+
return Object.fromEntries(Object.entries(value).map(([port, binding]) => {
|
|
492
|
+
assertNonEmptyString(port, `${location} port`);
|
|
493
|
+
return [port, normalizeInputBinding(runtime, binding, `${location}.${port}`)];
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function normalizeRevisionOperation(
|
|
498
|
+
runtime: AIDataControlRuntime,
|
|
499
|
+
value: unknown,
|
|
500
|
+
index: number,
|
|
501
|
+
): AIDataControlRevisionOperation {
|
|
502
|
+
const location = `decision.operations[${index}]`;
|
|
503
|
+
if (!isPlainRecord(value)) fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location} must be a plain record.`);
|
|
504
|
+
if (value.op === 'remove-node') {
|
|
505
|
+
const record = exactRecord(value, ['op', 'nodeId'], [], location);
|
|
506
|
+
return { op: 'remove-node', nodeId: stringField(record, 'nodeId', location) };
|
|
507
|
+
}
|
|
508
|
+
if (value.op === 'add-capability' || value.op === 'rewire-capability') {
|
|
509
|
+
const record = exactRecord(value, ['op', 'nodeId', 'capabilityId', 'inputs'], ['dependsOn'], location);
|
|
510
|
+
const dependsOn = normalizeDependsOn(record.dependsOn, `${location}.dependsOn`);
|
|
511
|
+
return {
|
|
512
|
+
op: value.op,
|
|
513
|
+
nodeId: stringField(record, 'nodeId', location),
|
|
514
|
+
capabilityId: stringField(record, 'capabilityId', location),
|
|
515
|
+
inputs: normalizeInputs(runtime, record.inputs, `${location}.inputs`),
|
|
516
|
+
...(dependsOn === undefined ? {} : { dependsOn }),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location}.op is unsupported.`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const DECISION_BASE_FIELDS = [
|
|
523
|
+
'schemaVersion',
|
|
524
|
+
'kind',
|
|
525
|
+
'decisionId',
|
|
526
|
+
'goalId',
|
|
527
|
+
'observationId',
|
|
528
|
+
'observationDigest',
|
|
529
|
+
'catalogDigest',
|
|
530
|
+
'reason',
|
|
531
|
+
] as const;
|
|
532
|
+
|
|
533
|
+
function normalizedDecisionBase(record: Record<string, unknown>): {
|
|
534
|
+
readonly schemaVersion: typeof AI_DATA_CONTROL_SCHEMA_VERSION;
|
|
535
|
+
readonly decisionId: string;
|
|
536
|
+
readonly goalId: string;
|
|
537
|
+
readonly observationId: string;
|
|
538
|
+
readonly observationDigest: string;
|
|
539
|
+
readonly catalogDigest: string;
|
|
540
|
+
readonly reason: string;
|
|
541
|
+
} {
|
|
542
|
+
assertSchemaVersion(record.schemaVersion, 'decision');
|
|
543
|
+
return {
|
|
544
|
+
schemaVersion: AI_DATA_CONTROL_SCHEMA_VERSION,
|
|
545
|
+
decisionId: stringField(record, 'decisionId', 'decision'),
|
|
546
|
+
goalId: stringField(record, 'goalId', 'decision'),
|
|
547
|
+
observationId: stringField(record, 'observationId', 'decision'),
|
|
548
|
+
observationDigest: stringField(record, 'observationDigest', 'decision'),
|
|
549
|
+
catalogDigest: stringField(record, 'catalogDigest', 'decision'),
|
|
550
|
+
reason: stringField(record, 'reason', 'decision'),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function normalizeAIDataControlDecision(
|
|
555
|
+
runtime: AIDataControlRuntime,
|
|
556
|
+
value: unknown,
|
|
557
|
+
config: NormalizeAIDataControlDecisionConfig,
|
|
558
|
+
): AIDataControlDecision {
|
|
559
|
+
assertBoundedDecisionValue(value, resolveDecisionLimits(config));
|
|
560
|
+
runtime.stableDigest(value);
|
|
561
|
+
if (!isPlainRecord(value)) fail('AI_DATA_CONTROL_DECISION_SHAPE', 'decision must be a plain record.');
|
|
562
|
+
if (value.kind === 'revise') {
|
|
563
|
+
const record = exactRecord(value, [...DECISION_BASE_FIELDS, 'operations'], [], 'decision');
|
|
564
|
+
if (!Array.isArray(record.operations)) {
|
|
565
|
+
fail('AI_DATA_CONTROL_DECISION_SHAPE', 'decision.operations must be an array.');
|
|
566
|
+
}
|
|
567
|
+
return deepFreeze({
|
|
568
|
+
...normalizedDecisionBase(record),
|
|
569
|
+
kind: 'revise',
|
|
570
|
+
operations: record.operations.map((operation, index) => normalizeRevisionOperation(runtime, operation, index)),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (value.kind === 'complete') {
|
|
574
|
+
const record = exactRecord(
|
|
575
|
+
value,
|
|
576
|
+
[...DECISION_BASE_FIELDS, 'verifierFactId', 'outputNodeId', 'outputPort', 'outputSchemaRef'],
|
|
577
|
+
[],
|
|
578
|
+
'decision',
|
|
579
|
+
);
|
|
580
|
+
return deepFreeze({
|
|
581
|
+
...normalizedDecisionBase(record),
|
|
582
|
+
kind: 'complete',
|
|
583
|
+
verifierFactId: stringField(record, 'verifierFactId', 'decision'),
|
|
584
|
+
outputNodeId: stringField(record, 'outputNodeId', 'decision'),
|
|
585
|
+
outputPort: stringField(record, 'outputPort', 'decision'),
|
|
586
|
+
outputSchemaRef: stringField(record, 'outputSchemaRef', 'decision'),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
if (value.kind === 'fail') {
|
|
590
|
+
const record = exactRecord(value, [...DECISION_BASE_FIELDS, 'failureCode'], [], 'decision');
|
|
591
|
+
const failureCode = record.failureCode;
|
|
592
|
+
if (
|
|
593
|
+
failureCode !== 'CONTROLLER_DECLARED_FAILURE'
|
|
594
|
+
&& failureCode !== 'GOAL_UNREACHABLE'
|
|
595
|
+
&& failureCode !== 'BUDGET_EXHAUSTED'
|
|
596
|
+
&& failureCode !== 'NO_PROGRESS'
|
|
597
|
+
) {
|
|
598
|
+
fail('AI_DATA_CONTROL_DECISION_SHAPE', 'decision.failureCode is unsupported.');
|
|
599
|
+
}
|
|
600
|
+
return deepFreeze({
|
|
601
|
+
...normalizedDecisionBase(record),
|
|
602
|
+
kind: 'fail',
|
|
603
|
+
failureCode,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
fail('AI_DATA_CONTROL_DECISION_SHAPE', 'decision.kind must be revise, complete or fail.');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function catalogContent(catalog: AIDataControlCapabilityCatalog): FreezeAIDataControlCapabilityCatalogInput {
|
|
610
|
+
return {
|
|
611
|
+
schemaVersion: catalog.schemaVersion,
|
|
612
|
+
catalogId: catalog.catalogId,
|
|
613
|
+
foundationNodes: catalog.foundationNodes,
|
|
614
|
+
capabilities: catalog.capabilities,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function assertCatalog(runtime: AIDataControlRuntime, catalog: AIDataControlCapabilityCatalog): void {
|
|
619
|
+
const normalized = freezeAIDataControlCapabilityCatalog(runtime, catalogContent(catalog), {});
|
|
620
|
+
if (normalized.digest !== catalog.digest) {
|
|
621
|
+
fail('AI_DATA_CONTROL_CATALOG_DIGEST', 'Catalog digest does not match its frozen content.');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function assertGoal(goal: AIDataControlGoal): void {
|
|
626
|
+
assertSchemaVersion(goal.schemaVersion, 'goal');
|
|
627
|
+
assertNonEmptyString(goal.goalId, 'goal.goalId');
|
|
628
|
+
assertNonEmptyString(goal.objective, 'goal.objective');
|
|
629
|
+
assertResourceRef(goal.verifierRef, 'goal.verifierRef');
|
|
630
|
+
assertNonEmptyString(goal.requiredOutputSchemaRef, 'goal.requiredOutputSchemaRef');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function assertBudget(budget: AIDataControlBudget): void {
|
|
634
|
+
assertSchemaVersion(budget.schemaVersion, 'budget');
|
|
635
|
+
assertPositiveInteger(budget.limits.maxIterations, 'budget.limits.maxIterations');
|
|
636
|
+
assertPositiveInteger(budget.limits.maxOperationsPerDecision, 'budget.limits.maxOperationsPerDecision');
|
|
637
|
+
assertPositiveInteger(budget.limits.maxNoProgressIterations, 'budget.limits.maxNoProgressIterations');
|
|
638
|
+
if (budget.limits.maxTokens !== undefined) assertPositiveInteger(budget.limits.maxTokens, 'budget.limits.maxTokens');
|
|
639
|
+
if (budget.limits.deadlineEpochMs !== undefined) assertNonNegativeInteger(budget.limits.deadlineEpochMs, 'budget.limits.deadlineEpochMs');
|
|
640
|
+
assertNonNegativeInteger(budget.usage.iteration, 'budget.usage.iteration');
|
|
641
|
+
assertNonNegativeInteger(budget.usage.noProgressIterations, 'budget.usage.noProgressIterations');
|
|
642
|
+
if (budget.usage.tokensUsed !== undefined) assertNonNegativeInteger(budget.usage.tokensUsed, 'budget.usage.tokensUsed');
|
|
643
|
+
if (budget.usage.nowEpochMs !== undefined) assertNonNegativeInteger(budget.usage.nowEpochMs, 'budget.usage.nowEpochMs');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function projectBudget(budget: AIDataControlBudget): AIDataControlBudgetProjection {
|
|
647
|
+
return {
|
|
648
|
+
iteration: budget.usage.iteration,
|
|
649
|
+
remainingIterations: Math.max(0, budget.limits.maxIterations - budget.usage.iteration),
|
|
650
|
+
remainingOperations: budget.limits.maxOperationsPerDecision,
|
|
651
|
+
noProgressIterations: budget.usage.noProgressIterations,
|
|
652
|
+
remainingNoProgressIterations: Math.max(
|
|
653
|
+
0,
|
|
654
|
+
budget.limits.maxNoProgressIterations - budget.usage.noProgressIterations,
|
|
655
|
+
),
|
|
656
|
+
...(budget.limits.maxTokens === undefined ? {} : {
|
|
657
|
+
remainingTokens: Math.max(0, budget.limits.maxTokens - (budget.usage.tokensUsed ?? 0)),
|
|
658
|
+
}),
|
|
659
|
+
...(budget.limits.deadlineEpochMs === undefined ? {} : {
|
|
660
|
+
remainingMs: Math.max(0, budget.limits.deadlineEpochMs - (budget.usage.nowEpochMs ?? 0)),
|
|
661
|
+
}),
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function capabilityIdForNode(node: AIDataWorkflowRunNode): string | undefined {
|
|
666
|
+
const value = node.config[AI_DATA_CONTROL_CAPABILITY_ID_CONFIG_KEY];
|
|
667
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function contractForNode(
|
|
671
|
+
node: AIDataWorkflowRunNode,
|
|
672
|
+
catalog: AIDataControlCapabilityCatalog,
|
|
673
|
+
): { readonly known: boolean; readonly contract: AIDataControlNodeContract; readonly capabilityId?: string } {
|
|
674
|
+
const foundation = catalog.foundationNodes[node.id];
|
|
675
|
+
if (foundation) return { known: true, contract: foundation };
|
|
676
|
+
const capabilityId = capabilityIdForNode(node);
|
|
677
|
+
const capability = capabilityId === undefined ? undefined : catalog.capabilities[capabilityId];
|
|
678
|
+
if (!capability || node.config[AI_DATA_CONTROL_CATALOG_DIGEST_CONFIG_KEY] !== catalog.digest) {
|
|
679
|
+
return {
|
|
680
|
+
known: false,
|
|
681
|
+
contract: { protected: true, inputSchemaRefs: {}, outputSchemaRefs: {} },
|
|
682
|
+
...(capabilityId === undefined ? {} : { capabilityId }),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
known: true,
|
|
687
|
+
capabilityId,
|
|
688
|
+
contract: {
|
|
689
|
+
protected: false,
|
|
690
|
+
inputSchemaRefs: capability.inputSchemaRefs,
|
|
691
|
+
outputSchemaRefs: capability.outputSchemaRefs,
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function observedNodes(
|
|
697
|
+
graph: AIDataWorkflowRunGraph,
|
|
698
|
+
catalog: AIDataControlCapabilityCatalog,
|
|
699
|
+
): readonly AIDataControlObservedNode[] {
|
|
700
|
+
return graph.declarationOrder.map((nodeId) => graph.nodes[nodeId]).filter(
|
|
701
|
+
(node): node is AIDataWorkflowRunNode => node !== undefined,
|
|
702
|
+
).map((node) => {
|
|
703
|
+
const resolved = contractForNode(node, catalog);
|
|
704
|
+
return {
|
|
705
|
+
nodeId: node.id,
|
|
706
|
+
tag: node.tag,
|
|
707
|
+
nodeType: node.nodeType,
|
|
708
|
+
status: node.status,
|
|
709
|
+
protected: resolved.contract.protected,
|
|
710
|
+
...(resolved.capabilityId === undefined ? {} : { capabilityId: resolved.capabilityId }),
|
|
711
|
+
dependsOn: [...node.dependsOn],
|
|
712
|
+
inputSchemaRefs: { ...resolved.contract.inputSchemaRefs },
|
|
713
|
+
outputSchemaRefs: { ...resolved.contract.outputSchemaRefs },
|
|
714
|
+
};
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function observationContent(observation: AIDataControlObservation): Omit<AIDataControlObservation, 'observationDigest'> {
|
|
719
|
+
const { observationDigest: _digest, ...content } = observation;
|
|
720
|
+
return content;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function assertVerifier(goal: AIDataControlGoal, graph: AIDataWorkflowRunGraph, verifier: AIDataControlVerifierFact): void {
|
|
724
|
+
assertSchemaVersion(verifier.schemaVersion, 'verifier');
|
|
725
|
+
if (
|
|
726
|
+
verifier.goalId !== goal.goalId
|
|
727
|
+
|| verifier.verifierRef !== goal.verifierRef
|
|
728
|
+
|| verifier.requiredOutputSchemaRef !== goal.requiredOutputSchemaRef
|
|
729
|
+
) {
|
|
730
|
+
fail('AI_DATA_CONTROL_VERIFIER', 'Verifier fact does not match the immutable goal contract.');
|
|
731
|
+
}
|
|
732
|
+
if (verifier.graphGeneration !== graph.currentGeneration) {
|
|
733
|
+
fail('AI_DATA_CONTROL_VERIFIER', 'Verifier fact must describe the current canonical graph generation.');
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export function projectAIDataControlObservation(
|
|
738
|
+
runtime: AIDataControlRuntime,
|
|
739
|
+
input: ProjectAIDataControlObservationInput,
|
|
740
|
+
config: ProjectAIDataControlObservationConfig,
|
|
741
|
+
): AIDataControlObservation {
|
|
742
|
+
assertGoal(input.goal);
|
|
743
|
+
assertBudget(input.budget);
|
|
744
|
+
assertCatalog(runtime, input.catalog);
|
|
745
|
+
assertVerifier(input.goal, input.graph, input.verifier);
|
|
746
|
+
assertPositiveInteger(config.maxObservedNodes, 'config.maxObservedNodes');
|
|
747
|
+
const nodes = observedNodes(input.graph, input.catalog);
|
|
748
|
+
if (nodes.length > config.maxObservedNodes) {
|
|
749
|
+
fail(
|
|
750
|
+
'AI_DATA_CONTROL_OBSERVATION_LIMIT',
|
|
751
|
+
`RunGraph has ${nodes.length} nodes, exceeding the observation limit ${config.maxObservedNodes}.`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const graphDigest = runtime.stableDigest(input.graph);
|
|
755
|
+
const observationId = `${input.graph.runId}@${input.graph.currentGeneration}:${input.budget.usage.iteration}`;
|
|
756
|
+
const content: Omit<AIDataControlObservation, 'observationDigest'> = {
|
|
757
|
+
schemaVersion: AI_DATA_CONTROL_SCHEMA_VERSION,
|
|
758
|
+
observationId,
|
|
759
|
+
goalId: input.goal.goalId,
|
|
760
|
+
catalogDigest: input.catalog.digest,
|
|
761
|
+
graph: {
|
|
762
|
+
runId: input.graph.runId,
|
|
763
|
+
generation: input.graph.currentGeneration,
|
|
764
|
+
digest: graphDigest,
|
|
765
|
+
nodes,
|
|
766
|
+
},
|
|
767
|
+
verifier: cloneJson(runtime, input.verifier),
|
|
768
|
+
budget: projectBudget(input.budget),
|
|
769
|
+
feedback: cloneJson(runtime, input.feedback),
|
|
770
|
+
};
|
|
771
|
+
return deepFreeze({
|
|
772
|
+
...content,
|
|
773
|
+
observationDigest: runtime.stableDigest(content),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function feedback(
|
|
778
|
+
code: AIDataControlFeedbackCode,
|
|
779
|
+
message: string,
|
|
780
|
+
details: Pick<AIDataControlFeedback, 'operationIndex' | 'nodeId' | 'port'> = {},
|
|
781
|
+
): AIDataControlFeedback {
|
|
782
|
+
return { code, message, ...details };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function rejected(
|
|
786
|
+
runtime: AIDataControlRuntime,
|
|
787
|
+
decision: AIDataControlDecision,
|
|
788
|
+
items: readonly AIDataControlFeedback[],
|
|
789
|
+
terminal = false,
|
|
790
|
+
): AIDataControlAdmission {
|
|
791
|
+
return {
|
|
792
|
+
schemaVersion: AI_DATA_CONTROL_SCHEMA_VERSION,
|
|
793
|
+
kind: 'rejected',
|
|
794
|
+
decisionId: decision.decisionId,
|
|
795
|
+
decisionDigest: runtime.stableDigest(decision),
|
|
796
|
+
feedback: items,
|
|
797
|
+
terminal,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function budgetExhaustion(budget: AIDataControlBudget): string | undefined {
|
|
802
|
+
if (budget.usage.iteration >= budget.limits.maxIterations) return 'iteration budget exhausted';
|
|
803
|
+
if (budget.usage.noProgressIterations >= budget.limits.maxNoProgressIterations) return 'no-progress budget exhausted';
|
|
804
|
+
if (
|
|
805
|
+
budget.limits.maxTokens !== undefined
|
|
806
|
+
&& (budget.usage.tokensUsed ?? 0) >= budget.limits.maxTokens
|
|
807
|
+
) return 'token budget exhausted';
|
|
808
|
+
if (
|
|
809
|
+
budget.limits.deadlineEpochMs !== undefined
|
|
810
|
+
&& (budget.usage.nowEpochMs ?? 0) >= budget.limits.deadlineEpochMs
|
|
811
|
+
) return 'deadline budget exhausted';
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
interface MutableNodeContract {
|
|
816
|
+
readonly known: boolean;
|
|
817
|
+
readonly protected: boolean;
|
|
818
|
+
readonly inputSchemaRefs: Readonly<Record<string, string>>;
|
|
819
|
+
readonly outputSchemaRefs: Readonly<Record<string, string>>;
|
|
820
|
+
readonly removed: boolean;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function nodeContracts(
|
|
824
|
+
graph: AIDataWorkflowRunGraph,
|
|
825
|
+
catalog: AIDataControlCapabilityCatalog,
|
|
826
|
+
): Map<string, MutableNodeContract> {
|
|
827
|
+
return new Map(Object.values(graph.nodes).map((node) => {
|
|
828
|
+
const resolved = contractForNode(node, catalog);
|
|
829
|
+
return [node.id, {
|
|
830
|
+
known: resolved.known,
|
|
831
|
+
...resolved.contract,
|
|
832
|
+
removed: node.status === 'Removed',
|
|
833
|
+
}];
|
|
834
|
+
}));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function validateInputs(
|
|
838
|
+
runtime: AIDataControlRuntime,
|
|
839
|
+
operation: Extract<AIDataControlRevisionOperation, { op: 'add-capability' | 'rewire-capability' }>,
|
|
840
|
+
capability: AIDataControlCapability,
|
|
841
|
+
contracts: ReadonlyMap<string, MutableNodeContract>,
|
|
842
|
+
operationIndex: number,
|
|
843
|
+
): AIDataControlFeedback | undefined {
|
|
844
|
+
const expectedPorts = Object.keys(capability.inputSchemaRefs).sort(compareCodeUnits);
|
|
845
|
+
const actualPorts = Object.keys(operation.inputs).sort(compareCodeUnits);
|
|
846
|
+
if (runtime.stableDigest(expectedPorts) !== runtime.stableDigest(actualPorts)) {
|
|
847
|
+
return feedback(
|
|
848
|
+
'INPUT_CONTRACT_MISMATCH',
|
|
849
|
+
`Capability ${capability.capabilityId} requires exactly inputs ${expectedPorts.join(', ')}.`,
|
|
850
|
+
{ operationIndex, nodeId: operation.nodeId },
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
for (const port of expectedPorts) {
|
|
854
|
+
const binding = operation.inputs[port]!;
|
|
855
|
+
const expectedSchema = capability.inputSchemaRefs[port]!;
|
|
856
|
+
if (binding.schemaRef !== expectedSchema) {
|
|
857
|
+
return feedback(
|
|
858
|
+
'INPUT_CONTRACT_MISMATCH',
|
|
859
|
+
`Input ${port} schema ${binding.schemaRef} does not match ${expectedSchema}.`,
|
|
860
|
+
{ operationIndex, nodeId: operation.nodeId, port },
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
if (binding.kind === 'literal') {
|
|
864
|
+
runtime.stableDigest(binding.value);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const source = contracts.get(binding.nodeId);
|
|
868
|
+
if (!source || source.removed || source.outputSchemaRefs[binding.port] !== binding.schemaRef) {
|
|
869
|
+
return feedback(
|
|
870
|
+
'INPUT_CONTRACT_MISMATCH',
|
|
871
|
+
`Input ${port} does not reference an active output with schema ${binding.schemaRef}.`,
|
|
872
|
+
{ operationIndex, nodeId: operation.nodeId, port },
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return undefined;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function graphInputs(inputs: Readonly<Record<string, AIDataControlInputBinding>>): AIDataWorkflowNodeInputs {
|
|
880
|
+
return Object.fromEntries(Object.entries(inputs).map(([port, binding]) => [port, graphInput(binding)]));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function graphInput(binding: AIDataControlInputBinding): AIDataWorkflowNodeInputs[string] {
|
|
884
|
+
if (binding.kind === 'literal') return cloneLiteral(binding.value);
|
|
885
|
+
return {
|
|
886
|
+
ref: `flow-port://#${binding.nodeId}/${binding.port}`,
|
|
887
|
+
nodeId: binding.nodeId,
|
|
888
|
+
port: binding.port,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function flowPortBinding(
|
|
893
|
+
value: AIDataWorkflowNodeInputs[string],
|
|
894
|
+
): { readonly nodeId: string; readonly port: string } | undefined {
|
|
895
|
+
if (
|
|
896
|
+
!isPlainRecord(value)
|
|
897
|
+
|| typeof value.ref !== 'string'
|
|
898
|
+
|| !value.ref.startsWith('flow-port://')
|
|
899
|
+
|| typeof value.nodeId !== 'string'
|
|
900
|
+
|| typeof value.port !== 'string'
|
|
901
|
+
) return undefined;
|
|
902
|
+
return { nodeId: value.nodeId, port: value.port };
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function validateCandidatePortContracts(
|
|
906
|
+
runtime: AIDataControlRuntime,
|
|
907
|
+
contracts: ReadonlyMap<string, MutableNodeContract>,
|
|
908
|
+
inputsByNode: ReadonlyMap<string, AIDataWorkflowNodeInputs>,
|
|
909
|
+
changedContractNodeIds: ReadonlySet<string>,
|
|
910
|
+
): AIDataControlFeedback | undefined {
|
|
911
|
+
for (const [nodeId, contract] of contracts) {
|
|
912
|
+
if (contract.removed) continue;
|
|
913
|
+
const inputs = inputsByNode.get(nodeId) ?? {};
|
|
914
|
+
if (contract.known) {
|
|
915
|
+
const expectedPorts = Object.keys(contract.inputSchemaRefs).sort(compareCodeUnits);
|
|
916
|
+
const actualPorts = Object.keys(inputs).sort(compareCodeUnits);
|
|
917
|
+
if (runtime.stableDigest(expectedPorts) !== runtime.stableDigest(actualPorts)) {
|
|
918
|
+
return feedback(
|
|
919
|
+
'INPUT_CONTRACT_MISMATCH',
|
|
920
|
+
`Active node ${nodeId} no longer has exactly its declared input ports.`,
|
|
921
|
+
{ nodeId },
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
for (const [port, value] of Object.entries(inputs)) {
|
|
926
|
+
const source = flowPortBinding(value);
|
|
927
|
+
if (!source) continue;
|
|
928
|
+
const sourceContract = contracts.get(source.nodeId);
|
|
929
|
+
const sourceSchema = sourceContract?.outputSchemaRefs[source.port];
|
|
930
|
+
if (!sourceContract || sourceContract.removed || sourceSchema === undefined) {
|
|
931
|
+
return feedback(
|
|
932
|
+
'INPUT_CONTRACT_MISMATCH',
|
|
933
|
+
`Active node ${nodeId}.${port} references an unavailable output ${source.nodeId}.${source.port}.`,
|
|
934
|
+
{ nodeId, port },
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
if (!contract.known && changedContractNodeIds.has(source.nodeId)) {
|
|
938
|
+
return feedback(
|
|
939
|
+
'INPUT_CONTRACT_MISMATCH',
|
|
940
|
+
`Active node ${nodeId}.${port} has no frozen contract to validate changed source ${source.nodeId}.`,
|
|
941
|
+
{ nodeId, port },
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
if (contract.known && contract.inputSchemaRefs[port] !== sourceSchema) {
|
|
945
|
+
return feedback(
|
|
946
|
+
'INPUT_CONTRACT_MISMATCH',
|
|
947
|
+
`Active node ${nodeId}.${port} expects ${contract.inputSchemaRefs[port]} but source provides ${sourceSchema}.`,
|
|
948
|
+
{ nodeId, port },
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function cloneLiteral(value: EagerDataFlowValue): EagerDataFlowValue {
|
|
957
|
+
return JSON.parse(JSON.stringify(value)) as EagerDataFlowValue;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function capabilityConfig(
|
|
961
|
+
capability: AIDataControlCapability,
|
|
962
|
+
catalog: AIDataControlCapabilityCatalog,
|
|
963
|
+
): AIDataWorkflowNodeConfig {
|
|
964
|
+
const base: AIDataWorkflowNodeConfig = {
|
|
965
|
+
...cloneLiteral(capability.fixedConfig) as AIDataWorkflowNodeConfig,
|
|
966
|
+
[AI_DATA_CONTROL_CAPABILITY_ID_CONFIG_KEY]: capability.capabilityId,
|
|
967
|
+
[AI_DATA_CONTROL_CATALOG_DIGEST_CONFIG_KEY]: catalog.digest,
|
|
968
|
+
};
|
|
969
|
+
if (capability.implementation.kind === 'code') {
|
|
970
|
+
return { ...base, src: capability.implementation.codeRef };
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
...base,
|
|
974
|
+
agent: {
|
|
975
|
+
agentDefinitionRef: capability.implementation.agentDefinitionRef,
|
|
976
|
+
taskProofRef: capability.implementation.taskProofRef,
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function operationDependencies(
|
|
982
|
+
operation: Extract<AIDataControlRevisionOperation, { op: 'add-capability' | 'rewire-capability' }>,
|
|
983
|
+
): readonly string[] {
|
|
984
|
+
return [...new Set([
|
|
985
|
+
...(operation.dependsOn ?? []),
|
|
986
|
+
...Object.values(operation.inputs).flatMap((binding) => binding.kind === 'port' ? [binding.nodeId] : []),
|
|
987
|
+
])].sort(compareCodeUnits);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function sameNodeRevision(
|
|
991
|
+
runtime: AIDataControlRuntime,
|
|
992
|
+
node: AIDataWorkflowRunNode,
|
|
993
|
+
operation: Extract<AIDataWorkflowGraphPatchOperation, { op: 'update-node' }>,
|
|
994
|
+
): boolean {
|
|
995
|
+
return runtime.stableDigest({
|
|
996
|
+
tag: node.tag,
|
|
997
|
+
nodeType: node.nodeType,
|
|
998
|
+
inputs: node.inputs,
|
|
999
|
+
outputs: node.outputs,
|
|
1000
|
+
config: node.config,
|
|
1001
|
+
dependsOn: node.dependsOn,
|
|
1002
|
+
}) === runtime.stableDigest({
|
|
1003
|
+
tag: operation.changes.tag ?? node.tag,
|
|
1004
|
+
nodeType: operation.changes.nodeType ?? node.nodeType,
|
|
1005
|
+
inputs: operation.changes.inputs ?? node.inputs,
|
|
1006
|
+
outputs: operation.changes.outputs ?? node.outputs,
|
|
1007
|
+
config: operation.changes.config ?? node.config,
|
|
1008
|
+
dependsOn: operation.changes.dependsOn ?? node.dependsOn,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function validateObservation(
|
|
1013
|
+
runtime: AIDataControlRuntime,
|
|
1014
|
+
input: AdmitAIDataControlDecisionInput,
|
|
1015
|
+
): AIDataControlFeedback | undefined {
|
|
1016
|
+
const { observation, decision, graph, catalog, goal } = input;
|
|
1017
|
+
if (decision.schemaVersion !== AI_DATA_CONTROL_SCHEMA_VERSION) {
|
|
1018
|
+
return feedback('SCHEMA_VERSION_MISMATCH', 'Decision schema version is unsupported.');
|
|
1019
|
+
}
|
|
1020
|
+
if (decision.goalId !== goal.goalId || observation.goalId !== goal.goalId) {
|
|
1021
|
+
return feedback('GOAL_MISMATCH', 'Decision or observation does not match the immutable goal.');
|
|
1022
|
+
}
|
|
1023
|
+
if (decision.catalogDigest !== catalog.digest || observation.catalogDigest !== catalog.digest) {
|
|
1024
|
+
return feedback('CATALOG_MISMATCH', 'Decision or observation does not match the frozen capability catalog.');
|
|
1025
|
+
}
|
|
1026
|
+
if (
|
|
1027
|
+
decision.observationId !== observation.observationId
|
|
1028
|
+
|| decision.observationDigest !== observation.observationDigest
|
|
1029
|
+
|| observation.observationDigest !== runtime.stableDigest(observationContent(observation))
|
|
1030
|
+
|| observation.graph.runId !== graph.runId
|
|
1031
|
+
|| observation.graph.generation !== graph.currentGeneration
|
|
1032
|
+
|| observation.graph.digest !== runtime.stableDigest(graph)
|
|
1033
|
+
|| observation.observationId !== `${graph.runId}@${graph.currentGeneration}:${input.budget.usage.iteration}`
|
|
1034
|
+
|| runtime.stableDigest(observation.budget) !== runtime.stableDigest(projectBudget(input.budget))
|
|
1035
|
+
|| runtime.stableDigest(observation.graph.nodes) !== runtime.stableDigest(observedNodes(graph, catalog))
|
|
1036
|
+
|| runtime.stableDigest(observation.verifier) !== runtime.stableDigest(input.verifier)
|
|
1037
|
+
|| observation.verifier.schemaVersion !== AI_DATA_CONTROL_SCHEMA_VERSION
|
|
1038
|
+
|| observation.verifier.goalId !== goal.goalId
|
|
1039
|
+
|| observation.verifier.graphGeneration !== graph.currentGeneration
|
|
1040
|
+
|| observation.verifier.verifierRef !== goal.verifierRef
|
|
1041
|
+
|| observation.verifier.requiredOutputSchemaRef !== goal.requiredOutputSchemaRef
|
|
1042
|
+
) {
|
|
1043
|
+
return feedback('STALE_OBSERVATION', 'Decision does not reference the exact current canonical observation.');
|
|
1044
|
+
}
|
|
1045
|
+
return undefined;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function outputContractFeedback(
|
|
1049
|
+
observation: AIDataControlObservation,
|
|
1050
|
+
decision: Extract<AIDataControlDecision, { kind: 'complete' }>,
|
|
1051
|
+
goal: AIDataControlGoal,
|
|
1052
|
+
): AIDataControlFeedback | undefined {
|
|
1053
|
+
if (decision.outputSchemaRef !== goal.requiredOutputSchemaRef) {
|
|
1054
|
+
return feedback('OUTPUT_CONTRACT_MISMATCH', 'Completion output schema does not match the goal.', {
|
|
1055
|
+
nodeId: decision.outputNodeId,
|
|
1056
|
+
port: decision.outputPort,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
const node = observation.graph.nodes.find((candidate) => candidate.nodeId === decision.outputNodeId);
|
|
1060
|
+
if (
|
|
1061
|
+
!node
|
|
1062
|
+
|| (node.status !== 'Succeeded' && node.status !== 'Reused')
|
|
1063
|
+
|| node.outputSchemaRefs[decision.outputPort] !== decision.outputSchemaRef
|
|
1064
|
+
) {
|
|
1065
|
+
return feedback('OUTPUT_CONTRACT_MISMATCH', 'Completion does not reference an active output with the required schema.', {
|
|
1066
|
+
nodeId: decision.outputNodeId,
|
|
1067
|
+
port: decision.outputPort,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return undefined;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export function admitAIDataControlDecision(
|
|
1074
|
+
runtime: AIDataControlRuntime,
|
|
1075
|
+
input: AdmitAIDataControlDecisionInput,
|
|
1076
|
+
config: AdmitAIDataControlDecisionConfig,
|
|
1077
|
+
): AIDataControlAdmission {
|
|
1078
|
+
return admitNormalizedAIDataControlDecision(runtime, {
|
|
1079
|
+
...input,
|
|
1080
|
+
decision: normalizeAIDataControlDecision(runtime, input.decision, config.decisionLimits ?? {}),
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function admitNormalizedAIDataControlDecision(
|
|
1085
|
+
runtime: AIDataControlRuntime,
|
|
1086
|
+
input: AdmitAIDataControlDecisionInput,
|
|
1087
|
+
): AIDataControlAdmission {
|
|
1088
|
+
assertGoal(input.goal);
|
|
1089
|
+
assertBudget(input.budget);
|
|
1090
|
+
assertCatalog(runtime, input.catalog);
|
|
1091
|
+
assertVerifier(input.goal, input.graph, input.verifier);
|
|
1092
|
+
runtime.stableDigest(input.decision);
|
|
1093
|
+
|
|
1094
|
+
const observationProblem = validateObservation(runtime, input);
|
|
1095
|
+
if (observationProblem) return rejected(runtime, input.decision, [observationProblem]);
|
|
1096
|
+
|
|
1097
|
+
if (input.decision.kind === 'fail') {
|
|
1098
|
+
return {
|
|
1099
|
+
schemaVersion: AI_DATA_CONTROL_SCHEMA_VERSION,
|
|
1100
|
+
kind: 'fail',
|
|
1101
|
+
decisionId: input.decision.decisionId,
|
|
1102
|
+
decisionDigest: runtime.stableDigest(input.decision),
|
|
1103
|
+
failureCode: input.decision.failureCode,
|
|
1104
|
+
reason: input.decision.reason,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (input.decision.kind === 'complete') {
|
|
1109
|
+
const verifier = input.verifier;
|
|
1110
|
+
if (verifier.status !== 'passed') {
|
|
1111
|
+
return rejected(runtime, input.decision, [
|
|
1112
|
+
feedback('VERIFIER_REQUIRED', 'Completion requires a passing verifier fact for the current graph.'),
|
|
1113
|
+
]);
|
|
1114
|
+
}
|
|
1115
|
+
if (
|
|
1116
|
+
input.decision.verifierFactId !== verifier.factId
|
|
1117
|
+
|| verifier.goalId !== input.goal.goalId
|
|
1118
|
+
|| verifier.graphGeneration !== input.graph.currentGeneration
|
|
1119
|
+
|| verifier.verifierRef !== input.goal.verifierRef
|
|
1120
|
+
|| verifier.requiredOutputSchemaRef !== input.goal.requiredOutputSchemaRef
|
|
1121
|
+
) {
|
|
1122
|
+
return rejected(runtime, input.decision, [
|
|
1123
|
+
feedback('VERIFIER_MISMATCH', 'Completion does not reference the exact current verifier fact.'),
|
|
1124
|
+
]);
|
|
1125
|
+
}
|
|
1126
|
+
const outputProblem = outputContractFeedback(input.observation, input.decision, input.goal);
|
|
1127
|
+
if (outputProblem) return rejected(runtime, input.decision, [outputProblem]);
|
|
1128
|
+
return {
|
|
1129
|
+
schemaVersion: AI_DATA_CONTROL_SCHEMA_VERSION,
|
|
1130
|
+
kind: 'complete',
|
|
1131
|
+
decisionId: input.decision.decisionId,
|
|
1132
|
+
decisionDigest: runtime.stableDigest(input.decision),
|
|
1133
|
+
verifierFactId: input.decision.verifierFactId,
|
|
1134
|
+
outputNodeId: input.decision.outputNodeId,
|
|
1135
|
+
outputPort: input.decision.outputPort,
|
|
1136
|
+
outputSchemaRef: input.decision.outputSchemaRef,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const exhausted = budgetExhaustion(input.budget);
|
|
1141
|
+
if (exhausted) {
|
|
1142
|
+
return rejected(
|
|
1143
|
+
runtime,
|
|
1144
|
+
input.decision,
|
|
1145
|
+
[feedback('BUDGET_EXHAUSTED', `Cannot revise: ${exhausted}.`)],
|
|
1146
|
+
true,
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
if (
|
|
1150
|
+
input.decision.operations.length < 1
|
|
1151
|
+
|| input.decision.operations.length > input.budget.limits.maxOperationsPerDecision
|
|
1152
|
+
) {
|
|
1153
|
+
return rejected(runtime, input.decision, [
|
|
1154
|
+
feedback(
|
|
1155
|
+
'INVALID_OPERATION_COUNT',
|
|
1156
|
+
`Revision must contain 1..${input.budget.limits.maxOperationsPerDecision} operations.`,
|
|
1157
|
+
),
|
|
1158
|
+
]);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const targets = new Set<string>();
|
|
1162
|
+
for (let index = 0; index < input.decision.operations.length; index += 1) {
|
|
1163
|
+
const operation = input.decision.operations[index]!;
|
|
1164
|
+
if (targets.has(operation.nodeId)) {
|
|
1165
|
+
return rejected(runtime, input.decision, [
|
|
1166
|
+
feedback('DUPLICATE_TARGET', `Revision targets ${operation.nodeId} more than once.`, {
|
|
1167
|
+
operationIndex: index,
|
|
1168
|
+
nodeId: operation.nodeId,
|
|
1169
|
+
}),
|
|
1170
|
+
]);
|
|
1171
|
+
}
|
|
1172
|
+
targets.add(operation.nodeId);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const contracts = nodeContracts(input.graph, input.catalog);
|
|
1176
|
+
const candidateInputs = new Map<string, AIDataWorkflowNodeInputs>(
|
|
1177
|
+
Object.values(input.graph.nodes).map((node) => [node.id, node.inputs]),
|
|
1178
|
+
);
|
|
1179
|
+
const changedContractNodeIds = new Set<string>();
|
|
1180
|
+
const patchOperations: AIDataWorkflowGraphPatchOperation[] = [];
|
|
1181
|
+
const removed = new Set<string>();
|
|
1182
|
+
|
|
1183
|
+
for (let index = 0; index < input.decision.operations.length; index += 1) {
|
|
1184
|
+
const operation = input.decision.operations[index]!;
|
|
1185
|
+
const existingContract = contracts.get(operation.nodeId);
|
|
1186
|
+
if (operation.op === 'remove-node') {
|
|
1187
|
+
if (!existingContract || existingContract.removed) {
|
|
1188
|
+
return rejected(runtime, input.decision, [feedback('UNKNOWN_NODE', `Node ${operation.nodeId} is not active.`, {
|
|
1189
|
+
operationIndex: index,
|
|
1190
|
+
nodeId: operation.nodeId,
|
|
1191
|
+
})]);
|
|
1192
|
+
}
|
|
1193
|
+
if (existingContract.protected) {
|
|
1194
|
+
return rejected(runtime, input.decision, [feedback('PROTECTED_NODE', `Node ${operation.nodeId} is protected.`, {
|
|
1195
|
+
operationIndex: index,
|
|
1196
|
+
nodeId: operation.nodeId,
|
|
1197
|
+
})]);
|
|
1198
|
+
}
|
|
1199
|
+
patchOperations.push({ op: 'remove-node', nodeId: operation.nodeId });
|
|
1200
|
+
contracts.set(operation.nodeId, { ...existingContract, removed: true });
|
|
1201
|
+
removed.add(operation.nodeId);
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (operation.op === 'add-capability' && existingContract) {
|
|
1206
|
+
return rejected(runtime, input.decision, [feedback('INVALID_DECISION', `Node ${operation.nodeId} already exists.`, {
|
|
1207
|
+
operationIndex: index,
|
|
1208
|
+
nodeId: operation.nodeId,
|
|
1209
|
+
})]);
|
|
1210
|
+
}
|
|
1211
|
+
if (operation.op === 'rewire-capability') {
|
|
1212
|
+
if (!existingContract || existingContract.removed) {
|
|
1213
|
+
return rejected(runtime, input.decision, [feedback('UNKNOWN_NODE', `Node ${operation.nodeId} is not active.`, {
|
|
1214
|
+
operationIndex: index,
|
|
1215
|
+
nodeId: operation.nodeId,
|
|
1216
|
+
})]);
|
|
1217
|
+
}
|
|
1218
|
+
if (existingContract.protected) {
|
|
1219
|
+
return rejected(runtime, input.decision, [feedback('PROTECTED_NODE', `Node ${operation.nodeId} is protected.`, {
|
|
1220
|
+
operationIndex: index,
|
|
1221
|
+
nodeId: operation.nodeId,
|
|
1222
|
+
})]);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const capability = input.catalog.capabilities[operation.capabilityId];
|
|
1227
|
+
if (!capability) {
|
|
1228
|
+
return rejected(runtime, input.decision, [feedback('UNKNOWN_CAPABILITY', `Capability ${operation.capabilityId} is not frozen.`, {
|
|
1229
|
+
operationIndex: index,
|
|
1230
|
+
nodeId: operation.nodeId,
|
|
1231
|
+
})]);
|
|
1232
|
+
}
|
|
1233
|
+
const inputProblem = validateInputs(runtime, operation, capability, contracts, index);
|
|
1234
|
+
if (inputProblem) return rejected(runtime, input.decision, [inputProblem]);
|
|
1235
|
+
const dependsOn = operationDependencies(operation);
|
|
1236
|
+
for (const dependency of dependsOn) {
|
|
1237
|
+
const dependencyContract = contracts.get(dependency);
|
|
1238
|
+
if (!dependencyContract || dependencyContract.removed) {
|
|
1239
|
+
return rejected(runtime, input.decision, [feedback('UNKNOWN_NODE', `Dependency ${dependency} is not active.`, {
|
|
1240
|
+
operationIndex: index,
|
|
1241
|
+
nodeId: operation.nodeId,
|
|
1242
|
+
})]);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
const nodeFacts = {
|
|
1246
|
+
tag: capability.tag,
|
|
1247
|
+
nodeType: capability.nodeType,
|
|
1248
|
+
inputs: graphInputs(operation.inputs),
|
|
1249
|
+
outputs: Object.keys(capability.outputSchemaRefs),
|
|
1250
|
+
config: capabilityConfig(capability, input.catalog),
|
|
1251
|
+
dependsOn,
|
|
1252
|
+
};
|
|
1253
|
+
if (operation.op === 'add-capability') {
|
|
1254
|
+
patchOperations.push({
|
|
1255
|
+
op: 'add-node',
|
|
1256
|
+
node: { id: operation.nodeId, ...nodeFacts },
|
|
1257
|
+
});
|
|
1258
|
+
} else {
|
|
1259
|
+
patchOperations.push({
|
|
1260
|
+
op: 'update-node',
|
|
1261
|
+
nodeId: operation.nodeId,
|
|
1262
|
+
changes: nodeFacts,
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
contracts.set(operation.nodeId, {
|
|
1266
|
+
known: true,
|
|
1267
|
+
protected: false,
|
|
1268
|
+
inputSchemaRefs: capability.inputSchemaRefs,
|
|
1269
|
+
outputSchemaRefs: capability.outputSchemaRefs,
|
|
1270
|
+
removed: false,
|
|
1271
|
+
});
|
|
1272
|
+
candidateInputs.set(operation.nodeId, nodeFacts.inputs);
|
|
1273
|
+
changedContractNodeIds.add(operation.nodeId);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const candidatePortProblem = validateCandidatePortContracts(
|
|
1277
|
+
runtime,
|
|
1278
|
+
contracts,
|
|
1279
|
+
candidateInputs,
|
|
1280
|
+
changedContractNodeIds,
|
|
1281
|
+
);
|
|
1282
|
+
if (candidatePortProblem) return rejected(runtime, input.decision, [candidatePortProblem]);
|
|
1283
|
+
|
|
1284
|
+
for (const node of Object.values(input.graph.nodes)) {
|
|
1285
|
+
if (node.status === 'Removed' || removed.has(node.id)) continue;
|
|
1286
|
+
const effectiveOperation = patchOperations.find(
|
|
1287
|
+
(operation): operation is Extract<AIDataWorkflowGraphPatchOperation, { op: 'update-node' }> => (
|
|
1288
|
+
operation.op === 'update-node' && operation.nodeId === node.id
|
|
1289
|
+
),
|
|
1290
|
+
);
|
|
1291
|
+
const dependencies = effectiveOperation?.changes.dependsOn ?? node.dependsOn;
|
|
1292
|
+
if (dependencies.some((dependency) => removed.has(dependency))) {
|
|
1293
|
+
return rejected(runtime, input.decision, [feedback(
|
|
1294
|
+
'INVALID_DECISION',
|
|
1295
|
+
`Active node ${node.id} would retain a dependency on a removed node.`,
|
|
1296
|
+
{ nodeId: node.id },
|
|
1297
|
+
)]);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
for (const operation of patchOperations) {
|
|
1302
|
+
if (operation.op === 'remove-node') continue;
|
|
1303
|
+
const dependencies = operation.op === 'add-node'
|
|
1304
|
+
? operation.node.dependsOn ?? []
|
|
1305
|
+
: operation.changes.dependsOn ?? input.graph.nodes[operation.nodeId]?.dependsOn ?? [];
|
|
1306
|
+
if (dependencies.some((dependency) => removed.has(dependency))) {
|
|
1307
|
+
return rejected(runtime, input.decision, [feedback(
|
|
1308
|
+
'INVALID_DECISION',
|
|
1309
|
+
`Node ${operation.op === 'add-node' ? operation.node.id : operation.nodeId} would depend on a removed node.`,
|
|
1310
|
+
)]);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const dependencyGraph = new Map<string, readonly string[]>();
|
|
1315
|
+
for (const node of Object.values(input.graph.nodes)) {
|
|
1316
|
+
if (node.status !== 'Removed') dependencyGraph.set(node.id, node.dependsOn);
|
|
1317
|
+
}
|
|
1318
|
+
for (const operation of patchOperations) {
|
|
1319
|
+
if (operation.op === 'remove-node') dependencyGraph.delete(operation.nodeId);
|
|
1320
|
+
if (operation.op === 'add-node') dependencyGraph.set(operation.node.id, operation.node.dependsOn ?? []);
|
|
1321
|
+
if (operation.op === 'update-node') {
|
|
1322
|
+
dependencyGraph.set(
|
|
1323
|
+
operation.nodeId,
|
|
1324
|
+
operation.changes.dependsOn ?? dependencyGraph.get(operation.nodeId) ?? [],
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
const visiting = new Set<string>();
|
|
1329
|
+
const visited = new Set<string>();
|
|
1330
|
+
const visit = (nodeId: string): boolean => {
|
|
1331
|
+
if (visiting.has(nodeId)) return true;
|
|
1332
|
+
if (visited.has(nodeId)) return false;
|
|
1333
|
+
visiting.add(nodeId);
|
|
1334
|
+
for (const dependency of dependencyGraph.get(nodeId) ?? []) {
|
|
1335
|
+
if (!dependencyGraph.has(dependency) || visit(dependency)) return true;
|
|
1336
|
+
}
|
|
1337
|
+
visiting.delete(nodeId);
|
|
1338
|
+
visited.add(nodeId);
|
|
1339
|
+
return false;
|
|
1340
|
+
};
|
|
1341
|
+
if ([...dependencyGraph.keys()].some(visit)) {
|
|
1342
|
+
return rejected(runtime, input.decision, [feedback(
|
|
1343
|
+
'INVALID_DECISION',
|
|
1344
|
+
'Revision would create a cyclic or unresolved dependency graph.',
|
|
1345
|
+
)]);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (patchOperations.every((operation) => (
|
|
1349
|
+
operation.op === 'update-node'
|
|
1350
|
+
&& sameNodeRevision(runtime, input.graph.nodes[operation.nodeId]!, operation)
|
|
1351
|
+
))) {
|
|
1352
|
+
const terminal = input.budget.usage.noProgressIterations + 1
|
|
1353
|
+
>= input.budget.limits.maxNoProgressIterations;
|
|
1354
|
+
return rejected(
|
|
1355
|
+
runtime,
|
|
1356
|
+
input.decision,
|
|
1357
|
+
[feedback('NO_PROGRESS', 'Revision does not change the canonical graph.')],
|
|
1358
|
+
terminal,
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
const patch: AIDataWorkflowGraphPatch = {
|
|
1363
|
+
patchId: `control:${input.decision.decisionId}`,
|
|
1364
|
+
generation: input.graph.currentGeneration + 1,
|
|
1365
|
+
reason: input.decision.reason,
|
|
1366
|
+
operations: patchOperations,
|
|
1367
|
+
};
|
|
1368
|
+
return {
|
|
1369
|
+
schemaVersion: AI_DATA_CONTROL_SCHEMA_VERSION,
|
|
1370
|
+
kind: 'patch',
|
|
1371
|
+
decisionId: input.decision.decisionId,
|
|
1372
|
+
decisionDigest: runtime.stableDigest(input.decision),
|
|
1373
|
+
patch,
|
|
1374
|
+
};
|
|
1375
|
+
}
|