octaflow 0.28.0 → 0.29.1

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.
@@ -1,7 +1,7 @@
1
1
  import { A as TypedStep, H as RetryPolicy, M as TypedWorkflow, f as Decider, j as TypedStepContext, vt as FlowErrorShape, yt as Result } from "../index-B8SvtiYn.js";
2
2
  import { n as Logger } from "../logger-BCus3AOa.js";
3
3
  import { i as BuildStepContextArgs, o as WorkflowHooks } from "../hooks-BV-Vany9.js";
4
- import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-BvXBqJDR.js";
4
+ import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-C_mOfdHH.js";
5
5
  import { z } from "zod";
6
6
  import { EmbeddingModelV4, LanguageModelV4, SharedV4ProviderMetadata } from "@ai-sdk/provider";
7
7
  //#region src/ai/instrumented-model.d.ts
@@ -73,8 +73,8 @@ export interface TokenUsage {
73
73
  cacheWriteTokens: number;
74
74
  }
75
75
  /**
76
- * Default pricing, USD per million tokens — Anthropic as of 2026-09, Mistral and
77
- * the OpenAI embeddings as of 2026-08. A best-effort estimate, not a billing
76
+ * Default pricing, USD per million tokens — Anthropic as of 2026-10, Mistral and
77
+ * the OpenAI embeddings as of 2026-08, the OpenAI language models as of 2026-10. A best-effort estimate, not a billing
78
78
  * record: a BYOK caller can name any model id, and an unknown id prices at the
79
79
  * priciest row (safe overcounting — see {@link createCostEstimator}). Retired ids
80
80
  * are kept rather than deleted: dropping one silently reprices every historical
@@ -507,7 +507,8 @@ export declare function createRefusalAwareModel(model: LanguageModelV4): Languag
507
507
  /**
508
508
  * Per-model API capability switches.
509
509
  *
510
- * Claude 4.6+ models (Opus 4.6/4.7/4.8/5, Sonnet 4.6/5, Fable/Mythos 5) use
510
+ * Claude 4.6+ models (Opus 4.6/4.7/4.8/5/5.5, Sonnet 4.6/5/5.5, Haiku 5.5,
511
+ * Fable/Mythos 5) use
511
512
  * adaptive thinking; on 4.7+ the legacy `thinking: { type: 'enabled',
512
513
  * budget_tokens }` shape and non-default sampling params (`temperature`,
513
514
  * `top_p`, `top_k`) are rejected with a 400. Older models (Haiku 4.5,
@@ -519,7 +520,7 @@ export declare function createRefusalAwareModel(model: LanguageModelV4): Languag
519
520
  * functions are that branch, written once. `providerId` is a plain string:
520
521
  * only `'anthropic'` has provider-specific shapes today.
521
522
  */
522
- /** True for Claude 4.6+ / 5-family models: adaptive thinking, no budgetTokens, no sampling params. */
523
+ /** True for Claude 4.6+ / 5-family models (`claude-opus-5` covers `claude-opus-5-5`, and so on): adaptive thinking, no budgetTokens, no sampling params. */
523
524
  export declare function supportsAdaptiveThinking(modelId: string): boolean;
524
525
  export interface AnthropicThinkingSettings {
525
526
  temperature?: number;
package/dist/ai/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { D as ok, E as err, T as noopLogger, _ as defineStep, m as buildWorkflow } from "../core-C9h3-KFr.js";
2
2
  import { a as readRefusalDetails, i as isAiRefusalError, n as REFUSAL_FINISH_REASON, r as createRefusalAwareModel, t as AiRefusalError } from "../refusal-BsL2HPDb.js";
3
- import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, i as DEFAULT_EMBEDDING_PROVIDER, l as embeddingDimensionsFor, n as AI_PROVIDER_IDS, o as EMBEDDING_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, s as OPENAI_COMPATIBLE_PROVIDER, t as AI_PROVIDERS, u as isSubprocessorResidency } from "../provider-registry-BRwjC6_3.js";
3
+ import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, i as DEFAULT_EMBEDDING_PROVIDER, l as embeddingDimensionsFor, n as AI_PROVIDER_IDS, o as EMBEDDING_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, s as OPENAI_COMPATIBLE_PROVIDER, t as AI_PROVIDERS, u as isSubprocessorResidency } from "../provider-registry-B91lgOmu.js";
4
4
  import { z } from "zod";
5
5
  import { Output, generateText, wrapEmbeddingModel, wrapLanguageModel } from "ai";
6
6
  //#region src/ai/instrumented-model.ts
