@cleocode/core 2026.9.25 → 2026.9.26

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/dist/admin/import-tasks.d.ts.map +1 -1
  2. package/dist/admin/import-tasks.js +5 -2
  3. package/dist/admin/import-tasks.js.map +1 -1
  4. package/dist/admin/import.d.ts.map +1 -1
  5. package/dist/admin/import.js +7 -2
  6. package/dist/admin/import.js.map +1 -1
  7. package/dist/build-provenance.json +2 -2
  8. package/dist/cloud/index.d.ts +1 -0
  9. package/dist/cloud/index.d.ts.map +1 -1
  10. package/dist/cloud/index.js +1 -0
  11. package/dist/cloud/index.js.map +1 -1
  12. package/dist/cloud/nexus-auth.d.ts +26 -1
  13. package/dist/cloud/nexus-auth.d.ts.map +1 -1
  14. package/dist/cloud/nexus-auth.js +61 -32
  15. package/dist/cloud/nexus-auth.js.map +1 -1
  16. package/dist/cloud/nexus-credentials.js +2 -2
  17. package/dist/cloud/nexus-credentials.js.map +1 -1
  18. package/dist/cloud/nexus-device.d.ts +809 -0
  19. package/dist/cloud/nexus-device.d.ts.map +1 -0
  20. package/dist/cloud/nexus-device.js +1680 -0
  21. package/dist/cloud/nexus-device.js.map +1 -0
  22. package/dist/cloud/nexus-enrol.d.ts +210 -0
  23. package/dist/cloud/nexus-enrol.d.ts.map +1 -0
  24. package/dist/cloud/nexus-enrol.js +1023 -0
  25. package/dist/cloud/nexus-enrol.js.map +1 -0
  26. package/dist/cloud/nexus-link.d.ts +3 -0
  27. package/dist/cloud/nexus-link.d.ts.map +1 -1
  28. package/dist/cloud/nexus-link.js +38 -4
  29. package/dist/cloud/nexus-link.js.map +1 -1
  30. package/dist/cloud/signing.d.ts +20 -0
  31. package/dist/cloud/signing.d.ts.map +1 -1
  32. package/dist/cloud/signing.js +14 -0
  33. package/dist/cloud/signing.js.map +1 -1
  34. package/dist/crypto/credentials.d.ts +61 -0
  35. package/dist/crypto/credentials.d.ts.map +1 -1
  36. package/dist/crypto/credentials.js +154 -58
  37. package/dist/crypto/credentials.js.map +1 -1
  38. package/dist/docs/export-document.js +830 -190
  39. package/dist/docs/export-document.js.map +3 -3
  40. package/dist/doctor/dependency-cycles.d.ts +41 -0
  41. package/dist/doctor/dependency-cycles.d.ts.map +1 -0
  42. package/dist/doctor/dependency-cycles.js +39 -0
  43. package/dist/doctor/dependency-cycles.js.map +1 -0
  44. package/dist/doctor/index.d.ts +2 -0
  45. package/dist/doctor/index.d.ts.map +1 -1
  46. package/dist/doctor/index.js +1 -0
  47. package/dist/doctor/index.js.map +1 -1
  48. package/dist/doctor/row-identity.d.ts +11 -0
  49. package/dist/doctor/row-identity.d.ts.map +1 -1
  50. package/dist/doctor/row-identity.js +67 -9
  51. package/dist/doctor/row-identity.js.map +1 -1
  52. package/dist/error-catalog.d.ts.map +1 -1
  53. package/dist/error-catalog.js +4 -0
  54. package/dist/error-catalog.js.map +1 -1
  55. package/dist/error-registry.d.ts.map +1 -1
  56. package/dist/error-registry.js +8 -0
  57. package/dist/error-registry.js.map +1 -1
  58. package/dist/llm/plugin-facade.js +857 -217
  59. package/dist/llm/plugin-facade.js.map +3 -3
  60. package/dist/nexus/workspace.d.ts.map +1 -1
  61. package/dist/nexus/workspace.js +54 -4
  62. package/dist/nexus/workspace.js.map +1 -1
  63. package/dist/orchestrate/lifecycle-ops.js +1 -1
  64. package/dist/orchestrate/lifecycle-ops.js.map +1 -1
  65. package/dist/orchestrate/pivot.d.ts.map +1 -1
  66. package/dist/orchestrate/pivot.js +4 -2
  67. package/dist/orchestrate/pivot.js.map +1 -1
  68. package/dist/orchestrate/query-ops.d.ts.map +1 -1
  69. package/dist/orchestrate/query-ops.js +3 -3
  70. package/dist/orchestrate/query-ops.js.map +1 -1
  71. package/dist/orchestrate/spawn-ops.js +1 -1
  72. package/dist/orchestrate/spawn-ops.js.map +1 -1
  73. package/dist/orchestration/index.js +1 -1
  74. package/dist/orchestration/index.js.map +1 -1
  75. package/dist/orchestration/lead-rollup.d.ts.map +1 -1
  76. package/dist/orchestration/lead-rollup.js +3 -3
  77. package/dist/orchestration/lead-rollup.js.map +1 -1
  78. package/dist/orchestration/parallel.js +1 -1
  79. package/dist/orchestration/parallel.js.map +1 -1
  80. package/dist/orchestration/wave-topic.d.ts +4 -2
  81. package/dist/orchestration/wave-topic.d.ts.map +1 -1
  82. package/dist/orchestration/wave-topic.js +6 -4
  83. package/dist/orchestration/wave-topic.js.map +1 -1
  84. package/dist/orchestration/waves.d.ts +2 -1
  85. package/dist/orchestration/waves.d.ts.map +1 -1
  86. package/dist/orchestration/waves.js +4 -3
  87. package/dist/orchestration/waves.js.map +1 -1
  88. package/dist/phases/index.d.ts.map +1 -1
  89. package/dist/phases/index.js +4 -2
  90. package/dist/phases/index.js.map +1 -1
  91. package/dist/selfimprove/fix-gen.js +856 -216
  92. package/dist/selfimprove/fix-gen.js.map +3 -3
  93. package/dist/session/engine-ops.d.ts.map +1 -1
  94. package/dist/session/engine-ops.js +77 -49
  95. package/dist/session/engine-ops.js.map +1 -1
  96. package/dist/sessions/focus-state-store.d.ts +69 -23
  97. package/dist/sessions/focus-state-store.d.ts.map +1 -1
  98. package/dist/sessions/focus-state-store.js +113 -27
  99. package/dist/sessions/focus-state-store.js.map +1 -1
  100. package/dist/sessions/handoff.js +1 -1
  101. package/dist/sessions/handoff.js.map +1 -1
  102. package/dist/sessions/index.d.ts.map +1 -1
  103. package/dist/sessions/index.js +24 -13
  104. package/dist/sessions/index.js.map +1 -1
  105. package/dist/sessions/terminal-identity.d.ts +165 -17
  106. package/dist/sessions/terminal-identity.d.ts.map +1 -1
  107. package/dist/sessions/terminal-identity.js +341 -32
  108. package/dist/sessions/terminal-identity.js.map +1 -1
  109. package/dist/skills/orchestrator/startup.js +2 -2
  110. package/dist/skills/orchestrator/startup.js.map +1 -1
  111. package/dist/snapshot/index.d.ts +4 -0
  112. package/dist/snapshot/index.d.ts.map +1 -1
  113. package/dist/snapshot/index.js +50 -3
  114. package/dist/snapshot/index.js.map +1 -1
  115. package/dist/store/db-helpers.d.ts +15 -2
  116. package/dist/store/db-helpers.d.ts.map +1 -1
  117. package/dist/store/db-helpers.js +84 -3
  118. package/dist/store/db-helpers.js.map +1 -1
  119. package/dist/store/dependency-cycles.d.ts +164 -0
  120. package/dist/store/dependency-cycles.d.ts.map +1 -0
  121. package/dist/store/dependency-cycles.js +360 -0
  122. package/dist/store/dependency-cycles.js.map +1 -0
  123. package/dist/store/display-id-alias.d.ts +93 -37
  124. package/dist/store/display-id-alias.d.ts.map +1 -1
  125. package/dist/store/display-id-alias.js +590 -115
  126. package/dist/store/display-id-alias.js.map +1 -1
  127. package/dist/store/dual-scope-db.d.ts.map +1 -1
  128. package/dist/store/dual-scope-db.js +34 -7
  129. package/dist/store/dual-scope-db.js.map +1 -1
  130. package/dist/store/exodus/migrate.d.ts.map +1 -1
  131. package/dist/store/exodus/migrate.js +8 -1
  132. package/dist/store/exodus/migrate.js.map +1 -1
  133. package/dist/store/global-salt.d.ts +35 -0
  134. package/dist/store/global-salt.d.ts.map +1 -1
  135. package/dist/store/global-salt.js +136 -16
  136. package/dist/store/global-salt.js.map +1 -1
  137. package/dist/store/migration-sqlite.d.ts.map +1 -1
  138. package/dist/store/migration-sqlite.js +19 -11
  139. package/dist/store/migration-sqlite.js.map +1 -1
  140. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  141. package/dist/store/portable-bundle-scan.js +6 -0
  142. package/dist/store/portable-bundle-scan.js.map +1 -1
  143. package/dist/store/row-identity-registry.d.ts.map +1 -1
  144. package/dist/store/row-identity-registry.js +11 -3
  145. package/dist/store/row-identity-registry.js.map +1 -1
  146. package/dist/store/row-identity.d.ts +91 -1
  147. package/dist/store/row-identity.d.ts.map +1 -1
  148. package/dist/store/row-identity.js +276 -7
  149. package/dist/store/row-identity.js.map +1 -1
  150. package/dist/store/safety-data-accessor.d.ts +3 -2
  151. package/dist/store/safety-data-accessor.d.ts.map +1 -1
  152. package/dist/store/safety-data-accessor.js +5 -2
  153. package/dist/store/safety-data-accessor.js.map +1 -1
  154. package/dist/store/session-store.d.ts +63 -3
  155. package/dist/store/session-store.d.ts.map +1 -1
  156. package/dist/store/session-store.js +121 -8
  157. package/dist/store/session-store.js.map +1 -1
  158. package/dist/store/split-brain-import.d.ts.map +1 -1
  159. package/dist/store/split-brain-import.js +36 -0
  160. package/dist/store/split-brain-import.js.map +1 -1
  161. package/dist/store/sqlite-data-accessor.d.ts +50 -3
  162. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  163. package/dist/store/sqlite-data-accessor.js +246 -13
  164. package/dist/store/sqlite-data-accessor.js.map +1 -1
  165. package/dist/store/tasks-sqlite.d.ts.map +1 -1
  166. package/dist/store/tasks-sqlite.js +14 -2
  167. package/dist/store/tasks-sqlite.js.map +1 -1
  168. package/dist/store/umbrella-data-accessor.d.ts +3 -2
  169. package/dist/store/umbrella-data-accessor.d.ts.map +1 -1
  170. package/dist/store/umbrella-data-accessor.js +5 -2
  171. package/dist/store/umbrella-data-accessor.js.map +1 -1
  172. package/dist/system/health.d.ts +2 -0
  173. package/dist/system/health.d.ts.map +1 -1
  174. package/dist/system/health.js +54 -0
  175. package/dist/system/health.js.map +1 -1
  176. package/dist/task-work/index.d.ts.map +1 -1
  177. package/dist/task-work/index.js +9 -6
  178. package/dist/task-work/index.js.map +1 -1
  179. package/dist/tasks/analyze.d.ts.map +1 -1
  180. package/dist/tasks/analyze.js +3 -2
  181. package/dist/tasks/analyze.js.map +1 -1
  182. package/dist/tasks/evidence.d.ts +19 -1
  183. package/dist/tasks/evidence.d.ts.map +1 -1
  184. package/dist/tasks/evidence.js +79 -18
  185. package/dist/tasks/evidence.js.map +1 -1
  186. package/dist/tasks/infer-add-params.js +1 -1
  187. package/dist/tasks/infer-add-params.js.map +1 -1
  188. package/dist/tasks/task-context.d.ts.map +1 -1
  189. package/dist/tasks/task-context.js +3 -1
  190. package/dist/tasks/task-context.js.map +1 -1
  191. package/dist/tasks/task-import.d.ts.map +1 -1
  192. package/dist/tasks/task-import.js +11 -3
  193. package/dist/tasks/task-import.js.map +1 -1
  194. package/dist/tasks/task-next.d.ts +5 -1
  195. package/dist/tasks/task-next.d.ts.map +1 -1
  196. package/dist/tasks/task-next.js +7 -2
  197. package/dist/tasks/task-next.js.map +1 -1
  198. package/dist/validation/engine-ops.d.ts.map +1 -1
  199. package/dist/validation/engine-ops.js +3 -2
  200. package/dist/validation/engine-ops.js.map +1 -1
  201. package/dist/workgraph/scaffold-apply.d.ts.map +1 -1
  202. package/dist/workgraph/scaffold-apply.js +14 -5
  203. package/dist/workgraph/scaffold-apply.js.map +1 -1
  204. package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/migration.sql +76 -0
  205. package/migrations/drizzle-cleo-project/20260930160000_t12886-dependency-cycle-guard/revert.sql +7 -0
  206. package/package.json +13 -13
  207. package/templates/CLEO-INJECTION.md +2 -2
  208. package/templates/CLEO-REFERENCE.md +1 -1
