@indigoai-us/hq-cloud 6.14.49 → 6.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.github/workflows/unreleased-commits-nag.yml +256 -0
  2. package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
  3. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.js +153 -25
  5. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
  7. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-routes.js +11 -0
  9. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
  11. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
  12. package/dist/bin/sync-runner-watch-routes.test.js +51 -0
  13. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
  14. package/dist/bin/sync-runner.d.ts +11 -12
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +173 -52
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/reindex-knowledge.d.ts +7 -0
  20. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  21. package/dist/cli/reindex-knowledge.js +389 -0
  22. package/dist/cli/reindex-knowledge.js.map +1 -0
  23. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  24. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  25. package/dist/cli/reindex-knowledge.test.js +260 -0
  26. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  27. package/dist/cli/reindex.d.ts.map +1 -1
  28. package/dist/cli/reindex.js +8 -0
  29. package/dist/cli/reindex.js.map +1 -1
  30. package/dist/cli/rescue-classify-ordering.test.js +8 -2
  31. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  32. package/dist/cli/rescue-core.d.ts +85 -0
  33. package/dist/cli/rescue-core.d.ts.map +1 -1
  34. package/dist/cli/rescue-core.js +390 -1
  35. package/dist/cli/rescue-core.js.map +1 -1
  36. package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
  37. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
  38. package/dist/cli/rescue-settings-reconcile.test.js +523 -0
  39. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/journal.d.ts.map +1 -1
  45. package/dist/journal.js +81 -51
  46. package/dist/journal.js.map +1 -1
  47. package/dist/journal.test.js +11 -19
  48. package/dist/journal.test.js.map +1 -1
  49. package/dist/sync/event-sync.d.ts +8 -25
  50. package/dist/sync/event-sync.d.ts.map +1 -1
  51. package/dist/sync/event-sync.js +23 -48
  52. package/dist/sync/event-sync.js.map +1 -1
  53. package/dist/sync/event-sync.test.js +6 -32
  54. package/dist/sync/event-sync.test.js.map +1 -1
  55. package/dist/sync/feature-flags.d.ts +20 -117
  56. package/dist/sync/feature-flags.d.ts.map +1 -1
  57. package/dist/sync/feature-flags.js +24 -147
  58. package/dist/sync/feature-flags.js.map +1 -1
  59. package/dist/sync/feature-flags.test.js +15 -61
  60. package/dist/sync/feature-flags.test.js.map +1 -1
  61. package/dist/sync/index.d.ts +1 -1
  62. package/dist/sync/index.d.ts.map +1 -1
  63. package/dist/sync/index.js +1 -1
  64. package/dist/sync/index.js.map +1 -1
  65. package/dist/sync/lease-client.d.ts +67 -0
  66. package/dist/sync/lease-client.d.ts.map +1 -0
  67. package/dist/sync/lease-client.js +158 -0
  68. package/dist/sync/lease-client.js.map +1 -0
  69. package/dist/sync/lease-client.test.d.ts +2 -0
  70. package/dist/sync/lease-client.test.d.ts.map +1 -0
  71. package/dist/sync/lease-client.test.js +119 -0
  72. package/dist/sync/lease-client.test.js.map +1 -0
  73. package/dist/sync/push-receiver.d.ts +8 -19
  74. package/dist/sync/push-receiver.d.ts.map +1 -1
  75. package/dist/sync/push-receiver.js +6 -50
  76. package/dist/sync/push-receiver.js.map +1 -1
  77. package/dist/sync/push-receiver.test.js +6 -57
  78. package/dist/sync/push-receiver.test.js.map +1 -1
  79. package/dist/sync/realtime-rollout.d.ts +85 -0
  80. package/dist/sync/realtime-rollout.d.ts.map +1 -0
  81. package/dist/sync/realtime-rollout.js +206 -0
  82. package/dist/sync/realtime-rollout.js.map +1 -0
  83. package/dist/sync/realtime-rollout.test.d.ts +2 -0
  84. package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
  85. package/dist/sync/realtime-rollout.test.js +71 -0
  86. package/dist/sync/realtime-rollout.test.js.map +1 -0
  87. package/dist/sync/state-store.d.ts +40 -0
  88. package/dist/sync/state-store.d.ts.map +1 -0
  89. package/dist/sync/state-store.js +680 -0
  90. package/dist/sync/state-store.js.map +1 -0
  91. package/dist/sync/state-store.test.d.ts +2 -0
  92. package/dist/sync/state-store.test.d.ts.map +1 -0
  93. package/dist/sync/state-store.test.js +159 -0
  94. package/dist/sync/state-store.test.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/bin/sync-runner-watch-loop.ts +176 -32
  99. package/src/bin/sync-runner-watch-routes.test.ts +71 -0
  100. package/src/bin/sync-runner-watch-routes.ts +27 -0
  101. package/src/bin/sync-runner.test.ts +188 -53
  102. package/src/bin/sync-runner.ts +11 -12
  103. package/src/cli/reindex-knowledge.test.ts +307 -0
  104. package/src/cli/reindex-knowledge.ts +450 -0
  105. package/src/cli/reindex.ts +9 -0
  106. package/src/cli/rescue-classify-ordering.test.ts +9 -3
  107. package/src/cli/rescue-core.ts +459 -1
  108. package/src/cli/rescue-settings-reconcile.test.ts +637 -0
  109. package/src/index.ts +2 -4
  110. package/src/journal.test.ts +19 -21
  111. package/src/journal.ts +87 -54
  112. package/src/sync/event-sync.test.ts +9 -44
  113. package/src/sync/event-sync.ts +26 -52
  114. package/src/sync/feature-flags.test.ts +22 -72
  115. package/src/sync/feature-flags.ts +32 -199
  116. package/src/sync/index.ts +2 -4
  117. package/src/sync/lease-client.test.ts +128 -0
  118. package/src/sync/lease-client.ts +207 -0
  119. package/src/sync/push-receiver.test.ts +6 -58
  120. package/src/sync/push-receiver.ts +9 -74
  121. package/src/sync/realtime-rollout.test.ts +86 -0
  122. package/src/sync/realtime-rollout.ts +262 -0
  123. package/src/sync/state-store.test.ts +194 -0
  124. package/src/sync/state-store.ts +727 -0
  125. package/src/types.ts +14 -0
  126. package/test/e2e/sync/transient-company-leg.test.ts +8 -4