@@ -125,8 +125,8 @@ function createInstrumentedEmbeddingModel(model, accumulator) {
125
125
  //#endregion
126
126
  //#region src/ai/cost.ts
127
127
  /**
128
- * Default pricing, USD per million tokens — Anthropic as of 2026-09, Mistral and
129
- * the OpenAI embeddings as of 2026-08. A best-effort estimate, not a billing
128
+ * Default pricing, USD per million tokens — Anthropic as of 2026-10, Mistral and
129
+ * the OpenAI embeddings as of 2026-08, the OpenAI language models as of 2026-10. A best-effort estimate, not a billing
130
130
  * record: a BYOK caller can name any model id, and an unknown id prices at the
131
131
  * priciest row (safe overcounting — see {@link createCostEstimator}). Retired ids
132
132
  * are kept rather than deleted: dropping one silently reprices every historical
@@ -170,6 +170,12 @@ const DEFAULT_MODEL_PRICING = {
170
170
  cacheReadPerMillion: .25,
171
171
  cacheWritePerMillion: 12.5
172
172
  },
173
+ "claude-opus-5-5": {
174
+ inputPerMillion: 4,
175
+ outputPerMillion: 20,
176
+ cacheReadPerMillion: .2,
177
+ cacheWritePerMillion: 5
178
+ },
173
179
  "claude-opus-5": {
174
180
  inputPerMillion: 5,
175
181
  outputPerMillion: 25,
@@ -182,6 +188,18 @@ const DEFAULT_MODEL_PRICING = {
182
188
  cacheReadPerMillion: .2,
183
189
  cacheWritePerMillion: 2.5
184
190
  },
191
+ "claude-sonnet-5-5": {
192
+ inputPerMillion: 2,
193
+ outputPerMillion: 10,
194
+ cacheReadPerMillion: .2,
195
+ cacheWritePerMillion: 2.5
196
+ },
197
+ "claude-haiku-5-5": {
198
+ inputPerMillion: .1,
199
+ outputPerMillion: .5,
200
+ cacheReadPerMillion: .01,
201
+ cacheWritePerMillion: .125
202
+ },
185
203
  "claude-opus-4-8": {
186
204
  inputPerMillion: 5,
187
205
  outputPerMillion: 25,
@@ -265,6 +283,30 @@ const DEFAULT_MODEL_PRICING = {
265
283
  outputPerMillion: 0,
266
284
  cacheReadPerMillion: 0,
267
285
  cacheWritePerMillion: 0
286
+ },
287
+ "gpt-6-astra": {
288
+ inputPerMillion: 10,
289
+ outputPerMillion: 50,
290
+ cacheReadPerMillion: 1,
291
+ cacheWritePerMillion: 12.5
292
+ },
293
+ "gpt-6.1-sol": {
294
+ inputPerMillion: 2,
295
+ outputPerMillion: 10,
296
+ cacheReadPerMillion: .1,
297
+ cacheWritePerMillion: 2.5
298
+ },
299
+ "gpt-6-sol": {
300
+ inputPerMillion: 2,
301
+ outputPerMillion: 10,
302
+ cacheReadPerMillion: .2,
303
+ cacheWritePerMillion: 2.5
304
+ },
305
+ "gpt-6-luna": {
306
+ inputPerMillion: .1,
307
+ outputPerMillion: .5,
308
+ cacheReadPerMillion: .01,
309
+ cacheWritePerMillion: .125
268
310
  }
269
311
  };
270
312
  /**
@@ -627,7 +669,8 @@ function createAiUsageAggregationService(deps) {
627
669
  /**
628
670
  * Per-model API capability switches.
629
671
  *
630
- * Claude 4.6+ models (Opus 4.6/4.7/4.8/5, Sonnet 4.6/5, Fable/Mythos 5) use
672
+ * Claude 4.6+ models (Opus 4.6/4.7/4.8/5/5.5, Sonnet 4.6/5/5.5, Haiku 5.5,
673
+ * Fable/Mythos 5) use
631
674
  * adaptive thinking; on 4.7+ the legacy `thinking: { type: 'enabled',
632
675
  * budget_tokens }` shape and non-default sampling params (`temperature`,
633
676
  * `top_p`, `top_k`) are rejected with a 400. Older models (Haiku 4.5,
@@ -647,9 +690,10 @@ const ADAPTIVE_THINKING_MODEL_PREFIXES = [
647
690
  "claude-opus-4-7",
648
691
  "claude-opus-4-6",
649
692
  "claude-sonnet-5",
650
- "claude-sonnet-4-6"
693
+ "claude-sonnet-4-6",
694
+ "claude-haiku-5"
651
695
  ];
652
- /** True for Claude 4.6+ / 5-family models: adaptive thinking, no budgetTokens, no sampling params. */
696
+ /** True for Claude 4.6+ / 5-family models (`claude-opus-5` covers `claude-opus-5-5`, and so on): adaptive thinking, no budgetTokens, no sampling params. */
653
697
  function supportsAdaptiveThinking(modelId) {
654
698
  return ADAPTIVE_THINKING_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
655
699
  }
@@ -1,4 +1,4 @@
1
- import { b as OpenAiCompatibleProviderId, g as EmbeddingProviderId, i as AiEndpointResidency, o as AiProviderId } from "../../provider-registry-BvXBqJDR.js";
1
+ import { b as OpenAiCompatibleProviderId, g as EmbeddingProviderId, i as AiEndpointResidency, o as AiProviderId } from "../../provider-registry-C_mOfdHH.js";
2
2
  import { EmbeddingModelV4, LanguageModelV4 } from "@ai-sdk/provider";
3
3
  //#region src/ai/providers/index.d.ts
4
4
  export type LanguageModelFactory = (modelId: string) => LanguageModelV4;
@@ -1,5 +1,5 @@
1
1
  import { r as createRefusalAwareModel } from "../../refusal-BsL2HPDb.js";
2
- import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, t as AI_PROVIDERS } from "../../provider-registry-BRwjC6_3.js";
2
+ import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, t as AI_PROVIDERS } from "../../provider-registry-B91lgOmu.js";
3
3
  import { wrapLanguageModel } from "ai";
4
4
  //#region src/ai/providers/index.ts
5
5
  const LLM_LOADERS = {
@@ -16,6 +16,13 @@ const LLM_LOADERS = {
16
16
  apiKey,
17
17
  baseURL
18
18
  });
19
+ },
20
+ openai: async (apiKey, baseURL) => {
21
+ const { createOpenAI } = await import("@ai-sdk/openai");
22
+ return createOpenAI({
23
+ apiKey,
24
+ baseURL
25
+ });
19
26
  }
20
27
  };
