@cleocode/contracts 2026.9.19 → 2026.9.21

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 (169) hide show
  1. package/README.md +1 -1
  2. package/dist/__tests__/decide.test.d.ts +7 -0
  3. package/dist/__tests__/decide.test.d.ts.map +1 -0
  4. package/dist/__tests__/decide.test.js +91 -0
  5. package/dist/__tests__/decide.test.js.map +1 -0
  6. package/dist/__tests__/evidence-atom-schema.test.js +2 -1
  7. package/dist/__tests__/evidence-atom-schema.test.js.map +1 -1
  8. package/dist/__tests__/memory-wire-shapes.test.js.map +1 -1
  9. package/dist/acceptance-gate-schema.d.ts +5 -0
  10. package/dist/acceptance-gate-schema.d.ts.map +1 -1
  11. package/dist/acceptance-gate-schema.js +26 -0
  12. package/dist/acceptance-gate-schema.js.map +1 -1
  13. package/dist/acceptance-gate.d.ts +70 -3
  14. package/dist/acceptance-gate.d.ts.map +1 -1
  15. package/dist/backup-manifest.d.ts +13 -2
  16. package/dist/backup-manifest.d.ts.map +1 -1
  17. package/dist/boundary.js +1 -1
  18. package/dist/boundary.js.map +1 -1
  19. package/dist/branch-lock.d.ts +11 -0
  20. package/dist/branch-lock.d.ts.map +1 -1
  21. package/dist/branch-lock.js +31 -4
  22. package/dist/branch-lock.js.map +1 -1
  23. package/dist/cloud/api.d.ts +607 -0
  24. package/dist/cloud/api.d.ts.map +1 -0
  25. package/dist/cloud/api.js +417 -0
  26. package/dist/cloud/api.js.map +1 -0
  27. package/dist/cloud/envelope.d.ts +84 -0
  28. package/dist/cloud/envelope.d.ts.map +1 -0
  29. package/dist/cloud/envelope.js +54 -0
  30. package/dist/cloud/envelope.js.map +1 -0
  31. package/dist/cloud/ids.d.ts +30 -0
  32. package/dist/cloud/ids.d.ts.map +1 -0
  33. package/dist/cloud/ids.js +32 -0
  34. package/dist/cloud/ids.js.map +1 -0
  35. package/dist/cloud/index.d.ts +17 -0
  36. package/dist/cloud/index.d.ts.map +1 -0
  37. package/dist/cloud/index.js +17 -0
  38. package/dist/cloud/index.js.map +1 -0
  39. package/dist/data-accessor.d.ts +23 -3
  40. package/dist/data-accessor.d.ts.map +1 -1
  41. package/dist/data-accessor.js.map +1 -1
  42. package/dist/decide.d.ts +333 -0
  43. package/dist/decide.d.ts.map +1 -0
  44. package/dist/decide.js +184 -0
  45. package/dist/decide.js.map +1 -0
  46. package/dist/dispatch/operations-registry.d.ts.map +1 -1
  47. package/dist/dispatch/operations-registry.js +55 -0
  48. package/dist/dispatch/operations-registry.js.map +1 -1
  49. package/dist/done-plan.d.ts +311 -0
  50. package/dist/done-plan.d.ts.map +1 -0
  51. package/dist/done-plan.js +17 -0
  52. package/dist/done-plan.js.map +1 -0
  53. package/dist/evidence-atom-schema.d.ts +18 -1
  54. package/dist/evidence-atom-schema.d.ts.map +1 -1
  55. package/dist/evidence-atom-schema.js +27 -2
  56. package/dist/evidence-atom-schema.js.map +1 -1
  57. package/dist/exit-codes.d.ts +40 -0
  58. package/dist/exit-codes.d.ts.map +1 -1
  59. package/dist/exit-codes.js +41 -0
  60. package/dist/exit-codes.js.map +1 -1
  61. package/dist/graph.d.ts +40 -0
  62. package/dist/graph.d.ts.map +1 -1
  63. package/dist/index.d.ts +14 -6
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +4 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/memory/observe.d.ts +16 -0
  68. package/dist/memory/observe.d.ts.map +1 -1
  69. package/dist/operations/__tests__/release-shared-surface.test.d.ts +10 -0
  70. package/dist/operations/__tests__/release-shared-surface.test.d.ts.map +1 -0
  71. package/dist/operations/__tests__/release-shared-surface.test.js +84 -0
  72. package/dist/operations/__tests__/release-shared-surface.test.js.map +1 -0
  73. package/dist/operations/dialectic.d.ts +16 -0
  74. package/dist/operations/dialectic.d.ts.map +1 -1
  75. package/dist/operations/memory.d.ts +48 -0
  76. package/dist/operations/memory.d.ts.map +1 -1
  77. package/dist/operations/nexus-scope-map.d.ts +8 -0
  78. package/dist/operations/nexus-scope-map.d.ts.map +1 -1
  79. package/dist/operations/nexus-scope-map.js +8 -0
  80. package/dist/operations/nexus-scope-map.js.map +1 -1
  81. package/dist/operations/nexus-user-profile.d.ts +27 -1
  82. package/dist/operations/nexus-user-profile.d.ts.map +1 -1
  83. package/dist/operations/nexus-user-profile.js +11 -1
  84. package/dist/operations/nexus-user-profile.js.map +1 -1
  85. package/dist/operations/nexus.d.ts +394 -3
  86. package/dist/operations/nexus.d.ts.map +1 -1
  87. package/dist/operations/release.d.ts +259 -0
  88. package/dist/operations/release.d.ts.map +1 -1
  89. package/dist/operations/release.js +130 -1
  90. package/dist/operations/release.js.map +1 -1
  91. package/dist/operations/session.d.ts +6 -4
  92. package/dist/operations/session.d.ts.map +1 -1
  93. package/dist/operations/tasks.d.ts +6 -0
  94. package/dist/operations/tasks.d.ts.map +1 -1
  95. package/dist/operations/tasks.js +1 -0
  96. package/dist/operations/tasks.js.map +1 -1
  97. package/dist/operations/validate.d.ts +5 -0
  98. package/dist/operations/validate.d.ts.map +1 -1
  99. package/dist/operations/worktree.d.ts +125 -1
  100. package/dist/operations/worktree.d.ts.map +1 -1
  101. package/dist/process-probe.d.ts +17 -0
  102. package/dist/process-probe.d.ts.map +1 -0
  103. package/dist/process-probe.js +21 -0
  104. package/dist/process-probe.js.map +1 -0
  105. package/dist/project-context.d.ts +44 -2
  106. package/dist/project-context.d.ts.map +1 -1
  107. package/dist/project-lifecycle.d.ts +143 -0
  108. package/dist/project-lifecycle.d.ts.map +1 -0
  109. package/dist/project-lifecycle.js +21 -0
  110. package/dist/project-lifecycle.js.map +1 -0
  111. package/dist/release/plan.d.ts +6 -2
  112. package/dist/release/plan.d.ts.map +1 -1
  113. package/dist/release/plan.js +8 -2
  114. package/dist/release/plan.js.map +1 -1
  115. package/dist/release/plan.test.js +6 -1
  116. package/dist/release/plan.test.js.map +1 -1
  117. package/dist/skills/install-gate.d.ts +109 -0
  118. package/dist/skills/install-gate.d.ts.map +1 -0
  119. package/dist/skills/install-gate.js +18 -0
  120. package/dist/skills/install-gate.js.map +1 -0
  121. package/dist/table-classification.d.ts +208 -0
  122. package/dist/table-classification.d.ts.map +1 -0
  123. package/dist/table-classification.js +23 -0
  124. package/dist/table-classification.js.map +1 -0
  125. package/dist/task.d.ts +79 -0
  126. package/dist/task.d.ts.map +1 -1
  127. package/package.json +6 -2
  128. package/schemas/gate-result.schema.json +43 -0
  129. package/src/__tests__/__snapshots__/operations-registry.test.ts.snap +1 -1
  130. package/src/__tests__/decide.test.ts +139 -0
  131. package/src/__tests__/evidence-atom-schema.test.ts +2 -1
  132. package/src/__tests__/memory-wire-shapes.test.ts +3 -0
  133. package/src/acceptance-gate-schema.ts +29 -0
  134. package/src/acceptance-gate.ts +68 -3
  135. package/src/backup-manifest.ts +13 -2
  136. package/src/boundary.ts +1 -1
  137. package/src/branch-lock.ts +36 -4
  138. package/src/cloud/api.ts +512 -0
  139. package/src/cloud/envelope.ts +63 -0
  140. package/src/cloud/ids.ts +51 -0
  141. package/src/cloud/index.ts +16 -0
  142. package/src/data-accessor.ts +24 -3
  143. package/src/decide.ts +355 -0
  144. package/src/dispatch/operations-registry.ts +58 -0
  145. package/src/done-plan.ts +350 -0
  146. package/src/evidence-atom-schema.ts +31 -2
  147. package/src/exit-codes.ts +42 -0
  148. package/src/graph.ts +44 -0
  149. package/src/index.ts +133 -0
  150. package/src/memory/observe.ts +16 -0
  151. package/src/operations/__tests__/release-shared-surface.test.ts +112 -0
  152. package/src/operations/dialectic.ts +17 -0
  153. package/src/operations/memory.ts +48 -0
  154. package/src/operations/nexus-scope-map.ts +9 -0
  155. package/src/operations/nexus-user-profile.ts +29 -1
  156. package/src/operations/nexus.ts +416 -3
  157. package/src/operations/release.ts +197 -0
  158. package/src/operations/session.ts +6 -4
  159. package/src/operations/tasks.ts +7 -0
  160. package/src/operations/validate.ts +5 -0
  161. package/src/operations/worktree.ts +133 -1
  162. package/src/process-probe.ts +21 -0
  163. package/src/project-context.ts +42 -2
  164. package/src/project-lifecycle.ts +149 -0
  165. package/src/release/plan.test.ts +6 -1
  166. package/src/release/plan.ts +8 -2
  167. package/src/skills/install-gate.ts +134 -0
  168. package/src/table-classification.ts +232 -0
  169. package/src/task.ts +84 -1
