@sabaiway/agent-workflow-kit 5.2.0 → 5.4.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/CHANGELOG.md +110 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +11 -3
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +154 -35
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +257 -4
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/references/driving-codex.md +5 -3
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +33 -15
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/coverage-check.md +1 -1
- package/references/modes/gates.md +7 -2
- package/references/modes/recommendations.md +3 -1
- package/references/modes/upgrade.md +1 -1
- package/references/modes/velocity.md +5 -1
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
- package/references/scripts/migrate-gates.mjs +102 -10
- package/references/scripts/migrate-gates.test.mjs +37 -0
- package/tools/core-evidence.mjs +42 -2
- package/tools/coverage-check.mjs +23 -7
- package/tools/coverage-producer.mjs +68 -0
- package/tools/coverage-state.mjs +24 -0
- package/tools/declared-paths.mjs +32 -0
- package/tools/detect-backends.mjs +1 -0
- package/tools/dispatch-record.mjs +926 -0
- package/tools/doc-parity.mjs +19 -4
- package/tools/flow-check.mjs +48 -12
- package/tools/gates-declaration.mjs +49 -0
- package/tools/gates-init.mjs +83 -6
- package/tools/recommendations.mjs +63 -19
- package/tools/run-gates.mjs +111 -32
- package/tools/velocity-profile.mjs +102 -23
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
// dispatch-record.mjs — the CLOSED delegation-record vocabulary (delegation Plan 1, Phase 1). Pure
|
|
2
|
+
// form: no filesystem, no git, no CLI, no side effects on import. The store IO (dispatch-store.mjs,
|
|
3
|
+
// Phase 2) and the engine (dispatch.mjs, Phase 3) consume these predicates; nothing here reads or
|
|
4
|
+
// writes a store.
|
|
5
|
+
//
|
|
6
|
+
// The delegation half of the funded intent. The flow family already accounts for REVIEW dispatches
|
|
7
|
+
// (receipts, finding manifests, deadlines); nothing today records {dispatched → returned → folded}
|
|
8
|
+
// for EXEC work, so the delegation metric is unmeasurable. This module is the measurement
|
|
9
|
+
// substrate's vocabulary: the record family, the failure-outcome enum with its allowed-successor
|
|
10
|
+
// table, the exec-return schema, the sub-task contract header with a FORM-only checker, and the
|
|
11
|
+
// per-kind byte-domain rules the metric is computed from.
|
|
12
|
+
//
|
|
13
|
+
// The record family (D3, versioned + closed): pre-registration · dispatch · return · fold ·
|
|
14
|
+
// observation · degrade. Fail-closed in BOTH directions — unknown schema, unknown kind, a missing
|
|
15
|
+
// field, a malformed field and an unknown EXTRA field are all refusals (the flow-record.mjs:22-24
|
|
16
|
+
// discipline): the per-record canonical digest is the record's identity, so a stray key would fork
|
|
17
|
+
// it.
|
|
18
|
+
//
|
|
19
|
+
// THE DESCRIPTOR DISCIPLINE, applied wherever a value is read: every field must be an OWN
|
|
20
|
+
// ENUMERABLE DATA property — including `schema` and `kind`, checked BEFORE their values are read —
|
|
21
|
+
// and every array field must be DENSE with a data property at each index. That is exactly the
|
|
22
|
+
// domain the canonical serialization walks, and each escape breaks record identity a different way:
|
|
23
|
+
// a prototype-supplied or own non-enumerable field validates but never enters the digest; an
|
|
24
|
+
// accessor is invoked a SECOND time at serialization and may answer differently; a sparse array
|
|
25
|
+
// slips a closed-set check because Array.prototype.every SKIPS holes. Producer-supplied numerator
|
|
26
|
+
// entries ride the same rule, and every field is read exactly ONCE into a local, so no value can
|
|
27
|
+
// change between two reads of the same enumeration.
|
|
28
|
+
//
|
|
29
|
+
// Reference domains, deliberately SPLIT (D3): a RECORD reference is a per-record CANONICAL digest
|
|
30
|
+
// (fold.returnDigest); THREAD linkage (nonce, retryOf) is by NONCE identity — a nonce is a thread
|
|
31
|
+
// id, never a record reference. A third domain is the metric's: a counted OBJECT is identified by a
|
|
32
|
+
// producer-supplied `objectId`, never by a path. Names cannot carry that identity — a rename chain
|
|
33
|
+
// (A→B→C) and a path re-created after a rename both make one name mean two objects or two names one
|
|
34
|
+
// object, and either confusion mis-counts the numerator. One identity also means one SIZE: a second
|
|
35
|
+
// entry claiming the same object at a different size is a producer contradiction, not a bigger
|
|
36
|
+
// object, and it refuses rather than inflating the total.
|
|
37
|
+
//
|
|
38
|
+
// The METRIC has two component domains that never mix, and provenance is bound to them in ONE
|
|
39
|
+
// direction: the ENUMERATED domain is a delegate's own account of what it read, so it REQUIRES
|
|
40
|
+
// `self-reported` — which means `wrapper-git` implies the EXEC domain, and `solo-construction`,
|
|
41
|
+
// being the SOLO baseline, describes an observation. The EXEC domain accepts BOTH `wrapper-git` and
|
|
42
|
+
// `self-reported`: a delegated return whose bytes the wrapper could not prove is still RECORDED,
|
|
43
|
+
// and its exclusion from the acceptance number happens downstream, where it belongs. An EMPTY
|
|
44
|
+
// component list belongs to no domain, so a legitimately ineligible record (zero numerator, named)
|
|
45
|
+
// is never blocked by the rule.
|
|
46
|
+
//
|
|
47
|
+
// Canonical discipline: the family has exactly ONE canonical serialization, so this module IMPORTS
|
|
48
|
+
// flow-record's rather than copying it — a copy is the only way the two could ever drift, and a
|
|
49
|
+
// parity test pins these bytes against core-evidence's canonicalKindSerialization on a single
|
|
50
|
+
// record (core adds only the per-line framing newline).
|
|
51
|
+
//
|
|
52
|
+
// Named grammars are taken by reference, never re-stated: the 64-hex digest, the safe nonce grammar
|
|
53
|
+
// SAFE_NONCE_RE (flow-record.mjs) — which also governs waveId, a name that keys a wave and rides a
|
|
54
|
+
// CLI flag — and the canonical UTC instant (toISOString round-trip). Every byte count is a SAFE
|
|
55
|
+
// integer: the metric is compared for exact equality, so a total that leaves the safe range can no
|
|
56
|
+
// longer be compared and refuses rather than silently agreeing.
|
|
57
|
+
//
|
|
58
|
+
// Honest limits, stated where they bite:
|
|
59
|
+
// • the contract checker is FORM-only BY NAME (D-R1-FORM-ONLY). Fields present, grammars
|
|
60
|
+
// respected. Boundedness, design-decidedness and acceptance adequacy stay explicit orchestrator
|
|
61
|
+
// judgment fed by the retro loop — a well-formed absurdity passes here by construction.
|
|
62
|
+
// • record-to-record legality (transitions, correlation, wave rules) needs a STORE snapshot and
|
|
63
|
+
// lands with dispatch-store.mjs; this module exposes the allowed-successor table and the
|
|
64
|
+
// terminality predicate the store consumes, and validates only ONE record at a time. The one
|
|
65
|
+
// exception is metric eligibility, which a record can substantiate from its OWN fields: the
|
|
66
|
+
// validator binds it, leaving only `dirty-baseline` — which needs the dispatch record — to the
|
|
67
|
+
// store, and only ever as a STRICTER override on a return.
|
|
68
|
+
// • the validator never throws on a DATA record: every such input path returns a refusal, and an
|
|
69
|
+
// accessor field is refused by name rather than read. An in-process caller CAN still reach past
|
|
70
|
+
// this — a hostile Proxy throws from its own traps, an array subclass can override `every` or
|
|
71
|
+
// `Symbol.iterator` — and that surface is deliberately NOT chased: records remain forgeable,
|
|
72
|
+
// and this module is a self-discipline mechanism, not a security boundary. The residual is
|
|
73
|
+
// tracked as DELEGATION-DESCRIPTOR-DISCIPLINE-INCOMPLETE.
|
|
74
|
+
// • the metric's byte domains are computed over STRUCTURED inputs handed in by a producer; the
|
|
75
|
+
// git-side producer is Plan 2, and it owns minting a stable `objectId`.
|
|
76
|
+
|
|
77
|
+
import { createHash } from 'node:crypto';
|
|
78
|
+
import { SAFE_NONCE_RE, flowCanonicalSerialization } from './flow-record.mjs';
|
|
79
|
+
|
|
80
|
+
const deepFreeze = (value) => {
|
|
81
|
+
if (value !== null && typeof value === 'object') {
|
|
82
|
+
Object.values(value).forEach(deepFreeze);
|
|
83
|
+
Object.freeze(value);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const refuse = (reason) => ({ ok: false, reason });
|
|
89
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
90
|
+
const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
91
|
+
const isHex64 = (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
|
|
92
|
+
const isSafeName = (v) => typeof v === 'string' && SAFE_NONCE_RE.test(v);
|
|
93
|
+
const isCanonicalInstant = (v) => typeof v === 'string' && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v;
|
|
94
|
+
const isByteCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
95
|
+
|
|
96
|
+
// A field must be a DATA property: reading an accessor here and again at serialization time could
|
|
97
|
+
// yield two different values for one record, which is the identity fork the closed key set prevents.
|
|
98
|
+
// The descriptor probe never invokes the accessor, so a throwing getter refuses instead of escaping.
|
|
99
|
+
const isDataProperty = (obj, field) => {
|
|
100
|
+
const descriptor = Object.getOwnPropertyDescriptor(obj, field);
|
|
101
|
+
return descriptor !== undefined && Object.hasOwn(descriptor, 'value');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// DENSE, with a data property at every index. Array.prototype.every SKIPS holes, so a sparse array
|
|
105
|
+
// would pass a closed-set membership check without the missing element ever being examined.
|
|
106
|
+
const isDenseDataArray = (v) => {
|
|
107
|
+
if (!Array.isArray(v)) return false;
|
|
108
|
+
for (let i = 0; i < v.length; i += 1) {
|
|
109
|
+
if (!isDataProperty(v, i)) return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const ACCESSOR_REFUSAL = 'is an ACCESSOR — a record field must be a data property, or the canonical serialization could read a different value than the validator did';
|
|
115
|
+
|
|
116
|
+
// Refusals quote the offending VALUE so a caller reading only the message can act on it; a long
|
|
117
|
+
// value is elided. The serialization is GUARDED: the store's preflight calls the validator on
|
|
118
|
+
// untrusted input, so a BigInt or a circular reference must come back as a refusal, never as a
|
|
119
|
+
// thrown TypeError from the formatter itself.
|
|
120
|
+
const short = (v) => {
|
|
121
|
+
let s;
|
|
122
|
+
try {
|
|
123
|
+
s = JSON.stringify(v);
|
|
124
|
+
} catch {
|
|
125
|
+
return `<unserializable ${typeof v}>`;
|
|
126
|
+
}
|
|
127
|
+
if (s === undefined) s = `<${typeof v}>`;
|
|
128
|
+
return s.length > 80 ? `${s.slice(0, 79)}…` : s;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// ── the closed vocabulary ─────────────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export const DELEGATION_SCHEMA_VERSION = 1;
|
|
134
|
+
|
|
135
|
+
export const DELEGATION_KINDS = deepFreeze(['pre-registration', 'dispatch', 'return', 'fold', 'observation', 'degrade']);
|
|
136
|
+
|
|
137
|
+
// The closed key set per kind (schema + kind are implicit on every record).
|
|
138
|
+
export const DELEGATION_KEY_SETS = deepFreeze({
|
|
139
|
+
'pre-registration': ['waveId', 'stepClasses', 'pairingKey', 'minPerClass', 'meanLThreshold', 'firstPassNum', 'firstPassDen', 'timestamp'],
|
|
140
|
+
dispatch: ['waveId', 'nonce', 'stepClass', 'vehicle', 'backend', 'contractDigest', 'preTreeDigest', 'baselineClean', 'deadlineS', 'retryOf', 'retryIndex', 'retryCap', 'rationale', 'timestamp'],
|
|
141
|
+
return: ['role', 'backend', 'nonce', 'contractDigest', 'preTreeDigest', 'postTreeDigest', 'diffDigest', 'diffLength', 'reportDigest', 'reportLength', 'bundleDigest', 'bundleLength', 'metric', 'outcome', 'exitStatus', 'sessionId', 'wrapperVersion', 'posture', 'timestamp'],
|
|
142
|
+
fold: ['nonce', 'returnDigest', 'treeDigestAtFold', 'verdict', 'timestamp'],
|
|
143
|
+
observation: ['waveId', 'stepClass', 'scope', 'metric', 'planId', 'phase', 'timestamp'],
|
|
144
|
+
degrade: ['waveId', 'nonce', 'stepClass', 'rationale', 'timestamp'],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// The two fields that IDENTIFY a record, checked before their values are read.
|
|
148
|
+
const IDENTITY_FIELDS = ['schema', 'kind'];
|
|
149
|
+
|
|
150
|
+
// D4 — the closed failure-outcome enum.
|
|
151
|
+
export const RETURN_OUTCOMES = deepFreeze(['success', 'transport-failure', 'contract-refusal', 'store-failure', 'missing-identity', 'partial-edit', 'acceptance-failure', 'stale-return']);
|
|
152
|
+
|
|
153
|
+
// A success or acceptance-failure return leaves the thread OPEN — it closes at fold or degrade
|
|
154
|
+
// (the orchestrator's fold-fix judgment, §7). Every other outcome IS the thread's closure.
|
|
155
|
+
export const NON_TERMINAL_RETURN_OUTCOMES = deepFreeze(['success', 'acceptance-failure']);
|
|
156
|
+
export const TERMINAL_RETURN_OUTCOMES = deepFreeze(RETURN_OUTCOMES.filter((o) => !NON_TERMINAL_RETURN_OUTCOMES.includes(o)));
|
|
157
|
+
|
|
158
|
+
// sessionId may be null ONLY where no session ever existed to identify.
|
|
159
|
+
export const SESSION_ID_NULLABLE_OUTCOMES = deepFreeze(['transport-failure', 'contract-refusal', 'store-failure', 'missing-identity']);
|
|
160
|
+
|
|
161
|
+
// The allowed-successor table WITHIN one nonce thread — an exported frozen structure, never prose
|
|
162
|
+
// (the flow-record ALLOWED_TRANSITIONS idiom). A degrade is the legal no-fold closure of a thread,
|
|
163
|
+
// stale success included; it always carries its rationale.
|
|
164
|
+
export const ALLOWED_TRANSITIONS = deepFreeze({
|
|
165
|
+
dispatch: ['return', 'degrade'],
|
|
166
|
+
return: Object.fromEntries(RETURN_OUTCOMES.map((o) => [o, NON_TERMINAL_RETURN_OUTCOMES.includes(o) ? ['fold', 'degrade'] : []])),
|
|
167
|
+
fold: [],
|
|
168
|
+
degrade: [],
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// D9 — the closed, versioned step-class taxonomy. The acceptance wave registers `code`: the
|
|
172
|
+
// wrapper/git-provable exec domain.
|
|
173
|
+
export const STEP_CLASSES = deepFreeze(['code', 'extraction', 'triage', 'draft', 'research', 'review-opinion', 'worktree-stream']);
|
|
174
|
+
|
|
175
|
+
// D6 — provenance lives in the metric and has exactly ONE home there. Acceptance aggregates
|
|
176
|
+
// wrapper-git only; self-reported is recorded, EXCLUDED, printed as observational.
|
|
177
|
+
export const METRIC_PROVENANCE = deepFreeze(['wrapper-git', 'self-reported', 'solo-construction']);
|
|
178
|
+
// An observation is hand-recorded by construction, so it can never claim the wrapper-git domain
|
|
179
|
+
// (delegated accounting is DERIVED from nonce threads, never hand-appended); a delegated return is
|
|
180
|
+
// never the SOLO baseline.
|
|
181
|
+
export const OBSERVATION_PROVENANCE = deepFreeze(['solo-construction', 'self-reported']);
|
|
182
|
+
export const RETURN_PROVENANCE = deepFreeze(['wrapper-git', 'self-reported']);
|
|
183
|
+
|
|
184
|
+
// Every ineligibility is NAMED — the fail-closed rule is "never a silent zero".
|
|
185
|
+
export const INELIGIBLE_REASONS = deepFreeze(['dirty-baseline', 'no-op-diff', 'empty-report', 'zero-length-bundle', 'zero-denominator', 'zero-byte-proxy']);
|
|
186
|
+
|
|
187
|
+
// D6 — the per-kind numerator rules, in TWO domains that never mix.
|
|
188
|
+
// • the EXEC diff kinds count the FULL image of every object the returned diff touches (a rename
|
|
189
|
+
// counts its object ONCE across both names); gate output counts only when the wrapper
|
|
190
|
+
// byte-preserved it (a Plan-2 producer). None of them enumerates content ranges — partial exec
|
|
191
|
+
// accounting is not expressible, by construction.
|
|
192
|
+
// • the ENUMERATED domain (the extraction step class) counts the source bytes a report
|
|
193
|
+
// enumerates, where overlapping ranges count once.
|
|
194
|
+
export const NUMERATOR_RULES = deepFreeze({
|
|
195
|
+
new: 'post-image',
|
|
196
|
+
deleted: 'pre-image',
|
|
197
|
+
modified: 'pre-image',
|
|
198
|
+
renamed: 'both-names-once',
|
|
199
|
+
binary: 'size-only',
|
|
200
|
+
symlink: 'size-only',
|
|
201
|
+
submodule: 'size-only',
|
|
202
|
+
'non-regular': 'size-only',
|
|
203
|
+
'gate-output': 'byte-preserved-only',
|
|
204
|
+
enumerated: 'enumerated-ranges-once',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
export const METRIC_COMPONENT_KINDS = deepFreeze(Object.keys(NUMERATOR_RULES));
|
|
208
|
+
export const ENUMERATED_COMPONENT_KINDS = deepFreeze(['enumerated']);
|
|
209
|
+
export const EXEC_COMPONENT_KINDS = deepFreeze(METRIC_COMPONENT_KINDS.filter((k) => !ENUMERATED_COMPONENT_KINDS.includes(k)));
|
|
210
|
+
|
|
211
|
+
// An EMPTY enumeration belongs to no domain, so it never blocks a named ineligibility.
|
|
212
|
+
const componentDomain = (kind) => (ENUMERATED_COMPONENT_KINDS.includes(kind) ? 'enumerated' : 'exec');
|
|
213
|
+
|
|
214
|
+
// Gate output is the one kind that counts no repository object: it names neither a path nor an id.
|
|
215
|
+
const namesAnObject = (kind) => kind !== 'gate-output';
|
|
216
|
+
|
|
217
|
+
// ── nested closed forms ───────────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
const VEHICLE_KEYS = ['requested', 'selected'];
|
|
220
|
+
const POSTURE_KEYS = ['model', 'effort', 'tier'];
|
|
221
|
+
const METRIC_KEYS = ['numeratorBytes', 'denominatorBytes', 'components', 'provenance', 'eligible', 'ineligibleReason'];
|
|
222
|
+
const COMPONENT_KEYS = ['kind', 'path', 'objectId', 'bytes'];
|
|
223
|
+
|
|
224
|
+
const UNSAFE_TOTAL = 'the running total leaves the safe-integer range — an exact byte comparison is no longer possible (fail closed)';
|
|
225
|
+
|
|
226
|
+
const checkClosedKeys = (at, value, keys) => {
|
|
227
|
+
if (!isPlainObject(value)) return refuse(`${at} must be an object`);
|
|
228
|
+
const own = Object.keys(value);
|
|
229
|
+
const stray = own.find((k) => !keys.includes(k));
|
|
230
|
+
if (stray !== undefined) return refuse(`${at}: unknown field "${stray}" — the nested key set is closed`);
|
|
231
|
+
const missing = keys.find((k) => !own.includes(k));
|
|
232
|
+
if (missing !== undefined) return refuse(`${at}: missing field "${missing}"`);
|
|
233
|
+
const accessor = keys.find((k) => !isDataProperty(value, k));
|
|
234
|
+
if (accessor !== undefined) return refuse(`${at}: field "${accessor}" ${ACCESSOR_REFUSAL}`);
|
|
235
|
+
return { ok: true };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const validateVehicle = (at, vehicle) => {
|
|
239
|
+
const closed = checkClosedKeys(at, vehicle, VEHICLE_KEYS);
|
|
240
|
+
if (!closed.ok) return closed;
|
|
241
|
+
const bad = VEHICLE_KEYS.find((k) => !isNonEmptyString(vehicle[k]));
|
|
242
|
+
return bad === undefined ? { ok: true } : refuse(`${at}: ${bad} must be a non-empty vehicle name (got ${short(vehicle[bad])})`);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const validatePosture = (at, posture) => {
|
|
246
|
+
const closed = checkClosedKeys(at, posture, POSTURE_KEYS);
|
|
247
|
+
if (!closed.ok) return closed;
|
|
248
|
+
if (!isNonEmptyString(posture.model)) return refuse(`${at}: model must be a non-empty model name (got ${short(posture.model)})`);
|
|
249
|
+
const bad = ['effort', 'tier'].find((k) => posture[k] !== null && !isNonEmptyString(posture[k]));
|
|
250
|
+
return bad === undefined ? { ok: true } : refuse(`${at}: ${bad} must be a non-empty string or null (got ${short(posture[bad])})`);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const validateMetric = (at, metric, allowedProvenance) => {
|
|
254
|
+
const closed = checkClosedKeys(at, metric, METRIC_KEYS);
|
|
255
|
+
if (!closed.ok) return closed;
|
|
256
|
+
for (const k of ['numeratorBytes', 'denominatorBytes']) {
|
|
257
|
+
if (!isByteCount(metric[k])) return refuse(`${at}: ${k} must be a non-negative safe-integer byte count (got ${short(metric[k])})`);
|
|
258
|
+
}
|
|
259
|
+
if (!allowedProvenance.includes(metric.provenance)) {
|
|
260
|
+
return refuse(`${at}: provenance must be one of ${allowedProvenance.join(' | ')} (got ${short(metric.provenance)})`);
|
|
261
|
+
}
|
|
262
|
+
if (typeof metric.eligible !== 'boolean') return refuse(`${at}: eligible must be a boolean (got ${short(metric.eligible)})`);
|
|
263
|
+
if (metric.eligible) {
|
|
264
|
+
if (metric.ineligibleReason !== null) return refuse(`${at}: an ELIGIBLE metric carries ineligibleReason null (got ${short(metric.ineligibleReason)})`);
|
|
265
|
+
} else if (!INELIGIBLE_REASONS.includes(metric.ineligibleReason)) {
|
|
266
|
+
return refuse(`${at}: ineligibleReason must name one of ${INELIGIBLE_REASONS.join(' | ')} — an ineligible metric is never a silent zero (got ${short(metric.ineligibleReason)})`);
|
|
267
|
+
}
|
|
268
|
+
if (!isDenseDataArray(metric.components)) {
|
|
269
|
+
return refuse(`${at}: components must be a DENSE array whose every index is an own enumerable data property`);
|
|
270
|
+
}
|
|
271
|
+
let sum = 0;
|
|
272
|
+
let domain = null;
|
|
273
|
+
for (let i = 0; i < metric.components.length; i += 1) {
|
|
274
|
+
const c = metric.components[i];
|
|
275
|
+
const cAt = `${at}.components[${i}]`;
|
|
276
|
+
const cClosed = checkClosedKeys(cAt, c, COMPONENT_KEYS);
|
|
277
|
+
if (!cClosed.ok) return cClosed;
|
|
278
|
+
if (!METRIC_COMPONENT_KINDS.includes(c.kind)) {
|
|
279
|
+
return refuse(`${cAt}: kind must be one of ${METRIC_COMPONENT_KINDS.join(' | ')} (got ${short(c.kind)})`);
|
|
280
|
+
}
|
|
281
|
+
const kindDomain = componentDomain(c.kind);
|
|
282
|
+
if (domain !== null && domain !== kindDomain) {
|
|
283
|
+
return refuse(`${cAt}: a metric's components all belong to ONE domain — this one is ${kindDomain}, the enumeration opened as ${domain}`);
|
|
284
|
+
}
|
|
285
|
+
domain = kindDomain;
|
|
286
|
+
const named = namesAnObject(c.kind);
|
|
287
|
+
if (named ? !isNonEmptyString(c.path) : c.path !== null) {
|
|
288
|
+
return refuse(`${cAt}: path must be ${named ? 'a non-empty path' : 'null — gate output names no path'} (got ${short(c.path)})`);
|
|
289
|
+
}
|
|
290
|
+
if (named ? !isNonEmptyString(c.objectId) : c.objectId !== null) {
|
|
291
|
+
return refuse(`${cAt}: objectId must be ${named ? 'the non-empty identity of the object counted (the dedup key)' : 'null — gate output counts no object'} (got ${short(c.objectId)})`);
|
|
292
|
+
}
|
|
293
|
+
if (!isByteCount(c.bytes)) return refuse(`${cAt}: bytes must be a non-negative safe-integer byte count (got ${short(c.bytes)})`);
|
|
294
|
+
sum += c.bytes;
|
|
295
|
+
if (!Number.isSafeInteger(sum)) return refuse(`${cAt}: ${UNSAFE_TOTAL}`);
|
|
296
|
+
}
|
|
297
|
+
// ONE direction only: the ENUMERATED domain is a delegate's own account, so it requires
|
|
298
|
+
// self-reported (hence wrapper-git implies EXEC). The EXEC domain accepts self-reported too — a
|
|
299
|
+
// return whose bytes the wrapper could not prove is still RECORDED, and excluded downstream.
|
|
300
|
+
if (domain === 'enumerated' && metric.provenance !== 'self-reported') {
|
|
301
|
+
return refuse(`${at}: the ENUMERATED component domain requires provenance "self-reported" (got ${short(metric.provenance)}) — enumerated bytes are never git-provable`);
|
|
302
|
+
}
|
|
303
|
+
if (sum !== metric.numeratorBytes) {
|
|
304
|
+
return refuse(`${at}: the components sum (${sum}) must equal numeratorBytes (${metric.numeratorBytes}) — the enumeration IS the numerator`);
|
|
305
|
+
}
|
|
306
|
+
return { ok: true };
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// ── per-field shapes ──────────────────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
const FIELD_CHECKS = {
|
|
312
|
+
waveId: { ok: isSafeName, want: 'a wave id in the safe name grammar ([A-Za-z0-9._-]{1,64})' },
|
|
313
|
+
nonce: { ok: isSafeName, want: 'a thread nonce in the safe nonce grammar ([A-Za-z0-9._-]{1,64})' },
|
|
314
|
+
nonceOrNull: { ok: (v) => v === null || isSafeName(v), want: 'a thread nonce in the safe nonce grammar, or null for a pre-dispatch degrade' },
|
|
315
|
+
retryOf: { ok: (v) => v === null || isSafeName(v), want: 'the prior thread nonce this dispatch retries, or null when it is not a retry' },
|
|
316
|
+
stepClass: { ok: (v) => STEP_CLASSES.includes(v), want: `one of the D9 step classes ${STEP_CLASSES.join(' | ')}` },
|
|
317
|
+
stepClasses: {
|
|
318
|
+
ok: (v) => isDenseDataArray(v) && v.length > 0 && v.every((c) => STEP_CLASSES.includes(c)) && new Set(v).size === v.length,
|
|
319
|
+
want: `a non-empty DENSE SET of D9 step classes ${STEP_CLASSES.join(' | ')}`,
|
|
320
|
+
},
|
|
321
|
+
pairingKey: { ok: isNonEmptyString, want: 'the non-empty pairing key the wave pairs observations by' },
|
|
322
|
+
minPerClass: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'a positive integer minimum of observations per class' },
|
|
323
|
+
meanLThreshold: { ok: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0, want: 'a positive finite per-class mean-L threshold' },
|
|
324
|
+
firstPassNum: { ok: isByteCount, want: 'a non-negative integer first-pass numerator' },
|
|
325
|
+
firstPassDen: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'a positive integer first-pass denominator' },
|
|
326
|
+
vehicle: { ok: isPlainObject, want: 'the closed vehicle pair {requested, selected}' },
|
|
327
|
+
backend: { ok: isNonEmptyString, want: 'a non-empty backend name' },
|
|
328
|
+
contractDigest: { ok: isHex64, want: 'the 64-hex digest of the canonical contract header' },
|
|
329
|
+
preTreeDigest: { ok: isHex64, want: 'the 64-hex uncommitted-state fingerprint at dispatch' },
|
|
330
|
+
postTreeDigest: { ok: isHex64, want: 'the 64-hex uncommitted-state fingerprint after the run' },
|
|
331
|
+
diffDigest: { ok: isHex64, want: 'the 64-hex digest of the returned diff' },
|
|
332
|
+
reportDigest: { ok: isHex64, want: 'the 64-hex digest of the returned report' },
|
|
333
|
+
bundleDigest: { ok: isHex64, want: 'the 64-hex digest of the canonical integration bundle' },
|
|
334
|
+
returnDigest: { ok: isHex64, want: 'the 64-hex per-record canonical digest of the folded return' },
|
|
335
|
+
treeDigestAtFold: { ok: isHex64, want: 'the 64-hex uncommitted-state fingerprint at the fold' },
|
|
336
|
+
baselineClean: { ok: (v) => typeof v === 'boolean', want: 'a boolean recording whether the dispatch started from a CLEAN baseline' },
|
|
337
|
+
deadlineS: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'a positive integer deadline in seconds' },
|
|
338
|
+
retryIndex: { ok: isByteCount, want: 'a non-negative integer retry index (0 = the first attempt)' },
|
|
339
|
+
retryCap: { ok: isByteCount, want: 'a non-negative integer retry cap, COPIED from the contract header at mint' },
|
|
340
|
+
rationale: { ok: isNonEmptyString, want: 'a non-empty recorded rationale' },
|
|
341
|
+
role: { ok: (v) => v === 'execute', want: 'exactly "execute" — this ledger records EXEC returns only' },
|
|
342
|
+
diffLength: { ok: isByteCount, want: 'a non-negative safe-integer diff byte length' },
|
|
343
|
+
reportLength: { ok: isByteCount, want: 'a non-negative safe-integer report byte length' },
|
|
344
|
+
bundleLength: { ok: isByteCount, want: 'a non-negative safe-integer bundle byte length' },
|
|
345
|
+
metric: { ok: isPlainObject, want: 'the closed metric object {numeratorBytes, denominatorBytes, components, provenance, eligible, ineligibleReason}' },
|
|
346
|
+
outcome: { ok: (v) => RETURN_OUTCOMES.includes(v), want: `one of the D4 outcomes ${RETURN_OUTCOMES.join(' | ')}` },
|
|
347
|
+
exitStatus: { ok: isByteCount, want: 'a non-negative integer process exit status' },
|
|
348
|
+
sessionId: { ok: (v) => v === null || isNonEmptyString(v), want: 'a non-empty backend session id, or null where no session existed' },
|
|
349
|
+
wrapperVersion: { ok: isNonEmptyString, want: 'the non-empty dispatching wrapper version' },
|
|
350
|
+
posture: { ok: isPlainObject, want: 'the closed posture object {model, effort, tier}' },
|
|
351
|
+
verdict: { ok: isNonEmptyString, want: 'the non-empty fold verdict' },
|
|
352
|
+
scope: { ok: isNonEmptyString, want: 'the non-empty observed scope' },
|
|
353
|
+
planId: { ok: isNonEmptyString, want: 'the non-empty owning plan id' },
|
|
354
|
+
phase: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'a positive integer phase index' },
|
|
355
|
+
timestamp: { ok: isCanonicalInstant, want: 'a canonical UTC ISO instant (toISOString round-trip)' },
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Kind-scoped check overrides where one field name carries a different nullability per kind: a
|
|
359
|
+
// degrade may precede any dispatch (a pre-dispatch degrade has no thread to name).
|
|
360
|
+
const KIND_FIELD_CHECK_OVERRIDES = {
|
|
361
|
+
degrade: { nonce: 'nonceOrNull' },
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const checkFields = (kind, record) => {
|
|
365
|
+
const overrides = KIND_FIELD_CHECK_OVERRIDES[kind] ?? {};
|
|
366
|
+
const fields = DELEGATION_KEY_SETS[kind];
|
|
367
|
+
const allowed = [...IDENTITY_FIELDS, ...fields];
|
|
368
|
+
const own = Object.keys(record);
|
|
369
|
+
const stray = own.find((k) => !allowed.includes(k));
|
|
370
|
+
if (stray !== undefined) {
|
|
371
|
+
return refuse(`${kind}: unknown field "${stray}" — the key set is closed (the canonical digest is identity; a stray key would fork it)`);
|
|
372
|
+
}
|
|
373
|
+
for (const field of fields) {
|
|
374
|
+
if (!own.includes(field)) {
|
|
375
|
+
return refuse(`${kind}: missing field "${field}" — every field of the closed set is pinned as an OWN ENUMERABLE key (the digest domain)`);
|
|
376
|
+
}
|
|
377
|
+
if (!isDataProperty(record, field)) return refuse(`${kind}: field "${field}" ${ACCESSOR_REFUSAL}`);
|
|
378
|
+
const check = FIELD_CHECKS[overrides[field] ?? field];
|
|
379
|
+
if (!check.ok(record[field])) return refuse(`${kind}: ${field} must be ${check.want} (got ${short(record[field])})`);
|
|
380
|
+
}
|
|
381
|
+
return { ok: true };
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// ── the record validator ──────────────────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
const validateDispatchCrossFields = (r) => {
|
|
387
|
+
if ((r.retryIndex === 0) !== (r.retryOf === null)) {
|
|
388
|
+
return r.retryIndex === 0
|
|
389
|
+
? refuse(`dispatch: retryIndex 0 is the FIRST attempt and carries retryOf null (got ${short(r.retryOf)}) — a retry starts at index 1`)
|
|
390
|
+
: refuse(`dispatch: retryIndex ${r.retryIndex} requires retryOf — a retry records the prior thread nonce it retries`);
|
|
391
|
+
}
|
|
392
|
+
if (r.retryIndex > r.retryCap) {
|
|
393
|
+
return refuse(`dispatch: retryIndex ${r.retryIndex} exceeds the recorded retryCap ${r.retryCap} — a thread never retries past its cap`);
|
|
394
|
+
}
|
|
395
|
+
return { ok: true };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const validateReturnCrossFields = (r) => {
|
|
399
|
+
const posture = validatePosture('return: posture', r.posture);
|
|
400
|
+
if (!posture.ok) return posture;
|
|
401
|
+
const metric = validateMetric('return: metric', r.metric, RETURN_PROVENANCE);
|
|
402
|
+
if (!metric.ok) return metric;
|
|
403
|
+
if (r.outcome === 'success' && r.exitStatus !== 0) {
|
|
404
|
+
return refuse(`return: outcome "success" requires exitStatus 0 (got ${r.exitStatus}) — a nonzero exitStatus never reports success`);
|
|
405
|
+
}
|
|
406
|
+
if (r.sessionId === null && !SESSION_ID_NULLABLE_OUTCOMES.includes(r.outcome)) {
|
|
407
|
+
return refuse(`return: sessionId may be null only for ${SESSION_ID_NULLABLE_OUTCOMES.join(' | ')} — outcome "${r.outcome}" requires a non-null sessionId`);
|
|
408
|
+
}
|
|
409
|
+
const framed = expectedBundleLength(r.diffLength, r.reportLength);
|
|
410
|
+
if (framed === null || r.bundleLength !== framed) {
|
|
411
|
+
const why = framed === null ? 'the framing arithmetic leaves the safe-integer range' : `expected ${framed}`;
|
|
412
|
+
return refuse(`return: bundleLength ${r.bundleLength} must equal the D6 framing of diffLength ${r.diffLength} and reportLength ${r.reportLength} (${why})`);
|
|
413
|
+
}
|
|
414
|
+
if (r.metric.denominatorBytes !== r.bundleLength) {
|
|
415
|
+
return refuse(`return: metric.denominatorBytes ${r.metric.denominatorBytes} must equal bundleLength ${r.bundleLength} — the denominator IS the canonical integration bundle`);
|
|
416
|
+
}
|
|
417
|
+
// A return substantiates its own eligibility. `baselineClean` lives on the DISPATCH (D5), so the
|
|
418
|
+
// dirty-baseline arm stays the store's — and it may only ever make the verdict stricter.
|
|
419
|
+
const local = evaluateMetricEligibility({
|
|
420
|
+
baselineClean: true,
|
|
421
|
+
numeratorBytes: r.metric.numeratorBytes,
|
|
422
|
+
diffLength: r.diffLength,
|
|
423
|
+
reportLength: r.reportLength,
|
|
424
|
+
bundleLength: r.bundleLength,
|
|
425
|
+
});
|
|
426
|
+
if (local.ineligibleReason !== null) {
|
|
427
|
+
if (r.metric.eligible) {
|
|
428
|
+
return refuse(`return: this return's own fields make the metric INELIGIBLE (${local.ineligibleReason}) — an eligible metric is never claimed over them`);
|
|
429
|
+
}
|
|
430
|
+
if (r.metric.ineligibleReason !== local.ineligibleReason && r.metric.ineligibleReason !== 'dirty-baseline') {
|
|
431
|
+
return refuse(`return: metric.ineligibleReason "${r.metric.ineligibleReason}" contradicts this return's own fields (locally "${local.ineligibleReason}") — only "dirty-baseline" may override`);
|
|
432
|
+
}
|
|
433
|
+
} else if (!r.metric.eligible && r.metric.ineligibleReason !== 'dirty-baseline') {
|
|
434
|
+
return refuse(`return: metric.ineligibleReason "${r.metric.ineligibleReason}" is not substantiated by this return's own fields — when the local evaluation finds the metric eligible, only "dirty-baseline" (the store-verified override) may be recorded`);
|
|
435
|
+
}
|
|
436
|
+
return { ok: true };
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const validateObservationCrossFields = (r) => {
|
|
440
|
+
const metric = validateMetric('observation: metric', r.metric, OBSERVATION_PROVENANCE);
|
|
441
|
+
if (!metric.ok) return metric;
|
|
442
|
+
// An observation has no diff, report or bundle — its own two numbers decide, and there is no
|
|
443
|
+
// store-side override arm, so the record must agree with them exactly.
|
|
444
|
+
const local = evaluateObservationEligibility({
|
|
445
|
+
numeratorBytes: r.metric.numeratorBytes,
|
|
446
|
+
denominatorBytes: r.metric.denominatorBytes,
|
|
447
|
+
});
|
|
448
|
+
if (local.ineligibleReason !== null) {
|
|
449
|
+
if (r.metric.eligible) {
|
|
450
|
+
return refuse(`observation: this observation's own numbers make the metric INELIGIBLE (${local.ineligibleReason}) — an eligible metric is never claimed over them`);
|
|
451
|
+
}
|
|
452
|
+
if (r.metric.ineligibleReason !== local.ineligibleReason) {
|
|
453
|
+
return refuse(`observation: metric.ineligibleReason "${r.metric.ineligibleReason}" contradicts this observation's own numbers (locally "${local.ineligibleReason}")`);
|
|
454
|
+
}
|
|
455
|
+
} else if (!r.metric.eligible) {
|
|
456
|
+
return refuse(`observation: metric.ineligibleReason "${r.metric.ineligibleReason}" is not substantiated by this observation's own numbers — an observation carries no store-side override`);
|
|
457
|
+
}
|
|
458
|
+
return { ok: true };
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// validateDelegationRecord(record) → { ok: true } | { ok: false, reason }. Fail closed on an unknown
|
|
462
|
+
// schema/kind, a missing/accessor/malformed field, any key outside the closed per-kind set, and
|
|
463
|
+
// every cross-field equality the kind pins. Never throws on a DATA record.
|
|
464
|
+
export const validateDelegationRecord = (record) => {
|
|
465
|
+
if (!isPlainObject(record)) return refuse('record is not an object');
|
|
466
|
+
const own = Object.keys(record);
|
|
467
|
+
const missingIdentity = IDENTITY_FIELDS.find((f) => !own.includes(f));
|
|
468
|
+
if (missingIdentity !== undefined) {
|
|
469
|
+
return refuse(`missing field "${missingIdentity}" — the identifying fields are pinned as OWN ENUMERABLE keys BEFORE their values are read (the digest domain)`);
|
|
470
|
+
}
|
|
471
|
+
const accessorIdentity = IDENTITY_FIELDS.find((f) => !isDataProperty(record, f));
|
|
472
|
+
if (accessorIdentity !== undefined) return refuse(`field "${accessorIdentity}" ${ACCESSOR_REFUSAL}`);
|
|
473
|
+
if (record.schema !== DELEGATION_SCHEMA_VERSION) {
|
|
474
|
+
return refuse(`unknown schema ${short(record.schema)} — this reader accepts delegation schema ${DELEGATION_SCHEMA_VERSION} only (fail closed)`);
|
|
475
|
+
}
|
|
476
|
+
if (!DELEGATION_KINDS.includes(record.kind)) {
|
|
477
|
+
return refuse(`unknown kind ${short(record.kind)} — closed set: ${DELEGATION_KINDS.join(' | ')} (fail closed)`);
|
|
478
|
+
}
|
|
479
|
+
const checked = checkFields(record.kind, record);
|
|
480
|
+
if (!checked.ok) return checked;
|
|
481
|
+
if (record.kind === 'pre-registration') {
|
|
482
|
+
return record.firstPassNum <= record.firstPassDen
|
|
483
|
+
? { ok: true }
|
|
484
|
+
: refuse(`pre-registration: firstPassNum ${record.firstPassNum} must not exceed firstPassDen ${record.firstPassDen} — the threshold is a rate`);
|
|
485
|
+
}
|
|
486
|
+
if (record.kind === 'dispatch') {
|
|
487
|
+
const vehicle = validateVehicle('dispatch: vehicle', record.vehicle);
|
|
488
|
+
return vehicle.ok ? validateDispatchCrossFields(record) : vehicle;
|
|
489
|
+
}
|
|
490
|
+
if (record.kind === 'return') return validateReturnCrossFields(record);
|
|
491
|
+
if (record.kind === 'observation') return validateObservationCrossFields(record);
|
|
492
|
+
return { ok: true };
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// ── thread successors + terminality (the store consumes these) ────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
export const allowedSuccessorKinds = (record) => {
|
|
498
|
+
if (!isPlainObject(record)) return [];
|
|
499
|
+
if (record.kind === 'return') return ALLOWED_TRANSITIONS.return[record.outcome] ?? [];
|
|
500
|
+
return ALLOWED_TRANSITIONS[record.kind] ?? [];
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
export const isThreadTerminalRecord = (record) => {
|
|
504
|
+
if (!isPlainObject(record)) return false;
|
|
505
|
+
if (record.kind === 'fold' || record.kind === 'degrade') return true;
|
|
506
|
+
return record.kind === 'return' && TERMINAL_RETURN_OUTCOMES.includes(record.outcome);
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// ── the per-record canonical digest (the record-reference id domain) ──────────────────────────────
|
|
510
|
+
|
|
511
|
+
// The family's ONE canonical discipline, imported rather than copied: recursively key-sorted JSON,
|
|
512
|
+
// NO trailing newline (the newline is store framing, never record identity).
|
|
513
|
+
export const delegationCanonicalSerialization = flowCanonicalSerialization;
|
|
514
|
+
|
|
515
|
+
export const canonicalDelegationDigest = (record) =>
|
|
516
|
+
createHash('sha256').update(delegationCanonicalSerialization(record), 'utf8').digest('hex');
|
|
517
|
+
|
|
518
|
+
// ── D8: the sub-task contract header (form only) ──────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
export const CONTRACT_INFO_STRING = 'aw-dispatch-contract';
|
|
521
|
+
export const CONTRACT_KEYS = deepFreeze(['schema', 'nonce', 'stepClass', 'vehicle', 'scope', 'inputs', 'acceptance', 'returnShape', 'producerContract', 'deadlineS', 'retry']);
|
|
522
|
+
|
|
523
|
+
// Markdown fence grammar: three or more backticks, then an optional info string. A block opened
|
|
524
|
+
// with N backticks closes only on a bare fence of at least N — which is exactly how documentation
|
|
525
|
+
// nests one fenced block inside another.
|
|
526
|
+
const FENCE_RE = /^(`{3,})(.*)$/;
|
|
527
|
+
|
|
528
|
+
const RETRY_KEYS = ['cap', 'index'];
|
|
529
|
+
|
|
530
|
+
const validateRetryPolicy = (at, retry) => {
|
|
531
|
+
const closed = checkClosedKeys(at, retry, RETRY_KEYS);
|
|
532
|
+
if (!closed.ok) return closed;
|
|
533
|
+
const bad = RETRY_KEYS.find((k) => !isByteCount(retry[k]));
|
|
534
|
+
if (bad !== undefined) return refuse(`${at}: ${bad} must be a non-negative integer (got ${short(retry[bad])})`);
|
|
535
|
+
return retry.index <= retry.cap ? { ok: true } : refuse(`${at}: index ${retry.index} exceeds cap ${retry.cap}`);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const CONTRACT_FIELD_CHECKS = {
|
|
539
|
+
schema: { ok: (v) => v === DELEGATION_SCHEMA_VERSION, want: `exactly ${DELEGATION_SCHEMA_VERSION}` },
|
|
540
|
+
nonce: { ok: isSafeName, want: 'a nonce in the safe grammar ([A-Za-z0-9._-]{1,64})' },
|
|
541
|
+
stepClass: { ok: (v) => STEP_CLASSES.includes(v), want: `one of the D9 step classes ${STEP_CLASSES.join(' | ')}` },
|
|
542
|
+
vehicle: { ok: (v) => validateVehicle('vehicle', v).ok, want: 'the closed vehicle pair {requested, selected} of non-empty names' },
|
|
543
|
+
scope: { ok: isNonEmptyString, want: 'a non-empty scope statement' },
|
|
544
|
+
inputs: { ok: isNonEmptyString, want: 'a non-empty inputs statement' },
|
|
545
|
+
acceptance: { ok: isNonEmptyString, want: 'a non-empty acceptance statement' },
|
|
546
|
+
returnShape: { ok: isNonEmptyString, want: 'a non-empty return-shape statement' },
|
|
547
|
+
producerContract: { ok: isNonEmptyString, want: 'a non-empty producer-contract statement' },
|
|
548
|
+
deadlineS: { ok: (v) => Number.isSafeInteger(v) && v >= 1, want: 'a positive integer deadline in seconds' },
|
|
549
|
+
retry: { ok: (v) => validateRetryPolicy('retry', v).ok, want: 'the closed retry policy {cap, index} of non-negative integers with index <= cap' },
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// extractContractBlock(text) → { ok: true, source } | { ok: false, reason }. Exactly ONE fenced
|
|
553
|
+
// block carrying the aw-dispatch-contract info string, AT TOP LEVEL; absent, unclosed and
|
|
554
|
+
// duplicated all refuse. The walk tracks fence nesting, so a contract marker appearing INSIDE
|
|
555
|
+
// another fenced block is example text — a documentation block showing the shape must never be
|
|
556
|
+
// mistaken for the contract the dispatch file actually carries. Both line endings are accepted: a
|
|
557
|
+
// CRLF-authored dispatch file is an ordinary case, and dropping the carriage return cannot move
|
|
558
|
+
// contractDigest, which is taken over the PARSED object.
|
|
559
|
+
export const extractContractBlock = (text) => {
|
|
560
|
+
if (typeof text !== 'string') return refuse('dispatch contract: the dispatch file must be text');
|
|
561
|
+
const lines = text.split(/\r?\n/);
|
|
562
|
+
const blocks = [];
|
|
563
|
+
let openTicks = 0;
|
|
564
|
+
let openInfo = '';
|
|
565
|
+
let openAt = -1;
|
|
566
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
567
|
+
const fence = FENCE_RE.exec(lines[i]);
|
|
568
|
+
if (fence === null) continue;
|
|
569
|
+
const ticks = fence[1].length;
|
|
570
|
+
const info = fence[2].trim();
|
|
571
|
+
if (openTicks === 0) {
|
|
572
|
+
openTicks = ticks;
|
|
573
|
+
openInfo = info;
|
|
574
|
+
openAt = i;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// Inside an open block, only a BARE fence of at least the opening length closes it; an
|
|
578
|
+
// info-bearing or shorter fence is content.
|
|
579
|
+
if (info === '' && ticks >= openTicks) {
|
|
580
|
+
if (openInfo === CONTRACT_INFO_STRING) blocks.push(lines.slice(openAt + 1, i).join('\n'));
|
|
581
|
+
openTicks = 0;
|
|
582
|
+
openInfo = '';
|
|
583
|
+
openAt = -1;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (openTicks !== 0 && openInfo === CONTRACT_INFO_STRING) {
|
|
587
|
+
return refuse(`dispatch contract: the \`\`\`${CONTRACT_INFO_STRING} block is never closed`);
|
|
588
|
+
}
|
|
589
|
+
if (blocks.length === 0) return refuse(`dispatch contract: no top-level \`\`\`${CONTRACT_INFO_STRING} block found — the header is absent (fail closed)`);
|
|
590
|
+
if (blocks.length > 1) return refuse(`dispatch contract: ${blocks.length} \`\`\`${CONTRACT_INFO_STRING} blocks found — a dispatch file carries exactly one`);
|
|
591
|
+
return { ok: true, source: blocks[0] };
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
export const parseDispatchContract = (text) => {
|
|
595
|
+
const block = extractContractBlock(text);
|
|
596
|
+
if (!block.ok) return block;
|
|
597
|
+
let contract;
|
|
598
|
+
try {
|
|
599
|
+
contract = JSON.parse(block.source);
|
|
600
|
+
} catch {
|
|
601
|
+
return refuse('dispatch contract: the block body is not valid JSON (fail closed)');
|
|
602
|
+
}
|
|
603
|
+
if (!isPlainObject(contract)) return refuse(`dispatch contract: the block body must be ONE JSON object (got ${short(contract)})`);
|
|
604
|
+
return { ok: true, contract };
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
// The FORM-only check over an already-parsed header. Names the FIRST violated field and stops —
|
|
608
|
+
// form only, by name: a well-formed absurdity passes here (D-R1-FORM-ONLY).
|
|
609
|
+
const checkContractObjectForm = (contract) => {
|
|
610
|
+
if (!isPlainObject(contract)) return refuse('dispatch contract: the header must be ONE JSON object');
|
|
611
|
+
const own = Object.keys(contract);
|
|
612
|
+
const stray = own.find((k) => !CONTRACT_KEYS.includes(k));
|
|
613
|
+
if (stray !== undefined) return refuse(`dispatch contract: unknown field "${stray}" — the header key set is closed`);
|
|
614
|
+
for (const field of CONTRACT_KEYS) {
|
|
615
|
+
if (!own.includes(field)) return refuse(`dispatch contract: missing field "${field}"`);
|
|
616
|
+
if (!isDataProperty(contract, field)) return refuse(`dispatch contract: field "${field}" ${ACCESSOR_REFUSAL}`);
|
|
617
|
+
const check = CONTRACT_FIELD_CHECKS[field];
|
|
618
|
+
if (!check.ok(contract[field])) return refuse(`dispatch contract: "${field}" must be ${check.want} (got ${short(contract[field])})`);
|
|
619
|
+
}
|
|
620
|
+
return { ok: true, contract };
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
export const checkDispatchContractForm = (text) => {
|
|
624
|
+
const parsed = parseDispatchContract(text);
|
|
625
|
+
return parsed.ok ? checkContractObjectForm(parsed.contract) : parsed;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// D6 — contractDigest is sha256 over the CANONICAL serialization of the PARSED header, so neither
|
|
629
|
+
// key order nor whitespace layout can move it.
|
|
630
|
+
export const contractDigest = (contract) =>
|
|
631
|
+
createHash('sha256').update(delegationCanonicalSerialization(contract), 'utf8').digest('hex');
|
|
632
|
+
|
|
633
|
+
// D3 — retryCap (and the rest of the mint-time copy) is COPIED from the contract header at dispatch
|
|
634
|
+
// mint, and contractDigest binds the copy. A dispatch that disagrees with the header it claims to
|
|
635
|
+
// carry is refused by NAME.
|
|
636
|
+
export const checkDispatchMintConsistency = (contract, dispatch) => {
|
|
637
|
+
const form = checkContractObjectForm(contract);
|
|
638
|
+
if (!form.ok) return form;
|
|
639
|
+
if (!isPlainObject(dispatch) || dispatch.kind !== 'dispatch') {
|
|
640
|
+
return refuse(`dispatch mint: the minted record must be a dispatch record (got kind ${short(dispatch?.kind)})`);
|
|
641
|
+
}
|
|
642
|
+
const expected = contractDigest(contract);
|
|
643
|
+
if (dispatch.contractDigest !== expected) {
|
|
644
|
+
return refuse(`dispatch mint: contractDigest ${short(dispatch.contractDigest)} does not bind this header (expected ${expected})`);
|
|
645
|
+
}
|
|
646
|
+
const copies = [
|
|
647
|
+
['nonce', contract.nonce, dispatch.nonce],
|
|
648
|
+
['stepClass', contract.stepClass, dispatch.stepClass],
|
|
649
|
+
['deadlineS', contract.deadlineS, dispatch.deadlineS],
|
|
650
|
+
['retryCap', contract.retry.cap, dispatch.retryCap],
|
|
651
|
+
['retryIndex', contract.retry.index, dispatch.retryIndex],
|
|
652
|
+
['vehicle.requested', contract.vehicle.requested, dispatch.vehicle?.requested],
|
|
653
|
+
['vehicle.selected', contract.vehicle.selected, dispatch.vehicle?.selected],
|
|
654
|
+
];
|
|
655
|
+
for (const [name, header, minted] of copies) {
|
|
656
|
+
if (header !== minted) {
|
|
657
|
+
return refuse(`dispatch mint: ${name} is COPIED from the contract header at mint (header ${short(header)}, dispatch ${short(minted)})`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { ok: true };
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// ── D6: byte domains ──────────────────────────────────────────────────────────────────────────────
|
|
664
|
+
|
|
665
|
+
// normalizeByteRanges(ranges) → merged, ordered, non-overlapping [start, end) pairs, or null when
|
|
666
|
+
// any pair is malformed (non-integer, negative, or empty). Adjacent ranges merge: [0,10) and
|
|
667
|
+
// [10,20) describe one contiguous run.
|
|
668
|
+
export const normalizeByteRanges = (ranges) => {
|
|
669
|
+
if (!Array.isArray(ranges)) return null;
|
|
670
|
+
const pairs = [];
|
|
671
|
+
for (const range of ranges) {
|
|
672
|
+
if (!Array.isArray(range) || range.length !== 2) return null;
|
|
673
|
+
const [start, end] = range;
|
|
674
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end <= start) return null;
|
|
675
|
+
pairs.push([start, end]);
|
|
676
|
+
}
|
|
677
|
+
pairs.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
678
|
+
const merged = [];
|
|
679
|
+
for (const [start, end] of pairs) {
|
|
680
|
+
const last = merged[merged.length - 1];
|
|
681
|
+
if (last !== undefined && start <= last[1]) last[1] = Math.max(last[1], end);
|
|
682
|
+
else merged.push([start, end]);
|
|
683
|
+
}
|
|
684
|
+
return merged;
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
// Null when the total leaves the safe-integer range — every caller refuses on it by name.
|
|
688
|
+
const rangeTotal = (ranges) => {
|
|
689
|
+
let total = 0;
|
|
690
|
+
for (const [start, end] of ranges) {
|
|
691
|
+
total += end - start;
|
|
692
|
+
if (!Number.isSafeInteger(total)) return null;
|
|
693
|
+
}
|
|
694
|
+
return total;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// Per-kind entry shapes: which field carries the byte count, the extra REQUIRED fields, and whether
|
|
698
|
+
// the kind names an object. Only the enumerated domain carries `ranges` — the exec diff kinds count
|
|
699
|
+
// full images, so a partial claim is not expressible there at all.
|
|
700
|
+
const ENTRY_SHAPES = {
|
|
701
|
+
new: { size: 'postImageBytes' },
|
|
702
|
+
deleted: { size: 'preImageBytes' },
|
|
703
|
+
modified: { size: 'preImageBytes' },
|
|
704
|
+
renamed: { size: 'preImageBytes', extra: ['fromPath'] },
|
|
705
|
+
binary: { size: 'sizeBytes' },
|
|
706
|
+
symlink: { size: 'sizeBytes' },
|
|
707
|
+
submodule: { size: 'sizeBytes' },
|
|
708
|
+
'non-regular': { size: 'sizeBytes' },
|
|
709
|
+
'gate-output': { size: 'bytes', extra: ['bytePreserved'] },
|
|
710
|
+
enumerated: { size: 'sourceBytes', extra: ['ranges'] },
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
// computeNumerator(entries) → { ok: true, components, numeratorBytes } | { ok: false, reason }.
|
|
714
|
+
// Dedup keys on the producer-supplied OBJECT IDENTITY, never on a path: a rename chain (A→B→C) and a
|
|
715
|
+
// path re-created after a rename are indistinguishable by name, and either confusion mis-counts. One
|
|
716
|
+
// identity also means one SIZE — a second entry claiming that object at a different size refuses.
|
|
717
|
+
// Every entry field rides the same descriptor discipline as a record field and is read exactly ONCE
|
|
718
|
+
// into a local, so no value can differ between the dedup lookup and the store that follows it.
|
|
719
|
+
// The per-entry component records that entry's OWN non-overlapping contribution, so the component
|
|
720
|
+
// sum IS the numerator — an object enumerated twice (a rename's two names, a repeated entry, two
|
|
721
|
+
// overlapping enumerated ranges) is counted ONCE and the second enumeration records an honest zero.
|
|
722
|
+
// One enumeration stays inside ONE domain: mixing git-provable and self-reported bytes into a single
|
|
723
|
+
// numerator is what the domain split exists to prevent.
|
|
724
|
+
export const computeNumerator = (entries) => {
|
|
725
|
+
if (!isDenseDataArray(entries)) {
|
|
726
|
+
return refuse('numerator: the enumeration must be a DENSE array whose every index is an own enumerable data property');
|
|
727
|
+
}
|
|
728
|
+
const claimed = new Map();
|
|
729
|
+
const components = [];
|
|
730
|
+
let numeratorBytes = 0;
|
|
731
|
+
let domain = null;
|
|
732
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
733
|
+
const entry = entries[i];
|
|
734
|
+
const at = `numerator: entry[${i}]`;
|
|
735
|
+
if (!isPlainObject(entry)) return refuse(`${at} must be an object`);
|
|
736
|
+
if (!isDataProperty(entry, 'kind')) return refuse(`${at}: field "kind" ${ACCESSOR_REFUSAL}`);
|
|
737
|
+
const kind = entry.kind;
|
|
738
|
+
const shape = ENTRY_SHAPES[kind];
|
|
739
|
+
if (shape === undefined) {
|
|
740
|
+
return refuse(`${at}: unknown component kind ${short(kind)} — closed set: ${METRIC_COMPONENT_KINDS.join(' | ')}`);
|
|
741
|
+
}
|
|
742
|
+
const entryDomain = componentDomain(kind);
|
|
743
|
+
if (domain !== null && domain !== entryDomain) {
|
|
744
|
+
return refuse(`${at}: one enumeration never mixes the ${domain} and ${entryDomain} component domains`);
|
|
745
|
+
}
|
|
746
|
+
domain = entryDomain;
|
|
747
|
+
const named = namesAnObject(kind);
|
|
748
|
+
const required = ['kind', shape.size, ...(named ? ['path', 'objectId'] : []), ...(shape.extra ?? [])];
|
|
749
|
+
const own = Object.keys(entry);
|
|
750
|
+
const missing = required.find((k) => !own.includes(k));
|
|
751
|
+
if (missing !== undefined) return refuse(`${at}: missing field "${missing}"`);
|
|
752
|
+
const stray = own.find((k) => !required.includes(k));
|
|
753
|
+
if (stray !== undefined) {
|
|
754
|
+
return refuse(stray === 'ranges'
|
|
755
|
+
? `${at}: a ${kind} entry never enumerates content ranges — it counts the FULL image; ranges belong to the "enumerated" kind`
|
|
756
|
+
: `${at}: unknown field "${stray}" — the entry key set is closed (${required.join(', ')})`);
|
|
757
|
+
}
|
|
758
|
+
const accessor = required.find((k) => !isDataProperty(entry, k));
|
|
759
|
+
if (accessor !== undefined) return refuse(`${at}: field "${accessor}" ${ACCESSOR_REFUSAL}`);
|
|
760
|
+
// Every field below is read EXACTLY ONCE, into a local.
|
|
761
|
+
const size = entry[shape.size];
|
|
762
|
+
const path = named ? entry.path : null;
|
|
763
|
+
const objectId = named ? entry.objectId : null;
|
|
764
|
+
if (!isByteCount(size)) return refuse(`${at}: ${shape.size} must be a non-negative safe-integer byte count (got ${short(size)})`);
|
|
765
|
+
if (named) {
|
|
766
|
+
if (!isNonEmptyString(path)) return refuse(`${at}: path must be a non-empty path (got ${short(path)})`);
|
|
767
|
+
if (!isNonEmptyString(objectId)) {
|
|
768
|
+
return refuse(`${at}: objectId must be the non-empty stable identity of the object counted — dedup keys on it, never on a path (got ${short(objectId)})`);
|
|
769
|
+
}
|
|
770
|
+
if (kind === 'renamed' && !isNonEmptyString(entry.fromPath)) {
|
|
771
|
+
return refuse(`${at}: fromPath must be a non-empty path (got ${short(entry.fromPath)})`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (kind === 'gate-output') {
|
|
775
|
+
const bytePreserved = entry.bytePreserved;
|
|
776
|
+
if (typeof bytePreserved !== 'boolean') return refuse(`${at}: bytePreserved must be a boolean (got ${short(bytePreserved)})`);
|
|
777
|
+
// Gate output counts ONLY when the wrapper preserved its bytes; a non-preserved run records
|
|
778
|
+
// an explicit zero rather than vanishing from the enumeration.
|
|
779
|
+
const bytes = bytePreserved ? size : 0;
|
|
780
|
+
components.push({ kind, path: null, objectId: null, bytes });
|
|
781
|
+
numeratorBytes += bytes;
|
|
782
|
+
if (!Number.isSafeInteger(numeratorBytes)) return refuse(`${at}: ${UNSAFE_TOTAL}`);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const prior = claimed.get(objectId);
|
|
786
|
+
if (prior !== undefined && prior.size !== size) {
|
|
787
|
+
return refuse(`${at}: objectId ${short(objectId)} was already counted at ${prior.size} bytes but this entry claims ${size} — one identity means one size, so a second size is a producer contradiction, not a bigger object`);
|
|
788
|
+
}
|
|
789
|
+
let ranges;
|
|
790
|
+
if (kind === 'enumerated') {
|
|
791
|
+
ranges = normalizeByteRanges(entry.ranges);
|
|
792
|
+
if (ranges === null) return refuse(`${at}: ranges must be [start, end) pairs of non-negative safe integers with end > start`);
|
|
793
|
+
const beyond = ranges.find(([, end]) => end > size);
|
|
794
|
+
if (beyond !== undefined) return refuse(`${at}: range [${beyond[0]}, ${beyond[1]}) extends beyond the object's ${size} bytes`);
|
|
795
|
+
} else {
|
|
796
|
+
ranges = size === 0 ? [] : [[0, size]];
|
|
797
|
+
}
|
|
798
|
+
const before = prior?.ranges ?? [];
|
|
799
|
+
const merged = normalizeByteRanges([...before, ...ranges]);
|
|
800
|
+
const mergedTotal = rangeTotal(merged);
|
|
801
|
+
const beforeTotal = rangeTotal(before);
|
|
802
|
+
if (mergedTotal === null || beforeTotal === null) return refuse(`${at}: ${UNSAFE_TOTAL}`);
|
|
803
|
+
claimed.set(objectId, { size, ranges: merged });
|
|
804
|
+
const bytes = mergedTotal - beforeTotal;
|
|
805
|
+
components.push({ kind, path, objectId, bytes });
|
|
806
|
+
numeratorBytes += bytes;
|
|
807
|
+
if (!Number.isSafeInteger(numeratorBytes)) return refuse(`${at}: ${UNSAFE_TOTAL}`);
|
|
808
|
+
}
|
|
809
|
+
return { ok: true, components, numeratorBytes };
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// ── D6: the canonical integration bundle (the denominator) ────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
// A LENGTH-PREFIXED two-part framing of (diff, report): boundary-unambiguous BY CONSTRUCTION — the
|
|
815
|
+
// reader takes exactly the declared byte count, so no payload can forge a boundary, whatever bytes
|
|
816
|
+
// it carries. The framing is also CANONICAL: the length prefix is a bare decimal without padding,
|
|
817
|
+
// so one payload pair has exactly one byte sequence and therefore exactly one bundleDigest.
|
|
818
|
+
export const BUNDLE_FRAMING_HEADER = 'aw-dispatch-bundle/1\n';
|
|
819
|
+
const BUNDLE_HEADER_BYTES = Buffer.from(BUNDLE_FRAMING_HEADER, 'utf8');
|
|
820
|
+
const CANONICAL_DECIMAL_RE = /^(?:0|[1-9][0-9]*)$/;
|
|
821
|
+
const NEWLINE = 0x0a;
|
|
822
|
+
|
|
823
|
+
const asBytes = (v) => (Buffer.isBuffer(v) ? v : typeof v === 'string' ? Buffer.from(v, 'utf8') : null);
|
|
824
|
+
|
|
825
|
+
// Null when either length is not a byte count or the framed total leaves the safe-integer range.
|
|
826
|
+
export const expectedBundleLength = (diffLength, reportLength) => {
|
|
827
|
+
if (!isByteCount(diffLength) || !isByteCount(reportLength)) return null;
|
|
828
|
+
const total = BUNDLE_HEADER_BYTES.length
|
|
829
|
+
+ String(diffLength).length + 1 + diffLength
|
|
830
|
+
+ String(reportLength).length + 1 + reportLength;
|
|
831
|
+
return Number.isSafeInteger(total) ? total : null;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
export const frameIntegrationBundle = (diff, report) => {
|
|
835
|
+
const diffBytes = asBytes(diff);
|
|
836
|
+
const reportBytes = asBytes(report);
|
|
837
|
+
if (diffBytes === null || reportBytes === null) {
|
|
838
|
+
throw new TypeError('frameIntegrationBundle: both parts must be a Buffer or a string');
|
|
839
|
+
}
|
|
840
|
+
return Buffer.concat([
|
|
841
|
+
BUNDLE_HEADER_BYTES,
|
|
842
|
+
Buffer.from(`${diffBytes.length}\n`, 'utf8'),
|
|
843
|
+
diffBytes,
|
|
844
|
+
Buffer.from(`${reportBytes.length}\n`, 'utf8'),
|
|
845
|
+
reportBytes,
|
|
846
|
+
]);
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
export const parseIntegrationBundle = (bundle) => {
|
|
850
|
+
const bytes = asBytes(bundle);
|
|
851
|
+
if (bytes === null) return refuse('integration bundle: the input must be a Buffer or a string');
|
|
852
|
+
if (bytes.length < BUNDLE_HEADER_BYTES.length || !bytes.subarray(0, BUNDLE_HEADER_BYTES.length).equals(BUNDLE_HEADER_BYTES)) {
|
|
853
|
+
return refuse(`integration bundle: the framing header ${short(BUNDLE_FRAMING_HEADER)} is absent (fail closed)`);
|
|
854
|
+
}
|
|
855
|
+
let at = BUNDLE_HEADER_BYTES.length;
|
|
856
|
+
const parts = [];
|
|
857
|
+
for (const part of ['diff', 'report']) {
|
|
858
|
+
const newlineAt = bytes.indexOf(NEWLINE, at);
|
|
859
|
+
if (newlineAt === -1) return refuse(`integration bundle: the ${part} length prefix is unterminated`);
|
|
860
|
+
const token = bytes.subarray(at, newlineAt).toString('utf8');
|
|
861
|
+
if (!CANONICAL_DECIMAL_RE.test(token)) {
|
|
862
|
+
return refuse(`integration bundle: the ${part} length prefix ${short(token)} is not a CANONICAL decimal byte count — padding would give one payload pair two framings, and two digests`);
|
|
863
|
+
}
|
|
864
|
+
const length = Number(token);
|
|
865
|
+
if (!Number.isSafeInteger(length)) return refuse(`integration bundle: the ${part} length ${token} leaves the safe-integer range (fail closed)`);
|
|
866
|
+
at = newlineAt + 1;
|
|
867
|
+
if (at + length > bytes.length) return refuse(`integration bundle: the ${part} part is truncated (${bytes.length - at} of ${length} bytes)`);
|
|
868
|
+
parts.push(bytes.subarray(at, at + length));
|
|
869
|
+
at += length;
|
|
870
|
+
}
|
|
871
|
+
if (at !== bytes.length) return refuse(`integration bundle: ${bytes.length - at} trailing byte(s) after the report part`);
|
|
872
|
+
return { ok: true, diff: parts[0], report: parts[1] };
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
// ── D5/D6: metric eligibility ─────────────────────────────────────────────────────────────────────
|
|
876
|
+
|
|
877
|
+
const ELIGIBILITY_INPUT_KEYS = ['baselineClean', 'numeratorBytes', 'diffLength', 'reportLength', 'bundleLength'];
|
|
878
|
+
const OBSERVATION_ELIGIBILITY_INPUT_KEYS = ['numeratorBytes', 'denominatorBytes'];
|
|
879
|
+
|
|
880
|
+
const checkEligibilityInput = (at, input, keys) => {
|
|
881
|
+
if (!isPlainObject(input)) return refuse(`${at}: the input must be an object`);
|
|
882
|
+
const own = Object.keys(input);
|
|
883
|
+
const stray = own.find((k) => !keys.includes(k));
|
|
884
|
+
if (stray !== undefined) return refuse(`${at}: unknown input "${stray}" — the input key set is closed`);
|
|
885
|
+
const missing = keys.find((k) => !own.includes(k));
|
|
886
|
+
if (missing !== undefined) return refuse(`${at}: missing input "${missing}"`);
|
|
887
|
+
return { ok: true };
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
// The baseline implication is DIRECTIONAL: baselineClean:false FORCES ineligibility (the
|
|
891
|
+
// uncommitted-state fingerprint is blind to the index↔worktree split, so a dirty baseline cannot
|
|
892
|
+
// attribute bytes to the dispatch); baselineClean:true implies NOTHING — a no-op diff, an empty
|
|
893
|
+
// report or a zero-length bundle still make the metric ineligible, each by its own NAME.
|
|
894
|
+
//
|
|
895
|
+
// This is also the PRODUCER's predicate, evaluated BEFORE framing — which is the only place
|
|
896
|
+
// `zero-length-bundle` can arise, since a framed bundle always carries a header and two prefixes.
|
|
897
|
+
// A framed return therefore never substantiates that reason, and the return validator refuses one
|
|
898
|
+
// that claims it.
|
|
899
|
+
export const evaluateMetricEligibility = (input) => {
|
|
900
|
+
const closed = checkEligibilityInput('metric eligibility', input, ELIGIBILITY_INPUT_KEYS);
|
|
901
|
+
if (!closed.ok) return closed;
|
|
902
|
+
if (typeof input.baselineClean !== 'boolean') return refuse(`metric eligibility: baselineClean must be a boolean (got ${short(input.baselineClean)})`);
|
|
903
|
+
const badCount = ELIGIBILITY_INPUT_KEYS.filter((k) => k !== 'baselineClean').find((k) => !isByteCount(input[k]));
|
|
904
|
+
if (badCount !== undefined) return refuse(`metric eligibility: ${badCount} must be a non-negative safe-integer byte count (got ${short(input[badCount])})`);
|
|
905
|
+
const ineligibleReason = input.baselineClean === false ? 'dirty-baseline'
|
|
906
|
+
: input.diffLength === 0 ? 'no-op-diff'
|
|
907
|
+
: input.reportLength === 0 ? 'empty-report'
|
|
908
|
+
: input.bundleLength === 0 ? 'zero-length-bundle'
|
|
909
|
+
: input.numeratorBytes === 0 ? 'zero-byte-proxy'
|
|
910
|
+
: null;
|
|
911
|
+
return { ok: true, eligible: ineligibleReason === null, ineligibleReason };
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// An observation has no diff, report or bundle — its own two numbers are the whole domain. When
|
|
915
|
+
// BOTH are zero the denominator is named FIRST, deterministically: a ratio with no denominator is
|
|
916
|
+
// undefined before it is small, and one record must never carry two possible names.
|
|
917
|
+
export const evaluateObservationEligibility = (input) => {
|
|
918
|
+
const closed = checkEligibilityInput('observation eligibility', input, OBSERVATION_ELIGIBILITY_INPUT_KEYS);
|
|
919
|
+
if (!closed.ok) return closed;
|
|
920
|
+
const badCount = OBSERVATION_ELIGIBILITY_INPUT_KEYS.find((k) => !isByteCount(input[k]));
|
|
921
|
+
if (badCount !== undefined) return refuse(`observation eligibility: ${badCount} must be a non-negative safe-integer byte count (got ${short(input[badCount])})`);
|
|
922
|
+
const ineligibleReason = input.denominatorBytes === 0 ? 'zero-denominator'
|
|
923
|
+
: input.numeratorBytes === 0 ? 'zero-byte-proxy'
|
|
924
|
+
: null;
|
|
925
|
+
return { ok: true, eligible: ineligibleReason === null, ineligibleReason };
|
|
926
|
+
};
|