things-api 0.19.3 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +1 -1
  2. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  3. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  5. package/dist/audit/schema.d.ts +42 -10
  6. package/dist/audit/schema.js +21 -0
  7. package/dist/audit/schema.js.map +1 -1
  8. package/dist/cli/commands/doctor.js +3 -1
  9. package/dist/cli/commands/doctor.js.map +1 -1
  10. package/dist/cli/commands/op-result.js +25 -7
  11. package/dist/cli/commands/op-result.js.map +1 -1
  12. package/dist/cli/commands/repeat-flags.d.ts +19 -1
  13. package/dist/cli/commands/repeat-flags.js +71 -0
  14. package/dist/cli/commands/repeat-flags.js.map +1 -1
  15. package/dist/cli/commands/rescue.d.ts +12 -0
  16. package/dist/cli/commands/rescue.js +83 -0
  17. package/dist/cli/commands/rescue.js.map +1 -0
  18. package/dist/cli/commands/writes.js +86 -32
  19. package/dist/cli/commands/writes.js.map +1 -1
  20. package/dist/cli/help.js +25 -25
  21. package/dist/cli/help.js.map +1 -1
  22. package/dist/cli/main.js +12 -2
  23. package/dist/cli/main.js.map +1 -1
  24. package/dist/cli/resolve-invocation.d.ts +9 -1
  25. package/dist/cli/resolve-invocation.js +71 -0
  26. package/dist/cli/resolve-invocation.js.map +1 -1
  27. package/dist/cli/unknown-command.d.ts +26 -0
  28. package/dist/cli/unknown-command.js +135 -0
  29. package/dist/cli/unknown-command.js.map +1 -0
  30. package/dist/contracts.d.ts +4 -3
  31. package/dist/contracts.js +1 -1
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/server.js +62 -10
  36. package/dist/mcp/server.js.map +1 -1
  37. package/dist/model/entities.d.ts +11 -0
  38. package/dist/model/entities.js.map +1 -1
  39. package/dist/model/mappers.js +15 -0
  40. package/dist/model/mappers.js.map +1 -1
  41. package/dist/op-result.d.ts +24 -2
  42. package/dist/op-result.js +77 -20
  43. package/dist/op-result.js.map +1 -1
  44. package/dist/process-instance.d.ts +41 -0
  45. package/dist/process-instance.js +82 -0
  46. package/dist/process-instance.js.map +1 -0
  47. package/dist/rescue.d.ts +247 -0
  48. package/dist/rescue.js +811 -0
  49. package/dist/rescue.js.map +1 -0
  50. package/dist/session-grant.js +4 -15
  51. package/dist/session-grant.js.map +1 -1
  52. package/dist/ui-state.d.ts +8 -4
  53. package/dist/ui-state.js +8 -4
  54. package/dist/ui-state.js.map +1 -1
  55. package/dist/write/batch.js +4 -1
  56. package/dist/write/batch.js.map +1 -1
  57. package/dist/write/clone.js +12 -6
  58. package/dist/write/clone.js.map +1 -1
  59. package/dist/write/commands.d.ts +9 -0
  60. package/dist/write/commands.js +135 -50
  61. package/dist/write/commands.js.map +1 -1
  62. package/dist/write/disclosures.d.ts +275 -0
  63. package/dist/write/disclosures.js +277 -0
  64. package/dist/write/disclosures.js.map +1 -0
  65. package/dist/write/experimental.d.ts +16 -0
  66. package/dist/write/experimental.js +19 -0
  67. package/dist/write/experimental.js.map +1 -1
  68. package/dist/write/failure-hints.js +3 -2
  69. package/dist/write/failure-hints.js.map +1 -1
  70. package/dist/write/guards.d.ts +1 -1
  71. package/dist/write/guards.js +102 -3
  72. package/dist/write/guards.js.map +1 -1
  73. package/dist/write/heading.js +4 -7
  74. package/dist/write/heading.js.map +1 -1
  75. package/dist/write/lock.d.ts +85 -1
  76. package/dist/write/lock.js +121 -6
  77. package/dist/write/lock.js.map +1 -1
  78. package/dist/write/make-repeating-project.js +2 -1
  79. package/dist/write/make-repeating-project.js.map +1 -1
  80. package/dist/write/operations.d.ts +52 -6
  81. package/dist/write/operations.js +22 -0
  82. package/dist/write/operations.js.map +1 -1
  83. package/dist/write/opid.d.ts +69 -8
  84. package/dist/write/opid.js +116 -20
  85. package/dist/write/opid.js.map +1 -1
  86. package/dist/write/param-schema.js +14 -2
  87. package/dist/write/param-schema.js.map +1 -1
  88. package/dist/write/pipeline.d.ts +82 -3
  89. package/dist/write/pipeline.js +204 -60
  90. package/dist/write/pipeline.js.map +1 -1
  91. package/dist/write/planner.d.ts +7 -0
  92. package/dist/write/planner.js +12 -0
  93. package/dist/write/planner.js.map +1 -1
  94. package/dist/write/preserve-modified.d.ts +17 -8
  95. package/dist/write/preserve-modified.js.map +1 -1
  96. package/dist/write/promote-clone.js +106 -53
  97. package/dist/write/promote-clone.js.map +1 -1
  98. package/dist/write/reorder.js +45 -39
  99. package/dist/write/reorder.js.map +1 -1
  100. package/dist/write/repeat-rule.d.ts +25 -1
  101. package/dist/write/repeat-rule.js +69 -0
  102. package/dist/write/repeat-rule.js.map +1 -1
  103. package/dist/write/resolution-timestamps.js +104 -78
  104. package/dist/write/resolution-timestamps.js.map +1 -1
  105. package/dist/write/reversibility.js +2 -2
  106. package/dist/write/reversibility.js.map +1 -1
  107. package/dist/write/spawn-expectation.d.ts +135 -0
  108. package/dist/write/spawn-expectation.js +237 -0
  109. package/dist/write/spawn-expectation.js.map +1 -0
  110. package/dist/write/template-mutation.js +34 -35
  111. package/dist/write/template-mutation.js.map +1 -1
  112. package/dist/write/undo.js +45 -5
  113. package/dist/write/undo.js.map +1 -1
  114. package/dist/write/vectors/simulator.js +48 -2
  115. package/dist/write/vectors/simulator.js.map +1 -1
  116. package/dist/write/vectors/types.d.ts +12 -0
  117. package/dist/write/vectors/ui-recipes.js +11 -1
  118. package/dist/write/vectors/ui-recipes.js.map +1 -1
  119. package/dist/write/vectors/ui-state.d.ts +64 -6
  120. package/dist/write/vectors/ui-state.js +91 -4
  121. package/dist/write/vectors/ui-state.js.map +1 -1
  122. package/dist/write/vectors/ui.d.ts +69 -3
  123. package/dist/write/vectors/ui.js +293 -146
  124. package/dist/write/vectors/ui.js.map +1 -1
  125. package/dist/write/vectors/url-scheme.js +26 -0
  126. package/dist/write/vectors/url-scheme.js.map +1 -1
  127. package/dist/write/verify/delta.d.ts +17 -2
  128. package/dist/write/verify/delta.js +46 -16
  129. package/dist/write/verify/delta.js.map +1 -1
  130. package/dist/write/verify/poller.d.ts +3 -2
  131. package/dist/write/verify/poller.js +3 -1
  132. package/dist/write/verify/poller.js.map +1 -1
  133. package/package.json +1 -1
  134. package/schema/envelope.schema.json +1 -2
  135. package/skills/things-cli/SKILL.md +4 -2
  136. package/skills/things-cli/references/repeating.md +1 -1
  137. package/dist/cli/commands/ui-state.d.ts +0 -11
  138. package/dist/cli/commands/ui-state.js +0 -27
  139. package/dist/cli/commands/ui-state.js.map +0 -1
