omk-agent-core 0.98.2 → 0.98.3

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 (61) hide show
  1. package/CHANGELOG.md +644 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +3 -5
  4. package/dist/agent.js.map +1 -1
  5. package/dist/effects/effect-journal.d.ts +43 -0
  6. package/dist/effects/effect-journal.d.ts.map +1 -0
  7. package/dist/effects/effect-journal.js +186 -0
  8. package/dist/effects/effect-journal.js.map +1 -0
  9. package/dist/effects/effect-recovery.d.ts +70 -0
  10. package/dist/effects/effect-recovery.d.ts.map +1 -0
  11. package/dist/effects/effect-recovery.js +120 -0
  12. package/dist/effects/effect-recovery.js.map +1 -0
  13. package/dist/effects/effect-transitions.d.ts +34 -0
  14. package/dist/effects/effect-transitions.d.ts.map +1 -0
  15. package/dist/effects/effect-transitions.js +148 -0
  16. package/dist/effects/effect-transitions.js.map +1 -0
  17. package/dist/effects/effect-types.d.ts +135 -0
  18. package/dist/effects/effect-types.d.ts.map +1 -0
  19. package/dist/effects/effect-types.js +32 -0
  20. package/dist/effects/effect-types.js.map +1 -0
  21. package/dist/harness/abort-delivery.d.ts +26 -0
  22. package/dist/harness/abort-delivery.d.ts.map +1 -0
  23. package/dist/harness/abort-delivery.js +36 -0
  24. package/dist/harness/abort-delivery.js.map +1 -0
  25. package/dist/harness/agent-harness.d.ts +18 -0
  26. package/dist/harness/agent-harness.d.ts.map +1 -1
  27. package/dist/harness/agent-harness.js +37 -28
  28. package/dist/harness/agent-harness.js.map +1 -1
  29. package/dist/harness/canonical-digest.d.ts +32 -0
  30. package/dist/harness/canonical-digest.d.ts.map +1 -0
  31. package/dist/harness/canonical-digest.js +164 -0
  32. package/dist/harness/canonical-digest.js.map +1 -0
  33. package/dist/harness/deferred-commands.d.ts +53 -0
  34. package/dist/harness/deferred-commands.d.ts.map +1 -0
  35. package/dist/harness/deferred-commands.js +96 -0
  36. package/dist/harness/deferred-commands.js.map +1 -0
  37. package/dist/harness/operation-outcome.d.ts +18 -10
  38. package/dist/harness/operation-outcome.d.ts.map +1 -1
  39. package/dist/harness/operation-outcome.js +71 -35
  40. package/dist/harness/operation-outcome.js.map +1 -1
  41. package/dist/harness/operation-trace-divergence.d.ts +60 -0
  42. package/dist/harness/operation-trace-divergence.d.ts.map +1 -0
  43. package/dist/harness/operation-trace-divergence.js +199 -0
  44. package/dist/harness/operation-trace-divergence.js.map +1 -0
  45. package/dist/harness/operation-trace.d.ts +134 -0
  46. package/dist/harness/operation-trace.d.ts.map +1 -0
  47. package/dist/harness/operation-trace.js +161 -0
  48. package/dist/harness/operation-trace.js.map +1 -0
  49. package/dist/harness/subscriber-fanout.d.ts +14 -1
  50. package/dist/harness/subscriber-fanout.d.ts.map +1 -1
  51. package/dist/harness/subscriber-fanout.js +25 -6
  52. package/dist/harness/subscriber-fanout.js.map +1 -1
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +2 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/listener-delivery.d.ts +22 -0
  58. package/dist/listener-delivery.d.ts.map +1 -0
  59. package/dist/listener-delivery.js +36 -0
  60. package/dist/listener-delivery.js.map +1 -0
  61. package/package.json +4 -3
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effect-journal.js","sourceRoot":"","sources":["../../src/effects/effect-journal.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Hash-chained effect journal reducer.\n *\n * Two entry points, one invariant. `reduceEffectJournal` is the write path:\n * it turns a command into the next record — sequence, previous hash, and\n * record hash all derived, never supplied — and refuses illegal moves.\n * `appendEffectRecord` is the read/replay path: it re-verifies a persisted\n * record against the same rules plus the chain, so a journal that was\n * tampered with, truncated in the middle, or produced by a reducer with a\n * different transition table fails to replay instead of silently loading.\n *\n * The state is plain data: the head hash, the last sequence, and the latest\n * record per effect. It is a pure fold over the record list, so recovery\n * cost is linear in journal length and replaying twice yields equal states.\n */\n\nimport { canonicalDigest, domainDigest } from \"../harness/canonical-digest.ts\";\nimport { legalNextPhases, reduceEffectPhase } from \"./effect-transitions.ts\";\nimport {\n\tEFFECT_JOURNAL_GENESIS_HASH,\n\tEFFECT_RECORD_SCHEMA_VERSION,\n\ttype EffectCommand,\n\ttype EffectIdentity,\n\ttype EffectIntent,\n\ttype EffectJournalResult,\n\ttype EffectJournalState,\n\tEffectJournalViolation,\n\ttype EffectRecord,\n\ttype EffectSemantics,\n} from \"./effect-types.ts\";\n\nexport const EFFECT_ID_DOMAIN = \"omk.effect.v2\";\nexport const EFFECT_RECORD_DOMAIN = \"omk.effect.record.v2\";\n\nconst SEMANTICS: readonly EffectSemantics[] = [\"pure\", \"idempotent\", \"inspectable\", \"compensatable\", \"opaque\"];\n\nexport interface EffectIdInput {\n\treadonly operationId: string;\n\t/** Logical attempt index of the action. A provider retry that keeps the same logical action keeps this. */\n\treadonly attemptLogicalIndex: number;\n\treadonly toolCallId: string;\n\treadonly intentDigest: string;\n\t/** Explicit resume token; without one, a different logical index yields a different effect. */\n\treadonly resumeToken?: string;\n}\n\n/** Stable effect identity per plan §6.5. Two logically distinct actions never share an id. */\nexport function deriveEffectId(input: EffectIdInput): string {\n\tconst attemptPart =\n\t\tinput.resumeToken === undefined ? `index:${input.attemptLogicalIndex}` : `resume:${input.resumeToken}`;\n\treturn domainDigest(EFFECT_ID_DOMAIN, [input.operationId, attemptPart, input.toolCallId, input.intentDigest]);\n}\n\nexport function initialEffectJournalState(): EffectJournalState {\n\treturn { headHash: EFFECT_JOURNAL_GENESIS_HASH, lastSequence: 0, effects: {} };\n}\n\nexport function computeEffectRecordHash(record: Omit<EffectRecord, \"recordHash\">): string {\n\treturn canonicalDigest({ domain: EFFECT_RECORD_DOMAIN, record });\n}\n\nexport function lookupEffect(state: EffectJournalState, effectId: string): EffectRecord | undefined {\n\treturn Object.hasOwn(state.effects, effectId) ? state.effects[effectId] : undefined;\n}\n\nfunction fail<T>(code: EffectJournalViolation[\"code\"], message: string, effectId?: string): EffectJournalResult<T> {\n\treturn { ok: false, error: new EffectJournalViolation(code, message, effectId) };\n}\n\nfunction nonEmpty(value: unknown): value is string {\n\treturn typeof value === \"string\" && value.trim().length > 0;\n}\n\nfunction validatePrepare(identity: EffectIdentity, intent: EffectIntent, timestamp: string): string | undefined {\n\tfor (const [name, value] of [\n\t\t[\"effectId\", identity.effectId],\n\t\t[\"operationId\", identity.operationId],\n\t\t[\"attemptId\", identity.attemptId],\n\t\t[\"processIncarnation\", identity.processIncarnation],\n\t\t[\"capabilityDigest\", intent.capabilityDigest],\n\t\t[\"intentDigest\", intent.intentDigest],\n\t\t[\"timestamp\", timestamp],\n\t] as const) {\n\t\tif (!nonEmpty(value)) return `${name} must be a non-empty string`;\n\t}\n\tif (!SEMANTICS.includes(intent.semantics)) return `unknown semantics ${String(intent.semantics)}`;\n\tif (intent.semantics === \"idempotent\" && !nonEmpty(intent.idempotencyKey)) {\n\t\treturn \"idempotent effects require an idempotencyKey\";\n\t}\n\tif (intent.semantics === \"compensatable\" && intent.compensationDescriptor === undefined) {\n\t\treturn \"compensatable effects require a compensationDescriptor\";\n\t}\n\tif (intent.semantics === \"inspectable\" && intent.inspectDescriptor === undefined) {\n\t\treturn \"inspectable effects require an inspectDescriptor\";\n\t}\n\tif (identity.laneEpoch !== undefined && (!Number.isInteger(identity.laneEpoch) || identity.laneEpoch < 0)) {\n\t\treturn \"laneEpoch must be a non-negative integer\";\n\t}\n\treturn undefined;\n}\n\nfunction seal(state: EffectJournalState, body: Omit<EffectRecord, \"recordHash\" | \"sequence\" | \"previousRecordHash\">) {\n\tconst unsealed = { ...body, sequence: state.lastSequence + 1, previousRecordHash: state.headHash };\n\tconst record: EffectRecord = Object.freeze({ ...unsealed, recordHash: computeEffectRecordHash(unsealed) });\n\tconst next: EffectJournalState = Object.freeze({\n\t\theadHash: record.recordHash,\n\t\tlastSequence: record.sequence,\n\t\teffects: Object.freeze({ ...state.effects, [record.effectId]: record }),\n\t});\n\treturn { ok: true as const, value: { state: next, record } };\n}\n\nexport interface EffectJournalStep {\n\treadonly state: EffectJournalState;\n\treadonly record: EffectRecord;\n}\n\n/** Write path: derive and chain the next record for `command`, or reject it. */\nexport function reduceEffectJournal(\n\tstate: EffectJournalState,\n\tcommand: EffectCommand,\n): EffectJournalResult<EffectJournalStep> {\n\tif (command.type === \"prepare\") {\n\t\tconst problem = validatePrepare(command.identity, command.intent, command.timestamp);\n\t\tif (problem !== undefined) return fail(\"invalid_record\", problem, command.identity.effectId);\n\t\tif (lookupEffect(state, command.identity.effectId) !== undefined) {\n\t\t\treturn fail(\n\t\t\t\t\"duplicate_effect\",\n\t\t\t\t`Effect ${command.identity.effectId} is already journaled`,\n\t\t\t\tcommand.identity.effectId,\n\t\t\t);\n\t\t}\n\t\treturn seal(state, {\n\t\t\t...command.identity,\n\t\t\t...command.intent,\n\t\t\tschemaVersion: EFFECT_RECORD_SCHEMA_VERSION,\n\t\t\tphase: \"prepared\",\n\t\t\ttimestamp: command.timestamp,\n\t\t\treasonCode: \"effect.prepared\",\n\t\t});\n\t}\n\tconst latest = lookupEffect(state, command.effectId);\n\tif (latest === undefined)\n\t\treturn fail(\"unknown_effect\", `Effect ${command.effectId} is not journaled`, command.effectId);\n\tif (!nonEmpty(command.timestamp))\n\t\treturn fail(\"invalid_record\", \"timestamp must be a non-empty string\", command.effectId);\n\tconst moved = reduceEffectPhase(latest, command);\n\tif (!moved.ok) return moved;\n\tconst { recordHash: _hash, sequence: _sequence, previousRecordHash: _previous, ...carried } = latest;\n\treturn seal(state, {\n\t\t...carried,\n\t\tphase: moved.value.phase,\n\t\ttimestamp: command.timestamp,\n\t\treasonCode: moved.value.reasonCode,\n\t});\n}\n\nconst IDENTITY_FIELDS = [\n\t\"operationId\",\n\t\"attemptId\",\n\t\"laneId\",\n\t\"laneEpoch\",\n\t\"processIncarnation\",\n\t\"semantics\",\n\t\"capabilityDigest\",\n\t\"intentDigest\",\n\t\"idempotencyKey\",\n] as const;\n\n/** Replay path: verify a persisted record against the chain and the transition table, then apply it. */\nexport function appendEffectRecord(\n\tstate: EffectJournalState,\n\trecord: EffectRecord,\n): EffectJournalResult<EffectJournalState> {\n\tconst id = record.effectId;\n\tif (record.schemaVersion !== EFFECT_RECORD_SCHEMA_VERSION) {\n\t\treturn fail(\"invalid_record\", `Unsupported effect record schema ${String(record.schemaVersion)}`, id);\n\t}\n\tif (record.sequence !== state.lastSequence + 1) {\n\t\treturn fail(\"sequence_violation\", `Record sequence ${record.sequence} is not ${state.lastSequence + 1}`, id);\n\t}\n\tif (record.previousRecordHash !== state.headHash) {\n\t\treturn fail(\n\t\t\t\"chain_break\",\n\t\t\t`Record ${record.sequence} chains to ${record.previousRecordHash}, head is ${state.headHash}`,\n\t\t\tid,\n\t\t);\n\t}\n\tconst { recordHash, ...body } = record;\n\tif (computeEffectRecordHash(body) !== recordHash) {\n\t\treturn fail(\"hash_mismatch\", `Record ${record.sequence} hash does not match its contents`, id);\n\t}\n\tconst latest = lookupEffect(state, id);\n\tif (latest === undefined) {\n\t\tconst problem = validatePrepare(record, record, record.timestamp);\n\t\tif (problem !== undefined) return fail(\"invalid_record\", problem, id);\n\t\tif (record.phase !== \"prepared\") return fail(\"invalid_record\", `First record for ${id} must be prepared`, id);\n\t} else {\n\t\tfor (const field of IDENTITY_FIELDS) {\n\t\t\tif (latest[field] !== record[field]) {\n\t\t\t\treturn fail(\"identity_mismatch\", `Record ${record.sequence} changes ${field} of effect ${id}`, id);\n\t\t\t}\n\t\t}\n\t\tif (!legalNextPhases(latest).includes(record.phase)) {\n\t\t\treturn fail(\"invalid_transition\", `Effect ${id} cannot move ${latest.phase} -> ${record.phase}`, id);\n\t\t}\n\t\tif (record.phase === \"abandoned\" && !nonEmpty(record.reasonCode)) {\n\t\t\treturn fail(\"invalid_record\", `Abandoned record for ${id} carries no reason code`, id);\n\t\t}\n\t}\n\tconst frozen = Object.freeze({ ...record });\n\treturn {\n\t\tok: true,\n\t\tvalue: Object.freeze({\n\t\t\theadHash: frozen.recordHash,\n\t\t\tlastSequence: frozen.sequence,\n\t\t\teffects: Object.freeze({ ...state.effects, [id]: frozen }),\n\t\t}),\n\t};\n}\n\n/** Fold a persisted record list into journal state; the first bad record stops the replay. */\nexport function replayEffectJournal(records: readonly EffectRecord[]): EffectJournalResult<EffectJournalState> {\n\tlet state = initialEffectJournalState();\n\tfor (const [index, record] of records.entries()) {\n\t\tconst next = appendEffectRecord(state, record);\n\t\tif (!next.ok) {\n\t\t\tconst error = new EffectJournalViolation(\n\t\t\t\tnext.error.code,\n\t\t\t\t`Journal record ${index}: ${next.error.message}`,\n\t\t\t\tnext.error.effectId,\n\t\t\t);\n\t\t\treturn { ok: false, error };\n\t\t}\n\t\tstate = next.value;\n\t}\n\treturn { ok: true, value: state };\n}\n"]}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Crash recovery over the effect journal: the uncertainty frontier and the
3
+ * per-effect recovery decision.
4
+ *
5
+ * After a restart the journal is replayed and every effect sits in some
6
+ * phase. This module answers two questions without touching the world:
7
+ *
8
+ * - Which effects are still uncertain? (`computeUncertaintyFrontier`) — the
9
+ * set that must be empty before an operation may be called verified.
10
+ * - What is the one safe next step for each effect? (`decideEffectRecovery`)
11
+ * — a deterministic function of the record and an optional inspection
12
+ * result, so the same journal always produces the same recovery plan.
13
+ *
14
+ * The decision table is conservative by construction: only `pure` and
15
+ * `idempotent` effects are ever replayed blind, `inspectable` effects are
16
+ * inspected first, `compensatable` effects are compensated, and `opaque`
17
+ * effects with an unknown commit always stop at the operator.
18
+ */
19
+ import { type EffectJournalState, type EffectRecord, type EffectSemantics } from "./effect-types.ts";
20
+ export interface EffectInspectionResult {
21
+ readonly effectId: string;
22
+ readonly outcome: "committed" | "not_committed" | "unknown";
23
+ }
24
+ export type RecoveryDecision = {
25
+ readonly action: "replay";
26
+ readonly reasonCode: string;
27
+ } | {
28
+ readonly action: "acknowledge";
29
+ readonly reasonCode: string;
30
+ } | {
31
+ readonly action: "inspect";
32
+ readonly reasonCode: string;
33
+ } | {
34
+ readonly action: "resolve";
35
+ readonly inspection: "committed" | "not_committed";
36
+ readonly reasonCode: string;
37
+ } | {
38
+ readonly action: "compensate";
39
+ readonly reasonCode: string;
40
+ } | {
41
+ readonly action: "mark_interrupted";
42
+ readonly reasonCode: string;
43
+ } | {
44
+ readonly action: "require_operator";
45
+ readonly reasonCode: string;
46
+ };
47
+ /** One safe next step for `record`. Pure; an `inspection` for another effect is ignored. */
48
+ export declare function decideEffectRecovery(record: EffectRecord, inspection?: EffectInspectionResult): RecoveryDecision;
49
+ export interface EffectUncertaintyFrontier {
50
+ readonly operationId: string;
51
+ readonly effectIds: readonly string[];
52
+ readonly countBySemantics: Readonly<Record<EffectSemantics, number>>;
53
+ }
54
+ /** Effects of `operationId` whose external outcome is unknown, in deterministic id order. */
55
+ export declare function computeUncertaintyFrontier(state: EffectJournalState, operationId: string): EffectUncertaintyFrontier;
56
+ /** A verified verdict requires an empty frontier; anything else is at most inconclusive. */
57
+ export declare function frontierBlocksVerified(frontier: EffectUncertaintyFrontier): boolean;
58
+ export interface EffectRecoveryPlanEntry {
59
+ readonly effectId: string;
60
+ readonly phase: EffectRecord["phase"];
61
+ readonly semantics: EffectSemantics;
62
+ readonly decision: RecoveryDecision;
63
+ }
64
+ /**
65
+ * Recovery decisions for every non-terminal effect of an operation, in id
66
+ * order. Terminal effects are omitted: they need no action, and listing them
67
+ * would bury the frontier in noise.
68
+ */
69
+ export declare function planEffectRecovery(state: EffectJournalState, operationId: string, inspections?: readonly EffectInspectionResult[]): readonly EffectRecoveryPlanEntry[];
70
+ //# sourceMappingURL=effect-recovery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effect-recovery.d.ts","sourceRoot":"","sources":["../../src/effects/effect-recovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAGH,OAAO,EACN,KAAK,kBAAkB,EACvB,KAAK,YAAY,EACjB,KAAK,eAAe,EAEpB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,WAAW,GAAG,eAAe,GAAG,SAAS,CAAC;CAC5D;AAED,MAAM,MAAM,gBAAgB,GACzB;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC/D;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC3D;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,WAAW,GAAG,eAAe,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC/G;IAAE,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC9D;IAAE,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACpE;IAAE,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AA0BxE,4FAA4F;AAC5F,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,UAAU,CAAC,EAAE,sBAAsB,GAAG,gBAAgB,CAyBhH;AAED,MAAM,WAAW,yBAAyB;IACzC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC,CAAC;CACrE;AAOD,6FAA6F;AAC7F,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,GAAG,yBAAyB,CAgBpH;AAED,4FAA4F;AAC5F,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,yBAAyB,GAAG,OAAO,CAEnF;AAED,MAAM,WAAW,uBAAuB;IACvC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC;IACtC,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC;IACpC,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;CACpC;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CACjC,KAAK,EAAE,kBAAkB,EACzB,WAAW,EAAE,MAAM,EACnB,WAAW,GAAE,SAAS,sBAAsB,EAAO,GACjD,SAAS,uBAAuB,EAAE,CAUpC","sourcesContent":["/**\n * Crash recovery over the effect journal: the uncertainty frontier and the\n * per-effect recovery decision.\n *\n * After a restart the journal is replayed and every effect sits in some\n * phase. This module answers two questions without touching the world:\n *\n * - Which effects are still uncertain? (`computeUncertaintyFrontier`) — the\n * set that must be empty before an operation may be called verified.\n * - What is the one safe next step for each effect? (`decideEffectRecovery`)\n * — a deterministic function of the record and an optional inspection\n * result, so the same journal always produces the same recovery plan.\n *\n * The decision table is conservative by construction: only `pure` and\n * `idempotent` effects are ever replayed blind, `inspectable` effects are\n * inspected first, `compensatable` effects are compensated, and `opaque`\n * effects with an unknown commit always stop at the operator.\n */\n\nimport { isReplaySafe } from \"./effect-transitions.ts\";\nimport {\n\ttype EffectJournalState,\n\ttype EffectRecord,\n\ttype EffectSemantics,\n\tUNCERTAIN_EFFECT_PHASES,\n} from \"./effect-types.ts\";\n\nexport interface EffectInspectionResult {\n\treadonly effectId: string;\n\treadonly outcome: \"committed\" | \"not_committed\" | \"unknown\";\n}\n\nexport type RecoveryDecision =\n\t| { readonly action: \"replay\"; readonly reasonCode: string }\n\t| { readonly action: \"acknowledge\"; readonly reasonCode: string }\n\t| { readonly action: \"inspect\"; readonly reasonCode: string }\n\t| { readonly action: \"resolve\"; readonly inspection: \"committed\" | \"not_committed\"; readonly reasonCode: string }\n\t| { readonly action: \"compensate\"; readonly reasonCode: string }\n\t| { readonly action: \"mark_interrupted\"; readonly reasonCode: string }\n\t| { readonly action: \"require_operator\"; readonly reasonCode: string };\n\nfunction decideFromInspection(record: EffectRecord, inspection: EffectInspectionResult): RecoveryDecision {\n\tif (inspection.outcome === \"committed\") {\n\t\treturn { action: \"resolve\", inspection: \"committed\", reasonCode: \"effect.inspection_committed\" };\n\t}\n\tif (inspection.outcome === \"not_committed\") {\n\t\treturn { action: \"resolve\", inspection: \"not_committed\", reasonCode: \"effect.inspection_not_committed\" };\n\t}\n\tif (record.compensationDescriptor !== undefined) {\n\t\treturn { action: \"compensate\", reasonCode: \"effect.inspection_unknown_compensate\" };\n\t}\n\treturn { action: \"require_operator\", reasonCode: \"effect.inspection_unknown\" };\n}\n\n/** Decision for an effect whose commit is not known (crash while dispatched, or commit_unknown). */\nfunction decideUnknownCommit(record: EffectRecord, inspection: EffectInspectionResult | undefined): RecoveryDecision {\n\tif (isReplaySafe(record.semantics)) return { action: \"replay\", reasonCode: \"effect.replay_safe\" };\n\tif (inspection !== undefined) return decideFromInspection(record, inspection);\n\tif (record.inspectDescriptor !== undefined) return { action: \"inspect\", reasonCode: \"effect.inspection_required\" };\n\tif (record.compensationDescriptor !== undefined) {\n\t\treturn { action: \"compensate\", reasonCode: \"effect.compensation_required\" };\n\t}\n\treturn { action: \"require_operator\", reasonCode: \"effect.opaque_commit_unknown\" };\n}\n\n/** One safe next step for `record`. Pure; an `inspection` for another effect is ignored. */\nexport function decideEffectRecovery(record: EffectRecord, inspection?: EffectInspectionResult): RecoveryDecision {\n\tconst scoped = inspection?.effectId === record.effectId ? inspection : undefined;\n\tswitch (record.phase) {\n\t\tcase \"acknowledged\":\n\t\t\treturn { action: \"mark_interrupted\", reasonCode: \"effect.already_acknowledged\" };\n\t\tcase \"compensated\":\n\t\t\treturn { action: \"mark_interrupted\", reasonCode: \"effect.already_compensated\" };\n\t\tcase \"abandoned\":\n\t\t\treturn { action: \"mark_interrupted\", reasonCode: \"effect.already_abandoned\" };\n\t\tcase \"prepared\":\n\t\t\treturn { action: \"replay\", reasonCode: \"effect.not_dispatched\" };\n\t\tcase \"observed_committed\":\n\t\t\treturn { action: \"acknowledge\", reasonCode: \"effect.observed_committed_unacknowledged\" };\n\t\tcase \"observed_not_committed\":\n\t\t\treturn { action: \"replay\", reasonCode: \"effect.retry_not_committed\" };\n\t\tcase \"dispatched\":\n\t\tcase \"commit_unknown\":\n\t\t\treturn decideUnknownCommit(record, scoped);\n\t\tcase \"compensating\":\n\t\t\tif (scoped !== undefined) return decideFromInspection(record, scoped);\n\t\t\tif (record.inspectDescriptor !== undefined) {\n\t\t\t\treturn { action: \"inspect\", reasonCode: \"effect.compensation_unknown_inspect\" };\n\t\t\t}\n\t\t\treturn { action: \"require_operator\", reasonCode: \"effect.compensation_unknown\" };\n\t}\n}\n\nexport interface EffectUncertaintyFrontier {\n\treadonly operationId: string;\n\treadonly effectIds: readonly string[];\n\treadonly countBySemantics: Readonly<Record<EffectSemantics, number>>;\n}\n\nfunction compareCodeUnits(left: string, right: string): number {\n\tif (left === right) return 0;\n\treturn left < right ? -1 : 1;\n}\n\n/** Effects of `operationId` whose external outcome is unknown, in deterministic id order. */\nexport function computeUncertaintyFrontier(state: EffectJournalState, operationId: string): EffectUncertaintyFrontier {\n\tconst countBySemantics: Record<EffectSemantics, number> = {\n\t\tpure: 0,\n\t\tidempotent: 0,\n\t\tinspectable: 0,\n\t\tcompensatable: 0,\n\t\topaque: 0,\n\t};\n\tconst effectIds: string[] = [];\n\tfor (const record of Object.values(state.effects)) {\n\t\tif (record.operationId !== operationId || !UNCERTAIN_EFFECT_PHASES.includes(record.phase)) continue;\n\t\teffectIds.push(record.effectId);\n\t\tcountBySemantics[record.semantics] += 1;\n\t}\n\teffectIds.sort(compareCodeUnits);\n\treturn { operationId, effectIds, countBySemantics };\n}\n\n/** A verified verdict requires an empty frontier; anything else is at most inconclusive. */\nexport function frontierBlocksVerified(frontier: EffectUncertaintyFrontier): boolean {\n\treturn frontier.effectIds.length > 0;\n}\n\nexport interface EffectRecoveryPlanEntry {\n\treadonly effectId: string;\n\treadonly phase: EffectRecord[\"phase\"];\n\treadonly semantics: EffectSemantics;\n\treadonly decision: RecoveryDecision;\n}\n\n/**\n * Recovery decisions for every non-terminal effect of an operation, in id\n * order. Terminal effects are omitted: they need no action, and listing them\n * would bury the frontier in noise.\n */\nexport function planEffectRecovery(\n\tstate: EffectJournalState,\n\toperationId: string,\n\tinspections: readonly EffectInspectionResult[] = [],\n): readonly EffectRecoveryPlanEntry[] {\n\tconst byEffect = new Map(inspections.map((inspection) => [inspection.effectId, inspection]));\n\tconst entries: EffectRecoveryPlanEntry[] = [];\n\tfor (const record of Object.values(state.effects)) {\n\t\tif (record.operationId !== operationId) continue;\n\t\tconst decision = decideEffectRecovery(record, byEffect.get(record.effectId));\n\t\tif (decision.action === \"mark_interrupted\") continue;\n\t\tentries.push({ effectId: record.effectId, phase: record.phase, semantics: record.semantics, decision });\n\t}\n\treturn entries.sort((left, right) => compareCodeUnits(left.effectId, right.effectId));\n}\n"]}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Crash recovery over the effect journal: the uncertainty frontier and the
3
+ * per-effect recovery decision.
4
+ *
5
+ * After a restart the journal is replayed and every effect sits in some
6
+ * phase. This module answers two questions without touching the world:
7
+ *
8
+ * - Which effects are still uncertain? (`computeUncertaintyFrontier`) — the
9
+ * set that must be empty before an operation may be called verified.
10
+ * - What is the one safe next step for each effect? (`decideEffectRecovery`)
11
+ * — a deterministic function of the record and an optional inspection
12
+ * result, so the same journal always produces the same recovery plan.
13
+ *
14
+ * The decision table is conservative by construction: only `pure` and
15
+ * `idempotent` effects are ever replayed blind, `inspectable` effects are
16
+ * inspected first, `compensatable` effects are compensated, and `opaque`
17
+ * effects with an unknown commit always stop at the operator.
18
+ */
19
+ import { isReplaySafe } from "./effect-transitions.js";
20
+ import { UNCERTAIN_EFFECT_PHASES, } from "./effect-types.js";
21
+ function decideFromInspection(record, inspection) {
22
+ if (inspection.outcome === "committed") {
23
+ return { action: "resolve", inspection: "committed", reasonCode: "effect.inspection_committed" };
24
+ }
25
+ if (inspection.outcome === "not_committed") {
26
+ return { action: "resolve", inspection: "not_committed", reasonCode: "effect.inspection_not_committed" };
27
+ }
28
+ if (record.compensationDescriptor !== undefined) {
29
+ return { action: "compensate", reasonCode: "effect.inspection_unknown_compensate" };
30
+ }
31
+ return { action: "require_operator", reasonCode: "effect.inspection_unknown" };
32
+ }
33
+ /** Decision for an effect whose commit is not known (crash while dispatched, or commit_unknown). */
34
+ function decideUnknownCommit(record, inspection) {
35
+ if (isReplaySafe(record.semantics))
36
+ return { action: "replay", reasonCode: "effect.replay_safe" };
37
+ if (inspection !== undefined)
38
+ return decideFromInspection(record, inspection);
39
+ if (record.inspectDescriptor !== undefined)
40
+ return { action: "inspect", reasonCode: "effect.inspection_required" };
41
+ if (record.compensationDescriptor !== undefined) {
42
+ return { action: "compensate", reasonCode: "effect.compensation_required" };
43
+ }
44
+ return { action: "require_operator", reasonCode: "effect.opaque_commit_unknown" };
45
+ }
46
+ /** One safe next step for `record`. Pure; an `inspection` for another effect is ignored. */
47
+ export function decideEffectRecovery(record, inspection) {
48
+ const scoped = inspection?.effectId === record.effectId ? inspection : undefined;
49
+ switch (record.phase) {
50
+ case "acknowledged":
51
+ return { action: "mark_interrupted", reasonCode: "effect.already_acknowledged" };
52
+ case "compensated":
53
+ return { action: "mark_interrupted", reasonCode: "effect.already_compensated" };
54
+ case "abandoned":
55
+ return { action: "mark_interrupted", reasonCode: "effect.already_abandoned" };
56
+ case "prepared":
57
+ return { action: "replay", reasonCode: "effect.not_dispatched" };
58
+ case "observed_committed":
59
+ return { action: "acknowledge", reasonCode: "effect.observed_committed_unacknowledged" };
60
+ case "observed_not_committed":
61
+ return { action: "replay", reasonCode: "effect.retry_not_committed" };
62
+ case "dispatched":
63
+ case "commit_unknown":
64
+ return decideUnknownCommit(record, scoped);
65
+ case "compensating":
66
+ if (scoped !== undefined)
67
+ return decideFromInspection(record, scoped);
68
+ if (record.inspectDescriptor !== undefined) {
69
+ return { action: "inspect", reasonCode: "effect.compensation_unknown_inspect" };
70
+ }
71
+ return { action: "require_operator", reasonCode: "effect.compensation_unknown" };
72
+ }
73
+ }
74
+ function compareCodeUnits(left, right) {
75
+ if (left === right)
76
+ return 0;
77
+ return left < right ? -1 : 1;
78
+ }
79
+ /** Effects of `operationId` whose external outcome is unknown, in deterministic id order. */
80
+ export function computeUncertaintyFrontier(state, operationId) {
81
+ const countBySemantics = {
82
+ pure: 0,
83
+ idempotent: 0,
84
+ inspectable: 0,
85
+ compensatable: 0,
86
+ opaque: 0,
87
+ };
88
+ const effectIds = [];
89
+ for (const record of Object.values(state.effects)) {
90
+ if (record.operationId !== operationId || !UNCERTAIN_EFFECT_PHASES.includes(record.phase))
91
+ continue;
92
+ effectIds.push(record.effectId);
93
+ countBySemantics[record.semantics] += 1;
94
+ }
95
+ effectIds.sort(compareCodeUnits);
96
+ return { operationId, effectIds, countBySemantics };
97
+ }
98
+ /** A verified verdict requires an empty frontier; anything else is at most inconclusive. */
99
+ export function frontierBlocksVerified(frontier) {
100
+ return frontier.effectIds.length > 0;
101
+ }
102
+ /**
103
+ * Recovery decisions for every non-terminal effect of an operation, in id
104
+ * order. Terminal effects are omitted: they need no action, and listing them
105
+ * would bury the frontier in noise.
106
+ */
107
+ export function planEffectRecovery(state, operationId, inspections = []) {
108
+ const byEffect = new Map(inspections.map((inspection) => [inspection.effectId, inspection]));
109
+ const entries = [];
110
+ for (const record of Object.values(state.effects)) {
111
+ if (record.operationId !== operationId)
112
+ continue;
113
+ const decision = decideEffectRecovery(record, byEffect.get(record.effectId));
114
+ if (decision.action === "mark_interrupted")
115
+ continue;
116
+ entries.push({ effectId: record.effectId, phase: record.phase, semantics: record.semantics, decision });
117
+ }
118
+ return entries.sort((left, right) => compareCodeUnits(left.effectId, right.effectId));
119
+ }
120
+ //# sourceMappingURL=effect-recovery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effect-recovery.js","sourceRoot":"","sources":["../../src/effects/effect-recovery.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Crash recovery over the effect journal: the uncertainty frontier and the\n * per-effect recovery decision.\n *\n * After a restart the journal is replayed and every effect sits in some\n * phase. This module answers two questions without touching the world:\n *\n * - Which effects are still uncertain? (`computeUncertaintyFrontier`) — the\n * set that must be empty before an operation may be called verified.\n * - What is the one safe next step for each effect? (`decideEffectRecovery`)\n * — a deterministic function of the record and an optional inspection\n * result, so the same journal always produces the same recovery plan.\n *\n * The decision table is conservative by construction: only `pure` and\n * `idempotent` effects are ever replayed blind, `inspectable` effects are\n * inspected first, `compensatable` effects are compensated, and `opaque`\n * effects with an unknown commit always stop at the operator.\n */\n\nimport { isReplaySafe } from \"./effect-transitions.ts\";\nimport {\n\ttype EffectJournalState,\n\ttype EffectRecord,\n\ttype EffectSemantics,\n\tUNCERTAIN_EFFECT_PHASES,\n} from \"./effect-types.ts\";\n\nexport interface EffectInspectionResult {\n\treadonly effectId: string;\n\treadonly outcome: \"committed\" | \"not_committed\" | \"unknown\";\n}\n\nexport type RecoveryDecision =\n\t| { readonly action: \"replay\"; readonly reasonCode: string }\n\t| { readonly action: \"acknowledge\"; readonly reasonCode: string }\n\t| { readonly action: \"inspect\"; readonly reasonCode: string }\n\t| { readonly action: \"resolve\"; readonly inspection: \"committed\" | \"not_committed\"; readonly reasonCode: string }\n\t| { readonly action: \"compensate\"; readonly reasonCode: string }\n\t| { readonly action: \"mark_interrupted\"; readonly reasonCode: string }\n\t| { readonly action: \"require_operator\"; readonly reasonCode: string };\n\nfunction decideFromInspection(record: EffectRecord, inspection: EffectInspectionResult): RecoveryDecision {\n\tif (inspection.outcome === \"committed\") {\n\t\treturn { action: \"resolve\", inspection: \"committed\", reasonCode: \"effect.inspection_committed\" };\n\t}\n\tif (inspection.outcome === \"not_committed\") {\n\t\treturn { action: \"resolve\", inspection: \"not_committed\", reasonCode: \"effect.inspection_not_committed\" };\n\t}\n\tif (record.compensationDescriptor !== undefined) {\n\t\treturn { action: \"compensate\", reasonCode: \"effect.inspection_unknown_compensate\" };\n\t}\n\treturn { action: \"require_operator\", reasonCode: \"effect.inspection_unknown\" };\n}\n\n/** Decision for an effect whose commit is not known (crash while dispatched, or commit_unknown). */\nfunction decideUnknownCommit(record: EffectRecord, inspection: EffectInspectionResult | undefined): RecoveryDecision {\n\tif (isReplaySafe(record.semantics)) return { action: \"replay\", reasonCode: \"effect.replay_safe\" };\n\tif (inspection !== undefined) return decideFromInspection(record, inspection);\n\tif (record.inspectDescriptor !== undefined) return { action: \"inspect\", reasonCode: \"effect.inspection_required\" };\n\tif (record.compensationDescriptor !== undefined) {\n\t\treturn { action: \"compensate\", reasonCode: \"effect.compensation_required\" };\n\t}\n\treturn { action: \"require_operator\", reasonCode: \"effect.opaque_commit_unknown\" };\n}\n\n/** One safe next step for `record`. Pure; an `inspection` for another effect is ignored. */\nexport function decideEffectRecovery(record: EffectRecord, inspection?: EffectInspectionResult): RecoveryDecision {\n\tconst scoped = inspection?.effectId === record.effectId ? inspection : undefined;\n\tswitch (record.phase) {\n\t\tcase \"acknowledged\":\n\t\t\treturn { action: \"mark_interrupted\", reasonCode: \"effect.already_acknowledged\" };\n\t\tcase \"compensated\":\n\t\t\treturn { action: \"mark_interrupted\", reasonCode: \"effect.already_compensated\" };\n\t\tcase \"abandoned\":\n\t\t\treturn { action: \"mark_interrupted\", reasonCode: \"effect.already_abandoned\" };\n\t\tcase \"prepared\":\n\t\t\treturn { action: \"replay\", reasonCode: \"effect.not_dispatched\" };\n\t\tcase \"observed_committed\":\n\t\t\treturn { action: \"acknowledge\", reasonCode: \"effect.observed_committed_unacknowledged\" };\n\t\tcase \"observed_not_committed\":\n\t\t\treturn { action: \"replay\", reasonCode: \"effect.retry_not_committed\" };\n\t\tcase \"dispatched\":\n\t\tcase \"commit_unknown\":\n\t\t\treturn decideUnknownCommit(record, scoped);\n\t\tcase \"compensating\":\n\t\t\tif (scoped !== undefined) return decideFromInspection(record, scoped);\n\t\t\tif (record.inspectDescriptor !== undefined) {\n\t\t\t\treturn { action: \"inspect\", reasonCode: \"effect.compensation_unknown_inspect\" };\n\t\t\t}\n\t\t\treturn { action: \"require_operator\", reasonCode: \"effect.compensation_unknown\" };\n\t}\n}\n\nexport interface EffectUncertaintyFrontier {\n\treadonly operationId: string;\n\treadonly effectIds: readonly string[];\n\treadonly countBySemantics: Readonly<Record<EffectSemantics, number>>;\n}\n\nfunction compareCodeUnits(left: string, right: string): number {\n\tif (left === right) return 0;\n\treturn left < right ? -1 : 1;\n}\n\n/** Effects of `operationId` whose external outcome is unknown, in deterministic id order. */\nexport function computeUncertaintyFrontier(state: EffectJournalState, operationId: string): EffectUncertaintyFrontier {\n\tconst countBySemantics: Record<EffectSemantics, number> = {\n\t\tpure: 0,\n\t\tidempotent: 0,\n\t\tinspectable: 0,\n\t\tcompensatable: 0,\n\t\topaque: 0,\n\t};\n\tconst effectIds: string[] = [];\n\tfor (const record of Object.values(state.effects)) {\n\t\tif (record.operationId !== operationId || !UNCERTAIN_EFFECT_PHASES.includes(record.phase)) continue;\n\t\teffectIds.push(record.effectId);\n\t\tcountBySemantics[record.semantics] += 1;\n\t}\n\teffectIds.sort(compareCodeUnits);\n\treturn { operationId, effectIds, countBySemantics };\n}\n\n/** A verified verdict requires an empty frontier; anything else is at most inconclusive. */\nexport function frontierBlocksVerified(frontier: EffectUncertaintyFrontier): boolean {\n\treturn frontier.effectIds.length > 0;\n}\n\nexport interface EffectRecoveryPlanEntry {\n\treadonly effectId: string;\n\treadonly phase: EffectRecord[\"phase\"];\n\treadonly semantics: EffectSemantics;\n\treadonly decision: RecoveryDecision;\n}\n\n/**\n * Recovery decisions for every non-terminal effect of an operation, in id\n * order. Terminal effects are omitted: they need no action, and listing them\n * would bury the frontier in noise.\n */\nexport function planEffectRecovery(\n\tstate: EffectJournalState,\n\toperationId: string,\n\tinspections: readonly EffectInspectionResult[] = [],\n): readonly EffectRecoveryPlanEntry[] {\n\tconst byEffect = new Map(inspections.map((inspection) => [inspection.effectId, inspection]));\n\tconst entries: EffectRecoveryPlanEntry[] = [];\n\tfor (const record of Object.values(state.effects)) {\n\t\tif (record.operationId !== operationId) continue;\n\t\tconst decision = decideEffectRecovery(record, byEffect.get(record.effectId));\n\t\tif (decision.action === \"mark_interrupted\") continue;\n\t\tentries.push({ effectId: record.effectId, phase: record.phase, semantics: record.semantics, decision });\n\t}\n\treturn entries.sort((left, right) => compareCodeUnits(left.effectId, right.effectId));\n}\n"]}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Legal phase moves for one effect. Pure and total: every command either
3
+ * yields the next phase (plus the reason code the journal should record) or a
4
+ * classified violation, and nothing here touches the chain, a clock, or I/O.
5
+ *
6
+ * The table encodes the two rules the plan refuses to compromise on:
7
+ *
8
+ * - `commit_unknown` is only ever left by inspection, compensation, an
9
+ * explicit abandonment, or — for `pure`/`idempotent` semantics alone — a
10
+ * replay. An `opaque`, `inspectable`, or `compensatable` effect whose commit
11
+ * is unknown cannot be re-dispatched (`unsafe_replay`).
12
+ * - Terminal phases are final. An acknowledged effect cannot be reopened, so a
13
+ * late duplicate observation after recovery cannot resurrect it.
14
+ */
15
+ import { type EffectCommand, type EffectJournalResult, type EffectPhase, type EffectRecord, type EffectSemantics } from "./effect-types.ts";
16
+ export interface EffectPhaseMove {
17
+ readonly phase: EffectPhase;
18
+ readonly reasonCode: string;
19
+ }
20
+ type MoveResult = EffectJournalResult<EffectPhaseMove>;
21
+ export declare function isTerminalEffectPhase(phase: EffectPhase): boolean;
22
+ export declare function isReplaySafe(semantics: EffectSemantics): boolean;
23
+ /**
24
+ * Every phase the effect may legally move to next, derived from the same
25
+ * rules as `reduceEffectPhase`. Replaying a persisted journal uses this to
26
+ * validate a stored record without reconstructing the command that made it.
27
+ */
28
+ export declare function legalNextPhases(record: EffectRecord): readonly EffectPhase[];
29
+ /** Next phase for `command` applied to the effect's latest record. */
30
+ export declare function reduceEffectPhase(record: EffectRecord, command: Exclude<EffectCommand, {
31
+ type: "prepare";
32
+ }>): MoveResult;
33
+ export {};
34
+ //# sourceMappingURL=effect-transitions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effect-transitions.d.ts","sourceRoot":"","sources":["../../src/effects/effect-transitions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACN,KAAK,aAAa,EAClB,KAAK,mBAAmB,EAExB,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,eAAe,EAEpB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED,KAAK,UAAU,GAAG,mBAAmB,CAAC,eAAe,CAAC,CAAC;AAIvD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAEjE;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,eAAe,GAAG,OAAO,CAEhE;AA8ED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,WAAW,EAAE,CAuB5E;AAED,sEAAsE;AACtE,wBAAgB,iBAAiB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,OAAO,CAAC,aAAa,EAAE;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC,GAClD,UAAU,CAgCZ","sourcesContent":["/**\n * Legal phase moves for one effect. Pure and total: every command either\n * yields the next phase (plus the reason code the journal should record) or a\n * classified violation, and nothing here touches the chain, a clock, or I/O.\n *\n * The table encodes the two rules the plan refuses to compromise on:\n *\n * - `commit_unknown` is only ever left by inspection, compensation, an\n * explicit abandonment, or — for `pure`/`idempotent` semantics alone — a\n * replay. An `opaque`, `inspectable`, or `compensatable` effect whose commit\n * is unknown cannot be re-dispatched (`unsafe_replay`).\n * - Terminal phases are final. An acknowledged effect cannot be reopened, so a\n * late duplicate observation after recovery cannot resurrect it.\n */\n\nimport {\n\ttype EffectCommand,\n\ttype EffectJournalResult,\n\tEffectJournalViolation,\n\ttype EffectPhase,\n\ttype EffectRecord,\n\ttype EffectSemantics,\n\tTERMINAL_EFFECT_PHASES,\n} from \"./effect-types.ts\";\n\nexport interface EffectPhaseMove {\n\treadonly phase: EffectPhase;\n\treadonly reasonCode: string;\n}\n\ntype MoveResult = EffectJournalResult<EffectPhaseMove>;\n\nconst REPLAY_SAFE_SEMANTICS: readonly EffectSemantics[] = [\"pure\", \"idempotent\"];\n\nexport function isTerminalEffectPhase(phase: EffectPhase): boolean {\n\treturn TERMINAL_EFFECT_PHASES.includes(phase);\n}\n\nexport function isReplaySafe(semantics: EffectSemantics): boolean {\n\treturn REPLAY_SAFE_SEMANTICS.includes(semantics);\n}\n\nfunction move(phase: EffectPhase, reasonCode: string): MoveResult {\n\treturn { ok: true, value: { phase, reasonCode } };\n}\n\nfunction invalid(record: EffectRecord, command: EffectCommand): MoveResult {\n\treturn {\n\t\tok: false,\n\t\terror: new EffectJournalViolation(\n\t\t\t\"invalid_transition\",\n\t\t\t`Effect ${record.effectId} cannot ${command.type} from phase ${record.phase}`,\n\t\t\trecord.effectId,\n\t\t),\n\t};\n}\n\nfunction reduceObserve(record: EffectRecord, command: Extract<EffectCommand, { type: \"observe\" }>): MoveResult {\n\tif (record.phase !== \"dispatched\") return invalid(record, command);\n\tswitch (command.observation) {\n\t\tcase \"committed\":\n\t\t\treturn move(\"observed_committed\", command.reasonCode ?? \"effect.observed_committed\");\n\t\tcase \"not_committed\":\n\t\t\treturn move(\"observed_not_committed\", command.reasonCode ?? \"effect.observed_not_committed\");\n\t\tcase \"unknown\":\n\t\t\treturn move(\"commit_unknown\", command.reasonCode ?? \"effect.commit_unknown\");\n\t}\n}\n\nfunction reduceRedispatch(record: EffectRecord, command: Extract<EffectCommand, { type: \"redispatch\" }>): MoveResult {\n\tif (record.phase === \"observed_not_committed\") return move(\"dispatched\", \"effect.retry_not_committed\");\n\tif (record.phase !== \"commit_unknown\") return invalid(record, command);\n\tif (isReplaySafe(record.semantics)) return move(\"dispatched\", \"effect.replay_safe\");\n\treturn {\n\t\tok: false,\n\t\terror: new EffectJournalViolation(\n\t\t\t\"unsafe_replay\",\n\t\t\t`Effect ${record.effectId} has ${record.semantics} semantics and an unknown commit; blind replay is forbidden`,\n\t\t\trecord.effectId,\n\t\t),\n\t};\n}\n\nfunction reduceCompensateBegin(\n\trecord: EffectRecord,\n\tcommand: Extract<EffectCommand, { type: \"compensate_begin\" }>,\n): MoveResult {\n\tif (record.phase !== \"observed_committed\" && record.phase !== \"commit_unknown\") return invalid(record, command);\n\tif (record.compensationDescriptor === undefined) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\terror: new EffectJournalViolation(\n\t\t\t\t\"missing_descriptor\",\n\t\t\t\t`Effect ${record.effectId} declares no compensation descriptor`,\n\t\t\t\trecord.effectId,\n\t\t\t),\n\t\t};\n\t}\n\treturn move(\"compensating\", \"effect.compensation_started\");\n}\n\n/** Abandonment is an explicit decision (waiver or operator) and must carry its reason. */\nfunction reduceAbandon(record: EffectRecord, command: Extract<EffectCommand, { type: \"abandon\" }>): MoveResult {\n\tconst allowed: readonly EffectPhase[] = [\"prepared\", \"observed_not_committed\", \"commit_unknown\", \"compensating\"];\n\tif (!allowed.includes(record.phase)) return invalid(record, command);\n\tif (command.reasonCode.trim().length === 0) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\terror: new EffectJournalViolation(\n\t\t\t\t\"invalid_record\",\n\t\t\t\t`Abandoning effect ${record.effectId} requires a reason code`,\n\t\t\t\trecord.effectId,\n\t\t\t),\n\t\t};\n\t}\n\treturn move(\"abandoned\", command.reasonCode);\n}\n\n/**\n * Every phase the effect may legally move to next, derived from the same\n * rules as `reduceEffectPhase`. Replaying a persisted journal uses this to\n * validate a stored record without reconstructing the command that made it.\n */\nexport function legalNextPhases(record: EffectRecord): readonly EffectPhase[] {\n\tswitch (record.phase) {\n\t\tcase \"prepared\":\n\t\t\treturn [\"dispatched\", \"abandoned\"];\n\t\tcase \"dispatched\":\n\t\t\treturn [\"observed_committed\", \"observed_not_committed\", \"commit_unknown\"];\n\t\tcase \"observed_committed\":\n\t\t\treturn record.compensationDescriptor === undefined ? [\"acknowledged\"] : [\"acknowledged\", \"compensating\"];\n\t\tcase \"observed_not_committed\":\n\t\t\treturn [\"dispatched\", \"abandoned\"];\n\t\tcase \"commit_unknown\": {\n\t\t\tconst phases: EffectPhase[] = [\"observed_committed\", \"observed_not_committed\", \"abandoned\"];\n\t\t\tif (record.compensationDescriptor !== undefined) phases.push(\"compensating\");\n\t\t\tif (isReplaySafe(record.semantics)) phases.push(\"dispatched\");\n\t\t\treturn phases;\n\t\t}\n\t\tcase \"compensating\":\n\t\t\treturn [\"compensated\", \"commit_unknown\", \"abandoned\"];\n\t\tcase \"acknowledged\":\n\t\tcase \"compensated\":\n\t\tcase \"abandoned\":\n\t\t\treturn [];\n\t}\n}\n\n/** Next phase for `command` applied to the effect's latest record. */\nexport function reduceEffectPhase(\n\trecord: EffectRecord,\n\tcommand: Exclude<EffectCommand, { type: \"prepare\" }>,\n): MoveResult {\n\tif (isTerminalEffectPhase(record.phase)) return invalid(record, command);\n\tswitch (command.type) {\n\t\tcase \"dispatch\":\n\t\t\treturn record.phase === \"prepared\" ? move(\"dispatched\", \"effect.dispatched\") : invalid(record, command);\n\t\tcase \"observe\":\n\t\t\treturn reduceObserve(record, command);\n\t\tcase \"acknowledge\":\n\t\t\treturn record.phase === \"observed_committed\"\n\t\t\t\t? move(\"acknowledged\", \"effect.acknowledged\")\n\t\t\t\t: invalid(record, command);\n\t\tcase \"resolve_unknown\":\n\t\t\tif (record.phase !== \"commit_unknown\") return invalid(record, command);\n\t\t\treturn command.inspection === \"committed\"\n\t\t\t\t? move(\"observed_committed\", \"effect.inspection_committed\")\n\t\t\t\t: move(\"observed_not_committed\", \"effect.inspection_not_committed\");\n\t\tcase \"redispatch\":\n\t\t\treturn reduceRedispatch(record, command);\n\t\tcase \"compensate_begin\":\n\t\t\treturn reduceCompensateBegin(record, command);\n\t\tcase \"compensate_end\":\n\t\t\tif (record.phase !== \"compensating\") return invalid(record, command);\n\t\t\treturn command.result === \"compensated\"\n\t\t\t\t? move(\"compensated\", \"effect.compensated\")\n\t\t\t\t: move(\"commit_unknown\", \"effect.compensation_unknown\");\n\t\tcase \"abandon\":\n\t\t\treturn reduceAbandon(record, command);\n\t\tdefault: {\n\t\t\tconst unknownCommand: never = command;\n\t\t\tthrow new EffectJournalViolation(\"invalid_transition\", `Unknown effect command ${String(unknownCommand)}`);\n\t\t}\n\t}\n}\n"]}
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Legal phase moves for one effect. Pure and total: every command either
3
+ * yields the next phase (plus the reason code the journal should record) or a
4
+ * classified violation, and nothing here touches the chain, a clock, or I/O.
5
+ *
6
+ * The table encodes the two rules the plan refuses to compromise on:
7
+ *
8
+ * - `commit_unknown` is only ever left by inspection, compensation, an
9
+ * explicit abandonment, or — for `pure`/`idempotent` semantics alone — a
10
+ * replay. An `opaque`, `inspectable`, or `compensatable` effect whose commit
11
+ * is unknown cannot be re-dispatched (`unsafe_replay`).
12
+ * - Terminal phases are final. An acknowledged effect cannot be reopened, so a
13
+ * late duplicate observation after recovery cannot resurrect it.
14
+ */
15
+ import { EffectJournalViolation, TERMINAL_EFFECT_PHASES, } from "./effect-types.js";
16
+ const REPLAY_SAFE_SEMANTICS = ["pure", "idempotent"];
17
+ export function isTerminalEffectPhase(phase) {
18
+ return TERMINAL_EFFECT_PHASES.includes(phase);
19
+ }
20
+ export function isReplaySafe(semantics) {
21
+ return REPLAY_SAFE_SEMANTICS.includes(semantics);
22
+ }
23
+ function move(phase, reasonCode) {
24
+ return { ok: true, value: { phase, reasonCode } };
25
+ }
26
+ function invalid(record, command) {
27
+ return {
28
+ ok: false,
29
+ error: new EffectJournalViolation("invalid_transition", `Effect ${record.effectId} cannot ${command.type} from phase ${record.phase}`, record.effectId),
30
+ };
31
+ }
32
+ function reduceObserve(record, command) {
33
+ if (record.phase !== "dispatched")
34
+ return invalid(record, command);
35
+ switch (command.observation) {
36
+ case "committed":
37
+ return move("observed_committed", command.reasonCode ?? "effect.observed_committed");
38
+ case "not_committed":
39
+ return move("observed_not_committed", command.reasonCode ?? "effect.observed_not_committed");
40
+ case "unknown":
41
+ return move("commit_unknown", command.reasonCode ?? "effect.commit_unknown");
42
+ }
43
+ }
44
+ function reduceRedispatch(record, command) {
45
+ if (record.phase === "observed_not_committed")
46
+ return move("dispatched", "effect.retry_not_committed");
47
+ if (record.phase !== "commit_unknown")
48
+ return invalid(record, command);
49
+ if (isReplaySafe(record.semantics))
50
+ return move("dispatched", "effect.replay_safe");
51
+ return {
52
+ ok: false,
53
+ error: new EffectJournalViolation("unsafe_replay", `Effect ${record.effectId} has ${record.semantics} semantics and an unknown commit; blind replay is forbidden`, record.effectId),
54
+ };
55
+ }
56
+ function reduceCompensateBegin(record, command) {
57
+ if (record.phase !== "observed_committed" && record.phase !== "commit_unknown")
58
+ return invalid(record, command);
59
+ if (record.compensationDescriptor === undefined) {
60
+ return {
61
+ ok: false,
62
+ error: new EffectJournalViolation("missing_descriptor", `Effect ${record.effectId} declares no compensation descriptor`, record.effectId),
63
+ };
64
+ }
65
+ return move("compensating", "effect.compensation_started");
66
+ }
67
+ /** Abandonment is an explicit decision (waiver or operator) and must carry its reason. */
68
+ function reduceAbandon(record, command) {
69
+ const allowed = ["prepared", "observed_not_committed", "commit_unknown", "compensating"];
70
+ if (!allowed.includes(record.phase))
71
+ return invalid(record, command);
72
+ if (command.reasonCode.trim().length === 0) {
73
+ return {
74
+ ok: false,
75
+ error: new EffectJournalViolation("invalid_record", `Abandoning effect ${record.effectId} requires a reason code`, record.effectId),
76
+ };
77
+ }
78
+ return move("abandoned", command.reasonCode);
79
+ }
80
+ /**
81
+ * Every phase the effect may legally move to next, derived from the same
82
+ * rules as `reduceEffectPhase`. Replaying a persisted journal uses this to
83
+ * validate a stored record without reconstructing the command that made it.
84
+ */
85
+ export function legalNextPhases(record) {
86
+ switch (record.phase) {
87
+ case "prepared":
88
+ return ["dispatched", "abandoned"];
89
+ case "dispatched":
90
+ return ["observed_committed", "observed_not_committed", "commit_unknown"];
91
+ case "observed_committed":
92
+ return record.compensationDescriptor === undefined ? ["acknowledged"] : ["acknowledged", "compensating"];
93
+ case "observed_not_committed":
94
+ return ["dispatched", "abandoned"];
95
+ case "commit_unknown": {
96
+ const phases = ["observed_committed", "observed_not_committed", "abandoned"];
97
+ if (record.compensationDescriptor !== undefined)
98
+ phases.push("compensating");
99
+ if (isReplaySafe(record.semantics))
100
+ phases.push("dispatched");
101
+ return phases;
102
+ }
103
+ case "compensating":
104
+ return ["compensated", "commit_unknown", "abandoned"];
105
+ case "acknowledged":
106
+ case "compensated":
107
+ case "abandoned":
108
+ return [];
109
+ }
110
+ }
111
+ /** Next phase for `command` applied to the effect's latest record. */
112
+ export function reduceEffectPhase(record, command) {
113
+ if (isTerminalEffectPhase(record.phase))
114
+ return invalid(record, command);
115
+ switch (command.type) {
116
+ case "dispatch":
117
+ return record.phase === "prepared" ? move("dispatched", "effect.dispatched") : invalid(record, command);
118
+ case "observe":
119
+ return reduceObserve(record, command);
120
+ case "acknowledge":
121
+ return record.phase === "observed_committed"
122
+ ? move("acknowledged", "effect.acknowledged")
123
+ : invalid(record, command);
124
+ case "resolve_unknown":
125
+ if (record.phase !== "commit_unknown")
126
+ return invalid(record, command);
127
+ return command.inspection === "committed"
128
+ ? move("observed_committed", "effect.inspection_committed")
129
+ : move("observed_not_committed", "effect.inspection_not_committed");
130
+ case "redispatch":
131
+ return reduceRedispatch(record, command);
132
+ case "compensate_begin":
133
+ return reduceCompensateBegin(record, command);
134
+ case "compensate_end":
135
+ if (record.phase !== "compensating")
136
+ return invalid(record, command);
137
+ return command.result === "compensated"
138
+ ? move("compensated", "effect.compensated")
139
+ : move("commit_unknown", "effect.compensation_unknown");
140
+ case "abandon":
141
+ return reduceAbandon(record, command);
142
+ default: {
143
+ const unknownCommand = command;
144
+ throw new EffectJournalViolation("invalid_transition", `Unknown effect command ${String(unknownCommand)}`);
145
+ }
146
+ }
147
+ }
148
+ //# sourceMappingURL=effect-transitions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effect-transitions.js","sourceRoot":"","sources":["../../src/effects/effect-transitions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAGN,sBAAsB,EAItB,sBAAsB,GACtB,MAAM,mBAAmB,CAAC;AAS3B,MAAM,qBAAqB,GAA+B,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;AAEjF,MAAM,UAAU,qBAAqB,CAAC,KAAkB,EAAW;IAClE,OAAO,sBAAsB,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;AAAA,CAC9C;AAED,MAAM,UAAU,YAAY,CAAC,SAA0B,EAAW;IACjE,OAAO,qBAAqB,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;AAAA,CACjD;AAED,SAAS,IAAI,CAAC,KAAkB,EAAE,UAAkB,EAAc;IACjE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,EAAE,CAAC;AAAA,CAClD;AAED,SAAS,OAAO,CAAC,MAAoB,EAAE,OAAsB,EAAc;IAC1E,OAAO;QACN,EAAE,EAAE,KAAK;QACT,KAAK,EAAE,IAAI,sBAAsB,CAChC,oBAAoB,EACpB,UAAU,MAAM,CAAC,QAAQ,WAAW,OAAO,CAAC,IAAI,eAAe,MAAM,CAAC,KAAK,EAAE,EAC7E,MAAM,CAAC,QAAQ,CACf;KACD,CAAC;AAAA,CACF;AAED,SAAS,aAAa,CAAC,MAAoB,EAAE,OAAoD,EAAc;IAC9G,IAAI,MAAM,CAAC,KAAK,KAAK,YAAY;QAAE,OAAO,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnE,QAAQ,OAAO,CAAC,WAAW,EAAE,CAAC;QAC7B,KAAK,WAAW;YACf,OAAO,IAAI,CAAC,oBAAoB,EAAE,OAAO,CAAC,UAAU,IAAI,2BAA2B,CAAC,CAAC;QACtF,KAAK,eAAe;YACnB,OAAO,IAAI,CAAC,wBAAwB,EAAE,OAAO,CAAC,UAAU,IAAI,+BAA+B,CAAC,CAAC;QAC9F,KAAK,SAAS;YACb,OAAO,IAAI,CAAC,gBAAgB,EAAE,OAAO,CAAC,UAAU,IAAI,uBAAuB,CAAC,CAAC;IAC/E,CAAC;AAAA,CACD;AAED,SAAS,gBAAgB,CAAC,MAAoB,EAAE,OAAuD,EAAc;IACpH,IAAI,MAAM,CAAC,KAAK,KAAK,wBAAwB;QAAE,OAAO,IAAI,CAAC,YAAY,EAAE,4BAA4B,CAAC,CAAC;IACvG,IAAI,MAAM,CAAC,KAAK,KAAK,gBAAgB;QAAE,OAAO,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvE,IAAI,YAAY,CAAC,MAAM,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC,YAAY,EAAE,oBAAoB,CAAC,CAAC;IACpF,OAAO;QACN,EAAE,EAAE,KAAK;QACT,KAAK,EAAE,IAAI,sBAAsB,CAChC,eAAe,EACf,UAAU,MAAM,CAAC,QAAQ,QAAQ,MAAM,CAAC,SAAS,6DAA6D,EAC9G,MAAM,CAAC,QAAQ,CACf;KACD,CAAC;AAAA,CACF;AAED,SAAS,qBAAqB,CAC7B,MAAoB,EACpB,OAA6D,EAChD;IACb,IAAI,MAAM,CAAC,KAAK,KAAK,oBAAoB,IAAI,MAAM,CAAC,KAAK,KAAK,gBAAgB;QAAE,OAAO,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChH,IAAI,MAAM,CAAC,sBAAsB,KAAK,SAAS,EAAE,CAAC;QACjD,OAAO;YACN,EAAE,EAAE,KAAK;YACT,KAAK,EAAE,IAAI,sBAAsB,CAChC,oBAAoB,EACpB,UAAU,MAAM,CAAC,QAAQ,sCAAsC,EAC/D,MAAM,CAAC,QAAQ,CACf;SACD,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC,cAAc,EAAE,6BAA6B,CAAC,CAAC;AAAA,CAC3D;AAED,0FAA0F;AAC1F,SAAS,aAAa,CAAC,MAAoB,EAAE,OAAoD,EAAc;IAC9G,MAAM,OAAO,GAA2B,CAAC,UAAU,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,cAAc,CAAC,CAAC;IACjH,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrE,IAAI,OAAO,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5C,OAAO;YACN,EAAE,EAAE,KAAK;YACT,KAAK,EAAE,IAAI,sBAAsB,CAChC,gBAAgB,EAChB,qBAAqB,MAAM,CAAC,QAAQ,yBAAyB,EAC7D,MAAM,CAAC,QAAQ,CACf;SACD,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC,WAAW,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;AAAA,CAC7C;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,MAAoB,EAA0B;IAC7E,QAAQ,MAAM,CAAC,KAAK,EAAE,CAAC;QACtB,KAAK,UAAU;YACd,OAAO,CAAC,YAAY,EAAE,WAAW,CAAC,CAAC;QACpC,KAAK,YAAY;YAChB,OAAO,CAAC,oBAAoB,EAAE,wBAAwB,EAAE,gBAAgB,CAAC,CAAC;QAC3E,KAAK,oBAAoB;YACxB,OAAO,MAAM,CAAC,sBAAsB,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC,cAAc,EAAE,cAAc,CAAC,CAAC;QAC1G,KAAK,wBAAwB;YAC5B,OAAO,CAAC,YAAY,EAAE,WAAW,CAAC,CAAC;QACpC,KAAK,gBAAgB,EAAE,CAAC;YACvB,MAAM,MAAM,GAAkB,CAAC,oBAAoB,EAAE,wBAAwB,EAAE,WAAW,CAAC,CAAC;YAC5F,IAAI,MAAM,CAAC,sBAAsB,KAAK,SAAS;gBAAE,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;YAC7E,IAAI,YAAY,CAAC,MAAM,CAAC,SAAS,CAAC;gBAAE,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;YAC9D,OAAO,MAAM,CAAC;QACf,CAAC;QACD,KAAK,cAAc;YAClB,OAAO,CAAC,aAAa,EAAE,gBAAgB,EAAE,WAAW,CAAC,CAAC;QACvD,KAAK,cAAc,CAAC;QACpB,KAAK,aAAa,CAAC;QACnB,KAAK,WAAW;YACf,OAAO,EAAE,CAAC;IACZ,CAAC;AAAA,CACD;AAED,sEAAsE;AACtE,MAAM,UAAU,iBAAiB,CAChC,MAAoB,EACpB,OAAoD,EACvC;IACb,IAAI,qBAAqB,CAAC,MAAM,CAAC,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzE,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACtB,KAAK,UAAU;YACd,OAAO,MAAM,CAAC,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,EAAE,mBAAmB,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACzG,KAAK,SAAS;YACb,OAAO,aAAa,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACvC,KAAK,aAAa;YACjB,OAAO,MAAM,CAAC,KAAK,KAAK,oBAAoB;gBAC3C,CAAC,CAAC,IAAI,CAAC,cAAc,EAAE,qBAAqB,CAAC;gBAC7C,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC7B,KAAK,iBAAiB;YACrB,IAAI,MAAM,CAAC,KAAK,KAAK,gBAAgB;gBAAE,OAAO,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YACvE,OAAO,OAAO,CAAC,UAAU,KAAK,WAAW;gBACxC,CAAC,CAAC,IAAI,CAAC,oBAAoB,EAAE,6BAA6B,CAAC;gBAC3D,CAAC,CAAC,IAAI,CAAC,wBAAwB,EAAE,iCAAiC,CAAC,CAAC;QACtE,KAAK,YAAY;YAChB,OAAO,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC1C,KAAK,kBAAkB;YACtB,OAAO,qBAAqB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAC/C,KAAK,gBAAgB;YACpB,IAAI,MAAM,CAAC,KAAK,KAAK,cAAc;gBAAE,OAAO,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YACrE,OAAO,OAAO,CAAC,MAAM,KAAK,aAAa;gBACtC,CAAC,CAAC,IAAI,CAAC,aAAa,EAAE,oBAAoB,CAAC;gBAC3C,CAAC,CAAC,IAAI,CAAC,gBAAgB,EAAE,6BAA6B,CAAC,CAAC;QAC1D,KAAK,SAAS;YACb,OAAO,aAAa,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACvC,SAAS,CAAC;YACT,MAAM,cAAc,GAAU,OAAO,CAAC;YACtC,MAAM,IAAI,sBAAsB,CAAC,oBAAoB,EAAE,0BAA0B,MAAM,CAAC,cAAc,CAAC,EAAE,CAAC,CAAC;QAC5G,CAAC;IACF,CAAC;AAAA,CACD","sourcesContent":["/**\n * Legal phase moves for one effect. Pure and total: every command either\n * yields the next phase (plus the reason code the journal should record) or a\n * classified violation, and nothing here touches the chain, a clock, or I/O.\n *\n * The table encodes the two rules the plan refuses to compromise on:\n *\n * - `commit_unknown` is only ever left by inspection, compensation, an\n * explicit abandonment, or — for `pure`/`idempotent` semantics alone — a\n * replay. An `opaque`, `inspectable`, or `compensatable` effect whose commit\n * is unknown cannot be re-dispatched (`unsafe_replay`).\n * - Terminal phases are final. An acknowledged effect cannot be reopened, so a\n * late duplicate observation after recovery cannot resurrect it.\n */\n\nimport {\n\ttype EffectCommand,\n\ttype EffectJournalResult,\n\tEffectJournalViolation,\n\ttype EffectPhase,\n\ttype EffectRecord,\n\ttype EffectSemantics,\n\tTERMINAL_EFFECT_PHASES,\n} from \"./effect-types.ts\";\n\nexport interface EffectPhaseMove {\n\treadonly phase: EffectPhase;\n\treadonly reasonCode: string;\n}\n\ntype MoveResult = EffectJournalResult<EffectPhaseMove>;\n\nconst REPLAY_SAFE_SEMANTICS: readonly EffectSemantics[] = [\"pure\", \"idempotent\"];\n\nexport function isTerminalEffectPhase(phase: EffectPhase): boolean {\n\treturn TERMINAL_EFFECT_PHASES.includes(phase);\n}\n\nexport function isReplaySafe(semantics: EffectSemantics): boolean {\n\treturn REPLAY_SAFE_SEMANTICS.includes(semantics);\n}\n\nfunction move(phase: EffectPhase, reasonCode: string): MoveResult {\n\treturn { ok: true, value: { phase, reasonCode } };\n}\n\nfunction invalid(record: EffectRecord, command: EffectCommand): MoveResult {\n\treturn {\n\t\tok: false,\n\t\terror: new EffectJournalViolation(\n\t\t\t\"invalid_transition\",\n\t\t\t`Effect ${record.effectId} cannot ${command.type} from phase ${record.phase}`,\n\t\t\trecord.effectId,\n\t\t),\n\t};\n}\n\nfunction reduceObserve(record: EffectRecord, command: Extract<EffectCommand, { type: \"observe\" }>): MoveResult {\n\tif (record.phase !== \"dispatched\") return invalid(record, command);\n\tswitch (command.observation) {\n\t\tcase \"committed\":\n\t\t\treturn move(\"observed_committed\", command.reasonCode ?? \"effect.observed_committed\");\n\t\tcase \"not_committed\":\n\t\t\treturn move(\"observed_not_committed\", command.reasonCode ?? \"effect.observed_not_committed\");\n\t\tcase \"unknown\":\n\t\t\treturn move(\"commit_unknown\", command.reasonCode ?? \"effect.commit_unknown\");\n\t}\n}\n\nfunction reduceRedispatch(record: EffectRecord, command: Extract<EffectCommand, { type: \"redispatch\" }>): MoveResult {\n\tif (record.phase === \"observed_not_committed\") return move(\"dispatched\", \"effect.retry_not_committed\");\n\tif (record.phase !== \"commit_unknown\") return invalid(record, command);\n\tif (isReplaySafe(record.semantics)) return move(\"dispatched\", \"effect.replay_safe\");\n\treturn {\n\t\tok: false,\n\t\terror: new EffectJournalViolation(\n\t\t\t\"unsafe_replay\",\n\t\t\t`Effect ${record.effectId} has ${record.semantics} semantics and an unknown commit; blind replay is forbidden`,\n\t\t\trecord.effectId,\n\t\t),\n\t};\n}\n\nfunction reduceCompensateBegin(\n\trecord: EffectRecord,\n\tcommand: Extract<EffectCommand, { type: \"compensate_begin\" }>,\n): MoveResult {\n\tif (record.phase !== \"observed_committed\" && record.phase !== \"commit_unknown\") return invalid(record, command);\n\tif (record.compensationDescriptor === undefined) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\terror: new EffectJournalViolation(\n\t\t\t\t\"missing_descriptor\",\n\t\t\t\t`Effect ${record.effectId} declares no compensation descriptor`,\n\t\t\t\trecord.effectId,\n\t\t\t),\n\t\t};\n\t}\n\treturn move(\"compensating\", \"effect.compensation_started\");\n}\n\n/** Abandonment is an explicit decision (waiver or operator) and must carry its reason. */\nfunction reduceAbandon(record: EffectRecord, command: Extract<EffectCommand, { type: \"abandon\" }>): MoveResult {\n\tconst allowed: readonly EffectPhase[] = [\"prepared\", \"observed_not_committed\", \"commit_unknown\", \"compensating\"];\n\tif (!allowed.includes(record.phase)) return invalid(record, command);\n\tif (command.reasonCode.trim().length === 0) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\terror: new EffectJournalViolation(\n\t\t\t\t\"invalid_record\",\n\t\t\t\t`Abandoning effect ${record.effectId} requires a reason code`,\n\t\t\t\trecord.effectId,\n\t\t\t),\n\t\t};\n\t}\n\treturn move(\"abandoned\", command.reasonCode);\n}\n\n/**\n * Every phase the effect may legally move to next, derived from the same\n * rules as `reduceEffectPhase`. Replaying a persisted journal uses this to\n * validate a stored record without reconstructing the command that made it.\n */\nexport function legalNextPhases(record: EffectRecord): readonly EffectPhase[] {\n\tswitch (record.phase) {\n\t\tcase \"prepared\":\n\t\t\treturn [\"dispatched\", \"abandoned\"];\n\t\tcase \"dispatched\":\n\t\t\treturn [\"observed_committed\", \"observed_not_committed\", \"commit_unknown\"];\n\t\tcase \"observed_committed\":\n\t\t\treturn record.compensationDescriptor === undefined ? [\"acknowledged\"] : [\"acknowledged\", \"compensating\"];\n\t\tcase \"observed_not_committed\":\n\t\t\treturn [\"dispatched\", \"abandoned\"];\n\t\tcase \"commit_unknown\": {\n\t\t\tconst phases: EffectPhase[] = [\"observed_committed\", \"observed_not_committed\", \"abandoned\"];\n\t\t\tif (record.compensationDescriptor !== undefined) phases.push(\"compensating\");\n\t\t\tif (isReplaySafe(record.semantics)) phases.push(\"dispatched\");\n\t\t\treturn phases;\n\t\t}\n\t\tcase \"compensating\":\n\t\t\treturn [\"compensated\", \"commit_unknown\", \"abandoned\"];\n\t\tcase \"acknowledged\":\n\t\tcase \"compensated\":\n\t\tcase \"abandoned\":\n\t\t\treturn [];\n\t}\n}\n\n/** Next phase for `command` applied to the effect's latest record. */\nexport function reduceEffectPhase(\n\trecord: EffectRecord,\n\tcommand: Exclude<EffectCommand, { type: \"prepare\" }>,\n): MoveResult {\n\tif (isTerminalEffectPhase(record.phase)) return invalid(record, command);\n\tswitch (command.type) {\n\t\tcase \"dispatch\":\n\t\t\treturn record.phase === \"prepared\" ? move(\"dispatched\", \"effect.dispatched\") : invalid(record, command);\n\t\tcase \"observe\":\n\t\t\treturn reduceObserve(record, command);\n\t\tcase \"acknowledge\":\n\t\t\treturn record.phase === \"observed_committed\"\n\t\t\t\t? move(\"acknowledged\", \"effect.acknowledged\")\n\t\t\t\t: invalid(record, command);\n\t\tcase \"resolve_unknown\":\n\t\t\tif (record.phase !== \"commit_unknown\") return invalid(record, command);\n\t\t\treturn command.inspection === \"committed\"\n\t\t\t\t? move(\"observed_committed\", \"effect.inspection_committed\")\n\t\t\t\t: move(\"observed_not_committed\", \"effect.inspection_not_committed\");\n\t\tcase \"redispatch\":\n\t\t\treturn reduceRedispatch(record, command);\n\t\tcase \"compensate_begin\":\n\t\t\treturn reduceCompensateBegin(record, command);\n\t\tcase \"compensate_end\":\n\t\t\tif (record.phase !== \"compensating\") return invalid(record, command);\n\t\t\treturn command.result === \"compensated\"\n\t\t\t\t? move(\"compensated\", \"effect.compensated\")\n\t\t\t\t: move(\"commit_unknown\", \"effect.compensation_unknown\");\n\t\tcase \"abandon\":\n\t\t\treturn reduceAbandon(record, command);\n\t\tdefault: {\n\t\t\tconst unknownCommand: never = command;\n\t\t\tthrow new EffectJournalViolation(\"invalid_transition\", `Unknown effect command ${String(unknownCommand)}`);\n\t\t}\n\t}\n}\n"]}