@gate-forge/core 0.4.0 → 0.6.1
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/baselines/index.js +1 -1
- package/dist/baselines/index.js.map +1 -1
- package/dist/classifier/classify.d.ts +1 -0
- package/dist/classifier/classify.d.ts.map +1 -1
- package/dist/classifier/classify.js +66 -10
- package/dist/classifier/classify.js.map +1 -1
- package/dist/config/index.d.ts +3 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +16 -0
- package/dist/config/index.js.map +1 -1
- package/dist/fingerprints.d.ts +35 -4
- package/dist/fingerprints.d.ts.map +1 -1
- package/dist/fingerprints.js +58 -5
- package/dist/fingerprints.js.map +1 -1
- package/dist/index.d.ts +46 -14
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -12
- package/dist/index.js.map +1 -1
- package/dist/mapping/resolve.d.ts +6 -2
- package/dist/mapping/resolve.d.ts.map +1 -1
- package/dist/mapping/resolve.js +122 -17
- package/dist/mapping/resolve.js.map +1 -1
- package/dist/policy/behavior.d.ts +44 -0
- package/dist/policy/behavior.d.ts.map +1 -0
- package/dist/policy/behavior.js +373 -0
- package/dist/policy/behavior.js.map +1 -0
- package/dist/policy/coverage.d.ts +4 -3
- package/dist/policy/coverage.d.ts.map +1 -1
- package/dist/policy/coverage.js +12 -6
- package/dist/policy/coverage.js.map +1 -1
- package/dist/policy/evaluate.d.ts +23 -0
- package/dist/policy/evaluate.d.ts.map +1 -1
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.d.ts.map +1 -1
- package/dist/policy/index.js +1 -0
- package/dist/policy/index.js.map +1 -1
- package/dist/receipt/index.d.ts +56 -4
- package/dist/receipt/index.d.ts.map +1 -1
- package/dist/receipt/index.js +139 -5
- package/dist/receipt/index.js.map +1 -1
- package/dist/report/index.js +4 -19
- package/dist/report/index.js.map +1 -1
- package/dist/schemas/behavior-catalog.d.ts +2868 -0
- package/dist/schemas/behavior-catalog.d.ts.map +1 -0
- package/dist/schemas/behavior-catalog.js +103 -0
- package/dist/schemas/behavior-catalog.js.map +1 -0
- package/dist/schemas/behavior-evidence.d.ts +174 -0
- package/dist/schemas/behavior-evidence.d.ts.map +1 -0
- package/dist/schemas/behavior-evidence.js +157 -0
- package/dist/schemas/behavior-evidence.js.map +1 -0
- package/dist/schemas/behavior-policy.d.ts +6214 -0
- package/dist/schemas/behavior-policy.d.ts.map +1 -0
- package/dist/schemas/behavior-policy.js +896 -0
- package/dist/schemas/behavior-policy.js.map +1 -0
- package/dist/schemas/classification-policy.d.ts +52 -0
- package/dist/schemas/classification-policy.d.ts.map +1 -1
- package/dist/schemas/classification-policy.js +93 -0
- package/dist/schemas/classification-policy.js.map +1 -1
- package/dist/schemas/execution-result.d.ts +4 -4
- package/dist/schemas/gate-receipt.d.ts +10 -3
- package/dist/schemas/gate-receipt.d.ts.map +1 -1
- package/dist/schemas/gate-receipt.js +46 -3
- package/dist/schemas/gate-receipt.js.map +1 -1
- package/dist/schemas/index.d.ts +3 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +3 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/obligation.d.ts +1 -0
- package/dist/schemas/obligation.d.ts.map +1 -1
- package/dist/schemas/obligation.js +9 -0
- package/dist/schemas/obligation.js.map +1 -1
- package/dist/schemas/runtime-config.d.ts +103 -0
- package/dist/schemas/runtime-config.d.ts.map +1 -0
- package/dist/schemas/runtime-config.js +168 -0
- package/dist/schemas/runtime-config.js.map +1 -0
- package/dist/schemas/test-catalog.d.ts +20 -2
- package/dist/schemas/test-catalog.d.ts.map +1 -1
- package/dist/schemas/test-catalog.js +13 -0
- package/dist/schemas/test-catalog.js.map +1 -1
- package/dist/schemas/test-map.d.ts +4 -0
- package/dist/schemas/test-map.d.ts.map +1 -1
- package/dist/schemas/test-map.js +24 -1
- package/dist/schemas/test-map.js.map +1 -1
- package/dist/schemas/verdict.d.ts +11 -0
- package/dist/schemas/verdict.d.ts.map +1 -1
- package/dist/schemas/verdict.js +33 -6
- package/dist/schemas/verdict.js.map +1 -1
- package/dist/testing/gate-runner.d.ts.map +1 -1
- package/dist/testing/gate-runner.js +2 -7
- package/dist/testing/gate-runner.js.map +1 -1
- package/dist/verdict/behavior.d.ts +87 -0
- package/dist/verdict/behavior.d.ts.map +1 -0
- package/dist/verdict/behavior.js +1328 -0
- package/dist/verdict/behavior.js.map +1 -0
- package/dist/verdict/evaluate.d.ts +10 -0
- package/dist/verdict/evaluate.d.ts.map +1 -1
- package/dist/verdict/evaluate.js +188 -27
- package/dist/verdict/evaluate.js.map +1 -1
- package/dist/verdict/index.d.ts +6 -0
- package/dist/verdict/index.d.ts.map +1 -1
- package/dist/verdict/index.js +6 -0
- package/dist/verdict/index.js.map +1 -1
- package/dist/verdict/pack-verifiers.d.ts.map +1 -1
- package/dist/verdict/pack-verifiers.js +124 -33
- package/dist/verdict/pack-verifiers.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior-policy schemas (plan 2026-09-19 §4.1–4.2, §15): the OWNER
|
|
3
|
+
* document that enumerates every discovered endpoint's required cases.
|
|
4
|
+
* Core accepts already-parsed data; YAML loading stays in the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Absence of the document preserves today's basic table/transport
|
|
7
|
+
* behavior. Presence is complete-behavior: no warnOnly, percentage, or
|
|
8
|
+
* silent fallback. Unknown keys fail closed.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { ContractNameSchema, SchemaVersionField } from './common.js';
|
|
12
|
+
import { FingerprintHexSchema } from './baseline.js';
|
|
13
|
+
/** Concrete HTTP methods a case may execute. `ANY` is never executable. */
|
|
14
|
+
export const BEHAVIOR_HTTP_METHODS = [
|
|
15
|
+
'GET',
|
|
16
|
+
'HEAD',
|
|
17
|
+
'POST',
|
|
18
|
+
'PUT',
|
|
19
|
+
'PATCH',
|
|
20
|
+
'DELETE',
|
|
21
|
+
'OPTIONS',
|
|
22
|
+
];
|
|
23
|
+
/** Case identity domain hashed into the stable case id. */
|
|
24
|
+
export const BEHAVIOR_CASE_DOMAIN = 'gateforge.case.v1';
|
|
25
|
+
/** Case-id slug: unique within the subject resource. */
|
|
26
|
+
export const BehaviorCaseIdSchema = z
|
|
27
|
+
.string()
|
|
28
|
+
.regex(/^[a-z0-9][a-z0-9._-]*$/, "case id must match '[a-z0-9][a-z0-9._-]*'");
|
|
29
|
+
/** Strong HTTP contracts introduced by this plan. */
|
|
30
|
+
export const HTTP_EFFECT_VERIFIED = 'http:effect-verified';
|
|
31
|
+
export const HTTP_READ_RESULT_VERIFIED = 'http:read-result-verified';
|
|
32
|
+
/** Existing weaker HTTP contracts retained with their transport meaning. */
|
|
33
|
+
export const HTTP_REQUEST_OBSERVED = 'http:request-observed';
|
|
34
|
+
export const HTTP_RESPONSE_STATUS_OK = 'http:response-status-ok';
|
|
35
|
+
/**
|
|
36
|
+
* Closed contract vocabulary a behavior case may name: the two strong
|
|
37
|
+
* HTTP contracts, the two retained transport contracts (operational-only
|
|
38
|
+
* routes), and every existing domain contract spelling.
|
|
39
|
+
*/
|
|
40
|
+
export const BEHAVIOR_CONTRACTS = [
|
|
41
|
+
HTTP_EFFECT_VERIFIED,
|
|
42
|
+
HTTP_READ_RESULT_VERIFIED,
|
|
43
|
+
HTTP_REQUEST_OBSERVED,
|
|
44
|
+
HTTP_RESPONSE_STATUS_OK,
|
|
45
|
+
'auth:role-allowed',
|
|
46
|
+
'auth:role-denied',
|
|
47
|
+
'auth:tenant-isolated',
|
|
48
|
+
'auth:denied-no-side-effect',
|
|
49
|
+
'auth:forged-token-rejected',
|
|
50
|
+
'validation:boundary-accepted',
|
|
51
|
+
'validation:boundary-rejected',
|
|
52
|
+
'validation:no-side-effect-on-reject',
|
|
53
|
+
'validation:error-message-explicit',
|
|
54
|
+
'validation:envelope-shape-stable',
|
|
55
|
+
'workflow:transition-allowed',
|
|
56
|
+
'workflow:transition-rejected',
|
|
57
|
+
'workflow:terminal-immutable',
|
|
58
|
+
'workflow:audit-emitted',
|
|
59
|
+
'workflow:persisted-final-state',
|
|
60
|
+
'task:retry-policy-enforced',
|
|
61
|
+
'task:idempotent',
|
|
62
|
+
'task:terminal-handled',
|
|
63
|
+
'task:observability-recorded',
|
|
64
|
+
'task:duplicate-delivery-handled',
|
|
65
|
+
'webhook:signature-accepted',
|
|
66
|
+
'webhook:signature-rejected',
|
|
67
|
+
'webhook:malformed-rejected',
|
|
68
|
+
'webhook:replay-idempotent',
|
|
69
|
+
'webhook:retry-bounded',
|
|
70
|
+
];
|
|
71
|
+
/** Contracts that require a paired positive-control case. */
|
|
72
|
+
export const CONTROL_REQUIRED_CONTRACTS = new Set([
|
|
73
|
+
'auth:role-denied',
|
|
74
|
+
'auth:tenant-isolated',
|
|
75
|
+
'auth:denied-no-side-effect',
|
|
76
|
+
'auth:forged-token-rejected',
|
|
77
|
+
'validation:boundary-rejected',
|
|
78
|
+
'validation:no-side-effect-on-reject',
|
|
79
|
+
'workflow:transition-rejected',
|
|
80
|
+
'webhook:signature-rejected',
|
|
81
|
+
'webhook:malformed-rejected',
|
|
82
|
+
]);
|
|
83
|
+
/** Contracts that may omit state rules (transport-only operational). */
|
|
84
|
+
export const TRANSPORT_ONLY_CONTRACTS = new Set([
|
|
85
|
+
HTTP_REQUEST_OBSERVED,
|
|
86
|
+
HTTP_RESPONSE_STATUS_OK,
|
|
87
|
+
]);
|
|
88
|
+
/** Contracts whose proof is a mutation, rejection, or duplicate-delivery. */
|
|
89
|
+
export const MUTATION_PROOF_CONTRACTS = new Set([
|
|
90
|
+
HTTP_EFFECT_VERIFIED,
|
|
91
|
+
HTTP_READ_RESULT_VERIFIED,
|
|
92
|
+
'auth:role-allowed',
|
|
93
|
+
'auth:role-denied',
|
|
94
|
+
'auth:tenant-isolated',
|
|
95
|
+
'auth:denied-no-side-effect',
|
|
96
|
+
'auth:forged-token-rejected',
|
|
97
|
+
'validation:boundary-accepted',
|
|
98
|
+
'validation:boundary-rejected',
|
|
99
|
+
'validation:no-side-effect-on-reject',
|
|
100
|
+
'validation:error-message-explicit',
|
|
101
|
+
'validation:envelope-shape-stable',
|
|
102
|
+
'workflow:transition-allowed',
|
|
103
|
+
'workflow:transition-rejected',
|
|
104
|
+
'workflow:terminal-immutable',
|
|
105
|
+
'workflow:audit-emitted',
|
|
106
|
+
'workflow:persisted-final-state',
|
|
107
|
+
'task:retry-policy-enforced',
|
|
108
|
+
'task:idempotent',
|
|
109
|
+
'task:terminal-handled',
|
|
110
|
+
'task:observability-recorded',
|
|
111
|
+
'task:duplicate-delivery-handled',
|
|
112
|
+
'webhook:signature-accepted',
|
|
113
|
+
'webhook:signature-rejected',
|
|
114
|
+
'webhook:malformed-rejected',
|
|
115
|
+
'webhook:replay-idempotent',
|
|
116
|
+
'webhook:retry-bounded',
|
|
117
|
+
]);
|
|
118
|
+
/** Field names that must never carry YAML secret literals. */
|
|
119
|
+
const SECRET_FIELD_PATTERN = /^(authorization|cookie|set-cookie|token|password|secret|api[-_]?key|credential)$/i;
|
|
120
|
+
/** Lookup segments forbidden in fixture/request pointers. */
|
|
121
|
+
const FORBIDDEN_LOOKUP_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
122
|
+
/** Finite JSON values (no NaN/Infinity, no class instances). */
|
|
123
|
+
export const JsonSchema = z.lazy(() => z.union([
|
|
124
|
+
z.null(),
|
|
125
|
+
z.boolean(),
|
|
126
|
+
z.number().finite(),
|
|
127
|
+
z.string(),
|
|
128
|
+
z.array(JsonSchema),
|
|
129
|
+
z.record(z.string(), JsonSchema),
|
|
130
|
+
]));
|
|
131
|
+
/**
|
|
132
|
+
* Rejects lookup segments that would punch through the prototype chain.
|
|
133
|
+
*
|
|
134
|
+
* Args:
|
|
135
|
+
* key (string): dotted fixture key or JSON Pointer path.
|
|
136
|
+
* path (PropertyKey[]): zod issue path.
|
|
137
|
+
* ctx (z.RefinementCtx): schema context.
|
|
138
|
+
*/
|
|
139
|
+
function rejectUnsafeLookup(key, path, ctx) {
|
|
140
|
+
const segments = key.includes('/')
|
|
141
|
+
? key.split('/').filter((segment) => segment.length > 0)
|
|
142
|
+
: key.split('.');
|
|
143
|
+
for (const segment of segments) {
|
|
144
|
+
if (FORBIDDEN_LOOKUP_SEGMENTS.has(segment)) {
|
|
145
|
+
ctx.addIssue({
|
|
146
|
+
code: 'custom',
|
|
147
|
+
path,
|
|
148
|
+
message: `lookup segment '${segment}' is forbidden (no prototype-chain traversal)`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Approved input: a case literal or a fixture-subject key. */
|
|
154
|
+
export const InputValueSchema = z.discriminatedUnion('from', [
|
|
155
|
+
z
|
|
156
|
+
.object({
|
|
157
|
+
from: z.literal('literal'),
|
|
158
|
+
value: JsonSchema,
|
|
159
|
+
})
|
|
160
|
+
.strict(),
|
|
161
|
+
z
|
|
162
|
+
.object({
|
|
163
|
+
from: z.literal('fixture'),
|
|
164
|
+
key: z.string().min(1),
|
|
165
|
+
})
|
|
166
|
+
.strict()
|
|
167
|
+
.superRefine((value, ctx) => {
|
|
168
|
+
rejectUnsafeLookup(value.key, ['key'], ctx);
|
|
169
|
+
}),
|
|
170
|
+
]);
|
|
171
|
+
/** Comparison transforms; exact identity is default. */
|
|
172
|
+
export const ValueTransformSchema = z.enum(['identity', 'trim', 'lowercase']);
|
|
173
|
+
/** Expected values: literals, fixture keys, observed request, or before-state. */
|
|
174
|
+
export const ExpectedValueSchema = z.discriminatedUnion('from', [
|
|
175
|
+
z
|
|
176
|
+
.object({
|
|
177
|
+
from: z.literal('literal'),
|
|
178
|
+
value: JsonSchema,
|
|
179
|
+
})
|
|
180
|
+
.strict(),
|
|
181
|
+
z
|
|
182
|
+
.object({
|
|
183
|
+
from: z.literal('fixture'),
|
|
184
|
+
key: z.string().min(1),
|
|
185
|
+
})
|
|
186
|
+
.strict()
|
|
187
|
+
.superRefine((value, ctx) => {
|
|
188
|
+
rejectUnsafeLookup(value.key, ['key'], ctx);
|
|
189
|
+
}),
|
|
190
|
+
z
|
|
191
|
+
.object({
|
|
192
|
+
from: z.literal('request'),
|
|
193
|
+
attempt: z.number().int().min(0),
|
|
194
|
+
pointer: z.string().min(1),
|
|
195
|
+
transform: ValueTransformSchema,
|
|
196
|
+
})
|
|
197
|
+
.strict()
|
|
198
|
+
.superRefine((value, ctx) => {
|
|
199
|
+
rejectUnsafeLookup(value.pointer, ['pointer'], ctx);
|
|
200
|
+
}),
|
|
201
|
+
z
|
|
202
|
+
.object({
|
|
203
|
+
from: z.literal('before'),
|
|
204
|
+
scope: z.string().min(1),
|
|
205
|
+
subject: InputValueSchema,
|
|
206
|
+
field: z.string().min(1),
|
|
207
|
+
})
|
|
208
|
+
.strict(),
|
|
209
|
+
]);
|
|
210
|
+
/**
|
|
211
|
+
* Bounded envelope-shape vocabulary (NOT JSON Schema). Recursive strict
|
|
212
|
+
* union: object/array/string/number/integer/boolean/null. No remote
|
|
213
|
+
* refs, callbacks, or coercion.
|
|
214
|
+
*/
|
|
215
|
+
export const EnvelopeShapeSchema = z.lazy(() => z.discriminatedUnion('type', [
|
|
216
|
+
z
|
|
217
|
+
.object({
|
|
218
|
+
type: z.literal('object'),
|
|
219
|
+
properties: z.record(z.string(), EnvelopeShapeSchema),
|
|
220
|
+
required: z.array(z.string().min(1)),
|
|
221
|
+
additionalProperties: z.literal(false),
|
|
222
|
+
})
|
|
223
|
+
.strict()
|
|
224
|
+
.superRefine((shape, ctx) => {
|
|
225
|
+
for (const name of shape.required) {
|
|
226
|
+
if (!(name in shape.properties)) {
|
|
227
|
+
ctx.addIssue({
|
|
228
|
+
code: 'custom',
|
|
229
|
+
path: ['required'],
|
|
230
|
+
message: `required field '${name}' is not a declared property`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}),
|
|
235
|
+
z
|
|
236
|
+
.object({
|
|
237
|
+
type: z.literal('array'),
|
|
238
|
+
items: EnvelopeShapeSchema,
|
|
239
|
+
minItems: z.number().int().min(0),
|
|
240
|
+
maxItems: z.number().int().min(0),
|
|
241
|
+
})
|
|
242
|
+
.strict()
|
|
243
|
+
.superRefine((shape, ctx) => {
|
|
244
|
+
if (shape.maxItems < shape.minItems) {
|
|
245
|
+
ctx.addIssue({
|
|
246
|
+
code: 'custom',
|
|
247
|
+
path: ['maxItems'],
|
|
248
|
+
message: 'maxItems must be >= minItems',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
z
|
|
253
|
+
.object({
|
|
254
|
+
type: z.literal('string'),
|
|
255
|
+
minLength: z.number().int().min(0),
|
|
256
|
+
maxLength: z.number().int().min(0),
|
|
257
|
+
enum: z.array(z.string()).min(1).optional(),
|
|
258
|
+
})
|
|
259
|
+
.strict()
|
|
260
|
+
.superRefine((shape, ctx) => {
|
|
261
|
+
if (shape.maxLength < shape.minLength) {
|
|
262
|
+
ctx.addIssue({
|
|
263
|
+
code: 'custom',
|
|
264
|
+
path: ['maxLength'],
|
|
265
|
+
message: 'maxLength must be >= minLength',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}),
|
|
269
|
+
z
|
|
270
|
+
.object({
|
|
271
|
+
type: z.literal('number'),
|
|
272
|
+
minimum: z.number().finite(),
|
|
273
|
+
maximum: z.number().finite(),
|
|
274
|
+
})
|
|
275
|
+
.strict(),
|
|
276
|
+
z
|
|
277
|
+
.object({
|
|
278
|
+
type: z.literal('integer'),
|
|
279
|
+
minimum: z.number().int(),
|
|
280
|
+
maximum: z.number().int(),
|
|
281
|
+
})
|
|
282
|
+
.strict(),
|
|
283
|
+
z.object({ type: z.literal('boolean') }).strict(),
|
|
284
|
+
z.object({ type: z.literal('null') }).strict(),
|
|
285
|
+
]));
|
|
286
|
+
/** Response observation rule. */
|
|
287
|
+
export const ResponseRuleSchema = z.discriminatedUnion('kind', [
|
|
288
|
+
z
|
|
289
|
+
.object({
|
|
290
|
+
kind: z.literal('equals'),
|
|
291
|
+
pointer: z.string().min(1),
|
|
292
|
+
value: ExpectedValueSchema,
|
|
293
|
+
})
|
|
294
|
+
.strict()
|
|
295
|
+
.superRefine((rule, ctx) => {
|
|
296
|
+
rejectUnsafeLookup(rule.pointer, ['pointer'], ctx);
|
|
297
|
+
}),
|
|
298
|
+
z
|
|
299
|
+
.object({
|
|
300
|
+
kind: z.literal('field-error'),
|
|
301
|
+
field: z.string().min(1),
|
|
302
|
+
fieldPointer: z.string().min(1),
|
|
303
|
+
codePointer: z.string().min(1),
|
|
304
|
+
allowedCodes: z.array(z.string().min(1)).min(1),
|
|
305
|
+
})
|
|
306
|
+
.strict(),
|
|
307
|
+
z
|
|
308
|
+
.object({
|
|
309
|
+
kind: z.literal('absent'),
|
|
310
|
+
pointer: z.string().min(1),
|
|
311
|
+
})
|
|
312
|
+
.strict()
|
|
313
|
+
.superRefine((rule, ctx) => {
|
|
314
|
+
rejectUnsafeLookup(rule.pointer, ['pointer'], ctx);
|
|
315
|
+
}),
|
|
316
|
+
z
|
|
317
|
+
.object({
|
|
318
|
+
kind: z.literal('entity-set'),
|
|
319
|
+
pointer: z.string().min(1),
|
|
320
|
+
identityFields: z.array(z.string().min(1)).min(1),
|
|
321
|
+
expected: z.array(InputValueSchema),
|
|
322
|
+
})
|
|
323
|
+
.strict(),
|
|
324
|
+
z
|
|
325
|
+
.object({
|
|
326
|
+
kind: z.literal('envelope'),
|
|
327
|
+
schema: EnvelopeShapeSchema,
|
|
328
|
+
})
|
|
329
|
+
.strict(),
|
|
330
|
+
]);
|
|
331
|
+
/** State observation rule over one declared effect scope. */
|
|
332
|
+
export const StateRuleSchema = z.discriminatedUnion('kind', [
|
|
333
|
+
z.object({ kind: z.literal('unchanged'), scope: z.string().min(1) }).strict(),
|
|
334
|
+
z
|
|
335
|
+
.object({
|
|
336
|
+
kind: z.literal('updated'),
|
|
337
|
+
scope: z.string().min(1),
|
|
338
|
+
subject: InputValueSchema,
|
|
339
|
+
fields: z.record(z.string(), ExpectedValueSchema),
|
|
340
|
+
})
|
|
341
|
+
.strict()
|
|
342
|
+
.superRefine((rule, ctx) => {
|
|
343
|
+
if (Object.keys(rule.fields).length === 0) {
|
|
344
|
+
ctx.addIssue({
|
|
345
|
+
code: 'custom',
|
|
346
|
+
path: ['fields'],
|
|
347
|
+
message: 'updated fields must be nonempty',
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}),
|
|
351
|
+
z
|
|
352
|
+
.object({
|
|
353
|
+
kind: z.literal('created'),
|
|
354
|
+
scope: z.string().min(1),
|
|
355
|
+
rows: z.array(z.object({ fields: z.record(z.string(), ExpectedValueSchema) }).strict()).min(1),
|
|
356
|
+
})
|
|
357
|
+
.strict(),
|
|
358
|
+
z
|
|
359
|
+
.object({
|
|
360
|
+
kind: z.literal('absent'),
|
|
361
|
+
scope: z.string().min(1),
|
|
362
|
+
subjects: z.array(InputValueSchema).min(1),
|
|
363
|
+
})
|
|
364
|
+
.strict(),
|
|
365
|
+
z
|
|
366
|
+
.object({
|
|
367
|
+
kind: z.literal('archived'),
|
|
368
|
+
scope: z.string().min(1),
|
|
369
|
+
subject: InputValueSchema,
|
|
370
|
+
fields: z.record(z.string(), ExpectedValueSchema),
|
|
371
|
+
})
|
|
372
|
+
.strict(),
|
|
373
|
+
z
|
|
374
|
+
.object({
|
|
375
|
+
kind: z.literal('exact-set'),
|
|
376
|
+
scope: z.string().min(1),
|
|
377
|
+
rows: z
|
|
378
|
+
.array(z
|
|
379
|
+
.object({
|
|
380
|
+
subject: InputValueSchema,
|
|
381
|
+
fields: z.record(z.string(), ExpectedValueSchema),
|
|
382
|
+
})
|
|
383
|
+
.strict())
|
|
384
|
+
.min(1),
|
|
385
|
+
})
|
|
386
|
+
.strict(),
|
|
387
|
+
z
|
|
388
|
+
.object({
|
|
389
|
+
kind: z.literal('append-only'),
|
|
390
|
+
scope: z.string().min(1),
|
|
391
|
+
rows: z.array(z.object({ fields: z.record(z.string(), ExpectedValueSchema) }).strict()).min(1),
|
|
392
|
+
})
|
|
393
|
+
.strict(),
|
|
394
|
+
z
|
|
395
|
+
.object({
|
|
396
|
+
kind: z.literal('count-delta'),
|
|
397
|
+
scope: z.string().min(1),
|
|
398
|
+
delta: z.number().int(),
|
|
399
|
+
})
|
|
400
|
+
.strict(),
|
|
401
|
+
z
|
|
402
|
+
.object({
|
|
403
|
+
kind: z.literal('transition'),
|
|
404
|
+
scope: z.string().min(1),
|
|
405
|
+
subject: InputValueSchema,
|
|
406
|
+
field: z.string().min(1),
|
|
407
|
+
from: ExpectedValueSchema,
|
|
408
|
+
to: ExpectedValueSchema,
|
|
409
|
+
})
|
|
410
|
+
.strict(),
|
|
411
|
+
z
|
|
412
|
+
.object({
|
|
413
|
+
kind: z.literal('attempts'),
|
|
414
|
+
resourceId: z.string().min(1),
|
|
415
|
+
count: z.number().int().min(0),
|
|
416
|
+
terminal: z.enum(['succeeded', 'failed', 'rejected']),
|
|
417
|
+
})
|
|
418
|
+
.strict(),
|
|
419
|
+
]);
|
|
420
|
+
/**
|
|
421
|
+
* One independently observed effect of a subject. Identity fields are
|
|
422
|
+
* the ordered real primary key (tenant included where needed) and cannot
|
|
423
|
+
* drop columns the classification already declared.
|
|
424
|
+
*/
|
|
425
|
+
export const EffectScopeSchema = z
|
|
426
|
+
.object({
|
|
427
|
+
id: z.string().min(1),
|
|
428
|
+
resourceId: z
|
|
429
|
+
.string()
|
|
430
|
+
.min(1)
|
|
431
|
+
.regex(/^[^:]+$/, "resourceId must not contain ':' (normalized graph id)"),
|
|
432
|
+
adapter: z.string().min(1),
|
|
433
|
+
scope: z.string().min(1),
|
|
434
|
+
identityFields: z.array(z.string().min(1)).min(1),
|
|
435
|
+
fields: z.array(z.string().min(1)).min(1),
|
|
436
|
+
completion: z.enum(['immediate', 'barrier']),
|
|
437
|
+
})
|
|
438
|
+
.strict();
|
|
439
|
+
const FieldMapSchema = z
|
|
440
|
+
.record(z.string(), InputValueSchema)
|
|
441
|
+
.superRefine((fields, ctx) => {
|
|
442
|
+
for (const name of Object.keys(fields)) {
|
|
443
|
+
if (SECRET_FIELD_PATTERN.test(name) && fields[name]?.from === 'literal') {
|
|
444
|
+
ctx.addIssue({
|
|
445
|
+
code: 'custom',
|
|
446
|
+
path: [name],
|
|
447
|
+
message: `secret field '${name}' must be an actor/signature reference, never a YAML literal`,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
const RequestBodySchema = z.discriminatedUnion('encoding', [
|
|
453
|
+
z
|
|
454
|
+
.object({
|
|
455
|
+
encoding: z.literal('json'),
|
|
456
|
+
fields: FieldMapSchema,
|
|
457
|
+
})
|
|
458
|
+
.strict(),
|
|
459
|
+
z
|
|
460
|
+
.object({
|
|
461
|
+
encoding: z.literal('form'),
|
|
462
|
+
fields: FieldMapSchema,
|
|
463
|
+
})
|
|
464
|
+
.strict(),
|
|
465
|
+
z
|
|
466
|
+
.object({
|
|
467
|
+
encoding: z.literal('multipart'),
|
|
468
|
+
fields: FieldMapSchema,
|
|
469
|
+
files: z.record(z.string(), z.string().min(1)),
|
|
470
|
+
})
|
|
471
|
+
.strict(),
|
|
472
|
+
z
|
|
473
|
+
.object({
|
|
474
|
+
encoding: z.literal('raw'),
|
|
475
|
+
fixture: z.string().min(1),
|
|
476
|
+
})
|
|
477
|
+
.strict(),
|
|
478
|
+
]);
|
|
479
|
+
/**
|
|
480
|
+
* Origin-relative path template: no scheme, userinfo, traversal, or
|
|
481
|
+
* encoded-separator ambiguity.
|
|
482
|
+
*
|
|
483
|
+
* Args:
|
|
484
|
+
* pathTemplate (string): candidate template.
|
|
485
|
+
*
|
|
486
|
+
* Returns:
|
|
487
|
+
* string | null: error message, or null when legal.
|
|
488
|
+
*/
|
|
489
|
+
export function pathTemplateError(pathTemplate) {
|
|
490
|
+
if (!pathTemplate.startsWith('/') || pathTemplate.startsWith('//')) {
|
|
491
|
+
return 'pathTemplate must be origin-relative and start with a single /';
|
|
492
|
+
}
|
|
493
|
+
if (/^[a-zA-Z][a-zA-Z+.-]*:/.test(pathTemplate) || pathTemplate.includes('://')) {
|
|
494
|
+
return 'pathTemplate must not be an absolute URL';
|
|
495
|
+
}
|
|
496
|
+
if (pathTemplate.includes('@')) {
|
|
497
|
+
return 'pathTemplate must not contain userinfo';
|
|
498
|
+
}
|
|
499
|
+
if (pathTemplate.split('/').some((segment) => segment === '..' || segment === '.')) {
|
|
500
|
+
return 'pathTemplate must not contain path traversal';
|
|
501
|
+
}
|
|
502
|
+
if (/%2f|%2e|%5c/i.test(pathTemplate)) {
|
|
503
|
+
return 'pathTemplate must not contain encoded separators';
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
/** Surface / HTTP / delivery action (no nested sequences). */
|
|
508
|
+
export const PrimitiveActionSchema = z.discriminatedUnion('kind', [
|
|
509
|
+
z
|
|
510
|
+
.object({
|
|
511
|
+
kind: z.literal('surface'),
|
|
512
|
+
surface: z.string().min(1),
|
|
513
|
+
operation: z.enum(['create', 'read', 'update', 'delete']),
|
|
514
|
+
subject: InputValueSchema.optional(),
|
|
515
|
+
fields: FieldMapSchema,
|
|
516
|
+
files: z.record(z.string(), z.string().min(1)).optional(),
|
|
517
|
+
})
|
|
518
|
+
.strict(),
|
|
519
|
+
z
|
|
520
|
+
.object({
|
|
521
|
+
kind: z.literal('request'),
|
|
522
|
+
method: z.enum(BEHAVIOR_HTTP_METHODS),
|
|
523
|
+
pathTemplate: z.string().min(1),
|
|
524
|
+
path: z.record(z.string(), InputValueSchema),
|
|
525
|
+
query: z.record(z.string(), InputValueSchema),
|
|
526
|
+
body: RequestBodySchema,
|
|
527
|
+
credentialVariant: z.enum(['valid', 'missing', 'corrupted']),
|
|
528
|
+
signatureProfile: z.string().min(1).optional(),
|
|
529
|
+
})
|
|
530
|
+
.strict()
|
|
531
|
+
.superRefine((action, ctx) => {
|
|
532
|
+
const error = pathTemplateError(action.pathTemplate);
|
|
533
|
+
if (error !== null) {
|
|
534
|
+
ctx.addIssue({ code: 'custom', path: ['pathTemplate'], message: error });
|
|
535
|
+
}
|
|
536
|
+
}),
|
|
537
|
+
z
|
|
538
|
+
.object({
|
|
539
|
+
kind: z.literal('deliver'),
|
|
540
|
+
resourceId: z
|
|
541
|
+
.string()
|
|
542
|
+
.min(1)
|
|
543
|
+
.regex(/^[^:]+$/, "resourceId must not contain ':'"),
|
|
544
|
+
payload: InputValueSchema,
|
|
545
|
+
idempotencyKey: InputValueSchema,
|
|
546
|
+
deliveryId: InputValueSchema,
|
|
547
|
+
count: z.number().int().min(1),
|
|
548
|
+
schedule: z.enum(['serial', 'concurrent']),
|
|
549
|
+
})
|
|
550
|
+
.strict(),
|
|
551
|
+
]);
|
|
552
|
+
const StatusListSchema = z
|
|
553
|
+
.array(z.number().int().min(100).max(599))
|
|
554
|
+
.superRefine((statuses, ctx) => {
|
|
555
|
+
const sorted = [...statuses].sort((a, b) => a - b);
|
|
556
|
+
for (let index = 0; index < statuses.length; index += 1) {
|
|
557
|
+
if (statuses[index] !== sorted[index]) {
|
|
558
|
+
ctx.addIssue({
|
|
559
|
+
code: 'custom',
|
|
560
|
+
path: [index],
|
|
561
|
+
message: 'statuses must be sorted ascending and duplicate-free',
|
|
562
|
+
});
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (index > 0 && statuses[index] === statuses[index - 1]) {
|
|
566
|
+
ctx.addIssue({
|
|
567
|
+
code: 'custom',
|
|
568
|
+
path: [index],
|
|
569
|
+
message: `duplicate status ${String(statuses[index])}`,
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
const StepExpectSchema = z
|
|
576
|
+
.object({
|
|
577
|
+
statuses: StatusListSchema,
|
|
578
|
+
state: z.array(StateRuleSchema),
|
|
579
|
+
})
|
|
580
|
+
.strict();
|
|
581
|
+
/** Finite nonempty sequence of primitive actions. */
|
|
582
|
+
export const SequenceActionSchema = z
|
|
583
|
+
.object({
|
|
584
|
+
kind: z.literal('sequence'),
|
|
585
|
+
steps: z
|
|
586
|
+
.array(z
|
|
587
|
+
.object({
|
|
588
|
+
id: z.string().min(1),
|
|
589
|
+
action: PrimitiveActionSchema,
|
|
590
|
+
expect: StepExpectSchema,
|
|
591
|
+
})
|
|
592
|
+
.strict())
|
|
593
|
+
.min(1),
|
|
594
|
+
})
|
|
595
|
+
.strict()
|
|
596
|
+
.superRefine((sequence, ctx) => {
|
|
597
|
+
const seen = new Set();
|
|
598
|
+
for (let index = 0; index < sequence.steps.length; index += 1) {
|
|
599
|
+
const stepId = sequence.steps[index]?.id;
|
|
600
|
+
if (stepId === undefined)
|
|
601
|
+
continue;
|
|
602
|
+
if (seen.has(stepId)) {
|
|
603
|
+
ctx.addIssue({
|
|
604
|
+
code: 'custom',
|
|
605
|
+
path: ['steps', index, 'id'],
|
|
606
|
+
message: `duplicate sequence step id '${stepId}'`,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
seen.add(stepId);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
/** Case action: one primitive or one finite sequence. */
|
|
613
|
+
export const BehaviorActionSchema = z.union([PrimitiveActionSchema, SequenceActionSchema]);
|
|
614
|
+
const VisibleExpectSchema = z
|
|
615
|
+
.object({
|
|
616
|
+
surface: z.string().min(1),
|
|
617
|
+
subject: InputValueSchema,
|
|
618
|
+
fields: z.record(z.string(), ExpectedValueSchema),
|
|
619
|
+
})
|
|
620
|
+
.strict();
|
|
621
|
+
const CaseExpectSchema = z
|
|
622
|
+
.object({
|
|
623
|
+
statuses: StatusListSchema,
|
|
624
|
+
response: z.array(ResponseRuleSchema),
|
|
625
|
+
state: z.array(StateRuleSchema),
|
|
626
|
+
visible: VisibleExpectSchema.optional(),
|
|
627
|
+
})
|
|
628
|
+
.strict();
|
|
629
|
+
/**
|
|
630
|
+
* Whether an action produces an HTTP/surface response that needs statuses.
|
|
631
|
+
*
|
|
632
|
+
* Args:
|
|
633
|
+
* action (BehaviorAction): the case action.
|
|
634
|
+
*
|
|
635
|
+
* Returns:
|
|
636
|
+
* boolean: true when statuses must be nonempty.
|
|
637
|
+
*/
|
|
638
|
+
function actionNeedsStatuses(action) {
|
|
639
|
+
if (action.kind === 'deliver')
|
|
640
|
+
return false;
|
|
641
|
+
if (action.kind === 'sequence') {
|
|
642
|
+
return action.steps.some((step) => step.action.kind !== 'deliver');
|
|
643
|
+
}
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
/** One finite, deterministic behavior case. */
|
|
647
|
+
export const BehaviorCaseSchema = z
|
|
648
|
+
.object({
|
|
649
|
+
id: BehaviorCaseIdSchema,
|
|
650
|
+
contract: z.enum(BEHAVIOR_CONTRACTS),
|
|
651
|
+
channel: z.enum(['engine-browser', 'engine-http', 'engine-task']),
|
|
652
|
+
fixture: z.string().min(1),
|
|
653
|
+
actor: z.string().min(1),
|
|
654
|
+
action: BehaviorActionSchema,
|
|
655
|
+
expect: CaseExpectSchema,
|
|
656
|
+
controlCase: BehaviorCaseIdSchema.optional(),
|
|
657
|
+
})
|
|
658
|
+
.strict()
|
|
659
|
+
.superRefine((behaviorCase, ctx) => {
|
|
660
|
+
if (CONTROL_REQUIRED_CONTRACTS.has(behaviorCase.contract) && behaviorCase.controlCase === undefined) {
|
|
661
|
+
ctx.addIssue({
|
|
662
|
+
code: 'custom',
|
|
663
|
+
path: ['controlCase'],
|
|
664
|
+
message: `contract '${behaviorCase.contract}' requires controlCase (a passing authorized counterpart)`,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (actionNeedsStatuses(behaviorCase.action) && behaviorCase.expect.statuses.length === 0) {
|
|
668
|
+
ctx.addIssue({
|
|
669
|
+
code: 'custom',
|
|
670
|
+
path: ['expect', 'statuses'],
|
|
671
|
+
message: 'statuses is nonempty for every HTTP/surface step',
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
if (behaviorCase.action.kind === 'deliver' &&
|
|
675
|
+
behaviorCase.expect.statuses.length > 0) {
|
|
676
|
+
ctx.addIssue({
|
|
677
|
+
code: 'custom',
|
|
678
|
+
path: ['expect', 'statuses'],
|
|
679
|
+
message: 'statuses is empty only for a purely queue-driven case',
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (MUTATION_PROOF_CONTRACTS.has(behaviorCase.contract) &&
|
|
683
|
+
behaviorCase.expect.state.length === 0) {
|
|
684
|
+
ctx.addIssue({
|
|
685
|
+
code: 'custom',
|
|
686
|
+
path: ['expect', 'state'],
|
|
687
|
+
message: 'empty state rules cannot satisfy a mutation, no-side-effect or idempotency contract',
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
if (behaviorCase.expect.state.length === 1 &&
|
|
691
|
+
behaviorCase.expect.state[0]?.kind === 'count-delta' &&
|
|
692
|
+
MUTATION_PROOF_CONTRACTS.has(behaviorCase.contract)) {
|
|
693
|
+
ctx.addIssue({
|
|
694
|
+
code: 'custom',
|
|
695
|
+
path: ['expect', 'state', 0, 'kind'],
|
|
696
|
+
message: 'count-delta cannot be the sole state rule for mutation, rejection, idempotency or duplicate-delivery proof',
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (behaviorCase.expect.visible !== undefined &&
|
|
700
|
+
behaviorCase.channel === 'engine-http' &&
|
|
701
|
+
behaviorCase.action.kind !== 'sequence') {
|
|
702
|
+
ctx.addIssue({
|
|
703
|
+
code: 'custom',
|
|
704
|
+
path: ['expect', 'visible'],
|
|
705
|
+
message: 'an engine-http case cannot assert browser visibility without a separately declared engine-browser step',
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
if (behaviorCase.channel === 'engine-task' && behaviorCase.action.kind === 'surface') {
|
|
709
|
+
ctx.addIssue({
|
|
710
|
+
code: 'custom',
|
|
711
|
+
path: ['action', 'kind'],
|
|
712
|
+
message: "channel 'engine-task' cannot drive a surface action",
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
if (behaviorCase.channel === 'engine-browser' && behaviorCase.action.kind === 'deliver') {
|
|
716
|
+
ctx.addIssue({
|
|
717
|
+
code: 'custom',
|
|
718
|
+
path: ['action', 'kind'],
|
|
719
|
+
message: "channel 'engine-browser' cannot drive a deliver action",
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
/** Owner disposition for an endpoint that is not strongly tested. */
|
|
724
|
+
export const BehaviorDispositionSchema = z
|
|
725
|
+
.object({
|
|
726
|
+
kind: z.enum(['operational-only', 'out-of-scope']),
|
|
727
|
+
reason: z.string().min(1),
|
|
728
|
+
})
|
|
729
|
+
.strict();
|
|
730
|
+
/**
|
|
731
|
+
* Validates unique case ids and that controlCase names a sibling case.
|
|
732
|
+
*
|
|
733
|
+
* Args:
|
|
734
|
+
* cases (BehaviorCase[]): cases of one subject.
|
|
735
|
+
* ctx (z.RefinementCtx): schema context.
|
|
736
|
+
* pathPrefix (PropertyKey[]): path to the cases array.
|
|
737
|
+
*/
|
|
738
|
+
function refineCases(cases, ctx, pathPrefix) {
|
|
739
|
+
const seen = new Set();
|
|
740
|
+
for (let index = 0; index < cases.length; index += 1) {
|
|
741
|
+
const id = cases[index]?.id;
|
|
742
|
+
if (id === undefined)
|
|
743
|
+
continue;
|
|
744
|
+
if (seen.has(id)) {
|
|
745
|
+
ctx.addIssue({
|
|
746
|
+
code: 'custom',
|
|
747
|
+
path: [...pathPrefix, index, 'id'],
|
|
748
|
+
message: `duplicate case id '${id}' within the subject resource`,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
seen.add(id);
|
|
752
|
+
}
|
|
753
|
+
for (let index = 0; index < cases.length; index += 1) {
|
|
754
|
+
const control = cases[index]?.controlCase;
|
|
755
|
+
if (control === undefined)
|
|
756
|
+
continue;
|
|
757
|
+
if (!seen.has(control)) {
|
|
758
|
+
ctx.addIssue({
|
|
759
|
+
code: 'custom',
|
|
760
|
+
path: [...pathPrefix, index, 'controlCase'],
|
|
761
|
+
message: `controlCase '${control}' does not name a case on this subject`,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
if (control === cases[index]?.id) {
|
|
765
|
+
ctx.addIssue({
|
|
766
|
+
code: 'custom',
|
|
767
|
+
path: [...pathPrefix, index, 'controlCase'],
|
|
768
|
+
message: 'controlCase cannot name the same case',
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Validates unique effect ids within a subject.
|
|
775
|
+
*
|
|
776
|
+
* Args:
|
|
777
|
+
* effects (EffectScope[]): declared effects.
|
|
778
|
+
* ctx (z.RefinementCtx): schema context.
|
|
779
|
+
* pathPrefix (PropertyKey[]): path to the effects array.
|
|
780
|
+
*/
|
|
781
|
+
function refineEffects(effects, ctx, pathPrefix) {
|
|
782
|
+
const seen = new Set();
|
|
783
|
+
for (let index = 0; index < effects.length; index += 1) {
|
|
784
|
+
const id = effects[index]?.id;
|
|
785
|
+
if (id === undefined)
|
|
786
|
+
continue;
|
|
787
|
+
if (seen.has(id)) {
|
|
788
|
+
ctx.addIssue({
|
|
789
|
+
code: 'custom',
|
|
790
|
+
path: [...pathPrefix, index, 'id'],
|
|
791
|
+
message: `duplicate effect id '${id}' within the subject`,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
seen.add(id);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/** One discovered HTTP endpoint's approved behavior. */
|
|
798
|
+
export const EndpointBehaviorSchema = z
|
|
799
|
+
.object({
|
|
800
|
+
resourceId: z
|
|
801
|
+
.string()
|
|
802
|
+
.min(1)
|
|
803
|
+
.regex(/^[^:]+$/, "resourceId must not contain ':' (normalized http.endpoint graph id)"),
|
|
804
|
+
effects: z.array(EffectScopeSchema),
|
|
805
|
+
cases: z.array(BehaviorCaseSchema),
|
|
806
|
+
disposition: BehaviorDispositionSchema.optional(),
|
|
807
|
+
})
|
|
808
|
+
.strict()
|
|
809
|
+
.superRefine((endpoint, ctx) => {
|
|
810
|
+
refineEffects(endpoint.effects, ctx, ['effects']);
|
|
811
|
+
refineCases(endpoint.cases, ctx, ['cases']);
|
|
812
|
+
if (endpoint.disposition === undefined && endpoint.cases.length === 0) {
|
|
813
|
+
ctx.addIssue({
|
|
814
|
+
code: 'custom',
|
|
815
|
+
path: ['cases'],
|
|
816
|
+
message: 'in-scope endpoints require at least one case or an owner disposition',
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
if (endpoint.disposition?.kind === 'operational-only' && endpoint.cases.length === 0) {
|
|
820
|
+
// Transport check is compiled even without owner cases.
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
for (let index = 0; index < endpoint.cases.length; index += 1) {
|
|
824
|
+
const behaviorCase = endpoint.cases[index];
|
|
825
|
+
if (behaviorCase === undefined)
|
|
826
|
+
continue;
|
|
827
|
+
for (let ruleIndex = 0; ruleIndex < behaviorCase.expect.state.length; ruleIndex += 1) {
|
|
828
|
+
const rule = behaviorCase.expect.state[ruleIndex];
|
|
829
|
+
if (rule === undefined || rule.kind === 'attempts')
|
|
830
|
+
continue;
|
|
831
|
+
if (!endpoint.effects.some((effect) => effect.id === rule.scope)) {
|
|
832
|
+
ctx.addIssue({
|
|
833
|
+
code: 'custom',
|
|
834
|
+
path: ['cases', index, 'expect', 'state', ruleIndex, 'scope'],
|
|
835
|
+
message: `state rule scope '${rule.scope}' is not a declared effect id`,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
/** One domain/task/workflow resource's approved behavior. */
|
|
842
|
+
export const ResourceBehaviorSchema = z
|
|
843
|
+
.object({
|
|
844
|
+
resourceId: z
|
|
845
|
+
.string()
|
|
846
|
+
.min(1)
|
|
847
|
+
.regex(/^[^:]+$/, "resourceId must not contain ':'"),
|
|
848
|
+
effects: z.array(EffectScopeSchema),
|
|
849
|
+
cases: z.array(BehaviorCaseSchema).min(1),
|
|
850
|
+
})
|
|
851
|
+
.strict()
|
|
852
|
+
.superRefine((resource, ctx) => {
|
|
853
|
+
refineEffects(resource.effects, ctx, ['effects']);
|
|
854
|
+
refineCases(resource.cases, ctx, ['cases']);
|
|
855
|
+
});
|
|
856
|
+
/** The `.gateforge/behavior.yml` document. */
|
|
857
|
+
export const BehaviorPolicySchema = z
|
|
858
|
+
.object({
|
|
859
|
+
schemaVersion: SchemaVersionField,
|
|
860
|
+
endpoints: z.array(EndpointBehaviorSchema),
|
|
861
|
+
resources: z.array(ResourceBehaviorSchema),
|
|
862
|
+
})
|
|
863
|
+
.strict()
|
|
864
|
+
.superRefine((policy, ctx) => {
|
|
865
|
+
const seenEndpoints = new Set();
|
|
866
|
+
for (let index = 0; index < policy.endpoints.length; index += 1) {
|
|
867
|
+
const id = policy.endpoints[index]?.resourceId;
|
|
868
|
+
if (id === undefined)
|
|
869
|
+
continue;
|
|
870
|
+
if (seenEndpoints.has(id)) {
|
|
871
|
+
ctx.addIssue({
|
|
872
|
+
code: 'custom',
|
|
873
|
+
path: ['endpoints', index, 'resourceId'],
|
|
874
|
+
message: `duplicate endpoint '${id}': contradictory bindings are invalid configuration`,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
seenEndpoints.add(id);
|
|
878
|
+
}
|
|
879
|
+
const seenResources = new Set();
|
|
880
|
+
for (let index = 0; index < policy.resources.length; index += 1) {
|
|
881
|
+
const id = policy.resources[index]?.resourceId;
|
|
882
|
+
if (id === undefined)
|
|
883
|
+
continue;
|
|
884
|
+
if (seenResources.has(id)) {
|
|
885
|
+
ctx.addIssue({
|
|
886
|
+
code: 'custom',
|
|
887
|
+
path: ['resources', index, 'resourceId'],
|
|
888
|
+
message: `duplicate resource '${id}': contradictory bindings are invalid configuration`,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
seenResources.add(id);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
/** Re-export for callers that hash case identities. */
|
|
895
|
+
export { FingerprintHexSchema, ContractNameSchema };
|
|
896
|
+
//# sourceMappingURL=behavior-policy.js.map
|