@@ -69,54 +69,98 @@
69
69
  * @task T12750
70
70
  * @epic T12323
71
71
  */
72
- import { ExitCode } from '@cleocode/contracts';
72
+ import { ExitCode, } from '@cleocode/contracts';
73
+ import { getTableColumns } from 'drizzle-orm';
73
74
  import { CleoError } from '../errors.js';
74
- import { BIRTH_FP_COLUMN, fillTableUids, markRowIdentityShared, mintRowUid, naturalRowUid, ROW_IDENTITY, ROW_IDENTITY_META_TABLE, registerRowUidFunction, rekeyedChildUid, rowIdentitySpec, UID_COLUMN, } from './row-identity.js';
75
- import { advanceTaskIdSequence, clearRowUidNative, deleteDisplayIdAliasNative, deleteQuarantineNative, deleteRowIdentityMetaNative, insertDisplayIdAliasNative, insertIdentityRowNative, insertQuarantineNative, insertUidAliasNative, rekeyQuarantineNative, renameTaskDisplayIdNative, rewriteStoredRefUidNative, setNaturalUidNative, setRowUidNative, writeRowIdentityMetaNative, } from './sqlite-data-accessor.js';
75
+ import { exceedsMaxDepth } from '../tasks/hierarchy.js';
76
+ import { resolveHierarchyPolicy } from '../tasks/hierarchy-policy.js';
77
+ import { AC_UID_GRAVEYARD, BIRTH_FP_COLUMN, fillTableUids, markRowIdentityShared, mintRowUid, naturalRowUid, ROW_IDENTITY, registerRowUidFunction, rekeyedChildUid, rowIdentitySpec, UID_COLUMN, } from './row-identity.js';
78
+ import { advanceTaskIdSequence, clearRowUidNative, deleteDisplayIdAliasNative, deleteQuarantineNative, insertDisplayIdAliasNative, insertIdentityRowNative, insertQuarantineNative, insertUidAliasNative, rekeyQuarantineNative, renameTaskDisplayIdNative, rewriteStoredRefUidNative, setNaturalUidNative, setRowUidNative, } from './sqlite-data-accessor.js';
79
+ import { compareHlc as compareSyncHlc, encodeHlc, parseHlc } from './sync/hlc.js';
80
+ import { tasks as tasksTable } from './tasks-schema.js';
81
+ import { insertTaskSchema } from './validation-schemas.js';
76
82
  /** Physical name of the display-id alias table. */
77
83
  export const DISPLAY_ID_ALIAS_TABLE = 'tasks_display_id_aliases';
78
84
  /** Physical name of the uid re-key alias table. */
79
85
  export const UID_ALIAS_TABLE = 'tasks_uid_aliases';
80
86
  /** Physical name of the identity quarantine (local-only). */
81
87
  export const QUARANTINE_TABLE = 'tasks_identity_quarantine';
82
- const HLC_RE = /^(\d{15})\.(\d{6})\.(.+)$/;
88
+ // ---- HLC -------------------------------------------------------------------
89
+ //
90
+ // HLC values are the change journal's (T12342): the wire string `Hlc` in
91
+ // `@cleocode/contracts` (`PPPPPPPPPPPPP-CCCCCC-<replica uuid>`), decoded and
92
+ // ordered by `store/sync/hlc.ts`. String order is HLC order.
83
93
  /**
84
- * Encode an HLC value.
94
+ * Total order of two encoded HLC values (`Hlc` wire strings).
85
95
  *
86
- * @param hlc - The value.
87
- * @returns Its sortable string form.
96
+ * @param a - One value.
97
+ * @param b - The other.
98
+ * @returns Negative, zero or positive.
99
+ * @throws HlcError when a value is not an encoded HLC.
88
100
  */
