@gmickel/gno 1.12.3 → 1.13.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 (69) hide show
  1. package/README.md +57 -30
  2. package/assets/skill/SKILL.md +6 -1
  3. package/assets/skill/cli-reference.md +16 -6
  4. package/assets/skill/mcp-reference.md +22 -3
  5. package/package.json +2 -1
  6. package/src/app/constants.ts +43 -10
  7. package/src/app/index-name.ts +127 -0
  8. package/src/cli/commands/doctor-activation.ts +151 -0
  9. package/src/cli/commands/doctor.ts +41 -16
  10. package/src/cli/commands/get.ts +18 -0
  11. package/src/cli/commands/mcp/atomic-config-write.ts +118 -0
  12. package/src/cli/commands/mcp/config-discovery.ts +42 -0
  13. package/src/cli/commands/mcp/config-editors.ts +432 -0
  14. package/src/cli/commands/mcp/config.ts +63 -160
  15. package/src/cli/commands/mcp/install.ts +75 -37
  16. package/src/cli/commands/mcp/paths.ts +141 -136
  17. package/src/cli/commands/mcp/server-entry.ts +66 -0
  18. package/src/cli/commands/mcp/status.ts +189 -57
  19. package/src/cli/commands/mcp/target-display.ts +30 -0
  20. package/src/cli/commands/mcp/uninstall.ts +29 -31
  21. package/src/cli/commands/mcp/yaml-config-editor.ts +257 -0
  22. package/src/cli/commands/mcp/yaml-layout-scanner.ts +447 -0
  23. package/src/cli/commands/multi-get.ts +31 -6
  24. package/src/cli/commands/status.ts +107 -11
  25. package/src/cli/program.ts +66 -20
  26. package/src/core/activation-connector-health.ts +19 -0
  27. package/src/core/activation-probe-plan.ts +321 -0
  28. package/src/core/activation-probe.ts +138 -0
  29. package/src/core/activation-receipt-store.ts +39 -0
  30. package/src/core/activation-status.ts +513 -0
  31. package/src/core/activation-verifier.ts +416 -0
  32. package/src/core/connector-environment.ts +68 -0
  33. package/src/core/connector-policy.ts +233 -0
  34. package/src/core/connector-verification-target.ts +150 -0
  35. package/src/core/connector-verifier.ts +497 -0
  36. package/src/core/context-resolver.ts +285 -0
  37. package/src/core/indexed-reference.ts +33 -8
  38. package/src/core/runtime-entrypoint.ts +24 -0
  39. package/src/mcp/activation-verification-mode.ts +4 -0
  40. package/src/mcp/server.ts +9 -2
  41. package/src/mcp/tools/index.ts +3 -3
  42. package/src/pipeline/answer-prompt.ts +80 -0
  43. package/src/pipeline/answer.ts +12 -26
  44. package/src/pipeline/hybrid.ts +2 -0
  45. package/src/pipeline/result-context.ts +51 -0
  46. package/src/pipeline/search.ts +5 -1
  47. package/src/pipeline/vsearch.ts +2 -0
  48. package/src/sdk/client.ts +7 -0
  49. package/src/sdk/types.ts +1 -0
  50. package/src/serve/activation-health.ts +91 -0
  51. package/src/serve/background-runtime.ts +11 -1
  52. package/src/serve/connectors.ts +164 -19
  53. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  54. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  55. package/src/serve/public/components/HealthCenter.tsx +8 -2
  56. package/src/serve/public/globals.built.css +1 -1
  57. package/src/serve/public/pages/Connectors.tsx +216 -55
  58. package/src/serve/public/pages/Dashboard.tsx +1 -0
  59. package/src/serve/routes/api.ts +152 -8
  60. package/src/serve/server.ts +44 -9
  61. package/src/serve/status-model.ts +4 -0
  62. package/src/serve/status.ts +79 -35
  63. package/src/store/activation-receipts.ts +390 -0
  64. package/src/store/index.ts +8 -0
  65. package/src/store/migrations/012-activation-receipts.ts +38 -0
  66. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  67. package/src/store/migrations/index.ts +4 -0
  68. package/src/store/sqlite/adapter.ts +320 -53
  69. package/src/store/types.ts +124 -0
