things-api 0.17.0 → 0.19.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 (277) hide show
  1. package/README.md +16 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -0
  4. package/deputy/helpers-Info.plist +27 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +22 -0
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +128 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +27 -0
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +137 -0
  14. package/deputy/reader/Info.plist +22 -0
  15. package/deputy/reader/entitlements.plist +45 -0
  16. package/deputy/reader/main.swift +580 -0
  17. package/deputy/src/main.swift +133 -0
  18. package/deputy/src/osascript.swift +102 -0
  19. package/deputy/src/server.swift +373 -0
  20. package/deputy/src/sqlite.swift +121 -0
  21. package/deputy/src/tcc.swift +58 -0
  22. package/dist/audit/schema.d.ts +13 -1
  23. package/dist/audit/schema.js.map +1 -1
  24. package/dist/capability.d.ts +160 -0
  25. package/dist/capability.js +416 -0
  26. package/dist/capability.js.map +1 -0
  27. package/dist/cli/commands/doctor.js +181 -2
  28. package/dist/cli/commands/doctor.js.map +1 -1
  29. package/dist/cli/commands/helpers.d.ts +17 -0
  30. package/dist/cli/commands/helpers.js +275 -0
  31. package/dist/cli/commands/helpers.js.map +1 -0
  32. package/dist/cli/commands/op-result.d.ts +15 -0
  33. package/dist/cli/commands/op-result.js +45 -0
  34. package/dist/cli/commands/op-result.js.map +1 -0
  35. package/dist/cli/commands/reads.js +47 -1
  36. package/dist/cli/commands/reads.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +63 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +262 -109
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +53 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/helpers-check.d.ts +10 -0
  52. package/dist/cli/helpers-check.js +59 -0
  53. package/dist/cli/helpers-check.js.map +1 -0
  54. package/dist/cli/main.js +22 -1
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +22 -2
  65. package/dist/client.js +52 -15
  66. package/dist/client.js.map +1 -1
  67. package/dist/config.d.ts +31 -0
  68. package/dist/config.js +9 -0
  69. package/dist/config.js.map +1 -1
  70. package/dist/contracts.d.ts +6 -2
  71. package/dist/contracts.js +1 -1
  72. package/dist/contracts.js.map +1 -1
  73. package/dist/db/baselines/db-v26.js +8 -4
  74. package/dist/db/baselines/db-v26.js.map +1 -1
  75. package/dist/db/baselines/db-v27.d.ts +42 -0
  76. package/dist/db/baselines/db-v27.js +47 -0
  77. package/dist/db/baselines/db-v27.js.map +1 -0
  78. package/dist/db/baselines/index.js +2 -1
  79. package/dist/db/baselines/index.js.map +1 -1
  80. package/dist/db/locate.d.ts +4 -1
  81. package/dist/db/locate.js +3 -1
  82. package/dist/db/locate.js.map +1 -1
  83. package/dist/db/schema.d.ts +1 -1
  84. package/dist/db/schema.js +9 -0
  85. package/dist/db/schema.js.map +1 -1
  86. package/dist/deputy/bridge-worker.d.ts +1 -0
  87. package/dist/deputy/bridge-worker.js +79 -0
  88. package/dist/deputy/bridge-worker.js.map +1 -0
  89. package/dist/deputy/bridge.d.ts +14 -0
  90. package/dist/deputy/bridge.js +70 -0
  91. package/dist/deputy/bridge.js.map +1 -0
  92. package/dist/deputy/client.d.ts +19 -0
  93. package/dist/deputy/client.js +104 -0
  94. package/dist/deputy/client.js.map +1 -0
  95. package/dist/deputy/db-facade.d.ts +18 -0
  96. package/dist/deputy/db-facade.js +71 -0
  97. package/dist/deputy/db-facade.js.map +1 -0
  98. package/dist/deputy/files.d.ts +1 -0
  99. package/dist/deputy/files.js +16 -0
  100. package/dist/deputy/files.js.map +1 -0
  101. package/dist/deputy/install.d.ts +319 -0
  102. package/dist/deputy/install.js +1239 -0
  103. package/dist/deputy/install.js.map +1 -0
  104. package/dist/deputy/notice.d.ts +6 -0
  105. package/dist/deputy/notice.js +28 -0
  106. package/dist/deputy/notice.js.map +1 -0
  107. package/dist/deputy/notices.d.ts +31 -0
  108. package/dist/deputy/notices.js +94 -0
  109. package/dist/deputy/notices.js.map +1 -0
  110. package/dist/deputy/osa.d.ts +21 -0
  111. package/dist/deputy/osa.js +72 -0
  112. package/dist/deputy/osa.js.map +1 -0
  113. package/dist/deputy/protocol.d.ts +140 -0
  114. package/dist/deputy/protocol.js +143 -0
  115. package/dist/deputy/protocol.js.map +1 -0
  116. package/dist/deputy/routing.d.ts +84 -0
  117. package/dist/deputy/routing.js +422 -0
  118. package/dist/deputy/routing.js.map +1 -0
  119. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  120. package/dist/deputy/shortcuts-exec.js +50 -0
  121. package/dist/deputy/shortcuts-exec.js.map +1 -0
  122. package/dist/diagnose.d.ts +57 -2
  123. package/dist/diagnose.js +105 -3
  124. package/dist/diagnose.js.map +1 -1
  125. package/dist/direct-setup.d.ts +78 -0
  126. package/dist/direct-setup.js +484 -0
  127. package/dist/direct-setup.js.map +1 -0
  128. package/dist/host-access.d.ts +63 -0
  129. package/dist/host-access.js +165 -0
  130. package/dist/host-access.js.map +1 -0
  131. package/dist/index.d.ts +31 -5
  132. package/dist/index.js +23 -1
  133. package/dist/index.js.map +1 -1
  134. package/dist/mcp/server.d.ts +27 -1
  135. package/dist/mcp/server.js +300 -97
  136. package/dist/mcp/server.js.map +1 -1
  137. package/dist/model/mappers.d.ts +12 -3
  138. package/dist/model/mappers.js +6 -1
  139. package/dist/model/mappers.js.map +1 -1
  140. package/dist/model/recurrence.d.ts +5 -1
  141. package/dist/model/recurrence.js +19 -1
  142. package/dist/model/recurrence.js.map +1 -1
  143. package/dist/model/template-projection.d.ts +26 -0
  144. package/dist/model/template-projection.js +113 -0
  145. package/dist/model/template-projection.js.map +1 -0
  146. package/dist/op-result.d.ts +55 -0
  147. package/dist/op-result.js +161 -0
  148. package/dist/op-result.js.map +1 -0
  149. package/dist/read/detail.js +7 -1
  150. package/dist/read/detail.js.map +1 -1
  151. package/dist/read/filter-contract.d.ts +1 -1
  152. package/dist/read/filter-contract.js +4 -0
  153. package/dist/read/filter-contract.js.map +1 -1
  154. package/dist/read/queries.js +8 -1
  155. package/dist/read/queries.js.map +1 -1
  156. package/dist/read/shape.js +4 -0
  157. package/dist/read/shape.js.map +1 -1
  158. package/dist/read/views.d.ts +35 -4
  159. package/dist/read/views.js +120 -18
  160. package/dist/read/views.js.map +1 -1
  161. package/dist/session-grant.d.ts +48 -0
  162. package/dist/session-grant.js +170 -0
  163. package/dist/session-grant.js.map +1 -0
  164. package/dist/surface-copy.d.ts +8 -0
  165. package/dist/surface-copy.js +8 -0
  166. package/dist/surface-copy.js.map +1 -1
  167. package/dist/sync-health.js +6 -2
  168. package/dist/sync-health.js.map +1 -1
  169. package/dist/wizard.d.ts +58 -0
  170. package/dist/wizard.js +167 -0
  171. package/dist/wizard.js.map +1 -0
  172. package/dist/write/accessibility-probe.js +4 -2
  173. package/dist/write/accessibility-probe.js.map +1 -1
  174. package/dist/write/automation-probe.js +5 -1
  175. package/dist/write/automation-probe.js.map +1 -1
  176. package/dist/write/availability.d.ts +1 -1
  177. package/dist/write/availability.js +10 -6
  178. package/dist/write/availability.js.map +1 -1
  179. package/dist/write/batch.d.ts +20 -0
  180. package/dist/write/batch.js +94 -15
  181. package/dist/write/batch.js.map +1 -1
  182. package/dist/write/capabilities.d.ts +9 -0
  183. package/dist/write/capabilities.js +2 -0
  184. package/dist/write/capabilities.js.map +1 -1
  185. package/dist/write/clear-reminder.js +1 -1
  186. package/dist/write/clear-reminder.js.map +1 -1
  187. package/dist/write/commands.d.ts +12 -1
  188. package/dist/write/commands.js +170 -237
  189. package/dist/write/commands.js.map +1 -1
  190. package/dist/write/experimental.d.ts +19 -0
  191. package/dist/write/experimental.js +50 -0
  192. package/dist/write/experimental.js.map +1 -1
  193. package/dist/write/failure-hints.d.ts +3 -1
  194. package/dist/write/failure-hints.js.map +1 -1
  195. package/dist/write/guards.js +19 -2
  196. package/dist/write/guards.js.map +1 -1
  197. package/dist/write/lock.d.ts +16 -13
  198. package/dist/write/lock.js +55 -0
  199. package/dist/write/lock.js.map +1 -1
  200. package/dist/write/make-repeating-project.js +9 -13
  201. package/dist/write/make-repeating-project.js.map +1 -1
  202. package/dist/write/move.js +14 -6
  203. package/dist/write/move.js.map +1 -1
  204. package/dist/write/operations.d.ts +64 -25
  205. package/dist/write/operations.js +7 -0
  206. package/dist/write/operations.js.map +1 -1
  207. package/dist/write/opid.d.ts +14 -0
  208. package/dist/write/opid.js +22 -0
  209. package/dist/write/opid.js.map +1 -1
  210. package/dist/write/param-schema.d.ts +124 -0
  211. package/dist/write/param-schema.js +747 -0
  212. package/dist/write/param-schema.js.map +1 -0
  213. package/dist/write/pipeline.d.ts +78 -2
  214. package/dist/write/pipeline.js +249 -4
  215. package/dist/write/pipeline.js.map +1 -1
  216. package/dist/write/pre-state.d.ts +8 -0
  217. package/dist/write/pre-state.js +62 -17
  218. package/dist/write/pre-state.js.map +1 -1
  219. package/dist/write/promote-clone.js +635 -367
  220. package/dist/write/promote-clone.js.map +1 -1
  221. package/dist/write/reorder.js +95 -29
  222. package/dist/write/reorder.js.map +1 -1
  223. package/dist/write/repeat-anchor.d.ts +30 -0
  224. package/dist/write/repeat-anchor.js +166 -1
  225. package/dist/write/repeat-anchor.js.map +1 -1
  226. package/dist/write/repeat-rule.d.ts +28 -1
  227. package/dist/write/repeat-rule.js +64 -4
  228. package/dist/write/repeat-rule.js.map +1 -1
  229. package/dist/write/resolution-timestamps.d.ts +11 -0
  230. package/dist/write/resolution-timestamps.js +83 -38
  231. package/dist/write/resolution-timestamps.js.map +1 -1
  232. package/dist/write/reversibility.js +4 -0
  233. package/dist/write/reversibility.js.map +1 -1
  234. package/dist/write/template-mutation.d.ts +137 -0
  235. package/dist/write/template-mutation.js +451 -0
  236. package/dist/write/template-mutation.js.map +1 -0
  237. package/dist/write/undo.js +10 -20
  238. package/dist/write/undo.js.map +1 -1
  239. package/dist/write/update-fields.d.ts +151 -0
  240. package/dist/write/update-fields.js +427 -0
  241. package/dist/write/update-fields.js.map +1 -0
  242. package/dist/write/vectors/applescript.js +10 -16
  243. package/dist/write/vectors/applescript.js.map +1 -1
  244. package/dist/write/vectors/registry.d.ts +1 -1
  245. package/dist/write/vectors/registry.js +10 -2
  246. package/dist/write/vectors/registry.js.map +1 -1
  247. package/dist/write/vectors/session-reachability.d.ts +55 -0
  248. package/dist/write/vectors/session-reachability.js +56 -8
  249. package/dist/write/vectors/session-reachability.js.map +1 -1
  250. package/dist/write/vectors/shortcuts.js +7 -16
  251. package/dist/write/vectors/shortcuts.js.map +1 -1
  252. package/dist/write/vectors/simulator.d.ts +16 -1
  253. package/dist/write/vectors/simulator.js +68 -12
  254. package/dist/write/vectors/simulator.js.map +1 -1
  255. package/dist/write/vectors/types.d.ts +124 -1
  256. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  257. package/dist/write/vectors/ui-recipes.js +158 -38
  258. package/dist/write/vectors/ui-recipes.js.map +1 -1
  259. package/dist/write/vectors/ui.d.ts +89 -0
  260. package/dist/write/vectors/ui.js +504 -49
  261. package/dist/write/vectors/ui.js.map +1 -1
  262. package/dist/write/verify/delta.d.ts +21 -0
  263. package/dist/write/verify/delta.js.map +1 -1
  264. package/dist/write/verify/poller.d.ts +13 -0
  265. package/dist/write/verify/poller.js +38 -0
  266. package/dist/write/verify/poller.js.map +1 -1
  267. package/package.json +9 -1
  268. package/schema/envelope.schema.json +5 -3
  269. package/scripts/build-helpers.sh +109 -0
  270. package/scripts/deputy-cert-setup.sh +60 -0
  271. package/skills/things-cli/SKILL.md +12 -5
  272. package/skills/things-cli/references/contracts.md +1 -0
  273. package/skills/things-cli/references/data-model.md +3 -0
  274. package/skills/things-cli/references/errors.md +5 -1
  275. package/skills/things-cli/references/gui.md +1 -0
  276. package/skills/things-cli/references/repeating.md +75 -0
  277. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -0,0 +1,747 @@
