@remnic/core 9.3.679 → 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 (161) hide show
  1. package/dist/access-boundary.d.ts +178 -0
  2. package/dist/access-boundary.js +121 -0
  3. package/dist/access-boundary.js.map +1 -0
  4. package/dist/access-cli.js +114 -100
  5. package/dist/access-cli.js.map +1 -1
  6. package/dist/access-http.d.ts +1 -1
  7. package/dist/access-http.js +47 -45
  8. package/dist/access-mcp.d.ts +1 -1
  9. package/dist/access-mcp.js +43 -41
  10. package/dist/access-operations.d.ts +127 -0
  11. package/dist/access-operations.js +115 -0
  12. package/dist/access-operations.js.map +1 -0
  13. package/dist/access-schema.d.ts +34 -34
  14. package/dist/access-schema.js +3 -3
  15. package/dist/{access-service-S9oGKPZc.d.ts → access-service-DvA6jyHL.d.ts} +1 -1
  16. package/dist/access-service.d.ts +1 -1
  17. package/dist/access-service.js +39 -39
  18. package/dist/access-surface-catalog.d.ts +125 -0
  19. package/dist/access-surface-catalog.js +162 -0
  20. package/dist/access-surface-catalog.js.map +1 -0
  21. package/dist/adapters/index.js +7 -7
  22. package/dist/adapters/registry.js +3 -3
  23. package/dist/auto-sync-5CJBJMPZ.js +1 -1
  24. package/dist/briefing.js +8 -8
  25. package/dist/causal-behavior.js +5 -5
  26. package/dist/causal-chain.js +3 -3
  27. package/dist/causal-consolidation.js +16 -16
  28. package/dist/causal-retrieval.js +3 -3
  29. package/dist/causal-trajectory.js +1 -1
  30. package/dist/{chunk-JBPKEARU.js → chunk-2QSZNTDO.js} +7 -7
  31. package/dist/{chunk-3OKWZT7F.js → chunk-3IND7N4X.js} +2 -2
  32. package/dist/{chunk-GYSYLGNE.js → chunk-7MOTEVAA.js} +2 -2
  33. package/dist/{chunk-6T4LTI2F.js → chunk-7XH7VJN4.js} +4 -4
  34. package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
  35. package/dist/{chunk-LZSMQHXC.js → chunk-ARLRTZZZ.js} +5 -5
  36. package/dist/{chunk-Q2H5U37U.js → chunk-B2B2IHUH.js} +2 -2
  37. package/dist/{chunk-SECQS4G4.js → chunk-BTVX7ZXZ.js} +5 -5
  38. package/dist/{chunk-DGEZKYVI.js → chunk-DOCTITOP.js} +4 -4
  39. package/dist/{chunk-EQYP3HA6.js → chunk-EG4TCVMU.js} +2 -2
  40. package/dist/{chunk-SLTKP5WJ.js → chunk-EW5KFXHL.js} +4 -4
  41. package/dist/{chunk-K2JYO6QV.js → chunk-FDSOMA6M.js} +28 -41
  42. package/dist/chunk-FDSOMA6M.js.map +1 -0
  43. package/dist/{chunk-CTCPB57O.js → chunk-G7Z3C2X6.js} +2 -2
  44. package/dist/{chunk-4PPMUNV5.js → chunk-H4BDNIKQ.js} +52 -52
  45. package/dist/{chunk-MTJ2LFAJ.js → chunk-H6PMGMNP.js} +2 -2
  46. package/dist/{chunk-7AAKSHDG.js → chunk-I3HSKQT7.js} +136 -136
  47. package/dist/{chunk-NXBXM7Q6.js → chunk-I75DF4FZ.js} +2 -2
  48. package/dist/{chunk-RC3AFF6Z.js → chunk-JD4SCARD.js} +1 -1
  49. package/dist/{chunk-LVTTO3VC.js → chunk-KACIOX42.js} +2 -2
  50. package/dist/{chunk-ATRB6Q25.js → chunk-KV6CX4ON.js} +2 -2
  51. package/dist/{chunk-VL5JJOOY.js → chunk-L5MUA6Q7.js} +5 -5
  52. package/dist/{chunk-PCGCQTU6.js → chunk-M4I3TREG.js} +75 -75
  53. package/dist/chunk-NHFXF4ZO.js +107 -0
  54. package/dist/chunk-NHFXF4ZO.js.map +1 -0
  55. package/dist/{chunk-MNUPGYIV.js → chunk-NQMBSSWW.js} +2 -2
  56. package/dist/{chunk-V4ZHKCGA.js → chunk-O2WELT5C.js} +5 -5
  57. package/dist/{chunk-Z6SEG36L.js → chunk-OUWAQVDJ.js} +4 -4
  58. package/dist/{chunk-57ME5VSI.js → chunk-Q5ZU3RNY.js} +4 -4
  59. package/dist/{chunk-ACYX37IM.js → chunk-QUA2JPH2.js} +6 -6
  60. package/dist/{chunk-UNZLU2MX.js → chunk-QVWM4C24.js} +37 -32
  61. package/dist/chunk-QVWM4C24.js.map +1 -0
  62. package/dist/{chunk-2AP4QJX5.js → chunk-TOQEZ63C.js} +8 -8
  63. package/dist/{chunk-EUM7CZFM.js → chunk-TY5NT3T3.js} +17 -17
  64. package/dist/{chunk-ZCVPFDHB.js → chunk-UAODC6GJ.js} +14 -14
  65. package/dist/{chunk-YJ4J2JJ2.js → chunk-UJDV2NLT.js} +9 -9
  66. package/dist/chunk-V254FAT5.js +85 -0
  67. package/dist/chunk-V254FAT5.js.map +1 -0
  68. package/dist/{chunk-3IE22DJ2.js → chunk-WEPMT6SC.js} +10 -10
  69. package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
  70. package/dist/{cli-B2Ve7R22.d.ts → cli-feUe-x3I.d.ts} +1 -1
  71. package/dist/cli.d.ts +2 -2
  72. package/dist/cli.js +74 -72
  73. package/dist/compounding/engine.js +9 -9
  74. package/dist/connectors/codex-materialize-runner.js +9 -9
  75. package/dist/connectors/index.js +9 -9
  76. package/dist/consolidation-provenance-check.js +2 -2
  77. package/dist/contradiction/index.js +4 -4
  78. package/dist/dashboard-runtime.js +2 -2
  79. package/dist/entity-retrieval.js +7 -7
  80. package/dist/extraction.js +2 -2
  81. package/dist/{first-start-migration-PG5HBC3K.js → first-start-migration-FF7YFGRP.js} +4 -4
  82. package/dist/index.d.ts +2 -2
  83. package/dist/index.js +209 -207
  84. package/dist/index.js.map +1 -1
  85. package/dist/lcm/engine.js +4 -4
  86. package/dist/lcm/index.js +12 -12
  87. package/dist/maintenance/memory-governance.js +8 -8
  88. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  89. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  90. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  91. package/dist/namespaces/migrate.js +17 -17
  92. package/dist/namespaces/search.js +8 -8
  93. package/dist/namespaces/storage.js +8 -8
  94. package/dist/operator-toolkit.js +22 -22
  95. package/dist/orchestrator.js +70 -70
  96. package/dist/resume-bundles.js +1 -1
  97. package/dist/schemas.d.ts +50 -50
  98. package/dist/search/factory.js +7 -7
  99. package/dist/search/index.js +11 -11
  100. package/dist/search/lancedb-backend.js +3 -3
  101. package/dist/search/meilisearch-backend.js +3 -3
  102. package/dist/search/orama-backend.js +3 -3
  103. package/dist/semantic-consolidation.js +11 -11
  104. package/dist/semantic-rule-promotion.js +7 -7
  105. package/dist/semantic-rule-verifier.js +8 -8
  106. package/dist/storage.js +6 -6
  107. package/dist/transfer/backup.js +2 -2
  108. package/dist/transfer/capsule-export.js +2 -2
  109. package/dist/transfer/capsule-import.js +1 -1
  110. package/dist/transfer/import-sqlite.js +2 -2
  111. package/dist/transfer/types.d.ts +38 -38
  112. package/dist/utils/serialize-mutations.d.ts +122 -0
  113. package/dist/utils/serialize-mutations.js +287 -0
  114. package/dist/utils/serialize-mutations.js.map +1 -0
  115. package/dist/verified-recall.js +8 -8
  116. package/package.json +12 -2
  117. package/src/access-boundary.test.ts +212 -0
  118. package/src/access-boundary.ts +235 -0
  119. package/src/access-cli.ts +32 -15
  120. package/src/access-http.ts +38 -28
  121. package/src/access-mcp.ts +41 -35
  122. package/src/access-operations.ts +157 -0
  123. package/src/access-surface-catalog.test.ts +772 -0
  124. package/src/access-surface-catalog.ts +218 -0
  125. package/src/utils/serialize-mutations.test.ts +1047 -0
  126. package/src/utils/serialize-mutations.ts +679 -0
  127. package/dist/chunk-K2JYO6QV.js.map +0 -1
  128. package/dist/chunk-UNZLU2MX.js.map +0 -1
  129. /package/dist/{chunk-JBPKEARU.js.map → chunk-2QSZNTDO.js.map} +0 -0
  130. /package/dist/{chunk-3OKWZT7F.js.map → chunk-3IND7N4X.js.map} +0 -0
  131. /package/dist/{chunk-GYSYLGNE.js.map → chunk-7MOTEVAA.js.map} +0 -0
  132. /package/dist/{chunk-6T4LTI2F.js.map → chunk-7XH7VJN4.js.map} +0 -0
  133. /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
  134. /package/dist/{chunk-LZSMQHXC.js.map → chunk-ARLRTZZZ.js.map} +0 -0
  135. /package/dist/{chunk-Q2H5U37U.js.map → chunk-B2B2IHUH.js.map} +0 -0
  136. /package/dist/{chunk-SECQS4G4.js.map → chunk-BTVX7ZXZ.js.map} +0 -0
  137. /package/dist/{chunk-DGEZKYVI.js.map → chunk-DOCTITOP.js.map} +0 -0
  138. /package/dist/{chunk-EQYP3HA6.js.map → chunk-EG4TCVMU.js.map} +0 -0
  139. /package/dist/{chunk-SLTKP5WJ.js.map → chunk-EW5KFXHL.js.map} +0 -0
  140. /package/dist/{chunk-CTCPB57O.js.map → chunk-G7Z3C2X6.js.map} +0 -0
  141. /package/dist/{chunk-4PPMUNV5.js.map → chunk-H4BDNIKQ.js.map} +0 -0
  142. /package/dist/{chunk-MTJ2LFAJ.js.map → chunk-H6PMGMNP.js.map} +0 -0
  143. /package/dist/{chunk-7AAKSHDG.js.map → chunk-I3HSKQT7.js.map} +0 -0
  144. /package/dist/{chunk-NXBXM7Q6.js.map → chunk-I75DF4FZ.js.map} +0 -0
  145. /package/dist/{chunk-RC3AFF6Z.js.map → chunk-JD4SCARD.js.map} +0 -0
  146. /package/dist/{chunk-LVTTO3VC.js.map → chunk-KACIOX42.js.map} +0 -0
  147. /package/dist/{chunk-ATRB6Q25.js.map → chunk-KV6CX4ON.js.map} +0 -0
  148. /package/dist/{chunk-VL5JJOOY.js.map → chunk-L5MUA6Q7.js.map} +0 -0
  149. /package/dist/{chunk-PCGCQTU6.js.map → chunk-M4I3TREG.js.map} +0 -0
  150. /package/dist/{chunk-MNUPGYIV.js.map → chunk-NQMBSSWW.js.map} +0 -0
  151. /package/dist/{chunk-V4ZHKCGA.js.map → chunk-O2WELT5C.js.map} +0 -0
  152. /package/dist/{chunk-Z6SEG36L.js.map → chunk-OUWAQVDJ.js.map} +0 -0
  153. /package/dist/{chunk-57ME5VSI.js.map → chunk-Q5ZU3RNY.js.map} +0 -0
  154. /package/dist/{chunk-ACYX37IM.js.map → chunk-QUA2JPH2.js.map} +0 -0
  155. /package/dist/{chunk-2AP4QJX5.js.map → chunk-TOQEZ63C.js.map} +0 -0
  156. /package/dist/{chunk-EUM7CZFM.js.map → chunk-TY5NT3T3.js.map} +0 -0
  157. /package/dist/{chunk-ZCVPFDHB.js.map → chunk-UAODC6GJ.js.map} +0 -0
  158. /package/dist/{chunk-YJ4J2JJ2.js.map → chunk-UJDV2NLT.js.map} +0 -0
  159. /package/dist/{chunk-3IE22DJ2.js.map → chunk-WEPMT6SC.js.map} +0 -0
  160. /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
  161. /package/dist/{first-start-migration-PG5HBC3K.js.map → first-start-migration-FF7YFGRP.js.map} +0 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Single input-validation and error boundary for the CLI/MCP/HTTP access
