@intentius/chant 0.124.23 → 0.124.25

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 (210) hide show
  1. package/dist/ci-jobs.d.ts +41 -1
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +48 -1
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.js +14 -0
  6. package/dist/cli/handlers/approve.d.ts.map +1 -1
  7. package/dist/cli/handlers/approve.js +9 -0
  8. package/dist/cli/handlers/emulator.d.ts +13 -2
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/emulator.js +63 -3
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/promote.js +7 -1
  13. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  14. package/dist/cli/handlers/run-wave.js +5 -4
  15. package/dist/cli/handlers/run.d.ts +4 -3
  16. package/dist/cli/handlers/run.d.ts.map +1 -1
  17. package/dist/cli/handlers/run.js +73 -9
  18. package/dist/cli/main.d.ts.map +1 -1
  19. package/dist/cli/main.js +38 -11
  20. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  21. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  22. package/dist/cli/mcp/workspace-tools.js +27 -4
  23. package/dist/cli/parse-args.d.ts.map +1 -1
  24. package/dist/cli/parse-args.js +31 -0
  25. package/dist/cli/registry.d.ts +12 -0
  26. package/dist/cli/registry.d.ts.map +1 -1
  27. package/dist/components/cli-support.d.ts +45 -6
  28. package/dist/components/cli-support.d.ts.map +1 -1
  29. package/dist/components/cli-support.js +97 -22
  30. package/dist/components/driver.d.ts.map +1 -1
  31. package/dist/components/driver.js +25 -10
  32. package/dist/effect-receipt.d.ts +13 -0
  33. package/dist/effect-receipt.d.ts.map +1 -1
  34. package/dist/effect-receipt.js +19 -0
  35. package/dist/graph-detail.d.ts.map +1 -1
  36. package/dist/graph-detail.js +24 -2
  37. package/dist/lexicon-module.d.ts +7 -1
  38. package/dist/lexicon-module.d.ts.map +1 -1
  39. package/dist/lexicon-module.js +7 -2
  40. package/dist/lifecycle/git.d.ts +11 -0
  41. package/dist/lifecycle/git.d.ts.map +1 -1
  42. package/dist/lifecycle/git.js +62 -6
  43. package/dist/lifecycle/no-record.d.ts +59 -0
  44. package/dist/lifecycle/no-record.d.ts.map +1 -0
  45. package/dist/lifecycle/no-record.js +88 -0
  46. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  47. package/dist/lifecycle/receipt-plan.js +4 -2
  48. package/dist/op/activities/shell.d.ts.map +1 -1
  49. package/dist/op/activities/shell.js +30 -9
  50. package/dist/op/emulator-lifecycle.d.ts +23 -0
  51. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  52. package/dist/op/emulator-lifecycle.js +39 -2
  53. package/dist/op/gate.d.ts.map +1 -1
  54. package/dist/op/gate.js +3 -0
  55. package/dist/op/index.d.ts +1 -1
  56. package/dist/op/index.d.ts.map +1 -1
  57. package/dist/op/index.js +1 -1
  58. package/dist/op/local-executor.d.ts +10 -0
  59. package/dist/op/local-executor.d.ts.map +1 -1
  60. package/dist/op/local-executor.js +71 -7
  61. package/dist/op/op-waves-notify.d.ts +2 -0
  62. package/dist/op/op-waves-notify.d.ts.map +1 -1
  63. package/dist/op/op-waves-notify.js +9 -3
  64. package/dist/op/op-waves-run.d.ts +8 -2
  65. package/dist/op/op-waves-run.d.ts.map +1 -1
  66. package/dist/op/op-waves-run.js +36 -11
  67. package/dist/op/op-waves.d.ts +85 -1
  68. package/dist/op/op-waves.d.ts.map +1 -1
  69. package/dist/op/op-waves.js +153 -1
  70. package/dist/op/receipt-store.d.ts +5 -0
  71. package/dist/op/receipt-store.d.ts.map +1 -1
  72. package/dist/op/receipt-store.js +4 -3
  73. package/dist/op/runtimes/local.d.ts.map +1 -1
  74. package/dist/op/runtimes/local.js +6 -0
  75. package/dist/op/step-console.d.ts +65 -0
  76. package/dist/op/step-console.d.ts.map +1 -0
  77. package/dist/op/step-console.js +140 -0
  78. package/dist/op/step-output-ref.d.ts +26 -18
  79. package/dist/op/step-output-ref.d.ts.map +1 -1
  80. package/dist/op/step-output-ref.js +86 -63
  81. package/dist/point-decision.d.ts.map +1 -1
  82. package/dist/point-decision.js +3 -0
  83. package/dist/wave-runtime.mjs +239 -239
  84. package/dist/workspace/agent-cli.d.ts.map +1 -1
  85. package/dist/workspace/agent-cli.js +3 -0
  86. package/dist/workspace/compose-graph.d.ts +22 -3
  87. package/dist/workspace/compose-graph.d.ts.map +1 -1
  88. package/dist/workspace/compose-graph.js +18 -5
  89. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  90. package/dist/workspace/conformance/writer.js +1 -1
  91. package/dist/workspace/decide.d.ts +51 -9
  92. package/dist/workspace/decide.d.ts.map +1 -1
  93. package/dist/workspace/decide.js +125 -14
  94. package/dist/workspace/graph.schema.json +68 -1
  95. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  96. package/dist/workspace/lineage-upgrade.js +20 -11
  97. package/dist/workspace/nested-graph.d.ts +1 -1
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +5 -1
  100. package/dist/workspace/point-answer.schema.json +16 -1
  101. package/dist/workspace/points-cli.d.ts +5 -3
  102. package/dist/workspace/points-cli.d.ts.map +1 -1
  103. package/dist/workspace/points-cli.js +16 -7
  104. package/dist/workspace/points-write.schema.json +39 -4
  105. package/dist/workspace/points.schema.json +42 -3
  106. package/dist/workspace/reason-codes.d.ts +2 -1
  107. package/dist/workspace/reason-codes.d.ts.map +1 -1
  108. package/dist/workspace/reason-codes.js +2 -1
  109. package/dist/workspace/records-cli.d.ts +12 -2
  110. package/dist/workspace/records-cli.d.ts.map +1 -1
  111. package/dist/workspace/records-cli.js +27 -7
  112. package/dist/workspace/records-write.d.ts.map +1 -1
  113. package/dist/workspace/records-write.js +2 -3
  114. package/dist/workspace/records.d.ts +10 -4
  115. package/dist/workspace/records.d.ts.map +1 -1
  116. package/dist/workspace/records.js +16 -3
  117. package/dist/workspace/records.schema.json +2 -1
  118. package/dist/workspace/runs.d.ts.map +1 -1
  119. package/dist/workspace/runs.js +4 -2
  120. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  121. package/dist/workspace/trust/verify-cli.js +2 -1
  122. package/dist/workspace/trust/verify.d.ts +22 -0
  123. package/dist/workspace/trust/verify.d.ts.map +1 -1
  124. package/dist/workspace/trust/verify.js +94 -14
  125. package/dist/workspace/write-scope.d.ts +3 -1
  126. package/dist/workspace/write-scope.d.ts.map +1 -1
  127. package/dist/workspace/write-scope.js +11 -4
  128. package/package.json +1 -1
  129. package/src/ci-jobs.ts +58 -1
  130. package/src/cli/commands/build.ts +18 -0
  131. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  132. package/src/cli/handlers/approve.ts +9 -0
  133. package/src/cli/handlers/emulator.test.ts +61 -1
  134. package/src/cli/handlers/emulator.ts +65 -4
  135. package/src/cli/handlers/promote.test.ts +7 -0
  136. package/src/cli/handlers/promote.ts +7 -1
  137. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  138. package/src/cli/handlers/run-wave.ts +5 -4
  139. package/src/cli/handlers/run.test.ts +126 -0
  140. package/src/cli/handlers/run.ts +69 -9
  141. package/src/cli/main.test.ts +34 -0
  142. package/src/cli/main.ts +38 -11
  143. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  144. package/src/cli/mcp/workspace-tools.ts +27 -4
  145. package/src/cli/parse-args.ts +21 -0
  146. package/src/cli/registry.ts +12 -0
  147. package/src/components/cli-support.test.ts +75 -0
  148. package/src/components/cli-support.ts +130 -25
  149. package/src/components/driver.test.ts +28 -0
  150. package/src/components/driver.ts +23 -10
  151. package/src/components/generate-disabled.test.ts +56 -0
  152. package/src/effect-receipt.ts +22 -0
  153. package/src/graph-ci-edges.test.ts +50 -0
  154. package/src/graph-detail.ts +21 -2
  155. package/src/lexicon-module.ts +9 -2
  156. package/src/lifecycle/git.ts +61 -6
  157. package/src/lifecycle/no-record.test.ts +175 -0
  158. package/src/lifecycle/no-record.ts +108 -0
  159. package/src/lifecycle/receipt-plan.test.ts +9 -0
  160. package/src/lifecycle/receipt-plan.ts +4 -1
  161. package/src/op/activities/shell.ts +28 -6
  162. package/src/op/effect-step.test.ts +95 -1
  163. package/src/op/emulator-lifecycle.test.ts +24 -1
  164. package/src/op/emulator-lifecycle.ts +58 -2
  165. package/src/op/gate.ts +3 -0
  166. package/src/op/index.ts +1 -1
  167. package/src/op/local-executor.test.ts +65 -0
  168. package/src/op/local-executor.ts +88 -8
  169. package/src/op/op-waves-jobs.test.ts +213 -0
  170. package/src/op/op-waves-notify.ts +11 -5
  171. package/src/op/op-waves-run.ts +42 -11
  172. package/src/op/op-waves.ts +202 -2
  173. package/src/op/receipt-store.ts +13 -3
  174. package/src/op/runtimes/local.ts +6 -0
  175. package/src/op/step-console.test.ts +150 -0
  176. package/src/op/step-console.ts +155 -0
  177. package/src/op/step-output-ref.test.ts +68 -24
  178. package/src/op/step-output-ref.ts +85 -65
  179. package/src/point-decision-bundle.test.ts +1 -0
  180. package/src/point-decision.ts +3 -0
  181. package/src/workspace/agent-cli.ts +2 -0
  182. package/src/workspace/compose-graph.test.ts +55 -0
  183. package/src/workspace/compose-graph.ts +28 -6
  184. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  185. package/src/workspace/conformance/writer.ts +1 -1
  186. package/src/workspace/decide.test.ts +87 -3
  187. package/src/workspace/decide.ts +142 -17
  188. package/src/workspace/graph-contract.test.ts +26 -1
  189. package/src/workspace/graph.schema.json +68 -1
  190. package/src/workspace/lineage-upgrade.test.ts +21 -0
  191. package/src/workspace/lineage-upgrade.ts +19 -11
  192. package/src/workspace/nested-graph.test.ts +14 -0
  193. package/src/workspace/nested-graph.ts +7 -2
  194. package/src/workspace/point-answer.schema.json +16 -1
  195. package/src/workspace/points-cli.ts +16 -8
  196. package/src/workspace/points-write.schema.json +39 -4
  197. package/src/workspace/points.schema.json +42 -3
  198. package/src/workspace/reason-codes.ts +2 -1
  199. package/src/workspace/record-assets.test.ts +14 -0
  200. package/src/workspace/records-cli.ts +28 -7
  201. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  202. package/src/workspace/records-write.ts +2 -3
  203. package/src/workspace/records.schema.json +2 -1
  204. package/src/workspace/records.ts +17 -4
  205. package/src/workspace/runs.ts +4 -2
  206. package/src/workspace/trust/trust.test.ts +105 -0
  207. package/src/workspace/trust/verify-cli.ts +2 -1
  208. package/src/workspace/trust/verify.ts +106 -13
  209. package/src/workspace/write-scope.test.ts +18 -0
  210. package/src/workspace/write-scope.ts +16 -5
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  import { activity, phase, effect } from "./builders";
11
11
  import type { OpConfig } from "./types";