@@ -0,0 +1,512 @@
1
+ import { z } from 'zod';
2
+ import { Base64, DeviceId, Hlc, ProjectId, ReplicaId, Sha256Hex, StreamId } from './ids.js';
3
+
4
+ /**
5
+ * Wire contract for the Cleo Nexus API (v1).
6
+ *
7
+ * Privacy rule: the server never receives plaintext content (ADR-093). Everything below is
8
+ * routing or integrity metadata. Content travels only as ciphertext (`Base64`) or as blobs
9
+ * addressed by the sha256 of their ciphertext.
10
+ */
11
+
12
+ export const API_VERSION = 'v1' as const;
13
+
14
+ /**
15
+ * The highest key version any schema accepts. Key versions go up by one per rotation, so this is far above
16
+ * any real account, and it keeps an absurd value (2^53 - 1, say) out of client state.
17
+ */
18
+ export const MAX_KEY_VERSION = 1_000_000;
19
+
20
+ /** A master key or project key version: a positive integer, at most MAX_KEY_VERSION. */
21
+ export const KeyVersion = z.number().int().positive().max(MAX_KEY_VERSION);
22
+
23
+ /** Largest inline segment. Larger segments go to R2 as a blob and are referenced by sha256. */
24
+ export const MAX_INLINE_SEGMENT_BYTES = 1_048_576;
25
+ export const MAX_PULL_LIMIT = 500;
26
+
27
+ // ---------- devices ----------
28
+
29
+ export const Platform = z.enum(['darwin', 'linux', 'win32', 'other']);
30
+
31
+ export const RegisterDeviceRequest = z.object({
32
+ deviceId: DeviceId,
33
+ name: z.string().min(1).max(120),
34
+ platform: Platform,
35
+ /** X25519 public key, base64 (32 bytes). Used to wrap the user master key for this device. */
36
+ encryptionPublicKey: Base64,
37
+ /** Ed25519 public key, base64 (32 bytes). Verifies segments this device's replicas sign. */
38
+ signingPublicKey: Base64,
39
+ cliVersion: z.string().max(40),
40
+ });
41
+ export type RegisterDeviceRequest = z.infer<typeof RegisterDeviceRequest>;
42
+
43
+ export const Device = z.object({
44
+ deviceId: DeviceId,
45
+ name: z.string(),
46
+ platform: Platform,
47
+ encryptionPublicKey: Base64,
48
+ signingPublicKey: Base64,
49
+ cliVersion: z.string(),
50
+ createdAt: z.iso.datetime(),
51
+ lastSeenAt: z.iso.datetime().nullable(),
52
+ revokedAt: z.iso.datetime().nullable(),
53
+ /**
54
+ * The device certificate from this device's current key grant: an HMAC under a key derived from the
55
+ * user master key over deviceCertificateMessage(...). Only a holder of the master key can make one,
56
+ * so a client that holds the master key can tell the user's real devices from rows the server
57
+ * invented (docs/security/e2e-keys.md, "Device trust"). Null until the device holds a grant.
58
+ */
59
+ certificate: Base64.nullable(),
60
+ /** The master key version the certificate was made under. */
61
+ certificateKeyVersion: KeyVersion.nullable(),
62
+ });
63
+ export type Device = z.infer<typeof Device>;
64
+
65
+ export const ListDevicesResult = z.object({ devices: z.array(Device) });
66
+ export type ListDevicesResult = z.infer<typeof ListDevicesResult>;
67
+
68
+ // ---------- projects ----------
69
+
70
+ export const RegisterProjectRequest = z.object({
71
+ projectId: ProjectId,
72
+ /**
73
+ * Optional plaintext label for the web view. The client decides whether to reveal a name;
74
+ * the default is the encrypted name only.
75
+ */
76
+ label: z.string().max(120).optional(),
77
+ /** The project name, encrypted with the project data key. */
78
+ encryptedName: Base64.optional(),
79
+ /** The git origin, only if the user opts in (it can reveal a private repo's name). */
80
+ remoteUrl: z.string().max(500).optional(),
81
+ /**
82
+ * The organization that will own the project. The caller must be a member of it. Defaults to the
83
+ * caller's personal organization, which every account has.
84
+ */
85
+ organizationId: z.string().uuid().optional(),
86
+ });
87
+ export type RegisterProjectRequest = z.infer<typeof RegisterProjectRequest>;
88
+
89
+ export const Project = z.object({
90
+ projectId: ProjectId,
91
+ label: z.string().nullable(),
92
+ encryptedName: Base64.nullable(),
93
+ remoteUrl: z.string().nullable(),
94
+ /** Projects are owned by an organization. Access derives from membership (ADR-095, ownership). */
95
+ organizationId: z.string().uuid(),
96
+ /** Who registered the project. Provenance only: it grants nothing. Null once that account is deleted. */
97
+ createdByUserId: z.string().uuid().nullable(),
98
+ createdAt: z.iso.datetime(),
99
+ });
100
+ export type Project = z.infer<typeof Project>;
101
+
102
+ export const RegisterProjectResult = z.object({ project: Project, streamId: StreamId });
103
+ export type RegisterProjectResult = z.infer<typeof RegisterProjectResult>;
104
+
105
+ /**
106
+ * Which devices hold a project. The server stores NO filesystem path, not even as a hint.
107
+ * Paths are per-device local state (cleo-dev review, 2026-09-27).
108
+ */
109
+ export const AttachProjectDeviceRequest = z.object({
110
+ deviceId: DeviceId,
111
+ replicaId: ReplicaId,
112
+ });
113
+
114
+ /**
115
+ * Path-free presence of one replica, for fleet status (cleo-dev SG-AGENTIC-CORE, T12479).
116
+ * The device keeps the full record, including paths. The cloud mirrors only this.
117
+ */
118
+ export const ReplicaPresence = z.object({
119
+ git: z
120
+ .object({
121
+ /** Sent only when the user opts in: branch names can be sensitive. */
122
+ branch: z.string().max(200).optional(),
123
+ dirty: z.boolean(),
124
+ ahead: z.number().int().nonnegative(),
125
+ behind: z.number().int().nonnegative(),
126
+ /** The remote tracking state: in sync, diverged, no upstream, or unknown. */
127
+ remote: z.enum(['in-sync', 'ahead', 'behind', 'diverged', 'no-upstream', 'unknown']),
128
+ lastCommitAt: z.iso.datetime().optional(),
129
+ })
130
+ .optional(),
131
+ cliVersion: z.string().max(40),
132
+ schemaVersion: z.number().int().positive(),
133
+ /** When the device measured this. The server also records when it received it. */
134
+ observedAt: z.iso.datetime(),
135
+ });
136
+ export type ReplicaPresence = z.infer<typeof ReplicaPresence>;
137
+
138
+ // ---------- journal segments ----------
139
+
140
+ /**
141
+ * Per-table row deltas in a segment: plaintext counts, no content. They let the server check a
142
+ * checkpoint manifest for regressions without reading any row (E_REGRESSION).
143
+ */
144
+ export const TableDeltas = z.record(
145
+ z.string().regex(/^[a-z][a-z0-9_]{0,62}$/),
146
+ z.object({ created: z.number().int().nonnegative(), deleted: z.number().int().nonnegative() }),
147
+ );
148
+ export type TableDeltas = z.infer<typeof TableDeltas>;
149
+
150
+ export const AppendSegmentRequest = z
151
+ .object({
152
+ replicaId: ReplicaId,
153
+ deviceId: DeviceId,
154
+ /** sha256 of the ciphertext bytes. With `replicaId`, it is the idempotency key. */
155
+ segmentHash: Sha256Hex,
156
+ /** The replica's own monotonically increasing segment counter, so gaps are detectable. */
157
+ replicaSeq: z.number().int().nonnegative(),
158
+ schemaVersion: z.number().int().positive(),
159
+ opCount: z.number().int().positive(),
160
+ hlcMin: Hlc,
161
+ hlcMax: Hlc,
162
+ deltas: TableDeltas,
163
+ /**
164
+ * Ed25519 signature by the device signing key over segmentSigningMessage (v2), which covers the
165
+ * stream, replica, device, replicaSeq, the ciphertext hash and the hash of every metadata field
166
+ * above (segmentMetaCanonical). The server verifies it on append; every client re-verifies it on pull.
167
+ */
168
+ signature: Base64,
169
+ ciphertext: Base64.optional(),
170
+ /** Set instead of `ciphertext` for segments over MAX_INLINE_SEGMENT_BYTES (uploaded first). */
171
+ blobSha256: Sha256Hex.optional(),
172
+ })
173
+ .refine((r) => (r.ciphertext === undefined) !== (r.blobSha256 === undefined), {
174
+ message: 'exactly one of ciphertext or blobSha256 is required',
175
+ })
176
+ .refine((r) => r.hlcMin <= r.hlcMax, { message: 'hlcMin must not exceed hlcMax' });
177
+ export type AppendSegmentRequest = z.infer<typeof AppendSegmentRequest>;
178
+
179
+ export const AppendSegmentResult = z.object({
180
+ streamId: StreamId,
181
+ /** The server-assigned position in the stream. */
182
+ seq: z.number().int().positive(),
183
+ /** True when this exact segment was already stored. The retry was a no-op. */
184
+ duplicate: z.boolean(),
185
+ });
186
+ export type AppendSegmentResult = z.infer<typeof AppendSegmentResult>;
187
+
188
+ export const Segment = z.object({
189
+ seq: z.number().int().positive(),
190
+ replicaId: ReplicaId,
191
+ deviceId: DeviceId,
192
+ replicaSeq: z.number().int().nonnegative(),
193
+ segmentHash: Sha256Hex,
194
+ schemaVersion: z.number().int().positive(),
195
+ opCount: z.number().int().positive(),
196
+ hlcMin: Hlc,
197
+ hlcMax: Hlc,
198
+ deltas: TableDeltas,
199
+ signature: Base64,
200
+ ciphertext: Base64.nullable(),
201
+ blobSha256: Sha256Hex.nullable(),
202
+ receivedAt: z.iso.datetime(),
203
+ });
204
+ export type Segment = z.infer<typeof Segment>;
205
+
206
+ export const PullSegmentsQuery = z.object({
207
+ after: z.coerce.number().int().nonnegative().default(0),
208
+ limit: z.coerce.number().int().positive().max(MAX_PULL_LIMIT).default(100),
209
+ });
210
+
211
+ export const PullSegmentsResult = z.object({
212
+ streamId: StreamId,
213
+ segments: z.array(Segment),
214
+ /** The stream head at read time. When `nextAfter < head`, keep pulling. */
215
+ head: z.number().int().nonnegative(),
216
+ nextAfter: z.number().int().nonnegative(),
217
+ });
218
+ export type PullSegmentsResult = z.infer<typeof PullSegmentsResult>;
219
+
220
+ // ---------- checkpoints ----------
221
+
222
+ /**
223
+ * A checkpoint id (UUIDv7). The authoring client mints it, because its signature and the bundle's AAD
224
+ * both cover it, so the id must exist before the bundle is encrypted.
225
+ */
226
+ export const CheckpointId = z
227
+ .string()
228
+ .regex(
229
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
230
+ 'expected a UUIDv7 checkpoint id',
231
+ );
232
+ export type CheckpointId = z.infer<typeof CheckpointId>;
233
+
234
+ export const TableManifestEntry = z.object({
235
+ rows: z.number().int().nonnegative(),
236
+ /** Canonical content hash keyed by uid (Gate D). The same algorithm runs in core and on replay. */
237
+ hash: Sha256Hex,
238
+ });
239
+
240
+ /** Plaintext manifest: per-table counts and uid-keyed hashes. No content (ADR-093 §3). */
241
+ export const Manifest = z.object({
242
+ schemaVersion: z.number().int().positive(),
243
+ tables: z.record(z.string().regex(/^[a-z][a-z0-9_]{0,62}$/), TableManifestEntry),
244
+ });
245
+ export type Manifest = z.infer<typeof Manifest>;
246
+
247
+ /**
248
+ * Where one replica stood at a checkpoint: the device that signs its segments and the last replicaSeq
249
+ * folded in. A checkpoint lists every replica with a segment at or below its coversSeq, so a client that
250
+ * restores it can check every later segment for gaps and re-attribution (PullCursor).
251
+ */
252
+ export const ReplicaHead = z.object({
253
+ deviceId: DeviceId,
254
+ lastReplicaSeq: z.number().int().nonnegative(),
255
+ });
256
+ export type ReplicaHead = z.infer<typeof ReplicaHead>;
257
+
258
+ /** Per replica id, keys are replica ids. Covered by the checkpoint signature (replicasCanonical). */
259
+ export const ReplicaHeads = z.record(ReplicaId, ReplicaHead);
260
+ export type ReplicaHeads = z.infer<typeof ReplicaHeads>;
261
+
262
+ export const CreateCheckpointRequest = z.object({
263
+ /** Minted by the author: the signature and the bundle's AAD both cover it. */
264
+ checkpointId: CheckpointId,
265
+ replicaId: ReplicaId,
266
+ /** The authoring device. It must be the calling device. */
267
+ deviceId: DeviceId,
268
+ /** null only for a stream's genesis checkpoint. */
269
+ parentCheckpointId: z.string().uuid().nullable(),
270
+ /** The last stream seq whose ops are folded into this checkpoint. */
271
+ coversSeq: z.number().int().nonnegative(),
272
+ manifest: Manifest,
273
+ /** Every replica with a segment at or below coversSeq. The server checks it against the journal. */
274
+ replicas: ReplicaHeads,
275
+ /** The encrypted checkpoint bundle, uploaded to R2 first. */
276
+ blobSha256: Sha256Hex,
277
+ sizeBytes: z.number().int().positive(),
278
+ /**
279
+ * Ed25519 signature by the authoring device over checkpointSigningMessage(...): the stream, the
280
+ * checkpoint and parent ids, replica, device, coversSeq, the manifest hash (manifestCanonical), the
281
+ * replica map hash (replicasCanonical), the bundle sha256 and size. The server verifies it on create;
282
+ * a client verifies it before restoring. Creating the same checkpoint again is a no-op.
283
+ */
284
+ signature: Base64,
285
+ });
286
+ export type CreateCheckpointRequest = z.infer<typeof CreateCheckpointRequest>;
287
+
288
+ export const Checkpoint = z.object({
289
+ checkpointId: CheckpointId,
290
+ streamId: StreamId,
291
+ parentCheckpointId: CheckpointId.nullable(),
292
+ replicaId: ReplicaId,
293
+ deviceId: DeviceId,
294
+ coversSeq: z.number().int().nonnegative(),
295
+ manifest: Manifest,
296
+ replicas: ReplicaHeads,
297
+ blobSha256: Sha256Hex,
298
+ sizeBytes: z.number().int().positive(),
299
+ signature: Base64,
300
+ /**
301
+ * Re-signatures by other devices (checkpointEndorsementMessage). A client accepts a checkpoint whose
302
+ * author it no longer trusts without a pin (a revoked device) when a live device endorsed it.
303
+ */
304
+ endorsements: z.array(z.object({ deviceId: DeviceId, signature: Base64 })),
305
+ createdAt: z.iso.datetime(),
306
+ });
307
+ export type Checkpoint = z.infer<typeof Checkpoint>;
308
+
309
+ /** A live device re-signs a checkpoint. The device must be the calling device. */
310
+ export const EndorseCheckpointRequest = z.object({ deviceId: DeviceId, signature: Base64 });
311
+ export type EndorseCheckpointRequest = z.infer<typeof EndorseCheckpointRequest>;
312
+
313
+ export const CreateCheckpointResult = z.object({ checkpoint: Checkpoint });
314
+ export type CreateCheckpointResult = z.infer<typeof CreateCheckpointResult>;
315
+
316
+ /** Newest first, at most 100. */
317
+ export const ListCheckpointsResult = z.object({ checkpoints: z.array(Checkpoint) });
318
+ export type ListCheckpointsResult = z.infer<typeof ListCheckpointsResult>;
319
+
320
+ // ---------- blobs ----------
321
+
322
+ export const PresignUploadRequest = z.object({
323
+ sha256: Sha256Hex,
324
+ sizeBytes: z
325
+ .number()
326
+ .int()
327
+ .positive()
328
+ .max(5 * 1024 ** 3),
329
+ purpose: z.enum(['segment', 'checkpoint', 'attachment']),
330
+ });
331
+ export const PresignUploadResult = z.object({
332
+ /** True when the blob is already stored and verified. Skip the upload. */
333
+ alreadyPresent: z.boolean(),
334
+ uploadUrl: z.string().nullable(),
335
+ /** Headers the uploader must send exactly (signed). They include the sha256 checksum the store enforces. */
336
+ uploadHeaders: z.record(z.string(), z.string()).nullable(),
337
+ expiresAt: z.iso.datetime().nullable(),
338
+ });
339
+ export type PresignUploadResult = z.infer<typeof PresignUploadResult>;
340
+
341
+ export const CompleteUploadResult = z.object({ sha256: Sha256Hex, verified: z.literal(true) });
342
+ export type CompleteUploadResult = z.infer<typeof CompleteUploadResult>;
343
+
344
+ /**
345
+ * A short-lived presigned download URL. The response of `GET /v1/streams/:streamId/segments/:seq/blob`
346
+ * and of `GET /v1/streams/:streamId/checkpoints/:checkpointId/download`. The client downloads at most
347
+ * `sizeBytes` and checks the bytes against the sha256 it already trusts, never against this one alone.
348
+ */
349
+ export const BlobDownload = z.object({
350
+ url: z.string().min(1),
351
+ sha256: Sha256Hex,
352
+ sizeBytes: z.number().int().positive(),
353
+ expiresInSeconds: z.number().int().positive(),
354
+ });
355
+ export type BlobDownload = z.infer<typeof BlobDownload>;
356
+
357
+ // ---------- elected-writer leases (T12338) ----------
358
+
359
+ /**
360
+ * Roles that must run on exactly one replica per stream (cleo-dev review R4). Consolidation
361
+ * writes (invalid_at, citation_count), sentient proposals and checkpoint authoring would otherwise
362
+ * produce op storms or duplicates. Ordinary journal appends are not leased, because merge handles concurrency.
363
+ */
364
+ export const LeaseRole = z.enum(['consolidator', 'sentient', 'checkpointer']);
365
+ export type LeaseRole = z.infer<typeof LeaseRole>;
366
+
367
+ export const AcquireLeaseRequest = z.object({
368
+ role: LeaseRole,
369
+ replicaId: ReplicaId,
370
+ ttlSeconds: z.number().int().min(15).max(3600).default(300),
371
+ /** Take the lease from a live holder. The server records a labelled fork event. */
372
+ force: z.boolean().default(false),
373
+ reason: z.string().max(300).optional(),
374
+ });
375
+ export const Lease = z.object({
376
+ streamId: StreamId,
377
+ role: LeaseRole,
378
+ leaseId: z.string().uuid(),
379
+ replicaId: ReplicaId,
380
+ expiresAt: z.iso.datetime(),
381
+ /** Set when this lease was taken by force from another replica. */
382
+ forkedFromReplicaId: ReplicaId.nullable(),
383
+ });
384
+ export type Lease = z.infer<typeof Lease>;
385
+
386
+ // ---------- keys (E2E; the server stores wrapped keys only) ----------
387
+
388
+ /**
389
+ * Bounds on Argon2id parameters. The server stores the parameters, so a client must not trust them: too
390
+ * low weakens the passphrase, too high is a denial of service (a 64 GiB allocation). Both sides enforce these.
391
+ */
392
+ export const KDF_LIMITS = {
393
+ memoryKiB: { min: 65_536, max: 1_048_576 },
394
+ iterations: { min: 2, max: 10 },
395
+ parallelism: { min: 1, max: 16 },
396
+ } as const;
397
+
398
+ export const KdfParams = z.object({
399
+ algorithm: z.literal('argon2id'),
400
+ memoryKiB: z.number().int().min(KDF_LIMITS.memoryKiB.min).max(KDF_LIMITS.memoryKiB.max),
401
+ iterations: z.number().int().min(KDF_LIMITS.iterations.min).max(KDF_LIMITS.iterations.max),
402
+ parallelism: z.number().int().min(KDF_LIMITS.parallelism.min).max(KDF_LIMITS.parallelism.max),
403
+ salt: Base64,
404
+ });
405
+
406
+ export const PutUserKeysRequest = z.object({
407
+ /** The user master key wrapped by a passphrase-derived key (XChaCha20-Poly1305). */
408
+ passphraseWrappedMasterKey: Base64,
409
+ kdf: KdfParams,
410
+ /** The master key wrapped by the recovery key, which is shown to the user once. */
411
+ recoveryWrappedMasterKey: Base64,
412
+ /** sha256 of the master key's public verifier, so a wrong unwrap is detected client-side. */
413
+ masterKeyVerifier: Sha256Hex,
414
+ keyVersion: KeyVersion,
415
+ });
416
+
417
+ /** What the server returns for `GET /v1/account/keys`. */
418
+ export const UserKeys = PutUserKeysRequest.extend({
419
+ /** The account the keys belong to. */
420
+ userId: z.string().uuid(),
421
+ updatedAt: z.iso.datetime(),
422
+ });
423
+ export type UserKeys = z.infer<typeof UserKeys>;
424
+
425
+ /**
426
+ * A device key grant: the master key sealed to the recipient device, signed by a device that holds the
427
+ * master key (docs/security/e2e-keys.md, "Device trust"). The sealed box alone is anonymous, so anyone,
428
+ * the server included, could seal a key of their choosing to a device. The signature is what the
429
+ * recipient checks, against a signer it already trusts, before it accepts the key.
430
+ */
431
+ export const PutDeviceWrappedKeyRequest = z.object({
432
+ /** The master key sealed to the recipient device's X25519 key. */
433
+ sealedMasterKey: Base64,
434
+ keyVersion: KeyVersion,
435
+ /** The recipient's device certificate (see Device.certificate), made by the signer under the master key. */
436
+ certificate: Base64,
437
+ /** The device that made the grant. It must be the calling device: a self-grant names the recipient. */
438
+ signerDeviceId: DeviceId,
439
+ /** Ed25519 by the signer over deviceGrantMessage(...). */
440
+ grantSignature: Base64,
441
+ });
442
+ export type PutDeviceWrappedKeyRequest = z.infer<typeof PutDeviceWrappedKeyRequest>;
443
+
444
+ /** What the server returns for `GET /v1/devices/:deviceId/key`. */
445
+ export const DeviceKeyGrant = PutDeviceWrappedKeyRequest.extend({ deviceId: DeviceId });
446
+ export type DeviceKeyGrant = z.infer<typeof DeviceKeyGrant>;
447
+
448
+ /**
449
+ * Where a revoked signing key stops being trusted: per replica, the last replicaSeq it may have signed,
450
+ * and per stream, the highest coversSeq of a checkpoint it may have signed. Keys are replica ids and
451
+ * stream ids. Covered by the revocation signature (revocationPinsCanonical).
452
+ */
453
+ export const RevocationPins = z.object({
454
+ replicas: z.record(ReplicaId, z.number().int().nonnegative()),
455
+ checkpoints: z.record(StreamId, z.number().int().nonnegative()),
456
+ });
457
+ export type RevocationPins = z.infer<typeof RevocationPins>;
458
+
459
+ /**
460
+ * A revocation record (docs/security/e2e-keys.md, "Revocation"): a certified device signs that one
461
+ * signing key of a device is revoked, and pins what it signed before. History up to the pins stays
462
+ * verifiable; anything past them is refused. The signer must be the calling device.
463
+ */
464
+ export const CreateDeviceRevocationRequest = z.object({
465
+ /** The revoked key (base64 Ed25519 public key): the device's current key or an earlier one. */
466
+ signingPublicKey: Base64,
467
+ pins: RevocationPins,
468
+ signerDeviceId: DeviceId,
469
+ /** Ed25519 by the signer over deviceRevocationMessage(...). */
470
+ signature: Base64,
471
+ });
472
+ export type CreateDeviceRevocationRequest = z.infer<typeof CreateDeviceRevocationRequest>;
473
+
474
+ export const DeviceRevocation = CreateDeviceRevocationRequest.extend({ deviceId: DeviceId });
475
+ export type DeviceRevocation = z.infer<typeof DeviceRevocation>;
476
+
477
+ /**
478
+ * One device certificate ever issued, kept for as long as the device's history exists. A revoke or a
479
+ * re-registration with new keys never deletes one. `live` is the server's claim that the key is the
480
+ * device's current key and the device is not revoked; clients trust it only in the safe direction.
481
+ */
482
+ export const DeviceCertificateRecord = z.object({
483
+ deviceId: DeviceId,
484
+ encryptionPublicKey: Base64,
485
+ signingPublicKey: Base64,
486
+ keyVersion: KeyVersion,
487
+ certificate: Base64,
488
+ live: z.boolean(),
489
+ });
490
+ export type DeviceCertificateRecord = z.infer<typeof DeviceCertificateRecord>;
491
+
492
+ /** `GET /v1/devices/trust`: everything a client needs to build its trusted signer set (certifiedSigners). */
493
+ export const DeviceTrust = z.object({
494
+ certificates: z.array(DeviceCertificateRecord),
495
+ revocations: z.array(DeviceRevocation),
496
+ });
497
+ export type DeviceTrust = z.infer<typeof DeviceTrust>;
498
+
499
+ export const PutProjectKeyRequest = z.object({
500
+ /** The project data key, wrapped by the member's master key. */
501
+ wrappedProjectKey: Base64,
502
+ keyVersion: KeyVersion,
503
+ });
504
+
505
+ // ---------- conflicts (metadata only; the content stays E2E) ----------
506
+
507
+ export const ConflictSummary = z.object({
508
+ replicaId: ReplicaId,
509
+ open: z.number().int().nonnegative(),
510
+ byKind: z.record(z.string().max(60), z.number().int().nonnegative()),
511
+ reportedAt: z.iso.datetime(),
512
+ });
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Stable error codes. Clients branch on `code`, never on `message`.
5
+ * Codes that describe a refused sync action are never retried blindly: the client must
6
+ * reassess (pull, then re-verify) first.
7
+ */
8
+ export const ErrorCode = z.enum([
9
+ 'E_VALIDATION',
10
+ 'E_UNAUTHENTICATED',
11
+ 'E_FORBIDDEN',
12
+ 'E_NOT_FOUND',
13
+ 'E_CONFLICT',
14
+ 'E_RATE_LIMITED',
15
+ 'E_PAYLOAD_TOO_LARGE',
16
+ /** The manifest does not descend from the stream's current head checkpoint. */
17
+ 'E_LINEAGE',
18
+ /** The manifest loses rows that no tombstone in the covered segments accounts for. */
19
+ 'E_REGRESSION',
20
+ /** A segment or manifest uses a schemaVersion newer than the stream accepts. It is quarantined, never dropped. */
21
+ 'E_SCHEMA_AHEAD',
22
+ /** Another replica holds the exporter lease for this stream. */
23
+ 'E_LEASE_HELD',
24
+ /** The referenced blob has not been uploaded and verified yet. */
25
+ 'E_BLOB_MISSING',
26
+ 'E_BLOB_INTEGRITY',
27
+ 'E_UNAVAILABLE',
28
+ 'E_INTERNAL',
29
+ ]);
30
+ export type ErrorCode = z.infer<typeof ErrorCode>;
31
+
32
+ export const ErrorBody = z.object({
33
+ code: ErrorCode,
34
+ message: z.string(),
35
+ requestId: z.string(),
36
+ details: z.record(z.string(), z.unknown()).optional(),
37
+ });
38
+ export type ErrorBody = z.infer<typeof ErrorBody>;
39
+
40
+ export const ErrorEnvelope = z.object({ success: z.literal(false), error: ErrorBody });
41
+ export type ErrorEnvelope = z.infer<typeof ErrorEnvelope>;
42
+
43
+ export type OkEnvelope<T> = { success: true; data: T; meta: { requestId: string } };
44
+ export type Envelope<T> = OkEnvelope<T> | ErrorEnvelope;
45
+
46
+ /** HTTP status for each code, so server and client agree. */
47
+ export const errorStatus: Record<ErrorCode, number> = {
48
+ E_VALIDATION: 400,
49
+ E_UNAUTHENTICATED: 401,
50
+ E_FORBIDDEN: 403,
51
+ E_NOT_FOUND: 404,
52
+ E_CONFLICT: 409,
53
+ E_LINEAGE: 409,
54
+ E_REGRESSION: 409,
55
+ E_LEASE_HELD: 409,
56
+ E_SCHEMA_AHEAD: 422,
57
+ E_BLOB_MISSING: 424,
58
+ E_BLOB_INTEGRITY: 422,
59
+ E_PAYLOAD_TOO_LARGE: 413,
60
+ E_RATE_LIMITED: 429,
61
+ E_UNAVAILABLE: 503,
62
+ E_INTERNAL: 500,
63
+ };
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
4
+ const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
5
+ const LEGACY_PROJECT_ID = /^[0-9a-f]{12}$/;
6
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
7
+
8
+ /**
9
+ * A CLEO project id, read from `.cleo/project-id` (write-once, ADR-094 / T12325).
10
+ * New projects use a UUID. Older projects carry a 12-hex id. Paths are never identity.
11
+ */
12
+ export const ProjectId = z
13
+ .string()
14
+ .refine(
15
+ (s) => UUID.test(s) || LEGACY_PROJECT_ID.test(s),
16
+ 'expected a UUID or legacy 12-hex project id',
17
+ );
18
+ export type ProjectId = z.infer<typeof ProjectId>;
19
+
20
+ /** A replica is one `cleo.db` store (per store, not per machine; worktree copies are separate replicas). */
21
+ export const ReplicaId = z.string().regex(UUID_V7, 'expected a UUIDv7 replica id');
22
+ export type ReplicaId = z.infer<typeof ReplicaId>;
23
+
24
+ export const DeviceId = z.string().regex(UUID, 'expected a UUID device id');
25
+ export type DeviceId = z.infer<typeof DeviceId>;
26
+
27
+ export const Sha256Hex = z.string().regex(SHA256_HEX, 'expected lowercase hex sha256');
28
+ export type Sha256Hex = z.infer<typeof Sha256Hex>;
29
+
30
+ /**
31
+ * A journal stream. `project:<projectId>` carries portable·project ops. `home:<userId>` carries
32
+ * portable·personal ops for one account.
33
+ */
34
+ export const StreamId = z
35
+ .string()
36
+ .regex(
37
+ /^(project:[0-9a-f-]{12,36}|home:[A-Za-z0-9_-]{8,64})$/,
38
+ 'expected project:<id> or home:<userId>',
39
+ );
40
+ export type StreamId = z.infer<typeof StreamId>;
41
+
42
+ /**
43
+ * Hybrid logical clock, encoded so that lexical order is causal order:
44
+ * 13-digit zero-padded wall millis, 6-digit counter, and the replica id, joined by '-'. Example:
45
+ * 1790545492500-000003-0192f1c2-....
46
+ */
47
+ export const Hlc = z.string().regex(/^\d{13}-\d{6}-[0-9a-f-]{36}$/, 'expected an encoded HLC');
48
+ export type Hlc = z.infer<typeof Hlc>;
49
+
50
+ /** Base64 (standard alphabet, padded). Used for ciphertext and wrapped keys on the wire. */
51
+ export const Base64 = z.string().regex(/^[A-Za-z0-9+/]*={0,2}$/, 'expected base64');
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `@cleocode/contracts/cloud`: the Cleo Nexus cloud wire contract (API v1).
3
+ *
4
+ * Zod schemas, inferred types and const data only (arch gate 10). The runtime (E2E crypto, key
5
+ * hierarchy, journal push/pull, manifest regression check, signing message) lives in
6
+ * `@cleocode/core` under `src/cloud`. The server implementation is in the cleo-nexus repo, which keeps
7
+ * a byte-compatible copy of these schemas and checks it against this file in CI.
8
+ *
9
+ * Privacy rule (ADR-093): nothing here carries plaintext content or a filesystem path.
10
+ *
11
+ * @module cloud
12
+ * @see ADR-095 (cleo-nexus docs/adr)
13
+ */
14
+ export * from './api.js';
15
+ export * from './envelope.js';
16
+ export * from './ids.js';
@@ -273,6 +273,22 @@ export interface TaskAuditLogRow {
273
273
  afterJson: string | null;
274
274
  }
