@remnic/core 9.3.680 → 9.3.681

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 (167) hide show
  1. package/dist/access-boundary.d.ts +178 -0
  2. package/dist/access-boundary.js +121 -0
  3. package/dist/access-cli.js +115 -101
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +1 -1
  6. package/dist/access-http.js +48 -46
  7. package/dist/access-mcp.d.ts +1 -1
  8. package/dist/access-mcp.js +44 -42
  9. package/dist/access-operations.d.ts +127 -0
  10. package/dist/access-operations.js +115 -0
  11. package/dist/access-operations.js.map +1 -0
  12. package/dist/access-schema.d.ts +34 -34
  13. package/dist/access-schema.js +6 -6
  14. package/dist/{access-service-S9oGKPZc.d.ts → access-service-DvA6jyHL.d.ts} +1 -1
  15. package/dist/access-service.d.ts +1 -1
  16. package/dist/access-service.js +40 -40
  17. package/dist/access-surface-catalog.d.ts +125 -0
  18. package/dist/access-surface-catalog.js +162 -0
  19. package/dist/access-surface-catalog.js.map +1 -0
  20. package/dist/adapters/index.js +7 -7
  21. package/dist/adapters/registry.js +3 -3
  22. package/dist/auto-sync-5CJBJMPZ.js +1 -1
  23. package/dist/briefing.js +8 -8
  24. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-7FJQINUR.js} +2 -2
  25. package/dist/capsule-crypto-7FJQINUR.js.map +1 -0
  26. package/dist/causal-behavior.js +5 -5
  27. package/dist/causal-chain.js +3 -3
  28. package/dist/causal-consolidation.js +16 -16
  29. package/dist/causal-retrieval.js +3 -3
  30. package/dist/causal-trajectory.js +1 -1
  31. package/dist/{chunk-BXLOS5AJ.js → chunk-2NLLXCJG.js} +2 -2
  32. package/dist/{chunk-JBPKEARU.js → chunk-2QSZNTDO.js} +7 -7
  33. package/dist/{chunk-3OKWZT7F.js → chunk-3IND7N4X.js} +2 -2
  34. package/dist/{chunk-GYSYLGNE.js → chunk-7MOTEVAA.js} +2 -2
  35. package/dist/{chunk-6T4LTI2F.js → chunk-7XH7VJN4.js} +4 -4
  36. package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
  37. package/dist/{chunk-LZSMQHXC.js → chunk-ARLRTZZZ.js} +5 -5
  38. package/dist/{chunk-DL6H3D7S.js → chunk-ARV3AUOM.js} +2 -2
  39. package/dist/{chunk-Q2H5U37U.js → chunk-B2B2IHUH.js} +2 -2
  40. package/dist/{chunk-SECQS4G4.js → chunk-BTVX7ZXZ.js} +5 -5
  41. package/dist/{chunk-DGEZKYVI.js → chunk-DOCTITOP.js} +4 -4
  42. package/dist/{chunk-EQYP3HA6.js → chunk-EG4TCVMU.js} +2 -2
  43. package/dist/{chunk-SLTKP5WJ.js → chunk-EW5KFXHL.js} +4 -4
  44. package/dist/{chunk-5TEYIXMP.js → chunk-FDSOMA6M.js} +28 -41
  45. package/dist/chunk-FDSOMA6M.js.map +1 -0
  46. package/dist/{chunk-CTCPB57O.js → chunk-G7Z3C2X6.js} +2 -2
  47. package/dist/{chunk-OBM7EVFU.js → chunk-H4BDNIKQ.js} +53 -53
  48. package/dist/{chunk-MTJ2LFAJ.js → chunk-H6PMGMNP.js} +2 -2
  49. package/dist/{chunk-7AAKSHDG.js → chunk-I3HSKQT7.js} +136 -136
  50. package/dist/{chunk-NXBXM7Q6.js → chunk-I75DF4FZ.js} +2 -2
  51. package/dist/{chunk-RC3AFF6Z.js → chunk-JD4SCARD.js} +1 -1
  52. package/dist/{chunk-LVTTO3VC.js → chunk-KACIOX42.js} +2 -2
  53. package/dist/{chunk-VDX2J7OX.js → chunk-KQAFEZQX.js} +2 -2
  54. package/dist/{chunk-ATRB6Q25.js → chunk-KV6CX4ON.js} +2 -2
  55. package/dist/{chunk-VL5JJOOY.js → chunk-L5MUA6Q7.js} +5 -5
  56. package/dist/{chunk-W67ZZDHO.js → chunk-M4I3TREG.js} +75 -75
  57. package/dist/chunk-NHFXF4ZO.js +107 -0
  58. package/dist/chunk-NHFXF4ZO.js.map +1 -0
  59. package/dist/{chunk-MNUPGYIV.js → chunk-NQMBSSWW.js} +2 -2
  60. package/dist/{chunk-V4ZHKCGA.js → chunk-O2WELT5C.js} +5 -5
  61. package/dist/{chunk-Z6SEG36L.js → chunk-OUWAQVDJ.js} +4 -4
  62. package/dist/{chunk-57ME5VSI.js → chunk-Q5ZU3RNY.js} +4 -4
  63. package/dist/{chunk-ACYX37IM.js → chunk-QUA2JPH2.js} +6 -6
  64. package/dist/{chunk-DWQPM67F.js → chunk-QVWM4C24.js} +37 -32
  65. package/dist/chunk-QVWM4C24.js.map +1 -0
  66. package/dist/{chunk-2AP4QJX5.js → chunk-TOQEZ63C.js} +8 -8
  67. package/dist/{chunk-EUM7CZFM.js → chunk-TY5NT3T3.js} +17 -17
  68. package/dist/{chunk-ZCVPFDHB.js → chunk-UAODC6GJ.js} +14 -14
  69. package/dist/{chunk-JI6HWBYL.js → chunk-UDJLF3BO.js} +2 -2
  70. package/dist/{chunk-YJ4J2JJ2.js → chunk-UJDV2NLT.js} +9 -9
  71. package/dist/chunk-V254FAT5.js +85 -0
  72. package/dist/chunk-V254FAT5.js.map +1 -0
  73. package/dist/{chunk-3IE22DJ2.js → chunk-WEPMT6SC.js} +10 -10
  74. package/dist/{chunk-DQEMWVMT.js → chunk-X7Y7WX73.js} +1 -1
  75. package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
  76. package/dist/{cli-B2Ve7R22.d.ts → cli-feUe-x3I.d.ts} +1 -1
  77. package/dist/cli.d.ts +2 -2
  78. package/dist/cli.js +75 -73
  79. package/dist/compounding/engine.js +9 -9
  80. package/dist/connectors/codex-materialize-runner.js +9 -9
  81. package/dist/connectors/index.js +9 -9
  82. package/dist/consolidation-provenance-check.js +2 -2
  83. package/dist/dashboard-runtime.js +2 -2
  84. package/dist/entity-retrieval.js +7 -7
  85. package/dist/extraction.js +2 -2
  86. package/dist/{first-start-migration-PG5HBC3K.js → first-start-migration-FF7YFGRP.js} +4 -4
  87. package/dist/index.d.ts +2 -2
  88. package/dist/index.js +209 -207
  89. package/dist/index.js.map +1 -1
  90. package/dist/lcm/engine.js +4 -4
  91. package/dist/lcm/index.js +12 -12
  92. package/dist/maintenance/memory-governance.js +8 -8
  93. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  94. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  95. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  96. package/dist/namespaces/migrate.js +17 -17
  97. package/dist/namespaces/search.js +8 -8
  98. package/dist/namespaces/storage.js +8 -8
  99. package/dist/operator-toolkit.js +22 -22
  100. package/dist/orchestrator.js +70 -70
  101. package/dist/resume-bundles.js +1 -1
  102. package/dist/schemas.d.ts +50 -50
  103. package/dist/search/factory.js +7 -7
  104. package/dist/search/index.js +11 -11
  105. package/dist/search/lancedb-backend.js +3 -3
  106. package/dist/search/meilisearch-backend.js +3 -3
  107. package/dist/search/orama-backend.js +3 -3
  108. package/dist/semantic-consolidation.js +11 -11
  109. package/dist/semantic-rule-promotion.js +7 -7
  110. package/dist/semantic-rule-verifier.js +8 -8
  111. package/dist/storage.js +6 -6
  112. package/dist/transfer/backup.js +4 -4
  113. package/dist/transfer/capsule-export.js +4 -4
  114. package/dist/transfer/capsule-import.js +3 -3
  115. package/dist/transfer/import-sqlite.js +2 -2
  116. package/dist/transfer/types.d.ts +32 -32
  117. package/dist/verified-recall.js +8 -8
  118. package/package.json +2 -2
  119. package/src/access-boundary.test.ts +212 -0
  120. package/src/access-boundary.ts +235 -0
  121. package/src/access-cli.ts +32 -15
  122. package/src/access-http.ts +38 -28
  123. package/src/access-mcp.ts +41 -35
  124. package/src/access-operations.ts +157 -0
  125. package/src/access-surface-catalog.test.ts +772 -0
  126. package/src/access-surface-catalog.ts +218 -0
  127. package/dist/chunk-5TEYIXMP.js.map +0 -1
  128. package/dist/chunk-DWQPM67F.js.map +0 -1
  129. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → access-boundary.js.map} +0 -0
  130. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-2NLLXCJG.js.map} +0 -0
  131. /package/dist/{chunk-JBPKEARU.js.map → chunk-2QSZNTDO.js.map} +0 -0
  132. /package/dist/{chunk-3OKWZT7F.js.map → chunk-3IND7N4X.js.map} +0 -0
  133. /package/dist/{chunk-GYSYLGNE.js.map → chunk-7MOTEVAA.js.map} +0 -0
  134. /package/dist/{chunk-6T4LTI2F.js.map → chunk-7XH7VJN4.js.map} +0 -0
  135. /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
  136. /package/dist/{chunk-LZSMQHXC.js.map → chunk-ARLRTZZZ.js.map} +0 -0
  137. /package/dist/{chunk-DL6H3D7S.js.map → chunk-ARV3AUOM.js.map} +0 -0
  138. /package/dist/{chunk-Q2H5U37U.js.map → chunk-B2B2IHUH.js.map} +0 -0
  139. /package/dist/{chunk-SECQS4G4.js.map → chunk-BTVX7ZXZ.js.map} +0 -0
  140. /package/dist/{chunk-DGEZKYVI.js.map → chunk-DOCTITOP.js.map} +0 -0
  141. /package/dist/{chunk-EQYP3HA6.js.map → chunk-EG4TCVMU.js.map} +0 -0
  142. /package/dist/{chunk-SLTKP5WJ.js.map → chunk-EW5KFXHL.js.map} +0 -0
  143. /package/dist/{chunk-CTCPB57O.js.map → chunk-G7Z3C2X6.js.map} +0 -0
  144. /package/dist/{chunk-OBM7EVFU.js.map → chunk-H4BDNIKQ.js.map} +0 -0
  145. /package/dist/{chunk-MTJ2LFAJ.js.map → chunk-H6PMGMNP.js.map} +0 -0
  146. /package/dist/{chunk-7AAKSHDG.js.map → chunk-I3HSKQT7.js.map} +0 -0
  147. /package/dist/{chunk-NXBXM7Q6.js.map → chunk-I75DF4FZ.js.map} +0 -0
  148. /package/dist/{chunk-RC3AFF6Z.js.map → chunk-JD4SCARD.js.map} +0 -0
  149. /package/dist/{chunk-LVTTO3VC.js.map → chunk-KACIOX42.js.map} +0 -0
  150. /package/dist/{chunk-VDX2J7OX.js.map → chunk-KQAFEZQX.js.map} +0 -0
  151. /package/dist/{chunk-ATRB6Q25.js.map → chunk-KV6CX4ON.js.map} +0 -0
  152. /package/dist/{chunk-VL5JJOOY.js.map → chunk-L5MUA6Q7.js.map} +0 -0
  153. /package/dist/{chunk-W67ZZDHO.js.map → chunk-M4I3TREG.js.map} +0 -0
  154. /package/dist/{chunk-MNUPGYIV.js.map → chunk-NQMBSSWW.js.map} +0 -0
  155. /package/dist/{chunk-V4ZHKCGA.js.map → chunk-O2WELT5C.js.map} +0 -0
  156. /package/dist/{chunk-Z6SEG36L.js.map → chunk-OUWAQVDJ.js.map} +0 -0
  157. /package/dist/{chunk-57ME5VSI.js.map → chunk-Q5ZU3RNY.js.map} +0 -0
  158. /package/dist/{chunk-ACYX37IM.js.map → chunk-QUA2JPH2.js.map} +0 -0
  159. /package/dist/{chunk-2AP4QJX5.js.map → chunk-TOQEZ63C.js.map} +0 -0
  160. /package/dist/{chunk-EUM7CZFM.js.map → chunk-TY5NT3T3.js.map} +0 -0
  161. /package/dist/{chunk-ZCVPFDHB.js.map → chunk-UAODC6GJ.js.map} +0 -0
  162. /package/dist/{chunk-JI6HWBYL.js.map → chunk-UDJLF3BO.js.map} +0 -0
  163. /package/dist/{chunk-YJ4J2JJ2.js.map → chunk-UJDV2NLT.js.map} +0 -0
  164. /package/dist/{chunk-3IE22DJ2.js.map → chunk-WEPMT6SC.js.map} +0 -0
  165. /package/dist/{chunk-DQEMWVMT.js.map → chunk-X7Y7WX73.js.map} +0 -0
  166. /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
  167. /package/dist/{first-start-migration-PG5HBC3K.js.map → first-start-migration-FF7YFGRP.js.map} +0 -0
