@intentius/chant 0.124.24 → 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 (96) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/main.js +21 -7
  3. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  4. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  5. package/dist/cli/mcp/workspace-tools.js +27 -4
  6. package/dist/effect-receipt.d.ts +13 -0
  7. package/dist/effect-receipt.d.ts.map +1 -1
  8. package/dist/effect-receipt.js +19 -0
  9. package/dist/lexicon-module.d.ts +7 -1
  10. package/dist/lexicon-module.d.ts.map +1 -1
  11. package/dist/lexicon-module.js +7 -2
  12. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  13. package/dist/lifecycle/receipt-plan.js +4 -2
  14. package/dist/op/local-executor.d.ts.map +1 -1
  15. package/dist/op/local-executor.js +64 -5
  16. package/dist/op/receipt-store.d.ts +5 -0
  17. package/dist/op/receipt-store.d.ts.map +1 -1
  18. package/dist/op/receipt-store.js +4 -3
  19. package/dist/op/step-output-ref.d.ts +26 -18
  20. package/dist/op/step-output-ref.d.ts.map +1 -1
  21. package/dist/op/step-output-ref.js +86 -63
  22. package/dist/wave-runtime.mjs +213 -213
  23. package/dist/workspace/agent-cli.d.ts.map +1 -1
  24. package/dist/workspace/agent-cli.js +3 -0
  25. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  26. package/dist/workspace/conformance/writer.js +1 -1
  27. package/dist/workspace/decide.d.ts +51 -9
  28. package/dist/workspace/decide.d.ts.map +1 -1
  29. package/dist/workspace/decide.js +125 -14
  30. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  31. package/dist/workspace/lineage-upgrade.js +20 -11
  32. package/dist/workspace/point-answer.schema.json +16 -1
  33. package/dist/workspace/points-cli.d.ts +5 -3
  34. package/dist/workspace/points-cli.d.ts.map +1 -1
  35. package/dist/workspace/points-cli.js +16 -7
  36. package/dist/workspace/points-write.schema.json +39 -4
  37. package/dist/workspace/points.schema.json +42 -3
  38. package/dist/workspace/reason-codes.d.ts +2 -1
  39. package/dist/workspace/reason-codes.d.ts.map +1 -1
  40. package/dist/workspace/reason-codes.js +2 -1
  41. package/dist/workspace/records-cli.d.ts +12 -2
  42. package/dist/workspace/records-cli.d.ts.map +1 -1
  43. package/dist/workspace/records-cli.js +27 -7
  44. package/dist/workspace/records-write.d.ts.map +1 -1
  45. package/dist/workspace/records-write.js +2 -3
  46. package/dist/workspace/records.d.ts +10 -4
  47. package/dist/workspace/records.d.ts.map +1 -1
  48. package/dist/workspace/records.js +16 -3
  49. package/dist/workspace/records.schema.json +2 -1
  50. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  51. package/dist/workspace/trust/verify-cli.js +2 -1
  52. package/dist/workspace/trust/verify.d.ts +22 -0
  53. package/dist/workspace/trust/verify.d.ts.map +1 -1
  54. package/dist/workspace/trust/verify.js +94 -14
  55. package/dist/workspace/write-scope.d.ts +3 -1
  56. package/dist/workspace/write-scope.d.ts.map +1 -1
  57. package/dist/workspace/write-scope.js +11 -4
  58. package/package.json +1 -1
  59. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  60. package/src/cli/main.test.ts +10 -0
  61. package/src/cli/main.ts +21 -7
  62. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  63. package/src/cli/mcp/workspace-tools.ts +27 -4
  64. package/src/effect-receipt.ts +22 -0
  65. package/src/lexicon-module.ts +9 -2
  66. package/src/lifecycle/receipt-plan.test.ts +9 -0
  67. package/src/lifecycle/receipt-plan.ts +4 -1
  68. package/src/op/effect-step.test.ts +95 -1
  69. package/src/op/local-executor.test.ts +65 -0
  70. package/src/op/local-executor.ts +68 -5
  71. package/src/op/receipt-store.ts +13 -3
  72. package/src/op/step-output-ref.test.ts +68 -24
  73. package/src/op/step-output-ref.ts +85 -65
  74. package/src/workspace/agent-cli.ts +2 -0
  75. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  76. package/src/workspace/conformance/writer.ts +1 -1
  77. package/src/workspace/decide.test.ts +87 -3
  78. package/src/workspace/decide.ts +142 -17
  79. package/src/workspace/lineage-upgrade.test.ts +21 -0
  80. package/src/workspace/lineage-upgrade.ts +19 -11
  81. package/src/workspace/point-answer.schema.json +16 -1
  82. package/src/workspace/points-cli.ts +16 -8
  83. package/src/workspace/points-write.schema.json +39 -4
  84. package/src/workspace/points.schema.json +42 -3
  85. package/src/workspace/reason-codes.ts +2 -1
  86. package/src/workspace/record-assets.test.ts +14 -0
  87. package/src/workspace/records-cli.ts +28 -7
  88. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  89. package/src/workspace/records-write.ts +2 -3
  90. package/src/workspace/records.schema.json +2 -1
  91. package/src/workspace/records.ts +17 -4
  92. package/src/workspace/trust/trust.test.ts +105 -0
  93. package/src/workspace/trust/verify-cli.ts +2 -1
  94. package/src/workspace/trust/verify.ts +106 -13
  95. package/src/workspace/write-scope.test.ts +18 -0
  96. package/src/workspace/write-scope.ts +16 -5
