@harperfast/harper 5.3.0-beta.1 → 5.3.0-beta.2

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/components/DESIGN.md +421 -0
  2. package/components/mcp/DESIGN.md +109 -0
  3. package/components/mcp/audit.ts +21 -17
  4. package/config/DESIGN.md +306 -0
  5. package/dataLayer/DESIGN.md +179 -0
  6. package/dataLayer/restoreMarker.ts +92 -25
  7. package/dist/components/mcp/audit.d.ts +2 -1
  8. package/dist/components/mcp/audit.js +21 -17
  9. package/dist/components/mcp/audit.js.map +1 -1
  10. package/dist/dataLayer/restoreMarker.d.ts +21 -8
  11. package/dist/dataLayer/restoreMarker.js +94 -27
  12. package/dist/dataLayer/restoreMarker.js.map +1 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +6 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/resources/RecordEncoder.js +25 -5
  17. package/dist/resources/RecordEncoder.js.map +1 -1
  18. package/dist/resources/Table.js +48 -2
  19. package/dist/resources/Table.js.map +1 -1
  20. package/dist/resources/analytics/write.d.ts +3 -0
  21. package/dist/resources/analytics/write.js +39 -13
  22. package/dist/resources/analytics/write.js.map +1 -1
  23. package/dist/resources/crdt.d.ts +10 -0
  24. package/dist/resources/crdt.js +22 -0
  25. package/dist/resources/crdt.js.map +1 -1
  26. package/dist/resources/databases.js +1 -1
  27. package/dist/resources/databases.js.map +1 -1
  28. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
  29. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
  30. package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
  31. package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
  32. package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
  33. package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
  34. package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
  35. package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
  36. package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
  37. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
  38. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
  39. package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
  40. package/dist/resources/recordLockCoordinator.d.ts +5 -5
  41. package/dist/resources/recordLockCoordinator.js +48 -16
  42. package/dist/resources/recordLockCoordinator.js.map +1 -1
  43. package/dist/resources/transactionBroadcast.js +4 -6
  44. package/dist/resources/transactionBroadcast.js.map +1 -1
  45. package/dist/security/auth.js +59 -23
  46. package/dist/security/auth.js.map +1 -1
  47. package/dist/security/deferredAuthentication.d.ts +11 -0
  48. package/dist/security/deferredAuthentication.js +25 -3
  49. package/dist/security/deferredAuthentication.js.map +1 -1
  50. package/dist/security/jsLoader.js +3 -0
  51. package/dist/security/jsLoader.js.map +1 -1
  52. package/dist/server/REST.js +6 -3
  53. package/dist/server/REST.js.map +1 -1
  54. package/dist/server/mqtt.js +5 -1
  55. package/dist/server/mqtt.js.map +1 -1
  56. package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
  57. package/dist/server/serverHelpers/serverUtilities.js +14 -3
  58. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  59. package/dist/server/serverHelpers/uwsServer.js +4 -1
  60. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  61. package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
  62. package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
  63. package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
  64. package/dist/utility/logging/harper_logger.js +1 -0
  65. package/dist/utility/logging/harper_logger.js.map +1 -1
  66. package/index.ts +3 -0
  67. package/npm-shrinkwrap.json +104 -104
  68. package/package.json +6 -5
  69. package/resources/DESIGN.md +568 -3
  70. package/resources/RecordEncoder.ts +27 -5
  71. package/resources/Table.ts +49 -3
  72. package/resources/analytics/DESIGN.md +38 -0
  73. package/resources/analytics/write.ts +40 -14
  74. package/resources/crdt.ts +22 -0
  75. package/resources/databases.ts +1 -1
  76. package/resources/indexes/DESIGN.md +833 -0
  77. package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
  78. package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
  79. package/resources/indexes/fullTextNativeBinding.ts +146 -0
  80. package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
  81. package/resources/record-locks.md +1407 -0
  82. package/resources/recordLockCoordinator.ts +61 -22
  83. package/resources/scheduler/DESIGN.md +40 -0
  84. package/resources/transactionBroadcast.ts +4 -4
  85. package/security/DESIGN.md +175 -0
  86. package/security/auth.ts +53 -24
  87. package/security/deferredAuthentication.ts +24 -2
  88. package/security/jsLoader.ts +3 -0
  89. package/server/DESIGN.md +264 -0
  90. package/server/REST.ts +6 -3
  91. package/server/mqtt.ts +6 -4
  92. package/server/serverHelpers/serverUtilities.ts +14 -3
  93. package/server/serverHelpers/uwsServer.ts +4 -1
  94. package/server/serverHelpers/webSocketCloseReason.ts +25 -0
  95. package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
  96. package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
  97. package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
  98. package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
  99. package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
  100. package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
  101. package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
  102. package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
  103. package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
  104. package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
  105. package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
  106. package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
  107. package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
  108. package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
  109. package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
  110. package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
  111. package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
  112. package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
  113. package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
  114. package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
  115. package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
  116. package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
  117. package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
  118. package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
  119. package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
  120. package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
  121. package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
  122. package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
  123. package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
  124. package/studio/web/index.html +1 -1
  125. package/utility/DESIGN.md +55 -0
  126. package/utility/logging/harper_logger.ts +1 -0