12
12
  import { activityContract, type ActivityContract } from "./activity-contract";
13
- import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs } from "./step-output-ref";
13
+ import { stepOutput, isStepOutputRef, collectStepOutputRefs, validateStepOutputRefs, validateStepOutputRefScope } from "./step-output-ref";
14
14
  import { EffectReceipt } from "../effect-receipt";
15
15
 
16
16
  const lifecycleDiffContract = activityContract(
@@ -248,39 +248,83 @@ describe("validateStepOutputRefs() — structural coverage", () => {
248
248
  expect(issues.some((i) => i.message.includes("declares no return schema"))).toBe(true);
249
249
  });
250
250
 
251
- it("flags a reference authored inside onFailure", () => {
251
+ it("reports every issue across multiple refs, not just the first", () => {
252
+ const apply1 = activity("applyStacks", { stacks: stepOutput("nope1", "x") });
253
+ const apply2 = activity("applyStacks", { stacks: stepOutput("nope2", "x") });
254
+ const config = opWith({ phases: [phase("P", [apply1, apply2])] });
255
+ const issues = validateStepOutputRefs(config, new Map([["applyStacks", applyStacksContract]]));
256
+ expect(issues.some((i) => i.message.includes("nope1"))).toBe(true);
257
+ expect(issues.some((i) => i.message.includes("nope2"))).toBe(true);
258
+ });
259
+ });
260
+
261
+ // ── onFailure and effect steps (#3948) ───────────────────────────────────────
262
+
263
+ describe("validateStepOutputRefs() — onFailure and effect steps (#3948)", () => {
264
+ const seeded = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
265
+ const contracts = new Map<string, ActivityContract>([
266
+ ["lifecycleDiff", lifecycleDiffContract],
267
+ ["applyStacks", applyStacksContract],
268
+ ]);
269
+
270
+ it("an onFailure step may reference any main-phase step", () => {
252
271
  const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
272
+ const apply = activity("applyStacks", { stacks: ["a"] });
253
273
  const rollback = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
274
+ const config = opWith({ phases: [phase("Diff", [diff]), phase("Apply", [apply])], onFailure: [phase("Rollback", [rollback])] });
275
+ expect(validateStepOutputRefs(config, contracts)).toEqual([]);
276
+ });
277
+
278
+ it("an onFailure step's reference is still checked against the producer's contract", () => {
279
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
280
+ const rollback = activity("applyStacks", { stacks: stepOutput("diff", "nope") });
254
281
  const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Rollback", [rollback])] });
255
- const contracts = new Map<string, ActivityContract>([
256
- ["lifecycleDiff", lifecycleDiffContract],
257
- ["applyStacks", applyStacksContract],
258
- ]);
259
282
  const issues = validateStepOutputRefs(config, contracts);
260
- expect(issues.some((i) => i.message.includes("onFailure compensation"))).toBe(true);
283
+ expect(issues).toHaveLength(1);
284
+ expect(issues[0].phase).toBe("Rollback");
285
+ expect(issues[0].message).toMatch(/path "nope", which does not exist/);
286
+ });
287
+
288
+ it("an onFailure step cannot reference another onFailure step", () => {
289
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "undoDiff" });
290
+ const rollback = activity("applyStacks", { stacks: stepOutput("undoDiff", "driftedStacks") });
291
+ const config = opWith({ phases: [phase("P", [activity("applyStacks", { stacks: [] })])], onFailure: [phase("Rollback", [diff, rollback])] });
292
+ expect(validateStepOutputRefScope(config)[0].message).toMatch(/unknown step id "undoDiff"/);
261
293
  });
262
294
 
263
- it("flags a reference authored inside an effect step's nested steps", () => {
264
- const receipt = EffectReceipt("seeded", { effect: "db-seed", flavor: "hash", inputs: { file: "seed.sql", version: 3 } });
295
+ it("a step inside an effect may reference a step that runs before the effect", () => {
265
296
  const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
266
297
  const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
267
- const effectStep = effect(receipt, [nested]);
268
- const config = opWith({ phases: [phase("P", [diff, effectStep])] });
269
- const contracts = new Map<string, ActivityContract>([
270
- ["lifecycleDiff", lifecycleDiffContract],
271
- ["applyStacks", applyStacksContract],
272
- ]);
273
- const issues = validateStepOutputRefs(config, contracts);
274
- expect(issues.some((i) => i.message.includes("nested inside an effect step"))).toBe(true);
298
+ const config = opWith({ phases: [phase("P", [diff, effect(seeded, [nested])])] });
299
+ expect(validateStepOutputRefs(config, contracts)).toEqual([]);
275
300
  });
276
301
 
277
- it("reports every issue across multiple refs, not just the first", () => {
278
- const apply1 = activity("applyStacks", { stacks: stepOutput("nope1", "x") });
279
- const apply2 = activity("applyStacks", { stacks: stepOutput("nope2", "x") });
280
- const config = opWith({ phases: [phase("P", [apply1, apply2])] });
281
- const issues = validateStepOutputRefs(config, new Map([["applyStacks", applyStacksContract]]));
282
- expect(issues.some((i) => i.message.includes("nope1"))).toBe(true);
283
- expect(issues.some((i) => i.message.includes("nope2"))).toBe(true);
302
+ it("a step inside an effect in onFailure may reference a main-phase step", () => {
303
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
304
+ const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
305
+ const config = opWith({ phases: [phase("P", [diff])], onFailure: [phase("Undo", [effect(seeded, [nested])])] });
306
+ expect(validateStepOutputRefs(config, contracts)).toEqual([]);
307
+ });
308
+
309
+ it("a step inside an effect cannot reference a step after the effect", () => {
310
+ const nested = activity("applyStacks", { stacks: stepOutput("diff", "driftedStacks") });
311
+ const diff = activity("lifecycleDiff", { env: "prod" }, { id: "diff" });
312
+ const config = opWith({ phases: [phase("P", [effect(seeded, [nested]), diff])] });
313
+ expect(validateStepOutputRefScope(config)[0].message).toMatch(/step "diff" does not run before this step/);
314
+ });
315
+
316
+ it("nothing can reference a step nested inside an effect (decision A7)", () => {
317
+ const inner = activity("lifecycleDiff", { env: "prod" }, { id: "inner" });
318
+ const sibling = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
319
+ const later = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
320
+ const rollback = activity("applyStacks", { stacks: stepOutput("inner", "driftedStacks") });
321
+ const config = opWith({
322
+ phases: [phase("P", [effect(seeded, [inner, sibling]), later])],
323
+ onFailure: [phase("Rollback", [rollback])],
324
+ });
325
+ const issues = validateStepOutputRefScope(config);
326
+ expect(issues).toHaveLength(3);
327
+ for (const issue of issues) expect(issue.message).toMatch(/"inner", which is nested inside an effect step/);
284
328
  });
285
329
  });
286
330
 
@@ -23,15 +23,20 @@
23
23
  * Scope, kept deliberately tight (see the issue's "Open questions"):
24
24
  * - same-Op only — no cross-Op plumbing (that needs a durable place to put
25
25
  * the value, which #1290 explicitly defers).
26
- * - main `phases` only — a step inside `onFailure` or nested inside an
27
- * `EffectStep` can neither produce nor consume a step-output reference.
28
- * `onFailure` compensation runs only when something upstream already
29
- * failed, so a main-phase step's captured result isn't reliably
30
- * available there; an effect step's nested steps run only when the
31
- * receipt comparison mismatches, so a value it produces isn't reliably
32
- * available to anything outside it either. Both are conditional
33
- * execution paths, which is exactly the class of thing #1290 keeps out
34
- * of an Op's inert-data model.
26
+ * - producers are the top-level activity steps of the main `phases`. A step
27
+ * in `onFailure` or nested inside an `EffectStep` produces nothing another
28
+ * step may reference: an effect's nested steps run only when the receipt
29
+ * comparison mismatches, so a value one of them returns isn't reliably
30
+ * there for anything else (#3948 decision A7, "for now").
31
+ * - consumers are those producers, plus (#3948) every activity step in
32
+ * `onFailure` and every activity step nested inside an effect. An
33
+ * `onFailure` step may reference any main-phase producer. Compensation
34
+ * runs after a failure, so its producer may never have completed; the
35
+ * executor then skips the compensation step and records why (decision
36
+ * A6), rather than calling it with an `undefined` in place of the value.
37
+ * A step nested inside an effect may reference a producer that runs
38
+ * before the effect, and the executor folds the resolved values into the
39
+ * effect's expectation, so a changed value re-fires the effect.
35
40
  * - producer must run to completion before the consumer starts: an earlier
36
41
  * phase, or an earlier step within the same non-parallel phase. Two
37
42
  * steps in the same parallel phase run concurrently (`Promise.all`) with
@@ -243,26 +248,63 @@ function locateStepOutputRefPath(value: unknown, target: StepOutputRef, path: st
243
248
  // ── Validation ──────────────────────────────────────────────────────────────
244
249
 
245
250
  interface StepLocation {
251
+ /** Index into `phases`; every `onFailure` phase sits after all of them. */
246
252
  phaseIndex: number;
253
+ /** Index into the phase's `steps`. A step nested in an effect takes the effect's. */
247
254
  stepIndex: number;
248
255
  parallel: boolean;
249
256
  step: ActivityStep;
257
+ phaseName: string;
250
258
  }
251
259
 
252
- /** Top-level `ActivityStep`s of `phases`, in authored (phase, then step) order — gates, effects, and steps nested inside an effect are not producers or consumers (see the module doc's scope note). */
260
+ /** Top-level `ActivityStep`s of `phases`, in authored (phase, then step) order: the producers (see the module doc's scope note). */
253
261
  function locateActivitySteps(phases: PhaseDefinition[]): StepLocation[] {
254
262
  const locations: StepLocation[] = [];
255
263
  phases.forEach((phase, phaseIndex) => {
256
- let stepIndex = 0;
257
- for (const step of phase.steps) {
258
- if (step.kind !== "activity") continue;
259
- locations.push({ phaseIndex, stepIndex, parallel: phase.parallel === true, step });
260
- stepIndex++;
261
- }
264
+ phase.steps.forEach((step, stepIndex) => {
265
+ if (step.kind !== "activity") return;
266
+ locations.push({ phaseIndex, stepIndex, parallel: phase.parallel === true, step, phaseName: phase.name });
267
+ });
262
268
  });
263
269
  return locations;
264
270
  }
265
271
 
272
+ /**
273
+ * Every activity step that may hold a reference (#3948): the producers, each
274
+ * step nested inside an effect of a main phase, and every activity step of
275
+ * `onFailure`, effect-nested ones included. An `onFailure` step is placed after
276
+ * every main phase, so any main-phase producer precedes it.
277
+ */
278
+ function locateConsumers(config: Pick<OpConfig, "phases" | "onFailure">): StepLocation[] {
279
+ const consumers: StepLocation[] = [];
280
+ const visit = (phases: PhaseDefinition[], offset: number) => {
281
+ phases.forEach((phase, i) => {
282
+ const at = { phaseIndex: offset + i, parallel: phase.parallel === true, phaseName: phase.name };
283
+ phase.steps.forEach((step, stepIndex) => {
284
+ if (step.kind === "activity") consumers.push({ ...at, stepIndex, step });
285
+ if (step.kind === "effect") {
286
+ for (const nested of step.steps) if (nested.kind === "activity") consumers.push({ ...at, stepIndex, step: nested });
287
+ }
288
+ });
289
+ });
290
+ };
291
+ visit(config.phases, 0);
292
+ visit(config.onFailure ?? [], config.phases.length);
293
+ return consumers;
294
+ }
295
+
296
+ /** Ids of the activity steps nested inside an effect, in any phase. Nothing may reference them (#3948 decision A7). */
297
+ function effectNestedIds(config: Pick<OpConfig, "phases" | "onFailure">): Set<string> {
298
+ const ids = new Set<string>();
299
+ for (const phase of [...config.phases, ...(config.onFailure ?? [])]) {
300
+ for (const step of phase.steps) {
301
+ if (step.kind !== "effect") continue;
302
+ for (const nested of step.steps) if (nested.kind === "activity" && nested.id) ids.add(nested.id);
303
+ }
304
+ }
305
+ return ids;
306
+ }
307
+
266
308
  /** True when `producer` is guaranteed to have completed before `consumer` starts. */
267
309
  function producerPrecedes(producer: StepLocation, consumer: StepLocation): boolean {
268
310
  if (producer.phaseIndex !== consumer.phaseIndex) return producer.phaseIndex < consumer.phaseIndex;
@@ -270,10 +312,6 @@ function producerPrecedes(producer: StepLocation, consumer: StepLocation): boole
270
312
  return producer.stepIndex < consumer.stepIndex;
271
313
  }
272
314
 
273
- function effectNestedActivitySteps(phase: PhaseDefinition): ActivityStep[] {
274
- return phase.steps.flatMap((s) => (s.kind === "effect" ? s.steps.filter((n) => n.kind === "activity") : []));
275
- }
276
-
277
315
  /** `byId`/`duplicateIds` over an Op's top-level main-phase activity steps — shared by {@link validateStepOutputRefScope} and {@link validateStepOutputRefs}. */
278
316
  function indexById(locations: StepLocation[]): { byId: Map<string, StepLocation>; duplicateIds: Set<string> } {
279
317
  const byId = new Map<string, StepLocation>();
@@ -298,46 +336,28 @@ function indexById(locations: StepLocation[]): { byId: Map<string, StepLocation>
298
336
  * a public export a caller can invoke directly, bypassing that lint pass —
299
337
  * this is its own defense-in-depth.
300
338
  *
301
- * Flags:
302
- * - a reference authored inside `onFailure`, or inside an `EffectStep`'s
303
- * nested steps — out of scope by design (see the module doc); flagged
304
- * explicitly here rather than left to fall through as "unknown producer",
339
+ * Consumers are the main-phase activity steps, the steps nested inside an
340
+ * effect, and every `onFailure` activity step (#3948). Flags:
341
+ * - a reference to a step nested inside an effect step, from anywhere
342
+ * (#3948 decision A7) — flagged explicitly here rather than left to fall
343
+ * through as "unknown producer",
305
344
  * - a reference to an unregistered step id (no step in scope declares it),
306
345
  * - a duplicate step id (ambiguous producer),
307
346
  * - a reference to a step that does not precede the referencing step
308
347
  * (a later phase, a later step in the same phase, itself, or a step in
309
- * the same parallel phase).
348
+ * the same parallel phase). A step nested inside an effect takes the
349
+ * effect's position, so it may reference only a step that runs before
350
+ * the effect. An `onFailure` step comes after every main phase.
310
351
  */
311
352
  export function validateStepOutputRefScope(
312
353
  config: Pick<OpConfig, "name" | "phases" | "onFailure"> & Partial<Pick<OpConfig, "workLease">>,
313
354
  ): ActivityContractIssue[] {
314
355
  const issues: ActivityContractIssue[] = [];
315
356
 
316
- const flagOutOfScope = (steps: ActivityStep[], phaseName: string, reason: string) => {
317
- for (const step of steps) {
318
- for (const ref of collectStepOutputRefs(step.args)) {
319
- issues.push({
320
- opName: config.name,
321
- phase: phaseName,
322
- fn: step.fn,
323
- message: `references step "${ref.step}"'s output, but ${reason}`,
324
- });
325
- }
326
- }
327
- };
328
- for (const phase of config.onFailure ?? []) {
329
- const steps = phase.steps.filter((s): s is ActivityStep => s.kind === "activity").concat(effectNestedActivitySteps(phase));
330
- flagOutOfScope(steps, phase.name, "step-output references are not supported in onFailure compensation phases");
331
- }
332
- for (const phase of config.phases) {
333
- flagOutOfScope(effectNestedActivitySteps(phase), phase.name, "step-output references are not supported for a step nested inside an effect step");
334
- }
335
-
336
- const locations = locateActivitySteps(config.phases);
337
- const { byId, duplicateIds } = indexById(locations);
338
- const phaseNameOf = (loc: StepLocation): string => config.phases[loc.phaseIndex]!.name;
357
+ const { byId, duplicateIds } = indexById(locateActivitySteps(config.phases));
358
+ const nestedIds = effectNestedIds(config);
339
359
 
340
- for (const consumer of locations) {
360
+ for (const consumer of locateConsumers(config)) {
341
361
  const refs = collectStepOutputRefs(consumer.step.args);
342
362
  for (const ref of refs) {
343
363
  // The run's work lease (#2748) is published under a reserved id once it
@@ -346,7 +366,7 @@ export function validateStepOutputRefScope(
346
366
  if (duplicateIds.has(ref.step)) {
347
367
  issues.push({
348
368
  opName: config.name,
349
- phase: phaseNameOf(consumer),
369
+ phase: consumer.phaseName,
350
370
  fn: consumer.step.fn,
351
371
  message: `references step id "${ref.step}", but that id is used by more than one step in this Op — ids must be unique`,
352
372
  });
@@ -357,9 +377,11 @@ export function validateStepOutputRefScope(
357
377
  if (!producer) {
358
378
  issues.push({
359
379
  opName: config.name,
360
- phase: phaseNameOf(consumer),
380
+ phase: consumer.phaseName,
361
381
  fn: consumer.step.fn,
362
- message: `references unknown step id "${ref.step}" — no step in this Op's main phases declares that id`,
382
+ message: nestedIds.has(ref.step)
383
+ ? `references step "${ref.step}", which is nested inside an effect step — a step inside an effect runs only when the effect fires, so no other step can reference its output`
384
+ : `references unknown step id "${ref.step}" — no step in this Op's main phases declares that id`,
363
385
  });
364
386
  continue;
365
387
  }
@@ -373,7 +395,7 @@ export function validateStepOutputRefScope(
373
395
  : `step "${ref.step}" does not run before this step`;
374
396
  issues.push({
375
397
  opName: config.name,
376
- phase: phaseNameOf(consumer),
398
+ phase: consumer.phaseName,
377
399
  fn: consumer.step.fn,
378
400
  message: `references ${reason} — a step can only reference an earlier step's output`,
379
401
  });
@@ -385,10 +407,10 @@ export function validateStepOutputRefScope(
385
407
  }
386
408
 
387
409
  /**
388
- * Validate every step-output reference in an Op's main phases against a
389
- * contract map and the Op's own step ordering. Scope: `config.phases` only
390
- * (never `onFailure`, never a step nested inside an `EffectStep` — see the
391
- * module doc). Layers contract-based checks on top of {@link
410
+ * Validate every step-output reference in an Op against a contract map and
411
+ * the Op's own step ordering, over the consumers {@link
412
+ * validateStepOutputRefScope} covers (see the module doc). Layers
413
+ * contract-based checks on top of {@link
392
414
  * validateStepOutputRefScope}'s scope/ordering checks.
393
415
  *
394
416
  * Flags everything {@link validateStepOutputRefScope} does, plus:
@@ -404,11 +426,9 @@ export function validateStepOutputRefs(
404
426
  ): ActivityContractIssue[] {
405
427
  const issues = validateStepOutputRefScope(config);
406
428
 
407
- const locations = locateActivitySteps(config.phases);
408
- const { byId, duplicateIds } = indexById(locations);
409
- const phaseNameOf = (loc: StepLocation): string => config.phases[loc.phaseIndex]!.name;
429
+ const { byId, duplicateIds } = indexById(locateActivitySteps(config.phases));
410
430
 
411
- for (const consumer of locations) {
431
+ for (const consumer of locateConsumers(config)) {
412
432
  const refs = collectStepOutputRefs(consumer.step.args);
413
433
  for (const ref of refs) {
414
434
  // Already flagged by validateStepOutputRefScope — nothing to validate
@@ -421,7 +441,7 @@ export function validateStepOutputRefs(
421
441
  if (!producerContract) {
422
442
  issues.push({
423
443
  opName: config.name,
424
- phase: phaseNameOf(consumer),
444
+ phase: consumer.phaseName,
425
445
  fn: consumer.step.fn,
426
446
  message: `references step "${ref.step}"'s output, but "${producer.step.fn}" has no registered activity contract to validate the reference against`,
427
447
  });
@@ -430,7 +450,7 @@ export function validateStepOutputRefs(
430
450
  if (!producerContract.returns) {
431
451
  issues.push({
432
452
  opName: config.name,
433
- phase: phaseNameOf(consumer),
453
+ phase: consumer.phaseName,
434
454
  fn: consumer.step.fn,
435
455
  message: `references step "${ref.step}"'s output, but "${producer.step.fn}"'s contract declares no return schema`,
436
456
  });
@@ -439,7 +459,7 @@ export function validateStepOutputRefs(
439
459
  if (ref.path && !pathExistsInSchema(producerContract.returns as z.ZodTypeAny, ref.path)) {
440
460
  issues.push({
441
461
  opName: config.name,
442
- phase: phaseNameOf(consumer),
462
+ phase: consumer.phaseName,
443
463
  fn: consumer.step.fn,
444
464
  message: `references step "${ref.step}"'s output path "${ref.path}", which does not exist on "${producer.step.fn}"'s declared return type`,
445
465
  });
@@ -466,7 +486,7 @@ export function validateStepOutputRefs(
466
486
  if (consumerKind && producerKind && consumerKind !== producerKind) {
467
487
  issues.push({
468
488
  opName: config.name,
469
- phase: phaseNameOf(consumer),
489
+ phase: consumer.phaseName,
470
490
  fn: consumer.step.fn,
471
491
  message: `references step "${ref.step}"'s output path "${ref.path}" (${producerKind}), but arg "${argPath.join(".")}" on "${consumer.step.fn}" declares ${consumerKind} — type mismatch`,
472
492
  });
@@ -51,6 +51,7 @@ describe("the point-decision bundle", () => {
51
51
  "packages/core/src/intrinsic.ts",
52
52
  "packages/core/src/lifecycle/gate-ledger-select.ts",
53
53
  "packages/core/src/lifecycle/gate-pending.ts",
54
+ "packages/core/src/lifecycle/no-record.ts",
54
55
  "packages/core/src/lifecycle/plan-digest.ts",
55
56
  "packages/core/src/op/duration.ts",
56
57
  "packages/core/src/op/gate-point.ts",
@@ -37,6 +37,7 @@ import type { GateLedgerPort } from "./op/gate";
37
37
  import { DEFAULT_GATE_POINT_PASS, defaultGatePointStore, gatePointInputs, gateSubject, type GatePoint, type GatePointFacts, type GatePointStore } from "./op/gate-point";
38
38
  import type { WaitingPoint } from "./op/steward-points";
39
39
  import { resolveGateRunLocator, type GateRunLocator } from "./op/gate-run-locator";
40
+ import { refuseGateWithoutRecording } from "./lifecycle/no-record";
40
41
 
41
42
  export {
42
43
  DEFAULT_GATE_POINT_PASS,
@@ -170,6 +171,8 @@ function resolvedByOf(q: GatePointQuestion): string {
170
171
 
171
172
  /** Decide a gate that asks a point: ask it, then record the pending fact or the resolution that cites the answer. */
172
173
  export async function evaluatePointGate(port: PointGateLedgerPort, asker: GatePointAsker, input: PointGateInput): Promise<PointGateCheck> {
174
+ // #3955: refused before the point is asked, which would itself write to the ledger.
175
+ refuseGateWithoutRecording(input.op, input.gate);
173
176
  const now = input.now ?? new Date().toISOString();
174
177
  const facts: GatePointFacts = { component: input.op, name: input.gate, env: input.env ?? null, planDigest: input.planDigest ?? null };
175
178
  const q = await asker.ask({ point: input.point.name, inputs: input.inputs ?? {}, facts, subject: gateSubject(input.op, input.gate, input.env), ...(input.point.store ? { store: input.point.store } : {}) });
@@ -91,6 +91,8 @@ export async function agentSession(q: { cwd: string; name: string }): Promise<Ag
91
91
  try {
92
92
  const source = scopeSource(q.cwd);
93
93
  if (source.declaration === null || source.from === null) {
94
+ // A declaration that is there but fails validation says so (#3433).
95
+ if (source.failure && source.failure.code !== "declaration-missing") throw source.failure;
94
96
  throw new WorkspaceReadError("declaration-missing", "no chant.workspace.json or .jsonc between this directory and the git root, so no agent session is declared");
95
97
  }
96
98
  const decl = source.declaration;
@@ -104,6 +104,61 @@ describe("composeWorkspaceGraph", () => {
104
104
  });
105
105
  });
106
106
 
107
+ describe("CI edges, pipelines and dangling references (#3922)", () => {
108
+ /** A github member as `chant graph` reports it after #3910. */
109
+ const ci = (workflow: string, job: string): GraphIR => ({
110
+ version: 1,
111
+ nodes: [
112
+ { id: workflow, kind: "Workflow", lexicon: "github", attrs: {} },
113
+ { id: job, kind: "Job", lexicon: "github", attrs: {} },
114
+ { id: "deploy", kind: "Job", lexicon: "github", attrs: {} },
115
+ ],
116
+ edges: [
117
+ { from: "deploy", to: job, kind: "needs", viaAttr: "needs" },
118
+ { from: job, to: workflow, kind: "calls", viaAttr: "uses", crossPipeline: true },
119
+ ],
120
+ groups: { byLexicon: { github: [job, "deploy", workflow] }, byPipeline: { ci: [job, "deploy", workflow] } },
121
+ dangling: [
122
+ { from: "deploy", kind: "needs", path: "needs", value: "lint" },
123
+ { from: job, kind: "calls", path: "uses", value: "other/repo/.github/workflows/x.yml@main", crossPipeline: true },
124
+ ],
125
+ });
126
+ const doc = composeWorkspaceGraph({ name: "acme", root: "." }, [
127
+ { member: member("svc", "svc"), ir: ci("ciWorkflow", "build") },
128
+ { member: member("app", "app"), ir: ci("ciWorkflow", "test") },
129
+ { member: member("db", "db"), ir: db },
130
+ ]);
131
+
132
+ test("edges keep their kind and crossPipeline", () => {
133
+ expect(doc.edges.filter((e) => e.member === "svc")).toEqual([
134
+ { from: "svc/build", to: "svc/ciWorkflow", kind: "calls", viaAttr: "uses", crossPipeline: true, member: "svc" },
135
+ { from: "svc/deploy", to: "svc/build", kind: "needs", viaAttr: "needs", member: "svc" },
136
+ ]);
137
+ });
138
+
139
+ test("byPipeline keys and ids are prefixed with the member", () => {
140
+ expect(doc.groups.byPipeline).toEqual({
141
+ "app/ci": ["app/ciWorkflow", "app/deploy", "app/test"],
142
+ "svc/ci": ["svc/build", "svc/ciWorkflow", "svc/deploy"],
143
+ });
144
+ });
145
+
146
+ test("dangling references keep their fields, with from prefixed and the member named, sorted", () => {
147
+ expect(doc.dangling).toEqual([
148
+ { from: "app/deploy", kind: "needs", path: "needs", value: "lint", member: "app" },
149
+ { from: "app/test", kind: "calls", path: "uses", value: "other/repo/.github/workflows/x.yml@main", crossPipeline: true, member: "app" },
150
+ { from: "svc/build", kind: "calls", path: "uses", value: "other/repo/.github/workflows/x.yml@main", crossPipeline: true, member: "svc" },
151
+ { from: "svc/deploy", kind: "needs", path: "needs", value: "lint", member: "svc" },
152
+ ]);
153
+ });
154
+
155
+ test("no dangling section and no byPipeline when no member reports either", () => {
156
+ const plain = composeWorkspaceGraph({ name: "acme", root: "." }, [{ member: member("db", "db"), ir: db }]);
157
+ expect("dangling" in plain).toBe(false);
158
+ expect(plain.groups.byPipeline).toBeUndefined();
159
+ });
160
+ });
161
+
107
162
  describe("the links section (#2539)", () => {
108
163
  test("inferred joins carry their label, and a declared link suppresses the inferred edge it covers", () => {
109
164
  const consumer: GraphIR = {
@@ -11,8 +11,12 @@
11
11
  * `runtimeOwner` and export and import nodes are rewritten the same way.
12
12
  * - `groups.byMember` maps each member to its node ids. `byLexicon` and
13
13
  * `byComposite` keep their keys (a lexicon or composite type is the same
14
- * thing in every member) and merge the ids. `byStack`, `byContainer` and
15
- * `byWave` name things inside one member, so their keys are prefixed too.
14
+ * thing in every member) and merge the ids. `byStack`, `byContainer`,
15
+ * `byWave` and `byPipeline` name things inside one member, so their keys
16
+ * are prefixed too.
17
+ * - Edges keep their `kind` and `crossPipeline` (#3910). A member's dangling
18
+ * references (`ir.dangling`) land on the top-level `dangling` list with
19
+ * `from` prefixed and a `member` field, like edges (#3922).
16
20
  * - `links` holds the member links (#2524 D6, #2539): the declared links and
17
21
  * the joins inferred from members' `imports` and `exports` with the core
18
22
  * `joinKey()`, labelled `exact` or `folded` (`./links.ts`). A declared link
@@ -36,7 +40,7 @@ import { graphLinks, type LinkTableRow } from "./links";
36
40
  import type { KindLinkRead } from "./kind-link-readers";
37
41
  import type { RecordLinkRow } from "./record-assets";
38
42
  import type { ReasonCode } from "./reason-codes";
39
- import { GRAPH_IR_VERSION, type GraphIR, type IRExport, type IRGroups, type IRImport, type IREdge, type IRNode } from "../graph-ir";
43
+ import { GRAPH_IR_VERSION, type GraphIR, type IRExport, type IRDanglingRef, type IRGroups, type IRImport, type IREdge, type IRNode } from "../graph-ir";
40
44
 
41
45
  /** The version of the composed document `chant workspace graph` writes. */
42
46
  export const WORKSPACE_GRAPH_VERSION = 1;
@@ -165,6 +169,8 @@ export const COLLECTOR_META_KEY = "collector";
165
169
  /** `nested` names the nested workspace's member a node was read through (#2551); absent for the outer workspace's own nodes. */
166
170
  export type ComposedNode = IRNode & { member: string; nested?: string };
167
171
  export type ComposedEdge = IREdge & { member: string };
172
+ /** A member's dangling reference (#3910, #3922): `from` is prefixed like a node id. `nested` as on {@link ComposedNode}. */
173
+ export type ComposedDangling = IRDanglingRef & { member: string; nested?: string };
168
174
 
169
175
  export interface WorkspaceGraph {
170
176
  version: number;
@@ -176,6 +182,12 @@ export interface WorkspaceGraph {
176
182
  exports: (IRExport & { member: string })[];
177
183
  imports: (IRImport & { member: string })[];
178
184
  derivedAttrs?: Record<string, string[]>;
185
+ /**
186
+ * The members' dangling CI references (#3910, #3922): each `ir.dangling`
187
+ * entry with `from` prefixed by its member and a `member` field, sorted by
188
+ * `from`, `path` and `value`. Absent when no member has one.
189
+ */
190
+ dangling?: ComposedDangling[];
179
191
  /**
180
192
  * Member links (#2524 D6, #2539), declared and inferred, then the links of
181
193
  * the records read with `--kind` (#2549). Empty when no declaration is given.
@@ -289,6 +301,13 @@ function prefixRefs(value: unknown, member: string): unknown {
289
301
  return out;
290
302
  }
291
303
 
304
+ /** The groups whose keys name something inside one member, so are prefixed with it. */
305
+ export const SCOPED_GROUPS = ["byStack", "byContainer", "byWave", "byPipeline"] as const;
306
+ type ScopedGroup = (typeof SCOPED_GROUPS)[number];
307
+
308
+ /** The order the composed `dangling` list is sorted in. */
309
+ export const danglingKey = (d: IRDanglingRef): string => `${d.from}\u0000${d.path}\u0000${d.value}`;
310
+
292
311
  function mergeInto(target: Record<string, string[]>, key: string, ids: string[]): void {
293
312
  (target[key] ??= []).push(...ids);
294
313
  }
@@ -317,7 +336,8 @@ export function composeWorkspaceGraph(
317
336
  const imports: WorkspaceGraph["imports"] = [];
318
337
  const byMember: Record<string, string[]> = {};
319
338
  const shared: Record<"byLexicon" | "byComposite", Record<string, string[]>> = { byLexicon: {}, byComposite: {} };
320
- const scoped: Record<"byStack" | "byContainer" | "byWave", Record<string, string[]>> = { byStack: {}, byContainer: {}, byWave: {} };
339
+ const scoped: Record<ScopedGroup, Record<string, string[]>> = { byStack: {}, byContainer: {}, byWave: {}, byPipeline: {} };
340
+ const dangling: ComposedDangling[] = [];
321
341
  const derivedAttrs: Record<string, Set<string>> = {};
322
342
  const collectors: MemberCollector[] = [];
323
343
 
@@ -337,9 +357,10 @@ export function composeWorkspaceGraph(
337
357
  for (const key of ["byLexicon", "byComposite"] as const) {
338
358
  for (const [k, v] of Object.entries(ir.groups[key] ?? {})) mergeInto(shared[key], k, v.map((id) => prefix(m, id)));
339
359
  }
340
- for (const key of ["byStack", "byContainer", "byWave"] as const) {
360
+ for (const key of SCOPED_GROUPS) {
341
361
  for (const [k, v] of Object.entries(ir.groups[key] ?? {})) mergeInto(scoped[key], prefix(m, k), v.map((id) => prefix(m, id)));
342
362
  }
363
+ for (const d of ir.dangling ?? []) dangling.push({ ...d, from: prefix(m, d.from), member: m });
343
364
  for (const e of ir.exports ?? []) exports.push({ ...e, ...(e.node ? { node: prefix(m, e.node) } : {}), member: m });
344
365
  for (const i of ir.imports ?? []) imports.push({ ...i, node: prefix(m, i.node), member: m });
345
366
  for (const [kind, attrs] of Object.entries(ir.derivedAttrs ?? {})) for (const a of attrs) (derivedAttrs[kind] ??= new Set()).add(a);
@@ -360,7 +381,7 @@ export function composeWorkspaceGraph(
360
381
 
361
382
  const groups: WorkspaceGraph["groups"] = { byMember };
362
383
  for (const key of ["byLexicon", "byComposite"] as const) if (Object.keys(shared[key]).length) groups[key] = sorted(shared[key]);
363
- for (const key of ["byStack", "byContainer", "byWave"] as const) if (Object.keys(scoped[key]).length) groups[key] = sorted(scoped[key]);
384
+ for (const key of SCOPED_GROUPS) if (Object.keys(scoped[key]).length) groups[key] = sorted(scoped[key]);
364
385
 
365
386
  const doc: WorkspaceGraph = {
366
387
  version: WORKSPACE_GRAPH_VERSION,
@@ -377,6 +398,7 @@ export function composeWorkspaceGraph(
377
398
  collectors,
378
399
  records: [],
379
400
  };
401
+ if (dangling.length) doc.dangling = dangling.sort((a, b) => danglingKey(a).localeCompare(danglingKey(b)));
380
402
  if (Object.keys(derivedAttrs).length) {
381
403
  doc.derivedAttrs = Object.fromEntries(Object.keys(derivedAttrs).sort().map((k) => [k, [...derivedAttrs[k]].sort()]));
382
404
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "urn:intentius:chant:point-answer:1",
4
4
  "title": "Decision point answer front matter",
5
- "description": "The front matter of one answer to a decision point (ws-058, #2739), as `chant workspace points ask` and `points answer` write it into the records directory of a record kind with an `answers` block. One record per point, declaration and inputs: its id is the point's name and the first 12 hex digits of the inputs hash, so the same question is answered once. A table's answer is `answered`; a model's answer at or above its threshold is `proposed` until a person confirms it; a question no decider before the quorum answered is `escalated`, open for people. An answer never changes in place: `points retract` escalates the question to people again and moves the answer into `retractions` (#3351, ws-084). An ad-hoc point's answer keeps the question and candidates its ask gave as `asked`, and they are part of the inputs hash (#3403). A workspace copies this file next to its answer kind, as reference-workspace/answers/ does. Values stay within what YAML and JSON share. Fields other than these must start with x-.",
5
+ "description": "The front matter of one answer to a decision point (ws-058, #2739), as `chant workspace points ask` and `points answer` write it into the records directory of a record kind with an `answers` block. One record per point, declaration and inputs: its id is the point's name and the first 12 hex digits of the inputs hash, so the same question is answered once. A table's answer is `answered`; a model's answer at or above its threshold is `proposed` until a person confirms it; a question no decider before the quorum answered is `escalated`, open for people. An answer never changes in place: `points retract` escalates the question to people again and moves the answer into `retractions` (#3351, ws-084). An open question that no longer applies is `withdrawn` (`points withdraw`, #2888), and asking it again opens it again. An ad-hoc point's answer keeps the question and candidates its ask gave as `asked`, and they are part of the inputs hash (#3403). A workspace copies this file next to its answer kind, as reference-workspace/answers/ does. Values stay within what YAML and JSON share. Fields other than these must start with x-.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "patternProperties": { "^x-": {} },
@@ -65,6 +65,17 @@
65
65
  "description": "Answers people took back, oldest first (`points retract`, #3351): each answer as it was, and who retracted it, when and why. A retracted question is escalated to people again.",
66
66
  "items": { "$ref": "#/definitions/retraction" }
67
67
  },
68
+ "withdrawn": {
69
+ "type": "object",
70
+ "description": "An open question that no longer applies, such as one about a work item its author changed, which a run asks about anew (`points withdraw`, #2888): who withdrew it, when and why. A withdrawn question is not open, and nobody answers it; asking it again opens it again.",
71
+ "required": ["by", "on"],
72
+ "properties": {
73
+ "by": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "description": "Who withdrew it." },
74
+ "on": { "$ref": "#/definitions/date" },
75
+ "note": { "type": "string", "minLength": 1, "description": "Why it was withdrawn (`points withdraw --note`)." }
76
+ },
77
+ "additionalProperties": false
78
+ },
68
79
  "asked_on": { "$ref": "#/definitions/date" },
69
80
  "answered_on": { "$ref": "#/definitions/date" },
70
81
  "source": {
@@ -85,6 +96,10 @@
85
96
  "if": { "properties": { "state": { "const": "escalated" } }, "required": ["state"] },
86
97
  "then": { "not": { "required": ["answer"] }, "properties": { "decider": { "properties": { "kind": { "const": "quorum" } } } } }
87
98
  },
99
+ {
100
+ "if": { "required": ["withdrawn"] },
101
+ "then": { "properties": { "state": { "enum": ["escalated", "proposed"] } } }
102
+ },
88
103
  {
89
104
  "if": { "properties": { "decider": { "properties": { "kind": { "const": "model" } }, "required": ["kind"] } }, "required": ["decider"] },
90
105
  "then": { "required": ["probabilities", "confidence", "threshold"], "properties": { "decider": { "required": ["backend", "model"] } } }
@@ -614,7 +614,7 @@ export function isReadCall(argv: readonly string[]): boolean {
614
614
  case "runs":
615
615
  return !["start", "end", "record"].includes(sub ?? "");
616
616
  case "points":
617
- return !["ask", "answer", "retract"].includes(sub ?? "");
617
+ return !["ask", "answer", "retract", "withdraw"].includes(sub ?? "");
618
618
  case "work":
619
619
  return sub === "history";
620
620
  case "wip":