1
+ /**
2
+ * The ONE per-operation PARAMETER schema registry — the structural contract every
3
+ * untyped entry point (batch JSONL, MCP `run_operation`/`batch`, a JavaScript
4
+ * caller handing over parsed JSON) is held to BEFORE anything is dispatched.
5
+ *
6
+ * Why it exists (#580): the engine used to duck-test its own params — `todo.add`
7
+ * asked whether `params.project` LOOKED like a container (`.uuid`/`.title`
8
+ * present) and, when a bare uuid STRING was supplied instead, concluded the field
9
+ * was ABSENT. No `list-id` compiled, no destination assertion was emitted, the
10
+ * to-do landed in the Inbox, verification passed against the (empty) delta, and
11
+ * the batch reported success. Malformed input degraded silently into a different,
12
+ * plausible-looking mutation. The same genus lurks anywhere a shape is inferred
13
+ * rather than asserted, so the fix is structural: ONE registry, checked at the
14
+ * two choke points (the batch's static preflight and the mutation pipeline), and
15
+ * duck tests downgraded to belt-and-braces throws.
16
+ *
17
+ * Doctrine (decisions.md 2026-08-17, #491 RRF1 — the exhaustive-map law): the
18
+ * registry is EXHAUSTIVE on both axes. `PARAM_SCHEMAS` is a
19
+ * `{ [K in OperationKind]: … }`, so a new operation breaks compilation until it
20
+ * declares a schema; each op's schema is a `{ [F in keyof Params]-?: FieldSpec }`,
21
+ * so a new parameter breaks compilation until its shape is consciously named.
22
+ * Neither axis can be satisfied by an out-of-date literal.
23
+ *
24
+ * Hand-written, zero-dependency validation in the style of
25
+ * [repeat-rule.ts](./repeat-rule.ts) — NO zod. zod is a consumer-surface
26
+ * dependency confined to `src/mcp/server.ts` (the CLI guest bundle ships neither
27
+ * it nor the MCP SDK; see the lazy loader in `src/index.ts`).
28
+ *
29
+ * The contract:
30
+ * - a REQUIRED field must be present and the right shape; absent or `null` is a
31
+ * structural error;
32
+ * - an OPTIONAL field may be absent, explicitly `null`, or the right shape —
33
+ * any other type is a structural error (never a silent "treat as absent");
34
+ * - UNKNOWN keys are refused by name (a typo is never silently dropped);
35
+ * - a container reference is an OBJECT (`{"uuid": …}` / `{"title": …}`) — a bare
36
+ * string is refused with steering copy rather than normalized. One canonical
37
+ * shape (ALPHA-CONTRACT: no shorthand alias machinery pre-1.0).
38
+ *
39
+ * The check is purely STRUCTURAL. It says nothing about whether a uuid exists,
40
+ * whether two fields contradict each other (that stays with each command's
41
+ * `preRead`), or whether a `"$name"` batch temp-reference is declared (that stays
42
+ * with the batch's own `staticRefError`) — so `$`-prefixed strings pass every
43
+ * plain-string field and every container `uuid`, as they must.
44
+ */
45
+ import { OPERATION_KINDS, WEEKDAYS, } from "./operations.js";
46
+ import { assertEndsBound, assertMonthlyAnchor } from "./repeat-rule.js";
47
+ const str = (describe = "a non-empty string") => ({
48
+ kind: "string",
49
+ optional: false,
50
+ nonEmpty: true,
51
+ describe,
52
+ });
53
+ /** A string field that legitimately accepts "" (free text: notes). */
54
+ const text = (describe = "a string") => ({ kind: "string", optional: false, describe });
55
+ const bool = (describe = "true or false") => ({
56
+ kind: "boolean",
57
+ optional: false,
58
+ describe,
59
+ });
60
+ const int = (min, max, describe) => ({
61
+ kind: "number",
62
+ optional: false,
63
+ integer: true,
64
+ min,
65
+ max,
66
+ describe: describe ?? `a whole number ${min}–${max}`,
67
+ });
68
+ const strArray = (describe = "an array of non-empty strings") => ({
69
+ kind: "stringArray",
70
+ optional: false,
71
+ describe,
72
+ });
73
+ const enumOf = (values) => ({
74
+ kind: "enum",
75
+ optional: false,
76
+ values,
77
+ describe: `one of ${values.join(" | ")}`,
78
+ });
79
+ const container = () => ({
80
+ kind: "container",
81
+ optional: false,
82
+ describe: 'a container reference object — {"uuid": "…"} or {"title": "…"}',
83
+ });
84
+ const custom = (describe, validate) => ({
85
+ kind: "custom",
86
+ optional: false,
87
+ describe,
88
+ validate,
89
+ });
90
+ /** Mark a spec optional (absent or `null` accepted). */
91
+ const opt = (spec) => ({ ...spec, optional: true });
92
+ // --------------------------------------------------------------- type naming
93
+ /** The received-type half of a refusal, in the words a caller reading JSON uses. */
94
+ export function describeType(value) {
95
+ if (value === null)
96
+ return "null";
97
+ if (Array.isArray(value))
98
+ return "an array";
99
+ switch (typeof value) {
100
+ case "string":
101
+ return "a string";
102
+ case "number":
103
+ return "a number";
104
+ case "boolean":
105
+ return "a boolean";
106
+ case "object":
107
+ return "an object";
108
+ default:
109
+ return typeof value;
110
+ }
111
+ }
112
+ function isRecord(value) {
113
+ return typeof value === "object" && value !== null && !Array.isArray(value);
114
+ }
115
+ // --------------------------------------------------------- shared field specs
116
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
117
+ const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?)?$/;
118
+ const REMINDER_RE = /^\d{1,2}:\d{2}$/;
119
+ const WHEN_KEYWORDS = ["today", "evening", "anytime", "someday"];
120
+ /**
121
+ * A scheduling value: a list keyword or a concrete `YYYY-MM-DD`. The wording
122
+ * matches `whenAssertions` (update-fields.ts) verbatim — the two check the same
123
+ * grammar at different depths and must not speak with two voices.
124
+ */
125
+ const WHEN_EXPECTED = "today | evening | anytime | someday | YYYY-MM-DD";
126
+ const whenValue = () => custom(WHEN_EXPECTED, (value, path) => {
127
+ if (typeof value !== "string") {
128
+ return `${path}: expected ${WHEN_EXPECTED} — received ${describeType(value)}`;
129
+ }
130
+ if (WHEN_KEYWORDS.includes(value) || ISO_DATE_RE.test(value))
131
+ return null;
132
+ return value.includes("@")
133
+ ? `${path}: a reminder time is a separate parameter (reminder: "HH:mm"; CLI --reminder), not an @ suffix — received "${value}"`
134
+ : `${path}: expected ${WHEN_EXPECTED} — received "${value}"`;
135
+ });
136
+ const isoDate = () => custom("a date (YYYY-MM-DD)", (value, path) => typeof value === "string" && ISO_DATE_RE.test(value)
137
+ ? null
138
+ : `${path}: expected a date (YYYY-MM-DD) — received ${typeof value === "string" ? `"${value}"` : describeType(value)}`);
139
+ const timestamp = () => custom("a date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:mm)", (value, path) => typeof value === "string" && TIMESTAMP_RE.test(value)
140
+ ? null
141
+ : `${path}: expected a date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:mm) — received ${typeof value === "string" ? `"${value}"` : describeType(value)}`);
142
+ const reminderTime = () => custom("a time of day (HH:mm, 24-hour)", (value, path) => typeof value === "string" && REMINDER_RE.test(value)
143
+ ? null
144
+ : `${path}: expected a time of day (HH:mm, 24-hour) — received ${typeof value === "string" ? `"${value}"` : describeType(value)}`);
145
+ /** An array of distinct weekday names (weekly rules). */
146
+ const weekdayArray = () => custom(`an array of weekday names (${WEEKDAYS.join(" | ")})`, (value, path) => {
147
+ if (!Array.isArray(value)) {
148
+ return `${path}: expected an array of weekday names — received ${describeType(value)}`;
149
+ }
150
+ for (let i = 0; i < value.length; i++) {
151
+ const day = value[i];
152
+ if (typeof day !== "string" || !WEEKDAYS.includes(day)) {
153
+ return `${path}[${i}]: expected one of ${WEEKDAYS.join(" | ")} — received ${typeof day === "string" ? `"${day}"` : describeType(day)}`;
154
+ }
155
+ }
156
+ return null;
157
+ });
158
+ /** Wrap a throwing engine validator (repeat-rule.ts) as a schema `custom`. */
159
+ function wrapThrowing(fn, path) {
160
+ try {
161
+ fn();
162
+ return null;
163
+ }
164
+ catch (err) {
165
+ return `${path}: ${err instanceof Error ? err.message : String(err)}`;
166
+ }
167
+ }
168
+ /** Refuse keys outside a CLOSED nested bag. */
169
+ function unknownNestedKey(value, allowed, path) {
170
+ for (const key of Object.keys(value)) {
171
+ if (value[key] === undefined)
172
+ continue;
173
+ if (!allowed.includes(key)) {
174
+ return `${path}.${key}: not a recognized field here — accepted fields are ${allowed.join(", ")}`;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+ /** MONTHLY anchor: a day-of-month OR an nth-weekday, never a bag holding both. */
180
+ const monthlyAnchor = () => custom('a day-of-month anchor {"day": 1–31 | "last"} or an nth-weekday anchor {"weekday": …, "ordinal": 1–5 | "last"}', (value, path) => {
181
+ if (!isRecord(value)) {
182
+ return `${path}: expected a day-of-month or nth-weekday anchor object — received ${describeType(value)}`;
183
+ }
184
+ const unknown = unknownNestedKey(value, ["day", "weekday", "ordinal"], path);
185
+ if (unknown !== null)
186
+ return unknown;
187
+ return wrapThrowing(() => assertMonthlyAnchor(value, "the anchor"), path);
188
+ });
189
+ /** YEARLY anchor: a month plus the monthly-style day anchor. */
190
+ const yearlyAnchor = () => custom('a month plus a day anchor — {"month": 1–12, "day": …} or {"month": 1–12, "weekday": …, "ordinal": …}', (value, path) => {
191
+ if (!isRecord(value)) {
192
+ return `${path}: expected a month plus a day anchor object — received ${describeType(value)}`;
193
+ }
194
+ const unknown = unknownNestedKey(value, ["month", "day", "weekday", "ordinal"], path);
195
+ if (unknown !== null)
196
+ return unknown;
197
+ const month = value["month"];
198
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
199
+ return `${path}.month: expected a whole number 1–12 — received ${describeType(month)}`;
200
+ }
201
+ return wrapThrowing(() => assertMonthlyAnchor(value, "the anchor"), path);
202
+ });
203
+ /** The "Ends" bound: a discriminated {kind} bag. */
204
+ const endsBound = () => custom('{"kind": "never"} | {"kind": "on-date", "date": "YYYY-MM-DD"} | {"kind": "after", "count": 1–999}', (value, path) => {
205
+ if (!isRecord(value)) {
206
+ return `${path}: expected an end-bound object — received ${describeType(value)}`;
207
+ }
208
+ const kind = value["kind"];
209
+ if (kind !== "never" && kind !== "on-date" && kind !== "after") {
210
+ return `${path}.kind: expected one of never | on-date | after — received ${typeof kind === "string" ? `"${kind}"` : describeType(kind)}`;
211
+ }
212
+ const allowed = kind === "on-date" ? ["kind", "date"] : kind === "after" ? ["kind", "count"] : ["kind"];
213
+ const unknown = unknownNestedKey(value, allowed, path);
214
+ if (unknown !== null)
215
+ return unknown;
216
+ if (kind === "on-date" && typeof value["date"] !== "string") {
217
+ return `${path}.date: expected a date (YYYY-MM-DD) — received ${describeType(value["date"])}`;
218
+ }
219
+ if (kind === "after" && typeof value["count"] !== "number") {
220
+ return `${path}.count: expected a whole number 1–999 — received ${describeType(value["count"])}`;
221
+ }
222
+ return wrapThrowing(() => assertEndsBound(value), path);
223
+ });
224
+ /** Heading placement: exactly one of a position keyword, a `before`, or an `after` anchor. */
225
+ const headingPlacement = () => custom('{"position": "first" | "last"} | {"before": "<heading>"} | {"after": "<heading>"}', (value, path) => {
226
+ if (!isRecord(value)) {
227
+ return `${path}: expected a placement object — received ${describeType(value)}`;
228
+ }
229
+ const unknown = unknownNestedKey(value, ["position", "before", "after"], path);
230
+ if (unknown !== null)
231
+ return unknown;
232
+ const forms = ["position", "before", "after"].filter((k) => value[k] !== undefined && value[k] !== null);
233
+ if (forms.length !== 1) {
234
+ return forms.length === 0
235
+ ? `${path}: expected exactly one of position, before, after — received an object naming none of them`
236
+ : `${path}: expected exactly one of position, before, after — received an object naming ${forms.join(" and ")}`;
237
+ }
238
+ const only = forms[0];
239
+ const v = value[only];
240
+ if (only === "position") {
241
+ if (v !== "first" && v !== "last") {
242
+ return `${path}.position: expected first | last — received ${typeof v === "string" ? `"${v}"` : describeType(v)}`;
243
+ }
244
+ return null;
245
+ }
246
+ if (typeof v !== "string" || v.length === 0) {
247
+ return `${path}.${only}: expected a non-empty string naming a heading — received ${describeType(v)}`;
248
+ }
249
+ return null;
250
+ });
251
+ /** `todo.replace-checklist` items: plain titles, or `{title, completed?}` bags. */
252
+ const checklistItems = () => custom('an array of titles, or {"title": "…", "completed": true|false} objects', (value, path) => {
253
+ if (!Array.isArray(value)) {
254
+ return `${path}: expected an array of checklist items — received ${describeType(value)}`;
255
+ }
256
+ for (let i = 0; i < value.length; i++) {
257
+ const item = value[i];
258
+ const at = `${path}[${i}]`;
259
+ if (typeof item === "string") {
260
+ if (item.length === 0)
261
+ return `${at}: expected a non-empty title — received an empty string`;
262
+ continue;
263
+ }
264
+ if (!isRecord(item)) {
265
+ return `${at}: expected a title string or a {"title": "…"} object — received ${describeType(item)}`;
266
+ }
267
+ const unknown = unknownNestedKey(item, ["title", "completed"], at);
268
+ if (unknown !== null)
269
+ return unknown;
270
+ if (typeof item["title"] !== "string" || item["title"].length === 0) {
271
+ return `${at}.title: expected a non-empty string — received ${describeType(item["title"])}`;
272
+ }
273
+ if (item["completed"] !== undefined && typeof item["completed"] !== "boolean") {
274
+ return `${at}.completed: expected true or false — received ${describeType(item["completed"])}`;
275
+ }
276
+ }
277
+ return null;
278
+ });
279
+ /** `project.add` structured items: ordered `to-do` / `heading` nodes. */
280
+ const projectItems = () => custom('an ordered array of {"kind": "to-do" | "heading", "title": "…"} nodes', (value, path) => {
281
+ if (!Array.isArray(value)) {
282
+ return `${path}: expected an array of project items — received ${describeType(value)}`;
283
+ }
284
+ for (let i = 0; i < value.length; i++) {
285
+ const item = value[i];
286
+ const at = `${path}[${i}]`;
287
+ if (!isRecord(item)) {
288
+ return `${at}: expected a {"kind": "to-do" | "heading", "title": "…"} object — received ${describeType(item)}`;
289
+ }
290
+ const kind = item["kind"];
291
+ if (kind !== "to-do" && kind !== "heading") {
292
+ return `${at}.kind: expected to-do | heading — received ${typeof kind === "string" ? `"${kind}"` : describeType(kind)}`;
293
+ }
294
+ const allowed = kind === "heading"
295
+ ? ["kind", "title"]
296
+ : ["kind", "title", "notes", "when", "deadline", "tags", "checklistItems"];
297
+ const unknown = unknownNestedKey(item, allowed, at);
298
+ if (unknown !== null)
299
+ return unknown;
300
+ if (typeof item["title"] !== "string" || item["title"].length === 0) {
301
+ return `${at}.title: expected a non-empty string — received ${describeType(item["title"])}`;
302
+ }
303
+ if (kind === "heading")
304
+ continue;
305
+ const childSpecs = {
306
+ notes: opt(text()),
307
+ when: opt(whenValue()),
308
+ deadline: opt(isoDate()),
309
+ tags: opt(strArray()),
310
+ checklistItems: opt(strArray()),
311
+ };
312
+ for (const [key, spec] of Object.entries(childSpecs)) {
313
+ const error = checkField(spec, item[key], `${at}.${key}`);
314
+ if (error !== null)
315
+ return error;
316
+ }
317
+ }
318
+ return null;
319
+ });
320
+ // ------------------------------------------------------------- the value check
321
+ /** Validate ONE field value against its spec; null when well-shaped. */
322
+ function checkField(spec, value, path) {
323
+ if (value === undefined || value === null) {
324
+ if (spec.optional)
325
+ return null;
326
+ return `${path}: required — expected ${spec.describe}, but the field is ${value === null ? "null" : "missing"}`;
327
+ }
328
+ switch (spec.kind) {
329
+ case "unvalidated":
330
+ return null;
331
+ case "string": {
332
+ if (typeof value !== "string") {
333
+ return `${path}: expected ${spec.describe} — received ${describeType(value)}`;
334
+ }
335
+ if (spec.nonEmpty === true && value.length === 0) {
336
+ return `${path}: expected ${spec.describe} — received an empty string`;
337
+ }
338
+ return null;
339
+ }
340
+ case "boolean":
341
+ return typeof value === "boolean"
342
+ ? null
343
+ : `${path}: expected ${spec.describe} — received ${describeType(value)}`;
344
+ case "number": {
345
+ if (typeof value !== "number" || Number.isNaN(value)) {
346
+ return `${path}: expected ${spec.describe} — received ${describeType(value)}`;
347
+ }
348
+ if (spec.integer === true && !Number.isInteger(value)) {
349
+ return `${path}: expected ${spec.describe} — received ${value}`;
350
+ }
351
+ if ((spec.min !== undefined && value < spec.min) ||
352
+ (spec.max !== undefined && value > spec.max)) {
353
+ return `${path}: expected ${spec.describe} — received ${value}`;
354
+ }
355
+ return null;
356
+ }
357
+ case "stringArray": {
358
+ if (!Array.isArray(value)) {
359
+ return `${path}: expected ${spec.describe} — received ${describeType(value)}`;
360
+ }
361
+ for (let i = 0; i < value.length; i++) {
362
+ const el = value[i];
363
+ if (typeof el !== "string") {
364
+ return `${path}[${i}]: expected a non-empty string — received ${describeType(el)}`;
365
+ }
366
+ if (el.length === 0) {
367
+ return `${path}[${i}]: expected a non-empty string — received an empty string`;
368
+ }
369
+ }
370
+ return null;
371
+ }
372
+ case "enum": {
373
+ const values = spec.values ?? [];
374
+ if (typeof value !== "string" || !values.includes(value)) {
375
+ return `${path}: expected ${spec.describe} — received ${typeof value === "string" ? `"${value}"` : describeType(value)}`;
376
+ }
377
+ return null;
378
+ }
379
+ case "container":
380
+ return checkContainer(value, path);
381
+ case "custom":
382
+ return spec.validate?.(value, path) ?? null;
383
+ default: {
384
+ const exhaustive = spec.kind;
385
+ return `${path}: unhandled field kind ${String(exhaustive)}`;
386
+ }
387
+ }
388
+ }
389
+ /**
390
+ * A container reference is an OBJECT. A bare string — the #580 shape — is refused
391
+ * with the object spelling it should have used; it is NOT normalized, because one
392
+ * canonical shape is the whole point (a shorthand alias would put the silent
393
+ * degradation back, just one layer up).
394
+ */
395
+ function checkContainer(value, path) {
396
+ const expected = 'a container reference object — {"uuid": "…"} or {"title": "…"}';
397
+ if (typeof value === "string") {
398
+ return (`${path}: expected ${expected} — received a string; to reference it by uuid ` +
399
+ `(or by a batch $ref), use {"uuid": ${JSON.stringify(value)}}`);
400
+ }
401
+ if (!isRecord(value)) {
402
+ return `${path}: expected ${expected} — received ${describeType(value)}`;
403
+ }
404
+ const unknown = unknownNestedKey(value, ["uuid", "title"], path);
405
+ if (unknown !== null)
406
+ return unknown;
407
+ const uuid = value["uuid"];
408
+ const title = value["title"];
409
+ if (uuid === undefined && title === undefined) {
410
+ return `${path}: expected ${expected} — received an object naming neither`;
411
+ }
412
+ for (const [key, v] of [
413
+ ["uuid", uuid],
414
+ ["title", title],
415
+ ]) {
416
+ if (v === undefined)
417
+ continue;
418
+ if (typeof v !== "string") {
419
+ return `${path}.${key}: expected a non-empty string — received ${describeType(v)}`;
420
+ }
421
+ if (v.length === 0) {
422
+ return `${path}.${key}: expected a non-empty string — received an empty string`;
423
+ }
424
+ }
425
+ // A `$name` temp-ref is a legal uuid VALUE here; whether it is DECLARED is the
426
+ // batch preflight's question (staticRefError), not this one's.
427
+ return null;
428
+ }
429
+ /** `{ uuid }` — the shape most verbs take. */
430
+ const UUID_ONLY = { uuid: str("an item uuid") };
431
+ /** The update vocabulary shared by `todo.update` and `project.update`. */
432
+ const UPDATE_FIELDS = {
433
+ title: opt(str()),
434
+ notes: opt(text()),
435
+ appendNotes: opt(text()),
436
+ prependNotes: opt(text()),
437
+ when: opt(whenValue()),
438
+ reminder: opt(reminderTime()),
439
+ deadline: opt(isoDate()),
440
+ };
441
+ /** The calendar-anchor rule vocabulary carried inline by the add-repeating composites. */
442
+ const ADD_REPEATING_RULE = {
443
+ frequency: enumOf(["daily", "weekly", "monthly", "yearly"]),
444
+ interval: int(1, 99, "a whole number 1–99"),
445
+ afterCompletion: opt(bool()),
446
+ weekdays: opt(weekdayArray()),
447
+ monthly: opt(monthlyAnchor()),
448
+ yearly: opt(yearlyAnchor()),
449
+ ends: opt(endsBound()),
450
+ };
451
+ const REPEAT_RULE = {
452
+ uuid: str("an item uuid"),
453
+ ...ADD_REPEATING_RULE,
454
+ reminder: opt(reminderTime()),
455
+ deadline: opt(bool("true or false (deadline the spawned occurrences)")),
456
+ startDaysEarlier: opt(int(0, 366, "a whole number of days ≥ 0")),
457
+ next: opt(isoDate()),
458
+ };
459
+ const SET_DATES = {
460
+ uuid: str("an item uuid"),
461
+ completedAt: opt(timestamp()),
462
+ createdAt: opt(timestamp()),
463
+ };
464
+ const NAME_OR_UUID = { target: str("a uuid or a unique title") };
465
+ const CLONE = {
466
+ uuid: str("an item uuid"),
467
+ title: opt(str()),
468
+ preserveCreated: opt(bool()),
469
+ };
470
+ const SET_TAGS = {
471
+ uuid: str("an item uuid"),
472
+ tags: strArray("an array of tag titles (an empty array clears all tags)"),
473
+ };
474
+ /** No parameters at all — any key is unknown. */
475
+ const NO_PARAMS = {};
476
+ const REORDER_SCOPES = [
477
+ "today",
478
+ "evening",
479
+ "project",
480
+ "area",
481
+ "inbox",
482
+ "someday",
483
+ "projects",
484
+ "heading",
485
+ "area-someday",
486
+ "anytime",
487
+ "container-day",
488
+ "day",
489
+ "heading-someday",
490
+ "tomorrow",
491
+ "upcoming",
492
+ ];
493
+ /**
494
+ * The registry. EXHAUSTIVE on both axes by construction: a new
495
+ * {@link OperationKind} breaks compilation here, and a new parameter on any
496
+ * operation breaks compilation in that operation's entry.
497
+ */
498
+ export const PARAM_SCHEMAS = {
499
+ "todo.add": {
500
+ title: str(),
501
+ notes: opt(text()),
502
+ when: opt(whenValue()),
503
+ reminder: opt(reminderTime()),
504
+ deadline: opt(isoDate()),
505
+ tags: opt(strArray("an array of tag titles")),
506
+ checklistItems: opt(strArray("an array of checklist item titles")),
507
+ project: opt(container()),
508
+ area: opt(container()),
509
+ heading: opt(str("a heading title inside the destination project")),
510
+ createdAt: opt(timestamp()),
511
+ completedAt: opt(timestamp()),
512
+ },
513
+ "todo.update": { uuid: str("an item uuid"), ...UPDATE_FIELDS },
514
+ "todo.complete": UUID_ONLY,
515
+ "todo.cancel": UUID_ONLY,
516
+ "todo.reopen": UUID_ONLY,
517
+ "todo.move": {
518
+ uuid: str("an item uuid"),
519
+ project: opt(container()),
520
+ area: opt(container()),
521
+ heading: opt(str("a heading title inside the destination project")),
522
+ inbox: opt(bool()),
523
+ noHeading: opt(bool()),
524
+ loose: opt(bool()),
525
+ },
526
+ "todo.set-tags": SET_TAGS,
527
+ "todo.replace-checklist": { uuid: str("an item uuid"), items: checklistItems() },
528
+ "todo.edit-checklist-item": {
529
+ uuid: str("an item uuid"),
530
+ action: enumOf(["add", "remove", "check", "uncheck", "rename", "move"]),
531
+ title: opt(str()),
532
+ index: opt(int(1, 10_000, "a whole number ≥ 1 (1-based position)")),
533
+ at: opt(int(1, 10_000, "a whole number ≥ 1 (1-based position)")),
534
+ to: opt(int(1, 10_000, "a whole number ≥ 1 (1-based position)")),
535
+ newTitle: opt(str()),
536
+ },
537
+ "todo.delete": UUID_ONLY,
538
+ "project.add": {
539
+ title: str(),
540
+ notes: opt(text()),
541
+ area: opt(container()),
542
+ when: opt(whenValue()),
543
+ deadline: opt(isoDate()),
544
+ todos: opt(strArray("an array of child to-do titles")),
545
+ items: opt(projectItems()),
546
+ createdAt: opt(timestamp()),
547
+ completedAt: opt(timestamp()),
548
+ },
549
+ "project.update": { uuid: str("a project uuid or unique title"), ...UPDATE_FIELDS },
550
+ "project.complete": {
551
+ uuid: str("a project uuid or unique title"),
552
+ children: enumOf(["require-resolved", "auto-complete"]),
553
+ },
554
+ "project.delete": { uuid: str("a project uuid or unique title") },
555
+ "area.add": { title: str(), tags: opt(strArray("an array of tag titles")) },
556
+ "area.delete": NAME_OR_UUID,
557
+ "tag.add": { title: str(), parent: opt(str("an existing parent tag title")) },
558
+ "tag.delete": NAME_OR_UUID,
559
+ "trash.empty": NO_PARAMS,
560
+ reorder: {
561
+ scope: enumOf(REORDER_SCOPES),
562
+ container: opt(container()),
563
+ uuids: strArray("an array of item uuids, top-first"),
564
+ named: opt(strArray("an array of item uuids (a subset of uuids)")),
565
+ strategy: opt(enumOf(["native", "bounce"])),
566
+ },
567
+ "todo.duplicate": UUID_ONLY,
568
+ "area.update": {
569
+ target: str("a uuid or a unique title"),
570
+ title: opt(str()),
571
+ tags: opt(strArray("an array of existing tag titles")),
572
+ },
573
+ "tag.update": {
574
+ target: str("a uuid or a unique title"),
575
+ title: opt(str()),
576
+ parent: opt(str("an existing parent tag title")),
577
+ unnest: opt(bool()),
578
+ shortcut: opt(str("a single character")),
579
+ clearShortcut: opt(bool()),
580
+ },
581
+ "project.move": {
582
+ uuid: str("a project uuid or unique title"),
583
+ area: opt(container()),
584
+ noArea: opt(bool()),
585
+ },
586
+ "todo.restore": UUID_ONLY,
587
+ "project.duplicate": { uuid: str("a project uuid or unique title") },
588
+ "project.cancel": {
589
+ uuid: str("a project uuid or unique title"),
590
+ children: enumOf(["require-resolved", "auto-cancel"]),
591
+ },
592
+ "project.reopen": { uuid: str("a project uuid or unique title") },
593
+ "project.restore": { uuid: str("a project uuid or unique title") },
594
+ "project.set-tags": {
595
+ uuid: str("a project uuid or unique title"),
596
+ tags: strArray("an array of tag titles (an empty array clears all tags)"),
597
+ },
598
+ "todo.set-dates": SET_DATES,
599
+ "project.set-dates": SET_DATES,
600
+ "project.add-heading": { project: container(), title: str() },
601
+ "project.rename-heading": { uuid: str("a heading uuid"), title: str() },
602
+ "project.archive-heading": {
603
+ uuid: str("a heading uuid"),
604
+ children: opt(enumOf(["complete", "cancel", "reparent"])),
605
+ },
606
+ "project.unarchive-heading": { uuid: str("a heading uuid"), restoreChildren: opt(bool()) },
607
+ "project.promote-heading": { uuid: str("a heading uuid") },
608
+ "project.move-heading": {
609
+ project: container(),
610
+ headings: strArray("an array of heading uuids, in the order they should land"),
611
+ placement: headingPlacement(),
612
+ },
613
+ "project.move-heading-to-project": {
614
+ project: container(),
615
+ heading: str("a heading title or uuid inside the source project"),
616
+ toProject: container(),
617
+ },
618
+ "project.dissolve-heading": { uuid: str("a heading uuid") },
619
+ "todo.clear-dated-reminder": UUID_ONLY,
620
+ "todo.make-repeating": REPEAT_RULE,
621
+ "todo.reschedule-repeat": REPEAT_RULE,
622
+ "todo.pause-repeat": UUID_ONLY,
623
+ "todo.resume-repeat": UUID_ONLY,
624
+ "todo.create-next-copy": UUID_ONLY,
625
+ "todo.convert-to-project": UUID_ONLY,
626
+ "project.reschedule-repeat": REPEAT_RULE,
627
+ "project.pause-repeat": { uuid: str("a project uuid or unique title") },
628
+ "project.resume-repeat": { uuid: str("a project uuid or unique title") },
629
+ "area.reorder": {
630
+ target: str("an area uuid or unique title"),
631
+ before: opt(str("an area uuid or unique title")),
632
+ after: opt(str("an area uuid or unique title")),
633
+ position: opt(enumOf(["first", "last"])),
634
+ },
635
+ "project.make-repeating": REPEAT_RULE,
636
+ "project.add-repeating": {
637
+ ...ADD_REPEATING_RULE,
638
+ title: str(),
639
+ notes: opt(text()),
640
+ area: opt(container()),
641
+ when: opt(whenValue()),
642
+ deadline: opt(isoDate()),
643
+ todos: opt(strArray("an array of child to-do titles")),
644
+ items: opt(projectItems()),
645
+ createdAt: opt(timestamp()),
646
+ },
647
+ "todo.add-repeating": {
648
+ ...ADD_REPEATING_RULE,
649
+ title: str(),
650
+ notes: opt(text()),
651
+ when: opt(whenValue()),
652
+ reminder: opt(reminderTime()),
653
+ deadline: opt(isoDate()),
654
+ startDaysEarlier: opt(int(0, 366, "a whole number of days ≥ 0")),
655
+ tags: opt(strArray("an array of tag titles")),
656
+ checklistItems: opt(strArray("an array of checklist item titles")),
657
+ project: opt(container()),
658
+ area: opt(container()),
659
+ heading: opt(str("a heading title inside the destination project")),
660
+ createdAt: opt(timestamp()),
661
+ },
662
+ "todo.clone": CLONE,
663
+ "project.clone": CLONE,
664
+ "log-now": NO_PARAMS,
665
+ };
666
+ // ------------------------------------------------------------------ the check
667
+ /**
668
+ * The STRUCTURAL parameter check for one operation. Returns the FIRST refusal
669
+ * detail (JSON path · expected shape · received type), or null when the bag is
670
+ * well-shaped. Never throws; never mutates or normalizes the params.
671
+ */
672
+ export function validateOperationParams(op, params) {
673
+ if (!isRecord(params)) {
674
+ return `params: expected an object of operation parameters — received ${describeType(params)}`;
675
+ }
676
+ const schema = PARAM_SCHEMAS[op];
677
+ const known = Object.keys(PARAM_SCHEMAS[op]);
678
+ for (const key of Object.keys(params)) {
679
+ if (params[key] === undefined)
680
+ continue;
681
+ if (!Object.hasOwn(schema, key)) {
682
+ return known.length === 0
683
+ ? `params.${key}: "${op}" takes no parameters — nothing was applied for this field`
684
+ : `params.${key}: not a parameter of "${op}" — accepted parameters are ${known.join(", ")}`;
685
+ }
686
+ }
687
+ for (const key of known) {
688
+ const spec = schema[key];
689
+ if (spec === undefined)
690
+ continue;
691
+ const error = checkField(spec, params[key], `params.${key}`);
692
+ if (error !== null)
693
+ return error;
694
+ }
695
+ return null;
696
+ }
697
+ /** A structural parameter refusal — an input-contract error, never an app failure. */
698
+ export class ParamSchemaError extends RangeError {
699
+ /** The operation whose parameter bag was refused. */
700
+ op;
701
+ constructor(op, detail) {
702
+ super(detail);
703
+ this.name = "ParamSchemaError";
704
+ this.op = op;
705
+ }
706
+ }
707
+ /** {@link validateOperationParams}, as a throwing assertion for the pipeline. */
708
+ export function assertOperationParams(op, params) {
709
+ const detail = validateOperationParams(op, params);
710
+ if (detail !== null)
711
+ throw new ParamSchemaError(op, detail);
712
+ }
713
+ /**
714
+ * The per-op parameter summary `things capabilities` publishes, so the promise
715
+ * that the catalog carries "operation kinds AND their parameter shapes" is met by
716
+ * data rather than by prose.
717
+ */
718
+ export function paramSummary(op) {
719
+ const schema = PARAM_SCHEMAS[op];
720
+ return Object.entries(schema).map(([name, spec]) => {
721
+ const row = {
722
+ name,
723
+ kind: spec.kind,
724
+ optional: spec.optional,
725
+ expects: spec.describe,
726
+ };
727
+ if (spec.values !== undefined)
728
+ row.values = spec.values;
729
+ return row;
730
+ });
731
+ }
732
+ /** Runtime completeness law: every cataloged operation declares a schema. */
733
+ export function operationsMissingSchema() {
734
+ return OPERATION_KINDS.filter((op) => !Object.hasOwn(PARAM_SCHEMAS, op));
735
+ }
736
+ /** Every `unvalidated` escape in the registry, with its written reason (currently none). */
737
+ export function unvalidatedFields() {
738
+ const out = [];
739
+ for (const op of OPERATION_KINDS) {
740
+ for (const [field, spec] of Object.entries(PARAM_SCHEMAS[op])) {
741
+ if (spec.kind === "unvalidated")
742
+ out.push({ op, field, reason: spec.reason ?? "" });
743
+ }
744
+ }
745
+ return out;
746
+ }
747
+ //# sourceMappingURL=param-schema.js.map