@@ -0,0 +1,772 @@
1
+ /**
2
+ * Fitness test for the access boundary (issue #1525).
3
+ *
4
+ * Walks the MCP `tools/list` surface and the HTTP route catalog, and asserts:
5
+ * 1. every MCP tool the server actually advertises has a catalog entry
6
+ * (so a new tool cannot ship without either migrating it or
7
+ * acknowledging it as unmigrated);
8
+ * 2. every catalog entry that claims a migration (`operation !== null`)
9
+ * resolves to a registered boundary operation AND dispatches through it
10
+ * — a flipped catalog row without wired dispatch is caught, so the
11
+ * ratchet cannot record a false migration;
12
+ * 3. the unmigrated-handler count equals the ratchet baseline, so the count
13
+ * may only decrease.
14
+ *
15
+ * Prove-fail-before (issue requirement): dedicated tests seed bypasses — an
16
+ * MCP tool the catalog does not know about, and a catalog entry whose
17
+ * operation is registered but never dispatched — and assert the validators
18
+ * report them. That demonstrates the gates catch the regression class before
19
+ * relying on them for the real surface.
20
+ */
21
+
22
+ import assert from "node:assert/strict";
23
+ import { readFileSync } from "node:fs";
24
+ import test from "node:test";
25
+
26
+ import { EngramMcpServer } from "./access-mcp.js";
27
+ import type { EngramAccessService } from "./access-service.js";
28
+ import { getOperation, listRegisteredOperations, type OperationName } from "./access-boundary.js";
29
+ // Importing access-operations registers the three pilot operations as a side
30
+ // effect — that is the migration state under test here.
31
+ import "./access-operations.js";
32
+ import {
33
+ HTTP_ROUTES,
34
+ MCP_TOOLS,
35
+ countUnmigratedHandlers,
36
+ type HttpRouteEntry,
37
+ type McpToolEntry,
38
+ } from "./access-surface-catalog.js";
39
+
40
+ // The ratchet baseline. Decrease this constant (and run
41
+ // `node scripts/check-ratchets.mjs --update`) whenever a follow-up PR
42
+ // migrates a handler. It may NEVER increase — an increase means a new handler
43
+ // shipped without going through the boundary. The only exception is a
44
+ // catalog-completeness correction: adding routes that were always live but
45
+ // omitted from the catalog (review-caught). Such a bump MUST be accompanied
46
+ // by the newly-cataloged entries; the higher count is the honest baseline.
47
+ const UNMIGRATED_HANDLER_BASELINE = 134;
48
+
49
+ // Keep the import live — `getOperation` is the call surfaces use at dispatch
50
+ // time; referencing it here pins the registry's lookup contract.
51
+ void getOperation;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Live MCP surface — short-name extraction
55
+ // ---------------------------------------------------------------------------
56
+
57
+ const LEGACY_PREFIX = "engram.";
58
+ const CANONICAL_PREFIX = "remnic.";
59
+
60
+ /** Strip the `engram.`/`remnic.` prefix to get the canonical short name. */
61
+ function shortToolName(advertised: string): string {
62
+ if (advertised.startsWith(LEGACY_PREFIX)) return advertised.slice(LEGACY_PREFIX.length);
63
+ if (advertised.startsWith(CANONICAL_PREFIX)) return advertised.slice(CANONICAL_PREFIX.length);
64
+ return advertised;
65
+ }
66
+
67
+ /** Spin up a server with emitLegacyTools=true and read the deduped short names. */
68
+ async function liveMcpToolShortNames(): Promise<ReadonlySet<string>> {
69
+ const stub = { briefingEnabled: true } as unknown as EngramAccessService;
70
+ const server = new EngramMcpServer(stub, { emitLegacyTools: true });
71
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/list" });
72
+ const result = (response as { result?: { tools?: Array<{ name: string }> } }).result;
73
+ const names = new Set<string>();
74
+ for (const tool of result?.tools ?? []) {
75
+ names.add(shortToolName(tool.name));
76
+ }
77
+ return names;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Coverage validator — pure function so the prove-fail-before test can seed a
82
+ // bypass against the SAME logic the real assertion uses.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ interface CoverageViolation {
86
+ readonly kind:
87
+ | "live-tool-not-in-catalog"
88
+ | "catalog-tool-not-live"
89
+ | "migrated-entry-not-registered"
90
+ | "duplicate-catalog-tool";
91
+ readonly detail: string;
92
+ }
93
+
94
+ function validateCoverage(
95
+ catalog: readonly McpToolEntry[],
96
+ liveShortNames: ReadonlySet<string>,
97
+ registered: ReadonlySet<OperationName>,
98
+ ): CoverageViolation[] {
99
+ const violations: CoverageViolation[] = [];
100
+ const catalogShortNames = new Set<string>();
101
+
102
+ for (const entry of catalog) {
103
+ if (catalogShortNames.has(entry.tool)) {
104
+ violations.push({ kind: "duplicate-catalog-tool", detail: entry.tool });
105
+ }
106
+ catalogShortNames.add(entry.tool);
107
+
108
+ if (!liveShortNames.has(entry.tool)) {
109
+ violations.push({ kind: "catalog-tool-not-live", detail: entry.tool });
110
+ }
111
+ if (entry.operation !== null && !registered.has(entry.operation)) {
112
+ violations.push({
113
+ kind: "migrated-entry-not-registered",
114
+ detail: `${entry.tool} -> ${entry.operation}`,
115
+ });
116
+ }
117
+ }
118
+
119
+ for (const live of liveShortNames) {
120
+ if (!catalogShortNames.has(live)) {
121
+ violations.push({ kind: "live-tool-not-in-catalog", detail: live });
122
+ }
123
+ }
124
+
125
+ return violations;
126
+ }
127
+
128
+ function shortNamesOf(catalog: readonly McpToolEntry[]): Set<string> {
129
+ const set = new Set<string>();
130
+ for (const entry of catalog) set.add(entry.tool);
131
+ return set;
132
+ }
133
+
134
+ function formatViolations(violations: readonly CoverageViolation[]): string {
135
+ return violations.map((v) => ` - [${v.kind}] ${v.detail}`).join("\n");
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Live dispatch extraction — verify surfaces route through the boundary
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Statically extract the MCP dispatch map (`MCP_MIGRATED_OPERATIONS`) from
144
+ * `access-mcp.ts`. This is the map that routes incoming tool calls to the
145
+ * boundary at dispatch time. Comparing it to the catalog proves a flipped
146
+ * catalog row is not a false migration — the surface code must also wire
147
+ * the dispatch.
148
+ */
149
+ function extractMcpDispatchMap(): Map<string, string> {
150
+ const source = readFileSync(
151
+ new URL("./access-mcp.ts", import.meta.url),
152
+ "utf-8",
153
+ );
154
+ const blockMatch = source.match(
155
+ /MCP_MIGRATED_OPERATIONS[^{]*\{([\s\S]*?)\}/,
156
+ );
157
+ const body = blockMatch?.[1] ?? "";
158
+ const map = new Map<string, string>();
159
+ for (const m of body.matchAll(/"engram\.(\w+)"\s*:\s*"(\w+)"/g)) {
160
+ map.set(m[1]!, m[2]!);
161
+ }
162
+ return map;
163
+ }
164
+
165
+ /**
166
+ * Normalize a regex route body (e.g. `\/engram\/v1\/peers\/([^/]+)`) to the
167
+ * catalog pathname form (`/engram/v1/peers/:id`).
168
+ */
169
+ function normalizeHttpRegexRoute(src: string): string {
170
+ return src.replace(/\\\//g, "/").replace(/\(\[\^\/\]\+\)/g, ":id");
171
+ }
172
+
173
+ /**
174
+ * Statically extract a route-specific dispatch map from `access-http.ts`:
175
+ * `"${method} ${pathname}"` → set of operations dispatched via
176
+ * `getOperation("…")` within that route's handler block. Keying by
177
+ * method+pathname (not just pathname) prevents cross-method contamination
178
+ * when GET and POST share a path — a dispatch in the POST block must not
179
+ * satisfy a GET catalog entry (review P2: key HTTP dispatch coverage by
180
+ * method and path).
181
+ */
182
+ function extractHttpRouteDispatchMap(): Map<string, Set<string>> {
183
+ const source = readFileSync(
184
+ new URL("./access-http.ts", import.meta.url),
185
+ "utf-8",
186
+ );
187
+ const lines = source.split("\n");
188
+ const routeOps = new Map<string, Set<string>>();
189
+ let currentKey: string | null = null;
190
+
191
+ for (let i = 0; i < lines.length; i++) {
192
+ const line = lines[i]!;
193
+
194
+ // Detect route block start — extract pathname and method.
195
+ let pathname: string | null = null;
196
+ let isExactMatch = false;
197
+
198
+ let m = line.match(/pathname\s*===\s*"((?:\/engram|\/remnic|\/v1)\/[^"]+)"/);
199
+ if (m) {
200
+ pathname = m[1]!.replace(/^\/remnic\//, "/engram/");
201
+ isExactMatch = true;
202
+ } else {
203
+ m = line.match(/\/\^((?:\\\/engram|\\\/remnic|\\\/v1).+?)\$\/g?\.(?:exec|test)\(pathname\)/);
204
+ if (m) {
205
+ pathname = normalizeHttpRegexRoute(m[1]!);
206
+ } else {
207
+ m = line.match(/pathname\.match\(\/\^((?:\\\/engram|\\\/remnic|\\\/v1).+?)\$\/g?\)/);
208
+ if (m) {
209
+ pathname = normalizeHttpRegexRoute(m[1]!);
210
+ } else {
211
+ m = line.match(/pathname\.startsWith\("((?:\/engram)\/v1\/[^"]+)"\)/);
212
+ if (m) {
213
+ pathname = m[1]!.replace(/\/$/, "") + "/:id";
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ if (pathname) {
220
+ // Determine the HTTP method: same line first, then backward for
221
+ // exact-match routes, forward for dynamic routes. Check both positive
222
+ // (===) and negated (!==) method patterns — `req.method !== "GET"` as
223
+ // a 405 guard means the route IS GET (review P2: require exact method
224
+ // keys; do not fall back to pathname-only when method is undetectable).
225
+ const detectMethod = (text: string): string | null => {
226
+ const pos = text.match(/req\.method\s*===\s*"(\w+)"/);
227
+ if (pos) return pos[1]!;
228
+ const neg = text.match(/req\.method\s*!==\s*"(\w+)"/);
229
+ if (neg) return neg[1]!;
230
+ return null;
231
+ };
232
+ let method = detectMethod(line);
233
+ if (!method && isExactMatch) {
234
+ for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
235
+ method = detectMethod(lines[j]!);
236
+ if (method) break;
237
+ }
238
+ }
239
+ if (!method && !isExactMatch) {
240
+ for (let j = i + 1; j < Math.min(lines.length, i + 5); j++) {
241
+ if (/pathname\s*===\s*"|\/\^.*\$\/g?\.(?:exec|test)\(pathname\)|pathname\.match\(/.test(lines[j]!)) break;
242
+ method = detectMethod(lines[j]!);
243
+ if (method) break;
244
+ }
245
+ }
246
+ // If the method cannot be determined, the route block is ambiguous and
247
+ // must not be keyed — validateDispatchCoverage will flag any catalog
248
+ // entry claiming migration through it (no pathname-only fallback).
249
+ currentKey = method ? `${method} ${pathname}` : null;
250
+ if (currentKey && !routeOps.has(currentKey)) {
251
+ routeOps.set(currentKey, new Set<string>());
252
+ }
253
+ continue;
254
+ }
255
+
256
+ // Collect getOperation calls within the current route block.
257
+ if (currentKey) {
258
+ const opMatch = line.match(/getOperation\("(\w+)"\)/);
259
+ if (opMatch) {
260
+ routeOps.get(currentKey)!.add(opMatch[1]!);
261
+ }
262
+ }
263
+ }
264
+
265
+ return routeOps;
266
+ }
267
+
268
+ /**
269
+ * Pure dispatch validator — shared by the prove-fail-before test and the real
270
+ * gate. Flags a catalog entry that claims a migration (`operation !== null`)
271
+ * but whose live surface code does not dispatch through the boundary. A
272
+ * flipped catalog row without wired dispatch is a false migration.
273
+ */
274
+ interface DispatchViolation {
275
+ readonly kind:
276
+ | "mcp-no-dispatch"
277
+ | "mcp-dispatch-mismatch"
278
+ | "http-no-dispatch";
279
+ readonly detail: string;
280
+ }
281
+
282
+ function validateDispatchCoverage(
283
+ mcpCatalog: readonly McpToolEntry[],
284
+ httpCatalog: readonly HttpRouteEntry[],
285
+ mcpDispatch: ReadonlyMap<string, string>,
286
+ httpRouteDispatch: ReadonlyMap<string, ReadonlySet<string>>,
287
+ ): DispatchViolation[] {
288
+ const violations: DispatchViolation[] = [];
289
+ for (const entry of mcpCatalog) {
290
+ if (entry.operation === null) continue;
291
+ const dispatched = mcpDispatch.get(entry.tool);
292
+ if (dispatched === undefined) {
293
+ violations.push({
294
+ kind: "mcp-no-dispatch",
295
+ detail: `${entry.tool} claims migration to ${entry.operation} but has no entry in MCP_MIGRATED_OPERATIONS`,
296
+ });
297
+ } else if (dispatched !== entry.operation) {
298
+ violations.push({
299
+ kind: "mcp-dispatch-mismatch",
300
+ detail: `${entry.tool}: dispatch routes to "${dispatched}" but catalog claims "${entry.operation}"`,
301
+ });
302
+ }
303
+ }
304
+ for (const entry of httpCatalog) {
305
+ if (entry.operation === null) continue;
306
+ // Method+path specific: the getOperation("…") call must be in THIS
307
+ // method's handler block for THIS pathname. No pathname-only fallback —
308
+ // if the extractor could not determine the method, the route block is
309
+ // ambiguous and the catalog entry must not be counted as migrated
310
+ // (review P2: require exact method keys; do not fall back).
311
+ const routeKey = `${entry.method} ${entry.pathname}`;
312
+ const routeOps = httpRouteDispatch.get(routeKey);
313
+ if (!routeOps || !routeOps.has(entry.operation)) {
314
+ violations.push({
315
+ kind: "http-no-dispatch",
316
+ detail: `HTTP ${entry.method} ${entry.pathname} claims migration to ${entry.operation} but no getOperation("${entry.operation}") found in its route block in access-http.ts`,
317
+ });
318
+ }
319
+ }
320
+ return violations;
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Prove-fail-before: the validator MUST catch a seeded bypass
325
+ // ---------------------------------------------------------------------------
326
+
327
+ test("fitness validator catches an MCP tool the catalog does not know about", () => {
328
+ // Seed: advertise a fake tool that was never added to the catalog. This is
329
+ // exactly the regression the boundary exists to prevent — a new handler
330
+ // shipping with its own ad-hoc validation instead of a registry entry.
331
+ const liveWithBypass = shortNamesOf(MCP_TOOLS);
332
+ liveWithBypass.add("rogue_unregistered_tool");
333
+
334
+ const violations = validateCoverage(
335
+ MCP_TOOLS,
336
+ liveWithBypass,
337
+ new Set(listRegisteredOperations()),
338
+ );
339
+ const rogue = violations.filter((v) => v.kind === "live-tool-not-in-catalog");
340
+ assert.ok(rogue.length > 0, "validator must flag a live tool missing from the catalog");
341
+ assert.equal(rogue[0].detail, "rogue_unregistered_tool");
342
+ });
343
+
344
+ test("fitness validator catches a catalog entry claiming a migration the registry does not back", () => {
345
+ // Seed: a catalog entry claims `operation: "memory_get"` but the registry
346
+ // passed in is EMPTY — simulating a handler that flipped its catalog row
347
+ // without adding the `defineOperation` call. Passing an explicit empty set
348
+ // (rather than resetting the real registry) keeps this test isolated from
349
+ // the live pilot registrations.
350
+ const lyingCatalog: readonly McpToolEntry[] = [
351
+ ...MCP_TOOLS,
352
+ { tool: "synthetic_liar", operation: "memory_get" },
353
+ ];
354
+ const live = shortNamesOf(lyingCatalog);
355
+ const emptyRegistry = new Set<OperationName>();
356
+
357
+ const violations = validateCoverage(lyingCatalog, live, emptyRegistry);
358
+ const unregistered = violations.filter((v) => v.kind === "migrated-entry-not-registered");
359
+ assert.ok(
360
+ unregistered.some((v) => v.detail.includes("synthetic_liar")),
361
+ "validator must flag a migrated entry with no backing registration",
362
+ );
363
+ });
364
+
365
+ test("fitness validator catches a stale catalog entry (tool removed from the server)", () => {
366
+ // Seed: catalog claims a tool the server no longer advertises.
367
+ const staleCatalog: readonly McpToolEntry[] = [
368
+ ...MCP_TOOLS,
369
+ { tool: "ghost_tool_removed_from_server", operation: null },
370
+ ];
371
+ const live = shortNamesOf(MCP_TOOLS); // server does NOT advertise the ghost
372
+
373
+ const violations = validateCoverage(staleCatalog, live, new Set(listRegisteredOperations()));
374
+ const stale = violations.filter((v) => v.kind === "catalog-tool-not-live");
375
+ assert.ok(stale.length > 0, "validator must flag a catalog entry with no live tool");
376
+ });
377
+
378
+ test("dispatch validator catches a catalog entry whose operation is registered but never dispatched", () => {
379
+ // Seed: a catalog entry claims `operation: "memory_get"` — the operation IS
380
+ // registered — but the MCP dispatch map passed in is EMPTY, simulating a
381
+ // handler that flipped its catalog row without wiring dispatch through the
382
+ // boundary. Registration alone would pass the old gate; this check must
383
+ // catch the false migration (review P2: verify dispatch before counting).
384
+ const lyingCatalog: readonly McpToolEntry[] = [
385
+ { tool: "synthetic_dispatch_liar", operation: "memory_get" },
386
+ ];
387
+ const violations = validateDispatchCoverage(
388
+ lyingCatalog,
389
+ [],
390
+ new Map<string, string>(),
391
+ new Map<string, ReadonlySet<string>>(),
392
+ );
393
+ const noDispatch = violations.filter((v) => v.kind === "mcp-no-dispatch");
394
+ assert.ok(
395
+ noDispatch.some((v) => v.detail.includes("synthetic_dispatch_liar")),
396
+ "dispatch validator must flag a migrated entry with no surface dispatch wiring",
397
+ );
398
+ });
399
+
400
+ test("dispatch validator catches an HTTP route whose operation is dispatched by a different route", () => {
401
+ // Seed: catalog claims `GET /engram/v1/synthetic` migrated to `memory_get`.
402
+ // The operation IS registered, and `memory_get` IS dispatched — but by a
403
+ // DIFFERENT route (`/engram/v1/memories/:id`). The route-specific check must
404
+ // catch that `/engram/v1/synthetic` itself has no `getOperation("memory_get")`
405
+ // in its handler block. A global set would pass this false migration.
406
+ // (review P2: bind dispatch validation to method/path routes)
407
+ const lyingHttpCatalog: readonly HttpRouteEntry[] = [
408
+ { method: "GET", pathname: "/engram/v1/synthetic", operation: "memory_get" },
409
+ ];
410
+ const routeDispatch = new Map<string, ReadonlySet<string>>([
411
+ ["GET /engram/v1/memories/:id", new Set(["memory_get"])],
412
+ ]);
413
+ const violations = validateDispatchCoverage(
414
+ [],
415
+ lyingHttpCatalog,
416
+ new Map<string, string>(),
417
+ routeDispatch,
418
+ );
419
+ const noDispatch = violations.filter((v) => v.kind === "http-no-dispatch");
420
+ assert.ok(
421
+ noDispatch.some((v) => v.detail.includes("/engram/v1/synthetic")),
422
+ "dispatch validator must flag an HTTP route whose operation is dispatched by a different route",
423
+ );
424
+ });
425
+
426
+ test("dispatch validator catches cross-method contamination on a shared path", () => {
427
+ // Seed: catalog claims `GET /engram/v1/memories` migrated to `memory_store`.
428
+ // The operation IS registered, and `POST /engram/v1/memories` DOES dispatch
429
+ // `memory_store` — but the GET method's own block does not. A pathname-only
430
+ // key would find the POST block's dispatch and pass; the method+pathname key
431
+ // must reject it. (review P2: key HTTP dispatch coverage by method and path)
432
+ const lyingHttpCatalog: readonly HttpRouteEntry[] = [
433
+ { method: "GET", pathname: "/engram/v1/memories", operation: "memory_store" },
434
+ ];
435
+ const routeDispatch = new Map<string, ReadonlySet<string>>([
436
+ ["POST /engram/v1/memories", new Set(["memory_store"])],
437
+ ]);
438
+ const violations = validateDispatchCoverage(
439
+ [],
440
+ lyingHttpCatalog,
441
+ new Map<string, string>(),
442
+ routeDispatch,
443
+ );
444
+ const noDispatch = violations.filter((v) => v.kind === "http-no-dispatch");
445
+ assert.ok(
446
+ noDispatch.some((v) => v.detail.includes("GET /engram/v1/memories")),
447
+ "dispatch validator must flag a GET route whose operation is dispatched only by the POST route on the same path",
448
+ );
449
+ });
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // Real surface coverage — the gate that runs on every build
453
+ // ---------------------------------------------------------------------------
454
+
455
+ test("MCP tools/list matches the catalog exactly (no untracked handlers)", async () => {
456
+ const live = await liveMcpToolShortNames();
457
+ const violations = validateCoverage(MCP_TOOLS, live, new Set(listRegisteredOperations()));
458
+ assert.deepEqual(
459
+ violations,
460
+ [],
461
+ `surface/catalog drift detected — either update access-surface-catalog.ts or migrate the new handler:\n${formatViolations(violations)}`,
462
+ );
463
+ });
464
+
465
+ // ---------------------------------------------------------------------------
466
+ // HTTP source-completeness — static extraction from access-http.ts
467
+ // ---------------------------------------------------------------------------
468
+
469
+ /**
470
+ * The MCP surface has a live coverage test (tools/list). HTTP has no
471
+ * equivalent introspection endpoint — routes are scattered if-branches in
472
+ * EngramAccessHttpServer.handle. This test statically extracts route
473
+ * patterns from the source and compares against HTTP_ROUTES so a new
474
+ * service-invoking route cannot land without a catalog entry.
475
+ *
476
+ * Infrastructure routes (health, adapters, admin console, UI assets, MCP
477
+ * delegate) are excluded — they carry no user-validated request envelope.
478
+ */
479
+ test("HTTP handler source routes match the catalog (static completeness)", () => {
480
+ const httpSource = readFileSync(
481
+ new URL("./access-http.ts", import.meta.url),
482
+ "utf-8",
483
+ );
484
+ const lines = httpSource.split("\n");
485
+
486
+ // --- Phase 1: pathname-level extraction (catches missing pathnames) -------
487
+ const sourcePaths = new Set<string>();
488
+
489
+ for (const m of httpSource.matchAll(
490
+ /pathname\s*===\s*"((?:\/engram|\/remnic|\/v1)\/[^"]+)"/g,
491
+ )) {
492
+ sourcePaths.add(m[1]!.replace(/^\/remnic\//, "/engram/"));
493
+ }
494
+ for (const m of httpSource.matchAll(
495
+ /pathname\.startsWith\("((?:\/engram)\/v1\/[^"]+\/)"/g,
496
+ )) {
497
+ sourcePaths.add(m[1]!.replace(/\/$/, "") + "/:id");
498
+ }
499
+ const normalizeRegexRoute = (src: string): string =>
500
+ src.replace(/\\\//g, "/").replace(/\(\[\^\/\]\+\)/g, ":id");
501
+ for (const m of httpSource.matchAll(/\/\^(.+?)\$\/g?\.(?:exec|test)\(pathname\)/g)) {
502
+ const normalized = normalizeRegexRoute(m[1]!);
503
+ if (normalized.startsWith("/engram/v1/") || normalized.startsWith("/v1/")) {
504
+ sourcePaths.add(normalized);
505
+ }
506
+ }
507
+ for (const m of httpSource.matchAll(/pathname\.match\(\/\^(.+?)\$\/g?\)/g)) {
508
+ const normalized = normalizeRegexRoute(m[1]!);
509
+ if (normalized.startsWith("/engram/v1/") || normalized.startsWith("/v1/")) {
510
+ sourcePaths.add(normalized);
511
+ }
512
+ }
513
+
514
+ const INFRA = [
515
+ /^\/engram\/v1\/health$/,
516
+ /^\/engram\/v1\/adapters$/,
517
+ /^\/engram\/v1\/admin\//,
518
+ /^\/engram\/ui/,
519
+ /^\/mcp$/,
520
+ ];
521
+ const servicePaths = [...sourcePaths]
522
+ .filter((p) => !INFRA.some((re) => re.test(p)))
523
+ .sort();
524
+
525
+ const catalogPaths = new Set(HTTP_ROUTES.map((r) => r.pathname));
526
+ const missingFromCatalog = servicePaths.filter(
527
+ (p) => !catalogPaths.has(p),
528
+ );
529
+ assert.deepEqual(
530
+ missingFromCatalog,
531
+ [],
532
+ `HTTP routes found in access-http.ts but missing from HTTP_ROUTES catalog.\n` +
533
+ `Add each to access-surface-catalog.ts with operation: null (or migrate it).\n` +
534
+ `Missing:\n${missingFromCatalog.map((p) => ` ${p}`).join("\n")}`,
535
+ );
536
+
537
+ // --- Phase 2: (method, pathname) tuple extraction (catches missing methods)
538
+ // For each exact-match route, scan backward up to 5 lines for the HTTP method.
539
+ // For dynamic routes, scan forward up to 40 lines for method checks inside the block.
540
+ const sourceTuples = new Set<string>();
541
+ const isServicePath = (p: string) =>
542
+ !INFRA.some((re) => re.test(p));
543
+
544
+ for (let i = 0; i < lines.length; i++) {
545
+ const pathMatch = lines[i]!.match(
546
+ /pathname\s*===\s*"((?:\/engram|\/remnic|\/v1)\/[^"]+)"/,
547
+ );
548
+ if (pathMatch) {
549
+ const pathname = pathMatch[1]!.replace(/^\/remnic\//, "/engram/");
550
+ if (!isServicePath(pathname)) continue;
551
+ // Scan backward for the method declaration.
552
+ for (let j = i; j >= Math.max(0, i - 5); j--) {
553
+ const mMatch = lines[j]!.match(/req\.method\s*===\s*"(\w+)"/);
554
+ if (mMatch) {
555
+ sourceTuples.add(`${mMatch[1]} ${pathname}`);
556
+ break;
557
+ }
558
+ }
559
+ }
560
+ }
561
+ // Dynamic routes: scan forward for method checks.
562
+ for (const m of httpSource.matchAll(
563
+ /(?:\/\^(.+?)\$\/g?\.(?:exec|test)\(pathname\)|pathname\.match\(\/\^(.+?)\$\/g?\)|pathname\.startsWith\("((?:\/engram)\/v1\/[^"]+)"\))/g,
564
+ )) {
565
+ const raw = m[1] ?? m[2] ?? m[3];
566
+ if (!raw) continue;
567
+ let pathname: string;
568
+ if (m[3]) {
569
+ pathname = m[3].replace(/\/$/, "") + "/:id";
570
+ } else {
571
+ pathname = normalizeRegexRoute(raw!);
572
+ }
573
+ if (!pathname.startsWith("/engram/v1/") || !isServicePath(pathname)) continue;
574
+ const matchIndex = m.index!;
575
+ const matchLine = httpSource.slice(0, matchIndex).split("\n").length;
576
+ // Scan forward for method checks inside the route block.
577
+ for (let j = matchLine; j < Math.min(lines.length, matchLine + 40); j++) {
578
+ // Stop at the next route block.
579
+ if (j > matchLine && /pathname\s*===\s*"|pathname\.startsWith\(|\/\^.*\$\/g?\.(?:exec|test)\(pathname\)|pathname\.match\(/.test(lines[j]!)) {
580
+ break;
581
+ }
582
+ const methodMatch = lines[j]!.match(/req\.method\s*===\s*"(\w+)"/);
583
+ if (methodMatch) {
584
+ sourceTuples.add(`${methodMatch[1]} ${pathname}`);
585
+ }
586
+ // Also handle negated checks: `req.method !== "GET"` → the route IS GET.
587
+ // Scan within the whole block, not just the match line — some routes
588
+ // (e.g. GET /engram/v1/peers/:id/profile) use the negation as a guard
589
+ // inside the block, not on the regex-match line.
590
+ const negMethodMatch = lines[j]!.match(/req\.method\s*!==\s*"(\w+)"/);
591
+ if (negMethodMatch) {
592
+ sourceTuples.add(`${negMethodMatch[1]} ${pathname}`);
593
+ }
594
+ }
595
+ }
596
+
597
+ const catalogTuples = new Set(
598
+ HTTP_ROUTES.map((r) => `${r.method} ${r.pathname}`),
599
+ );
600
+ const missingTuples = [...sourceTuples]
601
+ .filter((t) => !catalogTuples.has(t))
602
+ .sort();
603
+ assert.deepEqual(
604
+ missingTuples,
605
+ [],
606
+ `HTTP (method, pathname) tuples found in access-http.ts but missing from HTTP_ROUTES.\n` +
607
+ `Add each to access-surface-catalog.ts.\n` +
608
+ `Missing:\n${missingTuples.map((t) => ` ${t}`).join("\n")}`,
609
+ );
610
+ });
611
+
612
+ test("every migrated MCP/HTTP entry resolves to a registered AND dispatched boundary operation", () => {
613
+ // Phase 1 — registration: the operation name must exist in the live registry.
614
+ // This catches a flipped catalog row that references an operation no one
615
+ // ever defined.
616
+ const registered = new Set(listRegisteredOperations());
617
+ for (const entry of MCP_TOOLS) {
618
+ if (entry.operation !== null) {
619
+ assert.ok(
620
+ registered.has(entry.operation),
621
+ `MCP tool ${entry.tool} claims migration to ${entry.operation} but it is not registered`,
622
+ );
623
+ }
624
+ }
625
+ for (const entry of HTTP_ROUTES) {
626
+ if (entry.operation !== null) {
627
+ assert.ok(
628
+ registered.has(entry.operation),
629
+ `HTTP ${entry.method} ${entry.pathname} claims migration to ${entry.operation} but it is not registered`,
630
+ );
631
+ }
632
+ }
633
+
634
+ // Phase 2 — dispatch: the live surface code must route through the boundary.
635
+ // Registration alone is not enough — a flipped catalog row without wired
636
+ // dispatch is a false migration. The ratchet would lower the unmigrated
637
+ // count while the handler still uses its old direct service branch.
638
+ // MCP dispatch is verified against MCP_MIGRATED_OPERATIONS; HTTP dispatch is
639
+ // verified route-specifically (getOperation("…") must be in the entry's own
640
+ // handler block, not just anywhere in the file).
641
+ // (review P2: verify dispatch before counting handlers as migrated;
642
+ // review P2: bind dispatch validation to method/path routes)
643
+ const mcpDispatch = extractMcpDispatchMap();
644
+ const httpRouteDispatch = extractHttpRouteDispatchMap();
645
+ const violations = validateDispatchCoverage(
646
+ MCP_TOOLS,
647
+ HTTP_ROUTES,
648
+ mcpDispatch,
649
+ httpRouteDispatch,
650
+ );
651
+ assert.deepEqual(
652
+ violations,
653
+ [],
654
+ `catalog entries claim migrations the surface does not dispatch through — ` +
655
+ `a handler is marked migrated but still uses its old direct service branch.\n` +
656
+ violations.map((v) => ` - [${v.kind}] ${v.detail}`).join("\n"),
657
+ );
658
+ });
659
+
660
+ test("unmigrated-handler count matches the ratchet baseline (may only decrease)", () => {
661
+ const actual = countUnmigratedHandlers();
662
+ assert.ok(
663
+ actual <= UNMIGRATED_HANDLER_BASELINE,
664
+ `unmigrated handler count grew from ${UNMIGRATED_HANDLER_BASELINE} to ${actual} — every new handler MUST go through the access boundary (issue #1525). Either migrate it or, if it carries no user input, document the exemption in access-surface-catalog.ts and bump the baseline with a justified commit.`,
665
+ );
666
+ // Equality, not just ≤, is the real gate once the baseline is set. We assert
667
+ // it explicitly so a silent catalog edit (e.g. flipping an entry to null
668
+ // during a refactor) is caught even when the count stays under the ceiling.
669
+ assert.equal(
670
+ actual,
671
+ UNMIGRATED_HANDLER_BASELINE,
672
+ `unmigrated handler count changed from ${UNMIGRATED_HANDLER_BASELINE} to ${actual} — update UNMIGRATED_HANDLER_BASELINE here AND run \`node scripts/check-ratchets.mjs --update\` to record the improvement.`,
673
+ );
674
+ });
675
+
676
+
677
+ // ---------------------------------------------------------------------------
678
+ // Boundary hook forwarding — quota parity (issue #1525 acceptance criterion)
679
+ // ---------------------------------------------------------------------------
680
+
681
+ /**
682
+ * The HTTP memory_store route enforces its write-quota atomically inside the
683
+ * service's idempotent-write lock via an `enforceWriteQuota` callback passed
684
+ * as the service call's second argument. The boundary MUST forward `ctx.hooks`
685
+ * to that second argument — silently dropping it would let writes bypass the
686
+ * quota gate, the exact regression class the #1434 Codex review locked down.
687
+ * These tests prove the pilot operation forwards the hook end-to-end.
688
+ */
689
+
690
+ test("memory_store operation forwards ctx.hooks to service.memoryStore (quota hook parity)", async () => {
691
+ const captured: { hooks?: unknown } = {};
692
+ const service = {
693
+ memoryStore: async (_request: unknown, hooks?: unknown) => {
694
+ captured.hooks = hooks;
695
+ return {
696
+ schemaVersion: 1,
697
+ operation: "memory_store",
698
+ namespace: "default",
699
+ dryRun: false,
700
+ accepted: true,
701
+ queued: false,
702
+ status: "stored",
703
+ };
704
+ },
705
+ } as unknown as EngramAccessService;
706
+
707
+ let quotaCalled = false;
708
+ const op = getOperation("memory_store");
709
+ assert.ok(op, "memory_store must be registered for the pilot");
710
+ await op.run(
711
+ { content: "quota-parity-probe", category: "fact", confidence: 0.9 },
712
+ {
713
+ service,
714
+ hooks: {
715
+ enforceWriteQuota: () => {
716
+ quotaCalled = true;
717
+ },
718
+ },
719
+ },
720
+ );
721
+ // The hooks object MUST reach the service's second argument.
722
+ assert.ok(captured.hooks, "ctx.hooks must be forwarded to service.memoryStore — dropping it silently bypasses the quota gate (#1434)");
723
+ const forwarded = captured.hooks as { enforceWriteQuota?: () => void };
724
+ assert.equal(typeof forwarded?.enforceWriteQuota, "function", "enforceWriteQuota must survive the forwarding");
725
+ // And the forwarded function must be the same callable (the service invokes
726
+ // it as `beforeExecute` inside the idempotent-write lock).
727
+ forwarded?.enforceWriteQuota?.();
728
+ assert.equal(quotaCalled, true, "the forwarded enforceWriteQuota must be invocable");
729
+ });
730
+
731
+ test("memory_store operation forwards undefined hooks when ctx.hooks is absent (CLI parity)", async () => {
732
+ // The CLI store command has no write-quota hook (one-shot process). The
733
+ // boundary must forward `undefined` cleanly so service.memoryStore's
734
+ // optional-hooks parameter stays unset, not an empty object that could
735
+ // mask a future signature change.
736
+ const captured: { hooks?: unknown } = {};
737
+ const service = {
738
+ memoryStore: async (_request: unknown, hooks?: unknown) => {
739
+ captured.hooks = hooks;
740
+ return {
741
+ schemaVersion: 1,
742
+ operation: "memory_store",
743
+ namespace: "default",
744
+ dryRun: false,
745
+ accepted: true,
746
+ queued: false,
747
+ status: "stored",
748
+ };
749
+ },
750
+ } as unknown as EngramAccessService;
751
+
752
+ const op = getOperation("memory_store");
753
+ assert.ok(op);
754
+ await op.run(
755
+ { content: "cli-parity-probe", category: "fact", confidence: 0.9 },
756
+ { service },
757
+ );
758
+ assert.equal(captured.hooks, undefined, "absent ctx.hooks must forward as undefined");
759
+ });
760
+
761
+ test("memory_get operation resolves through the boundary without hooks (read parity)", async () => {
762
+ const service = {
763
+ memoryGet: async () => ({ found: false, memoryId: "x", content: "" }),
764
+ } as unknown as EngramAccessService;
765
+ const op = getOperation("memory_get");
766
+ assert.ok(op);
767
+ const output = (await op.run(
768
+ { memoryId: "abc" },
769
+ { service },
770
+ )) as { result: { found: boolean } };
771
+ assert.equal(output.result.found, false);
772
+ });