275
275
 
276
+ /**
277
+ * Optimistic-concurrency guard for a single-task write (T12503).
278
+ *
279
+ * A task's version is its `updatedAt` timestamp (falling back to `createdAt`
280
+ * for a row that was never updated). Writers keep it strictly increasing, so a
281
+ * caller that read version `v` and passes `expectedUpdatedAt: v` either writes
282
+ * against exactly the row it read or fails with `E_CONFLICT`
283
+ * (`ExitCode.VERSION_CONFLICT`) carrying the current version. The comparison
284
+ * runs inside the write transaction, after `BEGIN IMMEDIATE` holds the lock.
285
+ * Omitting the guard keeps last-writer-wins semantics.
286
+ */
287
+ export interface TaskWriteGuard {
288
+ /** Version the caller read; the write fails with `E_CONFLICT` if the row moved on. */
289
+ expectedUpdatedAt?: string;
290
+ }
291
+
276
292
  /**
277
293
  * Subset of DataAccessor methods available inside a transaction callback.
278
294
  * Write-only — reads use the outer accessor (snapshot isolation).
@@ -282,7 +298,7 @@ export interface TransactionAccessor {
282
298
  archiveSingleTask(taskId: string, fields: ArchiveFields): Promise<void>;
283
299
  removeSingleTask(taskId: string): Promise<void>;
284
300
  setMetaValue(key: string, value: unknown): Promise<void>;
285
- updateTaskFields(taskId: string, fields: TaskFieldUpdates): Promise<void>;
301
+ updateTaskFields(taskId: string, fields: TaskFieldUpdates, guard?: TaskWriteGuard): Promise<void>;
286
302
  /** Get direct non-archived children inside the caller-owned transaction. @task T10590 */
287
303
  getChildren(parentId: string): Promise<Task[]>;
288
304
  appendLog(entry: Record<string, unknown>): Promise<void>;
@@ -499,8 +515,13 @@ export interface DataAccessor {
499
515
 
500
516
  // ---- Targeted write methods (Phase 2 modernization) ----
501
517
 
502
- /** Update specific fields on a task without full load/save cycle. */
503
- updateTaskFields(taskId: string, fields: TaskFieldUpdates): Promise<void>;
518
+ /**
519
+ * Update specific fields on a task without full load/save cycle.
520
+ *
521
+ * With `guard.expectedUpdatedAt`, the stored version is compared inside the
522
+ * write transaction and a mismatch throws `E_CONFLICT` (T12503).
523
+ */
524
+ updateTaskFields(taskId: string, fields: TaskFieldUpdates, guard?: TaskWriteGuard): Promise<void>;
504
525
 
505
526
  /** Get next available position for a task within a parent scope (SQL-level, race-safe). */
506
527
  getNextPosition(parentId: string | null): Promise<number>;