@hunsu/protocol 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +157 -0
  2. package/dist/command-decoder.d.ts +5 -0
  3. package/dist/command-decoder.js +329 -0
  4. package/dist/constants.d.ts +15 -0
  5. package/dist/constants.js +15 -0
  6. package/dist/decoder-helpers.d.ts +58 -0
  7. package/dist/decoder-helpers.js +1757 -0
  8. package/dist/errors.d.ts +14 -0
  9. package/dist/errors.js +28 -0
  10. package/dist/event-decoder.d.ts +5 -0
  11. package/dist/event-decoder.js +232 -0
  12. package/dist/index.d.ts +10 -0
  13. package/dist/index.js +10 -0
  14. package/dist/lifecycle.d.ts +67 -0
  15. package/dist/lifecycle.js +271 -0
  16. package/dist/model.d.ts +1058 -0
  17. package/dist/model.js +1 -0
  18. package/dist/primitives.d.ts +59 -0
  19. package/dist/primitives.js +135 -0
  20. package/dist/projection.d.ts +8 -0
  21. package/dist/projection.js +875 -0
  22. package/dist/prompt-template.d.ts +12 -0
  23. package/dist/prompt-template.js +83 -0
  24. package/dist/protocol-validation.d.ts +36 -0
  25. package/dist/protocol-validation.js +1139 -0
  26. package/dist/result.d.ts +20 -0
  27. package/dist/result.js +21 -0
  28. package/dist/workflow-helpers.d.ts +31 -0
  29. package/dist/workflow-helpers.js +193 -0
  30. package/dist/workflow.d.ts +10 -0
  31. package/dist/workflow.js +594 -0
  32. package/package.json +30 -0
  33. package/src/command-decoder.ts +292 -0
  34. package/src/constants.ts +27 -0
  35. package/src/decoder-helpers.ts +1672 -0
  36. package/src/errors.ts +38 -0
  37. package/src/event-decoder.ts +225 -0
  38. package/src/index.ts +43 -0
  39. package/src/lifecycle.ts +403 -0
  40. package/src/model.ts +1233 -0
  41. package/src/primitives.ts +196 -0
  42. package/src/projection.ts +1081 -0
  43. package/src/prompt-template.ts +97 -0
  44. package/src/protocol-validation.ts +1252 -0
  45. package/src/result.ts +35 -0
  46. package/src/workflow-helpers.ts +239 -0
  47. package/src/workflow.ts +753 -0