@@ -11,6 +11,7 @@ import { statSync } from "node:fs";
11
11
  import { undoToken } from "../audit/schema.js";
12
12
  import { blockedCode, verifyFailedCode } from "../contracts.js";
13
13
  import { localToday } from "../model/dates.js";
14
+ import { currentInstance } from "../process-instance.js";
14
15
  import { liveSeriesInstances, resolveProjectWriteTarget, resolveTaskUuidPrefix, } from "../read/queries.js";
15
16
  import { namedProjectClause, taskMembershipClause } from "../read/scope.js";
16
17
  import { evaluateScope } from "./scope-guard.js";
@@ -18,13 +19,19 @@ import { isThingsRunning } from "./automation-probe.js";
18
19
  import { readShortcutProxies } from "./availability.js";
19
20
  import { uiAllowed, uiCapability as uiCapabilityDefault, urlSchemeAllowed, urlSchemeCapability as urlSchemeCapabilityDefault, writeAllowed, writeCapability as writeCapabilityDefault, } from "../capability.js";
20
21
  import { COMMANDS } from "./commands.js";
22
+ import { carry, disclose, newDisclosures, tiers } from "./disclosures.js";
21
23
  import { describeEnvironmentChanges, diffEnvironment, } from "./environment.js";
22
- import { privateReorderIsNoOp, sdefDeclaresPrivateReorder } from "./experimental.js";
24
+ import { privateReorderIsNoOp, sdefDeclaresPrivateReorder, urlReanchorSupported, } from "./experimental.js";
23
25
  import { classifyTransportFailure, classifyVerifyFailure, } from "./failure-hints.js";