@@ -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
  });
@@ -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;
@@ -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":
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
11
11
  import { runDeclarationChecks } from "./checks";
12
12
  import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
13
- import { answerFields, answerPoint, askPoint, reasonFields, retractAnswer, type PointsWriteDocument } from "./decide";
13
+ import { answerFields, answerPoint, askPoint, reasonFields, retractAnswer, withdrawQuestion, type PointsWriteDocument } from "./decide";
14
14
  import pointAnswerSchema from "./point-answer.schema.json";
15
15
  import { responseAsk, workspacePoints } from "./points-cli";
16
16
  import pointsSchema from "./points.schema.json";
@@ -293,13 +293,13 @@ describe("the intent walk's points, a note on an answer, and retracting it (#335
293
293
  });
294
294
 
295
295
  test("a workspace whose answer schema predates notes and retractions refuses them, rather than dropping them", () => {
296
- expect(answerFields(pointAnswerSchema)).toEqual({ note: true, retractions: true, relayedBy: true, asked: true });
296
+ expect(answerFields(pointAnswerSchema)).toEqual({ note: true, retractions: true, relayedBy: true, asked: true, withdrawn: true });
297
297
  const old = JSON.parse(JSON.stringify(pointAnswerSchema));
298
298
  delete old.properties.note;
299
299
  delete old.properties.retractions;
300
300
  delete old.properties.relayed_by;
301
301
  delete old.properties.asked;
302
- expect(answerFields(old)).toEqual({ note: false, retractions: false, relayedBy: false, asked: false });
302
+ expect(answerFields(old)).toEqual({ note: false, retractions: false, relayedBy: false, asked: false, withdrawn: true });
303
303
  });
304
304
 
305
305
  test("an answer relayed for a person records who relayed it, and a retraction keeps it (#3402)", async () => {
@@ -399,6 +399,90 @@ describe("an ad-hoc question whose candidates come with the ask (#3403)", () =>
399
399
  });
400
400
  });
401
401
 