3
+ * surfaces (issue #1525, epic #1520 Phase 1).
4
+ *
5
+ * Every operation that crosses the access-service facade passes through ONE
6
+ * registry entry: a zod-validated request envelope, a shared error mapper,
7
+ * and the "reject invalid input and list valid options" behavior that
8
+ * CLAUDE.md rules 14/17/24/28/36/48/51 previously had to be re-implemented
9
+ * per handler. The three surfaces become thin adapters — one operation
10
+ * definition, three transports — so a validation fix lands everywhere at
11
+ * once.
12
+ *
13
+ * Host-agnostic (rule 31): operation names carry no `openclaw-*`/`engram-*`
14
+ * prefix. Session/namespace tenancy stays in the handler layer (resolved via
15
+ * ScopePlan #1521); the boundary validates SHAPE, not tenancy.
16
+ */
17
+
18
+ import { z } from "zod";
19
+ import { EngramAccessInputError, type EngramAccessService } from "./access-service.js";
20
+ import { expandTildePath } from "./utils/path.js";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Canonical operation names — host-agnostic (rule 31)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Canonical operation ids. One id is shared by the MCP tool, the HTTP route,
28
+ * and the CLI command that expose the same operation. Add to this union as
29
+ * each domain-group migration PR (memory ops → connectors → namespaces …)
30
+ * lands; the fitness test in `access-surface-catalog.test.ts` treats the
31
+ * registered set as the migration state.
32
+ */
33
+ export type OperationName =
34
+ | "memory_get"
35
+ | "memory_search"
36
+ | "memory_store";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Operation context — what every handler receives
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Per-call context. `service` is the facade the handler delegates to; the
44
+ * boundary never reaches past it. `authenticatedPrincipal` is resolved by
45
+ * the SURFACE (MCP header / HTTP identity / CLI flag) before the boundary
46
+ * runs, so handlers stay principal-source-agnostic. `hooks` carries
47
+ * transport-level callbacks (e.g. HTTP write-quota enforcement) that must
48
+ * fire atomically inside the service call; surfaces that have no such hook
49
+ * leave it undefined.
50
+ */
51
+ export interface OperationContext {
52
+ readonly service: EngramAccessService;
53
+ readonly authenticatedPrincipal?: string;
54
+ readonly hooks?: OperationHooks;
55
+ }
56
+
57
+ /**
58
+ * Transport-level callbacks a handler forwards into the service call. Kept
59
+ * narrow on purpose: the boundary owns validation + dispatch shape, not
60
+ * transport policy. Add fields here only when a surface genuinely needs a
61
+ * callback the service itself consumes.
62
+ */
63
+ export interface OperationHooks {
64
+ /** HTTP write-quota gate; throws to reject the write when exhausted. */
65
+ readonly enforceWriteQuota?: () => void | Promise<void>;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Operation spec + bound operation
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export interface OperationSpec<In, Out> {
73
+ /** Canonical operation id; matches an {@link OperationName}. */
74
+ readonly name: OperationName;
75
+ readonly description: string;
76
+ /** Zod schema validating the raw request envelope. */
77
+ readonly schema: z.ZodType<In>;
78
+ /** Handler invoked with the parsed input; throws EngramAccessInputError for domain faults. */
79
+ readonly handler: (input: In, ctx: OperationContext) => Promise<Out>;
80
+ }
81
+
82
+ export interface BoundOperation<In = unknown, Out = unknown> {
83
+ readonly spec: OperationSpec<In, Out>;
84
+ /** Validate the raw envelope, then invoke the handler. Throws EngramAccessInputError on any validation failure. */
85
+ readonly run: (rawInput: unknown, ctx: OperationContext) => Promise<Out>;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Shared normalizers the boundary owns (rules 17, 28, 36, 48, 51)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Coerce boolean-like strings at the edge (rule 36). Accepts actual booleans
94
+ * and the string spellings clients send ("true"/"false"/"1"/"0"/"yes"/"no"/
95
+ * "on"/"off", case-insensitive). Rejects anything else loudly — `Boolean("false")`
96
+ * would silently be `true`, which is the bug rule 36 exists to prevent.
97
+ *
98
+ * `undefined`/`null`/`""` → `undefined`, so callers can keep treating an
99
+ * absent flag as "use the default" without a separate presence check.
100
+ */
101
+ export function coerceBooleanLike(value: unknown): boolean | undefined {
102
+ if (value === undefined || value === null || value === "") return undefined;
103
+ if (typeof value === "boolean") return value;
104
+ if (typeof value === "string") {
105
+ const lower = value.trim().toLowerCase();
106
+ if (lower === "true" || lower === "1" || lower === "yes" || lower === "on") return true;
107
+ if (lower === "false" || lower === "0" || lower === "no" || lower === "off") return false;
108
+ }
109
+ throw new EngramAccessInputError(
110
+ `expected a boolean-like value (true|false|1|0|yes|no|on|off); got ${JSON.stringify(value)}`,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Coerce + validate a positive integer from a numeric string or number
116
+ * (rule 28). Loosely-typed MCP/CLI clients send `"5"`; `typeof saved === "number"`
117
+ * on read-back would reject it later, so we coerce at the edge and reject
118
+ * booleans/objects loudly (`Number(true) === 1` would silently pass otherwise).
119
+ *
120
+ * `undefined`/`null`/`""` → `undefined`.
121
+ */
122
+ export function coercePositiveInteger(value: unknown, label: string): number | undefined {
123
+ if (value === undefined || value === null || value === "") return undefined;
124
+ if (typeof value === "number") {
125
+ if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) {
126
+ throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);
127
+ }
128
+ return value;
129
+ }
130
+ if (typeof value === "string") {
131
+ const trimmed = value.trim();
132
+ if (!/^[+-]?\d+$/.test(trimmed)) {
133
+ throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);
134
+ }
135
+ const parsed = Number(trimmed);
136
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
137
+ throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);
138
+ }
139
+ return parsed;
140
+ }
141
+ throw new EngramAccessInputError(`${label} expects a positive integer; got ${JSON.stringify(value)}`);
142
+ }
143
+
144
+ /**
145
+ * Expand `~` in a path-shaped input (rule 17). Node `fs` does NOT expand `~`;
146
+ * ad-hoc regex drifts. `undefined`/`null`/`""` → `undefined`.
147
+ */
148
+ export function normalizeOptionalPath(value: unknown): string | undefined {
149
+ if (value === undefined || value === null || value === "") return undefined;
150
+ if (typeof value !== "string") {
151
+ throw new EngramAccessInputError(`expected a path string; got ${JSON.stringify(value)}`);
152
+ }
153
+ return expandTildePath(value);
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Error formatting — rule 51: list valid options, never silently default
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Turn a zod failure into an {@link EngramAccessInputError} whose message
162
+ * names the offending field and — for enum/union issues — lists the valid
163
+ * options, so the caller can correct rather than guess (rule 51).
164
+ */
165
+ export function formatZodIssues(error: z.ZodError): string {
166
+ const parts: string[] = [];
167
+ for (const issue of error.issues) {
168
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
169
+ const options = enumOptionsFromIssue(issue);
170
+ const suffix = options ? `. Valid: ${options.join(", ")}` : "";
171
+ parts.push(`${path}: ${issue.message}${suffix}`);
172
+ }
173
+ return parts.length > 0
174
+ ? `request validation failed: ${parts.join("; ")}`
175
+ : "request validation failed";
176
+ }
177
+
178
+ function enumOptionsFromIssue(issue: z.ZodIssue): readonly string[] | undefined {
179
+ // zod exposes accepted enum values on the issue for ZodEnum / ZodNativeEnum
180
+ // and on the options of invalid_union discriminators. Reading them here
181
+ // keeps "list valid options" in ONE place rather than per handler.
182
+ if (issue.code === z.ZodIssueCode.invalid_enum_value) {
183
+ const rawOptions = (issue as { options?: unknown }).options;
184
+ if (Array.isArray(rawOptions)) {
185
+ return rawOptions.map((opt) => String(opt));
186
+ }
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Registry
193
+ // ---------------------------------------------------------------------------
194
+
195
+ const registry = new Map<OperationName, BoundOperation>();
196
+
197
+ /**
198
+ * Register an operation. Throws if the name is already registered — duplicate
199
+ * registration is a programming error, not a runtime input fault, so it throws
200
+ * a plain Error (not the input-error class surfaces translate for clients).
201
+ */
202
+ export function defineOperation<In, Out>(spec: OperationSpec<In, Out>): BoundOperation<In, Out> {
203
+ if (registry.has(spec.name)) {
204
+ throw new Error(`access-boundary: operation already registered: ${spec.name}`);
205
+ }
206
+ const bound: BoundOperation<In, Out> = {
207
+ spec,
208
+ run: async (rawInput, ctx) => {
209
+ const parseResult = spec.schema.safeParse(rawInput);
210
+ if (!parseResult.success) {
211
+ throw new EngramAccessInputError(formatZodIssues(parseResult.error));
212
+ }
213
+ return spec.handler(parseResult.data, ctx);
214
+ },
215
+ };
216
+ // Store under the canonical name; the cast is safe because In/Out are
217
+ // erased at the registry boundary and recovered by callers via getOperation.
218
+ registry.set(spec.name, bound as unknown as BoundOperation);
219
+ return bound;
220
+ }
221
+
222
+ /** Look up a registered operation by canonical name. */
223
+ export function getOperation(name: OperationName): BoundOperation | undefined {
224
+ return registry.get(name);
225
+ }
226
+
227
+ /** All registered operation names. */
228
+ export function listRegisteredOperations(): readonly OperationName[] {
229
+ return [...registry.keys()];
230
+ }
231
+
232
+ /** Test-only: clear the registry so pilot definitions can be re-registered. */
233
+ export function __resetRegistryForTest(): void {
234
+ registry.clear();
235
+ }
package/src/access-cli.ts CHANGED
@@ -7,6 +7,10 @@ import { EngramAccessService } from "./access-service.js";
7
7
  import { readEnvVar, resolveHomeDir } from "./runtime/env.js";
8
8
  import { resolvePluginEntry } from "./plugin-entry-resolver.js";
9
9
  import { expandTildePath } from "./utils/path.js";
10
+ import { getOperation } from "./access-boundary.js";
11
+ // Importing access-operations registers the pilot boundary operations as a
12
+ // side effect; the store command dispatches through the registry (issue #1525).
13
+ import "./access-operations.js";
10
14
 
11
15
  const OPENCLAW_REMNIC_PLUGIN_IDS = ["openclaw-remnic", "openclaw-engram"] as const;
12
16
 
@@ -428,21 +432,34 @@ async function runStore(args: ParsedArgs, preferredId?: string): Promise<void> {
428
432
  };
429
433
 
430
434
  const { config, service } = buildRuntime(preferredId);
431
- const result = await service.memoryStore({
432
- namespace: storeArgs.namespace,
433
- sessionKey: storeArgs.sessionKey,
434
- authenticatedPrincipal: getLastOption(args, "principal") ?? config.agentAccessHttp.principal,
435
- content: storeArgs.content,
436
- category: storeArgs.category,
437
- confidence: storeArgs.confidence,
438
- tags: storeArgs.tags,
439
- entityRef: storeArgs.entityRef,
440
- ttl: storeArgs.ttl,
441
- sourceReason: storeArgs.sourceReason,
442
- idempotencyKey: storeArgs.idempotencyKey,
443
- dryRun: storeArgs.dryRun,
444
- });
445
- console.log(JSON.stringify(result, null, 2));
435
+ // Migrated through the access boundary (issue #1525): the store command
436
+ // dispatches through the same registry entry as the MCP tool and HTTP
437
+ // route, so validation/normalization is owned in ONE place. The CLI has no
438
+ // write-quota hook (it is a one-shot process), so no hooks are forwarded.
439
+ const op = getOperation("memory_store");
440
+ if (!op) {
441
+ throw new Error("access-boundary: operation not registered: memory_store");
442
+ }
443
+ const output = (await op.run(
444
+ {
445
+ namespace: storeArgs.namespace,
446
+ sessionKey: storeArgs.sessionKey,
447
+ content: storeArgs.content,
448
+ category: storeArgs.category,
449
+ confidence: storeArgs.confidence,
450
+ tags: storeArgs.tags,
451
+ entityRef: storeArgs.entityRef,
452
+ ttl: storeArgs.ttl,
453
+ sourceReason: storeArgs.sourceReason,
454
+ idempotencyKey: storeArgs.idempotencyKey,
455
+ dryRun: storeArgs.dryRun,
456
+ },
457
+ {
458
+ service,
459
+ authenticatedPrincipal: getLastOption(args, "principal") ?? config.agentAccessHttp.principal,
460
+ },
461
+ )) as { result: unknown };
462
+ console.log(JSON.stringify(output.result, null, 2));
446
463
  }
447
464
 
448
465
  function expandOptionalPath(value: string | undefined): string | undefined {
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import { fileURLToPath, URL } from "node:url";
8
8
  import { gunzipSync } from "node:zlib";
9
9
  import { log } from "./logger.js";
10
- import { EngramAccessInputError, type EngramAccessService } from "./access-service.js";
10
+ import { EngramAccessInputError, type EngramAccessService, type EngramAccessMemoryResponse, type EngramAccessWriteResponse } from "./access-service.js";
11
11
  import { WearablesInputError } from "./wearables/errors.js";
12
12
  import { EngramMcpServer } from "./access-mcp.js";
13
13
  import { validateRequest, type SchemaName, type SchemaTypeFor } from "./access-schema.js";
@@ -27,6 +27,11 @@ import {
27
27
  } from "./graph-events.js";
28
28
  import { expandTildePath } from "./utils/path.js";
29
29
  import { projectTagProjectId } from "./coding/coding-namespace.js";
30
+ import { getOperation } from "./access-boundary.js";
31
+ // Importing access-operations registers the pilot boundary operations
32
+ // (memory_get / memory_store) as a side effect; the HTTP handlers below
33
+ // dispatch the migrated routes through the registry (issue #1525).
34
+ import "./access-operations.js";
30
35
 
31
36
  export interface EngramAccessHttpServerOptions {
32
37
  service: EngramAccessService;
@@ -1297,35 +1302,28 @@ export class EngramAccessHttpServer {
1297
1302
  }
1298
1303
 
1299
1304
  if (req.method === "POST" && pathname === "/engram/v1/memories") {
1305
+ // Migrated through the access boundary (issue #1525): the registry
1306
+ // entry owns schema validation, normalization, and service dispatch.
1307
+ // The HTTP transport resolves the request-scoped namespace and principal
1308
+ // BEFORE the boundary re-validates the cleaned envelope. The write-quota
1309
+ // hook is forwarded via ctx.hooks so it still fires atomically inside
1310
+ // the service's idempotent-write lock — never before, never on a replay
1311
+ // (#1434 invariant preserved by the boundary migration).
1300
1312
  const body = await this.readValidatedBody(req, "memoryStore");
1301
- const request = {
1302
- schemaVersion: body.schemaVersion,
1303
- idempotencyKey: body.idempotencyKey,
1304
- dryRun: body.dryRun === true,
1305
- sessionKey: body.sessionKey,
1306
- authenticatedPrincipal: this.resolveRequestPrincipal(req),
1307
- content: body.content,
1308
- category: body.category,
1309
- confidence: body.confidence,
1313
+ const envelope = {
1314
+ ...body,
1310
1315
  namespace: this.resolveNamespace(req, body.namespace),
1311
- tags: body.tags,
1312
- entityRef: body.entityRef,
1313
- ttl: body.ttl,
1314
- sourceReason: body.sourceReason,
1315
- cwd: body.cwd,
1316
- projectTag: body.projectTag,
1317
1316
  };
1318
- // Rate-limit enforcement is SOLELY authoritative inside memoryStore via
1319
- // enforceWriteQuota: it runs atomically with the real idempotency-miss
1320
- // determination (and the real resolved namespace), and is never invoked
1321
- // for a replay. We deliberately do NOT pre-check here: the write namespace
1322
- // is resolved from mutable session/git context, so a stale peek could
1323
- // report "miss" for a request that is actually an idempotent replay in the
1324
- // now-scoped namespace and hard-reject a safe replay with 429 (#1434 Codex
1325
- // review). Letting the in-lock hook be the only hard gate avoids that.
1326
- const response = await this.service.memoryStore(request, {
1327
- enforceWriteQuota: () => this.ensureWriteRateLimitAvailable(),
1328
- });
1317
+ const op = getOperation("memory_store");
1318
+ if (!op) {
1319
+ throw new EngramAccessInputError("access-boundary: operation not registered: memory_store");
1320
+ }
1321
+ const output = (await op.run(envelope, {
1322
+ service: this.service,
1323
+ authenticatedPrincipal: this.resolveRequestPrincipal(req),
1324
+ hooks: { enforceWriteQuota: () => this.ensureWriteRateLimitAvailable() },
1325
+ })) as { result: EngramAccessWriteResponse };
1326
+ const response = output.result;
1329
1327
  if (this.shouldCountWriteRateLimit(response as { dryRun?: boolean; idempotencyReplay?: boolean })) {
1330
1328
  this.recordWriteRateLimitHit();
1331
1329
  }
@@ -1387,7 +1385,19 @@ export class EngramAccessHttpServer {
1387
1385
  if (req.method === "GET" && memoryMatch) {
1388
1386
  const memoryId = decodeURIComponent(memoryMatch[1] ?? "");
1389
1387
  const namespace = parsed.searchParams.get("namespace") ?? undefined;
1390
- const response = await this.service.memoryGet(memoryId, namespace, this.resolveRequestPrincipal(req));
1388
+ // Migrated through the access boundary (issue #1525): the registry
1389
+ // entry owns memoryId presence/shape validation (rule 51: reject empty
1390
+ // ids loudly instead of silently passing "" into the service) and the
1391
+ // service dispatch.
1392
+ const op = getOperation("memory_get");
1393
+ if (!op) {
1394
+ throw new EngramAccessInputError("access-boundary: operation not registered: memory_get");
1395
+ }
1396
+ const output = (await op.run(
1397
+ { memoryId, namespace: namespace ?? null },
1398
+ { service: this.service, authenticatedPrincipal: this.resolveRequestPrincipal(req) },
1399
+ )) as { result: EngramAccessMemoryResponse };
1400
+ const response = output.result;
1391
1401
  this.respondJson(res, response.found ? 200 : 404, response);
1392
1402
  return;
1393
1403
  }
package/src/access-mcp.ts CHANGED
@@ -9,11 +9,15 @@ import {
9
9
  type CapsuleImportRequest,
10
10
  type CapsuleListRequest,
11
11
  type DaySummaryRequest,
12
- type MemoryStoreRequest,
13
12
  type SchemaName,
14
13
  type SchemaTypeFor,
15
14
  type SuggestionSubmitRequest,
16
15
  } from "./access-schema.js";
16
+ // Importing access-operations registers the pilot boundary operations
17
+ // (memory_get / memory_search / memory_store) as a side effect; callTool
18
+ // dispatches migrated tools through the registry (issue #1525).
19
+ import { getOperation, type OperationName } from "./access-boundary.js";
20
+ import "./access-operations.js";
17
21
  import { readEnvVar } from "./runtime/env.js";
18
22
  import type { RecallDisclosure, RecallPlanMode } from "./types.js";
19
23
  import { validateBriefingFormat } from "./briefing.js";
@@ -94,6 +98,19 @@ function withToolAliases(tool: McpTool, emitLegacyTools = true): McpTool[] {
94
98
  return emitLegacyTools ? [canonicalTool, tool] : [canonicalTool];
95
99
  }
96
100
 
101
+ /**
102
+ * MCP tool name (legacy `engram.*` form, since {@link toLegacyToolName}
103
+ * canonicalizes incoming calls) → boundary operation it dispatches through.
104
+ * A tool appears here once its `access-operations.ts` registration lands and
105
+ * its surface-local validation is deleted. The fitness test
106
+ * (`access-surface-catalog.test.ts`) asserts this map and the catalog agree.
107
+ */
108
+ const MCP_MIGRATED_OPERATIONS: Readonly<Record<string, OperationName>> = {
109
+ "engram.memory_get": "memory_get",
110
+ "engram.memory_search": "memory_search",
111
+ "engram.memory_store": "memory_store",
112
+ };
113
+
97
114
  function resolveChatGptInspectorRecallSessionKey(
98
115
  explicitSessionKey: string | undefined,
99
116
  authenticatedPrincipal: string | undefined,
@@ -2278,6 +2295,29 @@ export class EngramMcpServer {
2278
2295
  }
2279
2296
 
2280
2297
  private async callTool(name: string, args: Record<string, unknown>, effectivePrincipal?: string, mcpSessionId?: string): Promise<unknown> {
2298
+ // Migrated operations dispatch through the access boundary (issue #1525):
2299
+ // one registry entry owns schema validation, normalization (rules
2300
+ // 17/28/36/48/51), and error mapping for every surface. The switch
2301
+ // below still serves the not-yet-migrated tools. memory_store keeps its
2302
+ // parseMcpRequest strict-keys guard (MCP transport contract) before the
2303
+ // boundary re-validates the cleaned body.
2304
+ const migrated = MCP_MIGRATED_OPERATIONS[toLegacyToolName(name)];
2305
+ if (migrated) {
2306
+ const op = getOperation(migrated);
2307
+ if (!op) {
2308
+ throw new EngramAccessInputError(`access-boundary: operation not registered: ${migrated}`);
2309
+ }
2310
+ const envelope =
2311
+ migrated === "memory_store" ? parseMcpRequest("memoryStore", args) : args;
2312
+ // The registry erases In/Out at the Map boundary; the caller knows the
2313
+ // concrete output shape ({ result: <service response> }) so the cast is
2314
+ // the type-erasure seam, not a leap of faith.
2315
+ const output = (await op.run(envelope, {
2316
+ service: this.service,
2317
+ authenticatedPrincipal: effectivePrincipal,
2318
+ })) as { result: unknown };
2319
+ return output.result;
2320
+ }
2281
2321
  switch (toLegacyToolName(name)) {
2282
2322
  case "engram.recall": {
2283
2323
  // Forward `disclosure` only when the caller actually supplied it,
@@ -2744,12 +2784,6 @@ export class EngramMcpServer {
2744
2784
  },
2745
2785
  effectivePrincipal,
2746
2786
  );
2747
- case "engram.memory_get":
2748
- return this.service.memoryGet(
2749
- typeof args.memoryId === "string" ? args.memoryId : "",
2750
- typeof args.namespace === "string" ? args.namespace : undefined,
2751
- effectivePrincipal,
2752
- );
2753
2787
  case "engram.memory_timeline": {
2754
2788
  const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : 200;
2755
2789
  return this.service.memoryTimeline(
@@ -2759,26 +2793,6 @@ export class EngramMcpServer {
2759
2793
  effectivePrincipal,
2760
2794
  );
2761
2795
  }
2762
- case "engram.memory_store": {
2763
- const body: MemoryStoreRequest = parseMcpRequest("memoryStore", args);
2764
- return this.service.memoryStore({
2765
- schemaVersion: body.schemaVersion,
2766
- idempotencyKey: body.idempotencyKey,
2767
- dryRun: body.dryRun,
2768
- sessionKey: body.sessionKey,
2769
- authenticatedPrincipal: effectivePrincipal,
2770
- content: body.content,
2771
- category: body.category,
2772
- confidence: body.confidence,
2773
- namespace: body.namespace,
2774
- tags: body.tags,
2775
- entityRef: body.entityRef,
2776
- ttl: body.ttl,
2777
- sourceReason: body.sourceReason,
2778
- cwd: body.cwd,
2779
- projectTag: body.projectTag,
2780
- });
2781
- }
2782
2796
  case "engram.suggestion_submit": {
2783
2797
  const body: SuggestionSubmitRequest = parseMcpRequest("suggestionSubmit", args);
2784
2798
  return this.service.suggestionSubmit({
@@ -3017,14 +3031,6 @@ export class EngramMcpServer {
3017
3031
  expectedGuidelineVersion: typeof args.expectedGuidelineVersion === "number" ? args.expectedGuidelineVersion : undefined,
3018
3032
  });
3019
3033
  // ── Memory search & debug tools ──────────────────────────────────
3020
- case "engram.memory_search":
3021
- return this.service.memorySearch({
3022
- query: typeof args.query === "string" ? args.query : "",
3023
- namespace: typeof args.namespace === "string" ? args.namespace : undefined,
3024
- maxResults: typeof args.maxResults === "number" && Number.isFinite(args.maxResults) ? args.maxResults : undefined,
3025
- collection: typeof args.collection === "string" ? args.collection : undefined,
3026
- principal: effectivePrincipal,
3027
- });
3028
3034
  case "engram.memory_profile":
3029
3035
  return this.service.memoryProfile(
3030
3036
  typeof args.namespace === "string" ? args.namespace : undefined,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Pilot operation definitions for the access boundary (issue #1525).
3
+ *
4
+ * Three operations migrate through the registry in this PR — `memory_get`,
5
+ * `memory_search`, and the `memory_store` write op — so the boundary's
6
+ * normalization matrix (rules 17/28/36/48/51) and shared error mapping reach
7
+ * MCP, HTTP, and CLI from one place. Domain-group migrations (memory ops →
8
+ * connectors → namespaces …) land as follow-up PRs that add `defineOperation`
9
+ * calls here and delete the surface-local validation they replace.
10
+ *
11
+ * Importing this module for its side effects registers the pilot operations;
12
+ * surfaces then dispatch via {@link getOperation} from `./access-boundary.js`.
13
+ */
14
+
15
+ import { z } from "zod";
16
+
17
+ import { defineOperation } from "./access-boundary.js";
18
+ import { memoryStoreRequestSchema, type MemoryStoreRequest } from "./access-schema.js";
19
+ import type {
20
+ EngramAccessMemoryResponse,
21
+ EngramAccessWriteResponse,
22
+ } from "./access-service.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // memory_get — fetch one memory by id
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * `memoryId` is required and non-empty (rule 51: the MCP dispatcher previously
30
+ * fell back to `typeof args.memoryId === "string" ? args.memoryId : ""`,
31
+ * silently passing an empty id into the service). `namespace` is
32
+ * `.nullable().optional()` because MCP clients send `null` (gotcha #2).
33
+ * `namespace` has no `.min(1)` because the pre-boundary handlers forwarded
34
+ * empty/whitespace strings, and `resolveNamespace` treats empty identically
35
+ * to absent (both trim to falsy → default namespace). Rejecting empty would
36
+ * break HTTP callers that send a bare `?namespace=` (Cursor review).
37
+ */
38
+ const memoryGetSchema = z.object({
39
+ memoryId: z.string().trim().min(1, "memoryId is required").max(512),
40
+ namespace: z.string().trim().max(256).nullable().optional(),
41
+ });
42
+
43
+ export interface MemoryGetInput {
44
+ readonly memoryId: string;
45
+ readonly namespace?: string | null;
46
+ }
47
+
48
+ export interface MemoryGetOutput {
49
+ readonly result: EngramAccessMemoryResponse;
50
+ }
51
+
52
+ export const memoryGetOperation = defineOperation<MemoryGetInput, MemoryGetOutput>({
53
+ name: "memory_get",
54
+ description: "Fetch one memory by id.",
55
+ schema: memoryGetSchema,
56
+ handler: async (input, ctx) => {
57
+ const result = await ctx.service.memoryGet(
58
+ input.memoryId,
59
+ input.namespace ?? undefined,
60
+ ctx.authenticatedPrincipal,
61
+ );
62
+ return { result };
63
+ },
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // memory_search — semantic search across memories
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const memorySearchSchema = z.object({
71
+ query: z.string().trim().min(1, "query is required").max(2048),
72
+ namespace: z.string().trim().max(256).nullable().optional(),
73
+ // No upper cap: the pre-boundary MCP handler forwarded any finite number to
74
+ // memorySearch, and the QMD/search backends honor large limits. Capping at
75
+ // 100 would reject existing clients that request larger result sets.
76
+ maxResults: z.number().int().min(1).nullable().optional(),
77
+ collection: z.string().trim().min(1).max(256).nullable().optional(),
78
+ });
79
+
80
+ export interface MemorySearchInput {
81
+ readonly query: string;
82
+ readonly namespace?: string | null;
83
+ readonly maxResults?: number | null;
84
+ readonly collection?: string | null;
85
+ }
86
+
87
+ export interface MemorySearchOutput {
88
+ readonly result: {
89
+ readonly query: string;
90
+ readonly results: ReadonlyArray<{ path: string; score: number; snippet: string }>;
91
+ readonly count: number;
92
+ };
93
+ }
94
+
95
+ export const memorySearchOperation = defineOperation<MemorySearchInput, MemorySearchOutput>({
96
+ name: "memory_search",
97
+ description: "Search memories across readable namespaces.",
98
+ schema: memorySearchSchema,
99
+ handler: async (input, ctx) => {
100
+ const result = await ctx.service.memorySearch({
101
+ query: input.query,
102
+ namespace: input.namespace ?? undefined,
103
+ maxResults: input.maxResults ?? undefined,
104
+ collection: input.collection ?? undefined,
105
+ principal: ctx.authenticatedPrincipal,
106
+ });
107
+ return { result };
108
+ },
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // memory_store — the pilot WRITE op
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export type MemoryStoreInput = MemoryStoreRequest;
116
+
117
+ export interface MemoryStoreOutput {
118
+ readonly result: EngramAccessWriteResponse;
119
+ }
120
+
121
+ export const memoryStoreOperation = defineOperation<MemoryStoreInput, MemoryStoreOutput>({
122
+ name: "memory_store",
123
+ description: "Store an explicit memory through the access layer.",
124
+ // Reuse the existing schema verbatim — the migration is behavior-preserving;
125
+ // the schema's external contract is NOT changing in this PR (per the issue's
126
+ // pitfall note).
127
+ schema: memoryStoreRequestSchema,
128
+ handler: async (input, ctx) => {
129
+ const result = await ctx.service.memoryStore(
130
+ {
131
+ ...input,
132
+ authenticatedPrincipal: ctx.authenticatedPrincipal,
133
+ },
134
+ // Forward transport-level hooks (e.g. HTTP's atomic write-quota gate)
135
+ // so the hook still fires inside the service's idempotent-write lock —
136
+ // never before, never on a replay (#1434 invariant preserved by the
137
+ // boundary migration).
138
+ ctx.hooks,
139
+ );
140
+ return { result };
141
+ },
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Surface registration map — what each transport calls the pilot ops
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * The canonical short names (no `engram.`/`remnic.` prefix) of the operations
150
+ * the boundary owns today. The fitness test treats this set as the migrated
151
+ * set; everything else on a surface is unmigrated and counted by the ratchet.
152
+ */
153
+ export const REGISTERED_OPERATIONS = [
154
+ memoryGetOperation.spec.name,
155
+ memorySearchOperation.spec.name,
156
+ memoryStoreOperation.spec.name,
157
+ ] as const;