@@ -25,6 +25,8 @@ import { migration as m008 } from "./008-vector-fingerprints";
25
25
  import { migration as m009 } from "./009-content-type-rule-fingerprint";
26
26
  import { migration as m010 } from "./010-typed-edges";
27
27
  import { migration as m011 } from "./011-doc-edge-traversal-indexes";
28
+ import { migration as m012 } from "./012-activation-receipts";
29
+ import { migration as m013 } from "./013-fts-sync-marker";
28
30
 
29
31
  /** All migrations in order */
30
32
  export const migrations = [
@@ -39,4 +41,6 @@ export const migrations = [
39
41
  m009,
40
42
  m010,
41
43
  m011,
44
+ m012,
45
+ m013,
42
46
  ];
@@ -15,6 +15,10 @@ import { basename } from "node:path";
15
15
 
16
16
  import type { Collection, Context, FtsTokenizer } from "../../config/types";
17
17
  import type {
18
+ ActivationIndexDocument,
19
+ ActivationIndexIdentity,
20
+ ActivationIndexSnapshot,
21
+ ActivationVerificationReceipt,
18
22
  BacklinkRow,
19
23
  ChunkInput,
20
24
  ChunkRow,
@@ -63,7 +67,11 @@ import {
63
67
  buildWikiBestRankSubquery,
64
68
  } from "../../core/graph-resolver";
65
69
  import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
66
- import { migrations, runMigrations } from "../migrations";
70
+ import {
71
+ parseActivationReceipt,
72
+ serializeActivationReceipt,
73
+ } from "../activation-receipts";
74
+ import { getSchemaVersion, migrations, runMigrations } from "../migrations";
67
75
  import { err, ok } from "../types";
68
76
  import { getStoredEmbeddingFingerprint } from "../vector/freshness";
69
77
  import { modelTableName } from "../vector/sqlite-vec";
@@ -84,6 +92,29 @@ const FTS5_FIELD_WEIGHTS = {
84
92
  body: 1.0,
85
93
  } as const;
86
94
 
95
+ /** Content-free activation snapshot query; kept exported for contract tests. */
96
+ export const ACTIVATION_INDEX_SNAPSHOT_SQL = `SELECT
97
+ d.id,
98
+ d.uri,
99
+ d.source_hash,
100
+ d.mirror_hash,
101
+ d.active,
102
+ CASE WHEN f.rowid IS NULL THEN 0 ELSE 1 END AS fts_present,
103
+ CASE
104
+ WHEN d.mirror_hash IS NULL AND f.rowid IS NULL THEN 1
105
+ WHEN
106
+ f.rowid IS NOT NULL
107
+ AND f.filepath = d.rel_path
108
+ AND f.title = COALESCE(d.title, '')
109
+ AND d.fts_mirror_hash = d.mirror_hash
110
+ THEN 1
111
+ ELSE 0
112
+ END AS fts_current
113
+ FROM documents d
114
+ LEFT JOIN documents_fts f ON f.rowid = d.id
115
+ WHERE d.collection = ? AND d.active = 1
116
+ ORDER BY d.uri ASC, d.id ASC`;
117
+
87
118
  function sanitizeFts5Term(term: string): string {
88
119
  return term.replace(/[^\p{L}\p{N}'_]/gu, "").toLowerCase();
89
120
  }
@@ -278,6 +309,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
278
309
  private configPath = ""; // Set by CLI layer for status output
279
310
  private txDepth = 0; // Transaction nesting depth
280
311
  private txCounter = 0; // Savepoint counter for unique names
312
+ private contextGeneration = 0;
281
313
 
282
314
  // ─────────────────────────────────────────────────────────────────────────
283
315
  // Lifecycle
@@ -341,6 +373,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
341
373
  return result;
342
374
  }
343
375
 
376
+ this.contextGeneration += 1;
344
377
  return result;
345
378
  } catch (cause) {
346
379
  const message =
@@ -517,6 +550,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
517
550
  });
518
551
 
519
552
  transaction();
553
+ this.contextGeneration += 1;
520
554
  return ok(undefined);
521
555
  } catch (cause) {
522
556
  return err(
@@ -559,6 +593,177 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
559
593
  }
560
594
  }