24
26
  import { evaluateGuards } from "./guards.js";
25
- import { acquireMutationLock, MutationLockError, withMutationLock, } from "./lock.js";
26
- import { isHeadingTargetOp, } from "./operations.js";
27
+ import { acquireMutationLock, describeLockRefusal, MutationLockError, withMutationLock, } from "./lock.js";
28
+ import { isHeadingTargetOp, isRepeatReanchor, } from "./operations.js";
27
29
  import { describeTruncation } from "./field-limits.js";
30
+ // The idempotency gate is a MUTUAL import with this module (it builds its replay
31
+ // results from `replayResultFromRecord` here, and the double-checked lookback
32
+ // here calls back into it). Both sides are hoisted function declarations, so the
33
+ // cycle resolves at module-evaluation time; nothing is read at import time.
34
+ import { replayIfApplied } from "./opid.js";
28
35
  import { assertOperationParams } from "./param-schema.js";
29
36
  import { computeCompletionContext } from "./completion-context.js";
30
37
  import { assessOffRuleFirst } from "./repeat-anchor.js";
@@ -43,6 +50,15 @@ import { setInflight, trace } from "../trace/tracer.js";
43
50
  * transport exit is re-verified over ~2s before the write is declared lost.
44
51
  */
45
52
  const RECOVERY_VERIFY_TIMEOUT_MS = 2000;
