@gmickel/gno 1.12.4 → 1.14.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 (61) hide show
  1. package/README.md +126 -59
  2. package/assets/skill/SKILL.md +8 -1
  3. package/assets/skill/cli-reference.md +18 -7
  4. package/assets/skill/mcp-reference.md +22 -3
  5. package/package.json +3 -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/indexed-reference.ts +33 -8
  37. package/src/core/runtime-entrypoint.ts +24 -0
  38. package/src/mcp/activation-verification-mode.ts +4 -0
  39. package/src/mcp/server.ts +9 -2
  40. package/src/sdk/client.ts +7 -0
  41. package/src/sdk/types.ts +1 -0
  42. package/src/serve/activation-health.ts +91 -0
  43. package/src/serve/background-runtime.ts +11 -1
  44. package/src/serve/connectors.ts +164 -19
  45. package/src/serve/public/components/BootstrapStatus.tsx +94 -1
  46. package/src/serve/public/components/FirstRunWizard.tsx +13 -51
  47. package/src/serve/public/components/HealthCenter.tsx +8 -2
  48. package/src/serve/public/globals.built.css +1 -1
  49. package/src/serve/public/pages/Connectors.tsx +216 -55
  50. package/src/serve/public/pages/Dashboard.tsx +1 -0
  51. package/src/serve/routes/api.ts +152 -8
  52. package/src/serve/server.ts +44 -9
  53. package/src/serve/status-model.ts +4 -0
  54. package/src/serve/status.ts +79 -35
  55. package/src/store/activation-receipts.ts +390 -0
  56. package/src/store/index.ts +8 -0
  57. package/src/store/migrations/012-activation-receipts.ts +38 -0
  58. package/src/store/migrations/013-fts-sync-marker.ts +39 -0
  59. package/src/store/migrations/index.ts +4 -0
  60. package/src/store/sqlite/adapter.ts +313 -53
  61. package/src/store/types.ts +118 -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
  }
@@ -562,6 +593,173 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
562
593
  }
563
594
  }
564
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
+
565
763
  getContextGeneration(): number {
566
764
  return this.contextGeneration;
567
765
  }
@@ -578,8 +776,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
578
776
  const docid = deriveDocid(doc.sourceHash);
579
777
  const uri = buildUri(doc.collection, doc.relPath);
580
778
 