561
595
 
596
+ async getActivationIndexIdentity(
597
+ collection: string
598
+ ): Promise<StoreResult<ActivationIndexIdentity>> {
599
+ const snapshot = await this.getActivationIndexSnapshot(collection);
600
+ return snapshot.ok ? ok(snapshot.value.identity) : snapshot;
601
+ }
602
+
603
+ async getActivationIndexSnapshot(
604
+ collection: string
605
+ ): Promise<StoreResult<ActivationIndexSnapshot>> {
606
+ try {
607
+ const db = this.ensureOpen();
608
+ const indexName =
609
+ basename(this.dbPath)
610
+ .replace(SQLITE_EXT_REGEX, "")
611
+ .replace(INDEX_PREFIX_REGEX, "") || "default";
612
+ const rows = db
613
+ .query<
614
+ {
615
+ id: number;
616
+ uri: string;
617
+ source_hash: string;
618
+ mirror_hash: string | null;
619
+ active: number;
620
+ fts_present: number;
621
+ fts_current: number;
622
+ },
623
+ [string]
624
+ >(ACTIVATION_INDEX_SNAPSHOT_SQL)
625
+ .all(collection);
626
+ const ftsHasher = new Bun.CryptoHasher("sha256");
627
+ ftsHasher.update(
628
+ JSON.stringify(
629
+ rows.map(({ uri, fts_present, fts_current }) => ({
630
+ uri,
631
+ present: fts_present,
632
+ current: fts_current,
633
+ }))
634
+ )
635
+ );
636
+ const documents: ActivationIndexDocument[] = rows.map((row) => ({
637
+ id: row.id,
638
+ uri: row.uri,
639
+ sourceHash: row.source_hash,
640
+ mirrorHash: row.mirror_hash,
641
+ active: row.active === 1,
642
+ }));
643
+ return ok({
644
+ identity: {
645
+ indexName,
646
+ schemaVersion: getSchemaVersion(db),
647
+ ftsTokenizer: this.ftsTokenizer,
648
+ ftsStateHash: ftsHasher.digest("hex"),
649
+ activeDocumentCount: documents.length,
650
+ ftsSynchronized: rows.every((row) => row.fts_current === 1),
651
+ },
652
+ documents,
653
+ });
654
+ } catch (cause) {
655
+ return err(
656
+ "QUERY_FAILED",
657
+ cause instanceof Error
658
+ ? cause.message
659
+ : "Failed to get activation index identity",
660
+ cause
661
+ );
662
+ }
663
+ }
664
+
665
+ async getActivationReceipt(
666
+ collection: string,
667
+ expectedFingerprint: string,
668
+ connectorTarget = ""
669
+ ): Promise<StoreResult<ActivationVerificationReceipt | null>> {
670
+ try {
671
+ const db = this.ensureOpen();
672
+ const row = db
673
+ .query<
674
+ {
675
+ collection: string;
676
+ connector_target: string;
677
+ fingerprint: string;
678
+ receipt_json: string;
679
+ },
680
+ [string, string]
681
+ >(
682
+ `SELECT collection, connector_target, fingerprint, receipt_json
683
+ FROM activation_receipts
684
+ WHERE collection = ? AND connector_target = ?`
685
+ )
686
+ .get(collection, connectorTarget);
687
+
688
+ if (!row) {
689
+ return ok(null);
690
+ }
691
+ if (row.fingerprint !== expectedFingerprint) {
692
+ db.run(
693
+ "DELETE FROM activation_receipts WHERE collection = ? AND connector_target = ?",
694
+ [collection, connectorTarget]
695
+ );
696
+ return ok(null);
697
+ }
698
+
699
+ const receipt = parseActivationReceipt(row.receipt_json);
700
+ if (
701
+ !receipt ||
702
+ receipt.fingerprint !== expectedFingerprint ||
703
+ receipt.collection !== row.collection ||
704
+ (receipt.evidence.connectorTarget ?? "") !== row.connector_target
705
+ ) {
706
+ db.run(
707
+ "DELETE FROM activation_receipts WHERE collection = ? AND connector_target = ?",
708
+ [collection, connectorTarget]
709
+ );
710
+ return ok(null);
711
+ }
712
+ return ok(receipt);
713
+ } catch (cause) {
714
+ return err(
715
+ "QUERY_FAILED",
716
+ cause instanceof Error
717
+ ? cause.message
718
+ : "Failed to get activation receipt",
719
+ cause
720
+ );
721
+ }
722
+ }
723
+
724
+ async upsertActivationReceipt(
725
+ receipt: ActivationVerificationReceipt
726
+ ): Promise<StoreResult<void>> {
727
+ try {
728
+ const db = this.ensureOpen();
729
+ const serialized = serializeActivationReceipt(receipt);
730
+ if (!serialized.ok) {
731
+ return err("INVALID_INPUT", serialized.error);
732
+ }
733
+ db.run(
734
+ `INSERT INTO activation_receipts (
735
+ collection, connector_target, schema_version, fingerprint,
736
+ receipt_json, updated_at
737
+ ) VALUES (?, ?, ?, ?, ?, datetime('now'))
738
+ ON CONFLICT(collection, connector_target) DO UPDATE SET
739
+ schema_version = excluded.schema_version,
740
+ fingerprint = excluded.fingerprint,
741
+ receipt_json = excluded.receipt_json,
742
+ updated_at = datetime('now')`,
743
+ [
744
+ serialized.projected.collection,
745
+ serialized.connectorTarget,
746
+ serialized.projected.schemaVersion,
747
+ serialized.projected.fingerprint,
748
+ serialized.json,
749
+ ]
750
+ );
751
+ return ok(undefined);
752
+ } catch (cause) {
753
+ return err(
754
+ "QUERY_FAILED",
755
+ cause instanceof Error
756
+ ? cause.message
757
+ : "Failed to persist activation receipt",
758
+ cause
759
+ );
760
+ }
761
+ }
762
+
763
+ getContextGeneration(): number {
764
+ return this.contextGeneration;
765
+ }
766
+
562
767
  // ─────────────────────────────────────────────────────────────────────────