53
+ /**
54
+ * The drive's step play-by-play as a spreadable fragment, omitted when there is
55
+ * none (a transport vector runs no steps). One helper so the FAILURE paths, the
56
+ * change-history record, and the `--verbose` success all read the same field and
57
+ * cannot drift apart (#632).
58
+ */
59
+ function stepsOf(result) {
60
+ return result.steps !== undefined && result.steps.length > 0 ? { steps: result.steps } : {};
61
+ }
46
62
  export function readAuthToken(db) {
47
63
  try {
48
64
  const row = db.prepare("SELECT uriSchemeAuthenticationToken AS t FROM TMSettings").get();
@@ -295,40 +311,92 @@ function truncationDetail(delta, observed) {
295
311
  }
296
312
  return undefined;
297
313
  }
298
- /**
299
- * Run a COMPOSITE — a single verb the engine executes as several mutations —
300
- * under ONE mutation lock held end-to-end, so it serializes against other
301
- * writers as a whole instead of leg by leg. Every leg's own
302
- * `acquireMutationLock` inside `body` is a reentrant no-op (see
303
- * {@link withMutationLock}); the per-leg lock stays the base case for an
304
- * ordinary single mutation.
305
- *
306
- * Wrap only the MUTATING tail: validation, dry-run planning, and the hazard /
307
- * session gates belong outside, so a refusal never takes the lock. On contention
308
- * the refusal is the same `blocked` / `lock` shape the pipeline returns for a
309
- * single op, carrying the composite's own op name. No audit record is written
310
- * for it — unlike the single-op path, nothing was attempted, so there is no leg
311
- * to record.
312
- */
313
- export async function runComposite(deps, op, body,
314
+ export async function runComposite(deps, op, body, keyed,
314
315
  /** @internal test seam — see {@link AcquireMutationLockOptions}. */
315
316
  lockOptions = {}) {
316
317
  try {
317
- return await withMutationLock(deps.lockPath, body, lockOptions);
318
+ return await withMutationLock(deps.lockPath, async () => {
319
+ if (keyed === undefined || keyed.options.opId === undefined)
320
+ return body();
321
+ const locked = replayIfApplied(deps, keyed.options);
322
+ if (locked !== null)
323
+ return locked;
324
+ appendSummaryIntent(deps, op, keyed);
325
+ const outcome = await body();
326
+ closeSummaryIntent(deps, op, keyed, outcome);
327
+ return outcome;
328
+ }, lockOptions);
318
329
  }
319
330
  catch (err) {
320
331
  if (err instanceof MutationLockError) {
321
- return {
322
- kind: "blocked",
323
- op,
324
- reason: "lock",
325
- detail: err.message,
326
- remediation: "wait for the concurrent mutation to finish and retry",
327
- };
332
+ return { kind: "blocked", op, reason: "lock", ...describeLockRefusal(err) };
328
333
  }
329
334
  throw err;
330
335
  }
331
336
  }
337
+ /**
338
+ * The keyed composite's write-ahead intent: ONE summary-layer record marking the
339
+ * key as in flight, with the holder identity that makes "still running" a
340
+ * decidable question rather than a hedge. Superseded, append-only, by whichever
341
+ * summary the verb writes when it finishes — this record is never rewritten.
342
+ */
343
+ function appendSummaryIntent(deps, op, keyed, result = "intent") {
344
+ const fp = deps.fingerprint();
345
+ const inFlight = result === "intent";
346
+ deps.audit.append({
347
+ v: 1,
348
+ ts: keyed.startedAt.toISOString(),
349
+ actor: keyed.options.actor ?? deps.config.actor,
350
+ host: deps.config.host,
351
+ op,
352
+ uuid: keyed.uuid,
353
+ vector: null,
354
+ disruption: null,
355
+ invocation: null,
356
+ txn: { id: keyed.txnId, role: "summary" },
357
+ opId: keyed.options.opId,
358
+ ...(inFlight && { holder: currentInstance() }),
359
+ ...(inFlight && keyed.expected !== undefined && { expected: keyed.expected }),
360
+ requested: keyed.requested,
361
+ pre: null,
362
+ observed: null,
363
+ result,
364
+ verify: null,
365
+ durationMs: 0,
366
+ env: {
367
+ pkg: deps.pkgVersion ?? "0.0.1",
368
+ dbVersion: fp.observation.databaseVersion,
369
+ fingerprint: fingerprintLabel(fp, deps.config),
370
+ },
371
+ });
372
+ }
373
+ /**
374
+ * Supersede the write-ahead intent for the outcomes whose composite bodies write
375
+ * NO summary of their own — a refusal, an unsupported verb, or a verification
376
+ * failure that is not a timeout. Those all mean "this key is finished and
377
+ * nothing landed", and without a record saying so the intent would stand
378
+ * unsuperseded: once this process exits, its holder reads as dead and every
379
+ * later use of the key would be met with a reconcile refusal for a change that
380
+ * never happened.
381
+ *
382
+ * The two outcomes NOT closed here are the two the bodies already record: `ok`
383
+ * (the success summary, the verb's single undoable unit) and a `timeout`
384
+ * verify-failed (the AMBIGUOUS summary carrying the presence oracle). Writing a
385
+ * second record for either would compete with the one the caller's replay is
386
+ * meant to find.
387
+ */
388
+ function closeSummaryIntent(deps, op, keyed, outcome) {
389
+ if (outcome.kind === "ok" || outcome.kind === "dry-run")
390
+ return;
391
+ if (outcome.kind === "verify-failed" && outcome.reason === "timeout")
392
+ return;
393
+ const result = outcome.kind === "blocked"
394
+ ? blockedCode(outcome)
395
+ : outcome.kind === "unsupported"
396
+ ? "unsupported"
397
+ : verifyFailedCode(outcome);
398
+ appendSummaryIntent(deps, op, keyed, result);
399
+ }
332
400
  export async function runMutation(deps, op, params, options = {}) {
333
401
  const startedAt = deps.now?.() ?? new Date();
334
402
  // Things' own "Enable Things URLs" standing, read at most ONCE per mutation
@@ -452,17 +520,29 @@ export async function runMutation(deps, op, params, options = {}) {
452
520
  catch (err) {
453
521
  if (err instanceof MutationLockError) {
454
522
  audit({ result: blockedCode({ reason: "lock" }) });
455
- return {
456
- kind: "blocked",
457
- op,
458
- reason: "lock",
459
- detail: err.message,
460
- remediation: "wait for the concurrent mutation to finish and retry",
461
- };
523
+ return { kind: "blocked", op, reason: "lock", ...describeLockRefusal(err) };
462
524
  }
463
525
  throw err;
464
526
  }
465
527
  try {
528
+ // 2a. DOUBLE-CHECKED IDEMPOTENCY (#639). The consumer entry already ran this
529
+ // lookback before we got here — but it ran it BEFORE the lock, and the whole
530
+ // point of a lock is that the world changes while you wait for it. A retry
531
+ // fired while the original was still mid-drive saw no record, queued here,
532
+ // and used to wake up after the original finished and execute the verb a
533
+ // SECOND time. Re-asking under the lock is what makes the earlier answer
534
+ // safe to have acted on: by now the original has written its record, so the
535
+ // answer is a replay (or a reconcile refusal) instead of a duplicate.
536
+ //
537
+ // The pre-lock check is kept as the cheap fast path — a settled key never
538
+ // reaches the lock at all — so this costs one extra trail read on exactly
539
+ // the writes that raced.
540
+ // (Returning here releases the lock through the `finally` below — never an
541
+ // explicit release as well: releasing twice unlinks whatever lock the NEXT
542
+ // writer has since created.)
543
+ const locked = replayIfApplied(deps, options);
544
+ if (locked !== null)
545
+ return locked;
466
546
  // 3. Pre-read. The consumer zone (options.zone ?? deps.zone) is threaded so
467
547
  // any boundary-derived pre-state (the reorder swept/unswept log boundary,
468
548
  // LOGSORT ORD-13) is computed under the SAME zone the reads use.
@@ -543,12 +623,36 @@ export async function runMutation(deps, op, params, options = {}) {
543
623
  }
544
624
  params = norm.params;
545
625
  }
626
+ // 3c. The series RE-ANCHOR's VERSION gate (REANCH1 §6). A dated `when=` on a
627
+ // repeating template re-anchors the series on Things 3.23 and KILLS the
628
+ // process on 3.22.14 — an unannounced behavior change, in the unusual
629
+ // direction. So the capability is gated on the installed version and fails
630
+ // CLOSED on an unreadable one; below the gate the dialog spelling (which
631
+ // works on both lines) is the remedy. Checked here, before planning, so the
632
+ // refusal names the version rather than surfacing as an unsupported vector.
633
+ if ((op === "todo.reschedule-repeat" || op === "project.reschedule-repeat") &&
634
+ isRepeatReanchor(params) &&
635
+ !urlReanchorSupported(deps.environment?.capture().thingsVersion ?? null)) {
636
+ audit({ result: blockedCode({ reason: "environment" }) });
637
+ return {
638
+ kind: "blocked",
639
+ op,
640
+ reason: "environment",
641
+ detail: "this version of Things cannot move a repeating item's next occurrence on its own — " +
642
+ "the version that can is 3.23 or later",
643
+ remediation: "restate the rule instead: `things " +
644
+ (op.startsWith("project.") ? "project" : "todo") +
645
+ " reschedule-repeat <ref> --frequency <freq> --interval <n> --when <date> " +
646
+ "--dangerously-drive-gui`, or update Things",
647
+ };
648
+ }
546
649
  // 4. Vector planning under the disruption policy.
547
650
  const appRunning = (deps.isAppRunning ?? defaultIsAppRunning)();
548
651
  const plan = planVector(op, deps.vectors, {
549
652
  maxDisruption,
550
653
  appRunning,
551
654
  allowExperimental: config.allowExperimental,
655
+ ...(spec.vectorsFor !== undefined && { allowVectors: spec.vectorsFor(params) }),
552
656
  ...(options.vector !== undefined && { forcedVector: options.vector }),
553
657
  });
554
658
  if (plan.kind === "unsupported") {
@@ -674,6 +778,8 @@ export async function runMutation(deps, op, params, options = {}) {
674
778
  const preEval = evaluateDelta(delta, preReader, preCapture);
675
779
  if (preEval.satisfied) {
676
780
  const uuid = delta.uuid;
781
+ const noOp = newDisclosures();
782
+ disclose(noOp, "already-in-state", "the item was already in the requested state — no GUI drive was performed (idempotent no-op)");
677
783
  audit({
678
784
  result: "ok",
679
785
  vector: vector.id,
@@ -693,9 +799,7 @@ export async function runMutation(deps, op, params, options = {}) {
693
799
  vector: vector.id,
694
800
  tier: effectiveTier,
695
801
  // No undoToken: nothing changed, so there is nothing to invert.
696
- warnings: [
697
- "the item was already in the requested state — no GUI drive was performed (idempotent no-op)",
698
- ],
802
+ ...tiers(noOp),
699
803
  };
700
804
  }
701
805
  }
@@ -866,6 +970,12 @@ export async function runMutation(deps, op, params, options = {}) {
866
970
  // ONLY evidence the mutation may have landed. `things doctor` surfaces such
867
971
  // orphans; the final record written after verify supersedes this one.
868
972
  // (dry-run returned above, so nothing is recorded for a dry-run — preserved.)
973
+ //
974
+ // A KEYED write additionally makes this intent READABLE (#639): the holder's
975
+ // instance identity turns "no outcome recorded" into a decidable question
976
+ // (still running vs. died mid-flight), and `expected` gives a dead holder's
977
+ // resubmission the same presence oracle a timeout gets. Both are recorded
978
+ // only under a key — an unkeyed intent is crash evidence nobody polls.
869
979
  const intentUuid = delta.mode === "update" || delta.mode === "state"
870
980
  ? delta.uuid
871
981
  : delta.mode === "ordering"
@@ -878,6 +988,7 @@ export async function runMutation(deps, op, params, options = {}) {
878
988
  invocation: invocation.redactedPayload,
879
989
  pre: flattenPreFields(preCapture.fields),
880
990
  uuid: intentUuid,
991
+ ...(options.opId !== undefined && { holder: currentInstance(), expected: delta }),
881
992
  });
882
993
  // Mark the write as touching the app (read by the CLI's signal handler so a
883
994
  // SIGTERM/SIGINT can name the exact op — and last UI step — it interrupted,
@@ -962,6 +1073,7 @@ export async function runMutation(deps, op, params, options = {}) {
962
1073
  invocation: invocation.redactedPayload,
963
1074
  pre: flattenPreFields(preCapture.fields),
964
1075
  observed: recovery.observed,
1076
+ ...stepsOf(executeResult),
965
1077
  // The watchdog abort is THE ambiguous outcome — the detail below says
966
1078
  // so — so the assertion rides the record and a resubmission with the
967
1079
  // same opId re-reads state instead of risking the duplicate.
@@ -990,6 +1102,7 @@ export async function runMutation(deps, op, params, options = {}) {
990
1102
  `retrying (retrying could create a duplicate series).${traceNote}`,
991
1103
  uncertain: true,
992
1104
  ...(wd.tracePath != null && wd.tracePath !== "" && { tracePath: wd.tracePath }),
1105
+ ...stepsOf(executeResult),
993
1106
  };
994
1107
  }
995
1108
  // The ui drive stopped because the Things WINDOW stopped answering
@@ -1011,6 +1124,7 @@ export async function runMutation(deps, op, params, options = {}) {
1011
1124
  invocation: invocation.redactedPayload,
1012
1125
  pre: flattenPreFields(preCapture.fields),
1013
1126
  observed: recovery.observed,
1127
+ ...stepsOf(executeResult),
1014
1128
  });
1015
1129
  return {
1016
1130
  kind: "verify-failed",
@@ -1018,6 +1132,7 @@ export async function runMutation(deps, op, params, options = {}) {
1018
1132
  reason: "ui-unreachable",
1019
1133
  expected: delta,
1020
1134
  observed: recovery.observed,
1135
+ ...stepsOf(executeResult),
1021
1136
  detail: `the Things window could not be driven: the drive stopped at step ` +
1022
1137
  `"${unreachable.step}" because ` +
1023
1138
  (unreachable.cause === "unreachable"
@@ -1106,7 +1221,8 @@ export async function runMutation(deps, op, params, options = {}) {
1106
1221
  // and a row whose umd did not rise needs no restore (a silent op — no-op).
1107
1222
  // Best-effort: a failed restore is disclosed per row, never fatal (the
1108
1223
  // mutation already stands). The captured pre-values ride the audit record
1109
- // when the flag is active (enables a future symmetric undo).
1224
+ // when the flag is active, which is what the symmetric undo restores from
1225
+ // (undo.ts, keyed on `preModDates`).
1110
1226
  let preserve = null;
1111
1227
  let preModDatesAudit;
1112
1228
  if (options.preserveModified === true) {
@@ -1125,10 +1241,15 @@ export async function runMutation(deps, op, params, options = {}) {
1125
1241
  preModDatesAudit = captured;
1126
1242
  preserve = await restoreModDates(deps.db, deps.vectors, targets);
1127
1243
  }
1244
+ // The change-history record is the APPEND-ONLY DEBUG LOG (#632): it
1245
+ // captures the drive's step list on success as well as on failure, so the
1246
+ // play-by-play a default success output no longer prints is still
1247
+ // retrievable — `things op-result <key>` renders it.
1128
1248
  audit({
1129
1249
  ...auditCommon,
1130
1250
  result: "ok",
1131
1251
  uuid,
1252
+ ...stepsOf(executeResult),
1132
1253
  ...(preModDatesAudit !== undefined && { preModDates: preModDatesAudit }),
1133
1254
  });
1134
1255
  if (deps.environment !== undefined) {
@@ -1154,12 +1275,12 @@ export async function runMutation(deps, op, params, options = {}) {
1154
1275
  uuid,
1155
1276
  ...(options.txn !== undefined && { txn: options.txn }),
1156
1277
  });
1157
- const warnings = [];
1278
+ const bag = newDisclosures();
1158
1279
  // Auto-launch disclosure (#486): the app was not running when this write
1159
1280
  // started, so it was background-launched for the write. Never silent — a
1160
1281
  // side effect the caller should see (a simulating vector never launches).
1161
1282
  if (!appRunning && vector.simulates !== true) {
1162
- warnings.push("Things was not running, so it was launched in the background for this write");
1283
+ disclose(bag, "auto-launch", "Things was not running, so it was launched in the background for this write");
1163
1284
  }
1164
1285
  // Template-delete disclosure (public deletes only — internal trash-both legs
1165
1286
  // run under a txn and aggregate their own result). The series stops, its live
@@ -1168,48 +1289,64 @@ export async function runMutation(deps, op, params, options = {}) {
1168
1289
  if (templateDelete && options.txn?.role !== "leg" && pre.target !== null) {
1169
1290
  const kindNoun = op === "project.delete" ? "project" : "to-do";
1170
1291
  const series = liveSeriesInstances(deps.db, pre.target.uuid);
1171
- warnings.push("this repeating series will no longer generate new occurrences");
1292
+ disclose(bag, "template-delete-series-stops", "this repeating series will no longer generate new occurrences");
1172
1293
  if (series.count > 0) {
1173
- warnings.push(`its ${series.count} existing occurrence${series.count === 1 ? " was" : "s were"} ` +
1294
+ disclose(bag, "template-delete-occurrences-left", `its ${series.count} existing occurrence${series.count === 1 ? " was" : "s were"} ` +
1174
1295
  "left in place (not moved to the Trash)" +
1175
1296
  (series.currentUuid !== null ? ` — the current one is ${series.currentUuid}` : ""));
1176
1297
  }
1177
- warnings.push(`this cannot be undone here — to bring the series back, use the Things app's Trash ` +
1298
+ disclose(bag, "template-delete-irreversible", `this cannot be undone here — to bring the series back, use the Things app's Trash ` +
1178
1299
  `(Put Back on the ${kindNoun})`);
1179
1300
  }
1180
1301
  if (transportRecovered) {
1181
- warnings.push("the GUI drive reported a transport error, but a follow-up re-read confirmed the " +
1302
+ disclose(bag, "transport-recovered", "the GUI drive reported a transport error, but a follow-up re-read confirmed the " +
1182
1303
  "requested change DID land — no retry is needed (retrying could overwrite it)");
1183
1304
  }
1184
- if (outcome.repeatingWarnings !== undefined)
1185
- warnings.push(...outcome.repeatingWarnings);
1305
+ // The instance-derivation disclosures arrive ALREADY TIERED from the
1306
+ // expectation assertion (#634) — carried, never reclassified here.
1307
+ carry(bag, outcome.repeatingDisclosures);
1186
1308
  // DACON1 off-rule-first disclosure (reschedule-repeat): an explicit anchor
1187
1309
  // that disagrees with --when lands an OFF-RULE first occurrence (honored for
1188
1310
  // weekly/yearly). State both halves of the landed pattern; the dishonored
1189
1311
  // monthly shape was already refused at validation. (make/add-repeating carry
1190
1312
  // their own disclosure via the promote result.)
1191
1313
  if (op === "todo.reschedule-repeat" || op === "project.reschedule-repeat") {
1192
- const offRuleParams = params;
1193
- const offRule = assessOffRuleFirst(offRuleParams, preservedDeadlineOffsetFor(pre, offRuleParams));
1194
- if (offRule?.kind === "honored")
1195
- warnings.push(offRule.disclosure.message);
1314
+ const reschedule = params;
1315
+ if (isRepeatReanchor(reschedule)) {
1316
+ // The three things a re-anchor does that the caller did not ask for,
1317
+ // all measured (REANCH1 §2.3, §2.2/§7, §8): the recurring pattern moves
1318
+ // with the date, the slots in between are dropped, and there is no
1319
+ // headless way back.
1320
+ disclose(bag, "reanchor-series-moved", `the whole series moved: it now repeats from ${reschedule.next} — a weekly item ` +
1321
+ "moved to a Thursday repeats on Thursdays, a monthly one moved to the 17th " +
1322
+ "repeats on the 17th");
1323
+ disclose(bag, "reanchor-slots-skipped", `any occurrence due before ${reschedule.next} will not appear — the series resumes ` +
1324
+ "on the new date");
1325
+ disclose(bag, "reanchor-irreversible", "this cannot be undone here — move the series to another date, or use the Things " +
1326
+ "app's own undo");
1327
+ }
1328
+ else {
1329
+ const offRuleParams = reschedule;
1330
+ const offRule = assessOffRuleFirst(offRuleParams, preservedDeadlineOffsetFor(pre, offRuleParams));
1331
+ if (offRule?.kind === "honored") {
1332
+ disclose(bag, "promote-off-rule-first", offRule.disclosure.message);
1333
+ }
1334
+ }
1196
1335
  }
1197
1336
  if (vector.id === "ui") {
1198
- warnings.push("this change was applied by driving the local Things app through the Accessibility API");
1199
- // Surface the drive's own step summary (e.g. how a sidebar move was
1200
- // performed: one drag / scroll-while-held / N hops) behavior detail
1201
- // the caller can log, and the lab's certification evidence.
1202
- const driveSummary = executeResult.stdout.trim();
1203
- if (driveSummary !== "")
1204
- warnings.push(driveSummary);
1337
+ // COMPRESSED mechanism disclosure (#632). The old sentence spent a line
1338
+ // explaining the Accessibility API on every GUI-applied write; `vector`
1339
+ // and `tier` already carry the fact structurally, so the prose is now
1340
+ // just the few words a reader needs to place it.
1341
+ disclose(bag, "ui-mechanism", `applied via GUI drive (tier ${effectiveTier})`);
1205
1342
  const cert = certificationOf(op);
1206
1343
  if (cert !== undefined && cert.status !== "certified") {
1207
- warnings.push(`this operation is ${cert.status}: its GUI recipe has not been confirmed on real ` +
1344
+ disclose(bag, "ui-recipe-uncertified", `this operation is ${cert.status}: its GUI recipe has not been confirmed on real ` +
1208
1345
  "hardware (see `things doctor` / docs/lab/ui-certification-runbook.md)");
1209
1346
  }
1210
1347
  }
1211
1348
  if (envChanges.length > 0) {
1212
- warnings.push(`environment changed since the last verified write: ` +
1349
+ disclose(bag, "environment-changed", `environment changed since the last verified write: ` +
1213
1350
  `${describeEnvironmentChanges(envChanges)} — the first use of another ` +
1214
1351
  `capability may show a macOS consent prompt`);
1215
1352
  }
@@ -1246,12 +1383,18 @@ export async function runMutation(deps, op, params, options = {}) {
1246
1383
  ...(preserve !== null &&
1247
1384
  preserve.failures.length > 0 && { preserveFailures: preserve.failures }),
1248
1385
  ...(completionContext !== undefined && { context: completionContext }),
1249
- ...(warnings.length > 0 && { warnings }),
1386
+ // The step list rides a SUCCESS only when the caller asked for it —
1387
+ // the diagnostic ladder's middle rung (#632).
1388
+ ...(options.verbose === true && stepsOf(executeResult)),
1389
+ ...tiers(bag),
1250
1390
  };
1251
1391
  }
1392
+ // A FAILURE always carries the drive's play-by-play — on the record and on
1393
+ // the result (#632). It is why the field bug reports were rich enough to fix.
1252
1394
  audit({
1253
1395
  ...auditCommon,
1254
1396
  result: verifyFailedCode({ reason: outcome.kind }),
1397
+ ...stepsOf(executeResult),
1255
1398
  // A TIMEOUT is the one ambiguous verdict — the change may or may not have
1256
1399
  // landed — so the assertion this attempt was checking is recorded with it.
1257
1400
  // A resubmission carrying the same opId re-evaluates it against current
@@ -1264,6 +1407,7 @@ export async function runMutation(deps, op, params, options = {}) {
1264
1407
  reason: outcome.kind,
1265
1408
  expected: delta,
1266
1409
  observed: outcome.observed,
1410
+ ...stepsOf(executeResult),
1267
1411
  detail:
1268
1412
  // The collateral verdict carries its own sentence — which field moved,
1269
1413
  // from what to what, and why a retry is not the answer (CGRD1).