21
28
  const EMBEDDING_LOADERS = {
@@ -1,2 +1,2 @@
1
- import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-BvXBqJDR.js";
1
+ import { C as isSubprocessorResidency, S as embeddingDimensionsFor, _ as EmbeddingProviderMeta, a as AiProcessingRegion, b as OpenAiCompatibleProviderId, c as AiProviderModelOption, d as AiVendorResidency, f as DEFAULT_AI_PROVIDER, g as EmbeddingProviderId, h as EMBEDDING_PROVIDER_IDS, i as AiEndpointResidency, l as AiResidencyError, m as EMBEDDING_PROVIDERS, n as AI_PROVIDER_IDS, o as AiProviderId, p as DEFAULT_EMBEDDING_PROVIDER, r as AiEndpointRegion, s as AiProviderMeta, t as AI_PROVIDERS, u as AiResidencyResult, v as OPENAI_COMPATIBLE_PROVIDER, w as resolveEndpointResidency, x as OpenAiCompatibleProviderMeta, y as OPENAI_COMPATIBLE_PROVIDER_ID } from "../provider-registry-C_mOfdHH.js";
2
2
  export { AI_PROVIDERS, AI_PROVIDER_IDS, AiEndpointRegion, AiEndpointResidency, AiProcessingRegion, AiProviderId, AiProviderMeta, AiProviderModelOption, AiResidencyError, AiResidencyResult, AiVendorResidency, DEFAULT_AI_PROVIDER, DEFAULT_EMBEDDING_PROVIDER, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, EmbeddingProviderId, EmbeddingProviderMeta, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, OpenAiCompatibleProviderId, OpenAiCompatibleProviderMeta, embeddingDimensionsFor, isSubprocessorResidency, resolveEndpointResidency };
@@ -1,2 +1 @@
1
- import { a as EMBEDDING_PROVIDERS, c as OPENAI_COMPATIBLE_PROVIDER_ID, d as resolveEndpointResidency, i as DEFAULT_EMBEDDING_PROVIDER, l as embeddingDimensionsFor, n as AI_PROVIDER_IDS, o as EMBEDDING_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, s as OPENAI_COMPATIBLE_PROVIDER, t as AI_PROVIDERS, u as isSubprocessorResidency } from "../provider-registry-BRwjC6_3.js";
2
- export { AI_PROVIDERS, AI_PROVIDER_IDS, DEFAULT_AI_PROVIDER, DEFAULT_EMBEDDING_PROVIDER, EMBEDDING_PROVIDERS, EMBEDDING_PROVIDER_IDS, OPENAI_COMPATIBLE_PROVIDER, OPENAI_COMPATIBLE_PROVIDER_ID, embeddingDimensionsFor, isSubprocessorResidency, resolveEndpointResidency };
1
+ export { t as AI_PROVIDERS, n as AI_PROVIDER_IDS, r as DEFAULT_AI_PROVIDER, i as DEFAULT_EMBEDDING_PROVIDER, a as EMBEDDING_PROVIDERS, o as EMBEDDING_PROVIDER_IDS, s as OPENAI_COMPATIBLE_PROVIDER, c as OPENAI_COMPATIBLE_PROVIDER_ID, l as embeddingDimensionsFor, u as isSubprocessorResidency, d as resolveEndpointResidency } from "../provider-registry-B91lgOmu.js";
@@ -9,7 +9,11 @@
9
9
  * Adding a provider: an id here with its metadata, a factory in
10
10
  * `providers/`, and pricing rows in `cost.ts`.
11
11
  */
12
- const AI_PROVIDER_IDS = ["anthropic", "mistral"];
12
+ const AI_PROVIDER_IDS = [
13
+ "anthropic",
14
+ "mistral",
15
+ "openai"
16
+ ];
13
17
  const EMBEDDING_PROVIDER_IDS = ["openai", "mistral"];
14
18
  /**
15
19
  * What an unset provider resolves to: the EU-established vendor, so a deployment
@@ -29,27 +33,31 @@ const AI_PROVIDERS = {
29
33
  legalName: "Anthropic PBC",
30
34
  country: "US"
31
35
  },
32
- defaultModel: "claude-sonnet-5",
33
- classifierModel: "claude-haiku-4-5",
34
- testModel: "claude-haiku-4-5",
36
+ defaultModel: "claude-sonnet-5-5",
37
+ classifierModel: "claude-haiku-5-5",
38
+ testModel: "claude-haiku-5-5",
35
39
  keyPrefix: "sk-ant-",
36
40
  consoleKeyUrl: "https://console.anthropic.com/settings/keys",
37
41
  modelOptions: [
38
42
  {
39
- label: "Claude Opus 5",
40
- value: "claude-opus-5"
43
+ label: "Claude Opus 5.5",
44
+ value: "claude-opus-5-5"
41
45
  },
42
46
  {
43
- label: "Claude Opus 4.8",
44
- value: "claude-opus-4-8"
47
+ label: "Claude Sonnet 5.5",
48
+ value: "claude-sonnet-5-5"
45
49
  },
46
50
  {
47
- label: "Claude Sonnet 5",
48
- value: "claude-sonnet-5"
51
+ label: "Claude Haiku 5.5",
52
+ value: "claude-haiku-5-5"
53
+ },
54
+ {
55
+ label: "Claude Opus 5",
56
+ value: "claude-opus-5"
49
57
  },
50
58
  {
51
- label: "Claude Haiku 4.5",
52
- value: "claude-haiku-4-5"
59
+ label: "Claude Sonnet 5",
60
+ value: "claude-sonnet-5"
53
61
  }
54
62
  ]
55
63
  },
@@ -80,6 +88,34 @@ const AI_PROVIDERS = {
80
88
  value: "mistral-small-latest"
81
89
  }
82
90
  ]
91
+ },
92
+ openai: {
93
+ id: "openai",
94
+ label: "OpenAI",
95
+ residency: {
96
+ region: "third-country",
97
+ legalName: "OpenAI, L.L.C.",
98
+ country: "US"
99
+ },
100
+ defaultModel: "gpt-6.1-sol",
101
+ classifierModel: "gpt-6-luna",
102
+ testModel: "gpt-6-luna",
103
+ keyPrefix: "sk-",
104
+ consoleKeyUrl: "https://platform.openai.com/api-keys",
105
+ modelOptions: [
106
+ {
107
+ label: "GPT-6 Astra",
108
+ value: "gpt-6-astra"
109
+ },
110
+ {
111
+ label: "GPT-6.1 Sol",
112
+ value: "gpt-6.1-sol"
113
+ },
114
+ {
115
+ label: "GPT-6 Luna",
116
+ value: "gpt-6-luna"
117
+ }
118
+ ]
83
119
  }
84
120
  };
85
121
  const EMBEDDING_PROVIDERS = {
@@ -9,7 +9,7 @@
9
9
  * Adding a provider: an id here with its metadata, a factory in
10
10
  * `providers/`, and pricing rows in `cost.ts`.
11
11
  */
12
- declare const AI_PROVIDER_IDS: readonly ['anthropic', 'mistral'];
12
+ declare const AI_PROVIDER_IDS: readonly ['anthropic', 'mistral', 'openai'];
13
13
  type AiProviderId = (typeof AI_PROVIDER_IDS)[number];
14
14
  declare const EMBEDDING_PROVIDER_IDS: readonly ['openai', 'mistral'];
15
15
  type EmbeddingProviderId = (typeof EMBEDDING_PROVIDER_IDS)[number];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octaflow",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Durable DAG workflow engine: Zod-typed steps over Postgres + pg-boss, with an optional AI add-on (token/cost/quota). Layered single package.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,8 @@
47
47
  "files": [
48
48
  "dist",
49
49
  "README.md",
50
- "LICENSE"
50
+ "LICENSE",
51
+ "skills"
51
52
  ],
52
53
  "publishConfig": {
53
54
  "access": "public"
@@ -116,23 +117,23 @@
116
117
  }
117
118
  },