@@ -119,7 +119,7 @@ describe("journal", () => {
119
119
  expect(roundTripped.pulls).toEqual([]);
120
120
  });
121
121
 
122
- it("F08: recovers the last good journal when the primary JSON is torn", () => {
122
+ it("migrates a torn v2 primary from its last-good copy into the v3 state store", () => {
123
123
  const lastGood: SyncJournal = {
124
124
  version: "2",
125
125
  lastSync: "2026-06-18T00:00:00.000Z",
@@ -133,11 +133,11 @@ describe("journal", () => {
133
133
  },
134
134
  pulls: [],
135
135
  };
136
- writeJournal("indigo", lastGood);
137
-
138
- // Simulate a torn direct write of the primary journal file. The next
139
- // read must use the durable last-good copy rather than aborting sync.
140
136
  fs.writeFileSync(getJournalPath("indigo"), '{"version":"2","files":');
137
+ fs.writeFileSync(
138
+ `${getJournalPath("indigo")}.last-good`,
139
+ JSON.stringify(lastGood),
140
+ );
141
141
 
142
142
  const recovered = readJournal("indigo");
143
143
  expect(recovered.version).toBe("2");
@@ -149,23 +149,7 @@ describe("journal", () => {
149
149
  });
150
150
 
151
151
  it("R-F08: surfaces primary IO errors instead of masking them with last-good", () => {
152
- const lastGood: SyncJournal = {
153
- version: "2",
154
- lastSync: "2026-06-18T00:00:00.000Z",
155
- files: {
156
- "docs/stale.md": {
157
- hash: "stale-hash",
158
- size: 128,
159
- syncedAt: "2026-06-18T00:00:00.000Z",
160
- direction: "down",
161
- },
162
- },
163
- pulls: [],
164
- };
165
- writeJournal("indigo", lastGood);
166
-
167
152
  const journalPath = getJournalPath("indigo");
168
- fs.unlinkSync(journalPath);
169
153
  fs.mkdirSync(journalPath);
170
154
 
171
155
  expect(() => readJournal("indigo")).toThrow(/EISDIR|directory/i);
@@ -204,6 +188,20 @@ describe("journal", () => {
204
188
  expect(brandstage.files).toHaveProperty("b.md");
205
189
  expect(brandstage.files).not.toHaveProperty("a.md");
206
190
  });
191
+
192
+ it("migrates once, appends authoritative updates, and refreshes the compatibility mirror", () => {
193
+ const initial: SyncJournal = { version: "2", lastSync: "one", files: {}, pulls: [] };
194
+ writeJournal("indigo", initial);
195
+ const locator = fs.readFileSync(getJournalPath("indigo"));
196
+
197
+ writeJournal("indigo", { version: "2", lastSync: "two", files: {}, pulls: [] });
198
+
199
+ expect(fs.readFileSync(getJournalPath("indigo"))).not.toEqual(locator);
200
+ expect(
201
+ JSON.parse(fs.readFileSync(getJournalPath("indigo"), "utf8")) as SyncJournal,
202
+ ).toMatchObject({ version: "2", lastSync: "two" });
203
+ expect(readJournal("indigo").lastSync).toBe("two");
204
+ });
207
205
  });
208
206
 
209
207
  describe("updateEntry", () => {
package/src/journal.ts CHANGED
@@ -17,8 +17,9 @@ import * as fs from "fs";
17
17
  import * as os from "os";
18
18
  import * as path from "path";
19
19
  import * as crypto from "crypto";
20
- import type { SyncJournal, JournalEntry, PullRecord } from "./types.js";
20
+ import type { SyncJournal, JournalEntry, PullRecord, V3JournalState, V3JournalWalPayload } from "./types.js";
21
21
  import { toPosixKey } from "./s3.js";
22
+ import { StateStore } from "./sync/state-store.js";
22
23
 
23
24
  /** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
24
25
  export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -36,6 +37,8 @@ export const MAX_PULLS_PER_COMPANY = 50;
36
37
  const JOURNAL_FILE_PREFIX = "sync-journal.";
37
38
  const JOURNAL_FILE_SUFFIX = ".json";
38
39
  const JOURNAL_LAST_GOOD_SUFFIX = ".last-good";
40
+ const JOURNAL_WAL_RECORD_TYPE = 1;
41
+
39
42
 
40
43
  /**
41
44
  * Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
@@ -135,13 +138,54 @@ export function migratePersonalVaultJournal(): void {
135
138
  * installs never pass through v1 on disk.
136
139
  */
137
140
  export function readJournal(slug: string): SyncJournal {
141
+ if (StateStore.exists(getStateDir(), journalScopeId(slug))) {
142
+ return openJournalStore(slug, emptyJournal()).getState().journal;
143
+ }
138
144
  const journalPath = getJournalPath(slug);
145
+ let legacy: SyncJournal;
139
146
  if (fs.existsSync(journalPath)) {
140
- return readJournalFileWithLastGood(journalPath);
147
+ legacy = readJournalFileWithLastGood(journalPath);
148
+ } else {
149
+ legacy = emptyJournal();
141
150
  }
151
+ migrateToV2(legacy);
152
+ return openJournalStore(slug, legacy).getState().journal;
153
+ }
154
+
155
+ function emptyJournal(): SyncJournal {
142
156
  return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
143
157
  }
144
158
 
159
+ function journalScopeId(slug: string): string {
160
+ return `journal:${sanitizeSlug(slug)}`;
161
+ }
162
+
163
+ function openJournalStore(slug: string, initialJournal: SyncJournal): StateStore<V3JournalState> {
164
+ return StateStore.open({
165
+ rootDir: getStateDir(),
166
+ scopeId: journalScopeId(slug),
167
+ initialState: { journal: initialJournal },
168
+ reduce: (state, record) => {
169
+ if (record.type !== JOURNAL_WAL_RECORD_TYPE) {
170
+ throw new Error(`journal: unexpected v3 WAL record type ${record.type}`);
171
+ }
172
+ const payload = record.payload as Partial<V3JournalWalPayload>;
173
+ if (!payload || typeof payload !== "object" || !isJournal(payload.journal)) {
174
+ throw new Error("journal: v3 WAL record has an invalid journal payload");
175
+ }
176
+ return { ...state, journal: payload.journal };
177
+ },
178
+ });
179
+ }
180
+
181
+ function isJournal(value: unknown): value is SyncJournal {
182
+ return typeof value === "object" && value !== null &&
183
+ ((value as SyncJournal).version === "1" || (value as SyncJournal).version === "2") &&
184
+ typeof (value as SyncJournal).lastSync === "string" &&
185
+ typeof (value as SyncJournal).files === "object" &&
186
+ (value as SyncJournal).files !== null;
187
+ }
188
+
145
189
  function parseJournalContent(content: string): SyncJournal {
146
190
  if (content.length === 0) {
147
191
  throw new SyntaxError("journal JSON is empty");
@@ -225,7 +269,9 @@ export function listJournals(): JournalSummary[] {
225
269
  if (!slug) continue; // guard against a stray "sync-journal..json"
226
270
  const filePath = path.join(dir, name);
227
271
  try {
228
- const journal = readJournalFileWithLastGood(filePath);
272
+ // Prefer the v3 store when this legacy filename is only its immutable
273
+ // compatibility locator; otherwise this performs the v2 last-good read.
274
+ const journal = readJournal(slug);
229
275
  out.push({ slug, path: filePath, journal });
230
276
  } catch (err) {
231
277
  if (!isCorruptJournalJson(err)) throw err;
@@ -303,69 +349,56 @@ export function migrateToV2(journal: SyncJournal): SyncJournal {
303
349
  */
304
350
  export function writeJournal(slug: string, journal: SyncJournal): void {
305
351
  migrateToV2(journal);
306
- const journalPath = getJournalPath(slug);
307
- fs.mkdirSync(path.dirname(journalPath), { recursive: true });
308
- const content = JSON.stringify(journal, null, 2);
309
- atomicWriteFileSync(journalPath, content);
310
- atomicWriteFileSync(lastGoodJournalPath(journalPath), content);
311
- }
312
-
313
- function lastGoodJournalPath(journalPath: string): string {
314
- return `${journalPath}${JOURNAL_LAST_GOOD_SUFFIX}`;
352
+ const store = openJournalStore(slug, journal);
353
+ // The initial v2 → v3 migration is a durable snapshot. Every normal write
354
+ // thereafter is one framed append + fdatasync, never a whole-journal rewrite.
355
+ if (store.wasCreated) {
356
+ writeLegacyJournalMirror(slug, journal);
357
+ } else {
358
+ store.append(JOURNAL_WAL_RECORD_TYPE, { journal });
359
+ // The JSON file remains a compatibility mirror for shipped CLI/status
360
+ // consumers that have not moved to StateStore yet. It is never used for
361
+ // recovery once a v3 generation exists; the framed WAL above is the sole
362
+ // authority and hot path. Keep the mirror current so those consumers do
363
+ // not observe stale sync state during the phased migration.
364
+ writeLegacyJournalMirror(slug, journal);
365
+ }
315
366
  }
316
367
 
317
- function atomicWriteFileSync(filePath: string, content: string): void {
318
- const dir = path.dirname(filePath);
319
- const tmpPath = path.join(
320
- dir,
321
- `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${crypto
322
- .randomBytes(6)
323
- .toString("hex")}.tmp`,
368
+ /**
369
+ * Keep the v2-shaped filename as a compatibility mirror for older CLI/status
370
+ * surfaces. The v3 state store is authoritative after migration; this mirror
371
+ * is atomically replaced only to preserve existing readers until their owner
372
+ * migrates them. WAL recovery never consults it once a v3 generation exists.
373
+ */
374
+ function writeLegacyJournalMirror(slug: string, journal: SyncJournal): void {
375
+ const journalPath = getJournalPath(slug);
376
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
377
+ const temporary = path.join(
378
+ path.dirname(journalPath),
379
+ `.${path.basename(journalPath)}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`,
324
380
  );
325
381
  let fd: number | undefined;
326
382
  try {
327
- fd = fs.openSync(tmpPath, "wx");
328
- fs.writeFileSync(fd, content);
329
- fs.fsyncSync(fd);
383
+ fd = fs.openSync(temporary, "wx");
384
+ fs.writeFileSync(fd, JSON.stringify(journal));
385
+ fs.fdatasyncSync(fd);
330
386
  fs.closeSync(fd);
331
387
  fd = undefined;
332
- fs.renameSync(tmpPath, filePath);
333
- fsyncDirSync(dir);
334
- } catch (err) {
335
- if (fd !== undefined) {
336
- try {
337
- fs.closeSync(fd);
338
- } catch {
339
- /* ignore close failure while surfacing the original write error */
340
- }
341
- }
342
- try {
343
- fs.rmSync(tmpPath, { force: true });
344
- } catch {
345
- /* best-effort cleanup */
346
- }
347
- throw err;
388
+ fs.renameSync(temporary, journalPath);
389
+ } catch (error) {
390
+ try { fs.rmSync(temporary, { force: true }); } catch { /* preserve mirror write failure */ }
391
+ throw new Error(`journal: failed to update v3 compatibility mirror ${journalPath}`, { cause: error });
392
+ } finally {
393
+ if (fd !== undefined) fs.closeSync(fd);
348
394
  }
349
395
  }
350
396
 
351
- function fsyncDirSync(dir: string): void {
352
- let fd: number | undefined;
353
- try {
354
- fd = fs.openSync(dir, "r");
355
- fs.fsyncSync(fd);
356
- } catch {
357
- // Directory fsync is unsupported on some platforms/filesystems.
358
- } finally {
359
- if (fd !== undefined) {
360
- try {
361
- fs.closeSync(fd);
362
- } catch {
363
- /* ignore */
364
- }
365
- }
366
- }
397
+ function lastGoodJournalPath(journalPath: string): string {
398
+ return `${journalPath}${JOURNAL_LAST_GOOD_SUFFIX}`;
367
399
  }
368
400
 
401
+
369
402
  export function hashFile(filePath: string): string {
370
403
  const content = fs.readFileSync(filePath);
371
404
  return crypto.createHash("sha256").update(content).digest("hex");
@@ -14,7 +14,6 @@
14
14
  import { describe, expect, it, vi } from "vitest";
15
15
 
16
16
  import {
17
- EVENT_SYNC_ROLLOUT_EMAILS,
18
17
  resolveEventSync,
19
18
  subscribeSyncReceive,
20
19
  sqsClientFromAwsSdk,
@@ -25,8 +24,6 @@ import {
25
24
  import type { SqsClientLike, PushReceiverContext } from "./push-receiver.js";
26
25
  import type { PushEvent } from "./push-event.js";
27
26
 
28
- const ENROLLED = "hassaan@getindigo.ai";
29
-
30
27
  function subscribeResponse(
31
28
  overrides: Partial<SubscribeSyncResponse["credentials"]> = {},
32
29
  queueUrl = "https://sqs.us-east-1.amazonaws.com/000000000000/sync-push-prs_t-dev1",
@@ -46,48 +43,16 @@ function subscribeResponse(
46
43
 
47
44
  // ─── resolveEventSync (US-019) ──────────────────────────────────────────────
48
45
 
49
- describe("resolveEventSync — exact-email rollout gate", () => {
50
- it("enrolls exactly the allowlisted address (case-insensitive, trimmed)", () => {
51
- expect(EVENT_SYNC_ROLLOUT_EMAILS.has(ENROLLED)).toBe(true);
52
- expect(resolveEventSync(ENROLLED, undefined)).toBe(true);
53
- expect(resolveEventSync("HASSAAN@GetIndigo.AI", undefined)).toBe(true);
54
- expect(resolveEventSync(` ${ENROLLED} `, undefined)).toBe(true);
55
- });
56
-
57
- it("never matches look-alikes — exact full address, not suffix/superset", () => {
58
- expect(resolveEventSync("xhassaan@getindigo.ai", undefined)).toBe(false);
59
- expect(resolveEventSync("hassaan@getindigo.ai.evil.com", undefined)).toBe(
60
- false,
61
- );
62
- expect(resolveEventSync("hassaan@evil-getindigo.ai", undefined)).toBe(
63
- false,
64
- );
65
- expect(resolveEventSync("corey@getindigo.ai", undefined)).toBe(false);
66
- expect(resolveEventSync(undefined, undefined)).toBe(false);
67
- expect(resolveEventSync("", undefined)).toBe(false);
68
- });
69
-
70
- it.each(["1", "true", "yes", "on", " ON "])(
71
- "override %j forces ON for unenrolled accounts",
72
- (o) => {
73
- expect(resolveEventSync("nobody@example.com", o)).toBe(true);
74
- expect(resolveEventSync(undefined, o)).toBe(true);
75
- },
76
- );
77
-
46
+ describe("resolveEventSync — local disable only", () => {
78
47
  it.each(["0", "false", "no", "off"])(
79
- "override %j forces OFF even for the enrolled account",
80
- (o) => {
81
- expect(resolveEventSync(ENROLLED, o)).toBe(false);
82
- },
48
+ "override %j keeps the event path dormant",
49
+ (override) => expect(resolveEventSync(override)).toBe(false),
83
50
  );
84
51
 
85
- it("unset/blank/garbage override falls through to the email check", () => {
86
- expect(resolveEventSync(ENROLLED, undefined)).toBe(true);
87
- expect(resolveEventSync(ENROLLED, "")).toBe(true);
88
- expect(resolveEventSync(ENROLLED, "maybe")).toBe(true);
89
- expect(resolveEventSync("nobody@example.com", "maybe")).toBe(false);
90
- });
52
+ it.each([undefined, "", "1", "true", "yes", "on", "indigo"])(
53
+ "override %j cannot grant authority",
54
+ (override) => expect(resolveEventSync(override)).toBe(true),
55
+ );
91
56
  });
92
57
 
93
58
  // ─── subscribeSyncReceive ───────────────────────────────────────────────────
@@ -567,7 +532,7 @@ describe("startEventSync", () => {
567
532
  }
568
533
  });
569
534
 
570
- it("returns null (poll-only) when subscribe fails never throws", async () => {
535
+ it("does not create live handles when authenticated legacy inventory fails", async () => {
571
536
  const logged: string[] = [];
572
537
  const handles = await startEventSync({
573
538
  ...happyOpts([]),
@@ -581,7 +546,7 @@ describe("startEventSync", () => {
581
546
  expect(logged.join("\n")).toContain("subscribe 503");
582
547
  });
583
548
 
584
- it("returns null (poll-only) when tenant resolution fails", async () => {
549
+ it("does not create live handles when identity resolution fails", async () => {
585
550
  const logged: string[] = [];
586
551
  const handles = await startEventSync({
587
552
  ...happyOpts([]),
@@ -5,12 +5,10 @@
5
5
  * Phases 1–2 built every piece but left them unconnected: the watcher runs
6
6
  * targeted *push passes* (S3 upload) but never publishes a PushEvent, and the
7
7
  * runner's receiver seam defaults to {@link NoopPushReceiver}. This module is
8
- * the connective tissue that turns both on for enrolled accounts:
8
+ * the connective tissue that turns both on after a server grant:
9
9
  *
10
- * - {@link resolveEventSync} — the rollout gate. EXACT-email allowlist (full
11
- * address, not a domain suffix) + `HQ_SYNC_EVENT_SYNC` env override in both
12
- * directions. ONE gate governs publish AND receive so a device is never
13
- * publish-only or receive-only in a half-rolled state.
10
+ * - {@link resolveEventSync} — a negative-only local kill switch. A server
11
+ * grant remains the only positive authority for publish and receive.
14
12
  * - {@link subscribeSyncReceive} — `POST /v1/sync/subscribe` (US-015/US-016):
15
13
  * mints the per-device queue and returns `{queueUrl, region, credentials}`,
16
14
  * where `credentials` are short-lived STS creds scoped to receive/delete on
@@ -22,7 +20,7 @@
22
20
  * retry on an expiry-class error. The queue URL is stable across re-vends
23
21
  * (same device → same queue, idempotent endpoint); only creds rotate.
24
22
  * - {@link startEventSync} — the wiring entry the runner calls from the
25
- * `--event-push` watch block when the gate is ON. Resolves tenant + device
23
+ * `--event-push` watch block unless locally disabled. Resolves tenant + device
26
24
  * identity, builds the {@link HttpPushTransport} + {@link PushEventEmitter}
27
25
  * (publish leg) and the {@link SqsPushReceiver} (receive leg, self-echo
28
26
  * filtered), and returns handles. Any startup failure degrades to
@@ -57,42 +55,16 @@ import {
57
55
  createSyncLatencyMetricPublisher,
58
56
  type SyncMetricCredentials,
59
57
  } from "./metrics.js";
60
-
61
- // ─── US-019: rollout gate ───────────────────────────────────────────────────
58
+ import { isEventSyncLocallyDisabled } from "./feature-flags.js";
62
59
 
63
60
  /**
64
- * Accounts enrolled in event-driven sync (publish + receive).
61
+ * Decide whether the client may request event-sync authority from the server.
65
62
  *
66
- * EXACT full-address matching, case-insensitive NOT a domain suffix. The
67
- * single-account Phase 3 rollout (2026-06-10) targets the operator's own
68
- * devices; `xhassaan@getindigo.ai` and `hassaan@getindigo.ai.evil.com` must
69
- * never match. Broadening later is an entry here, then a domain set, then a
70
- * GA default-on switch (the same rollout path the presigned-URL sync transport
71
- * already took to GA).
63
+ * This is negative-only. An unset value or a former force-on value merely
64
+ * permits the authenticated server request; it cannot authorize event sync.
72
65
  */
73
- export const EVENT_SYNC_ROLLOUT_EMAILS: ReadonlySet<string> = new Set([
74
- "hassaan@getindigo.ai",
75
- ]);
76
-
77
- /**
78
- * Decide whether this session runs event-driven sync (publish + receive).
79
- *
80
- * Env-override-in-both-directions precedence: `HQ_SYNC_EVENT_SYNC`
81
- * overrides (`1`/`true`/`yes`/`on` → force on,
82
- * `0`/`false`/`no`/`off` → force off) so unenrolled testers can exercise it
83
- * and enrolled accounts can be rolled back without a release. An unset/blank
84
- * override falls through to the exact-email check; an unrecognized override
85
- * value is ignored (email check wins).
86
- */
87
- export function resolveEventSync(
88
- email: string | undefined,
89
- override: string | undefined,
90
- ): boolean {
91
- const o = (override ?? "").trim().toLowerCase();
92
- if (o === "1" || o === "true" || o === "yes" || o === "on") return true;
93
- if (o === "0" || o === "false" || o === "no" || o === "off") return false;
94
- if (typeof email !== "string") return false;
95
- return EVENT_SYNC_ROLLOUT_EMAILS.has(email.trim().toLowerCase());
66
+ export function resolveEventSync(override: string | undefined): boolean {
67
+ return !isEventSyncLocallyDisabled(override);
96
68
  }
97
69
 
98
70
  // ─── US-018: subscribe + credentials ────────────────────────────────────────
@@ -454,6 +426,19 @@ export async function startEventSync(
454
426
  const tenantId = await opts.resolveTenantId();
455
427
  const deviceId = opts.deviceId;
456
428
 
429
+ // The subscription response is the authenticated legacy inventory/grant.
430
+ // No email, environment, tenant, or queued hint can reach the live path
431
+ // without this server-issued queue and credential material.
432
+ const subscribe =
433
+ opts.subscribe ??
434
+ ((dev: string) =>
435
+ subscribeSyncReceive({
436
+ apiUrl: opts.apiUrl,
437
+ authToken: opts.authToken,
438
+ deviceId: dev,
439
+ }));
440
+ const initial = await subscribe(deviceId);
441
+
457
442
  // ── publish leg (US-017) ──
458
443
  const transport =
459
444
  opts.transport ??
@@ -467,8 +452,9 @@ export async function startEventSync(
467
452
  originTenantId: tenantId,
468
453
  originDeviceId: deviceId,
469
454
  transport,
470
- // The rollout gate (resolveEventSync) already said yes the emitter's
471
- // per-tenant env flag is a server-side convention, not the client gate.
455
+ // This adapter represents the authenticated server grant above. It is
456
+ // created only after the grant succeeds and ignores the tenant argument;
457
+ // it is not a tenant-derived client selector.
472
458
  flagProvider: { isEnabled: () => true },
473
459
  // Wall-clock sequence numbers: monotonic per device ACROSS daemon
474
460
  // restarts (the emitter's default in-process counter restarts at 0,
@@ -485,15 +471,6 @@ export async function startEventSync(
485
471
  });
486
472
 
487
473
  // ── receive leg (US-018) ──
488
- const subscribe =
489
- opts.subscribe ??
490
- ((dev: string) =>
491
- subscribeSyncReceive({
492
- apiUrl: opts.apiUrl,
493
- authToken: opts.authToken,
494
- deviceId: dev,
495
- }));
496
- const initial = await subscribe(deviceId);
497
474
  const publishMetric: PublishMetricFn = initial.cloudWatch
498
475
  ? createSyncLatencyMetricPublisher(initial.cloudWatch)
499
476
  : async () => undefined;
@@ -515,9 +492,6 @@ export async function startEventSync(
515
492
  await opts.syncFn(ctx);
516
493
  },
517
494
  publishMetric,
518
- // The client rollout gate is the authority; the env-driven per-tenant
519
- // flag provider is a server-side convention not set on user machines.
520
- enabled: true,
521
495
  });
522
496
  await receiver.start();
523
497
 
@@ -28,11 +28,8 @@ import path from "node:path";
28
28
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
29
29
 
30
30
  import {
31
- EnvTenantListFlagProvider,
32
- FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR,
33
- FEATURE_FLAG_TENANTS_ENV_VAR,
34
- StaticFlagProvider,
35
- defaultFlagProvider,
31
+ EVENT_SYNC_LOCAL_DISABLE_ENV_VAR,
32
+ isEventSyncLocallyDisabled,
36
33
  } from "./feature-flags.js";
37
34
  import {
38
35
  HttpPushTransport,
@@ -79,72 +76,25 @@ function makeFetch(
79
76
  return { fetch, calls };
80
77
  }
81
78
 
82
- // ─── 1. Flag gating (feature-flags.ts) ────────────────────────────────────────
79
+ // ─── 1. Local disable (feature-flags.ts) ─────────────────────────────────────
83
80
 
84
- describe("EventDrivenPush feature flags gating", () => {
85
- it("StaticFlagProvider enables only listed tenants", () => {
86
- const p = new StaticFlagProvider(["indigo"]);
87
- expect(p.isEnabled("indigo")).toBe(true);
88
- expect(p.isEnabled("acme")).toBe(false);
89
- });
90
-
91
- it("env tenant list: empty/unset → all tenants OFF (default)", () => {
92
- expect(new EnvTenantListFlagProvider({ env: {} }).isEnabled("indigo")).toBe(
93
- false,
94
- );
95
- expect(
96
- new EnvTenantListFlagProvider({
97
- env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: "" },
98
- }).isEnabled("indigo"),
99
- ).toBe(false);
100
- });
101
-
102
- it("env tenant list enables exact, case-sensitive matches only", () => {
103
- const p = new EnvTenantListFlagProvider({
104
- env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: " indigo , acme " },
105
- });
106
- expect(p.isEnabled("indigo")).toBe(true);
107
- expect(p.isEnabled("acme")).toBe(true);
108
- expect(p.isEnabled("Indigo")).toBe(false);
109
- expect(p.isEnabled("other")).toBe(false);
110
- expect(p.enabledTenants).toEqual(new Set(["indigo", "acme"]));
111
- });
81
+ const serverGranted = { isEnabled: () => true };
82
+ const serverDenied = { isEnabled: () => false };
112
83
 
113
- it("legacy global=true overrides → every tenant enabled", () => {
114
- const p = new EnvTenantListFlagProvider({
115
- env: { [FEATURE_FLAG_LEGACY_GLOBAL_ENV_VAR]: "true" },
116
- });
117
- expect(p.isEnabled("anything")).toBe(true);
118
- expect(p.globalOverride).toBe(true);
119
- });
120
-
121
- it("trailing/empty commas dropped silently; internal-whitespace warn-and-skipped (no throw)", () => {
122
- const warn = vi.fn();
123
- const p = new EnvTenantListFlagProvider({
124
- env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: ",,indigo,,bad tenant," },
125
- warn,
126
- });
127
- expect(p.isEnabled("indigo")).toBe(true);
128
- expect(p.isEnabled("bad tenant")).toBe(false);
129
- expect(warn).toHaveBeenCalledTimes(1);
130
- expect(warn.mock.calls[0][1]).toMatchObject({ rawEntry: "bad tenant" });
84
+ describe("Event sync local control", () => {
85
+ it("exports only the local disable control", () => {
86
+ expect(EVENT_SYNC_LOCAL_DISABLE_ENV_VAR).toBe("HQ_SYNC_EVENT_SYNC");
131
87
  });
132
88
 
133
- it("defaultFlagProvider reads from the supplied env snapshot", () => {
134
- const p = defaultFlagProvider({
135
- [FEATURE_FLAG_TENANTS_ENV_VAR]: "indigo",
136
- });
137
- expect(p.isEnabled("indigo")).toBe(true);
138
- expect(p.isEnabled("acme")).toBe(false);
139
- });
89
+ it.each(["0", "false", "no", "off", " OFF "])(
90
+ "recognizes %j as a local disable",
91
+ (value) => expect(isEventSyncLocallyDisabled(value)).toBe(true),
92
+ );
140
93
 
141
- it("enabledTenants returns a defensive copy (mutation does not corrupt cache)", () => {
142
- const p = new EnvTenantListFlagProvider({
143
- env: { [FEATURE_FLAG_TENANTS_ENV_VAR]: "indigo" },
144
- });
145
- (p.enabledTenants as Set<string>).add("acme");
146
- expect(p.isEnabled("acme")).toBe(false);
147
- });
94
+ it.each([undefined, "", "1", "true", "yes", "on", "indigo,acme"])(
95
+ "%j does not grant authority",
96
+ (value) => expect(isEventSyncLocallyDisabled(value)).toBe(false),
97
+ );
148
98
  });
149
99
 
150
100
  // ─── 2. POST (HttpPushTransport) ──────────────────────────────────────────────
@@ -307,7 +257,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
307
257
  originTenantId: "indigo",
308
258
  originDeviceId: "dev-1",
309
259
  transport: spyTransport,
310
- flagProvider: new StaticFlagProvider(["indigo"]),
260
+ flagProvider: serverGranted,
311
261
  now: () => new Date("2026-05-21T00:00:00.000Z"),
312
262
  });
313
263
 
@@ -331,7 +281,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
331
281
  originTenantId: "indigo",
332
282
  originDeviceId: "dev-1",
333
283
  transport: { start: async () => {}, dispose: async () => {}, connected: true, publish: async (e) => { published.push(e); } },
334
- flagProvider: new StaticFlagProvider(["indigo"]),
284
+ flagProvider: serverGranted,
335
285
  });
336
286
  writeFileSync(path.join(dir, "b.md"), "second");
337
287
  await emitter.emitForBatch(batchFor(REL));
@@ -345,7 +295,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
345
295
  originTenantId: "indigo",
346
296
  originDeviceId: "dev-1",
347
297
  transport: { start: async () => {}, dispose: async () => {}, connected: false, publish },
348
- flagProvider: new StaticFlagProvider([]), // indigo NOT enabled
298
+ flagProvider: serverDenied, // indigo NOT enabled
349
299
  });
350
300
  expect(emitter.enabled).toBe(false);
351
301
  await emitter.emitForBatch(batchFor(REL));
@@ -370,7 +320,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
370
320
  throw new Error("network down");
371
321
  },
372
322
  },
373
- flagProvider: new StaticFlagProvider(["indigo"]),
323
+ flagProvider: serverGranted,
374
324
  onError,
375
325
  });
376
326
  // Must not reject — the daemon stays alive.
@@ -387,7 +337,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
387
337
  originTenantId: "indigo",
388
338
  originDeviceId: "dev-1",
389
339
  transport: { start: async () => {}, dispose: async () => {}, connected: true, publish: async (e) => { published.push(e); } },
390
- flagProvider: new StaticFlagProvider(["indigo"]),
340
+ flagProvider: serverGranted,
391
341
  onError,
392
342
  });
393
343
  // REL exists; "ghost.md" does not.
@@ -414,7 +364,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
414
364
  originTenantId: "indigo",
415
365
  originDeviceId: "dev-1",
416
366
  transport,
417
- flagProvider: new StaticFlagProvider(["indigo"]),
367
+ flagProvider: serverGranted,
418
368
  });
419
369
  await transport.start();
420
370
  await emitter.emitForBatch(batchFor(REL));