things-api 0.11.0 → 0.12.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 (192) hide show
  1. package/README.md +16 -5
  2. package/dist/audit/schema.d.ts +7 -0
  3. package/dist/audit/schema.js.map +1 -1
  4. package/dist/cli/commands/area.d.ts +18 -11
  5. package/dist/cli/commands/area.js +55 -67
  6. package/dist/cli/commands/area.js.map +1 -1
  7. package/dist/cli/commands/doctor.d.ts +1 -2
  8. package/dist/cli/commands/doctor.js +11 -1
  9. package/dist/cli/commands/doctor.js.map +1 -1
  10. package/dist/cli/commands/install-skill.d.ts +19 -4
  11. package/dist/cli/commands/install-skill.js +101 -52
  12. package/dist/cli/commands/install-skill.js.map +1 -1
  13. package/dist/cli/commands/mcp.js +9 -0
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/project.d.ts +1 -0
  16. package/dist/cli/commands/project.js +20 -28
  17. package/dist/cli/commands/project.js.map +1 -1
  18. package/dist/cli/commands/reads.js +100 -28
  19. package/dist/cli/commands/reads.js.map +1 -1
  20. package/dist/cli/commands/show.js +32 -8
  21. package/dist/cli/commands/show.js.map +1 -1
  22. package/dist/cli/commands/todo.js +1 -1
  23. package/dist/cli/commands/todo.js.map +1 -1
  24. package/dist/cli/commands/writes.js +769 -143
  25. package/dist/cli/commands/writes.js.map +1 -1
  26. package/dist/cli/did-you-mean.d.ts +14 -3
  27. package/dist/cli/did-you-mean.js +26 -3
  28. package/dist/cli/did-you-mean.js.map +1 -1
  29. package/dist/cli/glyphs.d.ts +16 -2
  30. package/dist/cli/glyphs.js +60 -12
  31. package/dist/cli/glyphs.js.map +1 -1
  32. package/dist/cli/help.js +67 -12
  33. package/dist/cli/help.js.map +1 -1
  34. package/dist/cli/main.js +1 -1
  35. package/dist/cli/main.js.map +1 -1
  36. package/dist/cli/move-hint.d.ts +3 -2
  37. package/dist/cli/move-hint.js +2 -2
  38. package/dist/cli/move-hint.js.map +1 -1
  39. package/dist/cli/read-driver.d.ts +43 -5
  40. package/dist/cli/read-driver.js +81 -15
  41. package/dist/cli/read-driver.js.map +1 -1
  42. package/dist/cli/render.d.ts +19 -8
  43. package/dist/cli/render.js +59 -25
  44. package/dist/cli/render.js.map +1 -1
  45. package/dist/cli/resolve-invocation.d.ts +2 -2
  46. package/dist/cli/resolve-invocation.js +3 -3
  47. package/dist/cli/resolve-invocation.js.map +1 -1
  48. package/dist/cli/skill-check.d.ts +12 -6
  49. package/dist/cli/skill-check.js +31 -14
  50. package/dist/cli/skill-check.js.map +1 -1
  51. package/dist/cli/skill.d.ts +8 -0
  52. package/dist/cli/skill.js +10 -0
  53. package/dist/cli/skill.js.map +1 -1
  54. package/dist/cli/verb-hint.js +1 -1
  55. package/dist/cli/verb-hint.js.map +1 -1
  56. package/dist/client.d.ts +140 -19
  57. package/dist/client.js +253 -38
  58. package/dist/client.js.map +1 -1
  59. package/dist/config.d.ts +66 -2
  60. package/dist/config.js +120 -12
  61. package/dist/config.js.map +1 -1
  62. package/dist/contracts.d.ts +176 -28
  63. package/dist/contracts.js +22 -1
  64. package/dist/contracts.js.map +1 -1
  65. package/dist/diagnose.d.ts +34 -2
  66. package/dist/diagnose.js +36 -1
  67. package/dist/diagnose.js.map +1 -1
  68. package/dist/index.d.ts +19 -8
  69. package/dist/index.js +19 -3
  70. package/dist/index.js.map +1 -1
  71. package/dist/mcp/server.d.ts +9 -0
  72. package/dist/mcp/server.js +733 -304
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/model/entities.d.ts +46 -3
  75. package/dist/model/entities.js.map +1 -1
  76. package/dist/model/mappers.d.ts +2 -0
  77. package/dist/model/mappers.js +41 -2
  78. package/dist/model/mappers.js.map +1 -1
  79. package/dist/model/recurrence.d.ts +8 -1
  80. package/dist/model/recurrence.js.map +1 -1
  81. package/dist/read/area-filter.d.ts +76 -0
  82. package/dist/read/area-filter.js +59 -0
  83. package/dist/read/area-filter.js.map +1 -0
  84. package/dist/read/area-view.d.ts +11 -11
  85. package/dist/read/area-view.js +50 -28
  86. package/dist/read/area-view.js.map +1 -1
  87. package/dist/read/detail.js +15 -8
  88. package/dist/read/detail.js.map +1 -1
  89. package/dist/read/predicates.d.ts +18 -0
  90. package/dist/read/predicates.js +19 -0
  91. package/dist/read/predicates.js.map +1 -1
  92. package/dist/read/project-view.d.ts +31 -15
  93. package/dist/read/project-view.js +72 -39
  94. package/dist/read/project-view.js.map +1 -1
  95. package/dist/read/pseudo-area.d.ts +29 -0
  96. package/dist/read/pseudo-area.js +27 -0
  97. package/dist/read/pseudo-area.js.map +1 -0
  98. package/dist/read/queries.d.ts +105 -18
  99. package/dist/read/queries.js +185 -30
  100. package/dist/read/queries.js.map +1 -1
  101. package/dist/read/scope.d.ts +126 -0
  102. package/dist/read/scope.js +162 -0
  103. package/dist/read/scope.js.map +1 -0
  104. package/dist/read/search-rank.d.ts +2 -5
  105. package/dist/read/shape.d.ts +162 -0
  106. package/dist/read/shape.js +686 -0
  107. package/dist/read/shape.js.map +1 -0
  108. package/dist/read/show-target.d.ts +9 -1
  109. package/dist/read/show-target.js +45 -4
  110. package/dist/read/show-target.js.map +1 -1
  111. package/dist/read/stage.d.ts +199 -0
  112. package/dist/read/stage.js +125 -0
  113. package/dist/read/stage.js.map +1 -0
  114. package/dist/read/truncation.d.ts +7 -5
  115. package/dist/read/truncation.js +33 -8
  116. package/dist/read/truncation.js.map +1 -1
  117. package/dist/read/views.d.ts +49 -9
  118. package/dist/read/views.js +193 -40
  119. package/dist/read/views.js.map +1 -1
  120. package/dist/surface-copy.d.ts +9 -0
  121. package/dist/surface-copy.js +9 -0
  122. package/dist/surface-copy.js.map +1 -1
  123. package/dist/write/batch.d.ts +49 -10
  124. package/dist/write/batch.js +423 -71
  125. package/dist/write/batch.js.map +1 -1
  126. package/dist/write/commands.js +266 -55
  127. package/dist/write/commands.js.map +1 -1
  128. package/dist/write/guards.d.ts +1 -1
  129. package/dist/write/guards.js +71 -10
  130. package/dist/write/guards.js.map +1 -1
  131. package/dist/write/heading.d.ts +10 -1
  132. package/dist/write/heading.js +35 -5
  133. package/dist/write/heading.js.map +1 -1
  134. package/dist/write/make-repeating-project.d.ts +2 -2
  135. package/dist/write/make-repeating-project.js +9 -9
  136. package/dist/write/make-repeating-project.js.map +1 -1
  137. package/dist/write/move.d.ts +130 -0
  138. package/dist/write/move.js +1533 -0
  139. package/dist/write/move.js.map +1 -0
  140. package/dist/write/operations.d.ts +105 -19
  141. package/dist/write/operations.js +56 -7
  142. package/dist/write/operations.js.map +1 -1
  143. package/dist/write/opid.d.ts +31 -0
  144. package/dist/write/opid.js +30 -0
  145. package/dist/write/opid.js.map +1 -0
  146. package/dist/write/pipeline.d.ts +70 -3
  147. package/dist/write/pipeline.js +198 -33
  148. package/dist/write/pipeline.js.map +1 -1
  149. package/dist/write/pre-state.d.ts +162 -8
  150. package/dist/write/pre-state.js +385 -20
  151. package/dist/write/pre-state.js.map +1 -1
  152. package/dist/write/reorder.d.ts +23 -3
  153. package/dist/write/reorder.js +1871 -85
  154. package/dist/write/reorder.js.map +1 -1
  155. package/dist/write/reversibility.js +19 -7
  156. package/dist/write/reversibility.js.map +1 -1
  157. package/dist/write/scope-guard.d.ts +31 -0
  158. package/dist/write/scope-guard.js +162 -0
  159. package/dist/write/scope-guard.js.map +1 -0
  160. package/dist/write/undo.js +120 -13
  161. package/dist/write/undo.js.map +1 -1
  162. package/dist/write/vectors/applescript.js +16 -7
  163. package/dist/write/vectors/applescript.js.map +1 -1
  164. package/dist/write/vectors/shortcuts.js +1 -1
  165. package/dist/write/vectors/shortcuts.js.map +1 -1
  166. package/dist/write/vectors/simulator.js +83 -27
  167. package/dist/write/vectors/simulator.js.map +1 -1
  168. package/dist/write/vectors/types.d.ts +11 -0
  169. package/dist/write/vectors/ui-certification.d.ts +3 -3
  170. package/dist/write/vectors/ui-certification.js +41 -5
  171. package/dist/write/vectors/ui-certification.js.map +1 -1
  172. package/dist/write/vectors/ui-drag.d.ts +21 -1
  173. package/dist/write/vectors/ui-drag.js +138 -27
  174. package/dist/write/vectors/ui-drag.js.map +1 -1
  175. package/dist/write/vectors/ui-recipes.d.ts +21 -1
  176. package/dist/write/vectors/ui-recipes.js +166 -7
  177. package/dist/write/vectors/ui-recipes.js.map +1 -1
  178. package/dist/write/vectors/ui.d.ts +25 -1
  179. package/dist/write/vectors/ui.js +142 -33
  180. package/dist/write/vectors/ui.js.map +1 -1
  181. package/dist/write/verify/delta.d.ts +17 -1
  182. package/dist/write/verify/delta.js +106 -29
  183. package/dist/write/verify/delta.js.map +1 -1
  184. package/package.json +7 -1
  185. package/schema/envelope.schema.json +383 -0
  186. package/skills/things-cli/SKILL.md +34 -17
  187. package/skills/things-cli/references/banner.md +35 -0
  188. package/skills/things-cli/references/contracts.md +30 -9
  189. package/skills/things-cli/references/errors.md +49 -0
  190. package/skills/things-cli/references/gui.md +1 -1
  191. package/skills/things-cli/references/model.md +15 -6
  192. package/skills/things-cli/references/ordering.md +71 -0
@@ -1,15 +1,195 @@
1
- import { localToday, encodePackedDate } from "../model/dates.js";
1
+ /**
2
+ * write.reorder orchestrator — two strategies, both lab-derived:
3
+ *
4
+ * native — one `_private_experimental_ reorder` AppleScript call through
5
+ * the standard pipeline (drift gate → guards → canary → execute →
6
+ * ordering verification). Scopes: today (bucket-0 members, O01/
7
+ * O03/O12), project/area (un-headed children, O04/O05/O09–O11),
8
+ * container-day (a container's same-day scheduled children — a
9
+ * date-preserving todayIndex re-rank, DAYORD-b/O17). Gated by
10
+ * config.allowExperimental AND the sdef canary.
11
+ *
12
+ * bounce — verified `when=` round-trips (REORDGAPS + BOUNCE2): re-scheduling
13
+ * an item away from and back into its resting bucket RE-INSERTS it,
14
+ * and the DIRECTION follows the containment context (the BOUNCE2
15
+ * re-entry law, oddities §9h):
16
+ * - loose / area-direct items FRONT-insert below the group min, so
17
+ * reverse-order legs land the target order (today, evening,
18
+ * projects, area-less loose anytime, an area's someday members);
19
+ * - strict-container children (heading / project children)
20
+ * BACK-insert at the bucket end, so forward-order legs land the
21
+ * target order (a heading's anytime children; a project's someday
22
+ * children — the SOMEBNC-project fallback when native is off).
23
+ * Each leg is a full verified todo.update/project.update mutation;
24
+ * between items the live state is re-checked so a user editing Things
25
+ * concurrently causes a clean abort with partial-progress detail,
26
+ * never a fight. Several bounce scopes are the ONLY surface that
27
+ * reaches their bucket (evening O03; top-level projects P8e/scf2 P6;
28
+ * within-heading order HEADORD-b; an area's someday order §9f).
29
+ *
30
+ * The requested uuid list may be a subset of the scope: for native, the wire
31
+ * list is extended with every remaining member in current order (placement
32
+ * stays deterministic); for bounce, unrequested members simply stay put below
33
+ * the bounced block (neighbors untouched), except an anchored (`--before`/
34
+ * `--after`) placement co-bounces the minimal contiguous run between the block
35
+ * and the bucket edge (disclosed as `touched`).
36
+ *
37
+ * JSON collapse (BOUNCEJSON, oddities §9i): for the bounce classes whose
38
+ * PLACEMENT (`back`) leg is `when=anytime` landing into a loose or heading-
39
+ * container bucket, the whole N-item round-trip can collapse to ONE pre-
40
+ * validated `things:///json` update array (2N ops, 1 dispatch, exact array
41
+ * order, ~7×, validate-first FULL-ABORT). Eligibility is a per-BounceSpec flag
42
+ * ({@link BounceSpec.jsonCollapsible}) keyed off that mechanism, NOT the front/
43
+ * back direction — see the flag's doc for why a someday-placement or area-
44
+ * direct class must NEVER collapse (json is index-inert there — a collapse
45
+ * would silently fail to reorder).
46
+ */
47
+ import { randomBytes } from "node:crypto";
48
+ import { addDaysIso, decodePackedDate, localToday, encodePackedDate } from "../model/dates.js";
2
49
  import { resolveTaskUuidPrefix } from "../read/queries.js";
3
- import { computeReorderPre, resolveArea, resolveProject } from "./pre-state.js";
50
+ import { computeReorderPre, resolveArea, resolveProject, todayEveningFlagOf } from "./pre-state.js";
4
51
  import { sdefDeclaresPrivateReorder } from "./experimental.js";
5
- import { fingerprintLabel, runMutation, } from "./pipeline.js";
52
+ import { fingerprintLabel, readAuthToken, runMutation, } from "./pipeline.js";
6
53
  import { createDbReader, evaluateDelta } from "./verify/delta.js";
7
54
  import { pollUntilVerified } from "./verify/poller.js";
8
55
  /**
9
- * Bounce cost is 2 verified mutations per item; beyond this the today scope
10
- * should use the native strategy and the evening scope should be re-thought.
56
+ * DEFAULT bounce cap 2 verified mutations per item (~110 ms/item guest-local,
57
+ * BOUNCE2-t). The effective cap is `config.bounceMaxItems` (default 30); this
58
+ * constant is the built-in fallback used when a caller supplies no config value,
59
+ * and the figure surface copy cites.
60
+ */
61
+ export const BOUNCE_MAX_ITEMS = 30;
62
+ /**
63
+ * The bounce kinds whose N-item round-trip is eligible for the `things:///json`
64
+ * one-array collapse (BOUNCEJSON §9i) — exactly the classes whose placement
65
+ * (`back`) leg is `when=anytime` into a loose/heading-container bucket. Exported
66
+ * for the classification regression test (guards against "optimizing" a someday-
67
+ * placement or area-direct class into json, which §9i proves is a silent no-op).
11
68
  */