563
768
  // Documents
564
769
  // ─────────────────────────────────────────────────────────────────────────
@@ -571,8 +776,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
571
776
  const docid = deriveDocid(doc.sourceHash);
572
777
  const uri = buildUri(doc.collection, doc.relPath);
573
778
 
574
- db.run(
575
- `
779
+ const transaction = db.transaction((): UpsertDocumentResult => {
780
+ db.run(
781
+ `
576
782
  INSERT INTO documents (
577
783
  collection, rel_path, source_hash, source_mime, source_ext,
578
784
  source_size, source_mtime, source_ctime, docid, uri, title, mirror_hash,
@@ -610,48 +816,61 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
610
816
  ingest_version = excluded.ingest_version,
611
817
  updated_at = datetime('now')
612
818
  `,
613
- [
614
- doc.collection,
615
- doc.relPath,
616
- doc.sourceHash,
617
- doc.sourceMime,
618
- doc.sourceExt,
619
- doc.sourceSize,
620
- doc.sourceMtime,
621
- doc.sourceCtime ?? doc.sourceMtime,
622
- docid,
623
- uri,
624
- doc.title ?? null,
625
- doc.mirrorHash ?? null,
626
- doc.converterId ?? null,
627
- doc.converterVersion ?? null,
628
- doc.languageHint ?? null,
629
- doc.contentType ?? null,
630
- doc.categories ? JSON.stringify(doc.categories) : null,
631
- doc.contentTypeSource ?? null,
632
- doc.author ?? null,
633
- doc.frontmatterDate ?? null,
634
- doc.dateFields ? JSON.stringify(doc.dateFields) : null,
635
- doc.contentTypeRulesFingerprint ?? null,
636
- doc.lastErrorCode ?? null,
637
- doc.lastErrorMessage ?? null,
638
- doc.lastErrorCode ? new Date().toISOString() : null,
639
- doc.ingestVersion ?? null,
640
- ]
641
- );
819
+ [
820
+ doc.collection,
821
+ doc.relPath,
822
+ doc.sourceHash,
823
+ doc.sourceMime,
824
+ doc.sourceExt,
825
+ doc.sourceSize,
826
+ doc.sourceMtime,
827
+ doc.sourceCtime ?? doc.sourceMtime,
828
+ docid,
829
+ uri,
830
+ doc.title ?? null,
831
+ doc.mirrorHash ?? null,
832
+ doc.converterId ?? null,
833
+ doc.converterVersion ?? null,
834
+ doc.languageHint ?? null,
835
+ doc.contentType ?? null,
836
+ doc.categories ? JSON.stringify(doc.categories) : null,
837
+ doc.contentTypeSource ?? null,
838
+ doc.author ?? null,
839
+ doc.frontmatterDate ?? null,
840
+ doc.dateFields ? JSON.stringify(doc.dateFields) : null,
841
+ doc.contentTypeRulesFingerprint ?? null,
842
+ doc.lastErrorCode ?? null,
843
+ doc.lastErrorMessage ?? null,
844
+ doc.lastErrorCode ? new Date().toISOString() : null,
845
+ doc.ingestVersion ?? null,
846
+ ]
847
+ );
642
848
 
643
- // Get the row id (either inserted or updated)
644
- const idRow = db
645
- .query<{ id: number }, [string, string]>(
646
- "SELECT id FROM documents WHERE collection = ? AND rel_path = ?"
647
- )
648
- .get(doc.collection, doc.relPath);
849
+ // Get the row id (either inserted or updated)
850
+ const idRow = db
851
+ .query<{ id: number }, [string, string]>(
852
+ "SELECT id FROM documents WHERE collection = ? AND rel_path = ?"
853
+ )
854
+ .get(doc.collection, doc.relPath);
649
855
 
650
- if (!idRow) {
651
- return err("QUERY_FAILED", "Failed to get document id after upsert");
652
- }
856
+ if (!idRow) {
857
+ throw new Error("Failed to get document id after upsert");
858
+ }
859
+
860
+ // Conversion failures deliberately drop mirror ownership. Remove the
861
+ // old lexical projection in the same transaction so stale content can
862
+ // never join against the newly updated document metadata.
863
+ if (doc.mirrorHash == null) {
864
+ db.run("DELETE FROM documents_fts WHERE rowid = ?", [idRow.id]);
865
+ db.run("UPDATE documents SET fts_mirror_hash = NULL WHERE id = ?", [
866
+ idRow.id,
867
+ ]);
868
+ }
869
+
870
+ return { id: idRow.id, docid };
871
+ });
653
872
 
654
- return ok({ id: idRow.id, docid });
873
+ return ok(transaction());
655
874
  } catch (cause) {
656
875
  return err(
657
876
  "QUERY_FAILED",
@@ -1138,6 +1357,29 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1138
1357
  }
1139
1358
  }
1140
1359
 
1360
+ async getContentPrefix(
1361
+ mirrorHash: string,
1362
+ maxChars: number
1363
+ ): Promise<StoreResult<string | null>> {
1364
+ try {
1365
+ const db = this.ensureOpen();
1366
+ const boundedChars = Math.max(0, Math.floor(maxChars));
1367
+ const row = db
1368
+ .query<{ markdown: string }, [number, string]>(
1369
+ "SELECT substr(markdown, 1, ?) AS markdown FROM content WHERE mirror_hash = ?"
1370
+ )
1371
+ .get(boundedChars, mirrorHash);
1372
+
1373
+ return ok(row?.markdown ?? null);
1374
+ } catch (cause) {
1375
+ return err(
1376
+ "QUERY_FAILED",
1377
+ cause instanceof Error ? cause.message : "Failed to get content prefix",
1378
+ cause
1379
+ );
1380
+ }
1381
+ }
1382
+
1141
1383
  async getContentBatch(
1142
1384
  mirrorHashes: string[]
1143
1385
  ): Promise<StoreResult<Map<string, string>>> {
@@ -1352,12 +1594,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1352
1594
  params.push(`%${options.author.toLowerCase()}%`);
1353
1595
  }
1354
1596
 
1355
- if (options.collection) {
1356
- params.push(options.collection);
1357
- }
1358
-
1359
- const hasOuterFilters =
1360
- tagConditions.length > 0 || Boolean(options.collection);
1597
+ const hasOuterFilters = tagConditions.length > 0;
1361
1598
  const ftsLimit = hasOuterFilters ? limit * 10 : limit;
1362
1599
  params.push(limit);
1363
1600
 
@@ -1376,6 +1613,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1376
1613
  ) as score
1377
1614
  FROM documents_fts
1378
1615
  WHERE documents_fts MATCH ?
1616
+ AND rowid IN (
1617
+ SELECT id FROM documents
1618
+ WHERE active = 1
1619
+ ${options.collection ? "AND collection = ?" : ""}
1620
+ )
1379
1621
  ORDER BY score
1380
1622
  LIMIT ?
1381
1623
  )
@@ -1401,7 +1643,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1401
1643
  JOIN documents d ON d.id = fm.rowid AND d.active = 1
1402
1644
  WHERE 1 = 1
1403
1645
  ${tagConditions.length > 0 ? `AND ${tagConditions.join(" AND ")}` : ""}
1404
- ${options.collection ? "AND d.collection = ?" : ""}
1405
1646
  ORDER BY fm.score
1406
1647
  LIMIT ?
1407
1648
  `;
@@ -1426,7 +1667,12 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1426
1667
  categories: string | null;
1427
1668
  }
1428
1669
 
1429
- const queryParams = [builtQuery.query, ftsLimit, ...params];
1670
+ const queryParams = [
1671
+ builtQuery.query,
1672
+ ...(options.collection ? [options.collection] : []),
1673
+ ftsLimit,
1674
+ ...params,
1675
+ ];
1430
1676
  const rows = db
1431
1677
  .query<FtsRow, (string | number)[]>(sql)
1432
1678
  .all(...queryParams);
@@ -1485,12 +1731,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1485
1731
  id: number;
1486
1732
  rel_path: string;
1487
1733
  title: string | null;
1734
+ mirror_hash: string | null;
1488
1735
  markdown: string | null;
1489
1736
  }