118
119
  "devDependencies": {
119
- "@ai-sdk/anthropic": "^4.0.71",
120
- "@ai-sdk/mistral": "^4.0.56",
121
- "@ai-sdk/openai": "^4.0.83",
122
- "@ai-sdk/openai-compatible": "^3.0.62",
123
- "@ai-sdk/provider": "^4.0.21",
124
- "@testcontainers/postgresql": "^12.2.0",
125
- "@types/node": "^26.6.4",
120
+ "@ai-sdk/anthropic": "^4.0.79",
121
+ "@ai-sdk/mistral": "^4.0.63",
122
+ "@ai-sdk/openai": "^4.0.93",
123
+ "@ai-sdk/openai-compatible": "^3.0.68",
124
+ "@ai-sdk/provider": "^4.0.27",
125
+ "@testcontainers/postgresql": "^12.2.1",
126
+ "@types/node": "^26.6.5",
126
127
  "@types/pg": "^8.23.1",
127
- "ai": "^7.0.127",
128
- "drizzle-orm": "^0.45.3",
128
+ "ai": "^7.0.139",
129
+ "drizzle-orm": "^0.45.4",
129
130
  "pg": "^8.23.1",
130
- "pg-boss": "^12.35.1",
131
- "testcontainers": "^12.2.0",
132
- "tsdown": "^0.23.0",
131
+ "pg-boss": "^12.37.1",
132
+ "testcontainers": "^12.2.1",
133
+ "tsdown": "^0.23.1",
133
134
  "typescript": "^7.0.2",
134
- "vitest": "^5.0.3",
135
- "zod": "^4.6.5"
135
+ "vitest": "^5.0.4",
136
+ "zod": "^4.6.7"
136
137
  },
