@sabaiway/agent-workflow-kit 5.11.1 → 6.0.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 +96 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/grounding.md +1 -1
- package/references/modes/procedures.md +3 -3
- package/references/templates/agent_rules.md +4 -5
- package/tools/dispatch-record.mjs +1 -1
- package/tools/flow-finding-manifest.mjs +70 -0
- package/tools/flow-legality.mjs +248 -0
- package/tools/flow-record-identity.mjs +115 -0
- package/tools/flow-record-shape.mjs +283 -0
- package/tools/flow-record.mjs +49 -789
- package/tools/flow-vocabulary.mjs +96 -0
- package/tools/grounding.mjs +10 -20
- package/tools/inject-methodology.mjs +2 -0
- package/tools/procedures.mjs +7 -8
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// flow-record-identity.mjs — every answer to "what identifies this record, or this set of records":
|
|
2
|
+
// the per-kind supersession keys and the authoritative latest-per-key selection, the compound tree
|
|
3
|
+
// identity (#21), the canonical single-record serialization and its digest (#63 — the whole
|
|
4
|
+
// inter-record reference domain), the two Decision-7 derivation digests, and the owner-scoped
|
|
5
|
+
// projection with its order-sensitive hash. Split out of flow-record.mjs unchanged
|
|
6
|
+
// (baseline-practices tranche 3), which now re-exports every name here.
|
|
7
|
+
//
|
|
8
|
+
// Pure form: no filesystem, no git, no CLI, no side effects on import — node:crypto for the digests
|
|
9
|
+
// and the vocabulary leaf for the one kind constant are all it reaches. Imports run ONE way: the
|
|
10
|
+
// legality leaf composes this module; nothing here reaches back up to the facade.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { CHAIN_KIND } from './flow-vocabulary.mjs';
|
|
14
|
+
|
|
15
|
+
// ── per-kind keys + the authoritative latest-per-key selection ────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
// JSON-array keys (collision-proof across free-form fields — space-joining would let a planId forge
|
|
18
|
+
// a separator). The down-mark family shares ONE key per backend so up/clear supersede the mark;
|
|
19
|
+
// maintainer-override keys on its veto instance; internal-attestation keys on
|
|
20
|
+
// {plan, cycle, step, round, tree}.
|
|
21
|
+
export const flowRecordKey = (record) =>
|
|
22
|
+
record.kind === CHAIN_KIND ? JSON.stringify([CHAIN_KIND, record.planId, record.cycle, record.stepId, record.round, record.purpose])
|
|
23
|
+
: record.kind === 'internal-attestation' ? JSON.stringify([record.kind, record.planId, record.cycle, record.stepId, record.round, record.base, record.fingerprint])
|
|
24
|
+
: record.kind === 'down-mark' || record.kind === 'down-mark-up' || record.kind === 'down-mark-clear' ? JSON.stringify(['down-mark', record.backend])
|
|
25
|
+
: record.kind === 'degrade-justification' ? JSON.stringify([record.kind, record.downMark])
|
|
26
|
+
: record.kind === 'rerun-cause' ? JSON.stringify([record.kind, record.attempt])
|
|
27
|
+
: record.kind === 'bookkeeping-delta' ? JSON.stringify([record.kind, record.fingerprintBefore, record.fingerprintAfter, record.path])
|
|
28
|
+
: record.kind === 'maintainer-override' ? JSON.stringify([record.kind, record.vetoReceiptDigest])
|
|
29
|
+
: record.kind === 'consult-attestation' ? JSON.stringify([record.kind, record.backend, record.nonce])
|
|
30
|
+
: record.kind === 'subset-attempt' ? JSON.stringify([record.kind, record.planId, record.cycle, record.stepId, record.foldBatch, record.subsetDigest])
|
|
31
|
+
: null;
|
|
32
|
+
|
|
33
|
+
// The authoritative subset: the LATEST record per key, in file order of that latest appearance.
|
|
34
|
+
// Raw file order is a separate, surviving view — the transition/ordering checks consume ONLY raw.
|
|
35
|
+
export const authoritativeFlowRecords = (records) => {
|
|
36
|
+
const lastByKey = new Map();
|
|
37
|
+
records.forEach((r, i) => {
|
|
38
|
+
const k = flowRecordKey(r);
|
|
39
|
+
if (k != null) lastByKey.set(k, i);
|
|
40
|
+
});
|
|
41
|
+
const keep = new Set(lastByKey.values());
|
|
42
|
+
return records.filter((_, i) => keep.has(i));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ── tree identity (#21) ───────────────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export const isTransitionShaped = (record) =>
|
|
48
|
+
record.kind === 'bookkeeping-delta' || (record.kind === CHAIN_KIND && record.purpose === 'refresh');
|
|
49
|
+
|
|
50
|
+
// The compound tree identity every flow record carries; for transition-shaped records the singular
|
|
51
|
+
// fingerprint IS fingerprintAfter.
|
|
52
|
+
export const flowTreeIdentity = (record) => ({
|
|
53
|
+
base: record.base,
|
|
54
|
+
fingerprint: isTransitionShaped(record) ? record.fingerprintAfter : record.fingerprint,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── per-record canonical digest (#63) — the record-reference id domain ────────────────────────────
|
|
58
|
+
|
|
59
|
+
const serializeCanonical = (v) => {
|
|
60
|
+
if (Array.isArray(v)) return `[${v.map(serializeCanonical).join(',')}]`;
|
|
61
|
+
if (v !== null && typeof v === 'object') {
|
|
62
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${serializeCanonical(v[k])}`).join(',')}}`;
|
|
63
|
+
}
|
|
64
|
+
return JSON.stringify(v);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Canonical bytes of ONE record: recursively key-sorted JSON, NO trailing newline (the newline is
|
|
68
|
+
// store framing, not record identity). A parity test pins these bytes against core-evidence's
|
|
69
|
+
// canonicalKindSerialization on single-record fixtures.
|
|
70
|
+
export const flowCanonicalSerialization = (record) => serializeCanonical(record);
|
|
71
|
+
|
|
72
|
+
export const canonicalFlowDigest = (record) => createHash('sha256').update(flowCanonicalSerialization(record), 'utf8').digest('hex');
|
|
73
|
+
|
|
74
|
+
// ── Decision-7 derivation helpers (Plan 4) — pure digests over canonical bytes ────────────────────
|
|
75
|
+
|
|
76
|
+
// foldBatch keys the IMMUTABLE round identity projection: a round-ledger REVISION keeps
|
|
77
|
+
// {planId, cycle, stepId, round} (same digest — the budget never resets on supersession, #47),
|
|
78
|
+
// a NEW round moves it (fresh budget).
|
|
79
|
+
export const subsetFoldBatchDigest = ({ planId, cycle, stepId, round }) =>
|
|
80
|
+
createHash('sha256').update(flowCanonicalSerialization({ planId, cycle, stepId, round }), 'utf8').digest('hex');
|
|
81
|
+
|
|
82
|
+
// The derived subset's counting identity — declaring pregateExclude changes the ordered gate-id
|
|
83
|
+
// list, therefore the key, therefore the counting context (#47/#66).
|
|
84
|
+
export const subsetGateIdsDigest = (gateIds) =>
|
|
85
|
+
createHash('sha256').update(flowCanonicalSerialization(gateIds), 'utf8').digest('hex');
|
|
86
|
+
|
|
87
|
+
// ── the owner-scoped projection (Plan 4 Decision 2 / D10) — ONE pure helper, producer + consumer ──
|
|
88
|
+
|
|
89
|
+
// The hash domain is the OWNER-SCOPED projection, never the whole common store (#57): (a) every
|
|
90
|
+
// chain record whose owner is the committing worktree; (b) every planId-bearing global whose
|
|
91
|
+
// planId belongs to an owned chain; (c) every planId-less global (the down-mark family,
|
|
92
|
+
// degrade-justification, rerun-cause, bookkeeping-delta, maintainer-override — the rule is
|
|
93
|
+
// structural over every planId-less kind) whose tree identity (fingerprintAfter for transitions)
|
|
94
|
+
// is in {fingerprints appearing in owned-chain records} ∪ {the current tree fingerprint}. A
|
|
95
|
+
// foreign worktree's records fall outside (a)-(c) and never move the hash; a same-fingerprint
|
|
96
|
+
// foreign global is IN by (c) — same tree, same decision context. Raw store order is preserved:
|
|
97
|
+
// the projection hash is order-sensitive, so any in-projection append moves it.
|
|
98
|
+
export const ownerScopedFlowProjection = (records, { owner, currentFingerprint }) => {
|
|
99
|
+
const ownedChain = records.filter((r) => r.kind === CHAIN_KIND && r.owner === owner);
|
|
100
|
+
const ownedPlanIds = new Set(ownedChain.map((r) => r.planId));
|
|
101
|
+
const fingerprints = new Set(currentFingerprint == null ? [] : [currentFingerprint]);
|
|
102
|
+
for (const r of ownedChain) {
|
|
103
|
+
for (const field of ['fingerprint', 'fingerprintBefore', 'fingerprintAfter']) {
|
|
104
|
+
if (typeof r[field] === 'string') fingerprints.add(r[field]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return records.filter((r) => {
|
|
108
|
+
if (r.kind === CHAIN_KIND) return r.owner === owner;
|
|
109
|
+
if (typeof r.planId === 'string') return ownedPlanIds.has(r.planId);
|
|
110
|
+
return fingerprints.has(flowTreeIdentity(r).fingerprint);
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const flowProjectionHash = (records, ctx) =>
|
|
115
|
+
createHash('sha256').update(ownerScopedFlowProjection(records, ctx).map(flowCanonicalSerialization).join('\n'), 'utf8').digest('hex');
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// flow-record-shape.mjs — the CLOSED per-kind field shapes and the per-record validator: the field
|
|
2
|
+
// primitives beyond the five shared ones, FIELD_CHECKS, the per-purpose and per-kind shape tables,
|
|
3
|
+
// the Decision-8 earliest diagnosis index, checkFields, the two round-ledger arms (dispatches and
|
|
4
|
+
// dispositions), the bookkeeping-delta custody proof, and validateFlowRecord. Split out of
|
|
5
|
+
// flow-record.mjs unchanged (baseline-practices tranche 3), which now re-exports both public names
|
|
6
|
+
// here (SUBSET_ATTEMPT_DIAGNOSIS_FROM, validateFlowRecord).
|
|
7
|
+
//
|
|
8
|
+
// Pure form: no filesystem, no git, no CLI, no side effects on import. Imports run ONE way — the
|
|
9
|
+
// vocabulary leaf owns the closed kind/purpose sets and the shared form bindings this module states
|
|
10
|
+
// its refusals in, repo-lex.mjs owns the lexical path rule, and the legality leaf composes this
|
|
11
|
+
// module; nothing here reaches back up to the facade.
|
|
12
|
+
|
|
13
|
+
import { lexicalRepoRelative } from './repo-lex.mjs';
|
|
14
|
+
import {
|
|
15
|
+
CHAIN_KIND, CHAIN_PURPOSES, FLOW_KINDS, FLOW_SCHEMA_VERSION,
|
|
16
|
+
HEX64_RE, isHex64, isNonEmptyString, isPlainObject, refuse,
|
|
17
|
+
} from './flow-vocabulary.mjs';
|
|
18
|
+
|
|
19
|
+
// ── field shapes (closed key set per kind/purpose) ────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const HEX40_RE = /^[0-9a-f]{40}$/;
|
|
22
|
+
const isSha = (v) => typeof v === 'string' && (HEX40_RE.test(v) || HEX64_RE.test(v));
|
|
23
|
+
const isCanonicalInstant = (v) => typeof v === 'string' && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v;
|
|
24
|
+
const isUniqueNonEmptyStrings = (v) => Array.isArray(v) && v.every(isNonEmptyString) && new Set(v).size === v.length;
|
|
25
|
+
|
|
26
|
+
const POSTURE_KEYS = ['model', 'effort', 'tier'];
|
|
27
|
+
const isClosedPosture = (v) => isPlainObject(v)
|
|
28
|
+
&& Object.keys(v).length === POSTURE_KEYS.length && POSTURE_KEYS.every((k) => k in v)
|
|
29
|
+
&& isNonEmptyString(v.model)
|
|
30
|
+
&& (v.effort === null || isNonEmptyString(v.effort))
|
|
31
|
+
&& (v.tier === null || isNonEmptyString(v.tier));
|
|
32
|
+
|
|
33
|
+
const FIELD_CHECKS = {
|
|
34
|
+
base: { ok: (v) => v === null || isSha(v), want: 'the 40- or 64-hex base sha, or null on an unborn branch' },
|
|
35
|
+
baseBefore: { ok: isSha, want: 'the 40- or 64-hex pre-motion base sha' },
|
|
36
|
+
timestamp: { ok: isNonEmptyString, want: 'a non-empty timestamp string' },
|
|
37
|
+
fingerprint: { ok: isHex64, want: 'a 64-hex tree fingerprint' },
|
|
38
|
+
fingerprintBefore: { ok: isHex64, want: 'a 64-hex tree fingerprint' },
|
|
39
|
+
fingerprintAfter: { ok: isHex64, want: 'a 64-hex tree fingerprint' },
|
|
40
|
+
planId: { ok: isNonEmptyString, want: 'a non-empty plan id' },
|
|
41
|
+
cycle: { ok: (v) => Number.isInteger(v) && v >= 1, want: 'a positive integer cycle index' },
|
|
42
|
+
round: { ok: (v) => Number.isInteger(v) && v >= 0, want: 'a non-negative integer round index' },
|
|
43
|
+
commitEpoch: { ok: (v) => Number.isInteger(v) && v >= 0, want: 'a non-negative integer commit epoch' },
|
|
44
|
+
owner: { ok: isNonEmptyString, want: 'the non-empty owning-worktree identity' },
|
|
45
|
+
stepId: { ok: isNonEmptyString, want: 'a non-empty step id' },
|
|
46
|
+
stepIdNull: { ok: (v) => v === null, want: 'null — this purpose is plan-lane, never step-scoped' },
|
|
47
|
+
stepIdOrNull: { ok: (v) => v === null || isNonEmptyString(v), want: 'a non-empty step id, or null at a pre-first-step boundary' },
|
|
48
|
+
opensFrom: { ok: (v) => v === null || isHex64(v), want: 'the 64-hex prior-terminal record digest, or null off the step opening' },
|
|
49
|
+
dispatches: { ok: Array.isArray, want: 'the dispatch-ledger array (may be empty until dispatches land)' },
|
|
50
|
+
dispositions: { ok: Array.isArray, want: 'the disposition-ledger array (may be empty until findings land)' },
|
|
51
|
+
planLabel: { ok: isNonEmptyString, want: 'the non-empty plan label' },
|
|
52
|
+
createdAt: { ok: isNonEmptyString, want: 'a non-empty created-at string' },
|
|
53
|
+
planDigest: { ok: isHex64, want: 'the 64-hex plan content digest' },
|
|
54
|
+
cause: { ok: isNonEmptyString, want: 'a non-empty declared cause' },
|
|
55
|
+
refreshedRecord: { ok: isHex64, want: 'the 64-hex digest of the record this refresh re-attests' },
|
|
56
|
+
backend: { ok: isNonEmptyString, want: 'a non-empty backend name' },
|
|
57
|
+
reason: { ok: isNonEmptyString, want: 'a non-empty reason' },
|
|
58
|
+
expiresAt: { ok: isCanonicalInstant, want: 'a canonical UTC ISO instant (toISOString round-trip)' },
|
|
59
|
+
target: { ok: isHex64, want: 'the 64-hex digest of the down-mark this record supersedes' },
|
|
60
|
+
downMark: { ok: isHex64, want: 'the 64-hex digest of the down-mark this justification rides on' },
|
|
61
|
+
degradeDigest: { ok: isHex64, want: 'the 64-hex per-record canonical digest of the core degrade record' },
|
|
62
|
+
attempt: { ok: isNonEmptyString, want: 'the non-empty red final attempt id' },
|
|
63
|
+
path: { ok: (v) => isNonEmptyString(v) && lexicalRepoRelative(v).ok, want: 'a non-empty lexically repo-relative path' },
|
|
64
|
+
contentDigest: { ok: (v) => v === null || isHex64(v), want: 'the 64-hex post-change content digest, or null when the path lands absent' },
|
|
65
|
+
custodyProof: { ok: isPlainObject, want: 'the persisted proof object {preClass, tracked, headDigest, indexDigest, worktreeDigest, maskedFingerprint}' },
|
|
66
|
+
vetoReceiptDigest: { ok: isHex64, want: 'the 64-hex digest of the vetoing receipt' },
|
|
67
|
+
verdict: { ok: isNonEmptyString, want: 'the non-empty vetoing verdict' },
|
|
68
|
+
chainRecord: { ok: isHex64, want: 'the 64-hex digest of the bound chain record' },
|
|
69
|
+
supersedes: { ok: (v) => v === null || isHex64(v), want: 'the 64-hex digest of the superseded override, or null on the first override of a veto instance' },
|
|
70
|
+
nonce: { ok: isNonEmptyString, want: 'the non-empty wrapper nonce' },
|
|
71
|
+
lenses: { ok: (v) => isUniqueNonEmptyStrings(v) && v.length > 0, want: 'a non-empty array of unique non-empty lens names (the required-lens set)' },
|
|
72
|
+
degraded: { ok: isUniqueNonEmptyStrings, want: 'an array of unique non-empty backend names (may be empty)' },
|
|
73
|
+
posture: { ok: isClosedPosture, want: 'the closed posture object {model: non-empty, effort: non-empty|null, tier: non-empty|null}' },
|
|
74
|
+
authority: { ok: isNonEmptyString, want: 'the non-empty attesting authority' },
|
|
75
|
+
findingDigest: { ok: isHex64, want: 'the 64-hex digest of the consulted finding' },
|
|
76
|
+
proposedFixDigest: { ok: isHex64, want: 'the 64-hex digest of the proposed fix under consult' },
|
|
77
|
+
foldBatch: { ok: isHex64, want: 'the 64-hex digest of the owning round identity projection {planId, cycle, stepId, round}' },
|
|
78
|
+
subsetDigest: { ok: isHex64, want: "the 64-hex digest of the derived subset's ordered gate ids" },
|
|
79
|
+
attemptIndex: { ok: (v) => Number.isInteger(v) && v >= 1, want: 'a positive integer attempt index (monotonic per counting context)' },
|
|
80
|
+
status: { ok: (v) => v === 'green' || v === 'red', want: 'the closed enum green | red' },
|
|
81
|
+
diagnosis: { ok: isNonEmptyString, want: 'a non-empty diagnosis statement (Decision 8 — the recorded continuation past two reds)' },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const CHAIN_COMMON_FIELDS = ['planId', 'cycle', 'round', 'commitEpoch', 'owner', 'base', 'timestamp'];
|
|
85
|
+
|
|
86
|
+
// Per-purpose closed field sets. "stepIdNull" routes stepId through the plan-lane check; the
|
|
87
|
+
// transition-shaped refresh carries fingerprintBefore/After and deliberately NO singular
|
|
88
|
+
// fingerprint field (flowTreeIdentity supplies it). re-baseline's stepId is the prior-terminal
|
|
89
|
+
// anchor at a boundary (null only before the first step) or the open step inside one.
|
|
90
|
+
const PURPOSE_SHAPES = {
|
|
91
|
+
adoption: { stepId: 'stepIdNull', fields: ['fingerprint', 'planLabel', 'createdAt', 'planDigest'] },
|
|
92
|
+
round: { stepId: 'stepId', fields: ['fingerprint', 'opensFrom', 'dispatches', 'dispositions'] },
|
|
93
|
+
refresh: { stepId: 'stepId', fields: ['fingerprintBefore', 'fingerprintAfter', 'cause', 'refreshedRecord'] },
|
|
94
|
+
're-baseline': { stepId: 'stepIdOrNull', fields: ['fingerprint', 'baseBefore'] },
|
|
95
|
+
freeze: { stepId: 'stepId', fields: ['fingerprint'] },
|
|
96
|
+
unfreeze: { stepId: 'stepId', fields: ['fingerprint'] },
|
|
97
|
+
park: { stepId: 'stepIdNull', fields: ['fingerprint'] },
|
|
98
|
+
resume: { stepId: 'stepIdNull', fields: ['fingerprint'] },
|
|
99
|
+
converged: { stepId: 'stepId', fields: ['fingerprint'] },
|
|
100
|
+
complete: { stepId: 'stepIdNull', fields: ['fingerprint'] },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const GLOBAL_SHAPES = {
|
|
104
|
+
'internal-attestation': ['fingerprint', 'planId', 'stepId', 'cycle', 'round', 'lenses', 'degraded', 'posture', 'authority', 'base', 'timestamp'],
|
|
105
|
+
'down-mark': ['fingerprint', 'backend', 'reason', 'expiresAt', 'base', 'timestamp'],
|
|
106
|
+
'down-mark-up': ['fingerprint', 'backend', 'target', 'base', 'timestamp'],
|
|
107
|
+
'down-mark-clear': ['fingerprint', 'backend', 'target', 'base', 'timestamp'],
|
|
108
|
+
'degrade-justification': ['fingerprint', 'downMark', 'degradeDigest', 'base', 'timestamp'],
|
|
109
|
+
'rerun-cause': ['fingerprint', 'cause', 'attempt', 'base', 'timestamp'],
|
|
110
|
+
'bookkeeping-delta': ['fingerprintBefore', 'fingerprintAfter', 'path', 'contentDigest', 'custodyProof', 'base', 'timestamp'],
|
|
111
|
+
'maintainer-override': ['fingerprint', 'vetoReceiptDigest', 'backend', 'verdict', 'chainRecord', 'supersedes', 'base', 'timestamp'],
|
|
112
|
+
'consult-attestation': ['fingerprint', 'backend', 'nonce', 'planId', 'cycle', 'stepId', 'round', 'findingDigest', 'proposedFixDigest', 'base', 'timestamp'],
|
|
113
|
+
'subset-attempt': ['planId', 'cycle', 'stepId', 'foldBatch', 'subsetDigest', 'attemptIndex', 'status', 'base', 'fingerprint', 'timestamp'],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Kind-scoped check overrides where a field name collides across kinds (Decision 7): the
|
|
117
|
+
// subset-attempt stepId is nullable — attempts before any round key the ADOPTION context.
|
|
118
|
+
const GLOBAL_FIELD_CHECK_OVERRIDES = {
|
|
119
|
+
'subset-attempt': { stepId: 'stepIdOrNull' },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Decision 8: attempts 1-2 are the blind budget and never carry a diagnosis — this is the
|
|
123
|
+
// earliest index one MAY ride. REQUIRED-ness keys on the key's red count (>= 2, past the second
|
|
124
|
+
// red) and lives in the store gate + the locked factory: a record-local validator cannot see
|
|
125
|
+
// the key history, and a green history never owes a diagnosis.
|
|
126
|
+
export const SUBSET_ATTEMPT_DIAGNOSIS_FROM = 3;
|
|
127
|
+
|
|
128
|
+
const checkFields = (label, record, fieldToCheck) => {
|
|
129
|
+
const allowed = ['schema', 'kind', ...(record.kind === CHAIN_KIND ? ['purpose'] : []), ...Object.keys(fieldToCheck)];
|
|
130
|
+
const stray = Object.keys(record).find((k) => !allowed.includes(k));
|
|
131
|
+
if (stray !== undefined) return refuse(`${label}: unknown field "${stray}" — the key set is closed (the digest is identity; a stray key would fork it)`);
|
|
132
|
+
for (const [field, checkId] of Object.entries(fieldToCheck)) {
|
|
133
|
+
if (!(field in record)) return refuse(`${label}: missing field "${field}" — every required field is pinned`);
|
|
134
|
+
const check = FIELD_CHECKS[checkId];
|
|
135
|
+
if (!check.ok(record[field])) return refuse(`${label}: ${field} must be ${check.want}`);
|
|
136
|
+
}
|
|
137
|
+
return { ok: true };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// The per-dispatch ledger entry (#41/#42): watermark + nonce minted BEFORE dispatch; the receipt
|
|
141
|
+
// digest and the finding-manifest digest land TOGETHER once the receipt arrives.
|
|
142
|
+
const DISPATCH_KEYS = ['backend', 'dispatchBase', 'receiptWatermark', 'dispatchNonce', 'receiptDigest', 'findingManifestDigest'];
|
|
143
|
+
|
|
144
|
+
const validateDispatches = (label, list) => {
|
|
145
|
+
const seenIdentities = new Set();
|
|
146
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
147
|
+
const d = list[i];
|
|
148
|
+
const at = `${label}: dispatches[${i}]`;
|
|
149
|
+
if (!isPlainObject(d)) return refuse(`${at} must be an object`);
|
|
150
|
+
const stray = Object.keys(d).find((k) => !DISPATCH_KEYS.includes(k));
|
|
151
|
+
if (stray !== undefined) return refuse(`${at}: unknown field "${stray}" — the dispatch key set is closed`);
|
|
152
|
+
const missing = DISPATCH_KEYS.find((k) => !(k in d));
|
|
153
|
+
if (missing !== undefined) return refuse(`${at}: missing field "${missing}"`);
|
|
154
|
+
const identity = JSON.stringify([d.backend, d.dispatchNonce]);
|
|
155
|
+
if (seenIdentities.has(identity)) return refuse(`${at}: duplicate dispatch identity {backend, dispatchNonce} — one ledger entry per dispatch (the watermark is payload, never identity)`);
|
|
156
|
+
seenIdentities.add(identity);
|
|
157
|
+
if (!isNonEmptyString(d.backend)) return refuse(`${at}: backend must be a non-empty backend name`);
|
|
158
|
+
if (d.dispatchBase !== null && !isSha(d.dispatchBase)) return refuse(`${at}: dispatchBase must be the 40- or 64-hex base at dispatch, or null on an unborn branch`);
|
|
159
|
+
if (!Number.isInteger(d.receiptWatermark) || d.receiptWatermark < 0) return refuse(`${at}: receiptWatermark must be a non-negative integer (the receipts-file position minted before dispatch)`);
|
|
160
|
+
if (!isNonEmptyString(d.dispatchNonce)) return refuse(`${at}: dispatchNonce must be a non-empty string`);
|
|
161
|
+
for (const field of ['receiptDigest', 'findingManifestDigest']) {
|
|
162
|
+
if (d[field] !== null && !isHex64(d[field])) return refuse(`${at}: ${field} must be a 64-hex digest, or null while the dispatch is pending`);
|
|
163
|
+
}
|
|
164
|
+
if ((d.receiptDigest === null) !== (d.findingManifestDigest === null)) {
|
|
165
|
+
return refuse(`${at}: receiptDigest and findingManifestDigest land together — both null while pending, both 64-hex once the receipt landed`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { ok: true };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// The per-finding disposition ledger (#13/#33): every council finding lands as exactly one of the
|
|
172
|
+
// three closed arms, each carrying its proof (a consult-attestation/red-proof digest, a debt entry,
|
|
173
|
+
// or a stated rejection reason).
|
|
174
|
+
const DISPOSITION_KEYS = {
|
|
175
|
+
folded: ['findingDigest', 'action', 'proofKind', 'proofDigest'],
|
|
176
|
+
queued: ['findingDigest', 'action', 'debtId', 'debtDigest'],
|
|
177
|
+
rejected: ['findingDigest', 'action', 'reason'],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const validateDispositions = (label, list) => {
|
|
181
|
+
const seenFindings = new Set();
|
|
182
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
183
|
+
const d = list[i];
|
|
184
|
+
const at = `${label}: dispositions[${i}]`;
|
|
185
|
+
if (!isPlainObject(d)) return refuse(`${at} must be an object`);
|
|
186
|
+
const armKeys = Object.hasOwn(DISPOSITION_KEYS, d.action) ? DISPOSITION_KEYS[d.action] : undefined;
|
|
187
|
+
if (armKeys === undefined) return refuse(`${at}: action must be one of ${Object.keys(DISPOSITION_KEYS).join(' | ')} (got ${JSON.stringify(d.action)}) — an inherited prototype key never resolves an arm (fail closed)`);
|
|
188
|
+
const stray = Object.keys(d).find((k) => !armKeys.includes(k));
|
|
189
|
+
if (stray !== undefined) return refuse(`${at}: unknown field "${stray}" — the ${d.action} arm's key set is closed`);
|
|
190
|
+
const missing = armKeys.find((k) => !(k in d));
|
|
191
|
+
if (missing !== undefined) return refuse(`${at}: missing field "${missing}"`);
|
|
192
|
+
if (!isHex64(d.findingDigest)) return refuse(`${at}: findingDigest must be the 64-hex digest of the finding`);
|
|
193
|
+
if (seenFindings.has(d.findingDigest)) return refuse(`${at}: duplicate findingDigest — every finding gets exactly one disposition, whichever the arm`);
|
|
194
|
+
seenFindings.add(d.findingDigest);
|
|
195
|
+
if (d.action === 'folded') {
|
|
196
|
+
if (d.proofKind !== 'consult-attestation' && d.proofKind !== 'red-proof') return refuse(`${at}: proofKind must be consult-attestation | red-proof (the fold's proof record class)`);
|
|
197
|
+
if (!isHex64(d.proofDigest)) return refuse(`${at}: proofDigest must be the 64-hex digest of the proof record`);
|
|
198
|
+
} else if (d.action === 'queued') {
|
|
199
|
+
if (!isNonEmptyString(d.debtId)) return refuse(`${at}: debtId must be the non-empty stable debt-queue id`);
|
|
200
|
+
if (!isHex64(d.debtDigest)) return refuse(`${at}: debtDigest must be the 64-hex digest of the debt entry`);
|
|
201
|
+
} else if (!isNonEmptyString(d.reason)) {
|
|
202
|
+
return refuse(`${at}: reason must be a non-empty statement of why the finding is rejected`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return { ok: true };
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// The three-layer pre-state digest set (#60): HEAD entry, index entry, worktree bytes — null means
|
|
209
|
+
// "no entry in that layer". tracked-ness is derived (a HEAD or index entry exists); the presence
|
|
210
|
+
// class is the WORKTREE layer. The fingerprint domain (staged + unstaged + untracked) is exactly
|
|
211
|
+
// what these three layers reconstruct for the masked recompute.
|
|
212
|
+
const CUSTODY_PROOF_KEYS = ['preClass', 'tracked', 'headDigest', 'indexDigest', 'worktreeDigest', 'maskedFingerprint'];
|
|
213
|
+
const PRE_STATE_CLASSES = ['present', 'absent'];
|
|
214
|
+
|
|
215
|
+
const validateCustodyProof = (label, record) => {
|
|
216
|
+
const proof = record.custodyProof;
|
|
217
|
+
const stray = Object.keys(proof).find((k) => !CUSTODY_PROOF_KEYS.includes(k));
|
|
218
|
+
if (stray !== undefined) return refuse(`${label}: custodyProof carries unknown field "${stray}" — the proof key set is closed`);
|
|
219
|
+
const missing = CUSTODY_PROOF_KEYS.find((k) => !(k in proof));
|
|
220
|
+
if (missing !== undefined) return refuse(`${label}: custodyProof is missing field "${missing}"`);
|
|
221
|
+
if (!PRE_STATE_CLASSES.includes(proof.preClass)) return refuse(`${label}: custodyProof.preClass must be one of ${PRE_STATE_CLASSES.join(' | ')} — any other pre-state class refuses to mint by name (fail closed)`);
|
|
222
|
+
if (typeof proof.tracked !== 'boolean') return refuse(`${label}: custodyProof.tracked must be a boolean`);
|
|
223
|
+
for (const field of ['headDigest', 'indexDigest', 'worktreeDigest']) {
|
|
224
|
+
if (proof[field] !== null && !isHex64(proof[field])) return refuse(`${label}: custodyProof.${field} must be a 64-hex content digest, or null when that layer has no entry`);
|
|
225
|
+
}
|
|
226
|
+
if (!isHex64(proof.maskedFingerprint)) return refuse(`${label}: custodyProof.maskedFingerprint must be the 64-hex masked-recompute fingerprint`);
|
|
227
|
+
if ((proof.preClass === 'absent') !== (proof.worktreeDigest === null)) {
|
|
228
|
+
return refuse(`${label}: custodyProof.worktreeDigest must be null exactly when preClass is "absent" — the worktree layer IS the presence class`);
|
|
229
|
+
}
|
|
230
|
+
if (proof.tracked !== (proof.headDigest !== null || proof.indexDigest !== null)) {
|
|
231
|
+
return refuse(`${label}: custodyProof.tracked must equal the presence of a HEAD or index entry — a mismatched tracked-ness is a forged pre-state`);
|
|
232
|
+
}
|
|
233
|
+
if (proof.preClass === 'absent' && record.contentDigest === null) {
|
|
234
|
+
return refuse(`${label}: the absent→absent pre-state transition is unsupported — supported: present→present, present→absent, absent→present (fail closed)`);
|
|
235
|
+
}
|
|
236
|
+
return { ok: true };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// validateFlowRecord(record) → { ok: true } | { ok: false, reason }. Fail closed on unknown
|
|
240
|
+
// schema/kind/purpose, a missing/malformed field, or any key outside the closed per-kind set.
|
|
241
|
+
export const validateFlowRecord = (record) => {
|
|
242
|
+
if (!isPlainObject(record)) return refuse('record is not an object');
|
|
243
|
+
if (record.schema !== FLOW_SCHEMA_VERSION) {
|
|
244
|
+
return refuse(`unknown schema ${JSON.stringify(record.schema)} — this reader accepts flow schema ${FLOW_SCHEMA_VERSION} only (fail closed)`);
|
|
245
|
+
}
|
|
246
|
+
if (!FLOW_KINDS.includes(record.kind)) {
|
|
247
|
+
return refuse(`unknown kind ${JSON.stringify(record.kind)} — closed set: ${FLOW_KINDS.join(' | ')} (fail closed)`);
|
|
248
|
+
}
|
|
249
|
+
if (record.kind === CHAIN_KIND) {
|
|
250
|
+
if (!CHAIN_PURPOSES.includes(record.purpose)) {
|
|
251
|
+
return refuse(`chain: unknown purpose ${JSON.stringify(record.purpose)} — closed set: ${CHAIN_PURPOSES.join(' | ')} (fail closed)`);
|
|
252
|
+
}
|
|
253
|
+
const shape = PURPOSE_SHAPES[record.purpose];
|
|
254
|
+
const label = `chain/${record.purpose}`;
|
|
255
|
+
const fieldToCheck = Object.fromEntries([
|
|
256
|
+
...CHAIN_COMMON_FIELDS.map((f) => [f, f]),
|
|
257
|
+
['stepId', shape.stepId],
|
|
258
|
+
...shape.fields.map((f) => [f, f]),
|
|
259
|
+
]);
|
|
260
|
+
const checked = checkFields(label, record, fieldToCheck);
|
|
261
|
+
if (!checked.ok) return checked;
|
|
262
|
+
if (record.purpose !== 'round') return { ok: true };
|
|
263
|
+
const dispatches = validateDispatches(label, record.dispatches);
|
|
264
|
+
if (!dispatches.ok) return dispatches;
|
|
265
|
+
return validateDispositions(label, record.dispositions);
|
|
266
|
+
}
|
|
267
|
+
const overrides = GLOBAL_FIELD_CHECK_OVERRIDES[record.kind] ?? {};
|
|
268
|
+
const fieldToCheck = Object.fromEntries(GLOBAL_SHAPES[record.kind].map((f) => [f, overrides[f] ?? f]));
|
|
269
|
+
if (record.kind === 'subset-attempt' && 'diagnosis' in record) {
|
|
270
|
+
if (!(Number.isInteger(record.attemptIndex) && record.attemptIndex >= SUBSET_ATTEMPT_DIAGNOSIS_FROM)) {
|
|
271
|
+
return refuse(`subset-attempt: diagnosis rides only attemptIndex ${SUBSET_ATTEMPT_DIAGNOSIS_FROM} and later (Decision 8) — attempts 1-2 are the blind budget and never carry one`);
|
|
272
|
+
}
|
|
273
|
+
fieldToCheck.diagnosis = 'diagnosis';
|
|
274
|
+
}
|
|
275
|
+
const checked = checkFields(record.kind, record, fieldToCheck);
|
|
276
|
+
if (!checked.ok) return checked;
|
|
277
|
+
if (record.kind === 'down-mark') {
|
|
278
|
+
if (!isCanonicalInstant(record.timestamp)) return refuse('down-mark: timestamp must be a canonical UTC ISO instant (toISOString round-trip) — the TTL window needs comparable instants');
|
|
279
|
+
if (Date.parse(record.expiresAt) <= Date.parse(record.timestamp)) return refuse('down-mark: expiresAt must be strictly after timestamp — an already-expired mark is refused at the record level');
|
|
280
|
+
return { ok: true };
|
|
281
|
+
}
|
|
282
|
+
return record.kind === 'bookkeeping-delta' ? validateCustodyProof(record.kind, record) : { ok: true };
|
|
283
|
+
};
|