89
- export function encodeHlc(hlc) {
90
- return `${String(hlc.physicalMs).padStart(15, '0')}.${String(hlc.counter).padStart(6, '0')}.${hlc.node}`;
101
+ export function compareHlc(a, b) {
102
+ return compareSyncHlc(storedHlc(a), storedHlc(b));
103
+ }
104
+ /** Physical milliseconds of an encoded HLC. */
105
+ function hlcMs(value) {
106
+ return storedHlc(value).phys;
91
107
  }
108
+ /** The format 9.25 wrote with the uid flag on: `<ms 15>.<counter 6>.<node>`. */
109
+ const LEGACY_HLC_RE = /^(\d{15})\.(\d{6})\.(.+)$/;
92
110
  /**
93
- * Parse an encoded HLC value.
111
+ * Decode an HLC this module stored. The journal format is the only one
112
+ * written; a value in the 9.25 format (written only with
113
+ * `CLEO_ROW_UID_FILL=1`) is still read, ordered by the same
114
+ * `(physical, counter, node)` tuple, so an old alias never breaks a re-mint.
94
115
  *
95
- * @param value - Encoded HLC.
96
- * @returns The value.
97
- * @throws Error when the value is not an encoded HLC.
116
+ * @throws HlcError when the value is in neither format.
98
117
  */
99
- export function parseHlc(value) {
100
- const m = HLC_RE.exec(value);
101
- if (!m)
102
- throw new Error(`not an HLC value: ${value}`);
103
- return { physicalMs: Number(m[1]), counter: Number(m[2]), node: m[3] };
118
+ function storedHlc(value) {
119
+ const legacy = LEGACY_HLC_RE.exec(value);
120
+ if (legacy) {
121
+ return { phys: Number(legacy[1]), ctr: Number(legacy[2]), replica: legacy[3] };
122
+ }
123
+ return parseHlc(value);
104
124
  }
105
125
  /**
106
- * Total order of two encoded HLC values.
126
+ * Whether an incoming alias row ranks before the stored one of the same uid
127
+ * (T12800): the least displacement HLC by HLC order (a missing one ranks
128
+ * last), then origin, then `created_at`. The same on every replica.
129
+ */
130
+ function aliasPrecedes(incoming, stored) {
131
+ const a = incoming.displacedHlc;
132
+ const b = stored.displacedHlc;
133
+ if (a !== null && b === null)
134
+ return true;
135
+ if (a === null && b !== null)
136
+ return false;
137
+ if (a !== null && b !== null) {
138
+ const order = compareHlc(a, b);
139
+ if (order !== 0)
140
+ return order < 0;
141
+ }
142
+ const ao = incoming.origin ?? '';
143
+ const bo = stored.origin ?? '';
144
+ if (ao !== bo)
145
+ return ao < bo;
146
+ return incoming.createdAt < stored.createdAt;
147
+ }
148
+ /** The replica id a pre-HLC value carries: none issued it. */
149
+ export const PRE_HLC_REPLICA = '00000000-0000-0000-0000-000000000000';
150
+ /**
151
+ * The collision HLC of two rows that have no creation HLC (written before the
152
+ * change journal, T12342): the later of their two BIRTHS, at counter 0, from
153
+ * no replica ({@link PRE_HLC_REPLICA}). Every replica derives the same value
154
+ * from the rows' synced births; it only anchors the authority schedule
155
+ * ({@link remintAuthority}), never a clock (HLC never reads a uid's time).
107
156
  *
108
- * @param a - One value.
109
- * @param b - The other.
110
- * @returns Negative, zero or positive.
157
+ * @param birthMsA - One row's birth, epoch ms.
158
+ * @param birthMsB - The other's.
159
+ * @returns An encoded HLC.
160
+ * @task T12802
111
161
  */
112
- export function compareHlc(a, b) {
113
- const x = parseHlc(a);
114
- const y = parseHlc(b);
115
- if (x.physicalMs !== y.physicalMs)
116
- return x.physicalMs < y.physicalMs ? -1 : 1;
117
- if (x.counter !== y.counter)
118
- return x.counter < y.counter ? -1 : 1;
119
- return x.node < y.node ? -1 : x.node > y.node ? 1 : 0;
162
+ export function collisionHlcFromBirths(birthMsA, birthMsB) {
163
+ return encodeHlc({ phys: Math.max(birthMsA, birthMsB, 0), ctr: 0, replica: PRE_HLC_REPLICA });
120
164
  }
121
165
  // ---- Shared helpers ---------------------------------------------------------
122
166
  /** Quote an identifier for SQL. */
@@ -152,13 +196,6 @@ function inSavepoint(db, name, fn) {
152
196
  throw error;
153
197
  }
154
198
  }
155
- /** A row-identity meta value. */
156
- function readMeta(db, key) {
157
- const row = db
158
- .prepare(`SELECT value FROM main.${q(ROW_IDENTITY_META_TABLE)} WHERE key = ?`)
159
- .get(key);
160
- return row?.value;
161
- }
162
199
  /** The local row of a minted table with this uid AND birth fingerprint. */
163
200
  function localRow(db, table, uid, birthFp) {
164
201
  return db
@@ -200,7 +237,7 @@ export function remintAuthority(input) {
200
237
  if (input.cloudSynced)
201
238
  return { authority: 'server', reason: 'server', slot: 0 };
202
239
  const window = input.takeoverAfterMs ?? REMINT_TAKEOVER_MS;
203
- const elapsed = Math.max(0, parseHlc(input.atHlc).physicalMs - parseHlc(input.collisionHlc).physicalMs);
240
+ const elapsed = Math.max(0, hlcMs(input.atHlc) - hlcMs(input.collisionHlc));
204
241
  const active = input.replicas
205
242
  .filter((r) => compareHlc(r.joinedHlc, input.atHlc) <= 0 &&
206
243
  (r.retiredHlc == null || compareHlc(r.retiredHlc, input.atHlc) > 0))
@@ -230,10 +267,14 @@ export function remintAuthority(input) {
230
267
  */
231
268
  export function recordDisplayIdAlias(db, entry) {
232
269
  insertDisplayIdAliasNative(db, {
270
+ // The reason is part of the key (T12800 review): a `remint-assigned` and a
271
+ // `collision-remint` alias of one (id, row) are two facts, and neither may
272
+ // win an insert race and drop the other.
233
273
  uid: naturalRowUid('project', DISPLAY_ID_ALIAS_TABLE, [
234
274
  entry.table,
235
275
  entry.displayId,
236
276
  entry.entityUid,
277
+ entry.reason,
237
278
  ]),
238
279
  entityTable: entry.table,
239
280
  displayId: entry.displayId,
@@ -242,8 +283,21 @@ export function recordDisplayIdAlias(db, entry) {
242
283
  reason: entry.reason,
243
284
  origin: entry.origin ?? null,
244
285
  displacedHlc: entry.displacedHlc ?? null,
245
- createdAt: entry.now ?? new Date().toISOString(),
246
- });
286
+ // Derived from the displacement's HLC when there is one, so every replica
287
+ // that records this displacement writes the same row (T12800).
288
+ createdAt: entry.now ?? hlcTime(entry.displacedHlc) ?? new Date().toISOString(),
289
+ }, aliasPrecedes);
290
+ }
291
+ /** Wall-clock time of an encoded HLC, or `undefined` when there is none. */
292
+ function hlcTime(hlc) {
293
+ if (!hlc)
294
+ return undefined;
295
+ try {
296
+ return new Date(storedHlc(hlc).phys).toISOString();
297
+ }
298
+ catch {
299
+ return undefined;
300
+ }
247
301
  }
248
302
  /**
249
303
  * Resolve a display id: a live row always wins (aliases come back as
@@ -274,6 +328,9 @@ export function resolveDisplayId(db, table, displayId) {
274
328
  ORDER BY a.displaced_hlc, a.entity_uid`)
275
329
  .all(table, displayId)
276
330
  .filter((a) => a.uid !== live?.uid)
331
+ // One claimant per row: several reasons (collision-remint, remint-assigned)
332
+ // of one id and row are one history entry here.
333
+ .filter((a, i, all) => all.findIndex((b) => b.uid === a.uid) === i)
277
334
  .map((a) => ({ ...a, via: 'alias' }));
278
335
  if (live) {
279
336
  return {
@@ -337,15 +394,29 @@ function taskReferenceColumns(db) {
337
394
  const tables = new Set(db.prepare("SELECT name FROM main.sqlite_master WHERE type = 'table'").all().map((r) => r.name));
338
395
  return [...found.values()].filter((ref) => tables.has(ref.table));
339
396
  }
340
- function remintKey(uid, birthFp) {
341
- return `remint:tasks_tasks:${uid}:${birthFp}`;
342
- }
343
397
  function readRemint(db, uid, birthFp) {
344
- const raw = readMeta(db, remintKey(uid, birthFp));
345
- return raw === undefined ? undefined : JSON.parse(raw);
398
+ const rows = db
399
+ .prepare(`SELECT display_id AS newId, displaced_hlc AS hlc, origin FROM ${DISPLAY_ID_ALIAS_TABLE}
400
+ WHERE entity_table = 'tasks_tasks' AND entity_uid = ? AND entity_birth_fp = ?
401
+ AND reason = 'remint-assigned' AND displaced_hlc IS NOT NULL`)
402
+ .all(uid, birthFp);
403
+ let best;
404
+ for (const r of rows)
405
+ if (!best || compareHlc(r.hlc, best.hlc) > 0)
406
+ best = r;
407
+ return best;
346
408
  }
409
+ /** Record the display id an op assigned (every op, winner or not: its number stays an alias). */
347
410
  function writeRemint(db, op) {
348
- writeRowIdentityMetaNative(db, remintKey(op.uid, op.birthFp), JSON.stringify({ newId: op.newId, hlc: op.hlc, origin: op.origin }));
411
+ recordDisplayIdAlias(db, {
412
+ table: 'tasks_tasks',
413
+ displayId: op.newId,
414
+ entityUid: op.uid,
415
+ entityBirthFp: op.birthFp,
416
+ reason: 'remint-assigned',
417
+ origin: op.origin,
418
+ displacedHlc: op.hlc,
419
+ });
349
420
  }
350
421
  /**
351
422
  * AUTHORITY ONLY ({@link remintAuthority}): give the losing task of a
@@ -389,7 +460,11 @@ export function remintTaskDisplayId(db, taskId, options) {
389
460
  });
390
461
  const op = { uid, birthFp, oldId, newId, origin: options.origin, hlc: options.hlc };
391
462
  writeRemint(db, op);
392
- return { ...op, rewritten, released: releaseHeldRows(db) };
463
+ const released = releaseHeldRows(db, [
464
+ { table: 'tasks_tasks', key: taskId },
465
+ { table: 'tasks_tasks', key: oldId },
466
+ ]);
467
+ return { ...op, rewritten, released };
393
468
  });
394
469
  }
395
470
  /**
@@ -423,8 +498,7 @@ export function applyRemintOp(db, op) {
423
498
  if (order < 0) {
424
499
  inSavepoint(db, 'remint_superseded', () => {
425
500
  alias(op.oldId, 'collision-remint');
426
- if (op.newId !== prior.newId)
427
- alias(op.newId, 'superseded-remint');
501
+ writeRemint(db, op);
428
502
  });
429
503
  return { status: 'superseded', winner: prior };
430
504
  }
@@ -437,20 +511,40 @@ export function applyRemintOp(db, op) {
437
511
  }
438
512
  return inSavepoint(db, 'remint_apply', () => {
439
513
  alias(op.oldId, 'collision-remint');
440
- if (prior && prior.newId !== op.newId)
441
- alias(prior.newId, 'superseded-remint');
442
514
  writeRemint(db, op);
443
- if (!live)
444
- return { status: 'recorded', released: releaseHeldRows(db) };
515
+ if (!live) {
516
+ const held = { entityTable: 'tasks_tasks', uid: op.uid, birthFp: op.birthFp };
517
+ return {
518
+ status: 'recorded',
519
+ released: releaseHeldRows(db, [{ table: 'tasks_tasks', held }]),
520
+ };
521
+ }
445
522
  if (live.key === op.newId)
446
523
  return { status: 'already-applied' };
447
524
  const { rewritten } = renameTaskDisplayIdNative(db, op.uid, op.newId, taskReferenceColumns(db));
448
525
  return {
449
526
  status: 'applied',
450
- receipt: { ...op, rewritten, released: releaseHeldRows(db) },
527
+ receipt: {
528
+ ...op,
529
+ rewritten,
530
+ released: releaseHeldRows(db, [{ table: 'tasks_tasks', key: live.key }]),
531
+ },
451
532
  };
452
533
  });
453
534
  }
535
+ /**
536
+ * Version of the wire row format. v2 (T12798) carries no local key of
537
+ * another row in `values`; a row without it (or older) came from a build
538
+ * whose values hold raw local keys, and is held (`unsupported-wire`) rather
539
+ * than applied or silently stripped.
540
+ */
541
+ export const WIRE_VERSION = 2;
542
+ /**
543
+ * AC-history rows whose criterion is gone everywhere are still history: they
544
+ * are placed with the criterion's uid carried and a key that names no live
545
+ * criterion (`gone:<uid>`), never held forever.
546
+ */
547
+ const GONE_CRITERION_PREFIX = 'gone:';
454
548
  /** Follow `tasks_uid_aliases` from (uid, birth fp) to the row's current uid. */
455
549
  function followUidAlias(db, table, uid, birthFp) {
456
550
  let current = uid;
@@ -479,13 +573,20 @@ function followUidAlias(db, table, uid, birthFp) {
479
573
  * @returns Where it points.
480
574
  */
481
575
  export function resolveWireRef(db, table, ref) {
576
+ if (ref.uid === null)
577
+ return { status: 'pending' };
482
578
  const minted = specOf(table).kind === 'minted';
579
+ // A minted row is identified by uid AND fingerprint: a reference without a
580
+ // fingerprint never matches one (the other side of a uid collision holds the
581
+ // same uid).
582
+ if (minted && ref.birthFp === null)
583
+ return { status: 'pending' };
483
584
  const uid = minted && ref.birthFp !== null ? followUidAlias(db, table, ref.uid, ref.birthFp) : ref.uid;
484
585
  const row = db
485
586
  .prepare(`SELECT ${q(keyOf(table))} AS key, ${minted ? q(BIRTH_FP_COLUMN) : 'NULL'} AS fp
486
587
  FROM main.${q(table)} WHERE ${q(UID_COLUMN)} = ?`)
487
588
  .get(uid);
488
- if (row && (!minted || ref.birthFp === null || row.fp === ref.birthFp)) {
589
+ if (row && (!minted || row.fp === ref.birthFp)) {
489
590
  return { status: 'row', key: row.key, uid };
490
591
  }
491
592
  const held = db
@@ -493,6 +594,13 @@ export function resolveWireRef(db, table, ref) {
493
594
  .get(table, uid, ref.birthFp ?? '');
494
595
  return held ? { status: 'held' } : { status: 'pending' };
495
596
  }
597
+ /** Whether a column accepts NULL. */
598
+ function nullableColumn(db, table, column) {
599
+ const info = db
600
+ .prepare('SELECT "notnull" AS notnull, pk FROM pragma_table_info(?) WHERE name = ?')
601
+ .get(table, column);
602
+ return info !== undefined && info.notnull === 0 && info.pk === 0;
603
+ }
496
604
  /** Reference column → referenced table, for a declared table. */
497
605
  function refTargets(spec) {
498
606
  const out = new Map();
@@ -501,21 +609,57 @@ function refTargets(spec) {
501
609
  }
502
610
  return out;
503
611
  }
612
+ /** Stored reference uid column (`ac_uid`) → its table and key column (`ac_id`). */
613
+ function storedUidRefs(spec) {
614
+ const out = new Map();
615
+ for (const ref of spec.storedRefUids ?? []) {
616
+ if ((ref.source ?? 'uid') === 'uid')
617
+ out.set(ref.column, { table: ref.table, from: ref.from });
618
+ }
619
+ return out;
620
+ }
621
+ /** JSON array of ids column → referenced table. */
622
+ function jsonArrayTargets(spec) {
623
+ return new Map((spec.jsonArrayRefs ?? []).map((ref) => [ref.column, ref.table]));
624
+ }
625
+ /**
626
+ * Columns that hold another row's LOCAL key: never sent as values (T12798).
627
+ * `ac_text_hash` (a stored fact, not a key) is kept.
628
+ */
629
+ function localKeyColumns(spec) {
630
+ const out = new Set([...refTargets(spec).keys(), ...jsonArrayTargets(spec).keys()]);
631
+ for (const [column, { from }] of storedUidRefs(spec)) {
632
+ out.add(column);
633
+ out.add(from);
634
+ }
635
+ return out;
636
+ }
504
637
  /** Hold a row, keeping the time it was first held. */
505
638
  function hold(db, wire, reason, contestedId, previous) {
506
639
  const key = { entityTable: wire.table, uid: wire.uid, birthFp: wire.birthFp ?? '' };
507
640
  const was = previous ?? key;
641
+ const rowJson = JSON.stringify(wire);
508
642
  const first = db
509
- .prepare(`SELECT created_at AS createdAt FROM ${QUARANTINE_TABLE}
510
- WHERE entity_table = ? AND uid = ? AND birth_fp = ?`)
643
+ .prepare(`SELECT created_at AS createdAt, reason, contested_id AS contestedId, row_json AS rowJson
644
+ FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND uid = ? AND birth_fp = ?`)
511
645
  .get(was.entityTable, was.uid, was.birthFp);
646
+ // A still-held row that is held for the same reason is never rewritten (T12801).
647
+ if (first &&
648
+ was.entityTable === key.entityTable &&
649
+ was.uid === key.uid &&
650
+ was.birthFp === key.birthFp &&
651
+ first.reason === reason &&
652
+ first.contestedId === contestedId &&
653
+ first.rowJson === rowJson) {
654
+ return;
655
+ }
512
656
  if (previous)
513
657
  deleteQuarantineNative(db, previous);
514
658
  insertQuarantineNative(db, {
515
659
  ...key,
516
660
  reason,
517
661
  contestedId,
518
- rowJson: JSON.stringify(wire),
662
+ rowJson,
519
663
  receivedHlc: null,
520
664
  createdAt: first?.createdAt ?? new Date().toISOString(),
521
665
  });
@@ -524,6 +668,13 @@ function hold(db, wire, reason, contestedId, previous) {
524
668
  function placeRow(db, incoming, heldAs) {
525
669
  const spec = specOf(incoming.table);
526
670
  const minted = spec.kind === 'minted';
671
+ // A v1 sender's values hold raw local keys of other rows: recognised and
672
+ // held, never applied and never silently stripped (T12798 review).
673
+ const version = incoming.version ?? 1;
674
+ if (version !== WIRE_VERSION) {
675
+ hold(db, incoming, 'unsupported-wire', `wire v${version}`, heldAs);
676
+ return { status: 'held', reason: 'unsupported-wire' };
677
+ }
527
678
  if (minted && !incoming.birthFp) {
528
679
  throw new Error(`receive: a ${incoming.table} row must carry its birth fingerprint`);
529
680
  }
@@ -569,21 +720,67 @@ function placeRow(db, incoming, heldAs) {
569
720
  };
570
721
  }
571
722
  const values = { ...wire.values };
723
+ // A local key of another row never comes from the sender's values (T12798);
724
+ // a JSON column that is not an id array travels as a value.
725
+ const arrayColumns = jsonArrayTargets(spec);
726
+ for (const column of localKeyColumns(spec)) {
727
+ if (!arrayColumns.has(column) || wire.jsonRefs?.[column])
728
+ delete values[column];
729
+ }
572
730
  const targets = refTargets(spec);
731
+ const stored = storedUidRefs(spec);
732
+ const pending = () => {
733
+ hold(db, wire, 'ref-pending', null, heldAs);
734
+ return { status: 'held', reason: 'ref-pending' };
735
+ };
573
736
  for (const [column, ref] of Object.entries(wire.refs ?? {})) {
574
- const target = targets.get(column);
737
+ const storedRef = stored.get(column);
738
+ const target = targets.get(column) ?? storedRef?.table;
575
739
  if (!target)
576
740
  throw new Error(`receive: ${wire.table}.${column} is not a declared reference`);
577
741
  if (ref === null) {
578
742
  values[column] = null;
743
+ if (storedRef)
744
+ values[storedRef.from] = null;
579
745
  continue;
580
746
  }
581
747
  const resolved = resolveWireRef(db, target, ref);
582
- if (resolved.status !== 'row') {
583
- hold(db, wire, 'ref-pending', null, heldAs);
584
- return { status: 'held', reason: 'ref-pending' };
748
+ if (resolved.status !== 'row' && ref.gone) {
749
+ // The sender no longer has the row this names, and neither do we.
750
+ if (storedRef && wire.table === 'tasks_task_acceptance_criteria_history') {
751
+ // History of a criterion gone everywhere: keep the history, carry the
752
+ // criterion's uid, point the key at no live criterion.
753
+ values[column] = ref.uid;
754
+ values[storedRef.from] = `${GONE_CRITERION_PREFIX}${ref.uid ?? wire.uid}`;
755
+ continue;
756
+ }
757
+ if (!storedRef && nullableColumn(db, wire.table, column)) {
758
+ values[column] = null; // a dangling optional reference is dropped
759
+ continue;
760
+ }
761
+ }
762
+ if (resolved.status !== 'row')
763
+ return pending();
764
+ if (storedRef) {
765
+ values[storedRef.from] = resolved.key;
766
+ values[column] = resolved.uid;
767
+ }
768
+ else {
769
+ values[column] = resolved.key;
770
+ }
771
+ }
772
+ for (const [column, refs] of Object.entries(wire.jsonRefs ?? {})) {
773
+ const target = arrayColumns.get(column);
774
+ if (!target)
775
+ throw new Error(`receive: ${wire.table}.${column} is not a declared id array`);
776
+ const keys = [];
777
+ for (const ref of refs) {
778
+ const resolved = resolveWireRef(db, target, ref);
779
+ if (resolved.status !== 'row')
780
+ return pending();
781
+ keys.push(resolved.key);
585
782
  }
586
- values[column] = resolved.key;
783
+ values[column] = JSON.stringify(keys);
587
784
  }
588
785
  if (spec.displayId && minted) {
589
786
  const key = keyOf(wire.table);
@@ -610,6 +807,33 @@ function placeRow(db, incoming, heldAs) {
610
807
  };
611
808
  }
612
809
  }
810
+ if (wire.table === DISPLAY_ID_ALIAS_TABLE || wire.table === UID_ALIAS_TABLE) {
811
+ // An alias row's uid IS its primary key, computed on write from its key
812
+ // values (none is a reference): derive it the same way here (T12800).
813
+ const uid = naturalRowUid('project', wire.table, spec.key.map((k) => (values[k] ?? null)));
814
+ const existed = db.prepare(`SELECT 1 AS x FROM main.${q(wire.table)} WHERE ${q(UID_COLUMN)} = ?`).get(uid) !==
815
+ undefined;
816
+ if (wire.table === DISPLAY_ID_ALIAS_TABLE) {
817
+ // The order-independent upsert: whichever copy arrives first, every
818
+ // replica keeps the same row.
819
+ insertDisplayIdAliasNative(db, {
820
+ uid,
821
+ entityTable: String(values['entity_table']),
822
+ displayId: String(values['display_id']),
823
+ entityUid: String(values['entity_uid']),
824
+ entityBirthFp: values['entity_birth_fp'] ?? null,
825
+ reason: String(values['reason']),
826
+ origin: values['origin'] ?? null,
827
+ displacedHlc: values['displaced_hlc'] ?? null,
828
+ createdAt: String(values['created_at']),
829
+ }, aliasPrecedes);
830
+ }
831
+ else if (!existed) {
832
+ insertIdentityRowNative(db, wire.table, { ...values, [UID_COLUMN]: uid });
833
+ }
834
+ done();
835
+ return existed ? { status: 'duplicate', uid } : { status: 'inserted', key: uid, uid };
836
+ }
613
837
  if (!minted) {
614
838
  const existing = db
615
839
  .prepare(`SELECT ${q(UID_COLUMN)} AS uid FROM main.${q(wire.table)}
@@ -637,17 +861,38 @@ function placeRow(db, incoming, heldAs) {
637
861
  return { status: 'held', reason: 'key-collision' };
638
862
  }
639
863
  }
640
- if (minted) {
641
- insertIdentityRowNative(db, wire.table, {
642
- ...values,
643
- [UID_COLUMN]: wire.uid,
644
- [BIRTH_FP_COLUMN]: wire.birthFp,
864
+ // Validate like a local writer would (T12801): a received row is data from
865
+ // another device, never trusted to satisfy this store's invariants.
866
+ const invalid = validateReceived(db, wire.table, values);
867
+ if (invalid) {
868
+ hold(db, wire, 'invalid', invalid, heldAs);
869
+ return { status: 'held', reason: 'invalid' };
870
+ }
871
+ try {
872
+ inSavepoint(db, 'receive_insert', () => {
873
+ if (minted) {
874
+ insertIdentityRowNative(db, wire.table, {
875
+ ...values,
876
+ [UID_COLUMN]: wire.uid,
877
+ [BIRTH_FP_COLUMN]: wire.birthFp,
878
+ });
879
+ }
880
+ else {
881
+ // A natural uid is a pure function of its endpoints: derive it here.
882
+ insertIdentityRowNative(db, wire.table, { ...values, [UID_COLUMN]: null });
883
+ fillTableUids(db, 'project', wire.table);
884
+ }
645
885
  });
646
886
  }
647
- else {
648
- // A natural uid is a pure function of its endpoints: derive it here.
649
- insertIdentityRowNative(db, wire.table, { ...values, [UID_COLUMN]: null });
650
- fillTableUids(db, 'project', wire.table);
887
+ catch (error) {
888
+ // The store's own guards refused it (hierarchy cycle and type-matrix
889
+ // triggers, CHECK and NOT NULL constraints): hold it, never throw the
890
+ // merge away, never insert it.
891
+ if (!isConstraintError(error))
892
+ throw error;
893
+ const message = error instanceof Error ? error.message : String(error);
894
+ hold(db, wire, 'invalid', message.slice(0, 200), heldAs);
895
+ return { status: 'held', reason: 'invalid' };
651
896
  }
652
897
  done();
653
898
  const key = rowidKey
@@ -665,31 +910,123 @@ function rowidKeyOf(db, table) {
665
910
  const [only] = pk;
666
911
  return pk.length === 1 && only && only.type.toUpperCase() === 'INTEGER' ? only.name : undefined;
667
912
  }
913
+ /** Held rows a trigger may unblock (indexed lookups and a JSON text match, never a full retry). */
914
+ function candidatesFor(db, t) {
915
+ const cols = `entity_table AS entityTable, uid, birth_fp AS birthFp, row_json AS rowJson`;
916
+ const out = [];
917
+ if (t.held) {
918
+ const row = db
919
+ .prepare(`SELECT ${cols} FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND uid = ? AND birth_fp = ?`)
920
+ .get(t.held.entityTable, t.held.uid, t.held.birthFp);
921
+ if (row)
922
+ out.push(row);
923
+ }
924
+ if (t.key !== undefined) {
925
+ out.push(...db
926
+ .prepare(`SELECT ${cols} FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND contested_id = ?
927
+ ORDER BY created_at, uid`)
928
+ .all(t.table, t.key));
929
+ }
930
+ if (t.uid !== undefined) {
931
+ // Rows waiting on a reference to this uid carry it in their wire form,
932
+ // either as this uid or as an old uid a re-key led here.
933
+ const uids = new Set([t.uid]);
934
+ for (let frontier = [t.uid]; frontier.length > 0;) {
935
+ const older = db
936
+ .prepare(`SELECT old_uid AS uid FROM ${UID_ALIAS_TABLE}
937
+ WHERE entity_table = ? AND new_uid IN (${frontier.map(() => '?').join(', ')})`)
938
+ .all(t.table, ...frontier);
939
+ frontier = older.map((r) => r.uid).filter((u) => !uids.has(u));
940
+ for (const u of frontier)
941
+ uids.add(u);
942
+ }
943
+ const find = db.prepare(`SELECT ${cols} FROM ${QUARANTINE_TABLE} WHERE instr(row_json, ?) > 0 AND uid <> ?
944
+ ORDER BY created_at, uid`);
945
+ for (const uid of uids) {
946
+ out.push(...find.all(`"${uid}"`, t.uid));
947
+ }
948
+ }
949
+ return out;
950
+ }
668
951
  /**
669
- * Re-try every held row until none moves: a placement can unblock others
670
- * (a released owner unblocks its children).
952
+ * Re-try the held rows the triggers may unblock, then the rows each placement
953
+ * unblocks in turn (a placed owner unblocks its children). A held row is only
954
+ * re-tried when something it may wait on changed, and a row still held for
955
+ * the same reason is not rewritten (T12801: no full re-scan per receive).
956
+ * `'all'` re-tries every held row once (explicit {@link releaseHeld}).
671
957
  */
672
- function releaseHeldRows(db) {
958
+ function releaseHeldRows(db, triggers) {
673
959
  const released = [];
674
- for (let round = 0; round < 64; round++) {
960
+ const work = [];
961
+ const attempt = (h) => {
962
+ const key = { entityTable: h.entityTable, uid: h.uid, birthFp: h.birthFp };
963
+ const result = placeRow(db, JSON.parse(h.rowJson), key);
964
+ if (result.status === 'held')
965
+ return;
966
+ released.push(key);
967
+ if (result.status === 'inserted') {
968
+ work.push({ table: h.entityTable, uid: result.uid, key: result.key });
969
+ }
970
+ };
971
+ if (triggers === 'all') {
675
972
  const held = db
676
973
  .prepare(`SELECT entity_table AS entityTable, uid, birth_fp AS birthFp, row_json AS rowJson
677
974
  FROM ${QUARANTINE_TABLE} ORDER BY created_at, entity_table, uid`)
678
975
  .all();
679
- let moved = false;
680
- for (const h of held) {
681
- const key = { entityTable: h.entityTable, uid: h.uid, birthFp: h.birthFp };
682
- const result = placeRow(db, JSON.parse(h.rowJson), key);
683
- if (result.status !== 'held') {
684
- released.push(key);
685
- moved = true;
686
- }
976
+ for (const h of held)
977
+ attempt(h);
978
+ }
979
+ else {
980
+ work.push(...triggers);
981
+ }
982
+ for (let next = work.shift(); next !== undefined; next = work.shift()) {
983
+ const seen = new Set();
984
+ for (const h of candidatesFor(db, next)) {
985
+ const id = `${h.entityTable}\u0000${h.uid}\u0000${h.birthFp}`;
986
+ if (seen.has(id))
987
+ continue;
988
+ seen.add(id);
989
+ attempt(h);
687
990
  }
688
- if (!moved)
689
- break;
690
991
  }
691
992
  return released;
692
993
  }
994
+ /** Release triggers of a re-key receipt: its moved rows and the uids they left and took. */
995
+ function rekeyTriggers(receipt) {
996
+ const out = [
997
+ { table: receipt.table, uid: receipt.oldUid },
998
+ { table: receipt.table, uid: receipt.newUid },
999
+ {
1000
+ table: receipt.table,
1001
+ held: { entityTable: receipt.table, uid: receipt.newUid, birthFp: receipt.birthFp },
1002
+ },
1003
+ ];
1004
+ for (const child of receipt.cascaded)
1005
+ out.push(...rekeyTriggers(child));
1006
+ return out;
1007
+ }
1008
+ /** The policy each connection receives under; set by {@link receiveRow}'s `policy`. */
1009
+ const receivePolicies = new WeakMap();
1010
+ /**
1011
+ * Resolve the receive policy of a project from its configuration, exactly as
1012
+ * a local task write does (`resolveHierarchyPolicy(loadConfig())`, so
1013
+ * `hierarchy.maxDepth` and `CLEO_HIERARCHY_MAX_DEPTH` apply). The merge engine
1014
+ * resolves it once per merge and passes it to {@link receiveRow}.
1015
+ *
1016
+ * @param cwd - Project root.
1017
+ * @returns The policy.
1018
+ */
1019
+ export async function loadReceivePolicy(cwd) {
1020
+ // Loaded lazily: config.js is outside the store module graph.
1021
+ const { loadConfig } = await import('../config.js');
1022
+ return { maxDepth: resolveHierarchyPolicy(await loadConfig(cwd)).maxDepth };
1023
+ }
1024
+ /** The policy in force on `db`: the last one passed, else the default profile's. */
1025
+ function receivePolicyOf(db) {
1026
+ return (receivePolicies.get(db) ?? {
1027
+ maxDepth: resolveHierarchyPolicy({}).maxDepth,
1028
+ });
1029
+ }
693
1030
  /**
694
1031
  * Receive one row: place it, or hold it (module docs). The first receive
695
1032
  * marks the store's identity values as shared, so the open pass never
@@ -697,19 +1034,90 @@ function releaseHeldRows(db) {
697
1034
  *
698
1035
  * @param db - Connection on the project `cleo.db`, inside the merge transaction.
699
1036
  * @param wire - The incoming row.
1037
+ * @param policy - The project's resolved receive policy ({@link loadReceivePolicy}).
1038
+ * It stays in force on this connection, also for held rows released later;
1039
+ * without one, the default hierarchy profile applies.
700
1040
  * @returns What happened; a `held` result carries the collision for the caller
701
1041
  * (the merge engine) to hand to its authority.
702
1042
  */
703
- export function receiveRow(db, wire) {
1043
+ export function receiveRow(db, wire, policy) {
1044
+ if (policy)
1045
+ receivePolicies.set(db, policy);
704
1046
  registerRowUidFunction(db, 'project');
705
1047
  return inSavepoint(db, 'receive', () => {
706
1048
  markRowIdentityShared(db, 'receive');
707
1049
  const result = placeRow(db, wire);
708
- if (result.status === 'inserted')
709
- releaseHeldRows(db);
1050
+ if (result.status === 'inserted') {
1051
+ releaseHeldRows(db, [{ table: wire.table, uid: result.uid, key: result.key }]);
1052
+ }
710
1053
  return result;
711
1054
  });
712
1055
  }
1056
+ /** Depth of a local task (a root is 0), walking its parent chain. */
1057
+ function depthOf(db, taskId) {
1058
+ const parentOf = db.prepare('SELECT parent_id AS p FROM tasks_tasks WHERE id = ?');
1059
+ let depth = 0;
1060
+ let current = taskId;
1061
+ for (let hop = 0; hop < 64 && current !== null; hop++) {
1062
+ const row = parentOf.get(current);
1063
+ current = row?.p ?? null;
1064
+ if (current !== null)
1065
+ depth++;
1066
+ }
1067
+ return depth;
1068
+ }
1069
+ /**
1070
+ * Whether an insert error is the store refusing the row: SQLite's
1071
+ * SQLITE_CONSTRAINT family (CHECK, NOT NULL, UNIQUE, FOREIGN KEY, and the
1072
+ * guard triggers' `RAISE(ABORT, ...)`, which report SQLITE_CONSTRAINT_TRIGGER).
1073
+ * Anything else (a missing table, an I/O error) is not the row's fault and
1074
+ * is thrown.
1075
+ */
1076
+ function isConstraintError(error) {
1077
+ const code = error?.errcode;
1078
+ return typeof code === 'number' && (code & 0xff) === 19; // SQLITE_CONSTRAINT
1079
+ }
1080
+ /** The claim lease columns: local to the device that took the lease, never received. */
1081
+ const CLAIM_COLUMNS = ['claimed_by_session', 'claimed_by_agent', 'claimed_at', 'lease_expires_at'];
1082
+ /**
1083
+ * Per-table validation of a received row, in place (T12801). A task is checked
1084
+ * against `insertTaskSchema` (the schema every local task write satisfies) and
1085
+ * arrives without a claim lease: a lease belongs to the device and session
1086
+ * that took it. The store's triggers (containment cycle, type matrix, status
1087
+ * pipeline) then run on the insert itself.
1088
+ *
1089
+ * @returns Why the row is invalid, or `null`.
1090
+ */
1091
+ function validateReceived(db, table, values) {
1092
+ if (table !== 'tasks_tasks')
1093
+ return null;
1094
+ // The containment depth cap every local task write enforces, from the
1095
+ // project's resolved hierarchy policy (T12801 review).
1096
+ const parent = values.parent_id;
1097
+ if (typeof parent === 'string' && parent.length > 0) {
1098
+ const { maxDepth } = receivePolicyOf(db);
1099
+ const parentDepth = depthOf(db, parent);
1100
+ if (exceedsMaxDepth(parentDepth, maxDepth)) {
1101
+ return `E_DEPTH_EXCEEDED: depth ${parentDepth + 1} under ${parent} exceeds ${maxDepth}`;
1102
+ }
1103
+ }
1104
+ for (const column of CLAIM_COLUMNS) {
1105
+ if (column in values)
1106
+ values[column] = null;
1107
+ }
1108
+ const byColumn = new Map(Object.entries(getTableColumns(tasksTable)).map(([prop, col]) => [col.name, prop]));
1109
+ const candidate = {};
1110
+ for (const [column, value] of Object.entries(values)) {
1111
+ const prop = byColumn.get(column);
1112
+ if (prop !== undefined && value !== null)
1113
+ candidate[prop] = value;
1114
+ }
1115
+ const parsed = insertTaskSchema.safeParse(candidate);
1116
+ if (parsed.success)
1117
+ return null;
1118
+ const issue = parsed.error.issues[0];
1119
+ return `invalid ${issue?.path.join('.') ?? 'row'}: ${issue?.message ?? 'rejected'}`.slice(0, 200);
1120
+ }
713
1121
  /**
714
1122
  * Re-try the held rows now (after an op this module did not apply).
715
1123
  *
@@ -718,7 +1126,7 @@ export function receiveRow(db, wire) {
718
1126
  */
719
1127
  export function releaseHeld(db) {
720
1128
  registerRowUidFunction(db, 'project');
721
- return inSavepoint(db, 'release', () => releaseHeldRows(db));
1129
+ return inSavepoint(db, 'release', () => releaseHeldRows(db, 'all'));
722
1130
  }
723
1131
  /**
724
1132
  * Every held row (read-only: held rows are never edited in place).
@@ -748,32 +1156,91 @@ export function wireRowOf(db, table, uid) {
748
1156
  const row = db.prepare(`SELECT * FROM main.${q(table)} WHERE ${q(UID_COLUMN)} = ?`).get(uid);
749
1157
  if (!row)
750
1158
  throw new Error(`wire: no ${table} row with uid ${uid}`);
1159
+ const keyColumns = localKeyColumns(spec);
751
1160
  const values = {};
752
1161
  for (const [column, value] of Object.entries(row)) {
753
- if (column !== UID_COLUMN && column !== BIRTH_FP_COLUMN)
754
- values[column] = value;
1162
+ if (column === UID_COLUMN || column === BIRTH_FP_COLUMN || keyColumns.has(column))
1163
+ continue;
1164
+ values[column] = value;
755
1165
  }
1166
+ /** The identity of the target row, by its local key or by its uid. */
1167
+ const identityOf = (target, by, value) => {
1168
+ const minted = specOf(target).kind === 'minted';
1169
+ const t = db
1170
+ .prepare(`SELECT ${q(UID_COLUMN)} AS uid, ${minted ? q(BIRTH_FP_COLUMN) : 'NULL'} AS fp
1171
+ FROM main.${q(target)} WHERE ${q(by === 'key' ? keyOf(target) : UID_COLUMN)} = ?`)
1172
+ .get(value);
1173
+ // A missing target travels as gone (the raw key never travels); one whose
1174
+ // uid is not filled yet travels as uid null (the receiver waits).
1175
+ if (!t)
1176
+ return { uid: null, birthFp: null, gone: true };
1177
+ return t.uid ? { uid: t.uid, birthFp: t.fp } : { uid: null, birthFp: null };
1178
+ };
1179
+ /** The fingerprint a deleted criterion had, from the AC uid graveyard. */
1180
+ const graveyardFp = (acUid) => {
1181
+ const has = db
1182
+ .prepare("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?")
1183
+ .get(AC_UID_GRAVEYARD);
1184
+ if (!has)
1185
+ return null;
1186
+ const r = db
1187
+ .prepare(`SELECT birth_fp AS fp FROM ${AC_UID_GRAVEYARD} WHERE uid = ? ORDER BY seq DESC LIMIT 1`)
1188
+ .get(acUid);
1189
+ return r?.fp ?? null;
1190
+ };
756
1191
  const refs = {};
757
1192
  for (const [column, target] of refTargets(spec)) {
758
1193
  const value = row[column];
759
- if (value === null || value === undefined) {
760
- refs[column] = null;
1194
+ refs[column] = value === null || value === undefined ? null : identityOf(target, 'key', value);
1195
+ }
1196
+ for (const [column, { table: target, from }] of storedUidRefs(spec)) {
1197
+ const recorded = row[column];
1198
+ const key = row[from];
1199
+ if (recorded !== null && recorded !== undefined) {
1200
+ refs[column] = identityOf(target, 'uid', recorded);
1201
+ // A recorded uid whose row this store no longer has keeps its uid, with
1202
+ // the fingerprint the graveyard kept when there is one; the receiver
1203
+ // never matches it without a fingerprint.
1204
+ if (refs[column]?.uid === null) {
1205
+ refs[column] = {
1206
+ uid: String(recorded),
1207
+ birthFp: graveyardFp(String(recorded)),
1208
+ gone: true,
1209
+ };
1210
+ }
1211
+ }
1212
+ else {
1213
+ refs[column] = key === null || key === undefined ? null : identityOf(target, 'key', key);
1214
+ }
1215
+ }
1216
+ const jsonRefs = {};
1217
+ for (const [column, target] of jsonArrayTargets(spec)) {
1218
+ const raw = row[column];
1219
+ let ids;
1220
+ try {
1221
+ ids = typeof raw === 'string' ? JSON.parse(raw) : null;
1222
+ }
1223
+ catch {
1224
+ ids = null;
1225
+ }
1226
+ if (!Array.isArray(ids)) {
1227
+ values[column] = raw ?? null; // not an id array: sent as is
761
1228
  continue;
762
1229
  }
763
- const minted = specOf(target).kind === 'minted';
764
- const t = db
765
- .prepare(`SELECT ${q(UID_COLUMN)} AS uid, ${minted ? q(BIRTH_FP_COLUMN) : 'NULL'} AS fp
766
- FROM main.${q(target)} WHERE ${q(keyOf(target))} = ?`)
767
- .get(value);
768
- if (t)
769
- refs[column] = { uid: t.uid, birthFp: t.fp };
1230
+ // An id whose task this store no longer has is dropped from the array:
1231
+ // there is nothing it could name on the receiver (T12798 review).
1232
+ jsonRefs[column] = ids
1233
+ .map((id) => identityOf(target, 'key', String(id)))
1234
+ .filter((ref) => !ref.gone);
770
1235
  }
771
1236
  return {
1237
+ version: WIRE_VERSION,
772
1238
  table,
773
1239
  uid,
774
1240
  birthFp: spec.kind === 'minted' ? (row[BIRTH_FP_COLUMN] ?? null) : null,
775
1241
  values,
776
1242
  refs,
1243
+ ...(Object.keys(jsonRefs).length > 0 ? { jsonRefs } : {}),
777
1244
  };
778
1245
  }
779
1246
  /** Stored-uid copies, display aliases, the re-mint record and the uid alias of a moved row. */
@@ -796,7 +1263,12 @@ function followRow(db, table, key, oldUid, birthFp, newUid, options) {
796
1263
  .all(table, oldUid, birthFp);
797
1264
  for (const a of aliases) {
798
1265
  deleteDisplayIdAliasNative(db, a.uid);
799
- const uid = naturalRowUid('project', DISPLAY_ID_ALIAS_TABLE, [table, a.displayId, newUid]);
1266
+ const uid = naturalRowUid('project', DISPLAY_ID_ALIAS_TABLE, [
1267
+ table,
1268
+ a.displayId,
1269
+ newUid,
1270
+ a.reason,
1271
+ ]);
800
1272
  insertDisplayIdAliasNative(db, {
801
1273
  uid,
802
1274
  entityTable: table,
@@ -807,16 +1279,9 @@ function followRow(db, table, key, oldUid, birthFp, newUid, options) {
807
1279
  origin: a.origin,
808
1280
  displacedHlc: a.displacedHlc,
809
1281
  createdAt: a.createdAt,
810
- });
1282
+ }, aliasPrecedes);
811
1283
  moved.push({ table: DISPLAY_ID_ALIAS_TABLE, oldUid: a.uid, newUid: uid });
812
1284
  }
813
- if (table === 'tasks_tasks') {
814
- const remint = readMeta(db, remintKey(oldUid, birthFp));
815
- if (remint !== undefined) {
816
- writeRowIdentityMetaNative(db, remintKey(newUid, birthFp), remint);
817
- deleteRowIdentityMetaNative(db, remintKey(oldUid, birthFp));
818
- }
819
- }
820
1285
  insertUidAliasNative(db, {
821
1286
  uid: naturalRowUid('project', UID_ALIAS_TABLE, [table, oldUid, birthFp]),
822
1287
  entityTable: table,
@@ -963,16 +1428,26 @@ function rekeyDerive(db, table, oldUid, birthFp, newUid, options) {
963
1428
  * @param db - Connection on the project `cleo.db`.
964
1429
  * @param table - Minted table of the row.
965
1430
  * @param uid - The colliding uid.
966
- * @param loserBirthFp - The loser's birth fingerprint.
1431
+ * @param collision - The loser's and the winner's birth fingerprints (the
1432
+ * winner must be the smaller; T12801).
967
1433
  * @param options - The authority and the HLC of the re-key.
968
1434
  * @returns The receipt to publish, with its cascade and natural uids.
969
1435
  * @throws CleoError when this replica has no row (uid, loserBirthFp), or the
970
1436
  * fingerprint is not the loser's.
971
1437
  */
972
- export function rekeyRowUid(db, table, uid, loserBirthFp, options = {}) {
1438
+ export function rekeyRowUid(db, table, uid, collision, options = {}) {
973
1439
  if (specOf(table).kind !== 'minted') {
974
1440
  throw new Error(`rekey: ${table} is not a declared minted table`);
975
1441
  }
1442
+ const { loserBirthFp, winnerBirthFp } = collision;
1443
+ // The loser is the greater fingerprint of the pair; the caller names both,
1444
+ // so a swapped or stale pair is refused rather than re-keying the winner.
1445
+ if (!(winnerBirthFp < loserBirthFp)) {
1446
+ throw new CleoError(ExitCode.VALIDATION_ERROR, `rekey: ${loserBirthFp} is not the loser of the ${table} uid collision on ${uid} (the loser is the greater fingerprint; winner ${winnerBirthFp})`, {
1447
+ fix: 'Re-key the row with the greater birth fingerprint; the smaller one keeps the uid.',
1448
+ details: { field: 'loserBirthFp', actual: loserBirthFp, expected: winnerBirthFp },
1449
+ });
1450
+ }
976
1451
  const rivals = [
977
1452
  ...db
978
1453
  .prepare(`SELECT ${q(BIRTH_FP_COLUMN)} AS fp FROM main.${q(table)} WHERE ${q(UID_COLUMN)} = ?`)
@@ -981,7 +1456,7 @@ export function rekeyRowUid(db, table, uid, loserBirthFp, options = {}) {
981
1456
  .prepare(`SELECT birth_fp AS fp FROM ${QUARANTINE_TABLE} WHERE entity_table = ? AND uid = ?`)
982
1457
  .all(table, uid).map((r) => r.fp),
983
1458
  ].filter((fp) => fp !== null && fp !== loserBirthFp);
984
- const greater = rivals.find((fp) => fp > loserBirthFp);
1459
+ const greater = rivals.find((fp) => fp > loserBirthFp || fp !== winnerBirthFp);
985
1460
  if (greater !== undefined) {
986
1461
  throw new CleoError(ExitCode.VALIDATION_ERROR, `rekey: ${loserBirthFp} is not the loser of the ${table} uid collision on ${uid}`, {
987
1462
  fix: 'Re-key the row with the greater birth fingerprint; the smaller one keeps the uid.',
@@ -997,7 +1472,7 @@ export function rekeyRowUid(db, table, uid, loserBirthFp, options = {}) {
997
1472
  registerRowUidFunction(db, 'project');
998
1473
  return inSavepoint(db, 'rekey', () => {
999
1474
  const receipt = rekeyDerive(db, table, uid, loserBirthFp, mintRowUid(), options);
1000
- releaseHeldRows(db);
1475
+ releaseHeldRows(db, rekeyTriggers(receipt));
1001
1476
  return receipt;
1002
1477
  });
1003
1478
  }
@@ -1057,7 +1532,7 @@ export function applyRekey(db, receipt) {
1057
1532
  markRowIdentityShared(db, 'receive');
1058
1533
  const rows = [];
1059
1534
  applyEntry(db, receipt, rows);
1060
- return { rows, released: releaseHeldRows(db) };
1535
+ return { rows, released: releaseHeldRows(db, rekeyTriggers(receipt)) };
1061
1536
  });
1062
1537
  }
1063
1538
  //# sourceMappingURL=display-id-alias.js.map