137
138
  "scripts": {
138
139
  "build": "tsdown",
@@ -0,0 +1,200 @@
1
+ ---
2
+ name: add-a-decision
3
+ description: Add an octaflow decision step — a decider (a model, a rules function, your own) answers fixed questions with fixed options and a confidence; a confident answer is applied on its own as an `autopilot` ledger entry, an unsure one waits for a person in the decision card, and every applied decision can be reverted. Use to classify, route, tag, prioritise or score a record; when the model writes content a person must always see, use add-an-ai-proposal-workflow.
4
+ ---
5
+
6
+ # Add a decision
7
+
8
+ > Paths under `apps/` and `packages/` are in the platform repository,
9
+ > <https://github.com/octabits-io/octabits>. In your own app, read a package's README at
10
+ > `node_modules/@octabits-io/<name>/README.md`.
11
+
12
+ A decision asks questions whose answers you can list — *which team*, *how urgent* — and gets
13
+ one option per question with a confidence. The step guarantees the shape: an answer outside the
14
+ options fails the step without a retry and never reaches a dependent. Below a threshold a
15
+ question goes to a person; above it the answer stands, and the ledger says that nobody looked.
16
+
17
+ ```
18
+ decide ── review (only when a question is below the threshold) ── outcome ── apply
19
+ ```
20
+
21
+ Shown end to end in `apps/starter/src/server/triage.ts` (Next.js, Drizzle, pg-boss) with its
22
+ page in `apps/starter/src/components/triage-panel.tsx`. The engine's own example is
23
+ `packages/octaflow/examples/17-decisions.ts`.
24
+
25
+ **Which pattern?**
26
+
27
+ | The model… | Pattern |
28
+ |---|---|
29
+ | chooses among options you can list, and a confident choice may stand | a decision (this skill) |
30
+ | writes content — a text, field values, new rows — a person always reviews | `add-an-ai-proposal-workflow` (beside this skill) |
31
+
32
+ An effect a person must always see first is a decision with `threshold: 1`. An effect that
33
+ cannot be undone (a payment, a message to a third party) never runs on autopilot: keep the
34
+ threshold at 1 and record the reversibility as `irreversible`.
35
+
36
+ ## Step 1 — the three steps
37
+
38
+ ```ts
39
+ import { z } from 'zod';
40
+ import { defineDecisionStep, defineDecisionReviewStep, defineDecisionOutcomeStep, functionDecider } from 'octaflow';
41
+ import { createModelDecider } from 'octaflow/ai';
42
+
43
+ const input = z.object({ dealId: z.string() });
44
+
45
+ const route = defineDecisionStep({
46
+ type: 'deal-routing.decide',
47
+ workflowInputSchema: input,
48
+ questions: { // inline, so the options stay literal types
49
+ team: ['sales', 'solutions', 'partners'],
50
+ urgency: ['low', 'normal', 'high'],
51
+ },
52
+ input: async (ctx) => readRoutingInput(ctx.context.host, ctx.workflowInput.dealId),
53
+ instructions: 'team: who should own this deal. urgency: how soon they should act.',
54
+ decider: createModelDecider<Host>(), // ctx.context.model, behind your disclosure gate
55
+ threshold: 0.8, // or per question: { team: 0.8 } — urgency then never asks
56
+ });
57
+ const review = defineDecisionReviewStep({ type: 'deal-routing.review', decision: { route } });
58
+ const outcome = defineDecisionOutcomeStep({ type: 'deal-routing.outcome', decision: { route }, review: { review } });
59
+ ```
60
+
61
+ - **`input` is data, not a prompt** — the smallest data that answers the questions. Leave out
62
+ names and contact details; the decision input leaves the process.
63
+ - **The decider.** `createModelDecider()` asks `ctx.context.model` (or the `model` you pass) with
64
+ structured output; wrap that model in the disclosure gate (`@octabits-io/disclosure/ai-sdk`).
65
+ Rules you can state outright are `functionDecider('rules', fn)`: confidence 1, `calibrated:
66
+ true`, never asks. Any object with `id`, `calibrated` and `decide()` is a `Decider`.
67
+ - **The threshold is a policy, not a probability.** A language model's stated confidence is
68
+ `calibrated: false`, and the step records that on every output. Start high; lower it only when
69
+ reviewers rarely change the decider's answer.
70
+ - **The review** suspends only when `output.review` is non-empty, and has no deadline by default.
71
+ `timeoutMs` with `onTimeout: 'accept'` keeps the decider's answers when it expires — that is
72
+ autopilot by another name, so prefer the default.
73
+ - **The outcome** merges both with `resolveDecision`: a person's choice wins where they made one.
74
+ Its `decidedBy` says, per question, `'decider'` or `'person'`.
75
+
76
+ ## Step 2 — the apply step
77
+
78
+ One more step after the outcome writes the settled choices. It is the host's code, because only
79
+ the host knows what an answer means in its schema:
80
+
81
+ ```ts
82
+ import { buildWorkflow, defineStep, nonRetryableError } from 'octaflow';
83
+
84
+ const apply = defineStep<Input, { entryId: number | null }, Ctx, { route: typeof route; outcome: typeof outcome }>({
85
+ type: 'deal-routing.apply',
86
+ workflowInputSchema: input,
87
+ outputSchema: z.object({ entryId: z.number().nullable() }),
88
+ dependencies: { route, outcome },
89
+ handler: async (ctx) => {
90
+ const applied = await applyRouting(ctx.context.host, ctx.workflowId, ctx.deps.route, ctx.deps.outcome);
91
+ if (!applied.ok) throw nonRetryableError(applied.error.message); // a refusal is not fixed by a retry
92
+ return applied.value;
93
+ },
94
+ });
95
+ export const routingWorkflow = buildWorkflow({ type: 'deal-routing', inputSchema: input, steps: { route, review, outcome, apply } });
96
+ ```
97
+
98
+ Inside `applyRouting`, in **one transaction**:
99
+
100
+ 1. **Find the run's ledger entry first** — ``ledger.findByWorkflow(`flow:${workflowId}`)``, the
101
+ same id the disclosure log keeps as its correlation id — and write nothing if it is there.
102
+ Steps run at least once; the entry is the idempotency marker.
103
+ 2. **Keep what each write replaced** — `proposeFields({ target, current, proposed: outcome.choices })`
104
+ from `@octabits-io/proposal` — so a revert can put it back.
105
+ 3. **Record the mode that follows the person**: a reviewer, or any `decidedBy` of `'person'`, is
106
+ `mode: 'reviewed'` with `principal.onBehalfOf` the reviewer; nobody is `mode: 'autopilot'`
107
+ with `principal.authorizationId` naming the policy (`threshold:0.8`). Put the decider, its
108
+ `calibrated` claim, the threshold and each answer's choice and confidence in `decision`.
109
+ 4. Emit the change on the outbox (`add-an-event-type`).
110
+
111
+ After the transaction commits, anchor the ledger's head (`anchor-the-ledger` in
112
+ `@octabits-io/agent-ledger`) — never inside it.
113
+
114
+ ## Step 3 — the engine, start, decide, revert
115
+
116
+ Register the workflow in the engine's registry exactly as in `add-an-ai-proposal-workflow`
117
+ (Step 3: `createStepHandlerRegistry`, `createAiWorkflowHooks`, `createWorkflowEngine` over the
118
+ `octaflow/postgres` store and the `octaflow/pg-boss` dispatcher). Then three routes:
119
+
120
+ - **start** checks the record exists in the caller's scope, then
121
+ ``engine.start(routingWorkflow, { dealId }, { entityRef: `deal:${dealId}`, idempotencyKey })``
122
+ and answers `202`. The decider runs in the step worker, never in the request.
123
+ - **decide** validates only `answers` from the body against the options (a Zod enum per
124
+ question), and calls `engine.resumeStep(workflowId, 'review', { answers, reviewer })` with the
125
+ reviewer from the session. Never take the reviewer from the request body. Refuse a run whose
126
+ `review` step is not `waiting` — `resumeStep` itself ignores it silently.
127
+ - **revert** reads the entry with `findByWorkflow`, refuses one already reverted, inverts its
128
+ operations (`invertOperations`), refuses on drift (`detectDrift` — a person's later change
129
+ stays theirs), writes the inverse and `ledger.recordRevert({ reverts: entry.id, principal })` in
130
+ one transaction, then anchors.
131
+
132
+ **Deleting the record.** Runs name it by `entityRef`, not a foreign key, and their steps hold
133
+ what the decider saw. Before the delete, `engine.purgeWorkflows({ entityRef })` cancels and
134
+ deletes them; it refuses with `workflow_step_running` while a step executes — answer `409`. The
135
+ ledger entries stay.
136
+
137
+ ## Step 4 — the sweep and the reminder
138
+
139
+ A step whose worker died stays `running`. Schedule `engine.recoverStuckWorkflows()` — every few
140
+ minutes, per scope with a run in flight (a pg-boss schedule, or your cron). It retries the step
141
+ while it has attempts (`onStuckStep: 'retry'`, the default) and fails it after, so the run
142
+ reaches `failed` and the page can say so. Keep handlers safe to run twice.
143
+
144
+ An unsure answer is never applied without a person, so a review may wait forever. The package
145
+ sends no reminder; read the waiting runs and send one from the same sweep, through your mail or
146
+ feed:
147
+
148
+ ```ts
149
+ const runs = await engine.listWorkflows({ type: 'deal-routing', status: 'running' });
150
+ const waiting = runs.ok ? runs.value.flatMap((run) => run.steps.filter((s) => s.key === 'review' && s.status === 'waiting').map((s) => ({ run, since: s.startedAt }))) : [];
151
+ ```
152
+
153
+ ## Step 5 — the card
154
+
155
+ Hand the card the decide step's output with its questions — `{ ...output, questions:
156
+ route.questions, threshold: 0.8 }` fits `DecisionReviewInput` as it is — and post what its
157
+ confirm hands you to the decide route:
158
+
159
+ - React: `DecisionReviewCard` from `@octabits-io/agent-ui/react/plain`, or `useDecisionReview`
160
+ from `@octabits-io/agent-ui/react` under your own design system;
161
+ - Vue: `useDecisionReview` from `@octabits-io/agent-ui/vue`, or
162
+ `@octabits-io/agent-ui/nuxt-ui/DecisionReviewCard.vue` on Nuxt UI;
163
+ - shadcn: `npx shadcn add` the `decision-review-card` registry item;
164
+ - headless: `createDecisionReviewStore` from `@octabits-io/agent-ui`.
165
+
166
+ The page shows who settled each value (autopilot with decider and confidence, or the person),
167
+ how long a review has waited, and Revert. It re-reads on events; it never polls.
168
+
169
+ ## Prove it works
170
+
171
+ In your app, a test on an in-memory engine — `createWorkflowEngine` over
172
+ `createInMemoryWorkflowStore()` and a dispatcher that runs each step inline, as
173
+ `packages/octaflow/src/core/decision.test.ts` builds it, with a `functionDecider` or a scripted
174
+ model — that asserts:
175
+
176
+ - a confident run applies itself: the columns are written and one `autopilot` entry records the
177
+ decider, `calibrated`, each choice and confidence;
178
+ - an unsure run waits on `review` and changes nothing; the decide route records the session's
179
+ user even when the body names another reviewer; a second decide is refused;
180
+ - revert restores, a second revert is refused, a revert after a manual change reports drift;
181
+ - another scope sees no runs and no rows.
182
+
183
+ ```bash
184
+ pnpm --filter octaflow test:unit
185
+ ```
186
+
187
+ Green: `src/core/decision.test.ts` passes, including "waits for a person below the threshold, and
188
+ their choice wins" and "fails the step, without retrying, when the decider answers outside the
189
+ options".
190
+
191
+ ## Checklist
192
+
193
+ - [ ] Questions inline with literal options; `input` is the smallest data that answers them
194
+ - [ ] The decider's model goes through the disclosure gate; the threshold is a stated policy
195
+ - [ ] The apply step finds the run's ledger entry before it writes, in one transaction
196
+ - [ ] `mode` is `autopilot` only when nobody decided; `reviewed` names the reviewer
197
+ - [ ] The ledger head is anchored after each apply and revert commits
198
+ - [ ] The decide route takes the reviewer from the session and refuses a review not waiting
199
+ - [ ] `recoverStuckWorkflows` runs on a schedule; records are purged with `purgeWorkflows` before delete
200
+ - [ ] The card shows confidence, who decided, and Revert
@@ -0,0 +1,228 @@
1
+ ---
2
+ name: add-an-ai-proposal-workflow
3
+ description: Add an octaflow AI workflow whose last step emits a reviewable Proposal, plus the host's apply and revert with a drift guard and a ledger row — the "agents propose, humans decide" loop. Use when a model should suggest changes to records that already exist and a person must approve them before they are written.
4
+ ---
5
+
6
+ # Add an AI workflow that ends in a proposal
7
+
8
+ > Paths under `apps/` and `packages/` are in the platform repository,
9
+ > <https://github.com/octabits-io/octabits>. In your own app, read a package's README at
10
+ > `node_modules/@octabits-io/<name>/README.md`.
11
+
12
+ A run is a DAG of typed steps (`octaflow/ai`). Its final step turns what the model produced
13
+ into a `Proposal` (`@octabits-io/proposal`): operations against rows the host already has,
14
+ each saying what it replaces, with a drift guard. The host stores the proposal as the run's
15
+ output, renders it for review, and applies the reviewer's decision through one mapping from
16
+ operations to writes, recording a ledger row (`@octabits-io/agent-ledger`) that revert derives
17
+ its inverse from. Nothing in the packages writes to your tables.
18
+
19
+ Reference implementations:
20
+ - Producer: `apps/demo-server/src/ai/workflows.ts` (the `contact-brief` DAG, the `propose` step)
21
+ - Engine wiring: `apps/demo-server/src/ai/engine.ts`
22
+ - Apply/revert on Drizzle: `apps/demo-server/src/ai/proposals.ts`; on Prisma: `apps/starter-next/src/lib/proposals.ts`
23
+ - Routes: `apps/demo-server/src/routes/ai.ts`; tests: `apps/demo-server/src/ai/ai.test.ts`
24
+
25
+ ## When to use
26
+
27
+ - The model's output is a change to existing records (fields, new child rows, deletions, an order) — not free text for a chat bubble.
28
+ - The change must be reviewable, partially acceptable, editable before apply, refusable if the record moved meanwhile, and undoable afterwards.
29
+
30
+ ## Step 1: the DAG
31
+
32
+ `defineAiStep` is octaflow's `defineStep` with the handler context fixed to
33
+ `AiContext<THost>`: handlers get `ctx.context.model` (already instrumented — usage and cost
34
+ are captured for you) and `ctx.context.host` (whatever your hooks' `resolveHost` returns).
35
+ Independent steps run in parallel; the engine derives that from `dependencies`.
36
+
37
+ ```ts
38
+ import { z } from 'zod';
39
+ import { generateText } from 'ai';
40
+ import { defineAiStep, buildAiWorkflow, type AiContext } from 'octaflow/ai';
41
+
42
+ const SCHEMA_INPUT = z.object({ orderId: z.string() });
43
+ type Input = z.infer<typeof SCHEMA_INPUT>;
44
+
45
+ const fetch = defineAiStep<Input, { title: string; notes: string | null }, Host>({
46
+ type: 'order-brief.fetch',
47
+ workflowInputSchema: SCHEMA_INPUT,
48
+ outputSchema: z.object({ title: z.string(), notes: z.string().nullable() }),
49
+ handler: async (ctx) => ctx.context.host.orders.get(ctx.workflowInput.orderId),
50
+ });
51
+
52
+ // THost is explicit on dependent steps: inference cannot recover it from `dependencies`.
53
+ const summarize = defineAiStep<Input, { summary: string }, Host, { fetch: typeof fetch }>({
54
+ type: 'order-brief.summarize',
55
+ workflowInputSchema: SCHEMA_INPUT,
56
+ outputSchema: z.object({ summary: z.string() }),
57
+ dependencies: { fetch },
58
+ retry: { maxAttempts: 3 },
59
+ handler: async (ctx) => {
60
+ const { text } = await generateText({ model: ctx.context.model, prompt: `Summarize: ${ctx.deps.fetch.title}` });
61
+ return { summary: text };
62
+ },
63
+ });
64
+ ```
65
+
66
+ ## Step 2: the `propose` step — the proposal IS its output schema
67
+
68
+ Re-read the row **now** so `current` is a fact about the run, not about the page load; carry
69
+ `guard: driftDigest(current)`; say what the model left empty in `skipped`; name the agent in
70
+ `provenance`.
71
+
72
+ ```ts
73
+ import { buildProposal, driftDigest, entityAnchor, proposalSchema, proposeCreate, proposeFields, SKIP_REASONS } from '@octabits-io/proposal';
74
+ import type { Proposal, SkippedItem } from '@octabits-io/proposal';
75
+
76
+ // flow constrains step outputs to Record<string, unknown>; an interface has no index signature.
77
+ type ProposalOutput = { [K in keyof Proposal]: Proposal[K] };
78
+ export const AGENT = { kind: 'agent', id: 'ai:order-brief', label: 'Order brief' } as const;
79
+
80
+ const propose = defineAiStep<Input, ProposalOutput, Host, { fetch: typeof fetch; summarize: typeof summarize }>({
81
+ type: 'order-brief.propose',
82
+ workflowInputSchema: SCHEMA_INPUT,
83
+ outputSchema: proposalSchema as unknown as z.ZodType<ProposalOutput>,
84
+ dependencies: { fetch, summarize },
85
+ handler: async (ctx) => {
86
+ const order = await ctx.context.host.orders.get(ctx.workflowInput.orderId); // re-read at emit time
87
+ const target = entityAnchor('order', order.id, order.title);
88
+ const summary = ctx.deps.summarize.summary.trim();
89
+ const skipped: SkippedItem[] = [];
90
+ if (!summary) skipped.push({ target, path: ['notes'], reason: SKIP_REASONS.noOutput });
91
+ return buildProposal({
92
+ scope: `order:${order.id}`,
93
+ workflowId: ctx.workflowId,
94
+ workflowType: 'order-brief',
95
+ operations: [
96
+ ...proposeFields({
97
+ target,
98
+ current: { notes: order.notes },
99
+ proposed: { notes: summary || undefined }, // dropped if it reproduces `current` verbatim
100
+ guard: { notes: driftDigest(order.notes) },
101
+ display: { notes: { labelKey: 'ai.order.fields.notes', control: 'multiline', maxLength: 2000, order: 1 } },
102
+ }),
103
+ proposeCreate({ collection: 'tasks', ref: 'followup', value: 'Call back next week', display: { labelKey: 'ai.order.fields.task', control: 'multiline', order: 2 } }),
104
+ ],
105
+ skipped,
106
+ provenance: { model: 'your-model-id', keySource: 'platform', generatedAt: new Date().toISOString(), principal: AGENT },
107
+ });
108
+ },
109
+ });
110
+
111
+ export const orderBriefWorkflow = buildAiWorkflow<Input, Host>({ type: 'order-brief', inputSchema: SCHEMA_INPUT, steps: { fetch, summarize, propose } });
112
+ ```
113
+
114
+ `display` is what a generic review surface renders: `labelKey` (the host translates),
115
+ `control` (`text` | `multiline` | `richtext` | …), `maxLength`, `hint`, `order`.
116
+
117
+ ## Step 3: the engine
118
+
119
+ `createAiWorkflowHooks` binds a model, a host and a key source per run; the refusal-aware
120
+ wrapper, per-model capability switches, the cost estimator and a quota policy all live in
121
+ `octaflow/ai` (`createRefusalAwareModel`, `createCostEstimator`, `DEFAULT_MODEL_PRICING`,
122
+ `AiQuotaPolicy`); `octaflow/ai/providers` turns a key into a model, `octaflow/ai/registry`
123
+ carries residency metadata. Register every workflow in one registry and build the engine:
124
+
125
+ ```ts
126
+ import { createStepHandlerRegistry, createWorkflowEngine } from 'octaflow';
127
+ import { createAiWorkflowHooks, createCostEstimator, DEFAULT_MODEL_PRICING } from 'octaflow/ai';
128
+
129
+ const hooks = createAiWorkflowHooks<Host>({
130
+ modelResolver: { resolveModel: () => model, resolveHost: () => host, resolveKeySource: () => 'platform' },
131
+ usageRecorder, quotaPolicy, costEstimator: createCostEstimator({ pricing: DEFAULT_MODEL_PRICING }),
132
+ });
133
+ const registry = createStepHandlerRegistry<AiContext<Host>>();
134
+ orderBriefWorkflow.register(registry);
135
+ const engine = createWorkflowEngine<AiContext<Host>>({ store, dispatcher, registry, partitionKey, logger, hooks });
136
+ ```
137
+
138
+ ## Step 4: apply and revert — the host's half, once
139
+
140
+ The same recipe on both stacks; only the write lines differ. Copy
141
+ `apps/demo-server/src/ai/proposals.ts` (Drizzle) or `apps/starter-next/src/lib/proposals.ts`
142
+ (Prisma) and replace the `applyOperation` switch.
143
+
144
+ ```ts
145
+ import { detectDrift, invertOperations, proposalSchema, resolveDecision, reversibilityOf, validateProposal } from '@octabits-io/proposal';
146
+ import type { ProposalDecision, ProposedOperation, ResolvedOperation } from '@octabits-io/proposal';
147
+ // ledger: createDrizzleAgentLedgerStore (…/agent-ledger/drizzle) or createPostgresAgentLedgerStore ({ db: pool }, …/agent-ledger/postgres)
148
+
149
+ async function apply(runId: number, decision: ProposalDecision, appliedBy: string | null) {
150
+ const proposal = proposalSchema.safeParse(run.output.propose); // 1. the stored proposal
151
+ const standing = await ledger.findByWorkflow(runId); // 2. refuse while one stands
152
+ if (standing.value && standing.value.revertedAt === null) return err({ key: 'proposal_already_applied', … });
153
+ const issues = validateProposal(proposal.data); // 3. structural validity
154
+ const resolved = resolveDecision(proposal.data, decision); // 4. what the reviewer kept, with edits
155
+ const drifted = detectDrift(resolved, (op) => (isNotesUpdate(op) ? liveRow.notes : undefined)); // 5. 409, never overwrite
156
+ const created: Record<string, string> = {};
157
+ for (const op of resolved) { /* 6. ONE switch: op.target.type / op.collection / op.path → your write */ }
158
+ // Drizzle: await db.update(orders).set({ notes: op.proposed }).where(eq(orders.id, id))
159
+ // Prisma: await prisma.order.update({ where: { id }, data: { notes: op.proposed } })
160
+ await ledger.record({ principal: { ...AGENT, onBehalfOf: appliedBy ?? undefined }, mode: 'reviewed', scope: proposal.data.scope,
161
+ workflowId: runId, decision, operations: resolved, created, reversibility: reversibilityOf(resolved), appliedAt: new Date().toISOString() });
162
+ }
163
+
164
+ async function revert(runId: number, revertedBy: string) {
165
+ const standing = await ledger.findByWorkflow(runId); // must stand: revertedAt === null
166
+ const plan = invertOperations(standing.value.operations as ResolvedOperation[], standing.value.created);
167
+ for (const op of plan.operations) { /* the same switch */ }
168
+ await ledger.markReverted(standing.value.id, { at: new Date().toISOString(), by: revertedBy });
169
+ return { missing: plan.missing, irreversible: plan.irreversible, compensable: plan.compensable };
170
+ }
171
+ ```
172
+
173
+ Whether a run is applied is never on the proposal: the host joins its ledger to the run
174
+ (`findByWorkflows` for lists), and "standing" means `revertedAt IS NULL`. A production host
175
+ wraps the writes and the ledger row in one transaction.
176
+
177
+ ## Step 5: routes
178
+
179
+ Generic read/control routes (list, active, get, snapshot, cancel, resume) come from
180
+ `@octabits-io/server`: `createFlowWorkflowHandler({ engine, extendWorkflow?, errorOverrides? })`
181
+ in `./fetch/octaflow` is the six routes as one fetch handler, and `createFlowWorkflowCore` in
182
+ `./octaflow` is the same logic without HTTP for a host that binds its own framework
183
+ (`apps/demo-server/src/hono/flow.ts` binds it to Hono). Both project `appliedAt`/`revertedAt`
184
+ from the ledger via `extendWorkflow`. Three routes stay app-side on any stack: the trigger (`POST …/workflows`
185
+ with your input vocabulary), `POST …/workflows/:id/apply` (body: `proposalDecisionSchema`)
186
+ and `POST …/workflows/:id/revert`. On Next these are route handlers
187
+ (`apps/starter-next/src/app/api/runs/[id]/apply/route.ts`). Map keys to statuses:
188
+ `proposal_drift` and `proposal_already_*` → 409, `proposal_invalid` → 400, `*_not_found` → 404.
189
+
190
+ ## Key rules
191
+
192
+ - `current` is read in the `propose` step, never earlier — the model steps took time.
193
+ - The proposal is the step's output schema; a step whose internals are a tool loop emits the same shape.
194
+ - One `applyOperation` switch is the only place that knows which table an anchor names. Revert goes through it too.
195
+ - Refuse a second apply while one stands; refuse on drift; refuse an empty decision. All three are 4xx, none is silent.
196
+ - The ledger stores the resolved operations as written and the ids assigned to creates — revert never re-reads the entity to guess "before".
197
+
198
+ ## Proof
199
+
200
+ ```bash
201
+ cd apps/demo-server && pnpm test # `proposal review loop (apply / revert)`: partial decision with an edit,
202
+ # drift → 409, revert restores and clears appliedAt, second apply refused, empty decision rejected
203
+ pnpm e2e # the loop in a browser against every host
204
+ ```
205
+
206
+ Write the same five tests for your workflow (`apps/demo-server/src/ai/ai.test.ts` is the
207
+ template, fully in-memory on octaflow's memory store). Green: all five pass and the e2e loop
208
+ shows the edited value landing and the rejected operation not being created.
209
+
210
+ ## Checklist
211
+
212
+ - [ ] Steps defined with `defineAiStep`, `THost` explicit on dependent steps, `retry` on model steps
213
+ - [ ] `propose` re-reads the row, sets `guard: driftDigest(current)`, fills `skipped` and `provenance`, output schema is `proposalSchema`
214
+ - [ ] Workflow registered in the engine's registry; hooks bind model, host, key source, usage recorder, cost estimator
215
+ - [ ] Apply: parse → standing check → validate → resolve → drift → one write switch → ledger row
216
+ - [ ] Revert: `invertOperations` over the ledger row → the same switch → `markReverted`
217
+ - [ ] Routes: generic ones from `server/fetch/octaflow` (or `server/octaflow` bound to your framework), trigger/apply/revert app-side, keys mapped to 4xx
218
+ - [ ] Five loop tests green; an e2e host drives it
219
+
220
+ ## Key files
221
+
222
+ | File | Purpose |
223
+ |---|---|
224
+ | `packages/octaflow/src/ai/define-ai-step.ts` | `defineAiStep`, `buildAiWorkflow` |
225
+ | `packages/octaflow/src/ai/hooks.ts`, `refusal.ts`, `capabilities.ts`, `cost.ts`, `quota.ts` | model resolution, refusal wrapper, capability switches, pricing, quota |
226
+ | `packages/proposal/src/build.ts`, `apply.ts`, `schema.ts` | `buildProposal`/`proposeFields`/`proposeCreate`/`driftDigest`; `validateProposal`/`resolveDecision`/`detectDrift`/`invertOperations`/`reversibilityOf`; `proposalSchema`, `proposalDecisionSchema` |
227
+ | `packages/agent-ledger/src/index.ts` | `AgentLedgerStore` — `record`, `findByWorkflow(s)`, `markReverted` |
228
+ | `packages/server/src/octaflow.ts`, `src/fetch/octaflow.ts` | `createFlowWorkflowCore`; `createFlowWorkflowHandler` |