@bratsos/workflow-engine 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +93 -0
  2. package/dist/{chunk-2HEV5ZJL.js → chunk-NANAXHS5.js} +2 -2
  3. package/dist/chunk-NANAXHS5.js.map +1 -0
  4. package/dist/{chunk-Q2XDO3UF.js → chunk-TWYPSP7P.js} +92 -3
  5. package/dist/chunk-TWYPSP7P.js.map +1 -0
  6. package/dist/{chunk-5C7LRNM7.js → chunk-XPWAEYOO.js} +449 -59
  7. package/dist/chunk-XPWAEYOO.js.map +1 -0
  8. package/dist/{client-DYs5wlHp.d.ts → client-YFKVt4p7.d.ts} +3 -3
  9. package/dist/client.d.ts +4 -4
  10. package/dist/conventions/index.d.ts +114 -0
  11. package/dist/conventions/index.js +95 -0
  12. package/dist/conventions/index.js.map +1 -0
  13. package/dist/{events-D_P24UaY.d.ts → events-B3XPPu0c.d.ts} +23 -1
  14. package/dist/{index-aNuJ2QgN.d.ts → index-CL0KmlyW.d.ts} +4 -1
  15. package/dist/index.d.ts +10 -10
  16. package/dist/index.js +3 -3
  17. package/dist/{interface-BeEPzTFy.d.ts → interface-BPz138Hf.d.ts} +108 -1
  18. package/dist/kernel/index.d.ts +6 -6
  19. package/dist/kernel/index.js +2 -2
  20. package/dist/kernel/testing/index.d.ts +3 -3
  21. package/dist/persistence/index.d.ts +2 -2
  22. package/dist/persistence/index.js +2 -2
  23. package/dist/persistence/prisma/index.d.ts +2 -2
  24. package/dist/persistence/prisma/index.js +2 -2
  25. package/dist/{plugins-Cl0WVVrE.d.ts → plugins-zT-aIcEZ.d.ts} +63 -4
  26. package/dist/{ports-swhiWFw4.d.ts → ports-DUL4hlQr.d.ts} +11 -2
  27. package/dist/{stage-_7BKqqUG.d.ts → stage-WuK0mfrC.d.ts} +81 -1
  28. package/dist/testing/index.d.ts +8 -1
  29. package/dist/testing/index.js +86 -1
  30. package/dist/testing/index.js.map +1 -1
  31. package/package.json +6 -1
  32. package/skills/workflow-engine/SKILL.md +58 -1
  33. package/skills/workflow-engine/migrations/README.md +275 -0
  34. package/skills/workflow-engine/migrations/migrate-0.7-to-0.8.md +528 -0
  35. package/skills/workflow-engine/references/10-annotations.md +479 -0
  36. package/dist/chunk-2HEV5ZJL.js.map +0 -1
  37. package/dist/chunk-5C7LRNM7.js.map +0 -1
  38. package/dist/chunk-Q2XDO3UF.js.map +0 -1
