@gate-forge/core 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/classifier/bind.d.ts +2 -0
- package/dist/classifier/bind.d.ts.map +1 -1
- package/dist/classifier/bind.js +8 -0
- package/dist/classifier/bind.js.map +1 -1
- package/dist/classifier/classify.d.ts +2 -0
- package/dist/classifier/classify.d.ts.map +1 -1
- package/dist/classifier/classify.js +127 -2
- package/dist/classifier/classify.js.map +1 -1
- package/dist/classifier/index.d.ts +2 -2
- package/dist/classifier/index.d.ts.map +1 -1
- package/dist/classifier/index.js +1 -1
- package/dist/classifier/index.js.map +1 -1
- package/dist/classifier/schema.d.ts +54 -0
- package/dist/classifier/schema.d.ts.map +1 -1
- package/dist/classifier/schema.js +17 -0
- package/dist/classifier/schema.js.map +1 -1
- package/dist/config/index.d.ts +186 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +327 -1
- package/dist/config/index.js.map +1 -1
- package/dist/graph/schema.d.ts +34 -0
- package/dist/graph/schema.d.ts.map +1 -1
- package/dist/index.d.ts +80 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -6
- package/dist/index.js.map +1 -1
- package/dist/mapping/resolve.d.ts +32 -1
- package/dist/mapping/resolve.d.ts.map +1 -1
- package/dist/mapping/resolve.js +297 -65
- package/dist/mapping/resolve.js.map +1 -1
- package/dist/policy/evaluate.d.ts +30 -0
- package/dist/policy/evaluate.d.ts.map +1 -1
- package/dist/policy/evaluate.js +22 -12
- package/dist/policy/evaluate.js.map +1 -1
- package/dist/quarantine/index.d.ts +82 -0
- package/dist/quarantine/index.d.ts.map +1 -0
- package/dist/quarantine/index.js +194 -0
- package/dist/quarantine/index.js.map +1 -0
- package/dist/receipt/index.d.ts +65 -1
- package/dist/receipt/index.d.ts.map +1 -1
- package/dist/receipt/index.js +115 -1
- package/dist/receipt/index.js.map +1 -1
- package/dist/report/human-message.d.ts +33 -0
- package/dist/report/human-message.d.ts.map +1 -0
- package/dist/report/human-message.js +33 -0
- package/dist/report/human-message.js.map +1 -0
- package/dist/report/index.d.ts +183 -9
- package/dist/report/index.d.ts.map +1 -1
- package/dist/report/index.js +317 -23
- package/dist/report/index.js.map +1 -1
- package/dist/schemas/adoption.d.ts +2 -0
- package/dist/schemas/adoption.d.ts.map +1 -1
- package/dist/schemas/adoption.js +6 -0
- package/dist/schemas/adoption.js.map +1 -1
- package/dist/schemas/behavior-catalog.d.ts +12 -0
- package/dist/schemas/behavior-catalog.d.ts.map +1 -1
- package/dist/schemas/behavior-evidence.d.ts +40 -0
- package/dist/schemas/behavior-evidence.d.ts.map +1 -1
- package/dist/schemas/behavior-evidence.js +9 -0
- package/dist/schemas/behavior-evidence.js.map +1 -1
- package/dist/schemas/behavior-policy.d.ts +26 -0
- package/dist/schemas/behavior-policy.d.ts.map +1 -1
- package/dist/schemas/behavior-policy.js +37 -1
- package/dist/schemas/behavior-policy.js.map +1 -1
- package/dist/schemas/execution-result.d.ts +17 -5
- package/dist/schemas/execution-result.d.ts.map +1 -1
- package/dist/schemas/execution-result.js +57 -2
- package/dist/schemas/execution-result.js.map +1 -1
- package/dist/schemas/gate-receipt.d.ts +25 -2
- package/dist/schemas/gate-receipt.d.ts.map +1 -1
- package/dist/schemas/gate-receipt.js +181 -2
- package/dist/schemas/gate-receipt.js.map +1 -1
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/policy.d.ts +19 -0
- package/dist/schemas/policy.d.ts.map +1 -1
- package/dist/schemas/policy.js +36 -0
- package/dist/schemas/policy.js.map +1 -1
- package/dist/schemas/quarantine.d.ts +25 -0
- package/dist/schemas/quarantine.d.ts.map +1 -0
- package/dist/schemas/quarantine.js +34 -0
- package/dist/schemas/quarantine.js.map +1 -0
- package/dist/schemas/queue-observer.d.ts +195 -0
- package/dist/schemas/queue-observer.d.ts.map +1 -0
- package/dist/schemas/queue-observer.js +190 -0
- package/dist/schemas/queue-observer.js.map +1 -0
- package/dist/schemas/run-record.d.ts +57 -0
- package/dist/schemas/run-record.d.ts.map +1 -0
- package/dist/schemas/run-record.js +108 -0
- package/dist/schemas/run-record.js.map +1 -0
- package/dist/schemas/runtime-config.d.ts +67 -0
- package/dist/schemas/runtime-config.d.ts.map +1 -1
- package/dist/schemas/runtime-config.js +75 -1
- package/dist/schemas/runtime-config.js.map +1 -1
- package/dist/schemas/signature-profile.d.ts +99 -0
- package/dist/schemas/signature-profile.d.ts.map +1 -0
- package/dist/schemas/signature-profile.js +197 -0
- package/dist/schemas/signature-profile.js.map +1 -0
- 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 +12 -0
- package/dist/schemas/test-map.js.map +1 -1
- package/dist/schemas/verdict.d.ts +19 -4
- package/dist/schemas/verdict.d.ts.map +1 -1
- package/dist/schemas/verdict.js +42 -5
- package/dist/schemas/verdict.js.map +1 -1
- package/dist/strictness.d.ts +102 -0
- package/dist/strictness.d.ts.map +1 -0
- package/dist/strictness.js +92 -0
- package/dist/strictness.js.map +1 -0
- package/dist/supervision/index.js +6 -6
- package/dist/supervision/index.js.map +1 -1
- package/dist/twin-paths.d.ts +125 -0
- package/dist/twin-paths.d.ts.map +1 -0
- package/dist/twin-paths.js +233 -0
- package/dist/twin-paths.js.map +1 -0
- package/dist/verdict/behavior.d.ts.map +1 -1
- package/dist/verdict/behavior.js +284 -10
- package/dist/verdict/behavior.js.map +1 -1
- package/dist/verdict/cause.d.ts.map +1 -1
- package/dist/verdict/cause.js +18 -0
- package/dist/verdict/cause.js.map +1 -1
- package/dist/verdict/evaluate.d.ts +62 -0
- package/dist/verdict/evaluate.d.ts.map +1 -1
- package/dist/verdict/evaluate.js +300 -13
- package/dist/verdict/evaluate.js.map +1 -1
- package/dist/verdict/index.d.ts +3 -3
- package/dist/verdict/index.d.ts.map +1 -1
- package/dist/verdict/index.js +3 -3
- package/dist/verdict/index.js.map +1 -1
- package/dist/verdict/pack-verifiers.d.ts +17 -3
- package/dist/verdict/pack-verifiers.d.ts.map +1 -1
- package/dist/verdict/pack-verifiers.js +47 -8
- package/dist/verdict/pack-verifiers.js.map +1 -1
- package/dist/verdict/registry.d.ts +19 -0
- package/dist/verdict/registry.d.ts.map +1 -1
- package/dist/verdict/registry.js +21 -0
- package/dist/verdict/registry.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twin path coverage (E64): the pure half of "did the raw test and its
|
|
3
|
+
* witnessed twin actually exercise the same requests?"
|
|
4
|
+
*
|
|
5
|
+
* The reported bug is not a red test. It is a GREEN one: a raw test and
|
|
6
|
+
* its witnessed twin shared a helper whose parameter defaults sent them
|
|
7
|
+
* down different paths — the list call carried `?tab=all` in one and
|
|
8
|
+
* `?tab=open` in the other — so "green three times" proved nothing
|
|
9
|
+
* about the path the witnessed twin covered, and nobody could say so
|
|
10
|
+
* from the run.
|
|
11
|
+
*
|
|
12
|
+
* This module turns two sets of observed requests into the honest
|
|
13
|
+
* question: which request shapes did one twin exercise that the other
|
|
14
|
+
* never did? The observation itself lives engine-side (the proxy); the
|
|
15
|
+
* comparison, the shape vocabulary and the finding text live here, in
|
|
16
|
+
* core, because they are domain logic and must be testable without a
|
|
17
|
+
* browser.
|
|
18
|
+
*
|
|
19
|
+
* What a shape may contain is deliberately narrow. A shape is a method,
|
|
20
|
+
* a route TEMPLATE (never a concrete id) and the values of an
|
|
21
|
+
* owner-declared query-key allowlist. No body, no header, no cookie, no
|
|
22
|
+
* non-allowlisted value: a shape list is something an owner pastes into
|
|
23
|
+
* a bug, so it must be impossible for it to carry a secret.
|
|
24
|
+
*/
|
|
25
|
+
import { compareStrings } from './graph/util.js';
|
|
26
|
+
/** Segments that identify nothing: an id is an id wherever it appears. */
|
|
27
|
+
const IDENTIFIER_SEGMENT = /^(\d+|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;
|
|
28
|
+
/** Splits a request target into its path and its decoded query pairs. */
|
|
29
|
+
function splitTarget(url) {
|
|
30
|
+
const cut = url.search(/[?#]/);
|
|
31
|
+
const path = cut === -1 ? url : url.slice(0, cut);
|
|
32
|
+
const search = cut === -1 ? '' : url.slice(cut + 1).split('#')[0] ?? '';
|
|
33
|
+
const query = search === ''
|
|
34
|
+
? []
|
|
35
|
+
: search.split('&').map((pair) => {
|
|
36
|
+
const equals = pair.indexOf('=');
|
|
37
|
+
return equals === -1
|
|
38
|
+
? [pair, '']
|
|
39
|
+
: [pair.slice(0, equals), decodeURIComponent(pair.slice(equals + 1).replace(/\+/g, ' '))];
|
|
40
|
+
});
|
|
41
|
+
return { path: path === '' ? '/' : path, query };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Rewrites a concrete path to a template: every numeric or UUID segment
|
|
45
|
+
* becomes `{}`. Used when the run has no compiled route inventory to
|
|
46
|
+
* resolve against, so a shape still never carries an identifier.
|
|
47
|
+
*/
|
|
48
|
+
function templatizePath(path) {
|
|
49
|
+
return path
|
|
50
|
+
.split('/')
|
|
51
|
+
.map((segment) => (IDENTIFIER_SEGMENT.test(segment) ? '{}' : segment))
|
|
52
|
+
.join('/');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Resolves a concrete path against the compiled route inventory: the
|
|
56
|
+
* first template whose segment count and static segments match wins. A
|
|
57
|
+
* run with no inventory (or no match) falls back to the identifier-
|
|
58
|
+
* blind template, never to the raw path.
|
|
59
|
+
*/
|
|
60
|
+
function routeOf(path, inventory) {
|
|
61
|
+
const segments = path.split('/');
|
|
62
|
+
for (const template of inventory?.templates ?? []) {
|
|
63
|
+
const parts = template.split('/');
|
|
64
|
+
if (parts.length !== segments.length)
|
|
65
|
+
continue;
|
|
66
|
+
const matches = parts.every((part, index) => part === '{}' || part === segments[index]);
|
|
67
|
+
if (matches)
|
|
68
|
+
return template;
|
|
69
|
+
}
|
|
70
|
+
return templatizePath(path);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Turns one observed request into its comparable shape.
|
|
74
|
+
*
|
|
75
|
+
* Args:
|
|
76
|
+
* request: the method and target the proxy observed.
|
|
77
|
+
* options: the run's route inventory and owner-declared query keys.
|
|
78
|
+
*
|
|
79
|
+
* Returns:
|
|
80
|
+
* TwinShape: method + route template + allowlisted query values.
|
|
81
|
+
*/
|
|
82
|
+
export function twinShapeOf(request, options = {}) {
|
|
83
|
+
const { path, query } = splitTarget(request.url);
|
|
84
|
+
const allowlist = new Set(options.queryKeys ?? []);
|
|
85
|
+
const values = {};
|
|
86
|
+
for (const [key, value] of [...query].sort((left, right) => compareStrings(left[0], right[0]))) {
|
|
87
|
+
// The KEY is always recorded; the VALUE only when the owner named
|
|
88
|
+
// this key. A non-allowlisted value cannot reach a shape, and a
|
|
89
|
+
// shape is what every report and state document carries.
|
|
90
|
+
if (allowlist.has(key))
|
|
91
|
+
values[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
method: request.method.toUpperCase(),
|
|
95
|
+
route: routeOf(path, options.inventory),
|
|
96
|
+
...(Object.keys(values).length === 0 ? {} : { query: values }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** The stable comparable identity of a shape. */
|
|
100
|
+
function shapeKey(shape) {
|
|
101
|
+
const query = Object.keys(shape.query ?? {})
|
|
102
|
+
.sort(compareStrings)
|
|
103
|
+
.map((key) => `${key}=${shape.query[key]}`)
|
|
104
|
+
.join('&');
|
|
105
|
+
return `${shape.method} ${shape.route}${query === '' ? '' : `?${query}`}`;
|
|
106
|
+
}
|
|
107
|
+
/** Renders one shape for a human, naming exactly what it carries. */
|
|
108
|
+
function describeShape(shape) {
|
|
109
|
+
const query = Object.keys(shape.query ?? {});
|
|
110
|
+
if (query.length === 0)
|
|
111
|
+
return `${shape.method} ${shape.route}`;
|
|
112
|
+
return `${shape.method} ${shape.route}?${query
|
|
113
|
+
.sort(compareStrings)
|
|
114
|
+
.map((key) => `${key}=${shape.query[key]}`)
|
|
115
|
+
.join('&')}`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Compares two twins' shapes and returns both directions of the
|
|
119
|
+
* divergence: what the witnessed twin did that the raw twin never did,
|
|
120
|
+
* and what the raw twin did that the witnessed twin never did. A shape
|
|
121
|
+
* both exercised is not reported — twins that agree are the healthy
|
|
122
|
+
* case and must stay silent.
|
|
123
|
+
*
|
|
124
|
+
* Args:
|
|
125
|
+
* witnessed: the witnessed twin's shapes.
|
|
126
|
+
* raw: the raw twin's shapes.
|
|
127
|
+
*
|
|
128
|
+
* Returns:
|
|
129
|
+
* TwinDivergence[]: every one-sided shape, witnessed-side first,
|
|
130
|
+
* deterministic in shape identity.
|
|
131
|
+
*/
|
|
132
|
+
export function twinPathDivergence(witnessed, raw) {
|
|
133
|
+
const rawKeys = new Set(raw.shapes.map(shapeKey));
|
|
134
|
+
const witnessedKeys = new Set(witnessed.shapes.map(shapeKey));
|
|
135
|
+
const oneSided = (shapes, other, presentIn, missingFrom) => shapes
|
|
136
|
+
.filter((shape) => !other.has(shapeKey(shape)))
|
|
137
|
+
.map((shape) => ({ presentIn, missingFrom, shape }));
|
|
138
|
+
return [
|
|
139
|
+
...oneSided(witnessed.shapes, rawKeys, witnessed.logicalKey, raw.logicalKey),
|
|
140
|
+
...oneSided(raw.shapes, witnessedKeys, raw.logicalKey, witnessed.logicalKey),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
/** The finding text a divergence produces, naming BOTH tests and the shape. */
|
|
144
|
+
export function twinDivergenceDetail(divergence) {
|
|
145
|
+
return (`${describeShape(divergence.shape)} is exercised by '${divergence.presentIn}' and never by ` +
|
|
146
|
+
`'${divergence.missingFrom}' — the twins do not cover the same request path`);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The tag a title carries to declare itself the WITNESSED twin. It is
|
|
150
|
+
* part of the title convention, not a gate: the link is a naming
|
|
151
|
+
* convention an owner follows, and the engine only reads it when the
|
|
152
|
+
* owner switched twin coverage on.
|
|
153
|
+
*/
|
|
154
|
+
export const WITNESSED_TITLE_TAG = '[witnessed]';
|
|
155
|
+
/**
|
|
156
|
+
* Resolves the raw/witnessed pairs a run should compare.
|
|
157
|
+
*
|
|
158
|
+
* Two rules, in that order, and never a guess:
|
|
159
|
+
* - `twinOf`: the explicit test-map declaration — the only link that
|
|
160
|
+
* survives a rename of either test, because it names logical keys.
|
|
161
|
+
* - the title convention: a witnessed title that carries
|
|
162
|
+
* {@link WITNESSED_TITLE_TAG} links to the untagged title with the
|
|
163
|
+
* same stem: `X raw`, else the one `X raw: <description>`, else a
|
|
164
|
+
* bare `X`.
|
|
165
|
+
*
|
|
166
|
+
* A link is only returned when BOTH tests exist in the catalog: a
|
|
167
|
+
* dangling `twinOf` or a title with no partner names nothing, because
|
|
168
|
+
* a pair with one missing side has no shapes to compare and a finding
|
|
169
|
+
* about it would be a ghost.
|
|
170
|
+
*
|
|
171
|
+
* Args:
|
|
172
|
+
* candidates: every test the catalog knows (logical key + title).
|
|
173
|
+
* twinOf: explicit `witnessed -> raw` logical-key declarations.
|
|
174
|
+
*
|
|
175
|
+
* Returns:
|
|
176
|
+
* TwinLink[]: every resolvable pair, deterministic in witnessed key.
|
|
177
|
+
*/
|
|
178
|
+
export function twinLinksFor(candidates, twinOf = {}) {
|
|
179
|
+
const known = new Set(candidates.map((candidate) => candidate.logicalKey));
|
|
180
|
+
const links = new Map();
|
|
181
|
+
for (const witnessed of [...candidates].sort((left, right) => compareStrings(left.logicalKey, right.logicalKey))) {
|
|
182
|
+
const declared = twinOf[witnessed.logicalKey];
|
|
183
|
+
if (declared !== undefined && known.has(declared) && declared !== witnessed.logicalKey) {
|
|
184
|
+
links.set(witnessed.logicalKey, { witnessed: witnessed.logicalKey, raw: declared, source: 'test-map' });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const tagIndex = witnessed.title.lastIndexOf(WITNESSED_TITLE_TAG);
|
|
188
|
+
if (tagIndex === -1)
|
|
189
|
+
continue;
|
|
190
|
+
const stem = witnessed.title.slice(0, tagIndex).trim();
|
|
191
|
+
if (stem === '')
|
|
192
|
+
continue;
|
|
193
|
+
// `X [witnessed]` pairs with `X raw` first, then with the ONE
|
|
194
|
+
// `X raw: <description>`, then with a bare `X`: the explicit raw twin
|
|
195
|
+
// is the owner's clearer statement, and a same-title bare match is
|
|
196
|
+
// only taken when nothing else claims it.
|
|
197
|
+
const untagged = candidates.filter((candidate) => candidate.logicalKey !== witnessed.logicalKey && !candidate.title.includes(WITNESSED_TITLE_TAG));
|
|
198
|
+
const described = untagged.filter((candidate) => candidate.title.startsWith(`${stem} raw:`));
|
|
199
|
+
// A described partner is only unambiguous when the label names ONE
|
|
200
|
+
// test on each side: two witnessed tests under one label (or two
|
|
201
|
+
// described raw ones) leave the pairing to the owner (`twinOf`) —
|
|
202
|
+
// never a guess, and never a fallback to a bare title.
|
|
203
|
+
const witnessedUnderLabel = candidates.filter((candidate) => {
|
|
204
|
+
const index = candidate.title.lastIndexOf(WITNESSED_TITLE_TAG);
|
|
205
|
+
return index !== -1 && candidate.title.slice(0, index).trim() === stem;
|
|
206
|
+
}).length;
|
|
207
|
+
const describedPartner = described.length === 1 && witnessedUnderLabel === 1 ? described[0] : undefined;
|
|
208
|
+
const partner = untagged.find((candidate) => candidate.title === `${stem} raw`) ?? describedPartner;
|
|
209
|
+
if (partner === undefined && described.length > 0)
|
|
210
|
+
continue;
|
|
211
|
+
const bare = partner ??
|
|
212
|
+
candidates.find((candidate) => candidate.logicalKey !== witnessed.logicalKey &&
|
|
213
|
+
!candidate.title.includes(WITNESSED_TITLE_TAG) &&
|
|
214
|
+
candidate.title === stem);
|
|
215
|
+
if (partner === undefined && bare !== undefined) {
|
|
216
|
+
// A bare `X` may also be the stem of another witnessed title's
|
|
217
|
+
// partner; the first witnessed key (sorted) owns it, so the same
|
|
218
|
+
// raw test is never reported as the twin of two witnessed ones.
|
|
219
|
+
const alreadyLinked = [...links.values()].some((link) => link.raw === bare.logicalKey);
|
|
220
|
+
if (alreadyLinked)
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (bare !== undefined) {
|
|
224
|
+
links.set(witnessed.logicalKey, {
|
|
225
|
+
witnessed: witnessed.logicalKey,
|
|
226
|
+
raw: bare.logicalKey,
|
|
227
|
+
source: 'title',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return [...links.values()].sort((left, right) => compareStrings(left.witnessed, right.witnessed));
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=twin-paths.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"twin-paths.js","sourceRoot":"","sources":["../src/twin-paths.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"behavior.d.ts","sourceRoot":"","sources":["../../src/verdict/behavior.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"behavior.d.ts","sourceRoot":"","sources":["../../src/verdict/behavior.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,eAAe,EAAwB,MAAM,gCAAgC,CAAC;AAO5F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAE3D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGxD,uEAAuE;AACvE,eAAO,MAAM,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAGpD,CAAC;AAEH,2EAA2E;AAC3E,eAAO,MAAM,cAAc,EAAE,WAAW,CAAC,MAAM,CAM7C,CAAC;AAEH,uEAAuE;AACvE,eAAO,MAAM,oBAAoB,EAAE,WAAW,CAAC,MAAM,CAMnD,CAAC;AAEH,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAMjD,CAAC;AAEH,0EAA0E;AAC1E,eAAO,MAAM,cAAc,EAAE,WAAW,CAAC,MAAM,CAM7C,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAMhD,CAAC;AAEH,2DAA2D;AAC3D,eAAO,MAAM,uBAAuB,EAAE,WAAW,CAAC,MAAM,CAOtD,CAAC;AAEH,qFAAqF;AACrF,MAAM,WAAW,yBAAyB;IACxC,oEAAoE;IACpE,OAAO,EAAE,eAAe,CAAC;IACzB,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC;AAED,yEAAyE;AACzE,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,OAAO,EAAE,eAAe,CAAC;IACzB,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvC;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,yEAAyE;IACzE,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED,wDAAwD;AACxD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED,yCAAyC;AACzC,MAAM,MAAM,mBAAmB,GAC3B;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,GAC5C;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,GAC1D;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAE/D;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,CAE9D;AA2pBD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE;IAC3C,UAAU,EAAE,UAAU,CAAC;IACvB,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,OAAO,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACvC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,UAAU,EAAE,SAAS,kBAAkB,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC;CAC9D,GAAG,mBAAmB,CA8BtB"}
|
package/dist/verdict/behavior.js
CHANGED
|
@@ -21,9 +21,14 @@
|
|
|
21
21
|
* - declarative bulk/import exact-set comparison (missing, extra, or
|
|
22
22
|
* duplicate entities fail).
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
24
|
+
* `request` cases grade over the `engine-http` channel, `surface` cases
|
|
25
|
+
* over `engine-browser` (the Phase 6 browser driver), and `deliver`
|
|
26
|
+
* cases over `engine-task`: the engine
|
|
27
|
+
* produces the delivery, reads the queue back until every job settled,
|
|
28
|
+
* and seals that read — `attempts` rules grade from it, and a delivery
|
|
29
|
+
* case without one settles nothing about the background job.
|
|
30
|
+
* `sequence` actions and non-HTTP channels still block with an
|
|
31
|
+
* explicit phase cause — never a pass.
|
|
27
32
|
*/
|
|
28
33
|
import { canonicalJson, sha256Canonical } from '../canonical-json.js';
|
|
29
34
|
import { compareStrings } from '../graph/util.js';
|
|
@@ -301,13 +306,18 @@ function scopeDelta(before, after) {
|
|
|
301
306
|
}
|
|
302
307
|
return { added, removed, changed };
|
|
303
308
|
}
|
|
309
|
+
/** Whether a declared state rule is an `attempts` rule (graded only over a queue observation). */
|
|
310
|
+
function isAttemptsRule(rule) {
|
|
311
|
+
return typeof rule === 'object' && rule !== null && 'kind' in rule && rule.kind === 'attempts';
|
|
312
|
+
}
|
|
304
313
|
/** Grades one state rule; records explained identities for atomicity. */
|
|
305
314
|
function gradeStateRule(obligationId, rule, before, after, valueCtx, explained, scopeKeyOf) {
|
|
306
315
|
const kind = rule['kind'];
|
|
307
316
|
if (kind === 'attempts') {
|
|
308
317
|
return {
|
|
309
318
|
ok: false,
|
|
310
|
-
reason: `'${obligationId}': 'attempts' rules
|
|
319
|
+
reason: `'${obligationId}': 'attempts' rules are graded over the engine's own queue ` +
|
|
320
|
+
"observation, not a scope snapshot — declare the case on the 'engine-task' channel; blocked, never satisfied",
|
|
311
321
|
};
|
|
312
322
|
}
|
|
313
323
|
if (typeof rule['scope'] !== 'string') {
|
|
@@ -772,6 +782,9 @@ function gradeRequiredCase(obligation, caseId, records, context, httpRoutes) {
|
|
|
772
782
|
recordIds: [],
|
|
773
783
|
};
|
|
774
784
|
}
|
|
785
|
+
if (action['kind'] === 'deliver') {
|
|
786
|
+
return gradeTaskCase(obligation, compiled, caseId, records, context);
|
|
787
|
+
}
|
|
775
788
|
if (action['kind'] !== 'request' && action['kind'] !== 'surface') {
|
|
776
789
|
return {
|
|
777
790
|
status: 'missing',
|
|
@@ -924,19 +937,17 @@ function gradeRequiredCase(obligation, caseId, records, context, httpRoutes) {
|
|
|
924
937
|
// forbidden change in one case result.
|
|
925
938
|
const explained = new Map();
|
|
926
939
|
const scopeKeyOf = (effectId) => compiled.effects.find((effect) => effect.id === effectId)?.scope ?? null;
|
|
927
|
-
let sawAttemptsRule = false;
|
|
928
940
|
for (const rule of compiled.definition.expect.state) {
|
|
929
|
-
if (rule.kind === 'attempts')
|
|
930
|
-
sawAttemptsRule = true;
|
|
931
941
|
const graded = gradeStateRule(obligationId, rule, beforeIndex, afterIndex, valueCtx, explained, scopeKeyOf);
|
|
932
942
|
if (!graded.ok) {
|
|
933
|
-
|
|
943
|
+
// No queue observation exists on this channel: the evidence is
|
|
944
|
+
// absent (fail closed), not contradicted.
|
|
945
|
+
if (isAttemptsRule(rule) || graded.reason.includes('Phase 8')) {
|
|
934
946
|
return { status: 'missing', reason: graded.reason, recordIds: [recordId] };
|
|
935
947
|
}
|
|
936
948
|
return { status: 'invalid', reason: `${graded.reason} (BEHAVIOR_EFFECT_MISMATCH)`, recordIds: [recordId] };
|
|
937
949
|
}
|
|
938
950
|
}
|
|
939
|
-
void sawAttemptsRule;
|
|
940
951
|
const declaredScopes = new Set(compiled.effects.map((effect) => effect.scope));
|
|
941
952
|
// Read contracts additionally require zero delta on every scope.
|
|
942
953
|
if (obligation.contract === 'http:read-result-verified') {
|
|
@@ -1201,7 +1212,7 @@ function gradeSurfaceCase(obligation, compiled, caseId, records, context) {
|
|
|
1201
1212
|
for (const rule of compiled.definition.expect.state) {
|
|
1202
1213
|
const graded = gradeStateRule(obligationId, rule, beforeIndex, afterIndex, valueCtx, explained, scopeKeyOf);
|
|
1203
1214
|
if (!graded.ok) {
|
|
1204
|
-
if (graded.reason.includes('Phase 8')) {
|
|
1215
|
+
if (isAttemptsRule(rule) || graded.reason.includes('Phase 8')) {
|
|
1205
1216
|
return { status: 'missing', reason: graded.reason, recordIds: [recordId] };
|
|
1206
1217
|
}
|
|
1207
1218
|
return { status: 'invalid', reason: `${graded.reason} (BEHAVIOR_EFFECT_MISMATCH)`, recordIds: [recordId] };
|
|
@@ -1233,6 +1244,269 @@ function gradeSurfaceCase(obligation, compiled, caseId, records, context) {
|
|
|
1233
1244
|
}
|
|
1234
1245
|
return { status: 'satisfied', recordIds: [recordId] };
|
|
1235
1246
|
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Grades one `attempts` rule over the engine's OWN read of the queue.
|
|
1249
|
+
* Nothing here consults the test: the
|
|
1250
|
+
* sealed observation carries the delivery identities the engine
|
|
1251
|
+
* stamped, the per-job attempt counts the queue reported, and the
|
|
1252
|
+
* transition timeline the engine sampled.
|
|
1253
|
+
*
|
|
1254
|
+
* The claims it settles:
|
|
1255
|
+
* - `terminal: succeeded` — every produced job reached the queue's
|
|
1256
|
+
* completed state (a job still waiting, delayed, active or paused is
|
|
1257
|
+
* not a success, it is an unsettled delivery);
|
|
1258
|
+
* - `terminal: failed` — every job reached the queue's failed state;
|
|
1259
|
+
* - `terminal: rejected` — every job failed on its FIRST attempt (the
|
|
1260
|
+
* error class the policy declares non-retryable);
|
|
1261
|
+
* - `count` — the declared attempt bound: no job may exceed it and the
|
|
1262
|
+
* queue's own declared bound may not exceed it either;
|
|
1263
|
+
* - `minAttempts` (optional) — every job must have USED at least that
|
|
1264
|
+
* many attempts, so "retries up to N" cannot be satisfied by a queue
|
|
1265
|
+
* that never retried anything;
|
|
1266
|
+
* - `recoveredFromStall` (optional) — the timeline must show a lost
|
|
1267
|
+
* worker being reclaimed: a job sampled `active` and later observed
|
|
1268
|
+
* non-terminal with UNCHANGED attempts and no failure reason (an
|
|
1269
|
+
* error retry raises both), and the delivery still settled.
|
|
1270
|
+
*
|
|
1271
|
+
* Args:
|
|
1272
|
+
obligationId: the obligation being graded (diagnostics).
|
|
1273
|
+
rule: the compiled `attempts` rule.
|
|
1274
|
+
* action: the case's compiled `deliver` action.
|
|
1275
|
+
observation: the sealed engine queue observation.
|
|
1276
|
+
|
|
1277
|
+
* Returns:
|
|
1278
|
+
AttemptsGrade: pass, or the verdict polarity plus the reason.
|
|
1279
|
+
*/
|
|
1280
|
+
function gradeAttemptsRule(obligationId, rule, action, observation) {
|
|
1281
|
+
const fail = (reason) => ({ ok: false, reason, status: 'invalid' });
|
|
1282
|
+
const declaredResource = rule['resourceId'];
|
|
1283
|
+
if (declaredResource !== action['resourceId']) {
|
|
1284
|
+
return fail(`'${obligationId}': the 'attempts' rule names resource '${String(declaredResource)}' but the case ` +
|
|
1285
|
+
`delivers to '${String(action['resourceId'])}' (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1286
|
+
}
|
|
1287
|
+
if (observation.complete !== true) {
|
|
1288
|
+
return fail(`'${obligationId}': the engine read queue '${observation.queue}' for ${String(observation.waitedMs)} ms and ` +
|
|
1289
|
+
'the delivery never reached a terminal state — a stuck background job is not a terminal outcome (BEHAVIOR_EFFECT_MISMATCH)');
|
|
1290
|
+
}
|
|
1291
|
+
const declaredDeliveries = action['count'];
|
|
1292
|
+
if (observation.deliveryIds.length !== declaredDeliveries) {
|
|
1293
|
+
return fail(`'${obligationId}': the engine produced ${String(declaredDeliveries)} deliveries but sealed ` +
|
|
1294
|
+
`${String(observation.deliveryIds.length)} (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1295
|
+
}
|
|
1296
|
+
if (observation.jobs.length !== observation.deliveryIds.length) {
|
|
1297
|
+
return fail(`'${obligationId}': the queue holds ${String(observation.jobs.length)} job(s) for ` +
|
|
1298
|
+
`${String(observation.deliveryIds.length)} engine deliveries — a duplicate or an extra delivery is ambiguity, not proof (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1299
|
+
}
|
|
1300
|
+
const produced = new Set(observation.deliveryIds);
|
|
1301
|
+
const terminal = rule['terminal'];
|
|
1302
|
+
const bound = rule['count'];
|
|
1303
|
+
const floor = rule['minAttempts'];
|
|
1304
|
+
for (const job of observation.jobs) {
|
|
1305
|
+
if (job.deliveryId === null || !produced.has(job.deliveryId)) {
|
|
1306
|
+
return fail(`'${obligationId}': queue job '${job.jobId}' carries no engine delivery identity ` +
|
|
1307
|
+
`(read as '${String(job.deliveryId)}') — the engine only grades deliveries it produced itself (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1308
|
+
}
|
|
1309
|
+
if (terminal === 'succeeded' && job.state !== 'completed') {
|
|
1310
|
+
return fail(`'${obligationId}': job '${job.jobId}' ended in state '${job.state}', not 'completed' — the queue itself ` +
|
|
1311
|
+
'says the delivery did not succeed (BEHAVIOR_EFFECT_MISMATCH)');
|
|
1312
|
+
}
|
|
1313
|
+
if (terminal === 'failed' && job.state !== 'failed') {
|
|
1314
|
+
return fail(`'${obligationId}': job '${job.jobId}' ended in state '${job.state}', not 'failed' (BEHAVIOR_EFFECT_MISMATCH)`);
|
|
1315
|
+
}
|
|
1316
|
+
if (terminal === 'rejected' && (job.state !== 'failed' || job.attemptsMade !== 1)) {
|
|
1317
|
+
return fail(`'${obligationId}': job '${job.jobId}' is declared terminal-on-error but the queue reports state ` +
|
|
1318
|
+
`'${job.state}' after ${String(job.attemptsMade)} attempt(s) — the error class was retried (BEHAVIOR_EFFECT_MISMATCH)`);
|
|
1319
|
+
}
|
|
1320
|
+
if (job.maxAttempts === null) {
|
|
1321
|
+
return fail(`'${obligationId}': job '${job.jobId}' declares no attempt bound in the queue, so a retry bound ` +
|
|
1322
|
+
`of ${String(bound)} cannot be proven (OBSERVATION_SCOPE_INCOMPLETE)`);
|
|
1323
|
+
}
|
|
1324
|
+
if (job.maxAttempts > bound) {
|
|
1325
|
+
return fail(`'${obligationId}': job '${job.jobId}' declares an attempt bound of ${String(job.maxAttempts)}, above the ` +
|
|
1326
|
+
`declared ${String(bound)} (BEHAVIOR_BINDING_MISMATCH)`);
|
|
1327
|
+
}
|
|
1328
|
+
if (job.attemptsMade > bound) {
|
|
1329
|
+
return fail(`'${obligationId}': job '${job.jobId}' ran ${String(job.attemptsMade)} attempts, above the declared bound of ` +
|
|
1330
|
+
`${String(bound)} (BEHAVIOR_EFFECT_MISMATCH)`);
|
|
1331
|
+
}
|
|
1332
|
+
if (typeof floor === 'number' && job.attemptsMade < floor) {
|
|
1333
|
+
return fail(`'${obligationId}': job '${job.jobId}' ran ${String(job.attemptsMade)} attempt(s) — the declared rule ` +
|
|
1334
|
+
`requires at least ${String(floor)}, so the retry path was never exercised (BEHAVIOR_EFFECT_MISMATCH)`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (rule['recoveredFromStall'] === true) {
|
|
1338
|
+
// A lost-worker reclaim, as the engine's own timeline saw it: the
|
|
1339
|
+
// same job was handed out AGAIN with UNCHANGED attempts and no
|
|
1340
|
+
// failure reason recorded between the two hand-outs (an error retry
|
|
1341
|
+
// raises attemptsMade and sets a reason before the re-queue). A
|
|
1342
|
+
// reclaim back onto the wait list is accepted too, for a queue that
|
|
1343
|
+
// parked the job before another worker took it.
|
|
1344
|
+
const reclaims = new Set();
|
|
1345
|
+
const activeAt = new Map();
|
|
1346
|
+
for (const sample of observation.samples) {
|
|
1347
|
+
if (sample.failedReason !== null)
|
|
1348
|
+
continue;
|
|
1349
|
+
if (sample.state === 'active') {
|
|
1350
|
+
const key = `${sample.jobId}|${String(sample.attemptsMade)}`;
|
|
1351
|
+
if (activeAt.has(key))
|
|
1352
|
+
reclaims.add(sample.jobId);
|
|
1353
|
+
else
|
|
1354
|
+
activeAt.set(key, sample.atMs);
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
if (sample.state !== 'waiting' && sample.state !== 'delayed')
|
|
1358
|
+
continue;
|
|
1359
|
+
const startedAt = activeAt.get(`${sample.jobId}|${String(sample.attemptsMade)}`);
|
|
1360
|
+
if (startedAt !== undefined && sample.atMs > startedAt)
|
|
1361
|
+
reclaims.add(sample.jobId);
|
|
1362
|
+
}
|
|
1363
|
+
const settled = observation.jobs.filter((job) => reclaims.has(job.jobId));
|
|
1364
|
+
if (settled.length === 0) {
|
|
1365
|
+
return fail(`'${obligationId}': no job was observed being reclaimed from a lost worker (handed out again with ` +
|
|
1366
|
+
'unchanged attempts and no failure reason) — nothing proves the queue recovers a killed worker ' +
|
|
1367
|
+
'(BEHAVIOR_EFFECT_MISMATCH)');
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return { ok: true };
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Grades one task-driven required case:
|
|
1374
|
+
* the engine produced the delivery itself, read the queue back until
|
|
1375
|
+
* every job settled, and sealed that read. Proof is the sealed
|
|
1376
|
+
* observation plus the declared state effects — never the suite's
|
|
1377
|
+
* account of what the job did. HTTP attempts and browser observations
|
|
1378
|
+
* are rejected outright: a task claim proved by a request would be a
|
|
1379
|
+
* different (transport) claim.
|
|
1380
|
+
*
|
|
1381
|
+
* @param obligation the obligation being graded.
|
|
1382
|
+
* @param compiled the compiled case from the trusted catalog.
|
|
1383
|
+
* @param caseId the compiled case id.
|
|
1384
|
+
* @param records the run's ledger records.
|
|
1385
|
+
* @param context the trusted grading context.
|
|
1386
|
+
*
|
|
1387
|
+
* @returns the case outcome.
|
|
1388
|
+
*/
|
|
1389
|
+
function gradeTaskCase(obligation, compiled, caseId, records, context) {
|
|
1390
|
+
const obligationId = obligation.id;
|
|
1391
|
+
if (compiled.definition.channel !== 'engine-task') {
|
|
1392
|
+
return {
|
|
1393
|
+
status: 'missing',
|
|
1394
|
+
reason: `'${obligationId}': delivery case '${compiled.definition.id}' requires the ` +
|
|
1395
|
+
`'${compiled.definition.channel}' channel — a delivery is only provable engine-side`,
|
|
1396
|
+
recordIds: [],
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
const matched = matchSingleCaseRecord(obligation, compiled, caseId, records, context);
|
|
1400
|
+
if (matched.kind === 'outcome')
|
|
1401
|
+
return matched.outcome;
|
|
1402
|
+
const { recordId, payload } = matched;
|
|
1403
|
+
const bound = validateCaseBindings(obligation, compiled, caseId, recordId, payload, context);
|
|
1404
|
+
if (bound.kind === 'outcome')
|
|
1405
|
+
return bound.outcome;
|
|
1406
|
+
if (payload.attempts.length > 0 || payload.browserObservation !== undefined) {
|
|
1407
|
+
return {
|
|
1408
|
+
status: 'invalid',
|
|
1409
|
+
reason: `'${obligationId}': delivery case record '${recordId}' carries transport evidence — a background ` +
|
|
1410
|
+
'delivery is proved by the queue state the engine read, not by an HTTP attempt (BEHAVIOR_BINDING_MISMATCH)',
|
|
1411
|
+
recordIds: [recordId],
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
const observation = payload.queueObservation;
|
|
1415
|
+
if (observation === undefined) {
|
|
1416
|
+
return {
|
|
1417
|
+
status: 'missing',
|
|
1418
|
+
reason: `'${obligationId}': delivery case '${compiled.definition.id}' sealed no queue observation — the ` +
|
|
1419
|
+
"engine's own queue observer produces that evidence",
|
|
1420
|
+
recordIds: [recordId],
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const beforeBuilt = indexSnapshots(payload.before, compiled);
|
|
1424
|
+
const afterBuilt = indexSnapshots(payload.after, compiled);
|
|
1425
|
+
if (!beforeBuilt.ok || !afterBuilt.ok) {
|
|
1426
|
+
return {
|
|
1427
|
+
status: 'invalid',
|
|
1428
|
+
reason: `'${obligationId}': ${(beforeBuilt.ok ? afterBuilt : beforeBuilt).reason}`,
|
|
1429
|
+
recordIds: [recordId],
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
const valueCtx = {
|
|
1433
|
+
fixtureValues: payload.fixtureValues,
|
|
1434
|
+
observation: null,
|
|
1435
|
+
before: beforeBuilt.index,
|
|
1436
|
+
};
|
|
1437
|
+
const beforeIndex = beforeBuilt.index;
|
|
1438
|
+
const afterIndex = afterBuilt.index;
|
|
1439
|
+
const action = compiled.definition.action;
|
|
1440
|
+
// The engine produced the delivery: the sealed submitted values and
|
|
1441
|
+
// the queue jobs must agree on the identity the engine stamped.
|
|
1442
|
+
const submitted = payload.submittedValues;
|
|
1443
|
+
if (typeof submitted !== 'object' || submitted === null || Array.isArray(submitted)) {
|
|
1444
|
+
return {
|
|
1445
|
+
status: 'invalid',
|
|
1446
|
+
reason: `'${obligationId}': case record '${recordId}' sealed no delivery identity (BEHAVIOR_BINDING_MISMATCH)`,
|
|
1447
|
+
recordIds: [recordId],
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
const stampedKey = submitted['idempotencyKey'];
|
|
1451
|
+
const mismatchedKey = observation.jobs.find((job) => typeof stampedKey === 'string' && job.idempotencyKey !== stampedKey);
|
|
1452
|
+
if (mismatchedKey !== undefined) {
|
|
1453
|
+
return {
|
|
1454
|
+
status: 'invalid',
|
|
1455
|
+
reason: `'${obligationId}': queue job '${mismatchedKey.jobId}' carries idempotency key ` +
|
|
1456
|
+
`'${mismatchedKey.idempotencyKey}', not the engine-stamped '${String(stampedKey)}' (BEHAVIOR_BINDING_MISMATCH)`,
|
|
1457
|
+
recordIds: [recordId],
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
const explained = new Map();
|
|
1461
|
+
const scopeKeyOf = (effectId) => compiled.effects.find((effect) => effect.id === effectId)?.scope ?? null;
|
|
1462
|
+
let sawAttemptsRule = false;
|
|
1463
|
+
for (const rule of compiled.definition.expect.state) {
|
|
1464
|
+
if (rule.kind === 'attempts') {
|
|
1465
|
+
sawAttemptsRule = true;
|
|
1466
|
+
const graded = gradeAttemptsRule(obligationId, rule, action, observation);
|
|
1467
|
+
if (!graded.ok) {
|
|
1468
|
+
return { status: graded.status, reason: graded.reason, recordIds: [recordId] };
|
|
1469
|
+
}
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
const graded = gradeStateRule(obligationId, rule, beforeIndex, afterIndex, valueCtx, explained, scopeKeyOf);
|
|
1473
|
+
if (!graded.ok) {
|
|
1474
|
+
return { status: 'invalid', reason: `${graded.reason} (BEHAVIOR_EFFECT_MISMATCH)`, recordIds: [recordId] };
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
if (!sawAttemptsRule) {
|
|
1478
|
+
return {
|
|
1479
|
+
status: 'invalid',
|
|
1480
|
+
reason: `'${obligationId}': delivery case '${compiled.definition.id}' declares no 'attempts' rule — without one ` +
|
|
1481
|
+
'the queue read settles nothing about the background job (BEHAVIOR_BINDING_MISMATCH)',
|
|
1482
|
+
recordIds: [recordId],
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
for (const scope of new Set(compiled.effects.map((effect) => effect.scope))) {
|
|
1486
|
+
const beforeScope = beforeIndex.get(scope);
|
|
1487
|
+
const afterScope = afterIndex.get(scope);
|
|
1488
|
+
if (beforeScope === undefined || afterScope === undefined)
|
|
1489
|
+
continue;
|
|
1490
|
+
const ledger = explained.get(scope);
|
|
1491
|
+
if (ledger?.complete.has(scope) === true)
|
|
1492
|
+
continue;
|
|
1493
|
+
const delta = scopeDelta(beforeScope, afterScope);
|
|
1494
|
+
const unexplained = [
|
|
1495
|
+
...delta.added.filter((key) => !ledger?.added.has(key)),
|
|
1496
|
+
...delta.removed.filter((key) => !ledger?.removed.has(key)),
|
|
1497
|
+
...delta.changed.filter((key) => !ledger?.changed.has(key)),
|
|
1498
|
+
];
|
|
1499
|
+
if (unexplained.length > 0) {
|
|
1500
|
+
return {
|
|
1501
|
+
status: 'invalid',
|
|
1502
|
+
reason: `'${obligationId}': scope '${scope}' has ${String(unexplained.length)} unexplained change(s) ` +
|
|
1503
|
+
'beside the declared effect — a correct delivery plus a wrong secondary effect still fails (BEHAVIOR_UNEXPECTED_EFFECT)',
|
|
1504
|
+
recordIds: [recordId],
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return { status: 'satisfied', recordIds: [recordId] };
|
|
1509
|
+
}
|
|
1236
1510
|
/**
|
|
1237
1511
|
* Checks declared route/query/body identity selectors: the observed
|
|
1238
1512
|
* concrete path must equal the template filled with resolved params,
|