@indigoai-us/hq-cloud 6.11.7 → 6.11.9

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 (55) hide show
  1. package/dist/cli/share.d.ts.map +1 -1
  2. package/dist/cli/share.js +29 -15
  3. package/dist/cli/share.js.map +1 -1
  4. package/dist/cli/share.test.js +56 -7
  5. package/dist/cli/share.test.js.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/personal-vault.d.ts +24 -0
  10. package/dist/personal-vault.d.ts.map +1 -1
  11. package/dist/personal-vault.js +36 -1
  12. package/dist/personal-vault.js.map +1 -1
  13. package/dist/personal-vault.test.js +46 -1
  14. package/dist/personal-vault.test.js.map +1 -1
  15. package/dist/sync/event-sync.test.js +18 -0
  16. package/dist/sync/event-sync.test.js.map +1 -1
  17. package/dist/sync/feature-flags.test.js +37 -4
  18. package/dist/sync/feature-flags.test.js.map +1 -1
  19. package/dist/sync/index.d.ts +1 -1
  20. package/dist/sync/index.d.ts.map +1 -1
  21. package/dist/sync/index.js.map +1 -1
  22. package/dist/sync/logger.test.js +1 -0
  23. package/dist/sync/logger.test.js.map +1 -1
  24. package/dist/sync/metrics.test.js +1 -0
  25. package/dist/sync/metrics.test.js.map +1 -1
  26. package/dist/sync/push-event.d.ts +23 -11
  27. package/dist/sync/push-event.d.ts.map +1 -1
  28. package/dist/sync/push-event.js +15 -8
  29. package/dist/sync/push-event.js.map +1 -1
  30. package/dist/sync/push-event.test.js +39 -3
  31. package/dist/sync/push-event.test.js.map +1 -1
  32. package/dist/sync/push-receiver.test.js +1 -0
  33. package/dist/sync/push-receiver.test.js.map +1 -1
  34. package/dist/watcher.d.ts.map +1 -1
  35. package/dist/watcher.js +25 -9
  36. package/dist/watcher.js.map +1 -1
  37. package/dist/watcher.test.js +65 -1
  38. package/dist/watcher.test.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/share.test.ts +59 -7
  41. package/src/cli/share.ts +39 -24
  42. package/src/index.ts +1 -0
  43. package/src/personal-vault.test.ts +56 -0
  44. package/src/personal-vault.ts +36 -1
  45. package/src/sync/event-sync.test.ts +21 -0
  46. package/src/sync/feature-flags.test.ts +40 -4
  47. package/src/sync/index.ts +5 -1
  48. package/src/sync/logger.test.ts +1 -0
  49. package/src/sync/metrics.test.ts +1 -0
  50. package/src/sync/push-event.test.ts +45 -3
  51. package/src/sync/push-event.ts +28 -12
  52. package/src/sync/push-receiver.test.ts +1 -0
  53. package/src/watcher.test.ts +81 -0
  54. package/src/watcher.ts +27 -9
  55. package/test/e2e/sync/cross-tenant-isolation.test.ts +2 -0