12
- export const BOUNCE_MAX_ITEMS = 10;
69
+ export function bounceJsonCollapsible(kind) {
70
+ return bounceSpecOf(kind).jsonCollapsible;
71
+ }
72
+ function bounceSpecOf(kind) {
73
+ switch (kind) {
74
+ case "today":
75
+ return {
76
+ away: "evening",
77
+ back: "today",
78
+ dated: false,
79
+ direction: "front",
80
+ rankKey: "todayIndex",
81
+ legOp: "todo.update",
82
+ // todayIndex leg, not an anytime placement — json reindex unproven here.
83
+ jsonCollapsible: false,
84
+ };
85
+ case "evening":
86
+ // EVEORD: to-dos AND projects share ONE evening todayIndex axis; a project's
87
+ // when=evening re-entry front-inserts at the group min just like a to-do
88
+ // (×3). So the evening bounce accepts MIXED movees via a per-type leg op.
89
+ // Caveat: when=evening CLEARS reminderTime for BOTH kinds (§9n / R07).
90
+ return {
91
+ away: "today",
92
+ back: "evening",
93
+ dated: false,
94
+ direction: "front",
95
+ rankKey: "todayIndex",
96
+ legOp: "per-type",
97
+ jsonCollapsible: false,
98
+ };
99
+ // SIT4 DAYBNC: the DATED BOUNCE for an arbitrary future day-group across all
100
+ // containers — loose/project-child/headed/area-direct to-dos AND area-less
101
+ // project rows front-insert at the day-D GLOBAL todayIndex min on the back
102
+ // leg. away/back are the cross-date round-trip (D+1 → D), per-type legs,
103
+ // reminder/deadline/heading-FK preserving (§2e/R21). Sequential URL only —
104
+ // the json when= reindex is unproven for dated placement (§9i tested anytime).
105
+ case "day":
106
+ return {
107
+ away: null,
108
+ back: null,
109
+ dated: true,
110
+ direction: "front",
111
+ rankKey: "todayIndex",
112
+ legOp: "per-type",
113
+ jsonCollapsible: false,
114
+ };
115
+ case "projects":
116
+ return {
117
+ away: "someday",
118
+ back: "anytime",
119
+ dated: false,
120
+ direction: "front",
121
+ rankKey: "index",
122
+ legOp: "project.update",
123
+ // project.update (type=1): the json when= reindex is unproven for a
124
+ // project row — stays on the URL loop (§9i tested to-dos only).
125
+ jsonCollapsible: false,
126
+ };
127
+ // Headed anytime children BACK-insert (BOUNCE2-h): forward-order bounce.
128
+ // Placement leg = anytime into a heading container -> json-collapsible (BJ-a).
129
+ case "heading":
130
+ return {
131
+ away: "someday",
132
+ back: "anytime",
133
+ dated: false,
134
+ direction: "back",
135
+ rankKey: "index",
136
+ legOp: "todo.update",
137
+ jsonCollapsible: true,
138
+ };
139
+ // Area someday members FRONT-insert (SOMEBNC-area): reverse-order bounce.
140
+ // Someday placement leg AND area-direct -> json index-INERT (§9i b+c): URL only.
141
+ case "area-someday":
142
+ return {
143
+ away: "anytime",
144
+ back: "someday",
145
+ dated: false,
146
+ direction: "front",
147
+ rankKey: "index",
148
+ legOp: "todo.update",
149
+ jsonCollapsible: false,
150
+ };
151
+ // Area-less loose anytime FRONT-insert (ANYBNC): reverse-order bounce.
152
+ // Placement leg = anytime into a loose bucket -> json-collapsible (BJ-0).
153
+ case "anytime":
154
+ return {
155
+ away: "someday",
156
+ back: "anytime",
157
+ dated: false,
158
+ direction: "front",
159
+ rankKey: "index",
160
+ legOp: "todo.update",
161
+ jsonCollapsible: true,
162
+ };
163
+ // Project someday children BACK-insert (SOMEBNC-project): forward-order bounce.
164
+ // Someday placement leg -> json index-INERT (§9i b): URL only, despite being
165
+ // a back-insert (eligibility is the placement-leg value, not the direction).
166
+ case "project-someday":
167
+ return {
168
+ away: "anytime",
169
+ back: "someday",
170
+ dated: false,
171
+ direction: "back",
172
+ rankKey: "index",
173
+ legOp: "todo.update",
174
+ jsonCollapsible: false,
175
+ };
176
+ // SIT7 SOMEBACK: loose someday to-dos AND area-less someday projects FRONT-insert
177
+ // at the loose someday index min via the anytime↔someday round-trip (reverse-order
178
+ // dispatch). Per-type leg op (todo.update / update-project) — the first MIXED-kind
179
+ // fixed-bucket bounce, mirroring the day/evening per-type legs. Someday placement
180
+ // leg -> json index-INERT (§9i b): URL loop only (never collapsible).
181
+ case "someday":
182
+ return {
183
+ away: "anytime",
184
+ back: "someday",
185
+ dated: false,
186
+ direction: "front",
187
+ rankKey: "index",
188
+ legOp: "per-type",
189
+ jsonCollapsible: false,
190
+ };
191
+ }
192
+ }
13
193
  function blocked(detail, remediation) {
14
194
  return {
15
195
  kind: "blocked",
@@ -25,89 +205,547 @@ function blocked(detail, remediation) {
25
205
  }
26
206
  export async function runReorder(deps, params, options = {}) {
27
207
  params = { ...params, uuids: params.uuids.map((u) => resolveTaskUuidPrefix(deps.db, u)) };
208
+ // HEADSUB1 within-heading sub-bucket compound (headsub1-heading-subbuckets.md):
209
+ // heading-someday re-heads the block in forward order (move-to-heading back-
210
+ // insert). (The former heading-day unhead→container-day→re-head round-trip is
211
+ // SUPERSEDED by the `day` dated bounce — SIT4 DAYBNC preserves the heading FK,
212
+ // so a headed same-day child just bounces.)
213
+ if (params.scope === "heading-someday")
214
+ return runHeadingSomeday(deps, params, options);
28
215
  const strategy = resolveStrategy(deps, params);
29
216
  if (strategy.kind === "blocked")
30
217
  return strategy.result;
31
218
  if (strategy.strategy === "native") {
32
219
  return runMutation(deps, "reorder", params, { ...options, vector: "applescript" });
33
220
  }
34
- return runBounce(deps, params, options);
221
+ const result = strategy.strategy === "bounce"
222
+ ? await runBounce(deps, params, strategy.bounceKind, options)
223
+ : await runMoveFallback(deps, params, strategy.fallback, options);
224
+ // SIT7 disclosure: when the private reorder surface is unavailable the scope
225
+ // ran a degraded-but-guaranteed non-experimental fallback — say so, so the
226
+ // caller never silently mistakes a fallback for the native placement.
227
+ if (strategy.fallbackNote !== undefined && result.kind === "ok") {
228
+ return { ...result, warnings: [...(result.warnings ?? []), strategy.fallbackNote] };
229
+ }
230
+ return result;
231
+ }
232
+ /**
233
+ * Why the native `_private_experimental_ reorder` command is unavailable, for the
234
+ * fallback disclosure note. Either the config gate is off or the sdef canary
235
+ * failed — the two triggers {@link resolveStrategy} routes a fallback for.
236
+ */
237
+ function nativeUnavailableReason(deps) {
238
+ return deps.config.allowExperimental
239
+ ? "the app no longer declares the private reorder command (sdef canary failed)"
240
+ : "allow-experimental is off";
241
+ }
242
+ /** The fallback disclosure warning — which protocol ran, and why native could not. */
243
+ function fallbackNoteFor(deps, protocol) {
244
+ return (`reordered via the non-experimental ${protocol} fallback because the native reorder ` +
245
+ `is unavailable (${nativeUnavailableReason(deps)})`);
246
+ }
247
+ /** A blocked result for a move-based SIT7 fallback while the shared move gate is off. */
248
+ function moveFallbackDisabled(what) {
249
+ return {
250
+ kind: "blocked",
251
+ result: {
252
+ kind: "blocked",
253
+ op: "reorder",
254
+ reason: "environment",
255
+ detail: `${what} falls back to a park + re-enter MOVE protocol (the native reorder is ` +
256
+ "unavailable), which shares the bounce gate and is disabled (bounce-enabled=false) — " +
257
+ "it was NOT attempted (no destructive or unverified fallback exists)",
258
+ remediation: "re-enable it with `things config set bounce-enabled true`" +
259
+ " (each moved item costs a park + re-enter leg), or turn allow-experimental back on",
260
+ },
261
+ };
262
+ }
263
+ /** Gate a move-based SIT7 fallback on bounce-enabled (the shared multi-leg move gate). */
264
+ function fallbackOk(deps, what, kind, protocol) {
265
+ if (!deps.config.bounceEnabled)
266
+ return moveFallbackDisabled(what);
267
+ return {
268
+ kind: "ok",
269
+ strategy: "fallback",
270
+ fallback: kind,
271
+ fallbackNote: fallbackNoteFor(deps, protocol),
272
+ };
273
+ }
274
+ /** A blocked result for a bounce-dependent placement while bounce is disabled. */
275
+ function bounceDisabled(what) {
276
+ return {
277
+ kind: "blocked",
278
+ result: {
279
+ kind: "blocked",
280
+ op: "reorder",
281
+ reason: "environment",
282
+ detail: `${what} requires the when= bounce, which is disabled (bounce-enabled=false) — ` +
283
+ "it was NOT attempted (no destructive or unverified fallback exists)",
284
+ remediation: "re-enable it with `things config set bounce-enabled true`" +
285
+ " (each bounced item costs two verified mutations)",
286
+ },
287
+ };
288
+ }
289
+ function bounceOk(deps, what, kind) {
290
+ if (!deps.config.bounceEnabled)
291
+ return bounceDisabled(what);
292
+ return { kind: "ok", strategy: "bounce", bounceKind: kind };
293
+ }
294
+ /**
295
+ * A when=-bounce SIT7 fallback (SOMEBACK someday, or a dated `day` bounce standing
296
+ * in for container-day/tomorrow): a bounce that ALSO carries the fallback disclosure
297
+ * note, because it ran only because the native surface was unavailable.
298
+ */
299
+ function bounceFallbackOk(deps, what, kind, protocol) {
300
+ const d = bounceOk(deps, what, kind);
301
+ if (d.kind !== "ok" || d.strategy !== "bounce")
302
+ return d;
303
+ return { ...d, fallbackNote: fallbackNoteFor(deps, protocol) };
35
304
  }
36
305
  function resolveStrategy(deps, params) {
37
306
  const nativeAvailable = deps.config.allowExperimental && (deps.sdefProbe ?? sdefDeclaresPrivateReorder)();
307
+ // Scopes whose ONLY surface is the bounce (no native command reaches them).
308
+ const bounceOnly = {
309
+ evening: { kind: "evening", what: "evening-section order" },
310
+ day: { kind: "day", what: "an arbitrary future-day order" },
311
+ projects: { kind: "projects", what: "top-level projects order" },
312
+ heading: { kind: "heading", what: "within-heading order" },
313
+ "area-someday": { kind: "area-someday", what: "an area's someday order" },
314
+ anytime: { kind: "anytime", what: "area-less loose anytime order" },
315
+ };
316
+ const bounceEntry = bounceOnly[params.scope];
38
317
  if (params.strategy === "native") {
39
- if (params.scope === "evening") {
40
- return blocked("evening reorder is bounce-only: the native command silently clears startBucket on " +
41
- "every listed item (O03)", "omit --strategy (evening defaults to bounce)");
42
- }
43
- if (params.scope === "projects") {
44
- return blocked("top-level sidebar order has NO native surface (every AppleScript spelling errors " +
45
- "and the private command no-ops — scf2 P6); only the when= bounce works (P8e)", "omit --strategy (projects defaults to bounce)");
318
+ if (bounceEntry !== undefined) {
319
+ return blocked(`${bounceEntry.what} has NO native surface — only the when= bounce reaches it`, "omit --strategy (it defaults to the bounce)");
46
320
  }
47
321
  return { kind: "ok", strategy: "native" };
48
322
  }
49
323
  if (params.strategy === "bounce") {
50
- if (params.scope === "project" ||
51
- params.scope === "area" ||
52
- params.scope === "inbox" ||
53
- params.scope === "headings" ||
54
- params.scope === "someday") {
55
- return blocked("bounce can only reorder the Today/Evening sections and top-level projects — its " +
56
- "primitive is a when= round-trip, which does not move this scope's order", "use the native strategy (requires `things config set allow-experimental true`)");
324
+ if (params.scope === "container-day") {
325
+ return blocked("a container's scheduled-day order is a native todayIndex re-rank (DAYORD-b), not a bounce", "omit --strategy (container-day defaults to native)");
326
+ }
327
+ // `someday` DOES have a when= bounce twin (SIT7 SOMEBACK) — route explicit
328
+ // --strategy bounce to it. project/area/inbox have no when= surface (their
329
+ // SIT7 fallbacks are MOVE protocols, planner-selected only).
330
+ if (params.scope === "someday") {
331
+ return bounceOk(deps, "loose someday order", "someday");
57
332
  }
58
- return { kind: "ok", strategy: "bounce" };
333
+ if (params.scope === "project" || params.scope === "area" || params.scope === "inbox") {
334
+ return blocked("bounce can only reorder the Today/Evening sections, top-level projects, within-heading, " +
335
+ "area-someday, area-less anytime, and loose someday — its primitive is a when= round-trip, " +
336
+ "which does not move this scope's order (use the native strategy, or omit --strategy to let " +
337
+ "the non-experimental MOVE fallback run when native is unavailable)", "use the native strategy (requires `things config set allow-experimental true`)");
338
+ }
339
+ if (bounceEntry !== undefined)
340
+ return bounceOk(deps, bounceEntry.what, bounceEntry.kind);
341
+ // today with explicit bounce.
342
+ return bounceOk(deps, "Today-section order", "today");
59
343
  }
60
344
  // Default per scope.
345
+ if (bounceEntry !== undefined)
346
+ return bounceOk(deps, bounceEntry.what, bounceEntry.kind);
61
347
  switch (params.scope) {
62
- case "evening":
63
- case "projects":
64
- return { kind: "ok", strategy: "bounce" };
65
348
  case "today":
66
- return { kind: "ok", strategy: nativeAvailable ? "native" : "bounce" };
349
+ return nativeAvailable
350
+ ? { kind: "ok", strategy: "native" }
351
+ : bounceOk(deps, "Today-section order", "today");
352
+ // SIT7 AUTOMATIC FALLBACKS — every native-only reorder scope degrades to a
353
+ // proven non-experimental protocol when the private surface is unavailable
354
+ // (allow-experimental off OR the sdef canary fails), rather than fail-explain.
67
355
  case "project":
356
+ // PROJROOT (fallback 3): park the unheaded children to a scratch project, then
357
+ // re-home `list-id=<P>` in FORWARD target order (back-insert). One protocol for
358
+ // ALL rows (flagged or not) — the move round-trip is proven flag-safe (SIT7).
359
+ return nativeAvailable
360
+ ? { kind: "ok", strategy: "native" }
361
+ : fallbackOk(deps, "within-project child order", "proj-root", "PROJROOT");
68
362
  case "area":
363
+ // AREABACK (fallback 4): park members out, re-home `list-id=`/`area-id=<area>`
364
+ // in REVERSE target order (front-insert), area FK preserved. Flag-safe move.
365
+ return nativeAvailable
366
+ ? { kind: "ok", strategy: "native" }
367
+ : fallbackOk(deps, "an area's member order", "area-back", "AREABACK");
69
368
  case "inbox":
70
- case "headings":
369
+ // INBOXBACK (fallback 1): park each row into a scratch project, then re-enter
370
+ // via `move … to list "Inbox"` in REVERSE target order (front-insert, restores
371
+ // start=0). A same-list `move … to "Inbox"` is a no-op, so park-first is required.
372
+ return nativeAvailable
373
+ ? { kind: "ok", strategy: "native" }
374
+ : fallbackOk(deps, "Inbox order", "inbox-park", "INBOXBACK");
71
375
  case "someday":
72
- // Native-only scopes: let the pipeline explain precisely why native is
73
- // unavailable (planner: experimental gate; canary: sdef change).
376
+ // SOMEBACK (fallback 2): the anytime↔someday when= bounce front-inserts loose
377
+ // someday to-dos AND area-less someday projects (reverse-target, per-type leg).
378
+ return nativeAvailable
379
+ ? { kind: "ok", strategy: "native" }
380
+ : bounceFallbackOk(deps, "loose someday order", "someday", "SOMEBACK");
381
+ case "container-day":
382
+ case "tomorrow":
383
+ // Fallback 5: the dated `day` bounce (ORD-6 DAYBNC) reaches every same-day child
384
+ // via a pure-URL cross-date round-trip — a non-experimental stand-in for both
385
+ // the container-day native re-rank and the one-call `list "Tomorrow"` sort.
386
+ return nativeAvailable
387
+ ? { kind: "ok", strategy: "native" }
388
+ : bounceFallbackOk(deps, params.scope === "tomorrow" ? "Tomorrow order" : "a container's scheduled-day order", "day", "dated-day-bounce");
389
+ // bounce-only scopes handled by bounceEntry above; unreachable here.
390
+ case "evening":
391
+ case "day":
392
+ case "projects":
393
+ case "heading":
394
+ case "area-someday":
395
+ case "anytime":
74
396
  return { kind: "ok", strategy: "native" };
397
+ // heading-someday is intercepted in runReorder before resolveStrategy runs;
398
+ // this case exists only for switch exhaustiveness.
399
+ case "heading-someday":
400
+ return { kind: "ok", strategy: "native" };
401
+ }
402
+ }
403
+ // (The SOMEBNC-project someday-only bounce fallback for the `project` scope is
404
+ // SUPERSEDED by PROJROOT — the SIT7 park + re-home MOVE round-trip that reorders
405
+ // ALL of a project's unheaded children flag-safely, one protocol for every row.
406
+ // The `project-someday` BounceKind survives as the §9i json-collapse classification
407
+ // pin, but it is no longer selected by resolveStrategy.)
408
+ // ----------------------------------------------------- compound leg options
409
+ //
410
+ // (The former park-sort-restore day compounds — loose-day / area-day / upcoming-
411
+ // day — and the heading-day unhead→re-head round-trip are DELETED: SIT4 DAYBNC
412
+ // proved the dated `day` bounce serves their whole population plus area-less
413
+ // project rows, reminder/deadline/heading-FK preserving, with NO scratch project
414
+ // and NO experimental gate. compoundLegOptions survives — heading-someday still
415
+ // uses it for its per-leg move sub-mutations.)
416
+ /** Leg options for a compound sub-mutation — each leg fully verified. */
417
+ function compoundLegOptions(options, txnId) {
418
+ const legs = { txn: { id: txnId, role: "leg" } };
419
+ if (options.maxDisruption !== undefined)
420
+ legs.maxDisruption = options.maxDisruption;
421
+ if (options.verifyTimeoutMs !== undefined)
422
+ legs.verifyTimeoutMs = options.verifyTimeoutMs;
423
+ if (options.actor !== undefined)
424
+ legs.actor = options.actor;
425
+ return legs;
426
+ }
427
+ // ----------------------------------------------------- heading sub-buckets
428
+ //
429
+ // HEADSUB1 (docs/lab/headsub1-heading-subbuckets.md) settled the per-class order
430
+ // of a heading's sub-buckets. heading-someday is wired here; the anytime sub-
431
+ // bucket rides the `heading` bounce, the EVENING sub-bucket rides the shipped
432
+ // `evening` bounce (Arm 2b — the heading FK survives the today↔evening round-
433
+ // trip), and a heading's same-day SCHEDULED children ride the `day` dated bounce
434
+ // (SIT4 DAYBNC — the cross-date round-trip preserves the heading FK, so the former
435
+ // unhead→container-day→re-head heading-day round-trip is no longer needed).
436
+ //
437
+ // heading-someday: UNHEAD the block, then RE-HEAD it in FORWARD target order
438
+ // (Arm B-someday / Arm C + HEADSUB2 Q1). A re-head of a row ALREADY under the
439
+ // target heading is a same-heading NO-OP (HEADSUB2 Q1(b) — index untouched),
440
+ // so the block MUST be unheaded FIRST (clean — heading→NULL, index/start=2
441
+ // preserved, Arm C); the re-head then BACK-INSERTS each now-loose row at the
442
+ // heading someday-bucket end (§9h renumber, Arm B), so forward-order re-heads
443
+ // land the exact target order (HEADSUB2 q1fix), `start=2` preserved. Two URL
444
+ // move legs per item (unhead + re-head), no json collapse (re-head is a list-
445
+ // id move, not a when= reindex); needs neither the experimental surface nor
446
+ // the bounce. (The earlier direct-re-head-only compile shipped in #327 was a
447
+ // silent no-op — HEADSUB2 §Q1 — corrected here.)
448
+ /** The project a heading belongs to (its re-head destination), or null. */
449
+ function headingProjectUuid(deps, headingUuid) {
450
+ if (headingUuid === null)
451
+ return null;
452
+ const row = deps.db
453
+ .prepare("SELECT project FROM TMTask WHERE uuid = ? AND type = 2 AND trashed = 0")
454
+ .get(headingUuid);
455
+ return row?.project ?? null;
456
+ }
457
+ /** Abort payload for heading-someday: items are UNHEADED in the project root. */
458
+ function headingSomedayAborted(detail, placed, remaining, cause) {
459
+ return { kind: "bounce-aborted", op: "reorder", detail, placed, remaining, cause };
460
+ }
461
+ /**
462
+ * heading-someday: the unhead → re-head round-trip (HEADSUB1 Arm B-someday / Arm
463
+ * C + HEADSUB2 Q1). A re-head of a row ALREADY under the target heading is a
464
+ * same-heading NO-OP (HEADSUB2 Q1(b) — the app leaves `index` untouched), so the
465
+ * block cannot be sorted by re-heading in place. The block is UNHEADED first
466
+ * (clean — heading→NULL, `index`/`start=2` preserved, Arm C), then RE-HEADED in
467
+ * forward target order — each now-loose row BACK-INSERTS at the heading someday-
468
+ * bucket end (§9h renumber, Arm B), so forward-order re-heads land the exact
469
+ * target order (HEADSUB2 q1fix). The block is the SUFFIX from the first named
470
+ * movee's target slot to the bucket end; the untouched prefix stays headed above
471
+ * (its `index` is below the re-inserted block), and unnamed siblings inside the
472
+ * suffix are co-unheaded + co-re-headed (disclosed as `touched`). Two `todo.move`
473
+ * legs per item (unhead + re-head), `start=2` preserved throughout; needs neither
474
+ * the experimental surface nor the bounce, and there is no json collapse (re-head
475
+ * is a list-id move). Non-atomic: a mid-protocol failure leaves items UNHEADED in
476
+ * the project root and fails loudly with placed/remaining detail.
477
+ */
478
+ async function runHeadingSomeday(deps, params, options) {
479
+ const startedAt = deps.now?.() ?? new Date();
480
+ const now = deps.now ?? (() => new Date());
481
+ const actor = options.actor ?? deps.config.actor;
482
+ const cap = deps.config.bounceMaxItems ?? BOUNCE_MAX_ITEMS;
483
+ const headingUuid = params.container?.uuid ?? null;
484
+ const projectUuid = headingProjectUuid(deps, headingUuid);
485
+ const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
486
+ const pre = computeReorderPre(deps.db, params, headingUuid, now());
487
+ const preRanks = {};
488
+ for (const m of pre.members)
489
+ preRanks[m.uuid] = m.rank;
490
+ // Back-insert (like the heading bounce): the SUFFIX from the first named movee's
491
+ // target slot to the bucket end is unheaded then re-headed forward (each appends).
492
+ const targetOrder = pre.wireList;
493
+ const named = new Set(params.named ?? params.uuids);
494
+ const movedPositions = targetOrder.map((u, i) => (named.has(u) ? i : -1)).filter((i) => i >= 0);
495
+ const firstMoved = movedPositions.length > 0 ? movedPositions[0] : 0;
496
+ const block = targetOrder.slice(firstMoved);
497
+ const touchedUnnamed = block.filter((u) => !named.has(u));
498
+ const problems = [];
499
+ if (headingUuid === null || projectUuid === null) {
500
+ problems.push("the heading did not resolve to a project (re-head needs the heading's project)");
501
+ }
502
+ if (params.uuids.length === 0)
503
+ problems.push("no uuids given");
504
+ if (pre.duplicates.length > 0)
505
+ problems.push(`duplicated uuid(s): ${pre.duplicates.join(", ")}`);
506
+ for (const r of pre.rejected)
507
+ problems.push(`${r.uuid} ${r.reason}`);
508
+ if (block.length > cap) {
509
+ problems.push(`${block.length} touched items exceed the cap of ${cap} (each costs an unhead + re-head leg` +
510
+ (touchedUnnamed.length > 0
511
+ ? `; ${touchedUnnamed.length} unnamed heading sibling(s) are co-re-headed to honor the order`
512
+ : "") +
513
+ ")");
514
+ }
515
+ if (problems.length > 0) {
516
+ const result = {
517
+ kind: "blocked",
518
+ op: "reorder",
519
+ reason: "hazard",
520
+ hazard: "H-REORDER-SCOPE",
521
+ detail: `within-heading someday reorder rejected: ${problems.join("; ")}`,
522
+ remediation: "reorder the Someday children of ONE heading (read the project first), " +
523
+ `at most ${cap} touched (set with \`things config set bounce-max-items\`)`,
524
+ };
525
+ auditSummary(deps, params, startedAt, "blocked:H-REORDER-SCOPE", null, {
526
+ pre: preRanks,
527
+ txnId,
528
+ actor,
529
+ });
530
+ return result;
531
+ }
532
+ if (options.dryRun === true) {
533
+ return {
534
+ kind: "dry-run",
535
+ op: "reorder",
536
+ plan: {
537
+ op: "reorder",
538
+ vector: "url-scheme",
539
+ tier: 0,
540
+ invocation: `unhead ×${block.length} → re-head ×${block.length} (back-insert, forward order` +
541
+ (touchedUnnamed.length > 0
542
+ ? `, touches ${touchedUnnamed.length} unnamed sibling(s)`
543
+ : "") +
544
+ "): a same-heading re-head is a no-op (HEADSUB2 Q1), so the block is unheaded first; " +
545
+ "each `list-id=<project>&heading=<heading>` then appends at the someday-bucket end; " +
546
+ "one terminal order verify",
547
+ expectedDelta: { mode: "ordering", key: "index", sequence: block },
548
+ hazardsChecked: ["H-REORDER-SCOPE"],
549
+ },
550
+ };
75
551
  }
552
+ const legOpts = compoundLegOptions(options, txnId);
553
+ // 1. UNHEAD each block member (clean — heading→NULL, index/start=2 preserved).
554
+ // A same-heading re-head is a NO-OP (HEADSUB2 Q1(b)), so the block MUST be
555
+ // unheaded before it can be re-inserted in order.
556
+ const unheaded = [];
557
+ for (const uuid of block) {
558
+ const res = await runMutation(deps, "todo.move", { uuid, noHeading: true }, legOpts);
559
+ if (res.kind !== "ok") {
560
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unheaded] }, { pre: preRanks, txnId, actor });
561
+ return headingSomedayAborted(`unheading ${uuid} failed — ${unheaded.length} item(s) are UNHEADED in project ` +
562
+ `${projectUuid} and must be moved back under the heading manually`, unheaded, block.slice(unheaded.length), res);
563
+ }
564
+ unheaded.push(uuid);
565
+ }
566
+ // 2. RE-HEAD each in forward target order — each now-loose row BACK-INSERTS at
567
+ // the someday-bucket end (Arm B), so forward-order re-heads land the target.
568
+ const placed = [];
569
+ for (const uuid of block) {
570
+ const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: projectUuid }, heading: headingUuid }, legOpts);
571
+ if (res.kind !== "ok") {
572
+ const stillUnheaded = block.filter((u) => !placed.includes(u));
573
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
574
+ return headingSomedayAborted(`re-heading ${uuid} failed — ${stillUnheaded.length} item(s) remain UNHEADED in project ` +
575
+ `${projectUuid} (ordered) and must be moved back under the heading manually`, placed, stillUnheaded, res);
576
+ }
577
+ placed.push(uuid);
578
+ }
579
+ // 3. Terminal verify: the block's index order matches the target.
580
+ const verify = await pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: "index", sequence: block }, createDbReader(deps.db), {
581
+ modDates: {},
582
+ fields: {},
583
+ }), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
584
+ if (verify.kind !== "ok") {
585
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
586
+ return headingSomedayAborted("the heading someday sub-bucket did not land the requested order after re-heading; " +
587
+ "re-run once Things is idle", placed, [], null);
588
+ }
589
+ const reader = createDbReader(deps.db);
590
+ const observed = {};
591
+ for (const uuid of block)
592
+ observed[uuid] = reader.rankOf(uuid, "index");
593
+ auditSummary(deps, params, startedAt, "ok", observed, { pre: preRanks, txnId, actor });
594
+ return {
595
+ kind: "ok",
596
+ op: "reorder",
597
+ uuid: null,
598
+ observed,
599
+ vector: "url-scheme",
600
+ tier: 0,
601
+ undoToken: txnId,
602
+ ...(touchedUnnamed.length > 0 && { touched: touchedUnnamed }),
603
+ };
76
604
  }