1490
1737
 
1491
1738
  const doc = db
1492
1739
  .query<DocWithContent, [string, string]>(
1493
- `SELECT d.id, d.rel_path, d.title, c.markdown
1740
+ `SELECT d.id, d.rel_path, d.title, d.mirror_hash, c.markdown
1494
1741
  FROM documents d
1495
1742
  LEFT JOIN content c ON c.mirror_hash = d.mirror_hash
1496
1743
  WHERE d.collection = ? AND d.rel_path = ? AND d.active = 1`
@@ -1503,13 +1750,20 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1503
1750
 
1504
1751
  // Delete existing FTS entry for this doc
1505
1752
  db.run("DELETE FROM documents_fts WHERE rowid = ?", [doc.id]);
1753
+ db.run("UPDATE documents SET fts_mirror_hash = NULL WHERE id = ?", [
1754
+ doc.id,
1755
+ ]);
1506
1756
 
1507
1757
  // Insert new FTS entry if we have content
1508
- if (doc.markdown) {
1758
+ if (doc.markdown !== null) {
1509
1759
  db.run(
1510
1760
  "INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)",
1511
1761
  [doc.id, doc.rel_path, doc.title ?? "", doc.markdown]
1512
1762
  );
1763
+ db.run("UPDATE documents SET fts_mirror_hash = ? WHERE id = ?", [
1764
+ doc.mirror_hash,
1765
+ doc.id,
1766
+ ]);
1513
1767
  }
1514
1768
  });
1515
1769
 
@@ -1536,18 +1790,20 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1536
1790
  const transaction = db.transaction(() => {
1537
1791
  // Clear FTS table
1538
1792
  db.run("DELETE FROM documents_fts");
1793
+ db.run("UPDATE documents SET fts_mirror_hash = NULL");
1539
1794
 
1540
1795
  // Get all active documents with content
1541
1796
  interface DocWithContent {
1542
1797
  id: number;
1543
1798
  rel_path: string;
1544
1799
  title: string | null;
1800
+ mirror_hash: string;
1545
1801
  markdown: string;
1546
1802
  }
1547
1803
 
1548
1804
  const docs = db
1549
1805
  .query<DocWithContent, []>(
1550
- `SELECT d.id, d.rel_path, d.title, c.markdown
1806
+ `SELECT d.id, d.rel_path, d.title, d.mirror_hash, c.markdown
1551
1807
  FROM documents d
1552
1808
  JOIN content c ON c.mirror_hash = d.mirror_hash
1553
1809
  WHERE d.active = 1 AND d.mirror_hash IS NOT NULL`
@@ -1558,9 +1814,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1558
1814
  const stmt = db.prepare(
1559
1815
  "INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)"
1560
1816
  );
1817
+ const markSynced = db.prepare(
1818
+ "UPDATE documents SET fts_mirror_hash = ? WHERE id = ?"
1819
+ );
1561
1820
 
1562
1821
  for (const doc of docs) {
1563
1822
  stmt.run(doc.id, doc.rel_path, doc.title ?? "", doc.markdown);
1823
+ markSynced.run(doc.mirror_hash, doc.id);
1564
1824
  count++;
1565
1825
  }
1566
1826
  });
