@intentius/chant 0.124.24 → 0.124.26

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 (195) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/main.js +21 -7
  9. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/mcp/workspace-tools.js +27 -4
  12. package/dist/effect-receipt.d.ts +13 -0
  13. package/dist/effect-receipt.d.ts.map +1 -1
  14. package/dist/effect-receipt.js +19 -0
  15. package/dist/gate-decision.d.ts +1 -1
  16. package/dist/gate-decision.d.ts.map +1 -1
  17. package/dist/gate-decision.js +1 -1
  18. package/dist/lexicon-module.d.ts +7 -1
  19. package/dist/lexicon-module.d.ts.map +1 -1
  20. package/dist/lexicon-module.js +7 -2
  21. package/dist/lifecycle/gate-audit.d.ts +7 -1
  22. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  23. package/dist/lifecycle/gate-audit.js +40 -3
  24. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  25. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  26. package/dist/lifecycle/gate-checkpoint.js +103 -0
  27. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  28. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.js +18 -0
  30. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  31. package/dist/lifecycle/receipt-plan.js +4 -2
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/run-ledger.js +2 -0
  35. package/dist/lint/rules/op/index.d.ts +6 -2
  36. package/dist/lint/rules/op/index.d.ts.map +1 -1
  37. package/dist/lint/rules/op/index.js +7 -3
  38. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  39. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  40. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  41. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  42. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  43. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  44. package/dist/op/activity-contract.d.ts +14 -0
  45. package/dist/op/activity-contract.d.ts.map +1 -1
  46. package/dist/op/activity-contract.js +16 -0
  47. package/dist/op/builders.d.ts +7 -0
  48. package/dist/op/builders.d.ts.map +1 -1
  49. package/dist/op/builders.js +13 -0
  50. package/dist/op/gate-alias.d.ts +57 -0
  51. package/dist/op/gate-alias.d.ts.map +1 -0
  52. package/dist/op/gate-alias.js +127 -0
  53. package/dist/op/gate-checkpoint.d.ts +123 -0
  54. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  55. package/dist/op/gate-checkpoint.js +280 -0
  56. package/dist/op/gate.d.ts +54 -2
  57. package/dist/op/gate.d.ts.map +1 -1
  58. package/dist/op/gate.js +142 -1
  59. package/dist/op/index.d.ts +4 -1
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/index.js +2 -1
  62. package/dist/op/local-executor.d.ts +57 -2
  63. package/dist/op/local-executor.d.ts.map +1 -1
  64. package/dist/op/local-executor.js +200 -11
  65. package/dist/op/local-output.d.ts.map +1 -1
  66. package/dist/op/local-output.js +20 -2
  67. package/dist/op/op-ir.d.ts +4 -0
  68. package/dist/op/op-ir.d.ts.map +1 -1
  69. package/dist/op/op-ir.js +4 -0
  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/runtime.d.ts +15 -0
  74. package/dist/op/runtime.d.ts.map +1 -1
  75. package/dist/op/runtimes/local.d.ts.map +1 -1
  76. package/dist/op/runtimes/local.js +5 -0
  77. package/dist/op/step-output-ref.d.ts +26 -18
  78. package/dist/op/step-output-ref.d.ts.map +1 -1
  79. package/dist/op/step-output-ref.js +86 -63
  80. package/dist/op/types.d.ts +31 -0
  81. package/dist/op/types.d.ts.map +1 -1
  82. package/dist/wave-runtime.mjs +223 -221
  83. package/dist/workspace/agent-cli.d.ts.map +1 -1
  84. package/dist/workspace/agent-cli.js +3 -0
  85. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  86. package/dist/workspace/conformance/writer.js +1 -1
  87. package/dist/workspace/decide.d.ts +51 -9
  88. package/dist/workspace/decide.d.ts.map +1 -1
  89. package/dist/workspace/decide.js +125 -14
  90. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  91. package/dist/workspace/lineage-upgrade.js +20 -11
  92. package/dist/workspace/point-answer.schema.json +16 -1
  93. package/dist/workspace/points-cli.d.ts +5 -3
  94. package/dist/workspace/points-cli.d.ts.map +1 -1
  95. package/dist/workspace/points-cli.js +16 -7
  96. package/dist/workspace/points-write.schema.json +39 -4
  97. package/dist/workspace/points.schema.json +42 -3
  98. package/dist/workspace/reason-codes.d.ts +2 -1
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +2 -1
  101. package/dist/workspace/records-cli.d.ts +12 -2
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-cli.js +27 -7
  104. package/dist/workspace/records-write.d.ts.map +1 -1
  105. package/dist/workspace/records-write.js +2 -3
  106. package/dist/workspace/records.d.ts +10 -4
  107. package/dist/workspace/records.d.ts.map +1 -1
  108. package/dist/workspace/records.js +16 -3
  109. package/dist/workspace/records.schema.json +2 -1
  110. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  111. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  112. package/dist/workspace/trust/gate-seal.js +41 -2
  113. package/dist/workspace/trust/seal.d.ts +1 -1
  114. package/dist/workspace/trust/seal.d.ts.map +1 -1
  115. package/dist/workspace/trust/seal.js +1 -1
  116. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  117. package/dist/workspace/trust/verify-cli.js +2 -1
  118. package/dist/workspace/trust/verify.d.ts +22 -0
  119. package/dist/workspace/trust/verify.d.ts.map +1 -1
  120. package/dist/workspace/trust/verify.js +94 -14
  121. package/dist/workspace/write-scope.d.ts +3 -1
  122. package/dist/workspace/write-scope.d.ts.map +1 -1
  123. package/dist/workspace/write-scope.js +11 -4
  124. package/package.json +1 -1
  125. package/src/cli/commands/build.ts +9 -2
  126. package/src/cli/commands/lint.ts +4 -2
  127. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  128. package/src/cli/handlers/approve.ts +46 -4
  129. package/src/cli/handlers/operator.test.ts +91 -0
  130. package/src/cli/main.test.ts +10 -0
  131. package/src/cli/main.ts +21 -7
  132. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  133. package/src/cli/mcp/workspace-tools.ts +27 -4
  134. package/src/effect-receipt.ts +22 -0
  135. package/src/gate-decision.ts +2 -0
  136. package/src/lexicon-module.ts +9 -2
  137. package/src/lifecycle/gate-audit.ts +41 -4
  138. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  139. package/src/lifecycle/gate-checkpoint.ts +133 -0
  140. package/src/lifecycle/gate-ledger.ts +61 -0
  141. package/src/lifecycle/receipt-plan.test.ts +9 -0
  142. package/src/lifecycle/receipt-plan.ts +4 -1
  143. package/src/lifecycle/run-ledger.ts +4 -0
  144. package/src/lint/rules/op/index.ts +9 -3
  145. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  146. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  147. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  148. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  149. package/src/op/activity-contract.ts +22 -1
  150. package/src/op/builders.ts +21 -1
  151. package/src/op/effect-step.test.ts +95 -1
  152. package/src/op/gate-alias.e2e.test.ts +78 -0
  153. package/src/op/gate-alias.test.ts +350 -0
  154. package/src/op/gate-alias.ts +137 -0
  155. package/src/op/gate-checkpoint.test.ts +284 -0
  156. package/src/op/gate-checkpoint.ts +322 -0
  157. package/src/op/gate.ts +198 -4
  158. package/src/op/index.ts +4 -1
  159. package/src/op/local-executor.test.ts +65 -0
  160. package/src/op/local-executor.ts +248 -12
  161. package/src/op/local-output.ts +18 -2
  162. package/src/op/op-ir.test.ts +25 -0
  163. package/src/op/op-ir.ts +8 -0
  164. package/src/op/receipt-store.ts +13 -3
  165. package/src/op/runtime.ts +8 -0
  166. package/src/op/runtimes/local.ts +5 -0
  167. package/src/op/step-output-ref.test.ts +68 -24
  168. package/src/op/step-output-ref.ts +85 -65
  169. package/src/op/types.ts +32 -0
  170. package/src/workspace/agent-cli.ts +2 -0
  171. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  172. package/src/workspace/conformance/writer.ts +1 -1
  173. package/src/workspace/decide.test.ts +87 -3
  174. package/src/workspace/decide.ts +142 -17
  175. package/src/workspace/lineage-upgrade.test.ts +21 -0
  176. package/src/workspace/lineage-upgrade.ts +19 -11
  177. package/src/workspace/point-answer.schema.json +16 -1
  178. package/src/workspace/points-cli.ts +16 -8
  179. package/src/workspace/points-write.schema.json +39 -4
  180. package/src/workspace/points.schema.json +42 -3
  181. package/src/workspace/reason-codes.ts +2 -1
  182. package/src/workspace/record-assets.test.ts +14 -0
  183. package/src/workspace/records-cli.ts +28 -7
  184. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  185. package/src/workspace/records-write.ts +2 -3
  186. package/src/workspace/records.schema.json +2 -1
  187. package/src/workspace/records.ts +17 -4
  188. package/src/workspace/trust/gate-seal.test.ts +29 -2
  189. package/src/workspace/trust/gate-seal.ts +46 -2
  190. package/src/workspace/trust/seal.ts +1 -1
  191. package/src/workspace/trust/trust.test.ts +105 -0
  192. package/src/workspace/trust/verify-cli.ts +2 -1
  193. package/src/workspace/trust/verify.ts +106 -13
  194. package/src/workspace/write-scope.test.ts +18 -0
  195. package/src/workspace/write-scope.ts +16 -5