@@ -0,0 +1,753 @@
1
+ import type {
2
+ ArtifactRecord,
3
+ BoardProjection,
4
+ Command,
5
+ DomainEvent,
6
+ HunsuId,
7
+ HunsuRecord,
8
+ LineId,
9
+ LineRecord,
10
+ MoveId,
11
+ MoveRecord,
12
+ NodeId,
13
+ NodeRecord,
14
+ RequestId,
15
+ Destination,
16
+ DestinationId,
17
+ DraftSkillDraft,
18
+ SkillDraftRecord,
19
+ ValidatedCommand
20
+ } from "./model.ts";
21
+ import {
22
+ abandonLine,
23
+ blockDestination,
24
+ claimDestination,
25
+ completeLine,
26
+ makeAccidentMoveRecord,
27
+ makeArrivedMoveRecord,
28
+ makeDraftSkillDraft,
29
+ makePlayableLine,
30
+ pauseLine,
31
+ reachDestination,
32
+ resumeLine,
33
+ startDestinationWork,
34
+ type DomainModelError
35
+ } from "./lifecycle.ts";
36
+ import {
37
+ DomainInvariantError,
38
+ toDomainWorkflowError,
39
+ unwrapDomainModelResult,
40
+ workflowError,
41
+ type DomainWorkflowError
42
+ } from "./errors.ts";
43
+ import { decodeCommand } from "./command-decoder.ts";
44
+ import { decodeDomainEvent } from "./event-decoder.ts";
45
+ import {
46
+ cloneHarness,
47
+ createDefaultHarness,
48
+ validateHarness
49
+ } from "./protocol-validation.ts";
50
+ import {
51
+ emptyBoardProjection,
52
+ projectBoard,
53
+ tryProjectBoard,
54
+ validateEventStream
55
+ } from "./projection.ts";
56
+ import {
57
+ assertNonEmpty,
58
+ assertSingleDestination,
59
+ assertText,
60
+ isClosedDestination,
61
+ nextTeamName,
62
+ nextNodeId,
63
+ requireBlockableDestination,
64
+ requireClaimableDestination,
65
+ requireCurrentNode,
66
+ requireExistingDestination,
67
+ requireLine,
68
+ requireMove,
69
+ requireNode,
70
+ requirePlayableLine,
71
+ requireReachibleDestinationInNode,
72
+ requireRequest,
73
+ requireRootNode,
74
+ requireSkillDraft,
75
+ rootNodeId
76
+ } from "./workflow-helpers.ts";
77
+ import { err, ok, type Result } from "./result.ts";
78
+
79
+ export { DomainInvariantError, type DomainWorkflowError } from "./errors.ts";
80
+ export {
81
+ cloneHarness,
82
+ createDefaultHarness,
83
+ createDefaultMemberConfig,
84
+ getHarnessMemberConfig,
85
+ isExecutableHarness,
86
+ validateHarness,
87
+ validateExecutableHarness
88
+ } from "./protocol-validation.ts";
89
+ export {
90
+ emptyBoardProjection,
91
+ projectBoard,
92
+ projectEvent,
93
+ tryProjectBoard,
94
+ validateEventStream
95
+ } from "./projection.ts";
96
+
97
+ export function tryHandleCommand(command: Command, state: BoardProjection = emptyBoardProjection()): Result<DomainEvent[], DomainWorkflowError> {
98
+ const validation = validateCommand(command);
99
+ if (!validation.ok) {
100
+ return validation;
101
+ }
102
+ const stateValidation = validateCommandState(validation.value, state);
103
+ if (!stateValidation.ok) {
104
+ return stateValidation;
105
+ }
106
+ try {
107
+ return ok(handleValidatedCommand(stateValidation.value, state));
108
+ } catch (error) {
109
+ return err(toDomainWorkflowError(error));
110
+ }
111
+ }
112
+
113
+ function handleValidatedCommand(command: ValidatedCommand, state: BoardProjection = emptyBoardProjection()): DomainEvent[] {
114
+ switch (command.type) {
115
+ case "RegisterHunsuOrigin":
116
+ return [{ type: "HunsuOriginRegistered", origin: { ...command.origin }, at: command.at }];
117
+ case "CreateInitialTeam": {
118
+ assertNonEmpty(command.destinations, "CreateInitialTeam requires at least one Destination");
119
+ const harness = command.harness ? cloneHarness(command.harness) : createDefaultHarness();
120
+ const rootNode = rootNodeId(command.requestId);
121
+ return [
122
+ {
123
+ type: "InitialTeamCreated",
124
+ request: {
125
+ id: command.requestId,
126
+ title: command.title,
127
+ goal: command.goal,
128
+ createdBy: "SYSTEM",
129
+ createdAt: command.at
130
+ },
131
+ line: {
132
+ id: command.lineId,
133
+ requestId: command.requestId,
134
+ teamName: command.teamName ?? nextTeamName(state),
135
+ status: "active",
136
+ moveIds: [],
137
+ rootNodeId: rootNode,
138
+ currentNodeId: rootNode,
139
+ nodeIds: [rootNode]
140
+ },
141
+ destinations: command.destinations,
142
+ harness,
143
+ harnessLock: command.harnessLock,
144
+ at: command.at
145
+ }
146
+ ];
147
+ }
148
+ case "StartLine": {
149
+ requireRequest(state, command.requestId);
150
+ const rootNode = requireRootNode(state, command.requestId);
151
+ const teamName = command.teamName ?? nextTeamName(state);
152
+ return [
153
+ {
154
+ type: "LineStarted",
155
+ line: {
156
+ id: command.lineId,
157
+ requestId: command.requestId,
158
+ teamName,
159
+ status: "active",
160
+ moveIds: [],
161
+ rootNodeId: rootNode.id,
162
+ currentNodeId: rootNode.id,
163
+ nodeIds: [rootNode.id]
164
+ }
165
+ }
166
+ ];
167
+ }
168
+ case "PauseLine":
169
+ requireLine(state, command.lineId);
170
+ return [{ type: "LinePaused", lineId: command.lineId, at: command.at }];
171
+ case "ResumeLine":
172
+ requireLine(state, command.lineId);
173
+ return [{ type: "LineResumed", lineId: command.lineId, at: command.at }];
174
+ case "AcceptLine":
175
+ requireLine(state, command.lineId);
176
+ return [{ type: "LineAccepted", lineId: command.lineId, reason: command.reason, at: command.at }];
177
+ case "RejectLine":
178
+ requireLine(state, command.lineId);
179
+ return [{ type: "LineRejected", lineId: command.lineId, reason: command.reason, at: command.at }];
180
+ case "ClaimDestination":
181
+ requireClaimableDestination(state, command.destinationId);
182
+ return [{ type: "DestinationClaimed", destinationId: command.destinationId, actor: command.actor, at: command.at }];
183
+ case "StartDestinationWork":
184
+ requireExistingDestination(state, command.destinationId);
185
+ return [{ type: "DestinationWorkStarted", destinationId: command.destinationId, actor: command.actor, at: command.at }];
186
+ case "ReportDestinationBlocked":
187
+ requireBlockableDestination(state, command.destinationId);
188
+ return [{ type: "DestinationBlocked", destinationId: command.destinationId, reason: command.reason, actor: command.actor, at: command.at }];
189
+ case "RecordMove": {
190
+ const line = requireLine(state, command.lineId);
191
+ requirePlayableLine(line);
192
+ const fromNode = requireCurrentNode(state, line);
193
+ assertSingleDestination(command.reachedDestinationIds, "MOVE must reach exactly one Destination");
194
+ for (const destinationId of command.reachedDestinationIds) {
195
+ requireReachibleDestinationInNode(fromNode, destinationId);
196
+ }
197
+ const toNodeId = nextNodeId(state);
198
+ const move = unwrapDomainModelResult(makeArrivedMoveRecord({
199
+ id: command.moveId,
200
+ lineId: command.lineId,
201
+ fromNodeId: fromNode.id,
202
+ toNodeId,
203
+ executeId: command.executeId,
204
+ conversationRef: command.conversationRef,
205
+ worktree: command.worktree,
206
+ summary: command.summary,
207
+ commit: command.commit,
208
+ reachedDestinationIds: command.reachedDestinationIds,
209
+ evidence: command.evidence,
210
+ risks: command.risks,
211
+ recordedBy: "SYSTEM",
212
+ recordedAt: command.at
213
+ }));
214
+ return [
215
+ {
216
+ type: "MoveRecorded",
217
+ move
218
+ }
219
+ ];
220
+ }
221
+ case "RecordAccident": {
222
+ const line = requireLine(state, command.lineId);
223
+ requirePlayableLine(line);
224
+ const fromNode = requireCurrentNode(state, line);
225
+ assertNonEmpty(command.evidence, "A ACCIDENT must include evidence");
226
+ assertText(command.failureReason, "A ACCIDENT must include a failure reason");
227
+ const toNodeId = nextNodeId(state);
228
+ const move = unwrapDomainModelResult(makeAccidentMoveRecord({
229
+ id: command.moveId,
230
+ lineId: command.lineId,
231
+ fromNodeId: fromNode.id,
232
+ toNodeId,
233
+ executeId: command.executeId,
234
+ conversationRef: command.conversationRef,
235
+ worktree: command.worktree,
236
+ failureReason: command.failureReason,
237
+ summary: command.summary,
238
+ commit: command.commit,
239
+ evidence: command.evidence,
240
+ risks: command.risks,
241
+ recordedBy: "SYSTEM",
242
+ recordedAt: command.at
243
+ }));
244
+ return [
245
+ {
246
+ type: "MoveRecorded",
247
+ move
248
+ }
249
+ ];
250
+ }
251
+ case "AttachMoveEvidence":
252
+ requireMove(state, command.moveId);
253
+ return [{ type: "ArtifactRecorded", artifact: command.artifact, at: command.at }];
254
+ case "ConfirmHunsuDraft": {
255
+ const draft = command.draft;
256
+ const line = requireLine(state, draft.sourceLineId);
257
+ const fromNode = requireNode(state, draft.sourceNodeId);
258
+ if (fromNode.requestId !== line.requestId) {
259
+ throw new DomainInvariantError(`Node ${fromNode.id} does not belong to line ${line.id}`);
260
+ }
261
+ if (draft.sourceMoveId) {
262
+ requireMove(state, draft.sourceMoveId);
263
+ }
264
+ assertNonEmpty(draft.changedFiles, "A confirmed HUNSU Draft must include at least one changed runtime file");
265
+ return [
266
+ hunsuRecorded(
267
+ draft.hunsuId,
268
+ draft.sourceLineId,
269
+ fromNode.id,
270
+ nextNodeId(state),
271
+ draft.target,
272
+ draft.summary,
273
+ draft.teamSnapshot,
274
+ draft.changedFiles,
275
+ command.at,
276
+ {
277
+ hunsuDraftId: draft.id,
278
+ conversationRef: draft.conversationRef,
279
+ newLineId: draft.newLineId,
280
+ sourceMoveId: draft.sourceMoveId,
281
+ newTeamName: draft.newTeamName
282
+ }
283
+ ),
284
+ {
285
+ type: "LineForkedByHunsu",
286
+ hunsuId: draft.hunsuId,
287
+ fromLineId: draft.sourceLineId,
288
+ newLineId: draft.newLineId,
289
+ newTeamName: draft.newTeamName,
290
+ fromMoveId: draft.sourceMoveId,
291
+ requestId: fromNode.requestId,
292
+ at: command.at
293
+ }
294
+ ];
295
+ }
296
+ case "CreateSkillDraft": {
297
+ const line = requireLine(state, command.lineId);
298
+ const fromNode = requireCurrentNode(state, line);
299
+ return [{
300
+ type: "SkillDraftCreated",
301
+ draft: makeDraftSkillDraft({
302
+ id: command.draftId,
303
+ name: command.name,
304
+ sourcePath: command.sourcePath,
305
+ draftPath: command.draftPath,
306
+ createdFromNodeId: fromNode.id,
307
+ createdAt: command.at
308
+ })
309
+ }];
310
+ }
311
+ case "DiscardSkillDraft": {
312
+ const draft = requireSkillDraft(state, command.draftId);
313
+ if (draft.status !== "draft") {
314
+ throw new DomainInvariantError(`Skill Draft ${command.draftId} cannot be discarded from status ${draft.status}`);
315
+ }
316
+ return [{ type: "SkillDraftDiscarded", draftId: command.draftId, at: command.at }];
317
+ }
318
+ case "RecordArtifact":
319
+ return [{ type: "ArtifactRecorded", artifact: command.artifact, at: command.at }];
320
+ }
321
+ }
322
+
323
+ export function tryApplyCommand(events: DomainEvent[], command: Command): Result<DomainEvent[], DomainWorkflowError> {
324
+ const projected = tryProjectBoard(events);
325
+ if (!projected.ok) {
326
+ return projected;
327
+ }
328
+ const state = projected.value;
329
+ const accepted = tryHandleCommand(command, state);
330
+ return accepted.ok ? ok([...events, ...accepted.value]) : accepted;
331
+ }
332
+
333
+ export function validateCommand(input: unknown): Result<ValidatedCommand, DomainWorkflowError> {
334
+ return decodeCommand(input, validateHarness);
335
+ }
336
+
337
+ export function validateDomainEvent(value: unknown): Result<DomainEvent, DomainWorkflowError> {
338
+ return decodeDomainEvent(value, validateHarness);
339
+ }
340
+
341
+ function validateCommandState(command: ValidatedCommand, state: BoardProjection): Result<ValidatedCommand, DomainWorkflowError> {
342
+ const aggregateIds = validateCommandAggregateIdsAvailable(command, state);
343
+ if (!aggregateIds.ok) {
344
+ return aggregateIds;
345
+ }
346
+ switch (command.type) {
347
+ case "RegisterHunsuOrigin":
348
+ case "CreateInitialTeam":
349
+ return ok(command);
350
+ case "RecordArtifact":
351
+ return firstStateValidationError([validateArtifactOwnerExists(state, command.artifact)], command);
352
+ case "StartLine":
353
+ return firstStateValidationError([
354
+ validateRequestExists(state, command.requestId),
355
+ validateRootNodeExists(state, command.requestId)
356
+ ], command);
357
+ case "PauseLine":
358
+ return firstStateValidationError([validatePausableLine(state, command.lineId)], command);
359
+ case "ResumeLine":
360
+ return firstStateValidationError([validateResumableLine(state, command.lineId)], command);
361
+ case "AcceptLine":
362
+ return firstStateValidationError([validateCompletableLine(state, command.lineId)], command);
363
+ case "RejectLine":
364
+ return firstStateValidationError([validateAbandonableLine(state, command.lineId)], command);
365
+ case "ClaimDestination":
366
+ return firstStateValidationError([validateClaimableDestination(state, command.destinationId)], command);
367
+ case "StartDestinationWork":
368
+ return firstStateValidationError([validateStartableDestination(state, command.destinationId)], command);
369
+ case "ReportDestinationBlocked":
370
+ return firstStateValidationError([validateBlockableDestination(state, command.destinationId)], command);
371
+ case "RecordMove":
372
+ return firstStateValidationError([
373
+ validatePlayableLineForCommand(state, command.lineId),
374
+ validateCurrentNodeForLine(state, command.lineId),
375
+ ...validateReachableDestinationsForCurrentNode(state, command.lineId, command.moveId, command.reachedDestinationIds)
376
+ ], command);
377
+ case "RecordAccident":
378
+ return firstStateValidationError([
379
+ validatePlayableLineForCommand(state, command.lineId),
380
+ validateCurrentNodeForLine(state, command.lineId)
381
+ ], command);
382
+ case "AttachMoveEvidence":
383
+ return firstStateValidationError([
384
+ validateMoveExists(state, command.moveId),
385
+ validateArtifactAttachedToMove(command.artifact, command.moveId)
386
+ ], command);
387
+ case "ConfirmHunsuDraft":
388
+ return firstStateValidationError([
389
+ validateNodeBelongsToExactLine(state, command.draft.sourceNodeId, command.draft.sourceLineId),
390
+ validateHunsuTargetBelongsToLine(state, command.draft.sourceLineId, command.draft.target),
391
+ ...(command.draft.sourceMoveId ? [validateMoveBelongsToLine(state, command.draft.sourceMoveId, command.draft.sourceLineId)] : [])
392
+ ], command);
393
+ case "CreateSkillDraft":
394
+ return firstStateValidationError([validateCurrentNodeForLine(state, command.lineId)], command);
395
+ case "DiscardSkillDraft":
396
+ return firstStateValidationError([validateDraftSkillDraft(state, command.draftId, "discarded")], command);
397
+ }
398
+ }
399
+
400
+ function validateCommandAggregateIdsAvailable(command: ValidatedCommand, state: BoardProjection): Result<void, DomainWorkflowError> {
401
+ switch (command.type) {
402
+ case "CreateInitialTeam": {
403
+ const request = validateRequestIdAvailable(state, command.requestId);
404
+ if (!request.ok) return request;
405
+ const line = validateLineIdAvailable(state, command.lineId);
406
+ if (!line.ok) return line;
407
+ return validateDestinationIdsAvailable(state, command.destinations.map(destination => destination.id));
408
+ }
409
+ case "StartLine":
410
+ return validateLineIdAvailable(state, command.lineId);
411
+ case "RecordMove":
412
+ case "RecordAccident":
413
+ return validateMoveIdAvailable(state, command.moveId);
414
+ case "AttachMoveEvidence":
415
+ case "RecordArtifact":
416
+ return validateArtifactIdAvailable(state, command.artifact.id);
417
+ case "CreateSkillDraft":
418
+ return validateSkillDraftIdAvailable(state, command.draftId);
419
+ case "ConfirmHunsuDraft": {
420
+ const hunsu = validateHunsuIdAvailable(state, command.draft.hunsuId);
421
+ if (!hunsu.ok) return hunsu;
422
+ return validateLineIdAvailable(state, command.draft.newLineId);
423
+ }
424
+ case "RegisterHunsuOrigin":
425
+ case "PauseLine":
426
+ case "ResumeLine":
427
+ case "AcceptLine":
428
+ case "RejectLine":
429
+ case "ClaimDestination":
430
+ case "StartDestinationWork":
431
+ case "ReportDestinationBlocked":
432
+ case "DiscardSkillDraft":
433
+ return ok(undefined);
434
+ }
435
+ }
436
+
437
+ function firstStateValidationError<T extends ValidatedCommand["type"]>(
438
+ validations: Result<unknown, DomainWorkflowError>[],
439
+ command: Extract<ValidatedCommand, { type: T }>
440
+ ): Result<Extract<ValidatedCommand, { type: T }>, DomainWorkflowError> {
441
+ const failed = validations.find(validation => !validation.ok);
442
+ if (failed && !failed.ok) {
443
+ return err(failed.error);
444
+ }
445
+ return ok(command);
446
+ }
447
+
448
+ function hunsuRecorded(
449
+ id: HunsuId,
450
+ lineId: LineId,
451
+ fromNodeId: NodeId,
452
+ toNodeId: NodeId,
453
+ target: HunsuRecord["target"],
454
+ summary: HunsuRecord["summary"],
455
+ teamSnapshot: HunsuRecord["teamSnapshot"],
456
+ changedFiles: HunsuRecord["changedFiles"],
457
+ at?: string,
458
+ metadata: Pick<HunsuRecord, "hunsuDraftId" | "conversationRef" | "newLineId" | "sourceMoveId" | "newTeamName"> = {}
459
+ ): DomainEvent {
460
+ assertNonEmpty(changedFiles, "A HUNSU must include at least one changed runtime file");
461
+ return {
462
+ type: "HunsuRecorded",
463
+ hunsu: { id, ...metadata, lineId, fromNodeId, toNodeId, target, summary, teamSnapshot, changedFiles, recordedBy: "DIRECTOR", recordedAt: at }
464
+ };
465
+ }
466
+
467
+ function requireModelTransition<T, U>(value: T, transition: Result<U, DomainModelError>): Result<T, DomainWorkflowError> {
468
+ return transition.ok ? ok(value) : workflowError(transition.error.message);
469
+ }
470
+
471
+ function validateRequestIdAvailable(state: BoardProjection, requestId: RequestId): Result<void, DomainWorkflowError> {
472
+ return state.requests.some(request => request.id === requestId)
473
+ ? workflowError(`Duplicate RequestId: ${requestId}`)
474
+ : ok(undefined);
475
+ }
476
+
477
+ function validateLineIdAvailable(state: BoardProjection, lineId: LineId): Result<void, DomainWorkflowError> {
478
+ return state.lines.some(line => line.id === lineId)
479
+ ? workflowError(`Duplicate LineId: ${lineId}`)
480
+ : ok(undefined);
481
+ }
482
+
483
+ function validateMoveIdAvailable(state: BoardProjection, moveId: MoveId): Result<void, DomainWorkflowError> {
484
+ return state.moves.some(move => move.id === moveId)
485
+ ? workflowError(`Duplicate MoveId: ${moveId}`)
486
+ : ok(undefined);
487
+ }
488
+
489
+ function validateHunsuIdAvailable(state: BoardProjection, hunsuId: HunsuId): Result<void, DomainWorkflowError> {
490
+ return state.hunsus.some(hunsu => hunsu.id === hunsuId)
491
+ ? workflowError(`Duplicate HunsuId: ${hunsuId}`)
492
+ : ok(undefined);
493
+ }
494
+
495
+ function validateSkillDraftIdAvailable(state: BoardProjection, draftId: string): Result<void, DomainWorkflowError> {
496
+ return state.skillDrafts.some(draft => draft.id === draftId)
497
+ ? workflowError(`Duplicate SkillDraftId: ${draftId}`)
498
+ : ok(undefined);
499
+ }
500
+
501
+ function validateArtifactIdAvailable(state: BoardProjection, artifactId: string): Result<void, DomainWorkflowError> {
502
+ return state.artifacts.some(artifact => artifact.id === artifactId)
503
+ ? workflowError(`Duplicate ArtifactId: ${artifactId}`)
504
+ : ok(undefined);
505
+ }
506
+
507
+ function validateDestinationIdsAvailable(state: BoardProjection, destinationIds: DestinationId[]): Result<void, DomainWorkflowError> {
508
+ const seen = new Set<string>();
509
+ const existing = destinationIdsInBoard(state);
510
+ for (const destinationId of destinationIds) {
511
+ if (seen.has(destinationId)) {
512
+ return workflowError(`Duplicate DestinationId in command: ${destinationId}`);
513
+ }
514
+ if (existing.has(destinationId)) {
515
+ return workflowError(`Duplicate DestinationId: ${destinationId}`);
516
+ }
517
+ seen.add(destinationId);
518
+ }
519
+ return ok(undefined);
520
+ }
521
+
522
+ function destinationIdsInBoard(state: BoardProjection): Set<string> {
523
+ return new Set([
524
+ ...state.destinations.map(destination => destination.id),
525
+ ...state.nodes.flatMap(node => node.destinations.map(destination => destination.id))
526
+ ]);
527
+ }
528
+
529
+ function validateRequestExists(state: BoardProjection, requestId: RequestId): Result<void, DomainWorkflowError> {
530
+ return state.requests.some(request => request.id === requestId) ? ok(undefined) : workflowError(`Unknown request: ${requestId}`);
531
+ }
532
+
533
+ function validateLineExists(state: BoardProjection, lineId: LineId): Result<LineRecord, DomainWorkflowError> {
534
+ return lineResult(state, lineId);
535
+ }
536
+
537
+ function validatePausableLine(state: BoardProjection, lineId: LineId): Result<LineRecord, DomainWorkflowError> {
538
+ const line = lineResult(state, lineId);
539
+ return line.ok ? requireModelTransition(line.value, pauseLine(line.value)) : line;
540
+ }
541
+
542
+ function validateResumableLine(state: BoardProjection, lineId: LineId): Result<LineRecord, DomainWorkflowError> {
543
+ const line = lineResult(state, lineId);
544
+ return line.ok ? requireModelTransition(line.value, resumeLine(line.value)) : line;
545
+ }
546
+
547
+ function validateCompletableLine(state: BoardProjection, lineId: LineId): Result<LineRecord, DomainWorkflowError> {
548
+ const line = lineResult(state, lineId);
549
+ return line.ok ? requireModelTransition(line.value, completeLine(line.value)) : line;
550
+ }
551
+
552
+ function validateAbandonableLine(state: BoardProjection, lineId: LineId): Result<LineRecord, DomainWorkflowError> {
553
+ const line = lineResult(state, lineId);
554
+ return line.ok ? requireModelTransition(line.value, abandonLine(line.value)) : line;
555
+ }
556
+
557
+ function validateRootNodeExists(state: BoardProjection, requestId: RequestId): Result<NodeRecord, DomainWorkflowError> {
558
+ return nodeResult(state, rootNodeId(requestId));
559
+ }
560
+
561
+ function validateMoveExists(state: BoardProjection, moveId: MoveId): Result<MoveRecord, DomainWorkflowError> {
562
+ return moveResult(state, moveId);
563
+ }
564
+
565
+ function validateHunsuExists(state: BoardProjection, hunsuId: HunsuId): Result<HunsuRecord, DomainWorkflowError> {
566
+ return hunsuResult(state, hunsuId);
567
+ }
568
+
569
+ function validateExistingDestination(state: BoardProjection, destinationId: DestinationId): Result<Destination, DomainWorkflowError> {
570
+ return destinationResult(state, destinationId);
571
+ }
572
+
573
+ function validateStartableDestination(state: BoardProjection, destinationId: DestinationId): Result<Destination, DomainWorkflowError> {
574
+ const destination = destinationResult(state, destinationId);
575
+ return destination.ok
576
+ ? requireModelTransition(destination.value, startDestinationWork(destination.value, { claimedBy: "validation", updatedBy: "TEAM" }))
577
+ : destination;
578
+ }
579
+
580
+ function validateCurrentNodeForLine(state: BoardProjection, lineId: LineId): Result<NodeRecord, DomainWorkflowError> {
581
+ return currentNodeForLineResult(state, lineId);
582
+ }
583
+
584
+ function validatePlayableLineForCommand(state: BoardProjection, lineId: LineId): Result<LineRecord, DomainWorkflowError> {
585
+ const line = lineResult(state, lineId);
586
+ if (!line.ok) {
587
+ return line;
588
+ }
589
+ return requireModelTransition(line.value, makePlayableLine(line.value));
590
+ }
591
+
592
+ function validateClaimableDestination(state: BoardProjection, destinationId: DestinationId): Result<Destination, DomainWorkflowError> {
593
+ const destination = destinationResult(state, destinationId);
594
+ if (!destination.ok) {
595
+ return destination;
596
+ }
597
+ return requireModelTransition(destination.value, claimDestination(destination.value, { claimedBy: "validation", updatedBy: "TEAM" }));
598
+ }
599
+
600
+ function validateBlockableDestination(state: BoardProjection, destinationId: DestinationId): Result<Destination, DomainWorkflowError> {
601
+ const destination = destinationResult(state, destinationId);
602
+ if (!destination.ok) {
603
+ return destination;
604
+ }
605
+ return requireModelTransition(destination.value, blockDestination(destination.value, { blockedReason: "validation", updatedBy: "TEAM" }));
606
+ }
607
+
608
+ function validateDestinationInCurrentNode(state: BoardProjection, lineId: LineId, destinationId: DestinationId): Result<Destination, DomainWorkflowError> {
609
+ const node = currentNodeForLineResult(state, lineId);
610
+ return node.ok ? destinationInNodeResult(node.value, destinationId) : node;
611
+ }
612
+
613
+ function validateReachableDestinationsForCurrentNode(state: BoardProjection, lineId: LineId, moveId: MoveId, destinationIds: DestinationId[]): Result<unknown, DomainWorkflowError>[] {
614
+ const node = currentNodeForLineResult(state, lineId);
615
+ if (!node.ok) {
616
+ return [node];
617
+ }
618
+ if (destinationIds.length !== 1) {
619
+ return [workflowError("MOVE must reach exactly one Destination")];
620
+ }
621
+ return destinationIds.map(destinationId => validateReachableDestinationInNode(node.value, destinationId, moveId));
622
+ }
623
+
624
+ function validateReachableDestinationInNode(node: NodeRecord, destinationId: DestinationId, moveId: MoveId): Result<Destination, DomainWorkflowError> {
625
+ const destination = destinationInNodeResult(node, destinationId);
626
+ if (!destination.ok) {
627
+ return destination;
628
+ }
629
+ return requireModelTransition(destination.value, reachDestination(destination.value, { reachedByMoveId: moveId, updatedBy: "TEAM" }));
630
+ }
631
+
632
+ function validateArtifactOwnerExists(state: BoardProjection, artifact: ArtifactRecord): Result<unknown, DomainWorkflowError> {
633
+ switch (artifact.owner.type) {
634
+ case "move":
635
+ return validateMoveExists(state, artifact.owner.id);
636
+ case "hunsu":
637
+ return validateHunsuExists(state, artifact.owner.id);
638
+ case "line":
639
+ return validateLineExists(state, artifact.owner.id);
640
+ }
641
+ }
642
+
643
+ function validateArtifactAttachedToMove(artifact: ArtifactRecord, moveId: MoveId): Result<void, DomainWorkflowError> {
644
+ return artifact.owner.type !== "move" || artifact.owner.id !== moveId
645
+ ? workflowError(`AttachMoveEvidence artifact owner must be move ${moveId}`)
646
+ : ok(undefined);
647
+ }
648
+
649
+ function validateNodeBelongsToExactLine(state: BoardProjection, nodeId: NodeId, lineId: LineId): Result<NodeRecord, DomainWorkflowError> {
650
+ const line = lineResult(state, lineId);
651
+ if (!line.ok) {
652
+ return line;
653
+ }
654
+ const node = nodeResult(state, nodeId);
655
+ if (!node.ok) {
656
+ return node;
657
+ }
658
+ return !line.value.nodeIds.includes(node.value.id)
659
+ ? workflowError(`Node ${node.value.id} does not belong to line ${line.value.id}`)
660
+ : node;
661
+ }
662
+
663
+ function validateMoveBelongsToLine(state: BoardProjection, moveId: MoveId, lineId: LineId): Result<MoveRecord, DomainWorkflowError> {
664
+ const line = lineResult(state, lineId);
665
+ if (!line.ok) {
666
+ return line;
667
+ }
668
+ const move = moveResult(state, moveId);
669
+ if (!move.ok) {
670
+ return move;
671
+ }
672
+ return move.value.lineId !== line.value.id && !line.value.moveIds.includes(move.value.id)
673
+ ? workflowError(`MOVE ${move.value.id} does not belong to line ${line.value.id}`)
674
+ : move;
675
+ }
676
+
677
+ function validateHunsuTargetBelongsToLine(
678
+ state: BoardProjection,
679
+ lineId: LineId,
680
+ target: HunsuRecord["target"]
681
+ ): Result<unknown, DomainWorkflowError> {
682
+ switch (target.type) {
683
+ case "line": {
684
+ const line = lineResult(state, lineId);
685
+ if (!line.ok) {
686
+ return line;
687
+ }
688
+ const targetLine = lineResult(state, target.id);
689
+ if (!targetLine.ok) {
690
+ return targetLine;
691
+ }
692
+ return targetLine.value.id === line.value.id
693
+ ? ok(line.value)
694
+ : workflowError(`HUNSU target line ${targetLine.value.id} does not match command line ${line.value.id}`);
695
+ }
696
+ case "node":
697
+ return validateNodeBelongsToExactLine(state, target.id, lineId);
698
+ case "move":
699
+ return validateMoveBelongsToLine(state, target.id, lineId);
700
+ case "destination":
701
+ return validateDestinationInCurrentNode(state, lineId, target.id);
702
+ }
703
+ }
704
+
705
+ function validateDraftSkillDraft(state: BoardProjection, draftId: string, action: "accepted" | "discarded"): Result<DraftSkillDraft, DomainWorkflowError> {
706
+ const draft = skillDraftResult(state, draftId);
707
+ if (!draft.ok) {
708
+ return draft;
709
+ }
710
+ return draft.value.status !== "draft"
711
+ ? workflowError(`Skill Draft ${draftId} cannot be ${action} from status ${draft.value.status}`)
712
+ : ok(draft.value);
713
+ }
714
+
715
+ function lineResult(state: BoardProjection, lineId: string): Result<LineRecord, DomainWorkflowError> {
716
+ const line = state.lines.find(candidate => candidate.id === lineId);
717
+ return line ? ok(line) : workflowError(`Unknown line: ${lineId}`);
718
+ }
719
+
720
+ function nodeResult(state: BoardProjection, nodeId: NodeId): Result<NodeRecord, DomainWorkflowError> {
721
+ const node = state.nodes.find(candidate => candidate.id === nodeId);
722
+ return node ? ok(node) : workflowError(`Unknown node: ${nodeId}`);
723
+ }
724
+
725
+ function currentNodeForLineResult(state: BoardProjection, lineId: LineId): Result<NodeRecord, DomainWorkflowError> {
726
+ const line = lineResult(state, lineId);
727
+ return line.ok ? nodeResult(state, line.value.currentNodeId) : line;
728
+ }
729
+
730
+ function moveResult(state: BoardProjection, moveId: string): Result<MoveRecord, DomainWorkflowError> {
731
+ const move = state.moves.find(candidate => candidate.id === moveId);
732
+ return move ? ok(move) : workflowError(`Unknown MOVE: ${moveId}`);
733
+ }
734
+
735
+ function hunsuResult(state: BoardProjection, hunsuId: string): Result<HunsuRecord, DomainWorkflowError> {
736
+ const hunsu = state.hunsus.find(candidate => candidate.id === hunsuId);
737
+ return hunsu ? ok(hunsu) : workflowError(`Unknown HUNSU: ${hunsuId}`);
738
+ }
739
+
740
+ function skillDraftResult(state: BoardProjection, draftId: string): Result<SkillDraftRecord, DomainWorkflowError> {
741
+ const draft = state.skillDrafts.find(candidate => candidate.id === draftId);
742
+ return draft ? ok(draft) : workflowError(`Unknown Skill Draft: ${draftId}`);
743
+ }
744
+
745
+ function destinationResult(state: BoardProjection, destinationId: DestinationId): Result<Destination, DomainWorkflowError> {
746
+ const destination = state.destinations.find(candidate => candidate.id === destinationId);
747
+ return destination ? ok(destination) : workflowError(`Unknown Destination: ${destinationId}`);
748
+ }
749
+
750
+ function destinationInNodeResult(node: NodeRecord, destinationId: DestinationId): Result<Destination, DomainWorkflowError> {
751
+ const destination = node.destinations.find(candidate => candidate.id === destinationId);
752
+ return destination ? ok(destination) : workflowError(`Unknown Destination in node ${node.id}: ${destinationId}`);
753
+ }