@@ -49,6 +49,7 @@ import type { TreeChangeBatch } from "../watcher.js";
49
49
  function fakePushEvent(overrides: Partial<PushEvent> = {}): PushEvent {
50
50
  return {
51
51
  relativePath: "personal/notes/a.md",
52
+ kind: "upsert",
52
53
  contentHash: `sha256:${"a".repeat(64)}`,
53
54
  mtime: "2026-05-21T12:00:00.000Z",
54
55
  originDeviceId: "device-1",
@@ -178,6 +179,34 @@ describe("HttpPushTransport — POST /sync/push", () => {
178
179
  expect(decodePushEvent(init.body)).toEqual(event);
179
180
  });
180
181
 
182
+ it("POSTs a delete tombstone without contentHash or mtime", async () => {
183
+ const { fetch, calls } = makeFetch({ ok: true, status: 200 });
184
+ const transport = new HttpPushTransport({
185
+ apiUrl: "https://vault-api.example.com/",
186
+ authToken: "tok-123",
187
+ fetchImpl: fetch,
188
+ });
189
+ const event = fakePushEvent({
190
+ kind: "delete",
191
+ contentHash: undefined,
192
+ mtime: undefined,
193
+ });
194
+
195
+ await transport.publish(event);
196
+
197
+ expect(calls).toHaveLength(1);
198
+ const body = (calls[0].init as { body: string }).body;
199
+ expect(JSON.parse(body)).toEqual({
200
+ relativePath: "personal/notes/a.md",
201
+ kind: "delete",
202
+ originDeviceId: "device-1",
203
+ originTenantId: "indigo",
204
+ sequenceNumber: 0,
205
+ eventTimestamp: "2026-05-21T12:00:01.000Z",
206
+ });
207
+ expect(decodePushEvent(body)).toEqual(JSON.parse(body));
208
+ });
209
+
181
210
  it("resolves the auth token per-request via an async getter (self-heals across refresh)", async () => {
182
211
  const { fetch, calls } = makeFetch();
183
212
  let n = 0;
@@ -287,6 +316,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
287
316
  expect(published).toHaveLength(1);
288
317
  const e = published[0];
289
318
  expect(e.relativePath).toBe(REL);
319
+ expect(e.kind).toBe("upsert");
290
320
  expect(e.originTenantId).toBe("indigo");
291
321
  expect(e.originDeviceId).toBe("dev-1");
292
322
  const expectedHex = createHash("sha256").update("hello world").digest("hex");
@@ -350,7 +380,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
350
380
  expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: REL });
351
381
  });
352
382
 
353
- it("a missing file (hash/stat race) is surfaced, other paths still ship", async () => {
383
+ it("a missing file emits a delete tombstone, and other paths still ship", async () => {
354
384
  const published: PushEvent[] = [];
355
385
  const onError = vi.fn();
356
386
  const emitter = new PushEventEmitter({
@@ -362,9 +392,15 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
362
392
  });
363
393
  // REL exists; "ghost.md" does not.
364
394
  await emitter.emitForBatch(batchFor(REL, "ghost.md"));
365
- expect(published.map((e) => e.relativePath)).toEqual([REL]);
366
- expect(onError).toHaveBeenCalledTimes(1);
367
- expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: "ghost.md" });
395
+ expect(published.map((e) => e.relativePath).sort()).toEqual([
396
+ REL,
397
+ "ghost.md",
398
+ ].sort());
399
+ const tombstone = published.find((e) => e.relativePath === "ghost.md");
400
+ expect(tombstone).toMatchObject({ kind: "delete" });
401
+ expect(tombstone).not.toHaveProperty("contentHash");
402
+ expect(tombstone).not.toHaveProperty("mtime");
403
+ expect(onError).not.toHaveBeenCalled();
368
404
  });
369
405
 