402
+ describe("withdrawing an open question that no longer applies (#2888)", () => {
403
+ const item = (title: string) => ({
404
+ "work-item.state": "proposed",
405
+ "work-item.title": title,
406
+ "work-item.source.ask.said": "make the port configurable",
407
+ "work-item.source.ask.by": "alice",
408
+ "work-item.source.ask.via": "hud",
409
+ "work-item.acceptance": [],
410
+ });
411
+
412
+ test("the earlier question about a changed item is withdrawn, and --open leaves it out", async () => {
413
+ const first = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Configurable port"), subject: "W-101", on }));
414
+ expect(first.question).toMatchObject({ state: "escalated", open: true, withdrawn: null });
415
+ // The author changes the item: the next ask is another question about W-101, and both are open.
416
+ const second = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Make the server port configurable"), subject: "W-101", on }));
417
+ expect(second.id).not.toBe(first.id);
418
+ const openAbout = async () => {
419
+ const listed = await workspacePoints({ cwd: root, open: true });
420
+ read.expectValid(listed);
421
+ if ("error" in listed) throw new Error(listed.error.message);
422
+ return listed.questions.filter((q) => q.subject === "W-101").map((q) => q.id);
423
+ };
424
+ expect((await openAbout()).sort()).toEqual([first.id, second.id].sort());
425
+
426
+ const withdrawn = ok(await withdrawQuestion({ cwd: root, id: first.id, by: ["factory"], note: `W-101 changed; ${second.id} asks it now.`, on: "2026-09-26" }));
427
+ expect(withdrawn).toMatchObject({ verb: "withdraw", written: true });
428
+ expect(withdrawn.question).toMatchObject({ state: "escalated", open: false, answer: null, withdrawn: { by: ["factory"], on: "2026-09-26", note: `W-101 changed; ${second.id} asks it now.` } });
429
+ expect(withdrawn.question.title).toMatch(/: withdrawn$/);
430
+ expect(fm(first.path)).toContain('withdrawn:\n by:\n - "factory"');
431
+ expect(await openAbout()).toEqual([second.id]);
432
+ });
433
+
434
+ test("a withdrawn question is closed: nobody answers it, and refusals", async () => {
435
+ const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Refusals"), on }));
436
+ expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: [" "] }))).toBe("write-usage-invalid");
437
+ expect(ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], dryRun: true }))).toMatchObject({ verb: "withdraw", written: false, question: { open: false } });
438
+ expect(fm(asked.path)).not.toContain("withdrawn:");
439
+ ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
440
+ expect(refused(await answerPoint({ cwd: root, id: asked.id, answer: "proceed", by: ["alice"] }))).toBe("record-closed");
441
+ expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"] }))).toBe("record-closed");
442
+ const table = ok(await askPoint({ cwd: root, point: "slice-tier", inputs: { "work-item.fits_small": true }, on }));
443
+ expect(refused(await withdrawQuestion({ cwd: root, id: table.id, by: ["factory"] }))).toBe("record-closed");
444
+ const all = await workspacePoints({ cwd: root });
445
+ if ("error" in all) throw new Error(all.error.message);
446
+ expect(all.summary.withdrawn).toBe(all.questions.filter((q) => q.withdrawn !== null).length);
447
+ expect(all.summary.withdrawn).toBeGreaterThan(0);
448
+ });
449
+
450
+ test("asking a withdrawn question again opens it again", async () => {
451
+ const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Port, again"), subject: "W-102", on }));
452
+ ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
453
+ // The item changed back: the same inputs ask the same question.
454
+ const again = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Port, again"), on: "2026-09-27" }));
455
+ expect(again).toMatchObject({ id: asked.id, reused: false, written: true, question: { state: "escalated", open: true, withdrawn: null, subject: "W-102", askedOn: "2026-09-27" } });
456
+ expect(fm(asked.path)).not.toContain("withdrawn:");
457
+ });
458
+
459
+ test("a retracted question withdrawn and asked again is open for people again, its retractions kept", async () => {
460
+ const inputs = { "region.id": "region:app/server.mjs:1-1", "node.id": "decision:ref-009" };
461
+ const asked = ok(await askPoint({ cwd: root, point: "intent-origin", inputs, on }));
462
+ ok(await answerPoint({ cwd: root, id: asked.id, answer: "incidental", by: ["alice"], on }));
463
+ ok(await retractAnswer({ cwd: root, id: asked.id, by: ["alice"], on }));
464
+ ok(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"], on }));
465
+ const again = ok(await askPoint({ cwd: root, point: "intent-origin", inputs, on }));
466
+ expect(again).toMatchObject({ reused: false, question: { state: "escalated", open: true, withdrawn: null, retractions: [{ answer: "incidental" }] } });
467
+ expect(again.question.title).toMatch(/: open for people$/);
468
+ });
469
+
470
+ test("a workspace whose answer schema predates withdrawn refuses to withdraw", async () => {
471
+ expect(answerFields(pointAnswerSchema).withdrawn).toBe(true);
472
+ const asked = ok(await askPoint({ cwd: root, point: "understand", inputs: item("Old schema"), on }));
473
+ const schemaFile = join(root, "answers", "answer.schema.json");
474
+ const original = readFileSync(schemaFile, "utf-8");
475
+ const old = JSON.parse(original);
476
+ delete old.properties.withdrawn;
477
+ writeFileSync(schemaFile, JSON.stringify(old, null, 2));
478
+ try {
479
+ expect(refused(await withdrawQuestion({ cwd: root, id: asked.id, by: ["factory"] }))).toBe("answer-field-unsupported");
480
+ } finally {
481
+ writeFileSync(schemaFile, original);
482
+ }
483
+ });
484
+ });
485
+
402
486
  describe("the points file in check and on read (#2738)", () => {
403
487
  test("a changed point warns on its answers, and an invalid points file is WSP116 and a points-invalid source", async () => {
404
488
  const file = join(root, "decisions", "points.json");