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