77
605
  // ------------------------------------------------------------------- bounce
78
- async function runBounce(deps, params, options) {
606
+ async function runBounce(deps, params, bounceKind, options) {
79
607
  const startedAt = deps.now?.() ?? new Date();
80
608
  const now = deps.now ?? (() => new Date());
81
609
  const actor = options.actor ?? deps.config.actor;
82
- const scope = params.scope;
83
- const rankKey = scope === "projects" ? "index" : "todayIndex";
84
- const legOp = scope === "projects" ? "project.update" : "todo.update";
610
+ const spec = bounceSpecOf(bounceKind);
611
+ const { direction, rankKey, legOp } = spec;
612
+ const cap = deps.config.bounceMaxItems ?? BOUNCE_MAX_ITEMS;
613
+ const containerUuid = resolveContainerUuid(deps, params);
614
+ const wantsContainer = bounceKind === "heading" ||
615
+ bounceKind === "area-someday" ||
616
+ bounceKind === "project-someday" ||
617
+ // SIT7 fallback routing: `container-day` degrades to the dated `day` bounce
618
+ // (fallback 5) and legitimately carries its project/area container.
619
+ params.scope === "container-day";
620
+ // The two `when=` leg values. A fixed-bucket bounce uses the spec's keywords; the
621
+ // DATED `day` bounce (SIT4 DAYBNC) derives them from the movees' shared day —
622
+ // back = the day D, away = the neighbour day D+1 (a strictly-future staging day
623
+ // ≠ D that keeps the transient visit out of Today). The day is read off the first
624
+ // requested uuid (the planner guarantees every movee shares it); an absent/
625
+ // malformed day yields an empty member set and is rejected below before any
626
+ // dispatch, so the today fallback here only keeps the WhenValue well-typed.
627
+ let dayPacked = null;
628
+ let awayValue;
629
+ let backValue;
630
+ if (spec.dated) {
631
+ const firstUuid = params.uuids[0];
632
+ dayPacked =
633
+ firstUuid !== undefined
634
+ ? (deps.db.prepare("SELECT startDate FROM TMTask WHERE uuid = ?").get(firstUuid)?.startDate ?? null)
635
+ : null;
636
+ const iso = decodePackedDate(dayPacked);
637
+ backValue = iso ?? localToday(now());
638
+ awayValue = iso !== null ? addDaysIso(iso, 1) : localToday(now());
639
+ }
640
+ else {
641
+ awayValue = spec.away;
642
+ backValue = spec.back;
643
+ }
644
+ // Per-item leg op: the mixed-kind `day`/`evening` bounces pick update-project for
645
+ // a project row (type=1) and todo.update for a to-do (type=0); every other bounce
646
+ // uses one fixed op. (NEVER send a dated when= leg to a template — §1 crash — but
647
+ // templates are already excluded from the member set, and a requested template
648
+ // ref is refused by computeReorderPre, so no leg ever targets one.)
649
+ const opForRow = (uuid) => {
650
+ if (legOp !== "per-type")
651
+ return legOp;
652
+ const t = deps.db.prepare("SELECT type FROM TMTask WHERE uuid = ?").get(uuid)?.type;
653
+ return t === 1 ? "project.update" : "todo.update";
654
+ };
85
655
  const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
86
656
  // Scope/membership guard — same data the native path's guard uses.
87
- const pre = computeReorderPre(deps.db, params, resolveContainerUuid(deps, params), now());
657
+ const pre = computeReorderPre(deps.db, params, containerUuid, now());
88
658
  // Pre-ranks make the SUMMARY record the undoable unit (a single inverse
89
659
  // reorder restores the old relative order); legs are excluded from undo.
90
660
  const preRanks = {};
91
661
  for (const m of pre.members)
92
662
  preRanks[m.uuid] = m.rank;
663
+ // The bounce re-inserts the MINIMAL contiguous run of `pre.wireList` (the full
664
+ // target order the planner spliced) needed to realize the request, per the
665
+ // BOUNCE2 laws:
666
+ // - back-insert (heading / project children): the SUFFIX from the first named
667
+ // movee's target slot to the bucket end, bounced FORWARD (each appends,
668
+ // ending at the bottom in order; the untouched prefix stays above);
669
+ // - front-insert (loose / area-direct): the PREFIX from the top to the last
670
+ // named movee's target slot, bounced REVERSE (each front-inserts, ending at
671
+ // the top in order; the untouched suffix stays below).
672
+ // Everything ELSE in the run beyond the named block is a CO-BOUNCED sibling —
673
+ // touched (mod bump, changes-feed entry, audit leg) and disclosed.
674
+ const targetOrder = pre.wireList;
675
+ const named = new Set(params.named ?? params.uuids);
676
+ const movedPositions = targetOrder.map((u, i) => (named.has(u) ? i : -1)).filter((i) => i >= 0);
677
+ const firstMoved = movedPositions.length > 0 ? movedPositions[0] : 0;
678
+ const lastMoved = movedPositions.length > 0 ? movedPositions[movedPositions.length - 1] : 0;
679
+ const coBounce = direction === "back" ? targetOrder.slice(firstMoved) : targetOrder.slice(0, lastMoved + 1);
680
+ const touchedUnnamed = coBounce.filter((u) => !named.has(u));
681
+ // FLAG-AWARE protocol routing (SIT6). The three json-collapsible index bounces
682
+ // (`heading` BOUNCE2-h, `anytime` ANYBNC, `projects` P8e) are when=someday →
683
+ // when=anytime round-trips whose `when=` legs OVERWRITE the Today/Evening flag:
684
+ // the someday leg nulls startDate (start 1→2), the anytime leg only flips start
685
+ // back to 1, so a flagged movee is silently DE-Todayed (PROJSTAR de-star). Each
686
+ // has a lab-proven flag-safe MOVE twin on the same `index` axis (SIT6): route
687
+ // the WHOLE touched set through it whenever ANY touched row carries the flag —
688
+ // heading → HEADMOVE (unhead → re-head in forward target order; back-insert),
689
+ // anytime → LOOSEPARK (park into a scratch PROJECT → unpark in reverse target;
690
+ // front-insert),
691
+ // projects → PROJPARK (park into a scratch AREA → detach in reverse target;
692
+ // front-insert).
693
+ // Every leg is a URL move (`list-id=`/`area-id=`), NO when= leg and NO private
694
+ // reorder surface, so the flag / reminder / deadline / FKs all survive. An
695
+ // all-UNFLAGGED touched set keeps the cheaper bounce below. Detection is
696
+ // `todayEveningFlagOf` over the full touched set (the coBounce run), the same
697
+ // single-source marker #351 used. This SUPERSEDES #351's `projects` de-Today
698
+ // refusal for flagged movees; a refusal remains only when the protocol itself is
699
+ // unavailable (cap exceeded), raised inside the protocol.
700
+ if (bounceKind === "heading" || bounceKind === "anytime" || bounceKind === "projects") {
701
+ const flagged = coBounce.some((uuid) => todayEveningFlagOf(deps.db, uuid, now()) !== null);
702
+ if (flagged) {
703
+ const ctx = {
704
+ coBounce,
705
+ containerUuid,
706
+ txnId,
707
+ actor,
708
+ touchedUnnamed,
709
+ startedAt,
710
+ options,
711
+ cap,
712
+ };
713
+ if (bounceKind === "heading")
714
+ return runHeadMove(deps, params, ctx);
715
+ if (bounceKind === "anytime")
716
+ return runLoosePark(deps, params, ctx);
717
+ return runProjPark(deps, params, ctx);
718
+ }
719
+ }
93
720
  const problems = [];
94
- if (params.container !== undefined)
95
- problems.push("container is only valid for project/area/headings scopes");
721
+ if (params.container !== undefined && !wantsContainer)
722
+ problems.push("container is only valid for the heading / area-someday / project scopes");
96
723
  if (params.uuids.length === 0)
97
724
  problems.push("no uuids given");
98
725
  if (pre.duplicates.length > 0)
99
726
  problems.push(`duplicated uuid(s): ${pre.duplicates.join(", ")}`);
727
+ // SIT7 SOMEBACK: the someday bounce is same-type only (loose to-dos front-insert on a
728
+ // different axis than area-less projects — a mixed wire list is unprobed), mirroring
729
+ // the native someday anchor-stack's same-type rule.
730
+ if (pre.mixedTypes) {
731
+ problems.push("a someday reorder must be all to-dos OR all projects (same-type only) — a mixed member set " +
732
+ "is unprobed");
733
+ }
100
734
  for (const r of pre.rejected)
101
735
  problems.push(`${r.uuid} ${r.reason}`);
102
- if (scope !== "projects") {
736
+ if (legOp === "todo.update") {
103
737
  for (const uuid of pre.projectMembers) {
104
738
  problems.push(`${uuid} is a project — bounce re-schedules via todo.update, which is only validated ` +
105
739
  "for to-dos; use the native strategy for Today lists containing projects");
106
740
  }
107
741
  }
108
- if (params.uuids.length > BOUNCE_MAX_ITEMS) {
109
- problems.push(`${params.uuids.length} items exceeds the bounce cap of ${BOUNCE_MAX_ITEMS} ` +
110
- "(each item costs two verified mutations)");
742
+ if (coBounce.length > cap) {
743
+ problems.push(`${coBounce.length} touched items exceed the bounce cap of ${cap} ` +
744
+ "(each costs two verified mutations" +
745
+ (touchedUnnamed.length > 0
746
+ ? `; the anchor placement re-inserts ${touchedUnnamed.length} unnamed sibling(s) too`
747
+ : "") +
748
+ ")");
111
749
  }
112
750
  if (problems.length > 0) {
113
751
  const result = {
@@ -117,7 +755,7 @@ async function runBounce(deps, params, options) {
117
755
  hazard: "H-REORDER-SCOPE",
118
756
  detail: `reorder request rejected: ${problems.join("; ")}`,
119
757
  remediation: "read the scope first (things today) and pass only its eligible members, " +
120
- `at most ${BOUNCE_MAX_ITEMS} for the bounce strategy`,
758
+ `at most ${cap} for the bounce strategy (set with \`things config set bounce-max-items\`)`,
121
759
  };
122
760
  auditSummary(deps, params, startedAt, "blocked:H-REORDER-SCOPE", null, {
123
761
  pre: preRanks,
@@ -126,9 +764,37 @@ async function runBounce(deps, params, options) {
126
764
  });
127
765
  return result;
128
766
  }
129
- const away = scope === "today" ? "evening" : scope === "projects" ? "someday" : "today";
130
- const back = scope === "projects" ? "anytime" : scope;
767
+ // Front-insert contexts (loose/area-direct) place last-first (reverse iterate,
768
+ // unshift); back-insert contexts (heading/project children) place first-first
769
+ // (forward iterate, push). Either way `placed` holds the current top-to-bottom
770
+ // order of the bounced block. The SAME per-item order drives the json-array
771
+ // collapse (array order == result index order for both directions).
772
+ const order = direction === "front"
773
+ ? coBounce.map((_, i) => coBounce.length - 1 - i)
774
+ : coBounce.map((_, i) => i);
775
+ // BOUNCEJSON collapse (§9i): when the placement (`back`) leg is when=anytime
776
+ // into a loose/heading-container bucket, the whole N-item round-trip collapses
777
+ // to ONE pre-validated `things:///json` array (2N ops, 1 dispatch, ~7×,
778
+ // validate-first FULL-ABORT). Only against a REAL dispatch surface — under the
779
+ // simulator (simulates=true, per-op appliers, no json-array applier) we keep
780
+ // the proven sequential legs.
781
+ // The json collapse is only ever eligible for a fixed-op, jsonCollapsible spec;
782
+ // per-type (day/evening) and every dated bounce stay on the sequential URL loop.
783
+ const dispatchVector = spec.jsonCollapsible && legOp !== "per-type" ? pickDispatchVector(deps, legOp) : undefined;
784
+ const useJson = spec.jsonCollapsible && dispatchVector !== undefined && dispatchVector.simulates !== true;
131
785
  if (options.dryRun === true) {
786
+ const invocation = useJson
787
+ ? `json-collapse ×${coBounce.length} (${direction}-insert, ` +
788
+ `${direction === "front" ? "reverse" : "forward"} array order, ` +
789
+ (touchedUnnamed.length > 0 ? `touches ${touchedUnnamed.length} unnamed sibling(s), ` : "") +
790
+ `1 dispatch / ${coBounce.length * 2} ops): ` +
791
+ `when=${awayValue} → when=${backValue} interleaved per item; validate-first full-abort, ` +
792
+ `one terminal order verify`
793
+ : `bounce ×${coBounce.length} (${direction}-insert, ` +
794
+ `${direction === "front" ? "reverse" : "forward"} order, ` +
795
+ (touchedUnnamed.length > 0 ? `touches ${touchedUnnamed.length} unnamed sibling(s), ` : "") +
796
+ `${coBounce.length * 2} legs): ` +
797
+ `when=${awayValue} → when=${backValue}; one verify per item round-trip`;
132
798
  return {
133
799
  kind: "dry-run",
134
800
  op: "reorder",
@@ -136,21 +802,34 @@ async function runBounce(deps, params, options) {
136
802
  op: "reorder",
137
803
  vector: "url-scheme",
138
804
  tier: 0,
139
- invocation: `bounce ×${params.uuids.length} (reverse order): ` +
140
- `when=${away} verified when=${back} verified; ` +
141
- "state re-checked between items",
142
- expectedDelta: { mode: "ordering", key: rankKey, sequence: params.uuids },
805
+ invocation,
806
+ expectedDelta: { mode: "ordering", key: rankKey, sequence: coBounce },
143
807
  hazardsChecked: ["H-REORDER-SCOPE"],
144
808
  },
145
809
  };
146
810
  }
147
- // Bounce in REVERSE: each round-trip front-inserts (O07/O08), so placing
148
- // the last item first leaves the requested order on top.
811
+ if (useJson) {
812
+ return runBounceJsonCollapse(deps, params, spec, {
813
+ coBounce,
814
+ order,
815
+ bounceKind,
816
+ containerUuid,
817
+ preRanks,
818
+ txnId,
819
+ actor,
820
+ touchedUnnamed,
821
+ startedAt,
822
+ options,
823
+ vector: dispatchVector,
824
+ });
825
+ }
149
826
  const placed = [];
150
- for (let i = params.uuids.length - 1; i >= 0; i--) {
151
- const uuid = params.uuids[i];
827
+ for (let step = 0; step < order.length; step++) {
828
+ const i = order[step];
829
+ const uuid = coBounce[i];
830
+ const remainingBefore = () => direction === "front" ? coBounce.slice(0, i + 1) : coBounce.slice(i);
152
831
  // Concurrent-edit re-check: the item must still be an eligible member.
153
- const memberProblem = checkStillMember(deps, uuid, scope, now());
832
+ const memberProblem = checkStillMember(deps, uuid, bounceKind, containerUuid, now(), dayPacked);
154
833
  if (memberProblem !== null) {
155
834
  const detail = `aborted before bouncing ${uuid}: ${memberProblem} (Things was likely edited ` +
156
835
  "concurrently); already-placed items keep their new positions";
@@ -160,38 +839,44 @@ async function runBounce(deps, params, options) {
160
839
  op: "reorder",
161
840
  detail,
162
841
  placed: [...placed],
163
- remaining: params.uuids.slice(0, i + 1),
842
+ remaining: remainingBefore(),
164
843
  cause: null,
165
844
  };
166
845
  }
167
- // bounce legs must land in strict reverse order for the front-insert trick (O07/O08) to produce the requested top-first sequence
168
- const leg1 = await runMutation(deps, legOp, { uuid, when: away }, legOptions(options, txnId));
846
+ // leg 1 sends the item AWAY from its resting bucket; leg 2 returns it the
847
+ // return leg is what front/back-inserts (BOUNCE2 re-entry law). The op is
848
+ // chosen per row type (to-do vs project) for the mixed-kind day/evening bounce.
849
+ const rowLegOp = opForRow(uuid);
850
+ const leg1 = await runMutation(deps, rowLegOp, { uuid, when: awayValue }, legOptions(options, txnId));
169
851
  if (leg1.kind !== "ok") {
170
852
  auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
171
853
  return {
172
854
  kind: "bounce-aborted",
173
855
  op: "reorder",
174
- detail: `bounce leg 1 (when=${away}) failed for ${uuid} — the item was NOT moved`,
856
+ detail: `bounce leg 1 (when=${awayValue}) failed for ${uuid} — the item was NOT moved`,
175
857
  placed: [...placed],
176
- remaining: params.uuids.slice(0, i + 1),
858
+ remaining: remainingBefore(),
177
859
  cause: leg1,
178
860
  };
179
861
  }
180
862
  // leg 2 must follow leg 1's committed state for the same item before the next item's bounce begins
181
- const leg2 = await runMutation(deps, legOp, { uuid, when: back }, legOptions(options, txnId));
863
+ const leg2 = await runMutation(deps, rowLegOp, { uuid, when: backValue }, legOptions(options, txnId));
182
864
  if (leg2.kind !== "ok") {
183
865
  auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, { pre: preRanks, txnId, actor });
184
866
  return {
185
867
  kind: "bounce-aborted",
186
868
  op: "reorder",
187
- detail: `bounce leg 2 (when=${back}) failed for ${uuid} — THE ITEM IS STRANDED IN ` +
188
- `${away.toUpperCase()}; re-schedule it (when=${back}) or fix in the app`,
869
+ detail: `bounce leg 2 (when=${backValue}) failed for ${uuid} — THE ITEM IS STRANDED IN ` +
870
+ `${String(awayValue).toUpperCase()}; re-schedule it (when=${backValue}) or fix in the app`,
189
871
  placed: [...placed],
190
- remaining: params.uuids.slice(0, i + 1),
872
+ remaining: remainingBefore(),
191
873
  cause: leg2,
192
874
  };
193
875
  }
194
- placed.unshift(uuid);
876
+ if (direction === "front")
877
+ placed.unshift(uuid);
878
+ else
879
+ placed.push(uuid);
195
880
  // Placed-prefix invariant: everything bounced so far must read back in
196
881
  // requested relative order — anything else means a concurrent reshuffle.
197
882
  // the placed-prefix must be verified after each item before bouncing the next, so a concurrent reshuffle is caught immediately rather than compounded
@@ -204,14 +889,14 @@ async function runBounce(deps, params, options) {
204
889
  detail: `placed items fell out of order after bouncing ${uuid} (concurrent edit?); ` +
205
890
  "re-run the reorder once Things is idle",
206
891
  placed: [...placed],
207
- remaining: params.uuids.slice(0, i),
892
+ remaining: direction === "front" ? coBounce.slice(0, i) : coBounce.slice(i + 1),
208
893
  cause: null,
209
894
  };
210
895
  }
211
896
  }
212
897
  const reader = createDbReader(deps.db);
213
898
  const observed = {};
214
- for (const uuid of params.uuids)
899
+ for (const uuid of coBounce)
215
900
  observed[uuid] = reader.rankOf(uuid, rankKey);
216
901
  auditSummary(deps, params, startedAt, "ok", observed, { pre: preRanks, txnId, actor });
217
902
  return {
@@ -224,6 +909,1026 @@ async function runBounce(deps, params, options) {
224
909
  // A bounce reorder is a summary txn, so its token is the txn id (matches
225
910
  // the audit record's undoToken); pass it to `things undo --txn <token>`.
226
911
  undoToken: txnId,
912
+ // Co-bounced siblings the anchor placement re-inserted (honest disclosure).
913
+ ...(touchedUnnamed.length > 0 && { touched: touchedUnnamed }),
914
+ };
915
+ }
916
+ /** A unique-enough scratch-container title suffix (opId-ish). */
917
+ function scratchSuffix(startedAt) {
918
+ return `${startedAt.getTime().toString(36)}-${randomBytes(3).toString("hex")}`;
919
+ }
920
+ /**
921
+ * Pre-ranks over the FULL touched set on the `index` axis — the flag-carrying
922
+ * movees are NOT scope members (a Today flag makes startDate non-null, so the
923
+ * bounce's `pre.members` excludes them), so the undoable summary must capture
924
+ * every coBounce row's prior `index` directly, not just `pre.members`.
925
+ */
926
+ function captureIndexRanks(deps, uuids) {
927
+ const reader = createDbReader(deps.db);
928
+ const pre = {};
929
+ for (const uuid of uuids)
930
+ pre[uuid] = reader.rankOf(uuid, "index");
931
+ return pre;
932
+ }
933
+ /** Abort payload for a flag-safe protocol (placed/remaining + recovery detail). */
934
+ function swapAborted(detail, placed, remaining, cause) {
935
+ return { kind: "bounce-aborted", op: "reorder", detail, placed, remaining, cause };
936
+ }
937
+ /** A H-REORDER-SCOPE block from a flag-safe protocol (records the summary). */
938
+ function swapBlocked(deps, params, ctx, preRanks, detail, remediation) {
939
+ const result = {
940
+ kind: "blocked",
941
+ op: "reorder",
942
+ reason: "hazard",
943
+ hazard: "H-REORDER-SCOPE",
944
+ detail,
945
+ remediation,
946
+ };
947
+ auditSummary(deps, params, ctx.startedAt, "blocked:H-REORDER-SCOPE", null, {
948
+ pre: preRanks,
949
+ txnId: ctx.txnId,
950
+ actor: ctx.actor,
951
+ });
952
+ return result;
953
+ }
954
+ /** Terminal ordering verify over the touched run on the `index` axis. */
955
+ async function verifyIndexOrder(deps, coBounce, options) {
956
+ return pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: "index", sequence: coBounce }, createDbReader(deps.db), {
957
+ modDates: {},
958
+ fields: {},
959
+ }), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
960
+ }
961
+ /** The OK result shape shared by the three protocols. */
962
+ function swapOk(deps, coBounce, ctx, warnings) {
963
+ const reader = createDbReader(deps.db);
964
+ const observed = {};
965
+ for (const uuid of coBounce)
966
+ observed[uuid] = reader.rankOf(uuid, "index");
967
+ return {
968
+ kind: "ok",
969
+ op: "reorder",
970
+ uuid: null,
971
+ observed,
972
+ vector: "url-scheme",
973
+ tier: 0,
974
+ undoToken: ctx.txnId,
975
+ ...(warnings !== undefined && warnings.length > 0 && { warnings }),
976
+ ...(ctx.touchedUnnamed.length > 0 && { touched: ctx.touchedUnnamed }),
977
+ };
978
+ }
979
+ /**
980
+ * HEADMOVE (SIT6) — a heading's flag-carrying anytime children: UNHEAD the whole
981
+ * touched run (clean — heading→NULL, `index` + flag preserved), then RE-HEAD it in
982
+ * FORWARD target order — each now-loose row BACK-INSERTS past the heading-bucket
983
+ * `index` max, so forward-order re-heads land the exact order (the shipped
984
+ * `heading-someday` mechanism, now for the ANYTIME class + proven flag-safe). Two
985
+ * `todo.move` URL legs per item (`list-id=<p>` then `list-id=<p>&heading=<h>`), no
986
+ * when= leg, no json collapse. Non-atomic: a mid-fail leaves rows UNHEADED in the
987
+ * project root, disclosed (same discipline as heading-someday).
988
+ */
989
+ async function runHeadMove(deps, params, ctx) {
990
+ const { coBounce, containerUuid, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
991
+ const headingUuid = containerUuid;
992
+ const projectUuid = headingProjectUuid(deps, headingUuid);
993
+ const preRanks = captureIndexRanks(deps, coBounce);
994
+ const problems = [];
995
+ if (headingUuid === null || projectUuid === null) {
996
+ problems.push("the heading did not resolve to a project (re-head needs the heading's project)");
997
+ }
998
+ if (coBounce.length > cap) {
999
+ problems.push(`${coBounce.length} touched items exceed the cap of ${cap} (each costs an unhead + re-head leg` +
1000
+ (touchedUnnamed.length > 0
1001
+ ? `; ${touchedUnnamed.length} unnamed heading sibling(s) are co-moved to honor the order`
1002
+ : "") +
1003
+ ")");
1004
+ }
1005
+ if (problems.length > 0) {
1006
+ return swapBlocked(deps, params, ctx, preRanks, `within-heading flag-safe reorder rejected: ${problems.join("; ")}`, "reorder the anytime children of ONE heading (read the project first), " +
1007
+ `at most ${cap} touched (set with \`things config set bounce-max-items\`)`);
1008
+ }
1009
+ if (options.dryRun === true) {
1010
+ return {
1011
+ kind: "dry-run",
1012
+ op: "reorder",
1013
+ plan: {
1014
+ op: "reorder",
1015
+ vector: "url-scheme",
1016
+ tier: 0,
1017
+ invocation: `HEADMOVE unhead ×${coBounce.length} → re-head ×${coBounce.length} (flag-safe back-insert, ` +
1018
+ `forward order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}): ` +
1019
+ "a flagged movee's when= bounce would de-Today it, so each child is unheaded (index + flag " +
1020
+ "preserved) then re-headed to append at the heading-bucket end; one terminal order verify",
1021
+ expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
1022
+ hazardsChecked: ["H-REORDER-SCOPE"],
1023
+ },
1024
+ };
1025
+ }
1026
+ const legOpts = compoundLegOptions(options, txnId);
1027
+ // 1. UNHEAD each touched member (clean — heading→NULL, index + flag preserved).
1028
+ const unheaded = [];
1029
+ for (const uuid of coBounce) {
1030
+ const res = await runMutation(deps, "todo.move", { uuid, noHeading: true }, legOpts);
1031
+ if (res.kind !== "ok") {
1032
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unheaded] }, {
1033
+ pre: preRanks,
1034
+ txnId,
1035
+ actor,
1036
+ });
1037
+ return swapAborted(`unheading ${uuid} failed — ${unheaded.length} item(s) are UNHEADED in project ` +
1038
+ `${projectUuid} and must be moved back under the heading manually`, unheaded, coBounce.slice(unheaded.length), res);
1039
+ }
1040
+ unheaded.push(uuid);
1041
+ }
1042
+ // 2. RE-HEAD in forward target order (each now-loose row back-inserts at the end).
1043
+ const placed = [];
1044
+ for (const uuid of coBounce) {
1045
+ const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: projectUuid }, heading: headingUuid }, legOpts);
1046
+ if (res.kind !== "ok") {
1047
+ const stillUnheaded = coBounce.filter((u) => !placed.includes(u));
1048
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, {
1049
+ pre: preRanks,
1050
+ txnId,
1051
+ actor,
1052
+ });
1053
+ return swapAborted(`re-heading ${uuid} failed — ${stillUnheaded.length} item(s) remain UNHEADED in project ` +
1054
+ `${projectUuid} (ordered) and must be moved back under the heading manually`, placed, stillUnheaded, res);
1055
+ }
1056
+ placed.push(uuid);
1057
+ }
1058
+ const verify = await verifyIndexOrder(deps, coBounce, options);
1059
+ if (verify.kind !== "ok") {
1060
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...placed] }, {
1061
+ pre: preRanks,
1062
+ txnId,
1063
+ actor,
1064
+ });
1065
+ return swapAborted("the heading did not land the requested order after re-heading; re-run once Things is idle", placed, [], null);
1066
+ }
1067
+ auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
1068
+ pre: preRanks,
1069
+ txnId,
1070
+ actor,
1071
+ });
1072
+ return swapOk(deps, coBounce, ctx);
1073
+ }
1074
+ /**
1075
+ * LOOSEPARK (SIT6) — flag-carrying area-less loose anytime to-dos: create a scratch
1076
+ * PROJECT, PARK every touched row into it (any order), then UNPARK in REVERSE target
1077
+ * order — each unpark (`list-id=` empty) FRONT-INSERTS at the loose Anytime `index`
1078
+ * min in dispatch order, so a reverse-target dispatch lands the exact order (the
1079
+ * central SIT6 law; NO in-scratch reorder needed). Verify the scratch is EMPTY, then
1080
+ * TRASH it (shallow project trash). Non-atomic: a mid-fail leaves rows PARKED in the
1081
+ * NAMED scratch project (recovery text); the scratch is NEVER trashed while non-empty
1082
+ * (AREADEL — that would Trash the parked rows).
1083
+ */
1084
+ async function runLoosePark(deps, params, ctx) {
1085
+ const { coBounce, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
1086
+ const preRanks = captureIndexRanks(deps, coBounce);
1087
+ if (coBounce.length > cap) {
1088
+ return swapBlocked(deps, params, ctx, preRanks, `area-less loose anytime flag-safe reorder rejected: ${coBounce.length} touched items exceed ` +
1089
+ `the cap of ${cap} (each costs a park + unpark leg` +
1090
+ (touchedUnnamed.length > 0
1091
+ ? `; ${touchedUnnamed.length} co-touched loose sibling(s)`
1092
+ : "") +
1093
+ ")", `reorder at most ${cap} loose Anytime to-dos (set with \`things config set bounce-max-items\`)`);
1094
+ }
1095
+ if (options.dryRun === true) {
1096
+ return {
1097
+ kind: "dry-run",
1098
+ op: "reorder",
1099
+ plan: {
1100
+ op: "reorder",
1101
+ vector: "url-scheme",
1102
+ tier: 0,
1103
+ invocation: `LOOSEPARK scratch project + park ×${coBounce.length} + unpark ×${coBounce.length} ` +
1104
+ `(flag-safe front-insert, reverse target order` +
1105
+ (touchedUnnamed.length > 0
1106
+ ? `, touches ${touchedUnnamed.length} unnamed sibling(s)`
1107
+ : "") +
1108
+ "; trash the empty scratch): a flagged movee's when= bounce would de-Today it, so each row " +
1109
+ "is parked into a scratch project then unparked to front-insert at the loose min; one terminal " +
1110
+ "order verify",
1111
+ expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
1112
+ hazardsChecked: ["H-REORDER-SCOPE"],
1113
+ },
1114
+ };
1115
+ }
1116
+ const legOpts = compoundLegOptions(options, txnId);
1117
+ const scratchTitle = `things-api reorder-anytime ${scratchSuffix(startedAt)}`;
1118
+ // 1. Create the scratch PROJECT.
1119
+ const add = await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
1120
+ if (add.kind !== "ok" || add.uuid === null) {
1121
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
1122
+ pre: preRanks,
1123
+ txnId,
1124
+ actor,
1125
+ });
1126
+ return swapAborted(`could not create the scratch project "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
1127
+ }
1128
+ const scratch = add.uuid;
1129
+ // 2. PARK each touched row into the scratch project (any order).
1130
+ const parked = [];
1131
+ for (const uuid of coBounce) {
1132
+ const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
1133
+ if (res.kind !== "ok") {
1134
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
1135
+ pre: preRanks,
1136
+ txnId,
1137
+ actor,
1138
+ });
1139
+ return swapAborted(`parking ${uuid} into scratch project ${scratch} failed — ${parked.length} item(s) are PARKED ` +
1140
+ `there (${scratch}) and must be moved back to the loose Anytime list manually; the scratch ` +
1141
+ "project was NOT trashed", parked, coBounce.slice(parked.length), res);
1142
+ }
1143
+ parked.push(uuid);
1144
+ }
1145
+ // 3. UNPARK in REVERSE target order — front-insert lands the target order.
1146
+ const dispatch = coBounce.toReversed();
1147
+ const unparked = [];
1148
+ for (const uuid of dispatch) {
1149
+ const res = await runMutation(deps, "todo.move", { uuid, loose: true }, legOpts);
1150
+ if (res.kind !== "ok") {
1151
+ const stillParked = dispatch.filter((u) => !unparked.includes(u));
1152
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unparked] }, {
1153
+ pre: preRanks,
1154
+ txnId,
1155
+ actor,
1156
+ });
1157
+ return swapAborted(`unparking ${uuid} from scratch project ${scratch} failed — ${stillParked.length} item(s) ` +
1158
+ `remain PARKED in ${scratch} and must be moved back to the loose Anytime list manually; the ` +
1159
+ "scratch project was NOT trashed", unparked, stillParked, res);
1160
+ }
1161
+ unparked.push(uuid);
1162
+ }
1163
+ // 4. Verify the scratch is EMPTY, then trash it (NEVER trash a non-empty scratch).
1164
+ const remaining = countProjectChildren(deps, scratch);
1165
+ if (remaining > 0) {
1166
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unparked] }, {
1167
+ pre: preRanks,
1168
+ txnId,
1169
+ actor,
1170
+ });
1171
+ return swapAborted(`the scratch project ${scratch} still holds ${remaining} parked item(s) after unparking — ` +
1172
+ "refusing to trash it (trashing a non-empty scratch would send them to the Trash, AREADEL); " +
1173
+ `move them back to the loose Anytime list and delete ${scratch} manually`, unparked, [], null);
1174
+ }
1175
+ const del = await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
1176
+ const scratchTrashed = del.kind === "ok";
1177
+ // 5. Terminal verify: the loose Anytime order matches the target.
1178
+ const verify = await verifyIndexOrder(deps, coBounce, options);
1179
+ if (verify.kind !== "ok") {
1180
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...unparked] }, {
1181
+ pre: preRanks,
1182
+ txnId,
1183
+ actor,
1184
+ });
1185
+ return swapAborted("the loose Anytime list did not land the requested order after unparking (scratch project " +
1186
+ `${scratch} was ${scratchTrashed ? "trashed" : "left in place"}); re-run once Things is idle`, unparked, [], null);
1187
+ }
1188
+ auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
1189
+ pre: preRanks,
1190
+ txnId,
1191
+ actor,
1192
+ });
1193
+ return swapOk(deps, coBounce, ctx, [
1194
+ `scratch project ${scratch} was created for the reorder and ` +
1195
+ (scratchTrashed
1196
+ ? "moved to the Trash (verified empty first — the protocol never trashes a non-empty scratch)"
1197
+ : `could NOT be trashed (${del.kind}) — it remains in your project list empty; delete it manually`),
1198
+ ]);
1199
+ }
1200
+ /**
1201
+ * PROJPARK (SIT6) — flag-carrying area-less sidebar projects: create a scratch AREA,
1202
+ * PARK each project into it (`area-id=` leg), then DETACH in REVERSE target order —
1203
+ * each detach (`area-id=` empty) FRONT-INSERTS at the area-less project `index` min in
1204
+ * dispatch order, so a reverse-target dispatch lands the exact order (stars intact).
1205
+ * Verify the scratch area is EMPTY, then DELETE it — the delete supplies its own
1206
+ * H-PERMANENT-DELETE acknowledgement INTERNALLY (this transaction created the area and
1207
+ * has just verified it empty); it NEVER deletes a non-empty area (AREADEL would Trash
1208
+ * the parked projects + shallow-trash their children). Replaces the #351 de-Today
1209
+ * refusal for flagged movees. Non-atomic: a mid-fail leaves projects PARKED in the
1210
+ * NAMED scratch area (recovery text).
1211
+ */
1212
+ async function runProjPark(deps, params, ctx) {
1213
+ const { coBounce, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
1214
+ const preRanks = captureIndexRanks(deps, coBounce);
1215
+ if (coBounce.length > cap) {
1216
+ return swapBlocked(deps, params, ctx, preRanks, `top-level projects flag-safe reorder rejected: ${coBounce.length} touched items exceed the cap ` +
1217
+ `of ${cap} (each costs a park + detach leg` +
1218
+ (touchedUnnamed.length > 0
1219
+ ? `; ${touchedUnnamed.length} co-touched sidebar sibling(s)`
1220
+ : "") +
1221
+ ")", `reorder at most ${cap} top-level projects (set with \`things config set bounce-max-items\`)`);
1222
+ }
1223
+ if (options.dryRun === true) {
1224
+ return {
1225
+ kind: "dry-run",
1226
+ op: "reorder",
1227
+ plan: {
1228
+ op: "reorder",
1229
+ vector: "url-scheme",
1230
+ tier: 0,
1231
+ invocation: `PROJPARK scratch area + park ×${coBounce.length} + detach ×${coBounce.length} ` +
1232
+ `(flag-safe front-insert, reverse target order` +
1233
+ (touchedUnnamed.length > 0
1234
+ ? `, touches ${touchedUnnamed.length} unnamed sibling(s)`
1235
+ : "") +
1236
+ "; delete the empty scratch area): a flagged project's when= bounce would de-star it, so each " +
1237
+ "project is parked into a scratch area then detached to front-insert at the sidebar min; one " +
1238
+ "terminal order verify",
1239
+ expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
1240
+ hazardsChecked: ["H-REORDER-SCOPE"],
1241
+ },
1242
+ };
1243
+ }
1244
+ const legOpts = compoundLegOptions(options, txnId);
1245
+ const scratchTitle = `things-api reorder-projects ${scratchSuffix(startedAt)}`;
1246
+ // 1. Create the scratch AREA.
1247
+ const add = await runMutation(deps, "area.add", { title: scratchTitle }, legOpts);
1248
+ if (add.kind !== "ok" || add.uuid === null) {
1249
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
1250
+ pre: preRanks,
1251
+ txnId,
1252
+ actor,
1253
+ });
1254
+ return swapAborted(`could not create the scratch area "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
1255
+ }
1256
+ const scratch = add.uuid;
1257
+ // 2. PARK each project into the scratch area (any order).
1258
+ const parked = [];
1259
+ for (const uuid of coBounce) {
1260
+ const res = await runMutation(deps, "project.move", { uuid, area: { uuid: scratch } }, legOpts);
1261
+ if (res.kind !== "ok") {
1262
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
1263
+ pre: preRanks,
1264
+ txnId,
1265
+ actor,
1266
+ });
1267
+ return swapAborted(`parking project ${uuid} into scratch area ${scratch} failed — ${parked.length} project(s) are ` +
1268
+ `PARKED there (${scratch}) and must be moved back to the sidebar manually; the scratch area was ` +
1269
+ "NOT deleted", parked, coBounce.slice(parked.length), res);
1270
+ }
1271
+ parked.push(uuid);
1272
+ }
1273
+ // 3. DETACH in REVERSE target order — front-insert lands the target order.
1274
+ const dispatch = coBounce.toReversed();
1275
+ const detached = [];
1276
+ for (const uuid of dispatch) {
1277
+ const res = await runMutation(deps, "project.move", { uuid, noArea: true }, legOpts);
1278
+ if (res.kind !== "ok") {
1279
+ const stillParked = dispatch.filter((u) => !detached.includes(u));
1280
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...detached] }, {
1281
+ pre: preRanks,
1282
+ txnId,
1283
+ actor,
1284
+ });
1285
+ return swapAborted(`detaching project ${uuid} from scratch area ${scratch} failed — ${stillParked.length} ` +
1286
+ `project(s) remain PARKED in ${scratch} and must be moved back to the sidebar manually; the ` +
1287
+ "scratch area was NOT deleted", detached, stillParked, res);
1288
+ }
1289
+ detached.push(uuid);
1290
+ }
1291
+ // 4. Verify the scratch area is EMPTY, then delete it (NEVER delete a non-empty area).
1292
+ const remaining = countAreaMembers(deps, scratch);
1293
+ if (remaining > 0) {
1294
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...detached] }, {
1295
+ pre: preRanks,
1296
+ txnId,
1297
+ actor,
1298
+ });
1299
+ return swapAborted(`the scratch area ${scratch} still holds ${remaining} parked project(s) after detaching — ` +
1300
+ "refusing to delete it (deleting a non-empty area TRASHES its projects and shallow-trashes their " +
1301
+ `children, AREADEL); move them back to the sidebar and delete ${scratch} manually`, detached, [], null);
1302
+ }
1303
+ // The delete supplies H-PERMANENT-DELETE internally (created + verified-empty this
1304
+ // txn); H-AREA-NOT-EMPTY does not trip on the verified-empty area. Never
1305
+ // acknowledge H-AREA-NOT-EMPTY — a non-empty area aborts above, never deletes.
1306
+ const del = await runMutation(deps, "area.delete", { target: scratch }, { ...legOpts, dangerouslyPermanent: true });
1307
+ const scratchDeleted = del.kind === "ok";
1308
+ // 5. Terminal verify: the sidebar order matches the target.
1309
+ const verify = await verifyIndexOrder(deps, coBounce, options);
1310
+ if (verify.kind !== "ok") {
1311
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...detached] }, {
1312
+ pre: preRanks,
1313
+ txnId,
1314
+ actor,
1315
+ });
1316
+ return swapAborted("the top-level projects did not land the requested order after detaching (scratch area " +
1317
+ `${scratch} was ${scratchDeleted ? "deleted" : "left in place"}); re-run once Things is idle`, detached, [], null);
1318
+ }
1319
+ auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
1320
+ pre: preRanks,
1321
+ txnId,
1322
+ actor,
1323
+ });
1324
+ return swapOk(deps, coBounce, ctx, [
1325
+ `scratch area ${scratch} was created for the reorder and ` +
1326
+ (scratchDeleted
1327
+ ? "deleted (verified empty first — the protocol never deletes a non-empty area)"
1328
+ : `could NOT be deleted (${del.kind}) — it remains in your sidebar empty; delete it manually`),
1329
+ ]);
1330
+ }
1331
+ /** Observed `index` ranks over the touched run (for the audit summary). */
1332
+ function swapObserved(deps, coBounce) {
1333
+ const reader = createDbReader(deps.db);
1334
+ const observed = {};
1335
+ for (const uuid of coBounce)
1336
+ observed[uuid] = reader.rankOf(uuid, "index");
1337
+ return observed;
1338
+ }
1339
+ /** Live (open, non-trashed) direct child count of a project (scratch emptiness). */
1340
+ function countProjectChildren(deps, projectUuid) {
1341
+ const row = deps.db
1342
+ .prepare("SELECT COUNT(*) AS n FROM TMTask WHERE trashed = 0 AND status = 0 AND " +
1343
+ "(project = ? OR heading IN (SELECT uuid FROM TMTask WHERE type = 2 AND project = ?))")
1344
+ .get(projectUuid, projectUuid);
1345
+ return row.n;
1346
+ }
1347
+ /** Live member count of an area (direct to-dos + projects) — scratch emptiness. */
1348
+ function countAreaMembers(deps, areaUuid) {
1349
+ const row = deps.db
1350
+ .prepare("SELECT COUNT(*) AS n FROM TMTask WHERE trashed = 0 AND status = 0 AND area = ?")
1351
+ .get(areaUuid);
1352
+ return row.n;
1353
+ }
1354
+ /** A row's type (0 = to-do, 1 = project, 2 = heading), or null when it is gone. */
1355
+ function rowTypeOf(deps, uuid) {
1356
+ const row = deps.db.prepare("SELECT type FROM TMTask WHERE uuid = ?").get(uuid);
1357
+ return row?.type ?? null;
1358
+ }
1359
+ // -------------------------------------------- SIT7 automatic MOVE fallbacks
1360
+ //
1361
+ // The non-experimental park + re-enter protocols the native-only reorder scopes
1362
+ // degrade to when the private `_private_experimental_ reorder` command is
1363
+ // unavailable (allow-experimental off OR the sdef canary fails) — the LIVE
1364
+ // automatic backups SIT7 proved (docs/lab/sit7-backup-laws.md). Every leg is a
1365
+ // URL/AppleScript MOVE (`list-id=`/`area-id=`/`move … to list "Inbox"`) — NO
1366
+ // when= leg, NO private reorder surface — so the Today/Evening flag + reminder +
1367
+ // deadline + FKs all survive the sort. Re-entry geometry follows the destination's
1368
+ // CONTAINMENT class (SIT7's general law): LOOSE-like buckets FRONT-insert (reverse
1369
+ // target dispatch), CONTAINERS BACK-insert (forward target dispatch):
1370
+ // - INBOXBACK (inbox): park each row into a scratch PROJECT, then re-enter
1371
+ // `move … to list "Inbox"` in REVERSE target order — FRONT-inserts and
1372
+ // RESTORES start=0 (a same-list `move … to "Inbox"` is a no-op, so park-first
1373
+ // is mandatory — the inbox cousin of §9l).
1374
+ // - PROJROOT (project): park each unheaded child into a scratch PROJECT, then
1375
+ // re-home `list-id=<P>` in FORWARD target order — BACK-inserts at the project-
1376
+ // root max (a project root behaves like a heading container). ONE protocol for
1377
+ // ALL rows (flagged or not) — the move round-trip is proven flag-safe.
1378
+ // - AREABACK (area): park each member OUT (a to-do into a scratch PROJECT, a project
1379
+ // into a scratch AREA), then re-home to the area in REVERSE target order —
1380
+ // FRONT-inserts at the area's member min (an area behaves like a loose bucket),
1381
+ // the area FK preserved.
1382
+ // They share the SIT6 move family's gate (bounce-enabled) and cap (bounce-max-items),
1383
+ // pre-ranks (undo via a single inverse reorder), named-scratch abort disclosure,
1384
+ // verify-empty teardown (AREADEL — never trash/delete a non-empty scratch), and
1385
+ // placement-honesty `touched` disclosure. Non-atomic: a mid-protocol failure leaves
1386
+ // rows in a disclosed transient state and fails loudly with placed/remaining detail.
1387
+ /**
1388
+ * Compute the touched run + build the {@link SwapCtx} for a SIT7 move fallback,
1389
+ * validate scope membership, and dispatch to the matching protocol. Unlike the
1390
+ * SIT6 flag-swap (whose movees are flag-EXCLUDED from `pre.members`), the SIT7
1391
+ * fallbacks operate on TRUE scope members, so duplicates/rejected/mixed-type are
1392
+ * validated here exactly as the bounce path validates them.
1393
+ */
1394
+ async function runMoveFallback(deps, params, fallback, options) {
1395
+ const startedAt = deps.now?.() ?? new Date();
1396
+ const now = deps.now ?? (() => new Date());
1397
+ const actor = options.actor ?? deps.config.actor;
1398
+ const cap = deps.config.bounceMaxItems ?? BOUNCE_MAX_ITEMS;
1399
+ const containerUuid = resolveContainerUuid(deps, params);
1400
+ const txnId = `txn-${startedAt.getTime().toString(36)}-${process.pid.toString(36)}`;
1401
+ const pre = computeReorderPre(deps.db, params, containerUuid, now());
1402
+ // proj-root BACK-inserts (forward dispatch → suffix from the first named slot);
1403
+ // inbox/area FRONT-insert (reverse dispatch → prefix to the last named slot).
1404
+ const direction = fallback === "proj-root" ? "back" : "front";
1405
+ const targetOrder = pre.wireList;
1406
+ const named = new Set(params.named ?? params.uuids);
1407
+ const movedPositions = targetOrder.map((u, i) => (named.has(u) ? i : -1)).filter((i) => i >= 0);
1408
+ const firstMoved = movedPositions.length > 0 ? movedPositions[0] : 0;
1409
+ const lastMoved = movedPositions.length > 0 ? movedPositions[movedPositions.length - 1] : 0;
1410
+ const coBounce = direction === "back" ? targetOrder.slice(firstMoved) : targetOrder.slice(0, lastMoved + 1);
1411
+ const touchedUnnamed = coBounce.filter((u) => !named.has(u));
1412
+ const ctx = {
1413
+ coBounce,
1414
+ containerUuid,
1415
+ txnId,
1416
+ actor,
1417
+ touchedUnnamed,
1418
+ startedAt,
1419
+ options,
1420
+ cap,
1421
+ };
1422
+ const preRanks = captureIndexRanks(deps, coBounce);
1423
+ const problems = [];
1424
+ if (params.uuids.length === 0)
1425
+ problems.push("no uuids given");
1426
+ if (pre.duplicates.length > 0)
1427
+ problems.push(`duplicated uuid(s): ${pre.duplicates.join(", ")}`);
1428
+ for (const r of pre.rejected)
1429
+ problems.push(`${r.uuid} ${r.reason}`);
1430
+ if (pre.mixedTypes) {
1431
+ problems.push("an area reorder must be all to-dos OR all projects — a mixed member set is unprobed");
1432
+ }
1433
+ if (problems.length > 0) {
1434
+ return swapBlocked(deps, params, ctx, preRanks, `reorder request rejected: ${problems.join("; ")}`, "read the scope first and pass only its eligible members, at most " +
1435
+ `${cap} for the fallback protocol (set with \`things config set bounce-max-items\`)`);
1436
+ }
1437
+ switch (fallback) {
1438
+ case "inbox-park":
1439
+ return runInboxPark(deps, params, ctx);
1440
+ case "proj-root":
1441
+ return runProjectRoot(deps, params, ctx);
1442
+ case "area-back":
1443
+ return runAreaBack(deps, params, ctx);
1444
+ }
1445
+ }
1446
+ /**
1447
+ * INBOXBACK (SIT7) — Inbox order without the private surface: create a scratch
1448
+ * PROJECT, PARK every touched row into it (`list-id=<scratch>` flips start 0→1
1449
+ * transiently), then re-enter `move to do id X to list "Inbox"` in REVERSE target
1450
+ * order — each re-entry FRONT-INSERTS at the inbox `index` min AND RESTORES start=0
1451
+ * (project→NULL), so a reverse-target dispatch lands the exact order. The inbox
1452
+ * return leg is AppleScript-only (`move … to list "Inbox"`; #356) — pinned here.
1453
+ * Verify the scratch is EMPTY, then TRASH it. Non-atomic: a mid-fail leaves rows
1454
+ * PARKED in the NAMED scratch project (recovery text); never trashes a non-empty
1455
+ * scratch (AREADEL).
1456
+ */
1457
+ async function runInboxPark(deps, params, ctx) {
1458
+ const { coBounce, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
1459
+ const preRanks = captureIndexRanks(deps, coBounce);
1460
+ if (coBounce.length > cap) {
1461
+ return swapBlocked(deps, params, ctx, preRanks, `Inbox order fallback rejected: ${coBounce.length} touched items exceed the cap of ${cap} ` +
1462
+ `(each costs a park + Inbox-return leg${touchedUnnamed.length > 0 ? `; ${touchedUnnamed.length} co-touched inbox sibling(s)` : ""})`, `reorder at most ${cap} inbox to-dos (set with \`things config set bounce-max-items\`)`);
1463
+ }
1464
+ if (options.dryRun === true) {
1465
+ return {
1466
+ kind: "dry-run",
1467
+ op: "reorder",
1468
+ plan: {
1469
+ op: "reorder",
1470
+ vector: "url-scheme",
1471
+ tier: 0,
1472
+ invocation: `INBOXBACK scratch project + park ×${coBounce.length} + Inbox-return ×${coBounce.length} ` +
1473
+ `(front-insert, reverse target order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}; ` +
1474
+ 'trash the empty scratch): a same-list `move … to "Inbox"` is a no-op, so each row is ' +
1475
+ "parked into a scratch project then re-entered to the Inbox to front-insert (restoring " +
1476
+ "start=0); one terminal order verify",
1477
+ expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
1478
+ hazardsChecked: ["H-REORDER-SCOPE"],
1479
+ },
1480
+ };
1481
+ }
1482
+ const legOpts = compoundLegOptions(options, txnId);
1483
+ const scratchTitle = `things-api reorder-inbox ${scratchSuffix(startedAt)}`;
1484
+ // 1. Create the scratch PROJECT.
1485
+ const add = await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
1486
+ if (add.kind !== "ok" || add.uuid === null) {
1487
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
1488
+ pre: preRanks,
1489
+ txnId,
1490
+ actor,
1491
+ });
1492
+ return swapAborted(`could not create the scratch project "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
1493
+ }
1494
+ const scratch = add.uuid;
1495
+ // 2. PARK each touched row into the scratch project (any order).
1496
+ const parked = [];
1497
+ for (const uuid of coBounce) {
1498
+ const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
1499
+ if (res.kind !== "ok") {
1500
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
1501
+ pre: preRanks,
1502
+ txnId,
1503
+ actor,
1504
+ });
1505
+ return swapAborted(`parking ${uuid} into scratch project ${scratch} failed — ${parked.length} item(s) are PARKED ` +
1506
+ `there (${scratch}) and must be moved back to the Inbox manually; the scratch project was ` +
1507
+ "NOT trashed", parked, coBounce.slice(parked.length), res);
1508
+ }
1509
+ parked.push(uuid);
1510
+ }
1511
+ // 3. RE-ENTER `move … to list "Inbox"` (AppleScript-only, #356) in REVERSE target
1512
+ // order — front-insert restores start=0 and lands the target order.
1513
+ const dispatch = coBounce.toReversed();
1514
+ const returned = [];
1515
+ for (const uuid of dispatch) {
1516
+ const res = await runMutation(deps, "todo.move", { uuid, inbox: true }, { ...legOpts, vector: "applescript" });
1517
+ if (res.kind !== "ok") {
1518
+ const stillParked = dispatch.filter((u) => !returned.includes(u));
1519
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...returned] }, {
1520
+ pre: preRanks,
1521
+ txnId,
1522
+ actor,
1523
+ });
1524
+ return swapAborted(`returning ${uuid} to the Inbox from scratch project ${scratch} failed — ${stillParked.length} ` +
1525
+ `item(s) remain PARKED in ${scratch} and must be moved back to the Inbox manually; the ` +
1526
+ "scratch project was NOT trashed", returned, stillParked, res);
1527
+ }
1528
+ returned.push(uuid);
1529
+ }
1530
+ // 4. Verify the scratch is EMPTY, then trash it (NEVER trash a non-empty scratch).
1531
+ const remaining = countProjectChildren(deps, scratch);
1532
+ if (remaining > 0) {
1533
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...returned] }, {
1534
+ pre: preRanks,
1535
+ txnId,
1536
+ actor,
1537
+ });
1538
+ return swapAborted(`the scratch project ${scratch} still holds ${remaining} parked item(s) after the Inbox return — ` +
1539
+ "refusing to trash it (trashing a non-empty scratch would send them to the Trash, AREADEL); " +
1540
+ `move them back to the Inbox and delete ${scratch} manually`, returned, [], null);
1541
+ }
1542
+ const del = await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
1543
+ const scratchTrashed = del.kind === "ok";
1544
+ // 5. Terminal verify: the inbox order matches the target.
1545
+ const verify = await verifyIndexOrder(deps, coBounce, options);
1546
+ if (verify.kind !== "ok") {
1547
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...returned] }, {
1548
+ pre: preRanks,
1549
+ txnId,
1550
+ actor,
1551
+ });
1552
+ return swapAborted("the Inbox did not land the requested order after the return (scratch project " +
1553
+ `${scratch} was ${scratchTrashed ? "trashed" : "left in place"}); re-run once Things is idle`, returned, [], null);
1554
+ }
1555
+ auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
1556
+ pre: preRanks,
1557
+ txnId,
1558
+ actor,
1559
+ });
1560
+ return swapOk(deps, coBounce, ctx, [
1561
+ `scratch project ${scratch} was created for the reorder and ` +
1562
+ (scratchTrashed
1563
+ ? "moved to the Trash (verified empty first — the protocol never trashes a non-empty scratch)"
1564
+ : `could NOT be trashed (${del.kind}) — it remains in your project list empty; delete it manually`),
1565
+ ]);
1566
+ }
1567
+ /**
1568
+ * PROJROOT (SIT7) — a project's unheaded children without the private surface: create
1569
+ * a scratch PROJECT, PARK every touched child into it (`list-id=<scratch>`), then
1570
+ * re-home to the original project (`list-id=<P>`, no heading) in FORWARD target order
1571
+ * — each re-home BACK-INSERTS at the project-root `index` max (a project root behaves
1572
+ * like a heading container), so a forward-target dispatch lands the exact order. ONE
1573
+ * protocol for ALL rows (flagged or not) — the move round-trip is proven flag-safe.
1574
+ * Verify the scratch is EMPTY, then TRASH it. Non-atomic: a mid-fail leaves children
1575
+ * PARKED in the NAMED scratch project (recovery text).
1576
+ */
1577
+ async function runProjectRoot(deps, params, ctx) {
1578
+ const { coBounce, containerUuid, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
1579
+ const preRanks = captureIndexRanks(deps, coBounce);
1580
+ if (containerUuid === null) {
1581
+ return swapBlocked(deps, params, ctx, preRanks, "the project did not resolve (the re-home leg needs the project container)", "pass the project by uuid or a unique title (`--project`)");
1582
+ }
1583
+ if (coBounce.length > cap) {
1584
+ return swapBlocked(deps, params, ctx, preRanks, `within-project order fallback rejected: ${coBounce.length} touched items exceed the cap of ${cap} ` +
1585
+ `(each costs a park + re-home leg${touchedUnnamed.length > 0 ? `; ${touchedUnnamed.length} co-touched child sibling(s)` : ""})`, `reorder at most ${cap} of a project's unheaded children (set with \`things config set bounce-max-items\`)`);
1586
+ }
1587
+ if (options.dryRun === true) {
1588
+ return {
1589
+ kind: "dry-run",
1590
+ op: "reorder",
1591
+ plan: {
1592
+ op: "reorder",
1593
+ vector: "url-scheme",
1594
+ tier: 0,
1595
+ invocation: `PROJROOT scratch project + park ×${coBounce.length} + re-home ×${coBounce.length} ` +
1596
+ `(flag-safe back-insert, forward target order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}; ` +
1597
+ "trash the empty scratch): the private reorder is unavailable, so each child is parked into a " +
1598
+ "scratch project then re-homed to the project root to back-insert at its end; one terminal " +
1599
+ "order verify",
1600
+ expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
1601
+ hazardsChecked: ["H-REORDER-SCOPE"],
1602
+ },
1603
+ };
1604
+ }
1605
+ const legOpts = compoundLegOptions(options, txnId);
1606
+ const scratchTitle = `things-api reorder-project ${scratchSuffix(startedAt)}`;
1607
+ // 1. Create the scratch PROJECT.
1608
+ const add = await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
1609
+ if (add.kind !== "ok" || add.uuid === null) {
1610
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
1611
+ pre: preRanks,
1612
+ txnId,
1613
+ actor,
1614
+ });
1615
+ return swapAborted(`could not create the scratch project "${scratchTitle}" — nothing was parked; no changes were made`, [], coBounce, add.kind === "ok" ? null : add);
1616
+ }
1617
+ const scratch = add.uuid;
1618
+ // 2. PARK each child into the scratch project (any order).
1619
+ const parked = [];
1620
+ for (const uuid of coBounce) {
1621
+ const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
1622
+ if (res.kind !== "ok") {
1623
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
1624
+ pre: preRanks,
1625
+ txnId,
1626
+ actor,
1627
+ });
1628
+ return swapAborted(`parking ${uuid} into scratch project ${scratch} failed — ${parked.length} child(ren) are PARKED ` +
1629
+ `there (${scratch}) and must be moved back to project ${containerUuid} manually; the scratch ` +
1630
+ "project was NOT trashed", parked, coBounce.slice(parked.length), res);
1631
+ }
1632
+ parked.push(uuid);
1633
+ }
1634
+ // 3. RE-HOME to the project root in FORWARD target order — back-insert lands the target.
1635
+ const rehomed = [];
1636
+ for (const uuid of coBounce) {
1637
+ const res = await runMutation(deps, "todo.move", { uuid, project: { uuid: containerUuid } }, legOpts);
1638
+ if (res.kind !== "ok") {
1639
+ const stillParked = coBounce.filter((u) => !rehomed.includes(u));
1640
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
1641
+ pre: preRanks,
1642
+ txnId,
1643
+ actor,
1644
+ });
1645
+ return swapAborted(`re-homing ${uuid} to project ${containerUuid} from scratch project ${scratch} failed — ` +
1646
+ `${stillParked.length} child(ren) remain PARKED in ${scratch} and must be moved back to ` +
1647
+ `project ${containerUuid} manually; the scratch project was NOT trashed`, rehomed, stillParked, res);
1648
+ }
1649
+ rehomed.push(uuid);
1650
+ }
1651
+ // 4. Verify the scratch is EMPTY, then trash it (NEVER trash a non-empty scratch).
1652
+ const remaining = countProjectChildren(deps, scratch);
1653
+ if (remaining > 0) {
1654
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
1655
+ pre: preRanks,
1656
+ txnId,
1657
+ actor,
1658
+ });
1659
+ return swapAborted(`the scratch project ${scratch} still holds ${remaining} parked child(ren) after re-homing — ` +
1660
+ "refusing to trash it (trashing a non-empty scratch would send them to the Trash, AREADEL); " +
1661
+ `move them back to project ${containerUuid} and delete ${scratch} manually`, rehomed, [], null);
1662
+ }
1663
+ const del = await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
1664
+ const scratchTrashed = del.kind === "ok";
1665
+ // 5. Terminal verify: the project-root order matches the target.
1666
+ const verify = await verifyIndexOrder(deps, coBounce, options);
1667
+ if (verify.kind !== "ok") {
1668
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
1669
+ pre: preRanks,
1670
+ txnId,
1671
+ actor,
1672
+ });
1673
+ return swapAborted("the project's children did not land the requested order after re-homing (scratch project " +
1674
+ `${scratch} was ${scratchTrashed ? "trashed" : "left in place"}); re-run once Things is idle`, rehomed, [], null);
1675
+ }
1676
+ auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
1677
+ pre: preRanks,
1678
+ txnId,
1679
+ actor,
1680
+ });
1681
+ return swapOk(deps, coBounce, ctx, [
1682
+ `scratch project ${scratch} was created for the reorder and ` +
1683
+ (scratchTrashed
1684
+ ? "moved to the Trash (verified empty first — the protocol never trashes a non-empty scratch)"
1685
+ : `could NOT be trashed (${del.kind}) — it remains in your project list empty; delete it manually`),
1686
+ ]);
1687
+ }
1688
+ /**
1689
+ * AREABACK (SIT7) — an area's members without the private surface. Park each member
1690
+ * OUT of the area (a to-do into a scratch PROJECT, a project into a scratch AREA),
1691
+ * then re-home to the area (`list-id=<area>` for a to-do, `area-id=<area>` for a
1692
+ * project) in REVERSE target order — each re-home FRONT-INSERTS at the area's member
1693
+ * `index` min (an area behaves like a loose bucket) with the area FK preserved, so a
1694
+ * reverse-target dispatch lands the exact order, flag-safe. The area scope is uniform-
1695
+ * type (mixed sets are rejected upstream), so the whole run is one kind. Verify the
1696
+ * scratch is EMPTY, then trash/delete it (the area delete supplies its own
1697
+ * H-PERMANENT-DELETE acknowledgement internally — created + verified-empty this txn).
1698
+ * Non-atomic: a mid-fail leaves members PARKED in the NAMED scratch container.
1699
+ */
1700
+ async function runAreaBack(deps, params, ctx) {
1701
+ const { coBounce, containerUuid, txnId, actor, touchedUnnamed, startedAt, options, cap } = ctx;
1702
+ const preRanks = captureIndexRanks(deps, coBounce);
1703
+ if (containerUuid === null) {
1704
+ return swapBlocked(deps, params, ctx, preRanks, "the area did not resolve (the re-home leg needs the area container)", "pass the area by uuid or a unique title (`--area`)");
1705
+ }
1706
+ const isProjects = rowTypeOf(deps, coBounce[0]) === 1;
1707
+ const noun = isProjects ? "project" : "to-do";
1708
+ if (coBounce.length > cap) {
1709
+ return swapBlocked(deps, params, ctx, preRanks, `an area's member order fallback rejected: ${coBounce.length} touched items exceed the cap of ${cap} ` +
1710
+ `(each costs a park + re-home leg${touchedUnnamed.length > 0 ? `; ${touchedUnnamed.length} co-touched area sibling(s)` : ""})`, `reorder at most ${cap} of an area's members (set with \`things config set bounce-max-items\`)`);
1711
+ }
1712
+ if (options.dryRun === true) {
1713
+ return {
1714
+ kind: "dry-run",
1715
+ op: "reorder",
1716
+ plan: {
1717
+ op: "reorder",
1718
+ vector: "url-scheme",
1719
+ tier: 0,
1720
+ invocation: `AREABACK scratch ${isProjects ? "area" : "project"} + park ×${coBounce.length} + re-home ×${coBounce.length} ` +
1721
+ `(flag-safe front-insert, reverse target order${touchedUnnamed.length > 0 ? `, touches ${touchedUnnamed.length} unnamed sibling(s)` : ""}; ` +
1722
+ `${isProjects ? "delete" : "trash"} the empty scratch): the private reorder is unavailable, so each ` +
1723
+ `${noun} is parked out then re-homed to the area to front-insert at its member min (area FK ` +
1724
+ "preserved); one terminal order verify",
1725
+ expectedDelta: { mode: "ordering", key: "index", sequence: coBounce },
1726
+ hazardsChecked: ["H-REORDER-SCOPE"],
1727
+ },
1728
+ };
1729
+ }
1730
+ const legOpts = compoundLegOptions(options, txnId);
1731
+ const scratchTitle = `things-api reorder-area ${scratchSuffix(startedAt)}`;
1732
+ // 1. Create the scratch container (a PROJECT to hold to-dos / an AREA to hold projects).
1733
+ const add = isProjects
1734
+ ? await runMutation(deps, "area.add", { title: scratchTitle }, legOpts)
1735
+ : await runMutation(deps, "project.add", { title: scratchTitle }, legOpts);
1736
+ if (add.kind !== "ok" || add.uuid === null) {
1737
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
1738
+ pre: preRanks,
1739
+ txnId,
1740
+ actor,
1741
+ });
1742
+ return swapAborted(`could not create the scratch ${isProjects ? "area" : "project"} "${scratchTitle}" — nothing was ` +
1743
+ "parked; no changes were made", [], coBounce, add.kind === "ok" ? null : add);
1744
+ }
1745
+ const scratch = add.uuid;
1746
+ // 2. PARK each member OUT of the area into the scratch container (any order).
1747
+ const parked = [];
1748
+ for (const uuid of coBounce) {
1749
+ const res = isProjects
1750
+ ? await runMutation(deps, "project.move", { uuid, area: { uuid: scratch } }, legOpts)
1751
+ : await runMutation(deps, "todo.move", { uuid, project: { uuid: scratch } }, legOpts);
1752
+ if (res.kind !== "ok") {
1753
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...parked] }, {
1754
+ pre: preRanks,
1755
+ txnId,
1756
+ actor,
1757
+ });
1758
+ return swapAborted(`parking ${noun} ${uuid} into scratch ${isProjects ? "area" : "project"} ${scratch} failed — ` +
1759
+ `${parked.length} ${noun}(s) are PARKED there (${scratch}) and must be moved back to area ` +
1760
+ `${containerUuid} manually; the scratch was NOT ${isProjects ? "deleted" : "trashed"}`, parked, coBounce.slice(parked.length), res);
1761
+ }
1762
+ parked.push(uuid);
1763
+ }
1764
+ // 3. RE-HOME to the area in REVERSE target order — front-insert lands the target.
1765
+ const dispatch = coBounce.toReversed();
1766
+ const rehomed = [];
1767
+ for (const uuid of dispatch) {
1768
+ const res = isProjects
1769
+ ? await runMutation(deps, "project.move", { uuid, area: { uuid: containerUuid } }, legOpts)
1770
+ : await runMutation(deps, "todo.move", { uuid, area: { uuid: containerUuid } }, legOpts);
1771
+ if (res.kind !== "ok") {
1772
+ const stillParked = dispatch.filter((u) => !rehomed.includes(u));
1773
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
1774
+ pre: preRanks,
1775
+ txnId,
1776
+ actor,
1777
+ });
1778
+ return swapAborted(`re-homing ${noun} ${uuid} to area ${containerUuid} from scratch ${scratch} failed — ` +
1779
+ `${stillParked.length} ${noun}(s) remain PARKED in ${scratch} and must be moved back to ` +
1780
+ `area ${containerUuid} manually; the scratch was NOT ${isProjects ? "deleted" : "trashed"}`, rehomed, stillParked, res);
1781
+ }
1782
+ rehomed.push(uuid);
1783
+ }
1784
+ // 4. Verify the scratch is EMPTY, then trash/delete it (NEVER teardown a non-empty scratch).
1785
+ const remaining = isProjects
1786
+ ? countAreaMembers(deps, scratch)
1787
+ : countProjectChildren(deps, scratch);
1788
+ if (remaining > 0) {
1789
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
1790
+ pre: preRanks,
1791
+ txnId,
1792
+ actor,
1793
+ });
1794
+ return swapAborted(`the scratch ${isProjects ? "area" : "project"} ${scratch} still holds ${remaining} parked ` +
1795
+ `${noun}(s) after re-homing — refusing to ${isProjects ? "delete" : "trash"} it (that would ` +
1796
+ `${isProjects ? "trash its members and shallow-trash their children" : "send them to the Trash"}, ` +
1797
+ `AREADEL); move them back to area ${containerUuid} and remove ${scratch} manually`, rehomed, [], null);
1798
+ }
1799
+ // The area delete supplies H-PERMANENT-DELETE internally (created + verified-empty
1800
+ // this txn); H-AREA-NOT-EMPTY cannot trip (a non-empty scratch aborts above).
1801
+ const del = isProjects
1802
+ ? await runMutation(deps, "area.delete", { target: scratch }, { ...legOpts, dangerouslyPermanent: true })
1803
+ : await runMutation(deps, "project.delete", { uuid: scratch }, legOpts);
1804
+ const scratchRemoved = del.kind === "ok";
1805
+ // 5. Terminal verify: the area's member order matches the target.
1806
+ const verify = await verifyIndexOrder(deps, coBounce, options);
1807
+ if (verify.kind !== "ok") {
1808
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [...rehomed] }, {
1809
+ pre: preRanks,
1810
+ txnId,
1811
+ actor,
1812
+ });
1813
+ return swapAborted("the area's members did not land the requested order after re-homing (scratch " +
1814
+ `${scratch} was ${scratchRemoved ? (isProjects ? "deleted" : "trashed") : "left in place"}); ` +
1815
+ "re-run once Things is idle", rehomed, [], null);
1816
+ }
1817
+ auditSummary(deps, params, startedAt, "ok", swapObserved(deps, coBounce), {
1818
+ pre: preRanks,
1819
+ txnId,
1820
+ actor,
1821
+ });
1822
+ return swapOk(deps, coBounce, ctx, [
1823
+ `scratch ${isProjects ? "area" : "project"} ${scratch} was created for the reorder and ` +
1824
+ (scratchRemoved
1825
+ ? `${isProjects ? "deleted" : "moved to the Trash"} (verified empty first — the protocol never ` +
1826
+ `${isProjects ? "deletes a non-empty area" : "trashes a non-empty scratch"})`
1827
+ : `could NOT be ${isProjects ? "deleted" : "trashed"} (${del.kind}) — it remains empty; remove it manually`),
1828
+ ]);
1829
+ }
1830
+ /**
1831
+ * The url-scheme dispatch surface for a leg op — the vector the json collapse
1832
+ * opens its `things:///json` array through. In tests this is the injected fake
1833
+ * (id "url-scheme"); in production `createUrlSchemeVector`; under the simulator
1834
+ * fence the simulator (id "url-scheme", simulates=true) — which the caller then
1835
+ * rejects for the collapse (it has no json-array applier) and falls back to the
1836
+ * sequential per-leg bounce the simulator DOES model.
1837
+ */
1838
+ function pickDispatchVector(deps, legOp) {
1839
+ return deps.vectors.find((v) => v.id === "url-scheme" && v.matrix[legOp]?.support === "yes");
1840
+ }
1841
+ /**
1842
+ * The BOUNCEJSON collapse (§9i / BJ-a / BJ-c): dispatch the whole bounce as ONE
1843
+ * pre-validated `things:///json` update array carrying `[{when:away},{when:back}]`
1844
+ * per item, iterated in the SAME per-item order the sequential loop uses (so
1845
+ * array order == the resulting index order for both insert directions), then run
1846
+ * ONE terminal ordering verify. The app validates the entire array first and
1847
+ * applies it all-or-nothing, so a failed dispatch/verify means NOTHING landed —
1848
+ * there is no partial-progress state to reconcile (contrast the sequential
1849
+ * path's placed/remaining bookkeeping). Every id was already resolved in
1850
+ * {@link runReorder} (a single unresolvable ref would full-abort the batch).
1851
+ */
1852
+ async function runBounceJsonCollapse(deps, params, spec, ctx) {
1853
+ const { coBounce, order, bounceKind, containerUuid, preRanks, txnId, actor, touchedUnnamed, startedAt, options, vector, } = ctx;
1854
+ const { rankKey } = spec;
1855
+ // The json collapse is only ever reached for a fixed-bucket, jsonCollapsible spec
1856
+ // (heading / anytime) — never a dated or per-type bounce — so away/back are the
1857
+ // concrete keywords the spec pins.
1858
+ const away = spec.away;
1859
+ const back = spec.back;
1860
+ const now = deps.now ?? (() => new Date());
1861
+ const abort = (detail) => {
1862
+ auditSummary(deps, params, startedAt, "verify-failed:mismatch", { placed: [] }, {
1863
+ pre: preRanks,
1864
+ txnId,
1865
+ actor,
1866
+ });
1867
+ return {
1868
+ kind: "bounce-aborted",
1869
+ op: "reorder",
1870
+ detail,
1871
+ placed: [],
1872
+ remaining: coBounce,
1873
+ cause: null,
1874
+ };
1875
+ };
1876
+ // Validate-first full-abort means nothing lands, so the concurrent-edit guard
1877
+ // checks EVERY member up front (not per item as the sequential loop does).
1878
+ for (const uuid of coBounce) {
1879
+ const problem = checkStillMember(deps, uuid, bounceKind, containerUuid, now(), null);
1880
+ if (problem !== null) {
1881
+ return abort(`aborted before the json-collapsed bounce: ${uuid} ${problem} (Things was likely ` +
1882
+ "edited concurrently); NOTHING was applied");
1883
+ }
1884
+ }
1885
+ // ONE json array, both legs interleaved per item, in placement order.
1886
+ const ops = order.flatMap((i) => {
1887
+ const uuid = coBounce[i];
1888
+ return [
1889
+ { type: "to-do", operation: "update", id: uuid, attributes: { when: away } },
1890
+ { type: "to-do", operation: "update", id: uuid, attributes: { when: back } },
1891
+ ];
1892
+ });
1893
+ const token = readAuthToken(deps.db);
1894
+ const data = encodeURIComponent(JSON.stringify(ops));
1895
+ const payload = `things:///json?data=${data}${token !== null ? `&auth-token=${encodeURIComponent(token)}` : ""}`;
1896
+ const redactedPayload = `things:///json?data=${data}${token !== null ? "&auth-token=REDACTED" : ""}`;
1897
+ const exec = await vector.execute({
1898
+ vector: "url-scheme",
1899
+ kind: "open-url",
1900
+ payload,
1901
+ redactedPayload,
1902
+ });
1903
+ if (exec.exitCode !== 0 || exec.timedOut === true) {
1904
+ return abort(`the json-collapsed bounce dispatch failed (exit ${exec.exitCode ?? "?"}` +
1905
+ `${exec.timedOut === true ? ", timed out" : ""}) — the array is validate-first ` +
1906
+ "all-or-nothing, so NOTHING was applied");
1907
+ }
1908
+ // ONE terminal ordering verify over the whole run.
1909
+ const verify = await pollUntilVerified(() => evaluateDelta({ mode: "ordering", key: rankKey, sequence: coBounce }, createDbReader(deps.db), {
1910
+ modDates: {},
1911
+ fields: {},
1912
+ }), options.verifyTimeoutMs ?? 4000, deps.poller ?? {});
1913
+ if (verify.kind !== "ok") {
1914
+ return abort("the json-collapsed bounce did not land the requested order — the app validates the " +
1915
+ "whole array first and applies it all-or-nothing (§9i / BJ-c), so NOTHING was applied " +
1916
+ "(no partial-progress repair needed); re-run once Things is idle");
1917
+ }
1918
+ const reader = createDbReader(deps.db);
1919
+ const observed = {};
1920
+ for (const uuid of coBounce)
1921
+ observed[uuid] = reader.rankOf(uuid, rankKey);
1922
+ auditSummary(deps, params, startedAt, "ok", observed, { pre: preRanks, txnId, actor });
1923
+ return {
1924
+ kind: "ok",
1925
+ op: "reorder",
1926
+ uuid: null,
1927
+ observed,
1928
+ vector: "url-scheme",
1929
+ tier: 0,
1930
+ undoToken: txnId,
1931
+ ...(touchedUnnamed.length > 0 && { touched: touchedUnnamed }),
227
1932
  };
228
1933
  }
