dsh-bettertaskgraphs 0.0.0-stage → 0.2.2

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/core.mjs ADDED
@@ -0,0 +1,432 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+
4
+ export const LIMITS = Object.freeze({ nodes: 128, edges: 512, depth: 16, cases: 8, events: 1024, bytes: 4 * 1024 * 1024, evidence: 32 })
5
+ export class GraphError extends Error {
6
+ constructor(code, message = code) { super(message); this.name = 'GraphError'; this.code = code }
7
+ }
8
+ const fail = (code, message) => { throw new GraphError(code, message) }
9
+ const own = (object, key) => Object.hasOwn(object, key)
10
+ export const copy = value => structuredClone(value)
11
+ export function canonical(value) {
12
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
13
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
14
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
15
+ }
16
+ export const digest = value => createHash('sha256').update(canonical(value)).digest('hex')
17
+ const identifier = value => typeof value === 'string' && /^[a-z][a-z0-9_-]{0,79}$/.test(value)
18
+ function text(value, maximum, name) {
19
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum) fail('INVALID_TEXT', name)
20
+ return value.trim()
21
+ }
22
+ function object(value, allowed, name) {
23
+ if (!value || typeof value !== 'object' || Array.isArray(value)
24
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail('INVALID_OBJECT', name)
25
+ if (Object.keys(value).some(key => !allowed.includes(key))) fail('UNKNOWN_FIELD', name)
26
+ }
27
+ function integer(value, minimum, name) {
28
+ if (!Number.isSafeInteger(value) || value < minimum) fail('INVALID_INTEGER', name)
29
+ return value
30
+ }
31
+ function noCycles(ids, pairs, code) {
32
+ const outgoing = new Map(ids.map(id => [id, []])), degree = new Map(ids.map(id => [id, 0]))
33
+ for (const [a, b] of pairs) { outgoing.get(a).push(b); degree.set(b, degree.get(b) + 1) }
34
+ const ready = ids.filter(id => degree.get(id) === 0)
35
+ let count = 0
36
+ for (let i = 0; i < ready.length; i++) {
37
+ const id = ready[i]; count++
38
+ for (const target of outgoing.get(id)) { degree.set(target, degree.get(target) - 1); if (degree.get(target) === 0) ready.push(target) }
39
+ }
40
+ if (count !== ids.length) fail(code)
41
+ }
42
+ export function evidenceRefs(value = []) {
43
+ if (!Array.isArray(value) || value.length > LIMITS.evidence) fail('EVIDENCE_LIMIT')
44
+ const used = new Set()
45
+ return value.map(ref => {
46
+ object(ref, ['kind', 'ref', 'sha256'], 'evidence reference')
47
+ if (!['session_event', 'tool_result', 'artifact'].includes(ref.kind)) fail('INVALID_EVIDENCE_KIND')
48
+ const result = { kind: ref.kind, ref: text(ref.ref, 512, 'ref') }
49
+ if (ref.sha256 !== undefined) {
50
+ if (typeof ref.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(ref.sha256)) fail('INVALID_EVIDENCE_SHA')
51
+ result.sha256 = ref.sha256
52
+ }
53
+ const key = canonical(result)
54
+ if (used.has(key)) fail('DUPLICATE_EVIDENCE')
55
+ used.add(key)
56
+ return result
57
+ })
58
+ }
59
+
60
+ // Durable IDs are generated by this program. Input keys are local plan labels.
61
+ export function normalizePlan(definition, { enableSwitch = false, id = prefix => `${prefix}_${randomUUID()}` } = {}) {
62
+ object(definition, ['title', 'nodes', 'edges'], 'plan')
63
+ if (!Array.isArray(definition.nodes) || definition.nodes.length < 2 || definition.nodes.length > LIMITS.nodes) fail('NODE_LIMIT')
64
+ if (!Array.isArray(definition.edges) || definition.edges.length > LIMITS.edges) fail('EDGE_LIMIT')
65
+ const keys = new Map(), usedIds = new Set()
66
+ const generated = prefix => {
67
+ const value = id(prefix)
68
+ if (!identifier(value) || usedIds.has(value)) fail('INVALID_GENERATED_ID')
69
+ usedIds.add(value); return value
70
+ }
71
+ const nodes = definition.nodes.map(raw => {
72
+ object(raw, ['key', 'kind', 'label', 'parent', 'switchCases'], 'node')
73
+ if (!identifier(raw.key) || keys.has(raw.key)) fail('DUPLICATE_OR_INVALID_KEY')
74
+ if (!['container', 'leaf'].includes(raw.kind)) fail('INVALID_NODE_KIND')
75
+ const cases = raw.switchCases ?? []
76
+ if (!Array.isArray(cases) || cases.length > LIMITS.cases || (cases.length && (!enableSwitch || raw.kind !== 'leaf'))) fail('SWITCH_DISABLED_OR_LIMIT')
77
+ const switchCases = cases.map(value => text(value, 64, 'case'))
78
+ if (new Set(switchCases).size !== switchCases.length) fail('DUPLICATE_CASE')
79
+ const node = { id: generated('node'), key: raw.key, kind: raw.kind, label: text(raw.label, 512, 'label'),
80
+ parent: raw.parent ?? null, switchCases }
81
+ keys.set(raw.key, node); return node
82
+ })
83
+ for (const node of nodes) {
84
+ if (node.parent !== null) {
85
+ if (!keys.has(node.parent)) fail('UNKNOWN_PARENT')
86
+ node.parent = keys.get(node.parent).id
87
+ }
88
+ }
89
+ const edges = definition.edges.map(raw => {
90
+ object(raw, ['kind', 'from', 'to', 'outcome', 'case'], 'edge')
91
+ if (!keys.has(raw.from) || !keys.has(raw.to)) fail('UNKNOWN_EDGE_ENDPOINT')
92
+ const edge = { id: generated('edge'), kind: raw.kind, from: keys.get(raw.from).id, to: keys.get(raw.to).id }
93
+ if (raw.outcome !== undefined) edge.outcome = raw.outcome
94
+ if (raw.case !== undefined) edge.case = raw.case
95
+ return edge
96
+ })
97
+ const plan = { schema: 1, id: generated('graph'), title: text(definition.title, 512, 'title'), enableSwitch, nodes, edges }
98
+ validatePlan(plan)
99
+ return plan
100
+ }
101
+
102
+ export function validatePlan(plan) {
103
+ object(plan, ['schema', 'id', 'title', 'enableSwitch', 'nodes', 'edges'], 'normalized plan')
104
+ if (plan.schema !== 1 || !identifier(plan.id) || typeof plan.enableSwitch !== 'boolean') fail('INVALID_PLAN')
105
+ text(plan.title, 512, 'title')
106
+ if (!Array.isArray(plan.nodes) || plan.nodes.length < 2 || plan.nodes.length > LIMITS.nodes
107
+ || !Array.isArray(plan.edges) || plan.edges.length > LIMITS.edges) fail('GRAPH_LIMIT')
108
+ const nodes = new Map(), ids = new Set([plan.id]), keys = new Set()
109
+ for (const node of plan.nodes) {
110
+ object(node, ['id', 'key', 'kind', 'label', 'parent', 'switchCases'], 'normalized node')
111
+ if (!identifier(node.id) || ids.has(node.id) || !identifier(node.key) || keys.has(node.key)
112
+ || !['container', 'leaf'].includes(node.kind)) fail('INVALID_NODE')
113
+ text(node.label, 512, 'label'); ids.add(node.id); keys.add(node.key); nodes.set(node.id, node)
114
+ if (!Array.isArray(node.switchCases) || node.switchCases.length > LIMITS.cases
115
+ || (node.switchCases.length && (!plan.enableSwitch || node.kind !== 'leaf'))
116
+ || new Set(node.switchCases).size !== node.switchCases.length) fail('INVALID_SWITCH')
117
+ for (const label of node.switchCases) text(label, 64, 'case')
118
+ }
119
+ const roots = plan.nodes.filter(node => node.parent === null)
120
+ if (roots.length !== 1 || roots[0].kind !== 'container') fail('ONE_ROOT_CONTAINER_REQUIRED')
121
+ const containment = [], semantic = []
122
+ for (const node of plan.nodes) {
123
+ if (node.parent === null) continue
124
+ if (!nodes.has(node.parent) || nodes.get(node.parent).kind !== 'container' || node.parent === node.id) fail('INVALID_CONTAINMENT')
125
+ containment.push([node.parent, node.id]); semantic.push([node.id, node.parent])
126
+ }
127
+ noCycles([...nodes.keys()], containment, 'CONTAINMENT_CYCLE')
128
+ for (const node of plan.nodes) {
129
+ let cursor = node, depth = 1
130
+ while (cursor.parent !== null) { cursor = nodes.get(cursor.parent); if (++depth > LIMITS.depth) fail('DEPTH_LIMIT') }
131
+ if (node.kind === 'container' && !plan.nodes.some(child => child.parent === node.id)) fail('EMPTY_CONTAINER')
132
+ }
133
+ const duplicate = new Set(), routes = new Set()
134
+ for (const edge of plan.edges) {
135
+ object(edge, ['id', 'kind', 'from', 'to', 'outcome', 'case'], 'normalized edge')
136
+ if (!identifier(edge.id) || ids.has(edge.id) || !nodes.has(edge.from) || !nodes.has(edge.to) || edge.from === edge.to) fail('INVALID_EDGE')
137
+ ids.add(edge.id)
138
+ const from = nodes.get(edge.from), to = nodes.get(edge.to)
139
+ if (!['prerequisite', 'route', 'case'].includes(edge.kind) || to.kind !== 'leaf') fail('ILLEGAL_EDGE_KIND_OR_TARGET')
140
+ if (edge.kind === 'prerequisite') {
141
+ if (edge.outcome !== undefined || edge.case !== undefined) fail('PREREQUISITE_HAS_CONDITION')
142
+ } else {
143
+ if (from.kind !== 'leaf') fail('ROUTE_SOURCE_MUST_BE_LEAF')
144
+ if (edge.kind === 'route') {
145
+ if (!['complete', 'incomplete'].includes(edge.outcome) || edge.case !== undefined
146
+ || (edge.outcome === 'complete' && from.switchCases.length)) fail('INVALID_ROUTE')
147
+ } else if (!plan.enableSwitch || !from.switchCases.includes(edge.case) || edge.outcome !== undefined) fail('INVALID_CASE_ROUTE')
148
+ const routeKey = `${edge.from}:${edge.kind}:${edge.outcome ?? edge.case}`
149
+ if (routes.has(routeKey)) fail('MULTIPLE_TARGETS_FOR_ONE_ROUTE')
150
+ routes.add(routeKey)
151
+ }
152
+ const signature = canonical({ kind: edge.kind, from: edge.from, to: edge.to, outcome: edge.outcome ?? null, case: edge.case ?? null })
153
+ if (duplicate.has(signature)) fail('DUPLICATE_EDGE')
154
+ duplicate.add(signature); semantic.push([edge.from, edge.to])
155
+ }
156
+ if (containment.length + plan.edges.length > LIMITS.edges) fail('TOTAL_EDGE_LIMIT')
157
+ noCycles([...nodes.keys()], semantic, 'PREREQUISITE_ROUTE_OR_COMPLETION_CYCLE')
158
+ for (const route of plan.edges.filter(edge => edge.kind === 'route' && edge.outcome === 'incomplete')) {
159
+ if (plan.edges.some(edge => edge.kind === 'prerequisite' && edge.from === route.from && edge.to === route.to)) fail('INCOMPLETE_ROUTE_REQUIRES_SAME_COMPLETE_SOURCE')
160
+ }
161
+ return true
162
+ }
163
+
164
+ function graphProgress({ plan, leaves }) {
165
+ const nodes = new Map(plan.nodes.map(node => [node.id, node]))
166
+ const children = new Map(plan.nodes.map(node => [node.id, []]))
167
+ const prerequisites = new Map(plan.nodes.map(node => [node.id, []]))
168
+ const routeSources = new Map(plan.nodes.map(node => [node.id, []]))
169
+ const incompleteTargets = new Map()
170
+ for (const node of plan.nodes) {
171
+ if (node.parent !== null) children.get(node.parent).push(node)
172
+ }
173
+ for (const edge of plan.edges) {
174
+ if (edge.kind === 'prerequisite') {
175
+ prerequisites.get(edge.to).push(edge.from)
176
+ } else {
177
+ routeSources.get(edge.to).push(edge.from)
178
+ if (edge.outcome === 'incomplete') incompleteTargets.set(edge.from, edge.to)
179
+ }
180
+ }
181
+
182
+ const branches = new Map()
183
+ const terminal = new Set(['COMPLETE', 'INCOMPLETE', 'SKIPPED'])
184
+ const priority = ['SELECTED', 'WAITING', 'NOT_SELECTED']
185
+ const mergeBranches = markers => priority.find(marker => markers.includes(marker)) ?? null
186
+
187
+ // Containment, prerequisites and routes were validated together as a DAG.
188
+ // Keep these markers derived so recorded outcomes and checksums stay intact.
189
+ function branchState(id) {
190
+ if (branches.has(id)) return branches.get(id)
191
+ const node = nodes.get(id)
192
+ let marker
193
+ if (node.kind === 'container') {
194
+ const markers = children.get(id).map(child => branchState(child.id))
195
+ marker = markers.every(Boolean) ? mergeBranches(markers) : null
196
+ } else if (routeSources.get(id).length) {
197
+ if (leaves[id].status !== 'INACTIVE') {
198
+ marker = 'SELECTED'
199
+ } else {
200
+ const stillOpen = routeSources.get(id).some(source =>
201
+ !terminal.has(leaves[source].status) && branchState(source) !== 'NOT_SELECTED')
202
+ marker = stillOpen ? 'WAITING' : 'NOT_SELECTED'
203
+ }
204
+ } else {
205
+ // A continuation with both selected and excluded prerequisites remains
206
+ // required. Its original prerequisite checks still decide eligibility.
207
+ marker = mergeBranches(prerequisites.get(id).map(branchState))
208
+ }
209
+ branches.set(id, marker)
210
+ return marker
211
+ }
212
+
213
+ function descendantLeaves(node) {
214
+ return node.kind === 'leaf' ? [node] : children.get(node.id).flatMap(descendantLeaves)
215
+ }
216
+ function complete(id) {
217
+ const node = nodes.get(id)
218
+ if (node.kind === 'leaf') return leaves[id].status === 'COMPLETE'
219
+ const required = new Set(descendantLeaves(node).filter(leaf => branchState(leaf.id) !== 'NOT_SELECTED').map(leaf => leaf.id))
220
+ // A failed decision can be handled by its chosen continuation in this
221
+ // container. That continuation must itself complete or handle its outcome.
222
+ return required.size > 0 && [...required].every(leaf =>
223
+ leaves[leaf].status === 'COMPLETE' ||
224
+ (leaves[leaf].status === 'INCOMPLETE' && required.has(incompleteTargets.get(leaf))))
225
+ }
226
+ return {
227
+ branchState, complete, children,
228
+ unmetPrerequisites: id => prerequisites.get(id).filter(source => !complete(source)),
229
+ pending: plan.nodes.filter(node =>
230
+ leaves[node.id]?.status === 'PENDING' && branchState(node.id) !== 'NOT_SELECTED'),
231
+ }
232
+ }
233
+
234
+ export function legalNext(state, progress) {
235
+ if (!state || state.active) return []
236
+ const { pending, unmetPrerequisites } = progress ?? graphProgress(state)
237
+ return pending.filter(node => unmetPrerequisites(node.id).length === 0)
238
+ }
239
+ function matchesRoute(edge, outcome, selectedCase) {
240
+ return (edge.kind === 'route' && edge.outcome === outcome)
241
+ || (edge.kind === 'case' && outcome === 'complete' && edge.case === selectedCase)
242
+ }
243
+ function activate(state, source, outcome, selectedCase) {
244
+ for (const edge of state.plan.edges) {
245
+ if (edge.from !== source) continue
246
+ if (matchesRoute(edge, outcome, selectedCase) && state.leaves[edge.to].status === 'INACTIVE')
247
+ state.leaves[edge.to].status = 'PENDING'
248
+ }
249
+ }
250
+ function finishLeaf(state, data) {
251
+ const active = state.active
252
+ if (!active || data.nodeId !== active.nodeId || data.attemptId !== active.attemptId || data.owner !== active.owner) fail('ACTIVE_ATTEMPT_MISMATCH')
253
+ if (!['complete', 'incomplete'].includes(data.outcome)) fail('INVALID_OUTCOME')
254
+ const node = state.plan.nodes.find(node => node.id === active.nodeId)
255
+ if (data.outcome === 'complete' && node.switchCases.length) {
256
+ if (!node.switchCases.includes(data.selectedCase)) fail('EXPLICIT_SWITCH_CASE_REQUIRED')
257
+ } else if (data.selectedCase !== null) fail('UNEXPECTED_SWITCH_CASE')
258
+ const refs = evidenceRefs(data.evidence)
259
+ if (data.outcome === 'complete' && !refs.length) fail('COMPLETE_REQUIRES_TRACEABLE_REFERENCE')
260
+ const disposition = data.disposition ?? (data.outcome === 'complete' ? 'COMPLETED' : 'UNSATISFIED')
261
+ if (data.outcome === 'complete' ? disposition !== 'COMPLETED' : !['SKIPPED_NOT_RUN', 'UNSATISFIED', 'FAILED'].includes(disposition)) fail('INVALID_FINISH_DISPOSITION')
262
+ text(data.note, 1200, 'finish note')
263
+ state.leaves[active.nodeId] = { status: data.outcome === 'complete' ? 'COMPLETE' : 'INCOMPLETE',
264
+ attemptId: active.attemptId, evidence: refs, note: data.note, disposition, acceptance: 'UNVERIFIED_REFERENCE' }
265
+ state.active = null
266
+ activate(state, active.nodeId, data.outcome, data.selectedCase)
267
+ }
268
+
269
+ // Deterministic replay is authoritative; snapshots are checked against it.
270
+ export function replay(events) {
271
+ if (!Array.isArray(events) || !events.length || events.length > LIMITS.events) fail('EVENT_LIMIT')
272
+ let state = null, previous = null
273
+ const operations = new Set(), eventIds = new Set()
274
+ for (const event of events) {
275
+ object(event, ['id', 'seq', 'operationId', 'type', 'data', 'previousHash', 'hash'], 'event')
276
+ const body = { ...event }; delete body.hash
277
+ if (!identifier(event.id) || eventIds.has(event.id) || !identifier(event.operationId) || operations.has(event.operationId)
278
+ || event.seq !== operations.size + 1 || event.previousHash !== previous || digest(body) !== event.hash) fail('EVENT_CHAIN_OR_REPLAY')
279
+ operations.add(event.operationId); eventIds.add(event.id)
280
+ if (!state) {
281
+ if (event.type !== 'CREATED') fail('FIRST_EVENT_MUST_CREATE')
282
+ object(event.data, ['plan'], 'created event')
283
+ validatePlan(event.data.plan)
284
+ const plan = copy(event.data.plan), incoming = new Set(plan.edges.filter(edge => edge.kind !== 'prerequisite').map(edge => edge.to))
285
+ const leaves = Object.fromEntries(plan.nodes.filter(node => node.kind === 'leaf').map(node => [node.id,
286
+ { status: incoming.has(node.id) ? 'INACTIVE' : 'PENDING', evidence: [], acceptance: 'UNVERIFIED_REFERENCE' }]))
287
+ state = { schema: 1, revision: 0, plan, leaves, active: null, operations: [] }
288
+ } else if (event.type === 'BEGUN') {
289
+ object(event.data, ['nodeId', 'attemptId', 'owner'], 'begin event')
290
+ if (state.active || !legalNext(state).some(node => node.id === event.data.nodeId)
291
+ || !identifier(event.data.attemptId) || !identifier(event.data.owner)) fail('BEGIN_NOT_LEGAL')
292
+ state.active = copy(event.data)
293
+ state.leaves[event.data.nodeId].status = 'ACTIVE'
294
+ } else if (event.type === 'FINISHED' || event.type === 'RECONCILED') {
295
+ object(event.data, ['nodeId', 'attemptId', 'owner', 'outcome', 'selectedCase', 'evidence', 'note', 'disposition'], 'finish event')
296
+ finishLeaf(state, event.data)
297
+ } else if (event.type === 'SKIPPED') {
298
+ object(event.data, ['nodeId', 'evidence', 'note'], 'skip event')
299
+ if (state.active || state.leaves[event.data.nodeId]?.status !== 'PENDING') fail('SKIP_NOT_LEGAL')
300
+ state.leaves[event.data.nodeId] = { status: 'SKIPPED', disposition: 'SKIPPED_NOT_RUN', evidence: evidenceRefs(event.data.evidence),
301
+ note: text(event.data.note, 1200, 'skip note'), acceptance: 'UNVERIFIED_REFERENCE' }
302
+ // A skip never fires a complete/incomplete route and never satisfies a prerequisite.
303
+ } else fail('UNKNOWN_EVENT_TYPE')
304
+ state.revision = event.seq; state.operations.push(event.operationId); previous = event.hash
305
+ }
306
+ return state
307
+ }
308
+ export function documentOf(events) {
309
+ const state = replay(events), document = { schema: 1, revision: state.revision, state, events: copy(events) }
310
+ if (Buffer.byteLength(canonical(document)) > LIMITS.bytes) fail('DOCUMENT_BYTE_LIMIT')
311
+ return { ...document, checksum: digest(document) }
312
+ }
313
+ export function validateDocument(document) {
314
+ object(document, ['schema', 'revision', 'state', 'events', 'checksum'], 'document')
315
+ if (document.schema !== 1 || Buffer.byteLength(canonical(document)) > LIMITS.bytes) fail('DOCUMENT_LIMIT')
316
+ const body = { ...document }; delete body.checksum
317
+ if (digest(body) !== document.checksum) fail('DOCUMENT_CHECKSUM')
318
+ const replayed = replay(document.events)
319
+ if (document.revision !== replayed.revision || canonical(document.state) !== canonical(replayed)) fail('SNAPSHOT_REPLAY_MISMATCH')
320
+ return true
321
+ }
322
+ export function view(document, owner) {
323
+ if (!document) return { revision: 0, graph: null, legalNext: [], active: null, goalReached: false }
324
+ validateDocument(document)
325
+ const state = document.state
326
+ const progress = graphProgress(state), ready = legalNext(state, progress)
327
+ const readyIds = new Set(ready.map(node => node.id))
328
+ const decisions = new Set(state.plan.edges.filter(edge => edge.kind !== 'prerequisite').map(edge => edge.from))
329
+ const selectedBranches = new Map()
330
+ for (const event of document.events) {
331
+ if (event.type !== 'FINISHED' && event.type !== 'RECONCILED') continue
332
+ const { nodeId, outcome, selectedCase } = event.data
333
+ if (!decisions.has(nodeId) && selectedCase === null) continue
334
+ const target = state.plan.edges.find(edge => edge.from === nodeId && matchesRoute(edge, outcome, selectedCase))
335
+ selectedBranches.set(nodeId, { outcome, ...(selectedCase === null ? {} : { case: selectedCase }),
336
+ ...(target ? { targetId: target.to } : {}) })
337
+ }
338
+ const rows = state.plan.nodes.map(node => {
339
+ const branchState = progress.branchState(node.id), selectedBranch = selectedBranches.get(node.id)
340
+ const row = { id: node.id, key: node.key, label: node.label, kind: node.kind, ...(branchState ? { branchState } : {}) }
341
+ if (node.kind === 'leaf') return { ...row, ...copy(state.leaves[node.id]), ...(selectedBranch ? { selectedBranch } : {}) }
342
+ const allChildrenComplete = progress.complete(node.id)
343
+ return { ...row, childCount: progress.children.get(node.id).length, allChildrenComplete,
344
+ status: allChildrenComplete ? 'COMPLETE' : 'CONTAINER_INCOMPLETE' }
345
+ })
346
+ return { revision: state.revision, graph: { id: state.plan.id, title: state.plan.title }, nodes: rows,
347
+ active: state.active ? { ...copy(state.active), recoveryRequired: state.active.owner !== owner,
348
+ caseChoices: state.plan.nodes.find(node => node.id === state.active.nodeId).switchCases.map((label, choice) => ({ choice, label })) } : null,
349
+ legalNext: ready.map((node, choice) => ({ choice, id: node.id, label: node.label, switchCases: node.switchCases })),
350
+ pendingButBlocked: progress.pending.filter(node => !readyIds.has(node.id)).map(node => ({ id: node.id,
351
+ reason: state.active ? 'ACTIVE_LEAF' : 'UNMET_PREREQUISITE',
352
+ unmetPrerequisites: progress.unmetPrerequisites(node.id) })),
353
+ skipChoices: state.active ? [] : progress.pending
354
+ .map((node, choice) => ({ choice, id: node.id, label: node.label })),
355
+ goalReached: false, evidenceMeaning: 'References are unverified. Transaction completion does not establish Goal completion.' }
356
+ }
357
+
358
+ export class TransactionGraph {
359
+ constructor(store, { enableSwitch = false, id = prefix => `${prefix}_${randomUUID()}`, verifyRecovery = async () => false } = {}) {
360
+ this.store = store; this.enableSwitch = enableSwitch; this.id = id; this.owner = id('owner'); this.verifyRecovery = verifyRecovery
361
+ if (!identifier(this.owner)) fail('INVALID_OWNER')
362
+ }
363
+ read() { return view(this.store.read(), this.owner) }
364
+ mutate(expectedRevision, operationId, type, makeData) {
365
+ integer(expectedRevision, 0, 'revision')
366
+ if (!identifier(operationId)) fail('INVALID_OPERATION_ID')
367
+ return this.store.compareAndSwap(expectedRevision, previous => {
368
+ if (previous) validateDocument(previous)
369
+ if (previous?.state.operations.includes(operationId)) fail('OPERATION_REPLAY')
370
+ if (previous && previous.state.plan.enableSwitch !== this.enableSwitch) fail('GRAPH_POLICY_CHANGED')
371
+ const data = makeData(previous?.state ?? null)
372
+ const event = { id: this.id('event'), seq: expectedRevision + 1, operationId, type, data,
373
+ previousHash: previous?.events.at(-1).hash ?? null }
374
+ event.hash = digest(event)
375
+ return documentOf([...(previous?.events ?? []), event])
376
+ })
377
+ }
378
+ create({ expectedRevision = 0, operationId, definition }) {
379
+ const result = this.mutate(expectedRevision, operationId, 'CREATED', state => {
380
+ if (state) fail('PLAN_IMMUTABLE_CREATE_ONCE')
381
+ return { plan: normalizePlan(definition, { enableSwitch: this.enableSwitch, id: this.id }) }
382
+ }); return view(result, this.owner)
383
+ }
384
+ begin({ expectedRevision, operationId, choice }) {
385
+ const result = this.mutate(expectedRevision, operationId, 'BEGUN', state => {
386
+ if (!state) fail('NO_PLAN')
387
+ if (state.active) fail(state.active.owner === this.owner ? 'ONE_ACTIVE_LEAF_ONLY' : 'RECOVERY_REQUIRED')
388
+ integer(choice, 0, 'choice')
389
+ const selected = legalNext(state)[choice]
390
+ if (!selected) fail('NO_SUCH_LEGAL_CHOICE')
391
+ return { nodeId: selected.id, attemptId: this.id('attempt'), owner: this.owner }
392
+ }); return view(result, this.owner)
393
+ }
394
+ finish({ expectedRevision, operationId, outcome, caseIndex, evidence = [], note, disposition = outcome === 'complete' ? 'COMPLETED' : 'UNSATISFIED' }) {
395
+ const result = this.mutate(expectedRevision, operationId, 'FINISHED', state => {
396
+ if (!state?.active) fail('NO_ACTIVE_LEAF')
397
+ if (state.active.owner !== this.owner) fail('RECOVERY_REQUIRED')
398
+ const node = state.plan.nodes.find(node => node.id === state.active.nodeId)
399
+ let selectedCase = null
400
+ if (caseIndex !== undefined) { integer(caseIndex, 0, 'caseIndex'); selectedCase = node.switchCases[caseIndex]; if (selectedCase === undefined) fail('NO_SUCH_CASE') }
401
+ return { ...state.active, outcome, selectedCase, disposition, evidence: evidenceRefs(evidence), note: text(note, 1200, 'note') }
402
+ }); return view(result, this.owner)
403
+ }
404
+ skip({ expectedRevision, operationId, choice, evidence = [], note }) {
405
+ const result = this.mutate(expectedRevision, operationId, 'SKIPPED', state => {
406
+ if (!state) fail('NO_PLAN')
407
+ if (state.active) fail(state.active.owner === this.owner ? 'ONE_ACTIVE_LEAF_ONLY' : 'RECOVERY_REQUIRED')
408
+ integer(choice, 0, 'choice')
409
+ const selected = graphProgress(state).pending[choice]
410
+ if (!selected) fail('NO_SUCH_SKIP_CHOICE')
411
+ return { nodeId: selected.id, evidence: evidenceRefs(evidence), note: text(note, 1200, 'note') }
412
+ }); return view(result, this.owner)
413
+ }
414
+ async reconcile({ expectedRevision, operationId, outcome, caseIndex, evidence = [], note }) {
415
+ const before = this.store.read()
416
+ if (!before) fail('NO_PLAN')
417
+ validateDocument(before)
418
+ if (before.revision !== expectedRevision || !before.state.active) fail('RECOVERY_STALE_OR_NO_ACTIVE')
419
+ if (before.state.active.owner === this.owner) fail('USE_NORMAL_FINISH')
420
+ const node = before.state.plan.nodes.find(node => node.id === before.state.active.nodeId)
421
+ let selectedCase = null
422
+ if (caseIndex !== undefined) { integer(caseIndex, 0, 'caseIndex'); selectedCase = node.switchCases[caseIndex]; if (selectedCase === undefined) fail('NO_SUCH_CASE') }
423
+ const data = { ...copy(before.state.active), outcome, selectedCase, disposition: outcome === 'complete' ? 'COMPLETED' : 'UNSATISFIED', evidence: evidenceRefs(evidence), note: text(note, 1200, 'note') }
424
+ // Freeze the exact route/outcome/refs before awaiting the external attestation.
425
+ const accepted = await this.verifyRecovery({ expectedRevision, ...copy(data) })
426
+ if (accepted !== true) fail('HOST_RECOVERY_ATTESTATION_REQUIRED')
427
+ const result = this.mutate(expectedRevision, operationId, 'RECONCILED', state => {
428
+ if (canonical(state.active) !== canonical(before.state.active)) fail('RECOVERY_ACTIVE_CHANGED')
429
+ return copy(data)
430
+ }); return view(result, this.owner)
431
+ }
432
+ }