370
406
  it("end-to-end: emit → HttpPushTransport POST with a valid schema (mocked fetch)", async () => {
package/src/sync/index.ts CHANGED
@@ -13,7 +13,11 @@ export {
13
13
  encodePushEvent,
14
14
  decodePushEvent,
15
15
  } from "./push-event.js";
16
- export type { PushEvent, PushEventDecodeIssue } from "./push-event.js";
16
+ export type {
17
+ PushEvent,
18
+ PushEventInput,
19
+ PushEventDecodeIssue,
20
+ } from "./push-event.js";
17
21
 
18
22
  export { NoopPushTransport, HttpPushTransport } from "./push-transport.js";
19
23
  export type {
@@ -70,6 +70,7 @@ async function until(predicate: () => boolean, timeoutMs = 1000): Promise<void>
70
70
  function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
71
71
  return {
72
72
  relativePath: "companies/indigo/notes.md",
73
+ kind: "upsert",
73
74
  contentHash:
74
75
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
75
76
  mtime: "2026-05-21T12:34:56.000Z",
@@ -82,6 +82,7 @@ const QUEUE_URL =
82
82
  function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
83
83
  return {
84
84
  relativePath: "companies/indigo/notes.md",
85
+ kind: "upsert",
85
86
  contentHash:
86
87
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
87
88
  mtime: "2026-05-21T12:34:56.000Z",
@@ -19,12 +19,14 @@ import {
19
19
  decodePushEvent,
20
20
  encodePushEvent,
21
21
  type PushEvent,
22
+ type PushEventInput,
22
23
  } from "../../src/sync/index.js";
23
24
 
24
25
  // A canonical, valid PushEvent. All other tests derive from this fixture so
25
26
  // any single field mutation can't accidentally pass for the wrong reason.
26
27
  const validFixture: PushEvent = {
27
28
  relativePath: "docs/architecture/overview.md",
29
+ kind: "upsert",
28
30
  contentHash:
29
31
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
30
32
  mtime: "2026-05-18T12:34:56.789Z",
@@ -34,6 +36,15 @@ const validFixture: PushEvent = {
34
36
  eventTimestamp: "2026-05-18T12:35:00.000Z",
35
37
  };
36
38
 
39
+ const deleteFixture: PushEvent = {
40
+ relativePath: "docs/architecture/overview.md",
41
+ kind: "delete",
42
+ originDeviceId: "device-laptop-a",
43
+ originTenantId: "tenant-indigo",
44
+ sequenceNumber: 43,
45
+ eventTimestamp: "2026-05-18T12:36:00.000Z",
46
+ };
47
+
37
48
  describe("PushEvent encode/decode", () => {
38
49
  // ── Acceptance #1: round-trip ──────────────────────────────────────────
39
50
  it("round-trips a known-good fixture through encode → decode", () => {
@@ -55,6 +66,21 @@ describe("PushEvent encode/decode", () => {
55
66
  expect(fromString).toEqual(validFixture);
56
67
  });
57
68
 
69
+ it("round-trips a delete tombstone without contentHash or mtime", () => {
70
+ const encoded = encodePushEvent(deleteFixture);
71
+ expect(JSON.parse(encoded)).toEqual(deleteFixture);
72
+ expect(decodePushEvent(encoded)).toEqual(deleteFixture);
73
+ });
74
+
75
+ it("defaults an absent kind to upsert for backward compatibility", () => {
76
+ const legacy = { ...validFixture } as PushEventInput;
77
+ delete legacy.kind;
78
+
79
+ const decoded = decodePushEvent(legacy);
80
+ expect(decoded).toEqual(validFixture);
81
+ expect(JSON.parse(encodePushEvent(legacy))).toEqual(validFixture);
82
+ });
83
+
58
84
  // ── Acceptance #2: unknown fields dropped ──────────────────────────────
59
85
  it("drops unknown extra fields silently (does not throw)", () => {
60
86
  const withExtras = {
@@ -80,8 +106,6 @@ describe("PushEvent encode/decode", () => {
80
106
  // ── Acceptance #3: missing required fields throw typed error ───────────
81
107
  it.each([
82
108
  "relativePath",
83
- "contentHash",
84
- "mtime",
85
109
  "originDeviceId",
86
110
  "originTenantId",
87
111
  "sequenceNumber",
@@ -106,6 +130,24 @@ describe("PushEvent encode/decode", () => {
106
130
  expect(error.issues.some((issue) => issue.path.includes(field))).toBe(true);
107
131
  });
108
132
 
133
+ it("rejects an upsert without contentHash and mtime via the schema refinement", () => {
134
+ let caught: unknown;
135
+ try {
136
+ decodePushEvent({
137
+ ...validFixture,
138
+ contentHash: undefined,
139
+ mtime: undefined,
140
+ });
141
+ } catch (err) {
142
+ caught = err;
143
+ }
144
+
145
+ expect(caught).toBeInstanceOf(PushEventDecodeError);
146
+ const error = caught as PushEventDecodeError;
147
+ expect(error.stage).toBe("schema-validation");
148
+ expect(error.issues.some((issue) => issue.message === "upsert requires contentHash and mtime")).toBe(true);
149
+ });
150
+
109
151
  it("PushEventDecodeError carries the underlying zod issues array", () => {
110
152
  // Multiple missing fields → multiple issues, all reachable via `.issues`.
111
153
  const sparse = { relativePath: "x.md" } as unknown;
@@ -117,7 +159,7 @@ describe("PushEvent encode/decode", () => {
117
159
  }
118
160
  expect(caught).toBeInstanceOf(PushEventDecodeError);
119
161
  const error = caught as PushEventDecodeError;
120
- expect(error.issues.length).toBeGreaterThanOrEqual(6);
162
+ expect(error.issues.length).toBeGreaterThanOrEqual(4);
121
163
  });
122
164
 
123
165
  // ── Supporting wire-contract invariants ────────────────────────────────
@@ -2,17 +2,20 @@
2
2
  * PushEvent — the wire-shared payload exchanged between every link in the
3
3
  * event-driven-hq-cloud-sync pipeline.
4
4
  *
5
- * Producers (the watcher) emit one PushEvent per local content change.
5
+ * Producers (the watcher) emit one PushEvent per local content change or
6
+ * delete tombstone.
6
7
  * Consumers (the push endpoint / receiver / coalescer) decode the payload,
7
8
  * validate it, and act on it. Because the same shape crosses a network
8
9
  * boundary, it has its own dedicated module.
9
10
  *
10
11
  * Conventions
11
12
  * ───────────
12
- * - `contentHash` is `sha256:<64-lowercase-hex>`. The `<algorithm>:<hex>`
13
- * prefix lets a future hash migration ship without breaking the wire
14
- * format consumers can branch on the prefix and fall back to refusing
15
- * unknown algorithms.
13
+ * - `kind` is `"upsert"` for content changes and `"delete"` for delete
14
+ * tombstones. Missing `kind` defaults to `"upsert"` for older producers.
15
+ * - `contentHash` is required for upserts and absent for delete tombstones.
16
+ * It is `sha256:<64-lowercase-hex>`. The `<algorithm>:<hex>` prefix lets a
17
+ * future hash migration ship without breaking the wire format — consumers
18
+ * can branch on the prefix and fall back to refusing unknown algorithms.
16
19
  * - `mtime` and `eventTimestamp` are strict ISO-8601 datetime strings.
17
20
  * `mtime` is the filesystem modification time of the source file at the
18
21
  * moment of capture; `eventTimestamp` is when the watcher emitted the
@@ -59,15 +62,18 @@ export const PushEventSchema = z
59
62
  relativePath: z
60
63
  .string()
61
64
  .min(1, "relativePath must be a non-empty string"),
65
+ kind: z.enum(["upsert", "delete"]).optional().default("upsert"),
62
66
  contentHash: z
63
67
  .string()
64
68
  .regex(
65
69
  CONTENT_HASH_PATTERN,
66
70
  "contentHash must match `sha256:<64 lowercase hex>`",
67
- ),
71
+ )
72
+ .optional(),
68
73
  mtime: z
69
74
  .string()
70
- .regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime"),
75
+ .regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime")
76
+ .optional(),
71
77
  originDeviceId: z
72
78
  .string()
73
79
  .min(1, "originDeviceId must be a non-empty string"),
@@ -91,13 +97,23 @@ export const PushEventSchema = z
91
97
  })
92
98
  // `.strip()` is the zod 4 default; called explicitly here so the intent is
93
99
  // obvious to future readers. Unknown keys MUST NOT throw — see module JSDoc.
94
- .strip();
100
+ .strip()
101
+ .refine(
102
+ (e) => e.kind === "delete" || (e.contentHash != null && e.mtime != null),
103
+ { message: "upsert requires contentHash and mtime" },
104
+ );
105
+
106
+ /**
107
+ * The canonical decoded PushEvent type. `kind` is always present after decode;
108
+ * `contentHash` and `mtime` are present for upserts and absent for deletes.
109
+ */
110
+ export type PushEvent = z.output<typeof PushEventSchema>;
95
111
 
96
112
  /**
97
- * The canonical PushEvent type. All fields are required; producers and
98
- * consumers share this exact shape.
113
+ * Input accepted by the schema. Kept public so encode callers can hand in
114
+ * legacy upsert payloads where `kind` is absent.
99
115
  */
100
- export type PushEvent = z.infer<typeof PushEventSchema>;
116
+ export type PushEventInput = z.input<typeof PushEventSchema>;
101
117
 
102
118
  // ─── Errors ────────────────────────────────────────────────────────────────
103
119
 
@@ -145,7 +161,7 @@ export class PushEventDecodeError extends Error {
145
161
  * Extra keys on `event` are dropped — the returned JSON string contains
146
162
  * only the declared PushEvent fields.
147
163
  */
148
- export function encodePushEvent(event: PushEvent): string {
164
+ export function encodePushEvent(event: PushEventInput): string {
149
165
  const parsed = PushEventSchema.safeParse(event);
150
166
  if (!parsed.success) {
151
167
  throw new PushEventDecodeError(
@@ -45,6 +45,7 @@ const QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789012/sync-push-in
45
45
  function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
46
46
  return {
47
47
  relativePath: "companies/indigo/notes.md",
48
+ kind: "upsert",
48
49
  contentHash:
49
50
  "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
50
51
  mtime: "2026-05-21T12:34:56.000Z",
@@ -7,7 +7,11 @@ import {
7
7
  WatchPushDriver,
8
8
  TreeWatcher,
9
9
  createWatchPathFilter,
10
+ PushEventEmitter,
10
11
  } from "./watcher.js";
12
+ import { StaticFlagProvider } from "./sync/feature-flags.js";
13
+ import type { PushEvent } from "./sync/push-event.js";
14
+ import type { PushTransport } from "./sync/push-transport.js";
11
15
 
12
16
  /**
13
17
  * US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
@@ -427,3 +431,80 @@ describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", ()
427
431
  w.dispose();
428
432
  }, 10_000);
429
433
  });
434
+
435
+ describe("PushEventEmitter — directory and delete tombstone handling", () => {
436
+ let dir: string;
437
+
438
+ beforeEach(() => {
439
+ dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pushemit-")));
440
+ });
441
+
442
+ afterEach(() => {
443
+ fs.rmSync(dir, { recursive: true, force: true });
444
+ });
445
+
446
+ function makeEmitter(opts?: {
447
+ published?: PushEvent[];
448
+ onError?: ReturnType<typeof vi.fn>;
449
+ getSequenceNumber?: () => number;
450
+ }): PushEventEmitter {
451
+ const published = opts?.published ?? [];
452
+ const transport: PushTransport = {
453
+ start: async () => {},
454
+ dispose: async () => {},
455
+ connected: true,
456
+ publish: async (event) => {
457
+ published.push(event);
458
+ },
459
+ };
460
+ return new PushEventEmitter({
461
+ originTenantId: "tenant-indigo",
462
+ originDeviceId: "device-a",
463
+ transport,
464
+ flagProvider: new StaticFlagProvider(["tenant-indigo"]),
465
+ now: () => new Date("2026-06-18T12:00:00.000Z"),
466
+ getSequenceNumber: opts?.getSequenceNumber,
467
+ onError: opts?.onError,
468
+ });
469
+ }
470
+
471
+ it("skips directory changes silently without publishing or reporting an error", async () => {
472
+ const published: PushEvent[] = [];
473
+ const onError = vi.fn();
474
+ const subdir = path.join(dir, "folder");
475
+ fs.mkdirSync(subdir);
476
+ const emitter = makeEmitter({ published, onError });
477
+
478
+ await emitter.emitForBatch({ paths: new Map([[subdir, "folder"]]) });
479
+
480
+ expect(published).toEqual([]);
481
+ expect(onError).not.toHaveBeenCalled();
482
+ });
483
+
484
+ it("emits and publishes a delete tombstone when the changed file is gone", async () => {
485
+ const published: PushEvent[] = [];
486
+ const onError = vi.fn();
487
+ const emitter = makeEmitter({
488
+ published,
489
+ onError,
490
+ getSequenceNumber: () => 99,
491
+ });
492
+ const missing = path.join(dir, "deleted.md");
493
+
494
+ await emitter.emitForBatch({ paths: new Map([[missing, "deleted.md"]]) });
495
+
496
+ expect(onError).not.toHaveBeenCalled();
497
+ expect(published).toEqual([
498
+ {
499
+ kind: "delete",
500
+ relativePath: "deleted.md",
501
+ originDeviceId: "device-a",
502
+ originTenantId: "tenant-indigo",
503
+ sequenceNumber: 99,
504
+ eventTimestamp: "2026-06-18T12:00:00.000Z",
505
+ },
506
+ ]);
507
+ expect(published[0]).not.toHaveProperty("contentHash");
508
+ expect(published[0]).not.toHaveProperty("mtime");
509
+ });
510
+ });
package/src/watcher.ts CHANGED
@@ -862,11 +862,12 @@ export class PushEventEmitter {
862
862
  ): Promise<void> {
863
863
  let event: PushEvent | undefined;
864
864
  try {
865
- const [contentHash, st] = await Promise.all([
866
- computeContentHash(absolutePath),
867
- stat(absolutePath),
868
- ]);
865
+ const st = await stat(absolutePath);
866
+ if (st.isDirectory()) return;
867
+
868
+ const contentHash = await computeContentHash(absolutePath);
869
869
  event = {
870
+ kind: "upsert",
870
871
  relativePath,
871
872
  contentHash,
872
873
  mtime: st.mtime.toISOString(),
@@ -876,12 +877,28 @@ export class PushEventEmitter {
876
877
  eventTimestamp: this.now().toISOString(),
877
878
  };
878
879
  } catch (err) {
879
- // File deleted between debounce fire and hash read (legal race), or stat
880
- // failed. Surface, don't crash.
881
- this.onError(err instanceof Error ? err : new Error(String(err)), {
880
+ const code =
881
+ err && typeof err === "object" && "code" in err
882
+ ? (err as { code?: string }).code
883
+ : undefined;
884
+ if (code !== "ENOENT") {
885
+ // Genuine stat/read failure. Surface, don't crash.
886
+ this.onError(err instanceof Error ? err : new Error(String(err)), {
887
+ relativePath,
888
+ });
889
+ return;
890
+ }
891
+
892
+ // Deleted before/during capture: publish a tombstone so peers run a
893
+ // targeted pull and let the vault-confirmed tombstone path remove it.
894
+ event = {
895
+ kind: "delete",
882
896
  relativePath,
883
- });
884
- return;
897
+ originDeviceId: this.originDeviceId,
898
+ originTenantId: this.originTenantId,
899
+ sequenceNumber: this.nextSeq(),
900
+ eventTimestamp: this.now().toISOString(),
901
+ };
885
902
  }
886
903
 
887
904
  // US-011: 1st link of the 3-log diagnostic chain. Stamps the same
@@ -891,6 +908,7 @@ export class PushEventEmitter {
891
908
  this.logger?.info(
892
909
  {
893
910
  event: "watcher.emit",
911
+ kind: event.kind,
894
912
  originTenantId: event.originTenantId,
895
913
  originDeviceId: event.originDeviceId,
896
914
  relativePath: event.relativePath,
@@ -215,6 +215,7 @@ function makeEvent(opts: {
215
215
  }): PushEvent {
216
216
  return {
217
217
  relativePath: opts.relativePath,
218
+ kind: "upsert",
218
219
  contentHash: ZERO_HASH,
219
220
  mtime: "2026-05-21T12:00:00.000Z",
220
221
  originDeviceId: opts.deviceId,
@@ -540,6 +541,7 @@ describe("US-018: receive path with vended credentials (isolation extension)", (
540
541
  function phase3Event(overrides: Partial<PushEvent>): PushEvent {
541
542
  return {
542
543
  relativePath: "companies/indigo/docs/x.md",
544
+ kind: "upsert",
543
545
  contentHash: `sha256:${"b".repeat(64)}`,
544
546
  mtime: "2026-06-10T12:00:00.000Z",
545
547
  originDeviceId: "peer",