581
- db.run(
582
- `
779
+ const transaction = db.transaction((): UpsertDocumentResult => {
780
+ db.run(
781
+ `
583
782
  INSERT INTO documents (
584
783
  collection, rel_path, source_hash, source_mime, source_ext,
585
784
  source_size, source_mtime, source_ctime, docid, uri, title, mirror_hash,
@@ -617,48 +816,61 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
617
816
  ingest_version = excluded.ingest_version,
618
817
  updated_at = datetime('now')
619
818
  `,
620
- [
621
- doc.collection,
622
- doc.relPath,
623
- doc.sourceHash,
624
- doc.sourceMime,
625
- doc.sourceExt,
626
- doc.sourceSize,
627
- doc.sourceMtime,
628
- doc.sourceCtime ?? doc.sourceMtime,
629
- docid,
630
- uri,
631
- doc.title ?? null,
632
- doc.mirrorHash ?? null,
633
- doc.converterId ?? null,
634
- doc.converterVersion ?? null,
635
- doc.languageHint ?? null,
636
- doc.contentType ?? null,
637
- doc.categories ? JSON.stringify(doc.categories) : null,
638
- doc.contentTypeSource ?? null,
639
- doc.author ?? null,
640
- doc.frontmatterDate ?? null,
641
- doc.dateFields ? JSON.stringify(doc.dateFields) : null,
642
- doc.contentTypeRulesFingerprint ?? null,
643
- doc.lastErrorCode ?? null,
644
- doc.lastErrorMessage ?? null,
645
- doc.lastErrorCode ? new Date().toISOString() : null,
646
- doc.ingestVersion ?? null,
647
- ]
648
- );
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
+ );
649
848
 
650
- // Get the row id (either inserted or updated)
651
- const idRow = db
652
- .query<{ id: number }, [string, string]>(
653
- "SELECT id FROM documents WHERE collection = ? AND rel_path = ?"
654
- )
655
- .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);
656
855
 
657
- if (!idRow) {
658
- return err("QUERY_FAILED", "Failed to get document id after upsert");
659
- }
856
+ if (!idRow) {
857
+ throw new Error("Failed to get document id after upsert");
858
+ }
660
859
 
661
- return ok({ id: idRow.id, docid });
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
+ });
872
+
873
+ return ok(transaction());
662
874
  } catch (cause) {
663
875
  return err(
664
876
  "QUERY_FAILED",
@@ -1145,6 +1357,29 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1145
1357
  }
1146
1358
  }
1147
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
+
1148
1383
  async getContentBatch(
1149
1384
  mirrorHashes: string[]
1150
1385
  ): Promise<StoreResult<Map<string, string>>> {
@@ -1359,12 +1594,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1359
1594
  params.push(`%${options.author.toLowerCase()}%`);
1360
1595
  }
1361
1596
 
1362
- if (options.collection) {
1363
- params.push(options.collection);
1364
- }
1365
-
1366
- const hasOuterFilters =
1367
- tagConditions.length > 0 || Boolean(options.collection);
1597
+ const hasOuterFilters = tagConditions.length > 0;
1368
1598
  const ftsLimit = hasOuterFilters ? limit * 10 : limit;
1369
1599
  params.push(limit);
1370
1600
 
@@ -1383,6 +1613,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1383
1613
  ) as score
1384
1614
  FROM documents_fts
1385
1615
  WHERE documents_fts MATCH ?
1616
+ AND rowid IN (
1617
+ SELECT id FROM documents
1618
+ WHERE active = 1
1619
+ ${options.collection ? "AND collection = ?" : ""}
1620
+ )
1386
1621
  ORDER BY score
1387
1622
  LIMIT ?
1388
1623
  )
@@ -1408,7 +1643,6 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1408
1643
  JOIN documents d ON d.id = fm.rowid AND d.active = 1
1409
1644
  WHERE 1 = 1
1410
1645
  ${tagConditions.length > 0 ? `AND ${tagConditions.join(" AND ")}` : ""}
1411
- ${options.collection ? "AND d.collection = ?" : ""}
1412
1646
  ORDER BY fm.score
1413
1647
  LIMIT ?
1414
1648
  `;
@@ -1433,7 +1667,12 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1433
1667
  categories: string | null;
1434
1668
  }
1435
1669
 
1436
- const queryParams = [builtQuery.query, ftsLimit, ...params];
1670
+ const queryParams = [
1671
+ builtQuery.query,
1672
+ ...(options.collection ? [options.collection] : []),
1673
+ ftsLimit,
1674
+ ...params,
1675
+ ];
1437
1676
  const rows = db
1438
1677
  .query<FtsRow, (string | number)[]>(sql)
1439
1678
  .all(...queryParams);
@@ -1492,12 +1731,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1492
1731
  id: number;
1493
1732
  rel_path: string;
1494
1733
  title: string | null;
1734
+ mirror_hash: string | null;
1495
1735
  markdown: string | null;
1496
1736
  }
1497
1737
 
1498
1738
  const doc = db