@@ -4,7 +4,8 @@
4
4
  * {@link askPoint} asks a point's deciders for one set of inputs and records
5
5
  * the answer in the answer kind's records directory, {@link answerPoint}
6
6
  * records people's answer to an open question, with an optional note, and
7
- * {@link retractAnswer} takes an answer back (#3351). Each writes one Markdown
7
+ * {@link retractAnswer} takes an answer back (#3351), and {@link withdrawQuestion}
8
+ * closes an open question that no longer applies (#2888). Each writes one Markdown
8
9
  * record or none, never commits, and goes through the checks every records write goes
9
10
  * through (`records-write.ts`): the record is read back with the kind's schema
10
11
  * before it is written, and no other record may become invalid.
@@ -33,6 +34,14 @@
33
34
  * and why. Asking a retracted question again returns it as it stands, since
34
35
  * people took it back from the deciders; people answer it again.
35
36
  *
37
+ * An open question that no longer applies, such as one about a work item its
38
+ * author changed while it was escalated, is withdrawn (#2888): it keeps its
39
+ * state, with who withdrew it, when and why as `withdrawn`, is no longer open,
40
+ * and nobody answers it. A changed item is another question with another id,
41
+ * so nothing closes the earlier one on its own; whoever asked withdraws it.
42
+ * Asking a withdrawn question again opens it again, as when the item changes
43
+ * back.
44
+ *
36
45
  * Each write holds the working tree's write lock (#3173, ws-089) from the first read
37
46
  * to the write, so two answers to one question are never both taken from the
38
47
  * same reading of it.
@@ -95,11 +104,11 @@ import { RefCASConflictError } from "../lifecycle/git";
95
104
  import { readLedgerAnswers, refreshLedger, withLedgerAnswers, writeLedgerAnswer, type LedgerAnswers } from "./answers-ledger";
96
105
  import { currentGateOrigin, isModelAuthored, type GateOrigin } from "../lifecycle/gate-origin";
97
106
 
98
- /** The version of the write documents `points ask`, `points answer` and `points retract` print. */
107
+ /** The version of the write documents `points ask`, `points answer`, `points retract` and `points withdraw` print. */
99
108
  export const POINTS_WRITE_CONTRACT_VERSION = 1;
100
109
  export const POINTS_WRITE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/points-write/v1/points-write.schema.json";
101
110
 
102
- /** Why `points ask`, `points answer` or `points retract` wrote nothing. Closed: a reader may switch on it. */
111
+ /** Why `points ask`, `points answer`, `points retract` or `points withdraw` wrote nothing. Closed: a reader may switch on it. */
103
112
  export const POINTS_WRITE_ERROR_CODES = [
104
113
  ...LOAD_ERROR_CODES,
105
114
  "write-usage-invalid",
@@ -129,7 +138,7 @@ export const POINTS_WRITE_ERROR_CODES = [
129
138
  "principal-unidentified",
130
139
  /** `points retract` names a question that has no answer: escalated or proposed (#3351). */
131
140
  "answer-not-answered",
132
- /** The answer kind's schema copy has no `note`, `retractions`, `relayed_by` or `asked` field for what the write was given (#3351, #3402, #3403). */
141
+ /** The answer kind's schema copy has no `note`, `retractions`, `relayed_by`, `asked` or `withdrawn` field for what the write was given (#3351, #3402, #3403, #2888). */
133
142
  "answer-field-unsupported",
134
143
  /** An ad-hoc point was asked without its question and candidates, a declared point with them, or they don't fit the point's question type (#3403). */
135
144
  "point-candidates-invalid",
@@ -158,7 +167,7 @@ export interface QuestionView {
158
167
  /** What the question is about, the record's first `constrains` entry, or null. */
159
168
  subject: string | null;
160
169
  state: "escalated" | "proposed" | "answered";
161
- /** Not answered yet: escalated to people, or proposed by a model and waiting for a person to confirm it. */
170
+ /** Not answered yet: escalated to people, or proposed by a model and waiting for a person to confirm it, and not withdrawn (#2888). */
162
171
  open: boolean;
163
172
  questionType: string;
164
173
  candidates: (string | boolean)[];
@@ -194,6 +203,8 @@ export interface QuestionView {
194
203
  relayedBy: string | null;
195
204
  /** Answers people took back, oldest first (`points retract`, #3351). Empty when none was. */
196
205
  retractions: RetractionView[];
206
+ /** Who withdrew the question, when and why, when it no longer applies (`points withdraw`, #2888), or null. A withdrawn question is not open. */
207
+ withdrawn: { by: string[]; on: string | null; note: string | null } | null;
197
208
  /**
198
209
  * An ad-hoc point's question as its ask gave it (#3403): the question's text
199
210
  * and its criteria, what each candidate means. null for a declared point's
@@ -255,6 +266,14 @@ function retractionsOf(d: Record<string, unknown>): RetractionView[] {
255
266
  }));
256
267
  }
257
268
 
269
+ /** The record's `withdrawn`, as a view, or null. */
270
+ function withdrawnOf(d: Record<string, unknown>): QuestionView["withdrawn"] {
271
+ const w = d.withdrawn;
272
+ if (w === null || typeof w !== "object" || Array.isArray(w)) return null;
273
+ const r = w as Record<string, unknown>;
274
+ return { by: Array.isArray(r.by) ? r.by.filter((x): x is string => typeof x === "string") : [], on: typeof r.on === "string" ? r.on : null, note: typeof r.note === "string" ? r.note : null };
275
+ }
276
+
258
277
  /** The harness a steward's question names in its source block (#2749). */
259
278
  export const STEWARD_HARNESS = "chant-steward";
260
279
 
@@ -304,6 +323,7 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
304
323
  }
305
324
  }
306
325
  const constrains = Array.isArray(d.constrains) ? d.constrains.filter((c): c is string => typeof c === "string") : [];
326
+ const withdrawn = withdrawnOf(d);
307
327
  return {
308
328
  id: entry.id,
309
329
  path: entry.path,
@@ -311,7 +331,7 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
311
331
  title: str(d.title) ?? "",
312
332
  subject: constrains[0] ?? null,
313
333
  state,
314
- open: state !== "answered",
334
+ open: state !== "answered" && withdrawn === null,
315
335
  questionType: str(d.question_type) ?? "",
316
336
  candidates: Array.isArray(d.candidates) ? (d.candidates as (string | boolean)[]) : [],
317
337
  inputs: d.inputs !== null && typeof d.inputs === "object" && !Array.isArray(d.inputs) ? (d.inputs as Record<string, unknown>) : {},
@@ -332,6 +352,7 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
332
352
  note: str(d.note),
333
353
  relayedBy: str(d.relayed_by),
334
354
  retractions: retractionsOf(d),
355
+ withdrawn,
335
356
  asked: askedOf(d) ?? null,
336
357
  askedBy: askedByOf(d),
337
358
  ledger,
@@ -341,9 +362,9 @@ export function questionView(entry: RecordEntry, point: Point | undefined, ledge
341
362
  }
342
363
 
343
364
  /** The write verbs of `points`. */
344
- export type PointsWriteVerb = "ask" | "answer" | "retract";
365
+ export type PointsWriteVerb = "ask" | "answer" | "retract" | "withdraw";
345
366
 
346
- /** What `points ask`, `points answer` and `points retract` print. */
367
+ /** What `points ask`, `points answer`, `points retract` and `points withdraw` print. */
347
368
  export type PointsWriteDocument =
348
369
  | {
349
370
  $schema: string;
@@ -460,7 +481,7 @@ const show = (answer: string | boolean, type: string): string => (type === "noul
460
481
 
461
482
  /** A record's title: the point's title, or an ad-hoc question's own text (#3403), what it is about, and where it stands. */
462
483
  function titleFor(point: Point, subject: string | undefined, state: string, answer: string | boolean | undefined, asked?: Asked): string {
463
- const outcome = state === "escalated" ? "open for people" : state === "proposed" ? `${show(answer!, point.question.type)}, proposed` : show(answer!, point.question.type);
484
+ const outcome = state === "withdrawn" ? "withdrawn" : state === "escalated" ? "open for people" : state === "proposed" ? `${show(answer!, point.question.type)}, proposed` : show(answer!, point.question.type);
464
485
  return `${asked ? asked.question : point.title}${subject ? ` (${subject})` : ""}: ${outcome}`;
465
486
  }
466
487
 
@@ -639,9 +660,25 @@ async function askPointLocked(opts: AskPointOptions): Promise<PointsWriteDocumen
639
660
  question: questionView(entry, point, extra.ledger !== undefined ? extra.ledger : heldAt(entry))!,
640
661
  ...(extra.text !== undefined ? { text: extra.text } : {}),
641
662
  });
642
- if (existing && existing.data !== null && (existing.state === "answered" || existing.state === "proposed")) return done(existing, { reused: true, written: false });
663
+ /** A retracted question withdrawn and now asked again: open for people again, as it stood before it was withdrawn (#2888). */
664
+ const reopen = async (entry: RecordEntry & { data: Record<string, unknown> }): Promise<PointsWriteDocument> => {
665
+ const { withdrawn: _, ...rest } = entry.data;
666
+ const subject = Array.isArray(rest.constrains) && typeof rest.constrains[0] === "string" ? (rest.constrains[0] as string) : undefined;
667
+ const title = titleFor(point, subject, String(rest.state), rest.answer as string | boolean | undefined, asked);
668
+ const data = recordData(o, { ...rest, title });
669
+ const text = renderRecord(data, body(point, title, asked));
670
+ const message = `Decision point ${opts.point}: asked again${subject ? ` (${subject})` : ""}`;
671
+ const wrote = await write(o, before, entry.path, text, false, !!opts.dryRun, { source: read, ledger, onLedger: toLedger || ledger.byPath.has(entry.path), message, who: { verb: "points ask", by: steward?.name ?? null } });
672
+ return done({ ...entry, data, valid: true, reasons: [], warnings: wrote.warnings }, { reused: false, written: !opts.dryRun, ledger: wrote.ledger, ...(opts.dryRun ? { text } : {}) });
673
+ };
674
+ // A withdrawn question is asked again (#2888): it applies again, so it is open again.
675
+ const withdrawn = existing !== undefined && existing.data !== null && existing.data.withdrawn !== undefined;
676
+ if (existing && existing.data !== null && !withdrawn && (existing.state === "answered" || existing.state === "proposed")) return done(existing, { reused: true, written: false });
643
677
  // People took a retracted question back from the deciders (ws-084): they answer it again, and asking returns it as it stands.
644
- if (existing && existing.data !== null && Array.isArray(existing.data.retractions) && existing.data.retractions.length > 0) return done(existing, { reused: true, written: false });
678
+ if (existing && existing.data !== null && Array.isArray(existing.data.retractions) && existing.data.retractions.length > 0) {
679
+ if (!withdrawn) return done(existing, { reused: true, written: false });
680
+ return reopen(existing as RecordEntry & { data: Record<string, unknown> });
681
+ }
645
682
 
646
683
  let result: ChainResult;
647
684
  try {
@@ -651,7 +688,7 @@ async function askPointLocked(opts: AskPointOptions): Promise<PointsWriteDocumen
651
688
  throw err;
652
689
  }
653
690
  // A standing escalation is kept while the chain still escalates.
654
- if (existing && existing.data !== null && result.status === "escalated") return done(existing, { reused: true, written: false });
691
+ if (existing && existing.data !== null && !withdrawn && result.status === "escalated") return done(existing, { reused: true, written: false });
655
692
 
656
693
  const on = opts.on ?? today();
657
694
  const state = result.status;
@@ -731,15 +768,16 @@ export interface AnswerPointOptions {
731
768
 
732
769
  /**
733
770
  * Whether an answer kind's schema copy takes the fields of #3351 (`note` on an
734
- * answer, and `retractions`), of #3402 (`relayed_by`) and of #3403 (`asked`).
735
- * A copy of point-answer.schema.json from before them refuses a note, a
736
- * retraction, a relay or an ad-hoc ask with `answer-field-unsupported`, rather
771
+ * answer, and `retractions`), of #3402 (`relayed_by`), of #3403 (`asked`) and
772
+ * of #2888 (`withdrawn`). A copy of point-answer.schema.json from before them
773
+ * refuses a note, a retraction, a relay, an ad-hoc ask or a withdrawal with
774
+ * `answer-field-unsupported`, rather
737
775
  * than dropping what a person wrote or the question an agent asked.
738
776
  */
739
- export function answerFields(schema: Record<string, unknown>): { note: boolean; retractions: boolean; relayedBy: boolean; asked: boolean } {
777
+ export function answerFields(schema: Record<string, unknown>): { note: boolean; retractions: boolean; relayedBy: boolean; asked: boolean; withdrawn: boolean } {
740
778
  const props = schema.properties as Record<string, unknown> | undefined;
741
779
  const has = (key: string): boolean => props !== undefined && props !== null && typeof props === "object" && Object.prototype.hasOwnProperty.call(props, key);
742
- return { note: has("note"), retractions: has("retractions"), relayedBy: has("relayed_by"), asked: has("asked") };
780
+ return { note: has("note"), retractions: has("retractions"), relayedBy: has("relayed_by"), asked: has("asked"), withdrawn: has("withdrawn") };
743
781
  }
744
782
 
745
783
  /** A note as given, trimmed, or undefined when it is empty. */
@@ -944,6 +982,9 @@ async function answerPointLocked(opts: AnswerPointOptions): Promise<PointsWriteD
944
982
  if (target.state === "answered") {
945
983
  throw new PointsWriteError("record-closed", `${opts.id} is answered, and an answer never changes in place: retract it with \`points retract ${opts.id}\` and answer again, ask again with other inputs, or change the point, which asks the question anew`);
946
984
  }
985
+ if (d.withdrawn !== undefined) {
986
+ throw new PointsWriteError("record-closed", `${opts.id} was withdrawn, so nobody answers it: ask it again with \`points ask\`, which opens it again, when it applies again`);
987
+ }
947
988
  const note = noteOf(opts.note);
948
989
  if (note !== undefined && !answerFields(o.loaded.schema).note) {
949
990
  throw new PointsWriteError("answer-field-unsupported", `the ${o.loaded.kind.name} kind's schema has no note field, so ${opts.id} can't keep the note: copy point-answer.schema.json from @intentius/chant anew`);
@@ -1151,3 +1192,87 @@ async function retractAnswerLocked(opts: RetractAnswerOptions): Promise<PointsWr
1151
1192
  return failure("retract", err);
1152
1193
  }
1153
1194
  }
1195
+
1196
+ // ── points withdraw ──────────────────────────────────────────────────────────
1197
+
1198
+ export interface WithdrawQuestionOptions {
1199
+ cwd: string;
1200
+ /** The answer record's id. */
1201
+ id: string;
1202
+ /** Who withdraws the question, such as the factory that asked it. At least one. */
1203
+ by: string[];
1204
+ /** Why, such as the question that asks about the subject now. */
1205
+ note?: string;
1206
+ kind?: string;
1207
+ on?: string;
1208
+ dryRun?: boolean;
1209
+ }
1210
+
1211
+ /**
1212
+ * Close an open question that no longer applies: `points withdraw` (#2888).
1213
+ * A run asks about a work item whose author then changes it, so the next ask
1214
+ * is another question, and the first one would wait on people forever. The
1215
+ * question keeps its state, escalated or proposed, and gains `withdrawn`: who
1216
+ * withdrew it, when and why. It is no longer open, so `points --open` leaves
1217
+ * it out, and `points answer` refuses it. Asking it again opens it again.
1218
+ *
1219
+ * Withdrawing answers nothing and passes no gate, so it needs no quorum, and
1220
+ * the asker withdraws its own question, a steward's turn or an MCP session
1221
+ * included. An answered question is not withdrawn: people retract it.
1222
+ */
1223
+ export async function withdrawQuestion(opts: WithdrawQuestionOptions): Promise<PointsWriteDocument> {
1224
+ try {
1225
+ return await withWriteLock(opts.cwd, { verb: "points withdraw", by: (opts.by ?? []).join(", ") || null }, opts.dryRun, () => withdrawQuestionLocked(opts));
1226
+ } catch (err) {
1227
+ return failure("withdraw", err);
1228
+ }
1229
+ }
1230
+
1231
+ async function withdrawQuestionLocked(opts: WithdrawQuestionOptions): Promise<PointsWriteDocument> {
1232
+ try {
1233
+ const by = [...new Set((opts.by ?? []).map((b) => b.trim()).filter((b) => b !== ""))];
1234
+ if (by.length === 0) throw new PointsWriteError("write-usage-invalid", "--by <name> is required: who withdraws the question");
1235
+ refuseUnidentified(scopeSource(opts.cwd), by, "--by");
1236
+ const found = await findQuestion(opts.cwd, opts.kind, opts.id);
1237
+ const { opened, before, target } = found;
1238
+ const ledger = found.read.ledger;
1239
+ const { o } = opened;
1240
+ const d = target.data;
1241
+ if (target.state === "answered") {
1242
+ throw new PointsWriteError("record-closed", `${opts.id} is answered, so it is not withdrawn: people take the answer back with \`points retract ${opts.id}\``);
1243
+ }
1244
+ if (d.withdrawn !== undefined) throw new PointsWriteError("record-closed", `${opts.id} is withdrawn already`);
1245
+ if (!answerFields(o.loaded.schema).withdrawn) {
1246
+ throw new PointsWriteError("answer-field-unsupported", `the ${o.loaded.kind.name} kind's schema has no withdrawn field, so ${opts.id} can't record that it no longer applies: copy point-answer.schema.json from @intentius/chant anew`);
1247
+ }
1248
+ const name = String(d.point);
1249
+ const point = pointOf(opened.points, name);
1250
+ if (!point) throw new PointsWriteError("point-unknown", `${opts.id} asks ${name}, which ${opened.pointsFile} no longer declares`);
1251
+ const on = opts.on ?? today();
1252
+ const note = noteOf(opts.note);
1253
+ const subject = Array.isArray(d.constrains) && typeof d.constrains[0] === "string" ? (d.constrains[0] as string) : undefined;
1254
+ const asked = askedOf(d);
1255
+ const title = titleFor(point, subject, "withdrawn", undefined, asked);
1256
+ const data = recordData(o, { ...d, title, withdrawn: Object.fromEntries(Object.entries({ by, on, note }).filter(([, v]) => v !== undefined)) });
1257
+ const text = renderRecord(data, body(point, title, asked));
1258
+ const onLedger = ledger.byPath.has(target.path);
1259
+ const message = `Decision point ${name}: withdrawn by ${by.join(", ")}${subject ? ` (${subject})` : ""}`;
1260
+ const wrote = await write(o, before, target.path, text, false, !!opts.dryRun, { source: found.read.source, ledger, onLedger, message, who: { verb: "points withdraw", by: by.join(", ") } });
1261
+ const entry: RecordEntry = { ...target, data, valid: true, reasons: [], warnings: wrote.warnings };
1262
+ return {
1263
+ $schema: POINTS_WRITE_SCHEMA_ID,
1264
+ contract: POINTS_WRITE_CONTRACT_VERSION,
1265
+ verb: "withdraw",
1266
+ kind: o.view,
1267
+ id: opts.id,
1268
+ path: target.path,
1269
+ reused: false,
1270
+ written: !opts.dryRun,
1271
+ dryRun: !!opts.dryRun,
1272
+ question: questionView(entry, point, onLedger ? (wrote.ledger ?? ledger.byPath.get(target.path)?.ledger ?? null) : null)!,
1273
+ ...(opts.dryRun ? { text } : {}),
1274
+ };
1275
+ } catch (err) {
1276
+ return failure("withdraw", err);
1277
+ }
1278
+ }
@@ -476,6 +476,27 @@ describe("chant workspace upgrade", () => {
476
476
  expect(isGovernancePath("src/main.ts")).toBe(false);
477
477
  });
478
478
 
479
+ test("the quorum counts the gates in a workspace member's Op files (#2892)", async () => {
480
+ const op = (overview: string): string =>
481
+ `export default { props: { name: "release", overview: ${JSON.stringify(overview)}, phases: [{ name: "Ship", steps: [{ kind: "gate", name: "ship", approval: { quorum: { count: 2, roles: ["maintainer"] } } }] }] } };\n`;
482
+ // A template whose Op lives in a member, and a project made from it.
483
+ release("v1.1.0", {
484
+ "chant.workspace.json": JSON.stringify({ name: "proj", schema: 1, members: [{ name: "delivery", dir: "delivery", kind: "chant" }] }, null, 2) + "\n",
485
+ "delivery/chant.config.ts": "export default {};\n",
486
+ "delivery/ops/release.op.ts": op("v1"),
487
+ });
488
+ proj = join(root, "members");
489
+ expect((await initFromCommand({ from: `${tpl}@v1.1.0`, path: proj })).error).toBeUndefined();
490
+ git(proj, ["init", "-q", "-b", "main"]);
491
+ commitProject("init from v1.1");
492
+ release("v2.0.0", { "delivery/ops/release.op.ts": op("v2") });
493
+ const port = ledger();
494
+ const result = await upgradeCommand({ root: proj, to: "v2.0.0", runChant: passing, ledger: port, now: "2026-09-23T10:00:00.000Z" });
495
+ expect(result.outcome).toBe("gated");
496
+ expect(result.staged!.governance).toMatchObject({ paths: ["delivery/ops/release.op.ts"], approval: { mode: "log-only", quorum: { count: 2, roles: ["maintainer"] } } });
497
+ expect(port.pending[0].approval).toEqual({ mode: "log-only", quorum: { count: 2, roles: ["maintainer"] } });
498
+ });
499
+
479
500
  test("refuses a scope with uncommitted changes, and a scope that does not exist", async () => {
480
501
  put(proj, "README.md", "dirty\n");
481
502
  await expect(stageUpgrade({ root: proj, to: "v1.0.0" })).rejects.toThrow(/uncommitted changes/);
@@ -571,22 +571,30 @@ export function isGovernancePath(path: string): boolean {
571
571
  * strictest gate declared at HEAD in any Op file the patch changes, at least
572
572
  * one. The Op files are read from the project's tree, which the upgrade
573
573
  * requires to match HEAD for every path the patch touches.
574
+ *
575
+ * Discovery runs from each changed Op file's own directory, so it finds the
576
+ * file under the project that owns it. Run from a workspace root, it would
577
+ * skip every member's Op files (#2892).
574
578
  */
575
- async function rulesAtHead(root: string, repo: string, paths: string[]): Promise<ResolvedGateApproval> {
579
+ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateApproval> {
576
580
  const opFiles = new Set(paths.filter((p) => GOVERNANCE[0].test(p)));
577
581
  let count = 1;
578
582
  let roles: string[] | undefined;
579
583
  if (opFiles.size > 0) {
580
584
  const { discoverOps } = await import("../op/discover");
581
- const { ops } = await discoverOps({ cwd: root });
582
- for (const { config, filePath } of ops.values()) {
583
- const rel = relative(repo, filePath).split(sep).join("/");
584
- if (!opFiles.has(rel)) continue;
585
- for (const approval of gateApprovals(config)) {
586
- const need = approval.quorum?.count ?? 1;
587
- if (need > count) {
588
- count = need;
589
- roles = approval.quorum?.roles;
585
+ const dirs = new Set([...opFiles].map((p) => dirname(join(repo, p))));
586
+ for (const dir of dirs) {
587
+ if (!existsSync(dir)) continue;
588
+ const { ops } = await discoverOps({ cwd: dir });
589
+ for (const { config, filePath } of ops.values()) {
590
+ const rel = relative(repo, filePath).split(sep).join("/");
591
+ if (!opFiles.has(rel)) continue;
592
+ for (const approval of gateApprovals(config)) {
593
+ const need = approval.quorum?.count ?? 1;
594
+ if (need > count) {
595
+ count = need;
596
+ roles = approval.quorum?.roles;
597
+ }
590
598
  }
591
599
  }
592
600
  }
@@ -854,7 +862,7 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
854
862
  if (touched.length > 0) {
855
863
  throw new UpgradeError(`the upgrade changes governance files that have uncommitted edits (${touched.map((l) => l.slice(3)).join(", ")}). Commit them first: their gates are read at HEAD.`);
856
864
  }
857
- governance = { paths: governed, approval: await rulesAtHead(root, repo, governed), rules: head };
865
+ governance = { paths: governed, approval: await rulesAtHead(repo, governed), rules: head };
858
866
  }
859
867
 
860
868
  // 5. Checks.
@@ -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"] } } }
@@ -6,12 +6,12 @@
6
6
  * The points come from each record kind the declaration names with an
7
7
  * `answers` block, or from `--kind`, and the questions are that kind's
8
8
  * records. A question is open while it is escalated to people or proposed by a
9
- * model and not yet confirmed, and it lists any model's answer with its
9
+ * model and not yet confirmed, and not withdrawn (#2888), and it lists any model's answer with its
10
10
  * confidence, so a reader such as hud can prompt a person and an MCP client
11
11
  * can see it. With `--open`, only open questions are listed. It never writes
12
12
  * and never calls a model.
13
13
  *
14
- * `points ask`, `points answer` and `points retract` are the writes, in
14
+ * `points ask`, `points answer`, `points retract` and `points withdraw` are the writes, in
15
15
  * `decide.ts`.
16
16
  */
17
17
 
@@ -21,7 +21,7 @@ import { formatError } from "../cli/format";
21
21
  import type { CommandContext } from "../cli/registry";
22
22
  import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
23
23
  import { declaredKindFile } from "./declared-kinds";
24
- import { answerPoint, askPoint, questionView, retractAnswer, POINTS_WRITE_CONTRACT_VERSION, POINTS_WRITE_SCHEMA_ID, type PointsWriteDocument, type PointsWriteVerb, type QuestionView } from "./decide";
24
+ import { answerPoint, askPoint, questionView, retractAnswer, withdrawQuestion, POINTS_WRITE_CONTRACT_VERSION, POINTS_WRITE_SCHEMA_ID, type PointsWriteDocument, type PointsWriteVerb, type QuestionView } from "./decide";
25
25
  import { candidates, inputOutput, pointsFileOf, pointVersion, quorumOf, readPointsThrough, type Decider, type ModelAsk, type WireAnswer } from "./points";
26
26
  import type { ReasonCode } from "./reason-codes";
27
27
  import { gitRevisionSource, workingTreeSource } from "./record-source";
@@ -107,7 +107,8 @@ export type PointsDocument =
107
107
  sources: SourceView[];
108
108
  points: PointView[];
109
109
  questions: QuestionView[];
110
- summary: { points: number; questions: number; open: number; escalated: number; proposed: number; answered: number };
110
+ /** escalated, proposed and answered count by state, withdrawn ones included; withdrawn counts the questions withdrawn (#2888). */
111
+ summary: { points: number; questions: number; open: number; escalated: number; proposed: number; answered: number; withdrawn: number };
111
112
  }
112
113
  | { $schema: string; contract: number; chant: string; error: { code: WorkspaceErrorCode; message: string; location: ErrorLocation | null } };
113
114
 
@@ -197,7 +198,7 @@ export async function workspacePoints(query: PointsQuery): Promise<PointsDocumen
197
198
  sources,
198
199
  points,
199
200
  questions,
200
- summary: { points: points.length, questions: questions.length, open: questions.filter((q) => q.open).length, escalated: count("escalated"), proposed: count("proposed"), answered: count("answered") },
201
+ summary: { points: points.length, questions: questions.length, open: questions.filter((q) => q.open).length, escalated: count("escalated"), proposed: count("proposed"), answered: count("answered"), withdrawn: questions.filter((q) => q.withdrawn !== null).length },
201
202
  };
202
203
  }
203
204
 
@@ -228,7 +229,7 @@ export function formatPoints(doc: Extract<PointsDocument, { points: unknown }>):
228
229
  lines.push("");
229
230
  for (const q of doc.questions) {
230
231
  const model = q.model && q.state !== "answered" ? ` model ${JSON.stringify(q.model.answer)} at ${q.model.confidence ?? "?"}${q.model.observed ? "" : `, below ${q.model.threshold ?? "?"}`}` : "";
231
- lines.push(`${q.id} ${q.state} ${q.title}${model}`);
232
+ lines.push(`${q.id} ${q.withdrawn ? "withdrawn" : q.state} ${q.title}${model}`);
232
233
  }
233
234
  const n = doc.summary.questions;
234
235
  lines.push(doc.open ? `${n} open ${n === 1 ? "question" : "questions"}` : `${n} ${n === 1 ? "question" : "questions"}, ${doc.summary.open} open`);
@@ -252,6 +253,7 @@ const USAGE = [
252
253
  "chant workspace points ask <point> --inputs <file|-|json> [--candidates <file|-|json>] [--response <file>] [--subject <id>] [--kind <kind file>] [--dry-run]",
253
254
  "chant workspace points answer <id> --answer <value> --by <name> [--by <name>...] [--note <text>] [--relayed-by <principal>] [--kind <kind file>] [--resume] [--dry-run]",
254
255
  "chant workspace points retract <id> --by <name> [--by <name>...] [--note <text>] [--kind <kind file>] [--dry-run]",
256
+ "chant workspace points withdraw <id> --by <name> [--by <name>...] [--note <text>] [--kind <kind file>] [--dry-run]",
255
257
  ].join("\n");
256
258
 
257
259
  function usage(verb: PointsWriteVerb, message: string): PointsWriteDocument {
@@ -276,7 +278,7 @@ function readJson(verb: PointsWriteVerb, flag: string, value: string, cwd: strin
276
278
  }
277
279
  }
278
280
 
279
- /** `chant workspace points`, and its `ask`, `answer` and `retract` verbs. */
281
+ /** `chant workspace points`, and its `ask`, `answer`, `retract` and `withdraw` verbs. */
280
282
  export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
281
283
  const { args } = ctx;
282
284
  const cwd = process.cwd();
@@ -324,8 +326,14 @@ export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
324
326
  if (!args.bys || args.bys.length === 0) return print(usage("retract", "--by <name> is required, once for each person who takes the answer back"));
325
327
  return print(await retractAnswer({ cwd, id, by: args.bys, note: args.note, kind: args.kind, dryRun: args.dryRun }));
326
328
  }
329
+ if (verb === "withdraw") {
330
+ const id = args.extraPositional2;
331
+ if (!id) return print(usage("withdraw", "withdraw needs the question's id"));
332
+ if (!args.bys || args.bys.length === 0) return print(usage("withdraw", "--by <name> is required: who withdraws the question"));
333
+ return print(await withdrawQuestion({ cwd, id, by: args.bys, note: args.note, kind: args.kind, dryRun: args.dryRun }));
334
+ }
327
335
  if (verb !== undefined) {
328
- console.error(formatError({ message: `chant workspace points takes no argument but ask, answer or retract (got ${verb})`, hint: USAGE }));
336
+ console.error(formatError({ message: `chant workspace points takes no argument but ask, answer, retract or withdraw (got ${verb})`, hint: USAGE }));
329
337
  return 1;
330
338
  }
331
339
  const doc = await workspacePoints({ cwd, at: args.at, open: args.open, kind: args.kind });
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/points-write/v1/points-write.schema.json",
4
4
  "title": "chant workspace points ask, answer and retract output",
5
- "description": "What `chant workspace points ask`, `points answer` and `points retract` print (ws-058, #2739, #3351): the answer record asked for or written, as `points --json` lists a question. ask returns the record that is there when the same point, declaration and inputs were answered or proposed before (reused), and otherwise writes one: answered from a table, proposed from a model at or above its threshold, escalated to people below it. answer records people's answer to an open question once the point's quorum is met, with their note. retract takes an answer back once the quorum is met: the question is escalated to people again and the answer moves into its retractions (ws-084). Nothing is committed to the working branch, and nothing is written on a refusal or with --dry-run. In a steward's turn a new record goes on the lifecycle ledger instead of the working tree (#2786), as a commit on chant/lifecycle, and a record already held there is rewritten there. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Written at contract version 1 of the write documents.",
5
+ "description": "What `chant workspace points ask`, `points answer`, `points retract` and `points withdraw` print (ws-058, #2739, #3351, #2888): the answer record asked for or written, as `points --json` lists a question. ask returns the record that is there when the same point, declaration and inputs were answered or proposed before (reused), and otherwise writes one: answered from a table, proposed from a model at or above its threshold, escalated to people below it. answer records people's answer to an open question once the point's quorum is met, with their note. retract takes an answer back once the quorum is met: the question is escalated to people again and the answer moves into its retractions (ws-084). withdraw closes an open question that no longer applies, with who withdrew it, when and why; asking it again opens it again (#2888). Nothing is committed to the working branch, and nothing is written on a refusal or with --dry-run. In a steward's turn a new record goes on the lifecycle ledger instead of the working tree (#2786), as a commit on chant/lifecycle, and a record already held there is rewritten there. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Written at contract version 1 of the write documents.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -38,7 +38,8 @@
38
38
  "enum": [
39
39
  "ask",
40
40
  "answer",
41
- "retract"
41
+ "retract",
42
+ "withdraw"
42
43
  ]
43
44
  },
44
45
  "kind": {
@@ -507,6 +508,38 @@
507
508
  }
508
509
  }
509
510
  },
511
+ "withdrawn": {
512
+ "type": [
513
+ "object",
514
+ "null"
515
+ ],
516
+ "description": "Who withdrew the question, when and why (points withdraw, #2888): an open question that no longer applies, such as one about a work item its author changed. A withdrawn question keeps its state and is not open. null when it was not withdrawn, and absent from a chant before it.",
517
+ "required": [
518
+ "by",
519
+ "on",
520
+ "note"
521
+ ],
522
+ "properties": {
523
+ "by": {
524
+ "type": "array",
525
+ "items": {
526
+ "type": "string"
527
+ }
528
+ },
529
+ "on": {
530
+ "type": [
531
+ "string",
532
+ "null"
533
+ ]
534
+ },
535
+ "note": {
536
+ "type": [
537
+ "string",
538
+ "null"
539
+ ]
540
+ }
541
+ }
542
+ },
510
543
  "asked": {
511
544
  "description": "An ad-hoc point's question as its ask gave it (points ask --candidates, #3403): the question's text, and its criteria, what each candidate means, in the point's question type's shape. People's answer is one of the candidates. null for a declared point's question, and absent from a chant before it.",
512
545
  "oneOf": [
@@ -567,7 +600,8 @@
567
600
  "answer-points-unreadable",
568
601
  "answer-point-unknown",
569
602
  "answer-point-changed",
570
- "record-unattested"
603
+ "record-unattested",
604
+ "record-ratified-below-quorum"
571
605
  ]
572
606
  },
573
607
  "message": {
@@ -598,7 +632,8 @@
598
632
  "enum": [
599
633
  "ask",
600
634
  "answer",
601
- "retract"
635
+ "retract",
636
+ "withdraw"
602
637
  ]
603
638
  },
604
639
  "error": {