@@ -0,0 +1,528 @@
1
+ # Migrating from 0.7 to 0.8
2
+
3
+ ## Summary
4
+
5
+ `0.8.0` introduces **annotations** — a first-class, queryable surface for attaching provenance to workflow runs and stages. It replaces the ad-hoc pattern of stuffing context into `WorkflowRun.metadata`, with a uniform attach/query API inspired by OpenTelemetry semantic conventions. No breaking API changes; the only deprecation is the existing `WorkflowRun.metadata` field, which still works in 0.8.
6
+
7
+ If you don't use `WorkflowRun.metadata` and don't care about provenance recording, you can upgrade with **only the schema migration step** below — everything else is opt-in.
8
+
9
+ ## Notes on conventions
10
+
11
+ - **Scalar / array value convention is documented, not enforced.** The `value` column accepts any JSON. Nested objects work but are discouraged for well-known keys (they break `keyPrefix` query semantics). Use the `payload` slot for rich blobs.
12
+
13
+ ## Required actions
14
+
15
+ ### 1. Schema migration: add `WorkflowAnnotation` table
16
+
17
+ Add the following model to your Prisma schema:
18
+
19
+ ```prisma
20
+ model WorkflowAnnotation {
21
+ id String @id @default(cuid())
22
+ createdAt DateTime @default(now())
23
+
24
+ workflowRunId String
25
+ workflowRun WorkflowRun @relation(fields: [workflowRunId], references: [id], onDelete: Cascade)
26
+
27
+ workflowStageRecordId String?
28
+ workflowStage WorkflowStage? @relation(fields: [workflowStageRecordId], references: [id], onDelete: SetNull)
29
+ attempt Int @default(0)
30
+
31
+ scope String
32
+ scopeId String?
33
+
34
+ actorKind String?
35
+ actorId String?
36
+ actorVersion String?
37
+
38
+ key String
39
+ value Json
40
+ payload Json?
41
+
42
+ idempotencyKey String?
43
+
44
+ @@unique([workflowRunId, key, idempotencyKey])
45
+ @@index([workflowRunId, key])
46
+ @@index([workflowRunId, createdAt])
47
+ @@index([workflowRunId, scope])
48
+ @@index([workflowRunId, actorId])
49
+ }
50
+ ```
51
+
52
+ You'll also need to add the back-relations to your existing models:
53
+
54
+ ```prisma
55
+ model WorkflowRun {
56
+ // ... existing fields ...
57
+ annotations WorkflowAnnotation[]
58
+ }
59
+
60
+ model WorkflowStage {
61
+ // ... existing fields ...
62
+ annotations WorkflowAnnotation[]
63
+ }
64
+ ```
65
+
66
+ Then run your migration:
67
+
68
+ ```bash
69
+ # Postgres / SQLite via Prisma
70
+ pnpm prisma migrate dev --name add_workflow_annotations
71
+ ```
72
+
73
+ ### 2. (Drive-by) Add missing `metadata` column to `AICall` if you copied the README schema before 0.8
74
+
75
+ `0.8` ships a documentation fix: the `AICall` Prisma schema in earlier README versions omitted `metadata Json?`, even though the code wrote to it (since 0.5). If you copy-pasted that schema, your existing `AICall` rows are missing rich metadata (`temperature`, `finishReason`, `durationMs`, tool details, etc.).
76
+
77
+ Add the column:
78
+
79
+ ```prisma
80
+ model AICall {
81
+ // ... existing fields ...
82
+ metadata Json?
83
+ }
84
+ ```
85
+
86
+ Then migrate:
87
+
88
+ ```bash
89
+ pnpm prisma migrate dev --name aicall_add_metadata_column
90
+ ```
91
+
92
+ Note: this is additive. Existing rows will have `null` metadata; new rows will be populated.
93
+
94
+ ## New features
95
+
96
+ ### Annotations: attach provenance from stages
97
+
98
+ Inside a stage's `execute()`, use `ctx.annotate()` to record decisions, observations, or anything else you want queryable later:
99
+
100
+ ```ts
101
+ const classifyUrgency = defineStage({
102
+ id: "classify-urgency",
103
+ // ... schemas ...
104
+ async execute(ctx) {
105
+ const { object: ai } = await aiHelper.generateObject(...);
106
+
107
+ const usedFallback = ai.confidence < 0.6;
108
+ const urgency = usedFallback ? keywordHeuristic(ctx.input.text) : ai.urgency;
109
+
110
+ ctx.annotate({
111
+ actor: { kind: "agent", id: "triage-agent", version: "v3" },
112
+ attributes: {
113
+ "decision.outcome": urgency,
114
+ "decision.rationale": usedFallback
115
+ ? "AI confidence < 0.6 — fell back to keyword heuristic"
116
+ : "AI classification accepted",
117
+ "decision.confidence": ai.confidence,
118
+ "decision.alternatives": ["low", "medium", "high"],
119
+ "decision.used_fallback": usedFallback,
120
+ },
121
+ });
122
+
123
+ return { output: { urgency } };
124
+ },
125
+ });
126
+ ```
127
+
128
+ Writes are **buffered and flushed atomically with the stage completion transaction** — if the stage fails, annotations recorded before the failure still persist; if a suspend/resume race rolls back the stage, annotations roll back too.
129
+
130
+ ### Annotations: attach trigger context at `run.create`
131
+
132
+ ```ts
133
+ await kernel.dispatch({
134
+ type: "run.create",
135
+ workflowId: "ticket-triage",
136
+ input: { ticket },
137
+ annotations: [{
138
+ actor: { kind: "system", id: "zendesk-integration" },
139
+ attributes: {
140
+ "trigger.source": "webhook:zendesk",
141
+ "trigger.parent_run_id": previousRunId,
142
+ "trigger.reason": "auto-triage on ticket create",
143
+ },
144
+ }],
145
+ });
146
+ ```
147
+
148
+ ### Annotations: query later
149
+
150
+ ```ts
151
+ // Everything attached to a run
152
+ const all = await kernel.annotations.list(runId);
153
+
154
+ // Just decisions
155
+ const decisions = await kernel.annotations.list(runId, { keyPrefix: "decision." });
156
+
157
+ // Just things a specific agent recorded
158
+ const trail = await kernel.annotations.list(runId, { actorId: "triage-agent" });
159
+
160
+ // Time-ranged
161
+ const recent = await kernel.annotations.list(runId, {
162
+ since: new Date(Date.now() - 3600_000),
163
+ });
164
+ ```
165
+
166
+ ### Annotations: typed keys via the conventions module
167
+
168
+ ```ts
169
+ import { Decision, Trigger } from "@bratsos/workflow-engine/conventions";
170
+
171
+ // Compile-time typo protection + value-type linkage
172
+ ctx.annotate(Decision.outcome.key, "low"); // ✅ value typed as JsonValue
173
+ ctx.annotate(Decision.confidence.key, 0.42); // ✅ value typed as number
174
+ ctx.annotate(Decision.confidence.key, "high"); // ❌ type error — expected number
175
+
176
+ // Custom keys remain free-form
177
+ ctx.annotate("acme.compliance.signoff", "alice@acme.com");
178
+ ```
179
+
180
+ The conventions module ships well-known keys for `Trigger.*`, `Decision.*`, `Approval.*`, `Revision.*`. See `references/10-annotations.md` for the full list and conventions for naming custom keys.
181
+
182
+ ### Annotations: external attach (plugins, post-hoc reviews)
183
+
184
+ ```ts
185
+ await kernel.annotations.attach(workflowRunId, {
186
+ actor: { kind: "user", id: "alice@acme.com" },
187
+ attributes: { "review.disposition": "approved-anyway" },
188
+ idempotencyKey: "review-2026-05-24-alice",
189
+ });
190
+ ```
191
+
192
+ ### Annotations: rerun attempt disambiguation
193
+
194
+ `run.rerunFrom` now assigns a fresh `attempt` value to recreated stages. Annotations from the prior run survive (with their original `attempt`) and new annotations carry the new value, so a future agent can disambiguate decisions across rerun generations:
195
+
196
+ ```ts
197
+ // First run → annotations with attempt=0
198
+ ctx.annotate(Decision.outcome, "low");
199
+
200
+ // After run.rerunFrom and re-execution → annotations with attempt=1
201
+ ctx.annotate(Decision.outcome, "high");
202
+
203
+ // Query just the latest attempt
204
+ await kernel.annotations.list(runId, { attempt: 1 });
205
+ ```
206
+
207
+ ### Annotations: opt-in outbox emission
208
+
209
+ When a plugin or external system wants real-time notifications on annotation writes (audit pipelines, SIEM, live dashboards), pass `emitEvent: true`. The engine writes an `annotation:created` outbox event in the same transaction as the annotation row, plugged into the same delivery machinery as `stage:completed` and friends. Off by default.
210
+
211
+ ```ts
212
+ ctx.annotate("decision.outcome", "low", { emitEvent: true });
213
+ ```
214
+
215
+ ## Migrating from pre-0.8 provenance patterns
216
+
217
+ Annotations replace several ad-hoc patterns that 0.7-and-earlier consumers used to stuff "why" context into the engine. None of these patterns are removed in 0.8 — the migration is opportunistic, not forced. Move callsites as you touch them.
218
+
219
+ ### Pattern: `ctx.log("INFO", "...", { decision: "low", confidence: 0.42 })` for decisions
220
+
221
+ **Before**: structured log entries served double duty as "what happened" and "what was decided."
222
+
223
+ ```ts
224
+ // Pre-0.8
225
+ await ctx.log("INFO", "classified as low urgency", {
226
+ confidence: 0.42,
227
+ usedFallback: true,
228
+ });
229
+ ```
230
+
231
+ **After**: keep logs for narrative; move structured facts to annotations.
232
+
233
+ ```ts
234
+ // 0.8+
235
+ await ctx.log("INFO", "classified as low urgency");
236
+ ctx.annotate({
237
+ attributes: {
238
+ "decision.outcome": "low",
239
+ "decision.confidence": 0.42,
240
+ "decision.used_fallback": true,
241
+ },
242
+ });
243
+ ```
244
+
245
+ **Why**: logs are time-ordered narrative and aren't indexed by key. You can't ask "show me every decision in this run" via logs — you'd grep through `message` fields. Annotations are key-addressable and queryable across runs.
246
+
247
+ ### Pattern: stuffing trigger context into `WorkflowRun.metadata`
248
+
249
+ **Before**: `metadata` was the only place to record who triggered the run.
250
+
251
+ ```ts
252
+ // Pre-0.8
253
+ await kernel.dispatch({
254
+ type: "run.create",
255
+ ...,
256
+ metadata: {
257
+ triggeredBy: "alice",
258
+ source: "webhook",
259
+ parentRunId: "run_abc",
260
+ },
261
+ });
262
+ ```
263
+
264
+ **After**: use the `annotations` array.
265
+
266
+ ```ts
267
+ // 0.8+
268
+ await kernel.dispatch({
269
+ type: "run.create",
270
+ ...,
271
+ annotations: [{
272
+ actor: { kind: "user", id: "alice" },
273
+ attributes: {
274
+ "trigger.source": "webhook",
275
+ "trigger.parent_run_id": "run_abc",
276
+ },
277
+ }],
278
+ });
279
+ ```
280
+
281
+ **Why**: the new path lands inside the same transaction as `createRun`, is queryable by key prefix (`trigger.*`), and the legacy `metadata` column is auto-projected as `legacy.metadata.*` until you migrate — no breakage.
282
+
283
+ ### Pattern: stuffing decision context into stage `outputData`
284
+
285
+ **Before**: consumers sometimes mixed the stage's "real output" with decision metadata to avoid losing it.
286
+
287
+ ```ts
288
+ // Pre-0.8 — output schema bloat
289
+ return {
290
+ output: {
291
+ urgency: "low", // the actual output
292
+ _meta_aiConfidence: 0.42, // hidden provenance
293
+ _meta_usedFallback: true,
294
+ },
295
+ };
296
+ ```
297
+
298
+ **After**: clean output, annotations carry the "why."
299
+
300
+ ```ts
301
+ // 0.8+
302
+ ctx.annotate({
303
+ attributes: {
304
+ "decision.outcome": "low",
305
+ "decision.confidence": 0.42,
306
+ "decision.used_fallback": true,
307
+ },
308
+ });
309
+ return { output: { urgency: "low" } };
310
+ ```
311
+
312
+ **Why**: stage output is the contract between stages. Polluting it with provenance creates schema noise, makes downstream stages care about things they shouldn't, and makes outputs hard to reuse across workflows. Annotations are out-of-band — downstream stages never see them.
313
+
314
+ ### Pattern: using outbox `causationId` to trace cross-stage provenance
315
+
316
+ **Before**: consumers correlated outbox events by `causationId` to reconstruct what an agent decided.
317
+
318
+ **After**: keep outbox events for state transitions (`stage:started`/`completed`/`failed`) — they remain the source of truth for *what happened*. Use annotations for *why* it happened. The two layers compose: events tell you a stage completed; annotations tell you what the agent decided during that stage.
319
+
320
+ ```ts
321
+ // 0.8+ — typical reconstruction query
322
+ const events = await persistence.getUnpublishedOutboxEvents(...); // state transitions
323
+ const annotations = await kernel.annotations.list(runId); // decisions/facts
324
+ // Join in your app code by stageId/scopeId.
325
+ ```
326
+
327
+ ### Pattern: `WorkflowArtifact.metadata` for non-output stage data
328
+
329
+ **Before**: consumers sometimes wrote small structured data to `WorkflowArtifact.metadata` to avoid the BlobStore round-trip.
330
+
331
+ **After**: artifacts remain the right place for *content* (large outputs, embeddings, model responses, debug traces). Annotations are for *labels and facts you'll query later*. If the data is small and you want it indexed by key, prefer annotations. If it's a blob you want attached to a specific artifact key, keep using `WorkflowArtifact`.
332
+
333
+ ### Pattern: relying on `WorkflowLog.metadata` for queryable structured logging
334
+
335
+ **Before**: `WorkflowLog.metadata` is a Json column on each log entry. Consumers occasionally treated it as a searchable index.
336
+
337
+ **After**: `WorkflowLog.metadata` stays — it's intrinsic to the log entry, useful for debugging context attached to a specific log line. But it's not indexed by key, isn't cross-DB queryable, and shouldn't be used as a provenance store. If you find yourself querying log metadata to ask "what did this agent decide?", that's a sign to move it to annotations.
338
+
339
+ ## Deprecations
340
+
341
+ ### `WorkflowRun.metadata` is deprecated
342
+
343
+ The `metadata` parameter on `RunCreateCommand` is marked `@deprecated` in 0.8. It still works — and reading `WorkflowRun.metadata` from the database row still works — but new code should use the `annotations` array instead.
344
+
345
+ ```ts
346
+ // Before (still works in 0.8, will be removed in 1.0)
347
+ await kernel.dispatch({
348
+ type: "run.create",
349
+ workflowId: "x",
350
+ input: {...},
351
+ metadata: { triggeredBy: "alice", source: "webhook" },
352
+ });
353
+
354
+ // After (recommended)
355
+ await kernel.dispatch({
356
+ type: "run.create",
357
+ workflowId: "x",
358
+ input: {...},
359
+ annotations: [{
360
+ actor: { kind: "user", id: "alice" },
361
+ attributes: { "trigger.source": "webhook" },
362
+ }],
363
+ });
364
+ ```
365
+
366
+ `kernel.annotations.list(runId)` automatically materializes legacy `metadata` as virtual `legacy.metadata.*` annotations in query results, so you can migrate reads incrementally.
367
+
368
+ #### Optional: eagerly migrate existing rows
369
+
370
+ For most consumers, the lazy read-shim above is sufficient — old rows surface as `legacy.metadata.*` on demand, no data migration required. If you operate at a volume where you'd rather drop the per-`list()` `getRun()` overhead, or you want to eventually remove the `metadata` column, you can copy existing rows into persisted annotations once.
371
+
372
+ There's no shipped utility for this — the right migration depends on what you put in `metadata` and whether you want to also re-key into proper conventions (`trigger.*`, `decision.*`, etc.). Below is a starting point you can adapt:
373
+
374
+ ```ts
375
+ // One-time migration. Idempotent — safe to re-run on the same rows.
376
+ async function copyLegacyMetadataToAnnotations(
377
+ kernel: Kernel,
378
+ prisma: PrismaClient,
379
+ batchSize = 100,
380
+ ) {
381
+ let migrated = 0;
382
+ let skipped = 0;
383
+
384
+ while (true) {
385
+ const runs = await prisma.workflowRun.findMany({
386
+ where: {
387
+ metadata: { not: null },
388
+ // Skip runs that already have legacy.metadata.* persisted
389
+ annotations: { none: { key: { startsWith: "legacy.metadata." } } },
390
+ },
391
+ take: batchSize,
392
+ select: { id: true, metadata: true },
393
+ });
394
+ if (runs.length === 0) break;
395
+
396
+ for (const run of runs) {
397
+ // Only object-shaped metadata can be projected to keys.
398
+ // Primitives, arrays, and null are skipped.
399
+ if (
400
+ typeof run.metadata !== "object" ||
401
+ run.metadata === null ||
402
+ Array.isArray(run.metadata)
403
+ ) {
404
+ skipped++;
405
+ continue;
406
+ }
407
+
408
+ const attributes: Record<string, unknown> = {};
409
+ for (const [k, v] of Object.entries(run.metadata)) {
410
+ // OPTIONAL: replace this verbatim copy with your own mapping
411
+ // — e.g., remap `metadata.user` → `trigger.actor.id` instead
412
+ // of `legacy.metadata.user`. The library can't do this for you;
413
+ // it depends on your domain.
414
+ attributes[`legacy.metadata.${k}`] = v;
415
+ }
416
+ if (Object.keys(attributes).length === 0) {
417
+ skipped++;
418
+ continue;
419
+ }
420
+
421
+ await kernel.annotations.attach(run.id, {
422
+ attributes,
423
+ idempotencyKey: `legacy-migrate-${run.id}`,
424
+ });
425
+ migrated++;
426
+ }
427
+ }
428
+ return { migrated, skipped };
429
+ }
430
+ ```
431
+
432
+ Once a run has any persisted `legacy.metadata.*` annotation, the read-side shim stops synthesizing for that run — so this script must produce the complete set of keys per run, not a partial migration. If your `metadata` values are non-object (a string, number, or array), the script can't project them into keys; you'll need to handle those rows by hand or leave them on the column.
433
+
434
+ ## Bug fixes
435
+
436
+ ### Stage failure path now persists pre-failure side effects
437
+
438
+ Previously, if a stage threw an exception mid-execution, any `ctx.log(...)` calls made before the throw were preserved (fire-and-forget), but there was no mechanism for the stage to record *why* it was failing — the error message went only to `WorkflowStage.errorMessage`.
439
+
440
+ With annotations in 0.8, you can attribute structured failure context that persists in the failure transaction:
441
+
442
+ ```ts
443
+ async execute(ctx) {
444
+ try {
445
+ return await doWork();
446
+ } catch (err) {
447
+ ctx.annotate({
448
+ attributes: {
449
+ "failure.kind": classifyError(err),
450
+ "failure.retryable": isRetryable(err),
451
+ },
452
+ });
453
+ throw err;
454
+ }
455
+ }
456
+ ```
457
+
458
+ The annotation is buffered before the throw and flushed in the failure-path transaction (Phase 3b in `job-execute.ts`).
459
+
460
+ ### Cancellation tightened across stage Phase 3 transactions
461
+
462
+ Before 0.8, `run.cancel` could commit between the kernel's outer ghost check and a stage's Phase 3 transaction, letting stage updates and outbox events commit against an already-cancelled run. 0.8 adds an in-transaction status guard: if cancel committed before the Phase 3 transaction starts, the transaction throws and rolls back atomically (stage update + annotations + outbox events). Same protection added to the suspended-stage poll path. The race window is meaningfully narrower than before. A residual microsecond-scale window remains under READ COMMITTED isolation when cancel commits during the Phase 3 transaction itself — this is general engine behavior, not annotation-specific, and may be closed in a future release. No code change required.
463
+
464
+ ### AICall metadata documentation gap fixed
465
+
466
+ The README's Prisma schema for `AICall` had been missing the `metadata` column since 0.5, even though the AI helper wrote to it. The documentation is now corrected. See the **Required actions** section above for the corresponding schema update.
467
+
468
+ ## Code examples — common patterns
469
+
470
+ ### Pattern: trigger annotation at the entrypoint of a chain of runs
471
+
472
+ ```ts
473
+ async function rerunFromAlert(originalRunId: string, alertId: string) {
474
+ return kernel.dispatch({
475
+ type: "run.create",
476
+ workflowId: "x",
477
+ input: {...},
478
+ annotations: [{
479
+ actor: { kind: "system", id: "alerting" },
480
+ attributes: {
481
+ "trigger.source": "alert",
482
+ "trigger.parent_run_id": originalRunId,
483
+ "trigger.reason": `Alert ${alertId} detected anomaly`,
484
+ },
485
+ }],
486
+ });
487
+ }
488
+
489
+ // Walk back the chain later
490
+ async function findChainRoot(runId: string): Promise<string> {
491
+ const trigger = await kernel.annotations.list(runId, { keyPrefix: "trigger." });
492
+ const parent = trigger.find(a => a.key === "trigger.parent_run_id")?.value as string | undefined;
493
+ return parent ? findChainRoot(parent) : runId;
494
+ }
495
+ ```
496
+
497
+ ### Pattern: durable decision audit for compliance
498
+
499
+ ```ts
500
+ ctx.annotate({
501
+ actor: { kind: "agent", id: "compliance-screener", version: "v2.1" },
502
+ attributes: {
503
+ "approval.approvers": ["alice", "bob"], // plural → array
504
+ "approval.timestamp": new Date().toISOString(),
505
+ "approval.policy.version": "v3.2",
506
+ },
507
+ payload: {
508
+ fullReviewArtifact: { ... }, // rich blob, not indexed
509
+ },
510
+ idempotencyKey: `approval-${ctx.workflowRunId}-${reviewId}`,
511
+ });
512
+ ```
513
+
514
+ ### Pattern: cross-stage trail via the timeline index
515
+
516
+ ```ts
517
+ // Get all annotations from a run in time order
518
+ const timeline = await kernel.annotations.list(runId);
519
+ // Already sorted by createdAt thanks to the (workflowRunId, createdAt) index.
520
+
521
+ for (const a of timeline) {
522
+ console.log(`[${a.createdAt.toISOString()}] ${a.actorKind}:${a.actorId} — ${a.key} = ${JSON.stringify(a.value)}`);
523
+ }
524
+ ```
525
+
526
+ ## Reference
527
+
528
+ For the full design rationale, see `docs/RFC-ANNOTATIONS.md` in the package source. The skill reference doc `references/10-annotations.md` covers the API surface in depth.