@@ -1614,10 +1874,17 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1614
1874
  // Update FTS for each document using this hash
1615
1875
  for (const doc of docs) {
1616
1876
  db.run("DELETE FROM documents_fts WHERE rowid = ?", [doc.id]);
1877
+ db.run("UPDATE documents SET fts_mirror_hash = NULL WHERE id = ?", [
1878
+ doc.id,
1879
+ ]);
1617
1880
  db.run(
1618
1881
  "INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)",
1619
1882
  [doc.id, doc.rel_path, doc.title ?? "", content.markdown]
1620
1883
  );
1884
+ db.run("UPDATE documents SET fts_mirror_hash = ? WHERE id = ?", [
1885
+ mirrorHash,
1886
+ doc.id,
1887
+ ]);
1621
1888
  }
1622
1889
  });
1623
1890
 
@@ -732,6 +732,90 @@ export interface MigrationResult {
732
732
  ftsTokenizer: FtsTokenizer;
733
733
  }
734
734
 
735
+ // ─────────────────────────────────────────────────────────────────────────────
736
+ // Activation Verification
737
+ // ─────────────────────────────────────────────────────────────────────────────
738
+
739
+ export type ActivationStageName =
740
+ | "index"
741
+ | "lexical"
742
+ | "semantic"
743
+ | "connector";
744
+
745
+ export type ActivationStageStatus = "passed" | "pending" | "failed" | "skipped";
746
+
747
+ export type ActivationVerificationCode =
748
+ | "no_documents"
749
+ | "index_out_of_sync"
750
+ | "no_probe_term"
751
+ | "index_query_failed"
752
+ | "retrieval_mismatch"
753
+ | "semantic_not_checked"
754
+ | "connector_not_requested"
755
+ | "connector_not_configured"
756
+ | "connector_probe_unavailable"
757
+ | "connector_unsupported_config"
758
+ | "connector_start_failed"
759
+ | "connector_timeout"
760
+ | "connector_missing_tools"
761
+ | "connector_status_failed"
762
+ | "connector_search_failed"
763
+ | "connector_result_mismatch"
764
+ | "target_runtime_unverifiable";
765
+
766
+ export interface ActivationStageReceipt {
767
+ status: ActivationStageStatus;
768
+ startedAt: string | null;
769
+ completedAt: string | null;
770
+ latencyMs: number | null;
771
+ code?: ActivationVerificationCode;
772
+ }
773
+
774
+ /** Privacy-bounded, per-collection proof that the local index can retrieve. */
775
+ export interface ActivationVerificationReceipt {
776
+ schemaVersion: "1.0";
777
+ collection: string;
778
+ fingerprint: string;
779
+ ready: boolean;
780
+ generatedAt: string;
781
+ stages: Record<ActivationStageName, ActivationStageReceipt>;
782
+ evidence: {
783
+ /** Corpus-keyed SHA-256 probe digest. The term/key are never persisted. */
784
+ probeHash?: string;
785
+ resultUri?: string;
786
+ resultSourceHash?: string;
787
+ connectorTarget?: string;
788
+ };
789
+ }
790
+
791
+ /** Stable index inputs included in activation fingerprints. */
792
+ export interface ActivationIndexIdentity {
793
+ indexName: string;
794
+ schemaVersion: number;
795
+ ftsTokenizer: FtsTokenizer;
796
+ /** Hash of collection-scoped active FTS synchronization state. */
797
+ ftsStateHash: string;
798
+ /** Number of active documents represented by the snapshot. */
799
+ activeDocumentCount: number;
800
+ /** True only when every active document has a current owned FTS row. */
801
+ ftsSynchronized: boolean;
802
+ }
803
+
804
+ /** Content-free document identity used by passive activation fingerprints. */
805
+ export interface ActivationIndexDocument {
806
+ id: number;
807
+ uri: string;
808
+ sourceHash: string;
809
+ mirrorHash: string | null;
810
+ active: boolean;
811
+ }
812
+
813
+ /** One metadata-only snapshot; never contains source or indexed body text. */
814
+ export interface ActivationIndexSnapshot {
815
+ identity: ActivationIndexIdentity;
816
+ documents: ActivationIndexDocument[];
817
+ }
818
+
735
819
  // ─────────────────────────────────────────────────────────────────────────────