229
1934
  function legOptions(options, txnId) {
@@ -236,22 +1941,36 @@ function legOptions(options, txnId) {
236
1941
  legs.verifyTimeoutMs = options.verifyTimeoutMs;
237
1942
  if (options.actor !== undefined)
238
1943
  legs.actor = options.actor;
1944
+ // One verify per item round-trip (BOUNCE2): the transient AWAY/BACK legs skip
1945
+ // their own state verify; the orchestrator's placed-position check after each
1946
+ // item is the single per-round-trip assertion. Transport fail-loud is retained.
1947
+ legs.skipVerify = true;
239
1948
  return legs;
240
1949
  }
241
1950
  function resolveContainerUuid(deps, params) {
242
- if (params.scope === "project" || params.scope === "headings") {
1951
+ if (params.scope === "project") {
243
1952
  return resolveProject(deps.db, params.container ?? {}).resolved?.uuid ?? null;
244
1953
  }
245
1954
  if (params.scope === "area") {
246
1955
  return resolveArea(deps.db, params.container ?? {}).resolved?.uuid ?? null;
247
1956
  }
1957
+ // heading / area-someday / container-day / heading-someday: the planner passes a
1958
+ // resolved uuid container directly (a heading uuid, an area uuid, or a project/
1959
+ // area uuid). The `day` bounce is container-less (a global cross-container axis).
1960
+ if (params.scope === "heading" ||
1961
+ params.scope === "area-someday" ||
1962
+ params.scope === "container-day" ||
1963
+ params.scope === "heading-someday") {
1964
+ return params.container?.uuid ?? null;
1965
+ }
248
1966
  return null;
249
1967
  }
250
1968
  /** null = still eligible; otherwise a human-readable problem. */
251
- function checkStillMember(deps, uuid, scope, now) {
1969
+ function checkStillMember(deps, uuid, bounceKind, containerUuid, now, dayPacked) {
252
1970
  const packedToday = encodePackedDate(localToday(now));
253
1971
  const row = deps.db
254
- .prepare("SELECT status, trashed, startBucket, startDate, start, type, area FROM TMTask WHERE uuid = ?")
1972
+ .prepare("SELECT status, trashed, startBucket, startDate, start, type, area, project, heading " +
1973
+ "FROM TMTask WHERE uuid = ?")
255
1974
  .get(uuid);
256
1975
  if (row === undefined)
257
1976
  return "the item no longer exists";
@@ -259,25 +1978,92 @@ function checkStillMember(deps, uuid, scope, now) {
259
1978
  return "the item was trashed";
260
1979
  if (row.status !== 0)
261
1980
  return "the item is no longer open";
262
- if (scope === "projects") {
263
- if (row.type !== 1)
264
- return "the item is not a project";
265
- if (row.area !== null)
266
- return "the project moved into an area";
267
- if (row.start !== 1 || row.startDate !== null) {
268
- return "the project is no longer a plain Anytime project";
1981
+ switch (bounceKind) {
1982
+ case "projects":
1983
+ if (row.type !== 1)
1984
+ return "the item is not a project";
1985
+ if (row.area !== null)
1986
+ return "the project moved into an area";
1987
+ if (row.start !== 1 || row.startDate !== null) {
1988
+ return "the project is no longer a plain Anytime project";
1989
+ }
1990
+ return null;
1991
+ case "heading":
1992
+ if (row.type !== 0)
1993
+ return "the item is not a to-do";
1994
+ if (row.heading !== containerUuid)
1995
+ return "the to-do left the heading";
1996
+ if (row.start !== 1 || row.startDate !== null) {
1997
+ return "the to-do is no longer a plain Anytime child of the heading";
1998
+ }
1999
+ return null;
2000
+ case "area-someday":
2001
+ if (row.type !== 0)
2002
+ return "the item is not a to-do";
2003
+ if (row.area !== containerUuid || row.heading !== null)
2004
+ return "the to-do left the area";
2005
+ if (row.start !== 2 || row.startDate !== null)
2006
+ return "the to-do is no longer a Someday item";
2007
+ return null;
2008
+ case "project-someday":
2009
+ if (row.type !== 0)
2010
+ return "the item is not a to-do";
2011
+ if (row.project !== containerUuid || row.heading !== null) {
2012
+ return "the to-do left the project";
2013
+ }
2014
+ if (row.start !== 2 || row.startDate !== null)
2015
+ return "the to-do is no longer a Someday item";
2016
+ return null;
2017
+ case "anytime":
2018
+ if (row.type !== 0)
2019
+ return "the item is not a to-do";
2020
+ if (row.project !== null || row.area !== null || row.heading !== null) {
2021
+ return "the to-do is no longer loose (it gained a container)";
2022
+ }
2023
+ if (row.start !== 1 || row.startDate !== null) {
2024
+ return "the to-do is no longer a plain Anytime item";
2025
+ }
2026
+ return null;
2027
+ // SIT7 SOMEBACK: a loose someday to-do OR an area-less someday project. Both
2028
+ // front-insert on the shared loose someday index axis via the per-type bounce.
2029
+ case "someday":
2030
+ if (row.type !== 0 && row.type !== 1)
2031
+ return "the item is not a to-do or project";
2032
+ if (row.start !== 2 || row.startDate !== null)
2033
+ return "the item is no longer a Someday item";
2034
+ if (row.type === 0 && (row.project !== null || row.area !== null || row.heading !== null)) {
2035
+ return "the to-do is no longer a loose Someday item (it gained a container)";
2036
+ }
2037
+ if (row.type === 1 && row.area !== null)
2038
+ return "the project moved into an area";
2039
+ return null;
2040
+ case "today":
2041
+ case "evening": {
2042
+ const inToday = row.startDate !== null &&
2043
+ row.startDate <= packedToday &&
2044
+ (row.start === 1 || row.start === 2);
2045
+ if (!inToday)
2046
+ return "the item left the Today list";
2047
+ if (bounceKind === "today" && row.startBucket !== 0)
2048
+ return "the item moved to This Evening";
2049
+ if (bounceKind === "evening" && (row.startBucket !== 1 || row.startDate !== packedToday)) {
2050
+ return "the item left This Evening";
2051
+ }
2052
+ return null;
2053
+ }
2054
+ case "day": {
2055
+ // The dated bounce's members are to-dos (any container) and scheduled project
2056
+ // rows (area-less OR area-direct — SIT5 AREAPROJDAY) sharing the day D on the
2057
+ // startBucket=0 axis. A concurrent edit that re-dates, de-schedules, or
2058
+ // evenings the row ejects it from the group.
2059
+ if (row.type !== 0 && row.type !== 1)
2060
+ return "the item is not a to-do or project";
2061
+ if (row.startBucket !== 0 || row.startDate !== dayPacked) {
2062
+ return "the item left the day-group (re-dated, evening-ed, or de-scheduled)";
2063
+ }
2064
+ return null;
269
2065
  }
270
- return null;
271
- }
272
- const inToday = row.startDate !== null && row.startDate <= packedToday && (row.start === 1 || row.start === 2);
273
- if (!inToday)
274
- return "the item left the Today list";
275
- if (scope === "today" && row.startBucket !== 0)
276
- return "the item moved to This Evening";
277
- if (scope === "evening" && (row.startBucket !== 1 || row.startDate !== packedToday)) {
278
- return "the item left This Evening";
279
2066
  }
280
- return null;
281
2067
  }
282
2068
  function auditSummary(deps, params, startedAt, result, observed, extras) {
283
2069
  const fp = deps.fingerprint();
@@ -290,7 +2076,7 @@ function auditSummary(deps, params, startedAt, result, observed, extras) {
290
2076
  uuid: null,
291
2077
  vector: "url-scheme",
292
2078
  disruption: 0,
293
- invocation: `bounce(${params.scope}) ×${params.uuids.length}`,
2079
+ invocation: `reorder(${params.scope}) ×${params.uuids.length}`,
294
2080
  requested: params,
295
2081
  pre: extras?.pre ?? null,
296
2082
  ...(extras?.txnId !== undefined && { txn: { id: extras.txnId, role: "summary" } }),