@@ -0,0 +1,833 @@
1
+ # resources/indexes/ — Design notes
2
+
3
+ The derived-index runtime and the HNSW vector index (JS graph and native plane).
4
+
5
+ **Read this when:** touching `derivedIndexRuntime.ts`, `HierarchicalNavigableSmallWorld.ts`, `hnswDerivedIndex.ts`, `hnswPlaneBinding.ts`, or vector query planning.
6
+
7
+ Index of every design note: [DESIGN.md](../../DESIGN.md).
8
+
9
+ ---
10
+
11
+ ## Graph size on the HNSW query path must come from node ids (`resources/indexes/HierarchicalNavigableSmallWorld.ts`)
12
+
13
+ The ef auto-scale needs to know how big the graph is, on every query. Two sources that look right
14
+ are not.
15
+
16
+ `getKeysCount()` on a RocksDB store is an exact key scan, so it is O(N): measured at 13 ms per call
17
+ at 10K keys, 128 ms at 100K, ~1 s at 500K. Calling it per query puts a linear-in-corpus-size term in
18
+ front of every vector search — 34% of query latency at 20K vectors on the real table stack.
19
+
20
+ RocksDB's `rocksdb.estimate-num-keys` property is O(1) and looks like the obvious replacement, but it
21
+ counts entries across memtable and SST files without reconciling overwrites. Building an HNSW graph
22
+ rewrites each node many times as its neighbours change, so on a real index it reads far high: 37,775
23
+ for a 2,000-record table whose exact key count is 4,001, and worse after deletes. It reads exact on a
24
+ fresh store with simple puts, so it validates clean in isolation and only misleads on a real index.
25
+
26
+ Node ids are the sound source. They are allocated monotonically from a `getUserSharedBuffer` counter,
27
+ so the counter (or one reverse seek to the largest id) gives the node count in O(1), unaffected by
28
+ how many times a node has been rewritten. Deletes leave it reading high until a rebuild, which only
29
+ makes ef slightly generous. A file-primary (`nativePlane`) index keeps no node-id keys in its store
30
+ at all — the plane slot carries the primary key — so its count is the plane's id high-water, which
31
+ reads the same way (allocation high-water, generous after deletes until a rebuild).
32
+
33
+ Note the unit: the JS index store holds two keys per record — the graph node and the primary-key
34
+ mapping — so a key count is twice the node count. `AUTO_EF_REF` is expressed in nodes for that
35
+ reason, and any change between the two units has to move it to keep the resolved ef the same.
36
+
37
+ ## HNSW layers above 0 are for routing only, and must be searched greedily
38
+
39
+ Each layer above 0 exists to hand the next layer down an entry point: `search()` and `index()` both
40
+ take `results[0]` and discard the rest. Searching them at the full `ef` therefore buys nothing and
41
+ costs work proportional to the layer's population rather than to ef — layer 1 holds ~N/M nodes, and
42
+ at ef 512 a query visited ~95% of it. That is a second linear-in-N term: upper-layer visits per query
43
+ grew 342 → 2,421 across 5K → 41K vectors on real embeddings, and reached 75% of query time at 100K. Greedy descent
44
+ (`ROUTING_EF`) is what standard HNSW does. Measured against the same graphs searched at the full `ef`
45
+ on every layer, across 16 (size, `ef`) points on a held-out real-embedding corpus, the worst
46
+ recall@10 change was -0.002 — one displaced neighbour at a single point — and 0.000 everywhere else.
47
+
48
+ The connection-building pass in `index()` is not routing — it selects the edges that get stored — so
49
+ it keeps `efConstruction`.
50
+
51
+ The insert-side change is the one that alters stored graphs, recoverable only by a reindex, so it was
52
+ measured separately (`benchmarks/hnsw-scale.js --build-upper-ef=100` restores the previous
53
+ index-time descent). At 20,000 real 768-dim embeddings with identical corpus and level assignments,
54
+ the two builds were indistinguishable on every metric measured — same recall at each `ef`, same visit
55
+ counts, same mean layer-0 degree — and the greedy build was 1.28x faster. That is consistent with the
56
+ graphs being identical, though equal metrics do not prove it. It is the expected result either way:
57
+ the upper layers are sparse enough that a greedy walk reaches the same entry point, which is why
58
+ standard HNSW descends this way.
59
+
60
+ Greedy-equals-full is statistical, not per-graph: rare level layouts route to a different layer-0
61
+ entry point and displace the tail of the top-k (~2-3% of random 600-node graphs in the unit test's
62
+ corpus). Tests that assert exact result-set equality across search strategies must therefore pin the
63
+ graph: level assignment draws from the instance's `random` property (a test seam defaulting to
64
+ `Math.random`), which the routing test replaces with a seeded PRNG. One pinned graph samples the
65
+ property once, so that test sweeps a fixed list of seeds, each verified non-divergent when the list
66
+ was written — a seed that starts diverging after an intentional index change is a re-pin, not
67
+ necessarily a routing regression.
68
+
69
+ ## `efConstruction` and the search-`ef` ceiling both auto-scale with the graph
70
+
71
+ The connection-building pass selects each node's stored edges from a candidate list of
72
+ `efConstruction` entries. Held at a constant (100) while the corpus grows, edge quality erodes in a
73
+ way no search-side setting can compensate: at 1M nodes (768-dim, int8, calibrated hard corpus)
74
+ recall@10 fell to 0.935 and sweeping the search `ef` from 512 to 1536 only reached 0.957 raw / 0.967
75
+ set at 4.7x the latency — the missing neighbours were not deep in the candidate list, they were
76
+ unreachable. Rebuilding the identical corpus (same seed, same level assignments) with
77
+ `efConstruction` 200 restored 0.985/0.997 and made queries _faster_ at the same `ef` (3,110 nodes
78
+ visited vs 3,948 — better-selected edges route more directly). Quantization contributed ~1.5 points
79
+ (float32 rebuild: 0.952); construction quality was the dominant term. Full sweep in #2180.
80
+
81
+ So when the schema does not configure `efConstruction`, it scales as `AUTO_EF_BASE * sqrt(nodes /
82
+ AUTO_EFC_REF)`, capped at `AUTO_EFC_MAX`. The healthy write path reads the count directly from the
83
+ shared id counter: one atomic load with no memo lag during bulk ingest. If an update-only worker
84
+ cannot attach that counter, it warns once, falls back to the memoized reverse seek, and retries the
85
+ attach after the memo TTL; a new insert still requires the shared counter rather than risking ids
86
+ from a private counter. Scaling starts at 250K nodes: efC 100 held recall through 500K (0.978), so
87
+ smaller graphs — the common case — build exactly as before. The sqrt shape mirrors the search-side
88
+ scale; the cost is build time (1.77x at 1M for efC 200), paid only by tables that actually grow
89
+ large, and partly returned as cheaper queries.
90
+
91
+ An explicit `efConstruction` stays authoritative and is structural, so changing it triggers a full
92
+ index rebuild. It also seeds the search `ef`: setting `efConstruction: 100` alone cuts query effort
93
+ to 100. Retaining the former large-graph search default while opting out of build scaling requires
94
+ an explicit `efConstructionSearch` as well (512 after the former auto-scale reached its plateau).
95
+ There is currently no "pinned build, auto search" combination.
96
+
97
+ The search side scales past its old plateau for the same reason. `AUTO_EF_MAX` (512, pinned from
98
+ ~13K nodes) was calibrated when layers above 0 were searched at the full `ef`, which made large efs
99
+ cost seconds; after the greedy-descent fix the same headroom costs tens of milliseconds (ef 1024 at
100
+ 5M nodes: ~45ms p50), and holding the pin leaves measured recall on the table — set-recall at a
101
+ pinned 512 on well-built graphs decays 0.997 → 0.955 → 0.935 across 1M/2M/5M. So past
102
+ `AUTO_EF_LARGE_REF` (1M nodes, where 512 was last measured sufficient) the scale resumes from the
103
+ plateau — `512 * sqrt(nodes / 1M)` — up to `AUTO_EF_CEILING` (2048, binding at ~16M). The 5M point
104
+ resolves 1,145, bracketed by the measured ef-1024 sweep there (0.985 set). The default's query
105
+ latency therefore grows as sqrt(N) on large tables; that is the recall-first trade chosen here, and
106
+ apps preferring latency pin `efConstructionSearch` or a per-query `ef`. The filtered-traversal
107
+ budget (`maxVisits`, #1241) deliberately does not follow the second regime: each budgeted visit is
108
+ a synchronous record load plus predicate evaluation, so an auto-scaled ef's budget contribution
109
+ stays capped at `AUTO_EF_MAX` — the recall decision and the filtered-scan bound are separate
110
+ decisions, and an explicit ef (per-query or schema) still raises the budget for callers who own
111
+ the cost. Both ceilings are finite on
112
+ purpose: total build work grows as N^1.5 under sqrt scaling, and past roughly tens of millions of
113
+ nodes per graph, sharded medium graphs beat one huge graph on build and query cost alike — scaling
114
+ the constants further is the wrong tool there.
115
+
116
+ Two caveats are accepted deliberately, both inherited from the count being a lifetime high-water
117
+ mark of allocated node ids rather than a live count. First, churn: a table that deletes heavily
118
+ (TTL eviction, delete-and-reinsert ingest) reads high forever, so its build-side efC can sit at the
119
+ cap while the live graph is small. The 6–7x build-time extrapolation applies to a comparably large
120
+ graph; it is not a bound for a small rolling window. When efC exceeds the live graph size, the
121
+ candidate list cannot fill and an insert can traverse a large fraction of the graph before storing
122
+ only `M << 1` edges. This wastes throughput without improving recall. The search side accepted the
123
+ same over-count as "slightly generous ef" on an opt-in read path; the write path inherits it as a
124
+ known cost until a live count exists (tracked follow-up). Second, ramp history: nodes indexed before
125
+ the graph crossed a scale threshold keep their original edges — the scale applies to inserts from
126
+ that point on. A reindex in a live process rebuilds roughly uniformly (the id counter keeps its
127
+ high-water mark), but a reindex after a restart re-seeds the counter from the largest id in the
128
+ rebuilding store and therefore repeats the ramp — its first 250K nodes rebuild at the base efC.
129
+ Later inserts add reverse edges to older nodes, but a default-ramp 1M build has not been compared
130
+ directly with the uniform-200 A/B. The larger default-ramp runs reached 0.988 set-recall at 2M and
131
+ 0.985 at 5M when searched at ef 1024, which shows that the measured neighbours remained reachable
132
+ at those sizes without proving uniform convergence.
133
+
134
+ Deletes have a separate tail-latency cost: connectivity repair can synchronously reinsert an orphan
135
+ and up to 256 nodes from a severed island. Those reinserts use the current auto-scaled efC, so the
136
+ per-insert build multiplier can land hundreds of times within one delete.
137
+
138
+ ## An approximate index returns at most `ef` rows, so `limit` has to reach it
139
+
140
+ Layer 0 keeps at most `ef` candidates, and ef resolves from the auto-scale, not from the query. A
141
+ `limit` above it came back short with no error: with the 512 cap no vector query could return more
142
+ than 512 rows however large the limit, and `{offset: 250, limit: 200}` returned zero rows, so
143
+ paginating a vector search past the first page returned nothing. `searchByIndex` threads the query's
144
+ `offset + limit` to the custom index as `minResults`, which widens the candidate list to cover the
145
+ request. Any future approximate index needs the same plumbing.
146
+
147
+ Two bounds keep that from becoming a new problem. `ef` drives a synchronous traversal that holds
148
+ every admitted candidate in a sorted array with an O(len) insert, so a limit-derived `ef` is capped
149
+ at `LIMIT_EF_MAX`; without it, ordinary deep pagination (`offset` in the millions) would walk the
150
+ whole graph on the event loop, which is worse than the truncation being fixed. And schema-level or
151
+ per-query `ef` values stay authoritative: each is an explicit cost ceiling, so it bounds the result
152
+ set rather than being raised by the limit. Only automatically scaled indexes widen toward
153
+ `LIMIT_EF_MAX` to satisfy a larger bounded request.
154
+
155
+ `LIMIT_EF_MAX` is the _only_ bound on the widening — deliberately not also the graph size. Clamping
156
+ there is tempting and costs more than it saves: the memoized size reads low while a table grows, so
157
+ it truncates the limit it was supposed to honour, and resolving a size exact enough to clamp against
158
+ puts a store lookup back on every query whose `limit` exceeds the table — the linear-in-N term this
159
+ whole change removed, reintroduced in miniature. An `ef` above the node count is free anyway: the
160
+ traversal is bounded by the nodes it can reach, so it ends at the graph, not at `ef`.
161
+
162
+ The filter budget deliberately does not follow a limit-derived `ef`. It is computed from the `ef`
163
+ the index resolved for itself, with an automatically scaled `ef` capped at `AUTO_EF_MAX` before it is
164
+ multiplied by `filterExpansion`; explicit schema or per-query `ef` values remain authoritative.
165
+ Multiplying the budget by a caller's `limit` would turn a filtered vector query into a record-loading
166
+ scan wearing an index's clothes.
167
+
168
+ Paging a vector search is best-effort, not a stable partition. Each page re-runs the approximate
169
+ search at a different `ef` (`offset 0, limit 250` resolves 250; `offset 250, limit 200` resolves 450),
170
+ and an HNSW candidate set at a larger `ef` is not guaranteed to be an ordered superset of the smaller
171
+ one, so a record can repeat across pages or be skipped. Honoring `limit` fixes the "second page is
172
+ empty" defect; it does not make offsets a cursor. Callers who need stability should fetch one page
173
+ large enough for the whole result set, or pin an explicit `ef`.
174
+
175
+ One consumer is still calibrated in index-store keys rather than nodes: `estimateCountAsSort`, the
176
+ planner's cost estimate for a vector sort. It is scaled by `INDEX_KEYS_PER_NODE` so the count-source
177
+ unit switch does not shift the estimate on its own. The ef term remains the configured search value,
178
+ not the runtime auto-scaled value, so the planner increasingly underestimates vector traversal cost
179
+ as an automatically scaled graph grows.
180
+
181
+ ## Derived-index runtime: committed-log delivery to native index backends (`resources/derivedIndexRuntime.ts`)
182
+
183
+ A derived index (the native HNSW plane, a future Tantivy full-text index) is a materialized view
184
+ that lives outside the record transaction: its apply is native, costs 0.2–1.4 ms per mutation, and
185
+ its durability barrier is an `msync` or a segment publish, none of which belong on the commit path.
186
+ The runtime is the one Harper-side implementation of the delivery protocol in harper#2489: **the
187
+ transaction log is the durable fact, a commit only wakes a runner, and every backend resumes from an
188
+ exact cursor into that log.** There is deliberately no second delivery fact — no transactional
189
+ dirty-key outbox, no `aftercommit` staging — because a second durable write per indexed mutation, a
190
+ cleanup protocol for it, and a new column family for every audited table would still not let an
191
+ engine-specific native file commit atomically with RocksDB, so the cursor protocol would be needed
192
+ anyway.
193
+
194
+ ### Invariants
195
+
196
+ 1. Only committed entries are delivered; the runtime never reads uncommitted log entries.
197
+ 2. One worker runs a given backend at a time (a process-wide `tryLock` per backend). The owner
198
+ merges every physical log into one serial stream and keeps a cursor per log.
199
+ 3. A cursor is `{ format: 1, logs: { <log name>: <completed transaction timestamp> } }`. It advances
200
+ only at `endTxn` and only after the backend's durability barrier covers the whole transaction.
201
+ 4. Live delivery and restart use the same cursor validation, iterator and dispatch code. A lost wake
202
+ or a full backend queue delays indexing; it cannot skip durable log work.
203
+ 5. Resume proves every saved log position with `exactStart`. A missing, incomplete or duplicate
204
+ boundary condemns the generation; approximate resume is not permitted.
205
+ 6. The runtime resolves the current primary entry once per changed record and projects only the
206
+ registered attributes. Backends never see the log entry's body: a conflict retry can re-resolve a
207
+ record after its log payload was staged, so the entry is identity and version evidence, not state.
208
+ 7. No exception from log iteration, primary reads, projection, backend calls or unlock callbacks
209
+ escapes the scheduled drain or interferes with subscriptions and replication.
210
+ 8. Registration adds nothing to the commit path except a local-only eviction marker for registered
211
+ caching tables (below). No `aftercommit` listener, no retained `AuditRecord`s, no awaited work.
212
+
213
+ ### Ownership and wake-up
214
+
215
+ Each worker holds one `DerivedIndexRuntime` per RocksDB root store, with the same schema-derived
216
+ registrations on every worker; the drain is lock-elected, registration is not. The runtime listens
217
+ to the root store's `committed` event and coalesces wakes through `setImmediate`. The winner keeps a
218
+ reusable aggregate iterator (`RocksTransactionLogStore.getRange` with `startByLog`, `exactStart`,
219
+ `resumeAfterExactStart`, `includeLogName`) and drains for bounded count, bytes and wall time per
220
+ turn. Ownership is sticky: the lock is not one record writers take, so holding it across turns
221
+ delays no commit, and it is released only after an idle grace period once durable progress equals
222
+ offered progress. The intended wake for a waiting runner is the lock's own unlock callback
223
+ (`tryLock(key, onUnlocked)`): one primitive, and a holder that dies releases natively and wakes the
224
+ waiters the same way. **Temporarily** the lock is taken without one: the pinned rocksdb-js queues
225
+ that callback as a thread-safe function of the caller's env, and on Node 22 one left behind by a
226
+ worker that was `terminate()`d aborts the process when another thread unlocks (Harper's thread
227
+ manager terminates workers on restart). Until the pin includes the fix (HarperFast/rocksdb-js#849),
228
+ successors are woken by the releasing owner's `notify()` on the readiness buffer and, for an owner
229
+ that died without releasing, by a retry timer (`lockRetryMilliseconds`, 5 s by default); the
230
+ releasing runner ignores the one notification it caused, and a runner already parked on that timer
231
+ does not re-probe the lock on commit wakes. This is a workaround with a tracked revert — the callback
232
+ path is simpler and picks up a dead owner immediately.
233
+
234
+ Configuration-level database aliases can load several table classes over the same physical audit
235
+ store, index column family and plane file. They must not run that backend more than once. Registration
236
+ therefore hands the backend to the newest class generation: it synchronously retires the predecessor,
237
+ installs the new class's index handle, projection, table id and lag policy, and lets the native lock keep
238
+ the successor idle until the predecessor releases ownership. A normal class cleanup only retires its own
239
+ generation. `dropTable()` is the destructive exception: it fences the immutable table id, retires every
240
+ registered backend for that generation (including settlement chains owned by another alias), and checks
241
+ that the catalog still names that table id before deleting name-keyed storage. A same-name recreation can
242
+ therefore register its new id without being retired by a stale alias.
243
+
244
+ Transaction timestamps are unique per physical log but **not monotone in physical order**
245
+ (`TransactionLogStore::writeBatch` only advances `latestTimestamp` when the batch's is greater), so
246
+ the runtime never compares timestamps to decide progress or retention. Resume is exact-start: find
247
+ the transaction, iterate after it. Repeat detection is a per-log set of completed timestamps
248
+ retained since the durable cursor. A log the cursor does not name is read from its beginning only
249
+ if it still retains it — `fileCount === 0 || oldestSequenceNumber === 1`; rocksdb-js reports
250
+ `oldestSequenceNumber: 0` for a log that has never written a file — otherwise the generation is
251
+ condemned.
252
+
253
+ ### Offered versus durable progress
254
+
255
+ A native backend accepts several batches before its next barrier, so the owner tracks **offered**
256
+ progress (cursor vectors of accepted batches, in memory, under the lock) separately from the
257
+ backend's **durable** cursor. Every acquisition mints an owner epoch from a process-wide atomic
258
+ counter and stamps it on each batch; a new epoch — including after a worker restart — resets offered
259
+ progress to the durable cursor before opening its iterator, because replaying work that survived
260
+ in a native queue is safe and trusting a dead worker's non-durable position is not. A reported
261
+ durable cursor must equal one offered vector exactly; validating logs independently would let a
262
+ backend assemble a cursor from different batch boundaries and hide work. Accepted-not-durable
263
+ progress is capped (`maxAcceptedBatchesAhead`, 64); at the cap the owner keeps the lock, stops
264
+ reading (`waiting-durable`) and resumes on a backend state-change wake, not on commit wakes.
265
+
266
+ ### Authoritative record resolution and the eviction marker
267
+
268
+ The log decides what must be revisited; the primary store decides what is in the index now. A
269
+ present entry yields its current version and projection; a missing, deleted, evicted or
270
+ invalidated entry yields `absent`, which is sound only because every removal now has a durable
271
+ fact: `delete`, `invalidate`, `relocate`, or the local-only `evict` marker that `Table.evict()` and
272
+ `createEvictionBatcher().stageInto()` stage into the same RocksDB transaction as the row removal for
273
+ tables with a registered derived index (`hasDerivedIndexRegistration`). The marker is `LOCAL_ONLY`,
274
+ a no-op in boot replay, filtered from customer history and subscriptions, and rejected by
275
+ replication. `message`, `publish` and structure entries advance progress without becoming
276
+ documents; a `reload` marker (replica base copy) condemns the generation because its rows have no
277
+ per-record entries. A projection that throws a 4xx `ClientError` yields
278
+ `{ kind: 'unindexable', reason: '<class> (<status>)' }` — the backend removes any entry and counts
279
+ it; the message never reaches shared memory or the backend because validation messages quote
280
+ record values. Any other projection or primary-read failure is fail-closed.
281
+
282
+ ### Backend contract
283
+
284
+ ```ts
285
+ interface DerivedIndexBackend {
286
+ readonly id: string;
287
+ attach(host: { isOwnerEpoch(epoch: bigint): boolean; getReadiness(): DerivedIndexReadiness }): void;
288
+ getDurableCursor(): DerivedIndexCursor | undefined;
289
+ deliver(batch: DerivedIndexBatch): DERIVED_INDEX_ACCEPTED | DERIVED_INDEX_DEFERRED | DERIVED_INDEX_FAILED;
290
+ flush(reason: 'age' | 'threshold' | 'shutdown'): void | Promise<void>; // barrier request
291
+ shutdown(ownerEpoch: bigint): void | Promise<void>; // quiescence: nothing further applies or publishes for the epoch
292
+ onStateChange(wake: (change?: 'changed' | 'accepted-work-lost' | 'failed') => void): () => void;
293
+ reset?(ownerEpoch: bigint): void | Promise<void>; // destroy state and cursor; first durable action invalidates the cursor
294
+ }
295
+ type DerivedIndexBatch = {
296
+ ownerEpoch: bigint;
297
+ transactions: { logName; timestamp; mutations }[];
298
+ records: DerivedIndexMutation[]; // last-write-wins per (tableId, writeKeyId(recordId)); non-enumerable
299
+ through?: DerivedIndexCursor; // absent on a rebuild scan chunk and while a transaction is still open
300
+ bytes: number; // estimate; non-enumerable
301
+ rebuild?: true;
302
+ };
303
+ ```
304
+
305
+ There is one contract and every hook is required. What an ownership handoff has to fence is work
306
+ that survives a method return — a queued apply, a barrier that completes later, a cursor that
307
+ trails delivery — so every backend supplies the epoch fence, the barrier request and the quiescence
308
+ handshake; one that completes inside `deliver()` implements them trivially. `deliver()` is not
309
+ bounded by the runner's turn budget (the runtime cannot bound work it does not perform): the
310
+ expected shape is enqueue, return `accepted`, apply in the backend's own time slices, advance the
311
+ durable cursor at its barrier, return `deferred` when its queue is full. `accepted` means the backend
312
+ owns the batch, not that the cursor may advance. `deferred` holds the batch until a state-change
313
+ wake. `accepted-work-lost` makes the owner rebuild its iterator from the durable cursor; `failed` or
314
+ `DERIVED_INDEX_FAILED` condemns the generation. `reset` is optional — without it `needs-rebuild` is
315
+ terminal — and a backend that implements it owns its crash safety: it must invalidate the cursor
316
+ before anything destructive, because shared readiness is process memory and is no evidence after a
317
+ restart. The runtime also writes a condemnation marker to the root store
318
+ (`Symbol.for('derived-index:<id>:condemned')`) before any reset and clears it at the first durable
319
+ `ready`, so a restart between condemning a cursor and destroying it still rebuilds; a marker that
320
+ cannot be written issues no reset. The cursor's atomic durability mechanism is the backend's
321
+ (Tantivy publishes it with segment state; HNSW writes it after the plane barrier), which is why the
322
+ cursor is backend-owned and validation is Harper's.
323
+
324
+ ### Native full-text backend
325
+
326
+ `resources/indexes/fullTextDerivedIndex.ts` adapts the shared runtime to
327
+ `@harperfast/fulltext/native`; it does not implement another replay or ownership protocol. Harper
328
+ turns each resolved mutation into one stable document id from `tableId` and the record id's
329
+ ordered-binary storage-key bytes, and
330
+ passes only the schema-selected string and array fields to the wrapper. Harper does not rescan array
331
+ contents; the wrapper owns value validation, Tantivy schema, exact frame partitioning, its exclusive writer, segment publication, and file
332
+ lifecycle. Harper keeps accepted runtime batches in a 64 MiB bounded queue and submits at most 256
333
+ records or 5 ms of conversion work per turn, so the runtime's 4096-record chunk cannot become one
334
+ long event-loop task. A rebuild chunk without a source-size estimate consumes the adapter's entire
335
+ queue-byte allowance, ensuring that only one unknown-size chunk is retained at a time. Wrapper
336
+ rejections remove the previous document and count it as unindexable; they do not leave stale search
337
+ content. A projector returning null or no string-valued fields deletes the prior document rather
338
+ than indexing an empty replacement.
339
+
340
+ Every runtime flush is a native publication barrier. Full-text activation chooses and benchmarks the
341
+ runtime flush thresholds; the adapter does not reinterpret a flush because the runtime uses durable
342
+ cursor progress to bound replay work and transaction-log retention.
343
+
344
+ The native commit payload contains Harper's exact derived-index cursor. A publish makes the Tantivy
345
+ mutations and that payload visible together; only then does the adapter report durable progress.
346
+ An ordinary apply or publish failure rollback-closes the writer, discards accepted-but-unpublished
347
+ work, and wakes the runtime to replay from the last native payload after exponential backoff to a
348
+ five-second ceiling. It does not condemn a structurally valid generation. During a rebuild, the same
349
+ accepted-work-lost signal aborts that rebuild attempt; the runtime retires the partial generation and
350
+ spends one of its bounded rebuild attempts before rescanning. A mutation-batch protocol
351
+ violation remains permanent because retry cannot change the wrapper contract. Writer open is lazy.
352
+ After a small immediate attempt budget, open errors use the same retry ceiling unless their stable
353
+ code proves the native generation is structurally incompatible or corrupt. Configuration, binding,
354
+ process-state, and unknown codes retry by default: they may require operator action or restart, but do
355
+ not prove the index files should be reset. Persistent writer unavailability emits one warning without native paths
356
+ or record content. Ownership handoff does not finish until drain and close prove quiescence. A
357
+ writer-open failure that lands after shutdown begins discards queued work before finalization so it
358
+ cannot enter another retry drain. Harper
359
+ gives native close its own 35-second bound and bounds the complete handoff at 70 seconds. If that proof fails, shutdown rejects and the runtime keeps its
360
+ runner lock, preventing a second writer. The underlying native operation continues and a later operator
361
+ retry attaches to the same shutdown rather than starting a competing close. A cursor that cannot fit
362
+ the native commit-payload limit is terminal for that backend instance: accepted work is rollback-closed
363
+ and reported lost, then further delivery stays deferred. The adapter does not report a permanent
364
+ backend failure because condemnation and reset cannot shrink the cursor. Automatic reset is refused
365
+ until configuration changes replace the backend instance. This terminal park keeps readiness
366
+ non-terminal; when the derived-index lag policy is enabled, source writes remain rejected after the
367
+ lag limit is crossed until the deployment is changed so the cursor fits and the backend is replaced,
368
+ or the index is disabled. Inspection accepts any payload within Harper's fixed 64 KiB format bound,
369
+ independent of the current publication limit, so lowering that limit never turns an already-valid
370
+ native generation into rebuild work.
371
+
372
+ Inspection is synchronous and writer-free. The first durable-cursor read in each ownership acquisition
373
+ refreshes native state, while later reads in the same acquisition use the cache; every shutdown path
374
+ invalidates it, including an owner that received no batch. A refresh failure never falls back to a
375
+ cached checkpoint because the runtime can publish `ready` before lazy writer-open reconciliation.
376
+ The synchronous backend contract has no retryable acquisition result, so an inspection exception
377
+ deliberately fails closed: the runtime condemns the generation and rebuilds from authoritative
378
+ records rather than trusting an unverified cursor. A transient filesystem error can therefore cost
379
+ a full rescan; reintroducing asynchronous acquisition solely to avoid that trade is out of scope.
380
+ Missing, cursorless, incompatible, or malformed native state has no usable cursor and therefore enters
381
+ the runtime's ordinary local rebuild from records.
382
+ Reset first asks the wrapper to retire the live generation atomically, then reclaims only wrapper-
383
+ validated retired paths. The reset operation is tracked and bounded; shutdown attaches to the same
384
+ operation and keeps the runner lock if it cannot prove settlement. Retired-path reclamation is
385
+ best-effort, serialized per lifecycle, and never extends that reset handoff. The native directory is node-local derived state: restarts reuse it and
386
+ replay after its payload; replicas independently derive it from their own applied transaction log;
387
+ backup and restore need only authoritative records and schema. A new or unusable directory serves no
388
+ full-text queries until rebuild and catch-up publish `ready`.
389
+
390
+ Tantivy files are not an opaque encrypted cache. They contain the document-id term dictionary,
391
+ analyzed term dictionaries, postings, frequencies and optionally positions; `surfaceTerms: true`
392
+ also stores the original projected strings needed for surface-term features. Operators must protect
393
+ the full-text directory with the same filesystem controls as Harper data. Removing source records
394
+ does not erase old segment bytes immediately; normal Tantivy merge/reclamation governs physical
395
+ removal, and destroying an index uses the wrapper's retirement protocol.
396
+
397
+ The binding remains unloaded until a full-text declaration is activated. Before activation can
398
+ construct this backend, Harper must exact-pin the Fulltext package, document the dependency in
399
+ `dependencies.md`, and prove that its native prebuild loads on Linux, macOS, and Windows CI. A
400
+ missing or incompatible binding is an activation error; Harper must not silently omit the declared
401
+ index.
402
+
403
+ ### Bounded delivery
404
+
405
+ A drain turn **collects** identities from the iterator — `(tableId, recordId, logVersion)` per
406
+ eligible entry, no primary read — under `maxTransactionsPerTurn`, `maxBytesPerTurn`,
407
+ `maxMillisecondsPerTurn` and `maxChunkRecords` distinct keys, then **resolves** each key once after
408
+ its last collected occurrence, under `maxChunkBytes` and the same wall budget, carrying the
409
+ remainder to the next turn. Resolving after the last occurrence, not on first encounter, is what
410
+ keeps a writer committing between two occurrences of a key from having its later state certified by
411
+ the cursor while the earlier state stayed indexed. An oversized transaction is delivered across
412
+ chunks with `through` withheld until the chunk that contains its `endTxn`; nothing marks such a
413
+ chunk because a backend can do nothing with the distinction, and query-visible atomicity of one
414
+ transaction across chunks is not promised. `bytes` is an estimate from stored record sizes (or the
415
+ log entry size), never a serialization; `maxChunkRecords` is the hard bound. All bounds are settable
416
+ per registration.
417
+
418
+ ### Durability cadence
419
+
420
+ `maxAcceptedBatchesAhead` is a ceiling; the runtime, which already tracks accepted-not-durable work,
421
+ is the scheduler. It requests `flush('age')` when the first accepted batch since the last request is
422
+ `maxFlushAgeMilliseconds` old (1 s), `flush('threshold')` at `flushAfterMutations` (4096) or
423
+ `flushAfterBytes` (8 MiB), and `flush('shutdown')` at release. The backend runs the barrier
424
+ asynchronously, coalesces requests that arrive mid-barrier, and publishes `through` atomically with
425
+ what the barrier made durable. Reaching the end of the log requests no extra barrier — arrivals
426
+ spaced just beyond drain completion would otherwise pay one per write; the age timer is idle
427
+ completion.
428
+
429
+ ### Rebuild
430
+
431
+ When the backend has `reset` and the runtime was built with `scanRecords`, `needs-rebuild` is a
432
+ phase, not an end state: publish `rebuilding`; `shutdown(previousEpoch)`; mint a new epoch and
433
+ republish; `reset(newEpoch)` (afterwards `getDurableCursor()` must be `undefined`); capture the
434
+ **committed tail** of every log; scan every registered table (tombstones and symbol keys skipped),
435
+ project, deliver bounded chunks with `through` absent; deliver a final chunk with `through` = tail;
436
+ install the tail as offered progress and replay through the ordinary drain; publish `ready` at the
437
+ first durable advance past the tail or the first idle pass with durable == offered.
438
+
439
+ The tail is safe because a committed read is a contiguous physical prefix: rocksdb-js keeps the
440
+ physically-written-but-uncommitted start offsets in a sorted set and `commitFinished()` advances
441
+ `lastCommittedPosition` to the earliest of them (`TransactionLogStore::commitFinished`,
442
+ `uncommittedTransactionPositions.front()`), so a transaction that wrote at offset 200 and committed
443
+ before one pending at 100 stays invisible until 100 commits. Everything committed before the
444
+ capture is in the scan; everything after it is replayed; a reload marker is therefore met exactly
445
+ once, with no capture-time bookkeeping. A log that cannot be read to its tail fails the attempt
446
+ closed — corruption inside the committed prefix cannot be replayed from any anchor. Attempts retry
447
+ with backoff (`rebuildBackoffMilliseconds` 1 s doubling to 5 min) up to `maxRebuildAttempts` (8),
448
+ then publish `unavailable` and release; the attempt count travels in shared memory so a peer
449
+ honours an exhausted budget. `requestRebuild()` from any worker sets a request word the owner
450
+ consumes at its next wake.
451
+
452
+ ### Native HNSW query coverage
453
+
454
+ Generation readiness and read freshness are separate. A `ready` native index can still be applying
455
+ committed mutations. Native vector sort/threshold conditions accept `maxIndexLagMilliseconds`:
456
+ **3000 ms by default**, `0` for strict coverage, or an explicit finite nonnegative number. This default
457
+ allows three ordinary 1000 ms flush ages; it does not change the separate writer backpressure budget.
458
+ Synchronous/non-native indexes ignore the native coverage options. For example, an HTTP QUERY body can contain:
459
+
460
+ ```json
461
+ {
462
+ "sort": {
463
+ "attribute": "vector",
464
+ "target": [1, 0, 0, 0],
465
+ "distance": "cosine",
466
+ "maxIndexLagMilliseconds": 0
467
+ },
468
+ "limit": 10
469
+ }
470
+ ```
471
+
472
+ For read-after-write, add `waitForIndexMilliseconds: 10000` to that sort (or vector condition).
473
+ Omitted or `0` preserves immediate admission; a positive finite number, capped at **30,000 ms**,
474
+ opts into a bounded wait for coverage of writes committed before the native search begins on first iteration.
475
+ The wait takes precedence over lag tolerance: a recent but stale proof cannot satisfy it. An already
476
+ physically current index proceeds immediately. Otherwise the query captures one monotonic target and
477
+ waits for the owner's certified time to reach it; later writes never reset the target. Each consumed
478
+ waiting branch has its own budget; sequential OR or concatenated branches can take longer in total.
479
+ A timeout throws retryable `DERIVED_INDEX_LAGGING`. On an already-started HTTP stream this is an error
480
+ record, not a new HTTP status. Request cancellation and iterator closure also end pending waits.
481
+
482
+ Waiting preserves the normal 1000 ms flush-age schedule: a small write followed by a search commonly
483
+ waits about a second plus barrier time. Shorter deadlines are valid but may expire. Replay must reach
484
+ an end-of-log pass with readable committed prefixes to publish a new capture; sustained overload or
485
+ unfinished transactions can prevent certification and cause timeouts even for unaffected tables.
486
+ The wait does not promise exact ANN recall, a historical graph snapshot, or visibility of writes that
487
+ have not committed locally.
488
+
489
+ When a vector condition also provides the sort order, each explicit coverage option takes precedence;
490
+ missing options inherit the sort's values. The query planner preserves both when combining them.
491
+
492
+ An ordinary non-waiting native query certifies coverage **at admission**. `Harper-Index-Coverage` is
493
+ `current; lag=0; tolerance=<requested-ms>` or `bounded; lag=<upper-bound-ms>; tolerance=<requested-ms>`.
494
+ Bounded coverage can omit recent committed mutations; it is not a claim of known incompleteness or an
495
+ ANN recall guarantee. The header is exposed to CORS clients and does not certify a later traversal.
496
+ Multiple non-waiting searches append one entry each.
497
+
498
+ Waiting queries retain the synchronous instance iterable API: custom resources can directly use
499
+ `super.search(query).map(...)` or concatenate results. Validation and unavailable/rebuilding checks
500
+ remain synchronous; the adapter opens a start gate only on consumption, so unused searches start no
501
+ wait or traversal. A zero-size page skips native work. The iterable library can pull one extra source
502
+ row at a page boundary; an exact boundary between branches can therefore start the next branch.
503
+
504
+ Waiting queries publish no HTTP coverage header: a header sent before consumption cannot certify the
505
+ pending work. The caller must consume results and check streamed errors. JSON array streams may include
506
+ an error element such as `{ "error": "DerivedIndexLagError: ..." }`; error serialization may instead
507
+ provide a separate `message` field. SSE/NDJSON use their existing terminal error records. HTTP 200 alone
508
+ is not evidence of successful completion. Prefix rows can precede a later branch error. Count pages
509
+ materialize before headers and can still return an error status. First-item HTTP status deferral is a
510
+ separate follow-up (#2670), not a guarantee here. Direct custom-index arrays retain `indexCoverage`;
511
+ ordinary native promises expose it before awaiting, while waiting promises expose it on resolved arrays
512
+ except for the zero-size fast path, which skips certification and carries no proof.
513
+
514
+ Waiting consumes the ordinary transaction timeout without special monitor renewal. An expired read
515
+ snapshot fails with `ReadSnapshotExpiredError`; timed-out staged writes retain their existing 422
516
+ failure and rollback. The adapter uses the captured snapshot for materialization and guards predicate
517
+ reads, so it never recreates a snapshot after waiting. Dropping an unconsumed iterable starts no work;
518
+ once consuming, close its iterator or abort the request to cancel. Lag rejection never invalidates a
519
+ healthy plane. The default/strict no-wait lag rejection remains HTTP 503.
520
+ File-primary node-to-record mappings are read from current storage, just like the native graph itself:
521
+ an older record snapshot must not hide mappings published after a record already visible in that snapshot.
522
+ Record filtering and materialization retain the request's snapshot; the native graph is not an MVCC index.
523
+
524
+ The runner captures the RocksDB process-wide transaction clock (`getMonotonicTimestamp()`) before listing
525
+ physical logs and polling their committed
526
+ prefixes. It synchronously adds discovered logs to the audit store's worker-local map. A capture is
527
+ usable only when each stats snapshot's committed position equals its written head: an earlier unfinished
528
+ transaction can hide later committed transactions behind the readable prefix. The native statistics
529
+ counter alone is insufficient here (it can remain nonzero at an equal head). Positions include both file
530
+ sequence and byte offset; cursor timestamps and origin clocks are never ordered or subtracted to prove
531
+ freshness.
532
+
533
+ After a poll reaches the end with no undelivered records, the capture is associated with its offered
534
+ cursor. Reconciliation publishes it only when that cursor, or a later entry in the ordered offered queue,
535
+ is durable. Unrelated-only progress may publish without a new barrier only if **all** non-durable
536
+ registered mutations, including unanchored chunks, are absent. Thus a queued relevant mutation cannot
537
+ be skipped by a later unrelated commit. Publication is owner/epoch fenced. The physical vector is an
538
+ optional `coverage` field in the existing durable cursor value, preserved by later cursor writes and
539
+ removed with the cursor before reset. No native file format changes.
540
+
541
+ Persisted coverage rewrites are limited to the flush cadence, including unrelated-table traffic. The
542
+ shared time can refresh sooner once its prefix is durable. A coverage-only write failure logs and skips
543
+ publication instead of rebuilding the healthy graph. Strict queries may wait for the next persisted proof.
544
+
545
+ Restart identity relies on the existing storage durability ordering: these index column families disable
546
+ WAL, and [RocksDB's database-flush callback](https://github.com/HarperFast/rocksdb-js/blob/v2.9.1/src/binding/transaction_log/transaction_log_store.cpp#L1080-L1111)
547
+ flushes transaction-log files before their index-store flush can become durable. Under successful storage
548
+ flushes, surviving coverage cannot refer to a lost, reusable log tail. Recovery also protects the flushed
549
+ prefix. [Age-based rotation runs on a write](https://github.com/HarperFast/rocksdb-js/blob/v2.9.1/src/binding/transaction_log/transaction_log_store.cpp#L947-L963),
550
+ so an ordinary idle log does not advance its head merely because time passed. These are dependency
551
+ contracts, not a guarantee against externally replacing log files or failed storage durability.
552
+
553
+ Bun gives each worker a different `process.hrtime.bigint()` origin, so it cannot certify cross-worker
554
+ coverage. The transaction clock is shared across workers; its milliseconds are encoded as integer
555
+ nanoseconds without multiplying the full epoch-sized floating-point value.
556
+
557
+ The monotonic time lives only in the process-wide shared readiness buffer and is cleared on non-ready
558
+ health transitions. An owner refreshes idle coverage at its flush cadence without extending its idle
559
+ release deadline. A query within the certified age bound reads only shared memory and the monotonic
560
+ clock; strict or older queries compare the persisted vector with current physical positions. This also
561
+ certifies an unchanged index after owner release or process restart, when no usable time proof remains.
562
+ Concurrent waiters on one worker/index share a single 25 ms poll timer, removed when all resolve,
563
+ time out, or abort. A first waiter nudges its local runner without changing writer-lag accounting or
564
+ retrying a deferred batch; an active peer owner already refreshes at flush cadence. No cross-worker
565
+ notification or per-query persisted coverage write is needed. Closing a registration rejects its waiters.
566
+ A completed nonempty drain also attaches its capture to the accepted boundary; ongoing writes need
567
+ not leave an empty turn between batches. The capture remains fenced behind every indexed mutation
568
+ accepted through that boundary.
569
+ The strict/ownerless path costs a cursor read and stats per physical log. If a database-wide backlog
570
+ prevents the owner from inspecting unrelated writes, coverage can conservatively become unprovable
571
+ for an otherwise unaffected index; queries do not scan logs to classify that backlog.
572
+
573
+ ### Handoff fencing
574
+
575
+ Release drops ownership, calls `flush('shutdown')` then `shutdown(epoch)`, and unlocks only when
576
+ that settles; a rejected `shutdown` **keeps the lock** and publishes `unavailable`, because a backend
577
+ that cannot prove its queue quiescent must not hand the index to another owner. `stop()` and the
578
+ unregister function return one cached promise that resolves after every backend settled and rejects
579
+ if any shutdown failed, so a caller cannot close storage while a backend is still draining into it.
580
+ `isOwnerEpoch(epoch)` is an `Atomics.load` of the shared counter; a backend checks it before each
581
+ apply, after each await and in barrier completions, and drops work for a superseded epoch. The
582
+ runner tracks a generation that changes on every acquisition, discard, reset and release and
583
+ ignores continuations from an earlier one.
584
+
585
+ ### Shared readiness
586
+
587
+ `indexStore.isIndexing` is per worker, so the owner publishes into one `getUserSharedBuffer`
588
+ allocation per backend (`derived-index:<id>:readiness`, `READINESS_BYTES`): `Int32` words for state
589
+ (`unknown | ready | rebuilding | needs-rebuild | unavailable`), a `DerivedIndexReadinessReason` code,
590
+ attempt count, rebuild request and lag-exceeded flag, then the `BigInt64` owner-epoch counter. Each
591
+ word is read with a plain `Atomics.load`; nothing needs two of them atomically, so there is no
592
+ sequence lock — the owner stores reason and attempts before state. The shared reason is a code,
593
+ never a message. `readDerivedIndexReadiness(logStore, id)` reads it on any worker without a runtime;
594
+ a query path uses it to choose between a 503 and an answer. `Atomics` over rocksdb-js's external
595
+ `ArrayBuffer` wrappers is the same dependency primary-key allocation (`Table.ts`), blob holds
596
+ (`blob.ts`) and HNSW node ids already carry; wakes use the binding's `notify()`, never
597
+ `Atomics.wait`. A successor publishes `ready` on acquisition one `setImmediate` before its lazy
598
+ `exactStart` validation can condemn the inherited cursor; readers see the previous, self-consistent
599
+ generation for that turn.
600
+
601
+ ### Lag policy
602
+
603
+ Opt-in per registration (`maxLagMilliseconds`; 0 = none; raised to two flush ages since catch-up is
604
+ only proven at a barrier). The owner takes the longest of: cursor distance behind what it has read,
605
+ time parked on backpressure or the ceiling, time since the oldest commit it may not have read
606
+ (bounded by how far its newest read trails the clock), and the age of the oldest accepted work not
607
+ yet durable. Past the budget it sets the shared flag; every worker's `derivedIndexWriteRejection`
608
+ (`resources/derivedIndexRegistry.ts`, one `WeakMap` miss for tables without a derived index) then
609
+ fails local user writes to the index's tables with `DerivedIndexLagError` — 503,
610
+ `DERIVED_INDEX_LAGGING`, `retryable: true` — at the staging layer (`_writeUpdate`, `_writeDelete`,
611
+ `_writeInvalidate`, `_writeRelocate`), never canonical-source applies (`transaction.sourceApply`),
612
+ crash-recovery replay or replication notifications, since a rejected canonical write would advance
613
+ the source cursor past a write that never landed. The flag is owned by the lock holder: it clears
614
+ with hysteresis once the owner has proven catch-up (a durable advance and the end of the log both
615
+ reached since acquiring, lag below half the budget), and on every transition out of "behind and
616
+ still reading" — entering a rebuild (no cursor to guard; readers act on `rebuilding`), `unavailable`,
617
+ a `needs-rebuild` the runtime cannot leave, a condemnation marker it could not write (its retry
618
+ needs a commit wake, and commits were what was being shed), and a held lock. An ordinary handoff
619
+ preserves it. The policy sheds writes; it does not pin retention — rocksdb-js has no protected
620
+ position registration — so a budget belongs well inside the effective retention window, and it must
621
+ be enabled only once every worker runs a runtime with the admission check.
622
+
623
+ ### Failure flow
624
+
625
+ ```mermaid
626
+ flowchart TD
627
+ A[load backend cursor] --> B{all saved logs and boundaries exact?}
628
+ B -->|no| X{backend has reset and runtime has scanRecords?}
629
+ B -->|yes| C[open aggregate iterator after anchors]
630
+ C --> D[bounded drain: collect, resolve, deliver]
631
+ D -->|accepted| E[record offered cursor vector]
632
+ D -->|deferred| F[park until backend wake]
633
+ D -->|failed or threw| X
634
+ E -->|backend barrier| G[durable cursor equals one offered vector]
635
+ E -->|accepted batches at cap| W[waiting-durable]
636
+ G --> T[publish ready]
637
+ X -->|no| Z[terminal: release lock, index unavailable]
638
+ X -->|yes| Y[publish rebuilding, shutdown old epoch, reset, scan, tail, replay]
639
+ Y -->|ready after final barrier| T
640
+ Y -->|failure| K{attempts below cap?}
641
+ K -->|yes, after backoff| Y
642
+ K -->|no| U[publish unavailable, release lock]
643
+ ```
644
+
645
+ ## Native HNSW plane: a file-primary mmap graph on the derived-index runtime (`resources/indexes/HierarchicalNavigableSmallWorld.ts`, `resources/indexes/hnswDerivedIndex.ts`, `resources/indexes/hnswPlaneBinding.ts`)
646
+
647
+ For a new locally declared HNSW index, Harper uses the native plane by default when the table's
648
+ primary descriptor already stores `audit: true` (or the same declaration explicitly enables it),
649
+ the native binding loads, RocksDB is in use, and the index has compatible geometry. An explicit
650
+ `nativePlane: false` selects the JS graph. The native plane replaces the RocksDB graph with a
651
+ memory-mapped fixed-slot file owned by `@harperfast/hnsw` (Rust, napi-rs, exact-pinned optional
652
+ dependency; crate at HarperFast/hnsw). The file **is the index**: graph nodes, adjacency, the entry
653
+ point, the id allocator, the freelist and each node's primary key exist only there. RocksDB keeps
654
+ the primary records, the `pk → nodeId` mapping (which node a key owns, for replacement and replay)
655
+ and the one durable replay cursor.
656
+
657
+ The decision belongs to the durable index-creation boundary, not `openIndex()` or the HNSW
658
+ constructor: those are also catalog-reload paths. An existing descriptor is therefore authoritative
659
+ when a later declaration omits `nativePlane`; legacy descriptors with no field stay on the JS graph,
660
+ and legacy string values retain their historical truthiness while an exactly matching declaration
661
+ keeps the stored spelling; a numerically equivalent value produced by the GraphQL numeric-literal
662
+ coercion does the same for a pre-upgrade numeric spelling. Numeric HNSW options are likewise
663
+ normalized and validated only at the declaration boundary: an exact redeclaration retains persisted
664
+ legacy values and their previous runtime coercion, while a different canonical declaration triggers
665
+ a rebuild when that interpretation changes (notably zero-valued `optimizeRouting` strings). A table
666
+ whose primary descriptor already stores `audit: true` qualifies for the default even when that value
667
+ originally came from the global audit setting. In
668
+ contrast, a new table and its omitted-mode index in the same declaration do not qualify unless that
669
+ declaration explicitly enables audit, because audit was not durable at the index-creation boundary.
670
+ A replicated new attribute uses native mode only when the receiving node is independently audited
671
+ and eligible, because the plane is node-local derived state. An ineligible receiver persists its
672
+ fallback as `nativePlane: false` in the local catalog, so installing the binding or changing storage
673
+ later does not switch that index automatically; redeclare it locally with `nativePlane: true` after
674
+ the node becomes eligible. Set
675
+ `HNSW_NO_NATIVE_DEFAULT=1` to keep newly omitted declarations on JS during rollout; it does not
676
+ disable an explicit or already persisted native index. Set it before creating indexes when a
677
+ rollback must remain cheap: an older release sees an omitted declaration against the persisted
678
+ `nativePlane: true` decision as a mode change and rebuilds that index back to JS. In a cluster, keep
679
+ the switch enabled on every node until all nodes run a release with the replicated-attribute fallback.
680
+
681
+ The default accepts the native implementation's existing operational contract: maintenance is
682
+ post-commit; writes receive retryable 503 responses after derived-index lag exceeds 30 seconds;
683
+ per-query distance overrides are rejected; the default capacity is 16M nodes; and adding the index
684
+ to a populated table keeps searches unavailable for the rebuild, which can take hours at large
685
+ sizes. `nativePlane: false` is the durable per-index opt-out.
686
+
687
+ Why the whole search loop is native and not just the distance kernel: at 5M nodes / ef 512, ~85% of
688
+ a warm JS visit is object bookkeeping (candidate heap, visited `Set`, property access, GC), the int8
689
+ cosine is 10%, and a warm RocksDB `Get` per visit (~1–2 µs) is 20–40× the SIMD distance it feeds. A
690
+ native loop over direct-addressed slots (`base + id × slot_size`, ~100–200 ns) with one NAPI crossing
691
+ per query is the only shape that reaches the ceiling; measured 7.2 ms → 0.75 ms p50 at 1M × 768-d,
692
+ recall@10 0.997 → 0.999.
693
+
694
+ ### File format
695
+
696
+ One file per index, `<index store path>/<store name>.hnsw`, created sparse at `nativePlaneMaxNodes`
697
+ slots (16M default; a structural, create-time header field — exhausting it makes the index
698
+ unavailable until the value is raised and the index rebuilt). Header page: magic + format version
699
+ (mismatch → rebuild, by contract), dims, quantization mode, `slot_size`/`layer0_cap`/`upper_cap`
700
+ (`layer0_cap` from `nativePlaneLayer0Cap` at creation; `upper_cap` fixed at 64 by the crate), entry point, atomic `id_high_water`, tag-guarded
701
+ freelist head, a transaction watermark advanced only after an `msync` barrier, and a clean-shutdown
702
+ flag. Layer-0 slot: seqlock word, flags + level, `scale`/`invMag`, degree, int8 vector padded to a
703
+ 4-byte boundary, `u32` neighbour ids, then the record's msgpack-encoded primary key (format v8;
704
+ `nativePlaneKeyCap` inline bytes, default 40; a longer key spills to an overflow arena after the
705
+ upper region, reserved at max(128, 4 × keyCap) bytes per node — a table whose keys are mostly
706
+ longer than 40 encoded bytes should raise `nativePlaneKeyCap` rather than live in the arena) — at the default degree cap of 64, 1,088 B at 768-d and 448 B at 128-d, the key fitting the cache-line padding.
707
+ Upper layers (~6% of nodes) live in a fixed-entry region in the same file, per-entry seqlocked.
708
+ Per-edge cached distances are dropped: recomputing costs ~50 ns natively, storing costs 8 B and
709
+ ~40% of a node. Searches and predicate batches return each hit's key with it, so no lookup by node
710
+ id remains on the query path.
711
+
712
+ Degree cap is the per-index `nativePlaneLayer0Cap`, **default 64** (supersedes the fixed 128 of
713
+ 2026-08-31, re-measured in [hnsw#14](https://github.com/HarperFast/hnsw/pull/14)): at 128-d and
714
+ 768-d int8, cap 64 holds recall@10 within ~0.5 pt of cap 128 at every ef ≥ 128 at 1M and 4M, and
715
+ within ~0.3 pt at 768-d, at the same resident latency — while cutting the 128-d slot 704 → 448 B and
716
+ the 768-d slot 1,344 → 1,088 B, which keeps a 4M-node plane resident under a 2 GB limit that makes
717
+ the cap-128 plane thrash. A live 7.7M-node plane carries a mean layer-0 degree of 29
718
+ ([hnsw#7](https://github.com/HarperFast/hnsw/issues/7)), so the reserved slot was mostly padding.
719
+ Cap 32 halves the slot again but trails cap 128 by 1.3–2.2 pts below ef 1024 at 4M and by 1.7 pts
720
+ at 1M for 768-d vectors, so it is a declaration for narrow vectors on a plane that outgrows RAM,
721
+ not a default. The cap is a create-time header field: `getPlane` compares it with the index's
722
+ value on attach and invalidates a plane that disagrees rather than reusing or truncating it, so
723
+ revising it is a rebuild, not a format change. A file-primary index builds no JS graph, so this is
724
+ the only layer-0 maximum it has; the JS graph's own cap in `addConnection` governs
725
+ non-`nativePlane` indexes only. A binary-code v2 slot reopens the question.
726
+
727
+ Upgrading a plane built at 128 costs one rebuild per node, the first time a process opens it under
728
+ the new default; no GA release line carries plane files, so this reaches 5.3 pre-releases only.
729
+ Declaring `nativePlaneLayer0Cap: 128` does not avoid that rebuild — adding the property changes the
730
+ attribute's canonical structural options, which reindexes the attribute by itself
731
+ (`indexOptionsStructurallyChanged` in `resources/databases.ts`); it preserves the geometry only once
732
+ it is already the persisted declaration. The default is not written into a descriptor that omits
733
+ the option, so nodes upgrade independently: a mixed-version cluster has each node rebuild its own
734
+ file as it reaches the new code, and no node invalidates another's.
735
+
736
+ ### Concurrency
737
+
738
+ Per-slot lock word: bit 31 locked, low bits the owner's pid; unlocked values are generations that
739
+ readers validate seqlock-style. A lock unchanged for 20 ms whose owner pid is dead is taken over and
740
+ the slot sanitized (marked invalid — a dead writer's payload is half-written; invisible until
741
+ rewritten, never spliced-but-valid). Elapsed time alone never robs a live writer. There is no
742
+ cross-slot atomicity: an insert writes its slot plus ~M neighbours' back-edges independently, and a
743
+ traversal may see a half-linked state — a missing edge or a just-deleted neighbour is skipped. That
744
+ relaxed adherence is safe _here_ because the read path loads the record and rescores exactly, which
745
+ rejects a wrong candidate; it is not a general storage pattern. Fields a reader acts on are read
746
+ through aligned `read_volatile`; the stored vector is an ordinary load so the int8 kernel keeps
747
+ autovectorizing, and a torn vector only perturbs a distance the generation check discards.
748
+
749
+ ### Durability
750
+
751
+ `msync` on a cadence, not per commit; the header watermark advances after a completed barrier. The
752
+ graph therefore has bounded-lag durability with deterministic catch-up, while the source of truth
753
+ (records, mappings, cursor) stays transactional. Backup treats the file as node-local derived state:
754
+ include it after a barrier, or rebuild on restore. A file whose format or checksum does not validate
755
+ is rebuilt from records. macOS `msync` is a weaker barrier than Linux (an `F_FULLFSYNC` pass is a
756
+ known follow-up); Windows is supported through the prebuild; performance is a Linux target.
757
+
758
+ ### Search
759
+
760
+ One crossing per query: `plane.search(query, k, ef, filter?)` runs on the module's thread pool with
761
+ an epoch-stamped visited array and a fixed-capacity heap, asymmetric int8 distance with SIMD
762
+ (AVX2/VNNI, NEON) and a scalar fallback. Filtering has two paths: a bitset over node ids for
763
+ allow-lists and companion-condition candidate sets (zero callbacks), and a pipelined
764
+ `ThreadsafeFunction` batch path for arbitrary JS predicates that keeps expanding in distance order
765
+ while verdicts are in flight, bounded by the same `filterExpansion` visit budget as the JS path.
766
+ Traversal never blocks on the event loop. A plane-backed `customIndex.search()` returns a
767
+ promise-backed, async-only iterable (`resources/search.ts` wraps it); a synchronous consumer throws.
768
+ Auto-ef reads the node count from the plane's `id_high_water` (freed ids are reused, so it stays
769
+ close to the live count; deletes leave it generous until a rebuild, as with the RocksDB graph). Every vector reaching the plane — a
770
+ committed projection or a query target — passes one invariant (`assertPlaneVector`): array-like of
771
+ positive length, every component a finite f32, a magnitude representable in f32, and the plane's
772
+ `dims` once known. What fails it is the client's 400, never a plane failure; a query the plane
773
+ cannot accept must not be read as corruption and cost a rebuild.
774
+
775
+ ### Delivery: a backend on the shared runtime
776
+
777
+ Maintenance is off the record transaction. The commit path (`prepareCommitted`) only validates a
778
+ changed projection; the derived-index runtime (§ above) reads the committed log and delivers batches
779
+ to `HnswDerivedIndexBackend`:
780
+
781
+ - `deliver()` enqueues and returns `accepted` (or `deferred` at 64 MiB queued); an applier drains
782
+ in 5 ms `setImmediate` slices. Each `records` entry (last-write-wins per key) becomes one
783
+ `applyDerivedValue(pk, vector | undefined, version)`: the stored mapping's signature short-circuits
784
+ an unchanged vector, an older observation is discarded, otherwise the old node is removed and the
785
+ new vector inserted — with the primary key in its slot, so it is searchable at once — and the
786
+ `pk -> node` mapping written **pending**. The store holds no `node -> pk` entries: hits and
787
+ predicate candidates carry their keys out of the plane, and a query deduplicates by key for the
788
+ crash case where a replayed record's earlier node survives without a published mapping.
789
+ - `flush()` runs at the next completed batch that carries `through`, or at the drain when none does:
790
+ `plane.flushAsync()`, then pending mappings are published, then that batch's `through` vector is
791
+ written as the cursor under `Symbol.for('derived-index-cursor')`. Waiting for the drain instead
792
+ would leave a catch-up — which never empties the queue — with no barrier at all, so the runtime's
793
+ `flushAfterMutations` / `flushAfterBytes` / `maxFlushAgeMilliseconds` cadence would be inert for
794
+ its whole duration. A rebuild scan chunk carries no `through` and so never interrupts a slice;
795
+ its barrier is the drain one, which the runtime's per-chunk `setImmediate` keeps reaching. An
796
+ interrupting barrier then idles for three times its own duration before another may interrupt:
797
+ application is paused for a barrier, and the runtime re-requests on a 1 s age timer, so on a plane
798
+ whose `flushAsync()` costs seconds — measured at 2–6.5 s on the Windows CI runner — honouring every
799
+ request would spend the whole catch-up inside barriers. The drain barrier is never delayed.
800
+ Application pauses while a barrier is in flight so the barrier publishes exactly the mappings it
801
+ covers. That order is the crash contract: a crash before the barrier leaves pending mappings that
802
+ replay re-derives; after it, a cursor that replays idempotently; never a published mapping to a
803
+ node the file did not durably get, and never a cursor over uncovered state.
804
+ - `reset(epoch)` removes the cursor first, then the file (invalidated in-band and via a `.stale`
805
+ sidecar so no peer adopts a stale inode or an undeletable Windows file), then the mappings.
806
+ - A vector the plane cannot hold at apply time (a dimensionality mismatch only the plane-holding
807
+ worker can see) is skipped and counted, never a rebuild.
808
+
809
+ Readiness is the runtime's shared record on every worker, not `indexStore.isIndexing`: a search on
810
+ a non-`ready` index is a 503 (`unavailable` after the rebuild budget); a `ready` index with no file
811
+ and no surviving node mapping answers no results; one whose file is gone while mappings survive
812
+ asks its owner for a rebuild. A search failure detaches this process from the plane and requests a
813
+ rebuild — only the owner's `reset` destroys state, so a failure observed after a peer has already
814
+ replaced the file cannot take out the replacement. Writer backpressure is the runtime's lag policy
815
+ (`maxLagMilliseconds`, 30 s default on a `nativePlane` attribute), required because accepting
816
+ unique-key load above native insert throughput and then rebuilding at that same throughput cannot
817
+ converge.
818
+
819
+ ### What native-plane mode requires, and what it does not promise
820
+
821
+ | requirement | enforcement |
822
+ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
823
+ | For the automatic default, a primary descriptor with `audit: true` or an explicit `audit: true` in the same local declaration — the transaction log is the recovery source, and the default must not widen the audit-readable surface by inheriting the global setting. On an existing table whose durable audit field is absent, explicit native mode may pin the effective audit value; a stored `false` wins. A new table still requires explicit audit. | On an existing table, `table()` writes an audit enable before the native index row. A new table publishes its primary row last as the catalog-completeness marker, already carrying explicit audit. Disabling audit writes every non-primary descriptor first, whether native mode is opted out or the index is removed, then writes the primary row last. Catalog reload propagates durable audit to an already-loaded class, and `attachDerivedIndexes()` checks the runtime flag; enabling logs warns once that the audit API retains full record history for the retention window. |
824
+ | RocksDB; `M=16`, `efConstruction=200`, `mL=1/ln(16)`, `optimizeRouting=0.5`, int8 cosine (the package's standalone `insert` fixes this geometry) | `ClientError` at index construction — never a silent rebuild under native defaults |
825
+ | `@harperfast/hnsw` loads on the platform | absence is 503, not degraded: there is no JS graph to fall back to |
826
+ | The log retains entries back to the cursor | a cursor the log cannot resolve rebuilds from records; 503 for the rebuild's duration |
827
+
828
+ Not promised: a single total order across concurrent CRDT/source-resolution arrivals (both delivery
829
+ and replay re-read the authoritative record, so the index converges on what the primary store
830
+ resolved; divergence is bounded by candidate selection, which the exact rescore filters), byte-identical
831
+ graphs across nodes or rebuilds, or in-place format upgrades. Rebuild rate falls with graph size
832
+ (≈4,700 inserts/s at 100k, 1,242/s at 1M measured), so a 16M rebuild is hours of 503; a native batch
833
+ insert is the phase-3 follow-up.