736
820
  // Transaction Types
737
821
  // ─────────────────────────────────────────────────────────────────────────────
@@ -800,6 +884,12 @@ export interface StorePort {
800
884
  */
801
885
  syncContexts(contexts: Context[]): Promise<StoreResult<void>>;
802
886
 
887
+ /**
888
+ * Monotonic in-process generation for the persisted context snapshot.
889
+ * Changes after a successful context sync or when the store is reopened.
890
+ */
891
+ getContextGeneration(): number;
892
+
803
893
  /**
804
894
  * Get all collections from DB.
805
895
  */
@@ -810,6 +900,31 @@ export interface StorePort {
810
900
  */
811
901
  getContexts(): Promise<StoreResult<ContextRow[]>>;
812
902
 
903
+ /** Schema/tokenizer identity used to invalidate activation receipts. */
904
+ getActivationIndexIdentity(
905
+ collection: string
906
+ ): Promise<StoreResult<ActivationIndexIdentity>>;
907
+
908
+ /** Metadata/hash-only identity and documents for passive activation checks. */
909
+ getActivationIndexSnapshot(
910
+ collection: string
911
+ ): Promise<StoreResult<ActivationIndexSnapshot>>;
912
+
913
+ /**
914
+ * Load the current activation receipt. A row with a different fingerprint is
915
+ * deleted and returned as null so stale readiness cannot escape the store.
916
+ */
917
+ getActivationReceipt(
918
+ collection: string,
919
+ expectedFingerprint: string,
920
+ connectorTarget?: string
921
+ ): Promise<StoreResult<ActivationVerificationReceipt | null>>;
922
+
923
+ /** Persist a strictly projected, privacy-bounded activation receipt. */
924
+ upsertActivationReceipt(
925
+ receipt: ActivationVerificationReceipt
926
+ ): Promise<StoreResult<void>>;
927
+
813
928
  // ─────────────────────────────────────────────────────────────────────────
814
929
  // Documents
815
930
  // ─────────────────────────────────────────────────────────────────────────
@@ -922,6 +1037,15 @@ export interface StorePort {
922
1037
  */
923
1038
  getContent(mirrorHash: string): Promise<StoreResult<string | null>>;
924
1039
 
1040
+ /**
1041
+ * Read at most maxChars from stored markdown. Used by bounded activation
1042
+ * probes so readiness checks never materialize whole documents.
1043
+ */
1044
+ getContentPrefix(
1045
+ mirrorHash: string,
1046
+ maxChars: number
1047
+ ): Promise<StoreResult<string | null>>;
1048
+
925
1049
  /**
926
1050
  * Batch fetch markdown content for multiple mirror hashes.
927
1051
  * Returns a map of mirrorHash -> markdown for hashes that exist.