brainclaw 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Le projecteur et ses trois filets — fédération v2 (pln#651 étape 5, RFC §4).
3
+ *
4
+ * C'est ici que la table de classification devient EXÉCUTABLE. Trois classes, et
5
+ * « non classé » n'en est pas une quatrième : c'est un refus.
6
+ *
7
+ * ── POURQUOI TROIS FILETS ET NON UN SEUL ──────────────────────────────────────
8
+ * Établi par quatre critiques sur quatre pendant l'idéation, chacune par un angle
9
+ * différent :
10
+ * (i) Zod fait `.strip()` PAR DÉFAUT — un champ nouveau passe la validation en
11
+ * silence, sans jamais lever ;
12
+ * (ii) un test couvre la SORTIE, pas la CONSTRUCTION — N projecteurs, un seul testé,
13
+ * et les N-1 autres fuient (application littérale de trp#5a8fb7d9) ;
14
+ * (iii) `{...entity, ciphertext}` compile parfaitement et déverse tout.
15
+ *
16
+ * Et le typage seul ne borne RIEN. `pushToCloud(payload: { id: string })` suivi d'un
17
+ * `JSON.stringify` sérialise chaque clé présente à L'EXÉCUTION : le typage structurel de
18
+ * TypeScript est une BORNE INFÉRIEURE sur ce que l'objet contient, jamais une borne
19
+ * supérieure. C'est la raison pour laquelle le filet 2 existe malgré le filet 1.
20
+ *
21
+ * FILET 1 — un builder nominal, marque de type non fabricable ailleurs, sélection
22
+ * explicite champ par champ, JAMAIS de spread.
23
+ * FILET 2 — un parse `.strict()` à UN SEUL point de sortie que tout push traverse.
24
+ * FILET 3 — fixture golden byte-exacte + test de complétude sur l'inventaire.
25
+ *
26
+ * ── CE QUI RESTE VISIBLE, ÉCRIT ET NON MASQUÉ ─────────────────────────────────
27
+ * « Lane 3 » cache le libellé, pas le GRAPHE. Le nombre de lanes, la forme du graphe de
28
+ * dépendances et la cardinalité restent lisibles par le Cloud. C'est assumé : le board
29
+ * aveugle rend une structure, et une structure est une information. Le prétendre
30
+ * autrement serait mentir sur la garantie.
31
+ */
32
+ import crypto from 'node:crypto';
33
+ import { z } from 'zod';
34
+ import { canonicalJson, canonicalSha256, b64url } from './federation-canonical.js';
35
+ import { seal, HPKE_SUITE } from './federation-hpke.js';
36
+ export const ENVELOPE_SCHEMA = 'brainclaw.federation-envelope/v1';
37
+ export const AAD_PROTOCOL = 'brainclaw/federation/v1';
38
+ /**
39
+ * Types d'entités PROJETABLES. Une famille absente de cette liste n'est pas « à faire
40
+ * plus tard » : son schéma entier est interdit de sortie (RFC §4.3). L'ajout d'une
41
+ * famille est un acte délibéré qui passe par la table de classification.
42
+ */
43
+ export const FEDERATED_KINDS = [
44
+ 'constraint', 'decision', 'trap', 'handoff', 'plan', 'plan_step', 'sequence',
45
+ 'claim', 'candidate', 'runtime_note', 'inbox_message', 'assignment',
46
+ 'agent_run', 'action_required', 'ai_task', 'runtime_event', 'lane_result',
47
+ ];
48
+ // ── Champs INTERDITS DE SORTIR (RFC §4.2, classe 3) ──────────────────────────
49
+ /**
50
+ * Ces noms ne doivent apparaître NI dans meta NI dans sealed. Leur présence fait échouer
51
+ * la projection — un refus, pas une troncature.
52
+ *
53
+ * POURQUOI UNE LISTE DE NOMS ICI ALORS QUE J'AI ÉCRIT AILLEURS QU'UNE LISTE DE NOMS NE
54
+ * SUFFIT PAS : elle ne suffit effectivement pas SEULE, et n'est pas seule. Le filet 1
55
+ * empêche déjà tout champ non explicitement sélectionné d'entrer dans meta, et le filet 2
56
+ * refuse toute clé inconnue à la sortie. Cette liste est un TROISIÈME contrôle qui vise
57
+ * le SCELLÉ — la seule partie que les deux autres filets ne peuvent pas inspecter,
58
+ * puisqu'ils la traitent comme opaque. Sans elle, `worktree_path` chiffré partirait quand
59
+ * même : chiffré n'est pas « autorisé à sortir ».
60
+ */
61
+ export const FORBIDDEN_LEAF_NAMES = new Set([
62
+ 'host_id', 'session_id', 'worktree_path', 'project_path', 'storage_dir', 'related_paths',
63
+ 'command', 'shell', 'pid', 'provider_run_id', 'base_sha', 'cwd',
64
+ 'api_key', 'apiKey', 'secret', 'token', 'password', 'private_key', 'privateKey',
65
+ 'env', 'environment',
66
+ ]);
67
+ /** Motifs de VALEURS trahissant un chemin local, quel que soit le nom du champ. */
68
+ const LOCAL_PATH_PATTERNS = [
69
+ /^[A-Za-z]:[\\/]/, // C:\... ou C:/...
70
+ /^\/(?:home|Users|root)\//, // /home/x, /Users/x, /root/x
71
+ /\.brainclaw[\\/]worktrees/,
72
+ ];
73
+ export class ProjectionRefused extends Error {
74
+ path;
75
+ constructor(message, path) {
76
+ super(message);
77
+ this.path = path;
78
+ this.name = 'ProjectionRefused';
79
+ }
80
+ }
81
+ /**
82
+ * Parcours RÉCURSIF refusant tout champ interdit, par son nom ou par la forme de sa
83
+ * valeur.
84
+ *
85
+ * Appliqué au CLAIR AVANT scellement, donc y compris à ce qui partira chiffré. Le RFC est
86
+ * explicite : la classe interdite est « absente de meta ET de sealed ». Un chemin de
87
+ * worktree chiffré reste une donnée qui a quitté l'hôte, et le jour où la clé fuit, elle
88
+ * a fui aussi. Le chiffrement protège le contenu ; il ne rend pas licite de l'envoyer.
89
+ */
90
+ export function assertNoForbiddenLeaf(value, path = '$') {
91
+ if (value === null || value === undefined)
92
+ return;
93
+ if (typeof value === 'string') {
94
+ for (const pattern of LOCAL_PATH_PATTERNS) {
95
+ if (pattern.test(value)) {
96
+ throw new ProjectionRefused(`Chemin local détecté en ${path} — interdit de sortir, même scellé (RFC §4.2).`, path);
97
+ }
98
+ }
99
+ return;
100
+ }
101
+ if (Array.isArray(value)) {
102
+ value.forEach((v, i) => assertNoForbiddenLeaf(v, `${path}[${i}]`));
103
+ return;
104
+ }
105
+ if (typeof value === 'object') {
106
+ for (const [key, v] of Object.entries(value)) {
107
+ if (FORBIDDEN_LEAF_NAMES.has(key)) {
108
+ throw new ProjectionRefused(`Champ interdit '${key}' en ${path} — absent de meta ET de sealed (RFC §4.2).`, `${path}.${key}`);
109
+ }
110
+ assertNoForbiddenLeaf(v, `${path}.${key}`);
111
+ }
112
+ }
113
+ }
114
+ // ── FILET 2 : le schéma strict du point de sortie unique ─────────────────────
115
+ /**
116
+ * `.strict()` PARTOUT, et c'est le point.
117
+ *
118
+ * Zod `.strip()` — le défaut — accepterait un objet porteur d'une clé inconnue et la
119
+ * retirerait SILENCIEUSEMENT du résultat. Le push passerait, et personne n'apprendrait
120
+ * qu'un champ non classé a été ajouté au projecteur. `.strict()` LÈVE, ce qui transforme
121
+ * l'oubli de classification en échec de CI plutôt qu'en fuite discrète.
122
+ */
123
+ const CanonicalAadSchema = z.object({
124
+ protocol: z.literal(AAD_PROTOCOL),
125
+ cloud_project_id: z.string().min(1),
126
+ object_id: z.string().min(1),
127
+ base_rev: z.number().int().nonnegative(),
128
+ object_type: z.enum(FEDERATED_KINDS),
129
+ schema: z.literal(ENVELOPE_SCHEMA),
130
+ }).strict();
131
+ const PublicMetaSchema = z.object({
132
+ id_opaque: z.string().uuid(),
133
+ kind: z.enum(FEDERATED_KINDS),
134
+ status: z.object({
135
+ object: z.string().min(1),
136
+ sync: z.enum(['pending', 'synced', 'conflict']).optional(),
137
+ }).strict(),
138
+ priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
139
+ rank: z.number().int().optional(),
140
+ deps: z.array(z.object({ from: z.string().uuid(), to: z.string().uuid() }).strict()),
141
+ // Jour UTC, JAMAIS l'heure. Une heure précise trahit le rythme de travail d'une
142
+ // personne ; le jour ne dit que la cadence, ce que le RFC assume explicitement.
143
+ timestamp_bucket_jour: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
144
+ base_rev: z.number().int().nonnegative(),
145
+ aad: CanonicalAadSchema,
146
+ // Ne contient NI nom NI empreinte de destinataire : la référence de paquet de clés
147
+ // aurait divulgué le roster, canal de fuite que l'idéation avait manqué.
148
+ wrap_hint: z.string().min(1),
149
+ transport: z.object({
150
+ operation_id: z.string().min(1),
151
+ content_hash: z.string().min(1),
152
+ idempotency_key: z.string().min(1),
153
+ }).strict(),
154
+ }).strict();
155
+ const SealedSchema = z.object({
156
+ alg: z.literal(HPKE_SUITE),
157
+ enc: z.string().min(1),
158
+ nonce: z.string().min(1),
159
+ ciphertext: z.string().min(1),
160
+ }).strict();
161
+ export const FederationEnvelopeSchema = z.object({
162
+ schema: z.literal(ENVELOPE_SCHEMA),
163
+ meta: PublicMetaSchema,
164
+ sealed: SealedSchema,
165
+ key_epoch: z.number().int().nonnegative(),
166
+ origin_sig: z.object({
167
+ alg: z.literal('Ed25519'),
168
+ key_id: z.string().min(1),
169
+ value: z.string().min(1),
170
+ }).strict(),
171
+ }).strict();
172
+ /** Jour UTC. La troncature est faite ICI pour qu'aucun appelant ne puisse l'oublier. */
173
+ function dayBucket(when) {
174
+ const d = typeof when === 'string' ? new Date(when) : when;
175
+ if (Number.isNaN(d.getTime()))
176
+ throw new ProjectionRefused('Date invalide pour le bucket jour.', '$.occurredAt');
177
+ return d.toISOString().slice(0, 10);
178
+ }
179
+ /**
180
+ * Construit la projection publique — SÉLECTION EXPLICITE, CHAMP PAR CHAMP.
181
+ *
182
+ * Aucun spread nulle part dans cette fonction, et c'est délibéré : `{...input}` suffirait
183
+ * à déverser tout ce que l'appelant a mis dans l'objet. Chaque champ de meta est nommé
184
+ * ici ou n'existe pas. Ajouter un champ à `PublicMetaSchema` sans l'ajouter ici produit
185
+ * une erreur de compilation, et l'inverse un échec de parse `.strict()`.
186
+ *
187
+ * `priority` et `rank` ne sont posés QUE s'ils existent : l'absence d'un champ optionnel
188
+ * est SIGNIFICATIVE (RFC §3). Inventer `priority: 'medium'` pour un objet qui n'en porte
189
+ * pas créerait une fuite par normalisation — le Cloud croirait à une priorité choisie.
190
+ */
191
+ export function toPublicProjection(input) {
192
+ const aad = {
193
+ protocol: AAD_PROTOCOL,
194
+ cloud_project_id: input.cloudProjectId,
195
+ object_id: input.idOpaque,
196
+ base_rev: input.baseRev,
197
+ object_type: input.kind,
198
+ schema: ENVELOPE_SCHEMA,
199
+ };
200
+ const meta = {
201
+ id_opaque: input.idOpaque,
202
+ kind: input.kind,
203
+ status: input.syncState
204
+ ? { object: input.statusObject, sync: input.syncState }
205
+ : { object: input.statusObject },
206
+ deps: (input.deps ?? []).map((d) => ({ from: d.from, to: d.to })),
207
+ timestamp_bucket_jour: dayBucket(input.occurredAt),
208
+ base_rev: input.baseRev,
209
+ aad,
210
+ wrap_hint: input.wrapHint,
211
+ transport: {
212
+ operation_id: input.operationId,
213
+ // Dérivés du CIPHERTEXT, jamais du clair (RFC §3.2). En v1, content_hash portait
214
+ // sur le corps sémantique en clair : le Cloud pouvait alors confirmer une
215
+ // devinette sur un contenu à faible entropie en comparant des hachages.
216
+ content_hash: canonicalSha256(input.sealed),
217
+ idempotency_key: '',
218
+ },
219
+ };
220
+ if (input.priority !== undefined)
221
+ meta.priority = input.priority;
222
+ if (input.rank !== undefined)
223
+ meta.rank = input.rank;
224
+ return meta;
225
+ }
226
+ /**
227
+ * LE SEUL point par lequel une enveloppe peut être produite.
228
+ *
229
+ * L'ordre des opérations est un contrat, pas un détail :
230
+ * 1. refus des champs interdits sur le CLAIR (avant qu'ils ne deviennent illisibles) ;
231
+ * 2. AAD canonique, lié au projet, à l'objet, à la révision et au type ;
232
+ * 3. scellement HPKE avec cet AAD exact ;
233
+ * 4. projection publique par le filet 1 ;
234
+ * 5. dérivés de transport calculés sur le CIPHERTEXT ;
235
+ * 6. signature d'origine sur meta ‖ sealed ‖ key_epoch ;
236
+ * 7. parse `.strict()` — le filet 2 — qui refuse toute clé inconnue.
237
+ *
238
+ * Inverser 1 et 3 laisserait passer un champ interdit dans le blob chiffré.
239
+ */
240
+ export function buildEnvelope(params) {
241
+ // (1) Le contrôle porte sur le clair, y compris ce qui sera scellé.
242
+ assertNoForbiddenLeaf(params.content, '$.content');
243
+ // (2) AAD comme STRUCTURE canonique et non concaténation ambiguë : « a|b » et « a|b »
244
+ // issus de découpages différents produisent la même chaîne, donc deux contextes
245
+ // distincts indiscernables.
246
+ const aad = {
247
+ protocol: AAD_PROTOCOL,
248
+ cloud_project_id: params.cloudProjectId,
249
+ object_id: params.idOpaque,
250
+ base_rev: params.baseRev,
251
+ object_type: params.kind,
252
+ schema: ENVELOPE_SCHEMA,
253
+ };
254
+ const aadBytes = new TextEncoder().encode(canonicalJson(aad));
255
+ // (3)
256
+ const sealed = seal({
257
+ recipientPublicKeyPem: params.recipientPublicKeyPem,
258
+ plaintext: new TextEncoder().encode(canonicalJson(params.content)),
259
+ aadCanonicalBytes: aadBytes,
260
+ });
261
+ // (4)
262
+ const meta = toPublicProjection({
263
+ kind: params.kind,
264
+ idOpaque: params.idOpaque,
265
+ cloudProjectId: params.cloudProjectId,
266
+ baseRev: params.baseRev,
267
+ statusObject: params.statusObject,
268
+ syncState: params.syncState,
269
+ priority: params.priority,
270
+ rank: params.rank,
271
+ deps: params.deps,
272
+ occurredAt: params.occurredAt,
273
+ wrapHint: params.wrapHint,
274
+ operationId: params.operationId,
275
+ sealed,
276
+ });
277
+ // (5) idempotency_key = SHA-256(canonical(sealed) || operation_id || origin_sig.key_id).
278
+ // CLEFÉE par l'identité du signataire : deux agents qui pousseraient le même contenu
279
+ // n'auraient pas la même clé, donc le Cloud ne peut pas déduire qu'ils poussent la
280
+ // même chose.
281
+ meta.transport.idempotency_key = b64url(new Uint8Array(crypto.createHash('sha256')
282
+ .update(canonicalJson(sealed), 'utf-8')
283
+ .update(params.operationId, 'utf-8')
284
+ .update(params.originKeyId, 'utf-8')
285
+ .digest()));
286
+ // (6) L'entrée couvre alg, enc, nonce ET ciphertext, plus key_epoch — pas seulement
287
+ // « meta || ciphertext ». Le raccourci ne couvrirait pas les paramètres permettant
288
+ // d'INTERPRÉTER le ciphertext, qu'un Cloud pourrait alors modifier sans casser la
289
+ // signature.
290
+ const signingInput = Buffer.concat([
291
+ Buffer.from('brainclaw/federation-envelope/v1\0', 'utf-8'),
292
+ Buffer.from(canonicalJson(meta), 'utf-8'),
293
+ Buffer.from(canonicalJson(sealed), 'utf-8'),
294
+ Buffer.from(canonicalJson(params.keyEpoch), 'utf-8'),
295
+ ]);
296
+ const signature = crypto.sign(null, signingInput, crypto.createPrivateKey(params.originPrivateKeyPem));
297
+ const envelope = {
298
+ schema: ENVELOPE_SCHEMA,
299
+ meta,
300
+ sealed,
301
+ key_epoch: params.keyEpoch,
302
+ origin_sig: { alg: 'Ed25519', key_id: params.originKeyId, value: signature.toString('base64url') },
303
+ };
304
+ // (7) FILET 2 — fail-closed. Une clé inconnue lève ici plutôt que de partir sur le fil.
305
+ return FederationEnvelopeSchema.parse(envelope);
306
+ }
307
+ /**
308
+ * Octets exacts sur lesquels porte la signature d'origine.
309
+ *
310
+ * Exporté pour que le VÉRIFICATEUR les reconstruise avec la même fonction que
311
+ * l'émetteur. Deux reconstructions indépendantes est précisément le défaut qui a rendu
312
+ * l'attestation d'appairage insatisfiable côté Cloud : le vérificateur fabriquait un
313
+ * champ que le signataire ne pouvait pas connaître.
314
+ */
315
+ export function originSigningInput(meta, sealed, keyEpoch) {
316
+ return Buffer.concat([
317
+ Buffer.from('brainclaw/federation-envelope/v1\0', 'utf-8'),
318
+ Buffer.from(canonicalJson(meta), 'utf-8'),
319
+ Buffer.from(canonicalJson(sealed), 'utf-8'),
320
+ Buffer.from(canonicalJson(keyEpoch), 'utf-8'),
321
+ ]);
322
+ }
323
+ // ── Ids opaques ──────────────────────────────────────────────────────────────
324
+ /**
325
+ * Ids RE-ROULÉS en UUID v4, mapping local↔cloud gardé LOCAL.
326
+ *
327
+ * Pourquoi ne pas simplement hacher l'id local : un hachage est déterministe, donc le
328
+ * même objet exporté vers deux projets Cloud produirait le même identifiant, et un
329
+ * observateur corrélerait les deux projets. Un UUID aléatoire par projet coupe cette
330
+ * corrélation — c'est le sens de « stable dans un projet Cloud, pas cross-projet »
331
+ * (RFC §4.1).
332
+ */
333
+ export function newOpaqueId() {
334
+ return crypto.randomUUID();
335
+ }
336
+ //# sourceMappingURL=federation-projection.js.map
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Commandes cloud matérialisées localement — le RELAIS (pln#651 étape 7, dec#154).
3
+ *
4
+ * ── CE QUI SURVIT INTACT AU CHIFFREMENT ───────────────────────────────────────
5
+ * À écrire d'emblée, pour qu'on ne « résolve » pas un faux problème : `base_rev` compare
6
+ * un IDENTIFIANT DE RÉVISION, pas du contenu. L'idempotence, `operation_id`, la
7
+ * provenance et l'audit ne référencent que des ids et des actions. Rien de tout cela n'a
8
+ * besoin de lire le clair, et le chiffrement ne gêne donc en rien ce module.
9
+ *
10
+ * LE RELAIS N'ÉCRIT JAMAIS DE CONTENU. Le contenu reste en écriture depuis le local
11
+ * uniquement. Une commande venue du dashboard porte sur des MÉTADONNÉES : priorité,
12
+ * ordre, rang, statut, métadonnées de roadmap. C'est la conséquence directe de dec#154 —
13
+ * « le Cloud est une projection et un relais ; le local est la source de vérité ».
14
+ *
15
+ * ── LA TROISIÈME CLASSE D'APPELANTS (dec#155) ─────────────────────────────────
16
+ * Le relais cloud n'a NI session, NI cwd, NI contexte ambiant : seulement un id d'entité
17
+ * et un `base_rev`. C'est le cas le plus pur du routage autoritatif par l'entité, déjà en
18
+ * place côté core depuis la 1.21.0.
19
+ *
20
+ * NE PAS RÉINVENTER DE RÉSOLUTION AMBIANTE POUR LUI. La tentation est réelle — « si le
21
+ * projet n'est pas précisé, prendre le projet actif » — et c'est exactement la dérive que
22
+ * pln#648/649 ont corrigée pour le routage local. Une commande sans cible résoluble est
23
+ * REFUSÉE, jamais devinée.
24
+ *
25
+ * ── LES CONFLITS ──────────────────────────────────────────────────────────────
26
+ * Présentés avec une proposition de résolution, JAMAIS de last-write-wins silencieux. Et
27
+ * c'est le contrat de refus de T3 exprimé sur un autre transport : une divergence PROUVÉE
28
+ * refuse et nomme ; une ABSENCE retombe sur la réponse ambiante. Le même mécanisme, pas
29
+ * un second.
30
+ */
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { z } from 'zod';
34
+ import { memoryDir, writeFileAtomic } from './io.js';
35
+ import { nowISO } from './ids.js';
36
+ import { canonicalJson } from './federation-canonical.js';
37
+ import { logger } from './logger.js';
38
+ const JOURNAL_DIR = ['coordination', 'federation', 'commands'];
39
+ /**
40
+ * Les seuls champs qu'une commande cloud peut toucher.
41
+ *
42
+ * LISTE FERMÉE ET NON EXTENSIBLE PAR CONFIGURATION : c'est la traduction exécutable de
43
+ * « le relais n'écrit jamais de contenu ». Ajouter `text` ou `description` ici ferait du
44
+ * Cloud une source d'écriture de contenu et retournerait dec#154.
45
+ */
46
+ export const RELAYABLE_FIELDS = ['priority', 'rank', 'status'];
47
+ export const CloudCommandSchema = z.object({
48
+ /** Identité de l'opération, créée par l'émetteur et REJOUÉE à l'identique en cas de retry. */
49
+ operation_id: z.string().min(1),
50
+ /** Cible : un id d'entité opaque. Pas de projet, pas de cwd, pas de session (dec#155). */
51
+ object_id: z.string().min(1),
52
+ /** Révision sur laquelle l'émetteur s'appuie. Un décalage produit un conflit VISIBLE. */
53
+ base_rev: z.number().int().nonnegative(),
54
+ field: z.enum(RELAYABLE_FIELDS),
55
+ value: z.union([z.string(), z.number()]),
56
+ /** Identité Ed25519 de l'émetteur — audité, pas cru sur parole. */
57
+ issued_by: z.string().min(1),
58
+ issued_at: z.string().min(1),
59
+ }).strict();
60
+ function journalDir(cwd) {
61
+ return path.join(memoryDir(cwd), ...JOURNAL_DIR);
62
+ }
63
+ /**
64
+ * Le journal est indexé par `operation_id` — c'est ce qui rend le rejeu inoffensif.
65
+ *
66
+ * Un index par (object_id, base_rev) ne suffirait pas : deux commandes distinctes peuvent
67
+ * légitimement viser la même révision d'un même objet (changer la priorité, puis le rang).
68
+ */
69
+ function entryPath(cwd, operationId) {
70
+ // Le nom de fichier est assaini : un operation_id venu du réseau ne doit pas pouvoir
71
+ // écrire hors du répertoire du journal via '../'.
72
+ const safe = operationId.replace(/[^A-Za-z0-9_.-]/g, '_');
73
+ return path.join(journalDir(cwd), `${safe}.json`);
74
+ }
75
+ export function loadJournalEntry(cwd, operationId) {
76
+ const filepath = entryPath(cwd, operationId);
77
+ if (!fs.existsSync(filepath))
78
+ return undefined;
79
+ try {
80
+ return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
81
+ }
82
+ catch (err) {
83
+ // Une entrée illisible n'est PAS traitée comme absente : la traiter ainsi ferait
84
+ // réappliquer une commande déjà appliquée, ce que tout ce module cherche à empêcher.
85
+ logger.warn(`Entrée de journal illisible (${operationId}) : ${err instanceof Error ? err.message : String(err)}`);
86
+ // `cause` conservée : sans elle, un opérateur voit « journal corrompu » sans savoir
87
+ // si c'est un JSON tronqué, un encodage cassé ou un disque plein.
88
+ throw new Error(`Journal corrompu pour l'opération ${operationId} — refus d'appliquer à l'aveugle.`, { cause: err });
89
+ }
90
+ }
91
+ function writeEntry(cwd, entry) {
92
+ const filepath = entryPath(cwd, entry.operation_id);
93
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
94
+ writeFileAtomic(filepath, `${JSON.stringify(entry, null, 2)}\n`);
95
+ }
96
+ /**
97
+ * Applique une commande cloud au journal local.
98
+ *
99
+ * `resolveLocalRev` est INJECTÉ plutôt que lu ici : ce module ne connaît pas le stockage
100
+ * des entités, et lui donner cette connaissance en ferait un second chemin d'écriture sur
101
+ * la mémoire — précisément ce que « le relais n'écrit jamais de contenu » interdit. Il
102
+ * écrit le journal ; c'est l'appelant qui, à partir du journal, applique un changement de
103
+ * métadonnée par la voie locale normale.
104
+ *
105
+ * TROIS ISSUES ET AUCUN ÉCRASEMENT SILENCIEUX :
106
+ * applied — base_rev correspond, l'effet est enregistré ;
107
+ * duplicate — operation_id déjà connu, aucun second effet (idempotence) ;
108
+ * conflict — base_rev périmé : l'entrée est écrite en état `conflict` AVEC une
109
+ * proposition, et attend une décision humaine.
110
+ */
111
+ export function applyCloudCommand(params) {
112
+ const parsed = CloudCommandSchema.safeParse(params.raw);
113
+ if (!parsed.success) {
114
+ return { status: 'refused', reason: `commande invalide : ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` };
115
+ }
116
+ const cmd = parsed.data;
117
+ // IDEMPOTENCE D'ABORD. Rejouer une commande déjà appliquée doit être un no-op, y compris
118
+ // si la révision locale a changé entre-temps — sinon un retry réseau produirait un
119
+ // conflit fantôme sur une opération pourtant déjà réussie.
120
+ const existing = loadJournalEntry(params.cwd, cmd.operation_id);
121
+ if (existing) {
122
+ return { status: 'duplicate', entry: existing };
123
+ }
124
+ const localRev = params.resolveLocalRev(cmd.object_id);
125
+ if (localRev === undefined) {
126
+ // TROISIÈME CLASSE D'APPELANTS (dec#155) : pas de repli ambiant. Un objet inconnu est
127
+ // refusé et nommé, jamais rattaché au « projet actif » par défaut.
128
+ return { status: 'refused', reason: `objet '${cmd.object_id}' inconnu localement — aucune résolution ambiante pour le relais cloud (dec#155).` };
129
+ }
130
+ const base = {
131
+ operation_id: cmd.operation_id,
132
+ object_id: cmd.object_id,
133
+ base_rev: cmd.base_rev,
134
+ field: cmd.field,
135
+ value: cmd.value,
136
+ issued_by: cmd.issued_by,
137
+ issued_at: cmd.issued_at,
138
+ materialized_at: nowISO(),
139
+ };
140
+ if (cmd.base_rev !== localRev) {
141
+ // CONFLIT VISIBLE, jamais un écrasement. La proposition est formulée ici parce que
142
+ // c'est ici qu'on connaît les deux révisions ; la rendre plus tard obligerait
143
+ // l'interface à re-déduire ce que le journal savait déjà.
144
+ const entry = {
145
+ ...base,
146
+ state: 'conflict',
147
+ conflict: {
148
+ local_rev: localRev,
149
+ proposal: localRev > cmd.base_rev
150
+ ? `Le local a avancé (rév. ${localRev} > ${cmd.base_rev}). Rejouer la commande sur la révision ${localRev}, ou l'abandonner si le changement local la rend caduque.`
151
+ : `La commande s'appuie sur une révision (${cmd.base_rev}) postérieure au local (${localRev}) — le local a probablement été restauré. Vérifier avant d'appliquer.`,
152
+ },
153
+ };
154
+ writeEntry(params.cwd, entry);
155
+ return { status: 'conflict', entry };
156
+ }
157
+ const entry = { ...base, state: 'pending' };
158
+ writeEntry(params.cwd, entry);
159
+ return { status: 'applied', entry };
160
+ }
161
+ /** Marque une entrée synchronisée une fois l'effet local réellement appliqué. */
162
+ export function markCommandSynced(cwd, operationId) {
163
+ const entry = loadJournalEntry(cwd, operationId);
164
+ if (!entry)
165
+ return false;
166
+ writeEntry(cwd, { ...entry, state: 'synced' });
167
+ return true;
168
+ }
169
+ /**
170
+ * Résout un conflit par une DÉCISION EXPLICITE.
171
+ *
172
+ * Il n'existe volontairement aucune résolution automatique : dec#154 dit « jamais de
173
+ * last-write-wins silencieux », et un mode « auto » finirait par être le défaut.
174
+ */
175
+ export function resolveCommandConflict(params) {
176
+ const entry = loadJournalEntry(params.cwd, params.operationId);
177
+ if (!entry || entry.state !== 'conflict')
178
+ return undefined;
179
+ const resolved = {
180
+ ...entry,
181
+ state: params.decision === 'accept' ? 'pending' : 'synced',
182
+ conflict: undefined,
183
+ };
184
+ writeEntry(params.cwd, resolved);
185
+ return resolved;
186
+ }
187
+ /** Entrées du journal dans un état donné — sert à rendre les conflits visibles. */
188
+ export function listCommands(cwd, state) {
189
+ const dir = journalDir(cwd);
190
+ if (!fs.existsSync(dir))
191
+ return [];
192
+ const out = [];
193
+ for (const name of fs.readdirSync(dir)) {
194
+ if (!name.endsWith('.json'))
195
+ continue;
196
+ try {
197
+ const entry = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
198
+ if (!state || entry.state === state)
199
+ out.push(entry);
200
+ }
201
+ catch {
202
+ logger.warn(`Entrée de journal ignorée (illisible) : ${name}`);
203
+ }
204
+ }
205
+ return out.sort((a, b) => a.materialized_at.localeCompare(b.materialized_at));
206
+ }
207
+ /**
208
+ * Empreinte d'audit d'une commande — ce qui a été demandé, par qui, sur quelle révision.
209
+ *
210
+ * Calculée sur les octets CANONIQUES pour qu'elle soit reproductible des deux côtés : un
211
+ * audit qu'on ne peut pas recalculer identiquement ne prouve rien.
212
+ */
213
+ export function commandAuditDigest(cmd) {
214
+ return canonicalJson({
215
+ operation_id: cmd.operation_id,
216
+ object_id: cmd.object_id,
217
+ base_rev: cmd.base_rev,
218
+ field: cmd.field,
219
+ value: cmd.value,
220
+ issued_by: cmd.issued_by,
221
+ });
222
+ }
223
+ //# sourceMappingURL=federation-relay.js.map