1499
1739
  .query<DocWithContent, [string, string]>(
1500
- `SELECT d.id, d.rel_path, d.title, c.markdown
1740
+ `SELECT d.id, d.rel_path, d.title, d.mirror_hash, c.markdown
1501
1741
  FROM documents d
1502
1742
  LEFT JOIN content c ON c.mirror_hash = d.mirror_hash
1503
1743
  WHERE d.collection = ? AND d.rel_path = ? AND d.active = 1`
@@ -1510,13 +1750,20 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1510
1750
 
1511
1751
  // Delete existing FTS entry for this doc
1512
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
+ ]);
1513
1756
 
1514
1757
  // Insert new FTS entry if we have content
1515
- if (doc.markdown) {
1758
+ if (doc.markdown !== null) {
1516
1759
  db.run(
1517
1760
  "INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)",
1518
1761
  [doc.id, doc.rel_path, doc.title ?? "", doc.markdown]
1519
1762
  );
1763
+ db.run("UPDATE documents SET fts_mirror_hash = ? WHERE id = ?", [
1764
+ doc.mirror_hash,
1765
+ doc.id,
1766
+ ]);
1520
1767
  }
1521
1768
  });
1522
1769
 
@@ -1543,18 +1790,20 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1543
1790
  const transaction = db.transaction(() => {
1544
1791
  // Clear FTS table
1545
1792
  db.run("DELETE FROM documents_fts");
1793
+ db.run("UPDATE documents SET fts_mirror_hash = NULL");
1546
1794
 
1547
1795
  // Get all active documents with content
1548
1796
  interface DocWithContent {
1549
1797
  id: number;
1550
1798
  rel_path: string;
1551
1799
  title: string | null;
1800
+ mirror_hash: string;
1552
1801
  markdown: string;
1553
1802
  }
1554
1803
 
1555
1804
  const docs = db
1556
1805
  .query<DocWithContent, []>(
1557
- `SELECT d.id, d.rel_path, d.title, c.markdown
1806
+ `SELECT d.id, d.rel_path, d.title, d.mirror_hash, c.markdown
1558
1807
  FROM documents d
1559
1808
  JOIN content c ON c.mirror_hash = d.mirror_hash
1560
1809
  WHERE d.active = 1 AND d.mirror_hash IS NOT NULL`
@@ -1565,9 +1814,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1565
1814
  const stmt = db.prepare(
1566
1815
  "INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)"
1567
1816
  );
1817
+ const markSynced = db.prepare(
1818
+ "UPDATE documents SET fts_mirror_hash = ? WHERE id = ?"
1819
+ );
1568
1820
 
1569
1821
  for (const doc of docs) {
1570
1822
  stmt.run(doc.id, doc.rel_path, doc.title ?? "", doc.markdown);
1823
+ markSynced.run(doc.mirror_hash, doc.id);
1571
1824
  count++;
1572
1825
  }
1573
1826
  });
@@ -1621,10 +1874,17 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1621
1874
  // Update FTS for each document using this hash
1622
1875
  for (const doc of docs) {
1623
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
+ ]);
1624
1880
  db.run(
1625
1881
  "INSERT INTO documents_fts (rowid, filepath, title, body) VALUES (?, ?, ?, ?)",
1626
1882
  [doc.id, doc.rel_path, doc.title ?? "", content.markdown]
1627
1883
  );
1884
+ db.run("UPDATE documents SET fts_mirror_hash = ? WHERE id = ?", [
1885
+ mirrorHash,
1886
+ doc.id,
1887
+ ]);
1628
1888
  }
1629
1889
  });
1630
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
  // ─────────────────────────────────────────────────────────────────────────────
@@ -816,6 +900,31 @@ export interface StorePort {
816
900
  */
817
901
  getContexts(): Promise<StoreResult<ContextRow[]>>;
818
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
+
819
928
  // ─────────────────────────────────────────────────────────────────────────
820
929
  // Documents
821
930
  // ─────────────────────────────────────────────────────────────────────────
@@ -928,6 +1037,15 @@ export interface StorePort {
928
1037
  */
929
1038
  getContent(mirrorHash: string): Promise<StoreResult<string | null>>;
930
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
+
931
1049
  /**
932
1050
  * Batch fetch markdown content for multiple mirror hashes.
933
1051
  * Returns a map of mirrorHash -> markdown for hashes that exist.