@remnic/core 9.3.676 → 9.3.677

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 (62) hide show
  1. package/dist/access-cli.js +16 -16
  2. package/dist/access-http.js +13 -13
  3. package/dist/access-mcp.js +12 -12
  4. package/dist/access-schema.js +3 -3
  5. package/dist/access-service.js +10 -10
  6. package/dist/{chunk-OG7A6AZX.js → chunk-2DKXY243.js} +4 -4
  7. package/dist/{chunk-Q5ZU3RNY.js → chunk-57ME5VSI.js} +4 -4
  8. package/dist/{chunk-SDLJ2W7S.js → chunk-7UTCHQTB.js} +2 -2
  9. package/dist/{chunk-T2AOOHDA.js → chunk-ACYX37IM.js} +2 -2
  10. package/dist/{chunk-ZLINDOBG.js → chunk-CZMLLVU2.js} +3 -3
  11. package/dist/{chunk-DOCTITOP.js → chunk-DGEZKYVI.js} +4 -4
  12. package/dist/{chunk-Q6MIDQEL.js → chunk-EQYP3HA6.js} +2 -2
  13. package/dist/{chunk-52LZ42LI.js → chunk-ERA5RSMZ.js} +1 -1
  14. package/dist/{chunk-IPLYGWQF.js → chunk-KQAFEZQX.js} +5 -5
  15. package/dist/{chunk-SF45RQDX.js → chunk-RP64QP7G.js} +3 -3
  16. package/dist/{chunk-QLRYXOAD.js → chunk-UDJLF3BO.js} +2 -2
  17. package/dist/{chunk-R37A3BEW.js → chunk-YEQBJXVO.js} +111 -101
  18. package/dist/chunk-YEQBJXVO.js.map +1 -0
  19. package/dist/{chunk-B55KFEGS.js → chunk-YJ4J2JJ2.js} +10 -10
  20. package/dist/{chunk-XVVEKF5I.js → chunk-Z56KDLDK.js} +20 -20
  21. package/dist/{chunk-OUWAQVDJ.js → chunk-Z6SEG36L.js} +4 -4
  22. package/dist/cli.js +22 -22
  23. package/dist/{coding-graph-types-Dd2tGrnm.d.ts → coding/coding-graph-types.d.ts} +1 -1
  24. package/dist/coding/coding-graph-types.js +10 -0
  25. package/dist/coding/coding-graph-types.js.map +1 -0
  26. package/dist/coding/optional-coding-graph.d.ts +2 -2
  27. package/dist/coding/optional-coding-graph.js +1 -1
  28. package/dist/contradiction/index.js +4 -4
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +33 -33
  31. package/dist/lcm/index.js +3 -3
  32. package/dist/namespaces/migrate.js +8 -8
  33. package/dist/namespaces/search.js +7 -7
  34. package/dist/operator-toolkit.js +9 -9
  35. package/dist/orchestrator.js +13 -13
  36. package/dist/search/factory.js +6 -6
  37. package/dist/search/index.js +11 -11
  38. package/dist/search/lancedb-backend.js +2 -2
  39. package/dist/search/meilisearch-backend.js +2 -2
  40. package/dist/search/orama-backend.js +2 -2
  41. package/dist/transfer/autodetect.js +1 -1
  42. package/dist/transfer/backup.js +1 -1
  43. package/dist/transfer/capsule-export.js +2 -2
  44. package/package.json +7 -2
  45. package/src/orchestrator.ts +50 -197
  46. package/src/scopes/scope-plan.test.ts +360 -0
  47. package/src/scopes/scope-plan.ts +320 -0
  48. package/dist/chunk-R37A3BEW.js.map +0 -1
  49. /package/dist/{chunk-OG7A6AZX.js.map → chunk-2DKXY243.js.map} +0 -0
  50. /package/dist/{chunk-Q5ZU3RNY.js.map → chunk-57ME5VSI.js.map} +0 -0
  51. /package/dist/{chunk-SDLJ2W7S.js.map → chunk-7UTCHQTB.js.map} +0 -0
  52. /package/dist/{chunk-T2AOOHDA.js.map → chunk-ACYX37IM.js.map} +0 -0
  53. /package/dist/{chunk-ZLINDOBG.js.map → chunk-CZMLLVU2.js.map} +0 -0
  54. /package/dist/{chunk-DOCTITOP.js.map → chunk-DGEZKYVI.js.map} +0 -0
  55. /package/dist/{chunk-Q6MIDQEL.js.map → chunk-EQYP3HA6.js.map} +0 -0
  56. /package/dist/{chunk-52LZ42LI.js.map → chunk-ERA5RSMZ.js.map} +0 -0
  57. /package/dist/{chunk-IPLYGWQF.js.map → chunk-KQAFEZQX.js.map} +0 -0
  58. /package/dist/{chunk-SF45RQDX.js.map → chunk-RP64QP7G.js.map} +0 -0
  59. /package/dist/{chunk-QLRYXOAD.js.map → chunk-UDJLF3BO.js.map} +0 -0
  60. /package/dist/{chunk-B55KFEGS.js.map → chunk-YJ4J2JJ2.js.map} +0 -0
  61. /package/dist/{chunk-XVVEKF5I.js.map → chunk-Z56KDLDK.js.map} +0 -0
  62. /package/dist/{chunk-OUWAQVDJ.js.map → chunk-Z6SEG36L.js.map} +0 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * ScopePlan resolver parity tests (issue #1521 step 2).
3
+ *
4
+ * These tests snapshot the effective namespace sets the resolver produces for a
5
+ * fixed matrix of inputs. The snapshots were derived by tracing the pre-migration
6
+ * inline resolution in `orchestrator.recallInternal` and
7
+ * `orchestrator.enqueueDirectAnswerObservation` — the SAME helpers in the SAME
8
+ * order. They MUST NOT change when consumers switch from the inline code to the
9
+ * resolver; if they do, the resolver diverged and must be corrected before
10
+ * migration lands.
11
+ *
12
+ * Input matrix (issue #1521 step 2):
13
+ * - default namespace (no policies, no coding context)
14
+ * - named namespace (explicit override, readable)
15
+ * - coding overlay (project scope and branch scope)
16
+ * - sparse metadata (empty/missing fields, no session key)
17
+ * - legacy `agent:*` session keys
18
+ * - scope-profile plan (active profile)
19
+ * - explicit namespace override (unreadable falls through)
20
+ */
21
+ import assert from "node:assert/strict";
22
+ import test from "node:test";
23
+
24
+ import { resolveScopePlan } from "./scope-plan.js";
25
+ import {
26
+ combineNamespaces,
27
+ lcmSessionKeyForNamespace,
28
+ projectNamespaceName,
29
+ } from "../coding/coding-namespace.js";
30
+ import type { CodingContext, PluginConfig } from "../types.js";
31
+
32
+ // ──────────────────────────────────────────────────────────────────────────
33
+ // Config builders
34
+ // ──────────────────────────────────────────────────────────────────────────
35
+
36
+ function baseConfig(overrides: Partial<PluginConfig> = {}): PluginConfig {
37
+ return {
38
+ namespacesEnabled: true,
39
+ defaultNamespace: "default",
40
+ sharedNamespace: "shared",
41
+ namespacePolicies: [],
42
+ defaultRecallNamespaces: ["self", "shared"],
43
+ codingMode: { projectScope: true, branchScope: false, globalFallback: true },
44
+ principalFromSessionKeyMode: "prefix",
45
+ principalFromSessionKeyRules: [],
46
+ scopeProfiles: {},
47
+ defaultScopeProfile: undefined,
48
+ teams: {},
49
+ ...overrides,
50
+ } as unknown as PluginConfig;
51
+ }
52
+
53
+ /** A principal whose self namespace exists as a policy, so the overlay base is
54
+ * non-default and readable. */
55
+ function withSelfPolicy(config: PluginConfig, principal: string): PluginConfig {
56
+ return {
57
+ ...config,
58
+ namespacePolicies: [
59
+ { name: principal, readPrincipals: [principal], writePrincipals: [principal] },
60
+ ...(config.namespacePolicies ?? []),
61
+ ],
62
+ principalFromSessionKeyMode: "prefix",
63
+ principalFromSessionKeyRules: [
64
+ { match: `${principal}:`, principal },
65
+ ...(config.principalFromSessionKeyRules ?? []),
66
+ ],
67
+ } as unknown as PluginConfig;
68
+ }
69
+
70
+ function codingContext(projectId: string, branch: string | null = null): CodingContext {
71
+ return { projectId, branch, rootPath: "/repo", defaultBranch: "main" };
72
+ }
73
+
74
+ // ──────────────────────────────────────────────────────────────────────────
75
+ // Snapshot 1: default namespace, no coding context
76
+ // ──────────────────────────────────────────────────────────────────────────
77
+
78
+ test("scope-plan: default namespace, no coding context → [default]", () => {
79
+ const config = baseConfig();
80
+ const plan = resolveScopePlan({
81
+ config,
82
+ namespacesEnabled: config.namespacesEnabled,
83
+ sessionKey: "sess-1",
84
+ });
85
+
86
+ assert.equal(plan.principal, "default");
87
+ assert.equal(plan.namespaceOverride, undefined);
88
+ assert.equal(plan.baseNamespace, "default");
89
+ assert.deepEqual(plan.readNamespaces, ["default", "shared"]);
90
+ assert.deepEqual(plan.readFallbacks, []);
91
+ assert.deepEqual(plan.lcmReadNamespaces, ["default"]);
92
+ assert.equal(plan.codingOverlay, null);
93
+ assert.equal(plan.scopeProfilePlan, null);
94
+ // LCM key is the raw sessionKey (default store, no overlay).
95
+ assert.deepEqual([...plan.lcmReadSessionIds], ["sess-1"]);
96
+ });
97
+
98
+ // ──────────────────────────────────────────────────────────────────────────
99
+ // Snapshot 2: named namespace (explicit, readable override)
100
+ // ──────────────────────────────────────────────────────────────────────────
101
+
102
+ test("scope-plan: explicit readable namespace override wins", () => {
103
+ const config = baseConfig({
104
+ namespacePolicies: [
105
+ { name: "team-data", readPrincipals: ["default"], writePrincipals: [] },
106
+ ],
107
+ } as Partial<PluginConfig>);
108
+ const plan = resolveScopePlan({
109
+ config,
110
+ namespacesEnabled: config.namespacesEnabled,
111
+ sessionKey: "sess-1",
112
+ namespace: "team-data",
113
+ });
114
+
115
+ assert.equal(plan.namespaceOverride, "team-data");
116
+ assert.equal(plan.baseNamespace, "team-data");
117
+ assert.deepEqual(plan.readNamespaces, ["team-data"]);
118
+ assert.deepEqual(plan.lcmReadNamespaces, ["team-data"]);
119
+ assert.equal(plan.codingOverlay, null);
120
+ assert.equal(plan.scopeProfilePlan, null);
121
+ assert.deepEqual(
122
+ [...plan.lcmReadSessionIds],
123
+ [lcmSessionKeyForNamespace("team-data", "sess-1", "default")],
124
+ );
125
+ });
126
+
127
+ // ──────────────────────────────────────────────────────────────────────────
128
+ // Snapshot 3: coding overlay (project scope)
129
+ // ──────────────────────────────────────────────────────────────────────────
130
+
131
+ test("scope-plan: coding overlay (project scope) substitutes self base", () => {
132
+ const config = withSelfPolicy(baseConfig(), "alice");
133
+ const ctx = codingContext("myproj");
134
+ const plan = resolveScopePlan({
135
+ config,
136
+ namespacesEnabled: config.namespacesEnabled,
137
+ sessionKey: "alice:sess-1",
138
+ codingContext: ctx,
139
+ });
140
+
141
+ const projectNs = projectNamespaceName("myproj");
142
+ const codingSelf = combineNamespaces("alice", projectNs);
143
+
144
+ assert.equal(plan.principal, "alice");
145
+ assert.equal(plan.baseNamespace, codingSelf);
146
+ // readNamespaces substitutes "alice" → codingSelf, keeps shared.
147
+ // globalFallback=true adds the root ("") fallback: combineNamespaces("alice",
148
+ // "") → "alice", so the principal's own namespace appears as a read fallback.
149
+ assert.deepEqual(plan.readNamespaces, [codingSelf, "shared", "alice"]);
150
+ assert.deepEqual(plan.readFallbacks, ["alice"]);
151
+ assert.deepEqual(plan.lcmReadNamespaces, [codingSelf, "alice"]);
152
+ assert.equal(plan.codingOverlay?.namespace, projectNs);
153
+ assert.deepEqual([...plan.codingOverlay?.readFallbacks ?? []], [""]);
154
+ });
155
+
156
+ // ──────────────────────────────────────────────────────────────────────────
157
+ // Snapshot 4: coding overlay (branch scope) appends project fallback
158
+ // ──────────────────────────────────────────────────────────────────────────
159
+
160
+ test("scope-plan: coding overlay (branch scope) appends project + root fallbacks", () => {
161
+ const config = withSelfPolicy(
162
+ baseConfig({
163
+ codingMode: { projectScope: true, branchScope: true, globalFallback: true },
164
+ } as Partial<PluginConfig>),
165
+ "alice",
166
+ );
167
+ const ctx = codingContext("myproj", "feature-x");
168
+ const plan = resolveScopePlan({
169
+ config,
170
+ namespacesEnabled: config.namespacesEnabled,
171
+ sessionKey: "alice:sess-1",
172
+ codingContext: ctx,
173
+ });
174
+
175
+ // Verify the key invariants: overlay is non-null, base is combined with self.
176
+ assert.notEqual(plan.codingOverlay, null);
177
+ assert.notEqual(plan.baseNamespace, "alice");
178
+ // readNamespaces includes the coding self (branch) and fallbacks.
179
+ assert.ok(plan.readNamespaces.length >= 2, "branch scope must include fallbacks");
180
+ // LCM read includes coding self + fallbacks.
181
+ assert.ok(plan.lcmReadNamespaces.length >= 2, "LCM must include fallback keys");
182
+ // readFallbacks is non-empty (project + root when globalFallback).
183
+ assert.ok(plan.readFallbacks.length >= 1, "branch scope has at least project fallback");
184
+ });
185
+
186
+ // ──────────────────────────────────────────────────────────────────────────
187
+ // Snapshot 5: sparse metadata — no session key
188
+ // ──────────────────────────────────────────────────────────────────────────
189
+
190
+ test("scope-plan: no session key → principal undefined, default namespace", () => {
191
+ const config = baseConfig();
192
+ const plan = resolveScopePlan({
193
+ config,
194
+ namespacesEnabled: config.namespacesEnabled,
195
+ });
196
+
197
+ assert.equal(plan.principal, undefined);
198
+ assert.equal(plan.baseNamespace, "default");
199
+ // recallNamespacesForPrincipal(undefined) → [] (no principal).
200
+ assert.deepEqual(plan.readNamespaces, []);
201
+ assert.deepEqual(plan.lcmReadNamespaces, ["default"]);
202
+ // Sessionless LCM → [undefined] (archive-wide read, no session_id filter).
203
+ assert.deepEqual([...plan.lcmReadSessionIds], [undefined]);
204
+ });
205
+
206
+ // ──────────────────────────────────────────────────────────────────────────
207
+ // Snapshot 6: legacy agent:* session key
208
+ // ──────────────────────────────────────────────────────────────────────────
209
+
210
+ test("scope-plan: legacy agent:* session key resolves principal via heuristic", () => {
211
+ const config = baseConfig();
212
+ const plan = resolveScopePlan({
213
+ config,
214
+ namespacesEnabled: config.namespacesEnabled,
215
+ sessionKey: "agent:bot-1:slack:chan-1",
216
+ });
217
+
218
+ // resolvePrincipal heuristic: parts[0] === "agent" → parts[1] = "bot-1".
219
+ assert.equal(plan.principal, "bot-1");
220
+ // No policy for "bot-1" → defaultNamespaceForPrincipal → "default".
221
+ assert.equal(plan.baseNamespace, "default");
222
+ // recallNamespacesForPrincipal("bot-1"): self="default" (readable), shared.
223
+ assert.deepEqual(plan.readNamespaces, ["default", "shared"]);
224
+ assert.deepEqual(plan.lcmReadNamespaces, ["default"]);
225
+ });
226
+
227
+ // ──────────────────────────────────────────────────────────────────────────
228
+ // Snapshot 7: namespacesEnabled false → single-store collapse
229
+ // ──────────────────────────────────────────────────────────────────────────
230
+
231
+ test("scope-plan: namespacesEnabled false collapses to default store", () => {
232
+ const config = baseConfig({ namespacesEnabled: false } as Partial<PluginConfig>);
233
+ const plan = resolveScopePlan({
234
+ config,
235
+ namespacesEnabled: config.namespacesEnabled,
236
+ sessionKey: "sess-1",
237
+ codingContext: codingContext("myproj"),
238
+ });
239
+
240
+ // resolvePrincipal returns "default" when namespaces disabled.
241
+ assert.equal(plan.principal, "default");
242
+ assert.equal(plan.baseNamespace, "default");
243
+ assert.deepEqual(plan.readNamespaces, ["default"]);
244
+ assert.equal(plan.codingOverlay, null);
245
+ assert.equal(plan.scopeProfilePlan, null);
246
+ // Single store → raw sessionKey.
247
+ assert.deepEqual([...plan.lcmReadSessionIds], ["sess-1"]);
248
+ });
249
+
250
+ // ──────────────────────────────────────────────────────────────────────────
251
+ // Snapshot 8: codingMode.projectScope false → no overlay
252
+ // ──────────────────────────────────────────────────────────────────────────
253
+
254
+ test("scope-plan: projectScope false → no overlay even with coding context", () => {
255
+ const config = withSelfPolicy(
256
+ baseConfig({
257
+ codingMode: { projectScope: false, branchScope: false, globalFallback: true },
258
+ } as Partial<PluginConfig>),
259
+ "alice",
260
+ );
261
+ const plan = resolveScopePlan({
262
+ config,
263
+ namespacesEnabled: config.namespacesEnabled,
264
+ sessionKey: "alice:sess-1",
265
+ codingContext: codingContext("myproj"),
266
+ });
267
+
268
+ assert.equal(plan.codingOverlay, null);
269
+ assert.equal(plan.baseNamespace, "alice");
270
+ // No overlay → readable recall set unchanged (self substituted by nothing).
271
+ assert.deepEqual(plan.readNamespaces, ["alice", "shared"]);
272
+ });
273
+
274
+ // ──────────────────────────────────────────────────────────────────────────
275
+ // Snapshot 9: explicit override not readable → falls through (observe parity)
276
+ // ──────────────────────────────────────────────────────────────────────────
277
+
278
+ test("scope-plan: unreadable namespace override falls through to coding/legacy", () => {
279
+ // No policy for "restricted" → canReadNamespace(default, "restricted") → false.
280
+ const config = withSelfPolicy(baseConfig(), "alice");
281
+ const plan = resolveScopePlan({
282
+ config,
283
+ namespacesEnabled: config.namespacesEnabled,
284
+ sessionKey: "alice:sess-1",
285
+ namespace: "restricted",
286
+ codingContext: codingContext("myproj"),
287
+ });
288
+
289
+ // Unreadable override → namespaceOverride is undefined in the plan.
290
+ assert.equal(plan.namespaceOverride, undefined);
291
+ // Falls through to coding overlay (alice has a policy + coding context).
292
+ assert.notEqual(plan.codingOverlay, null);
293
+ assert.notEqual(plan.baseNamespace, "restricted");
294
+ assert.notEqual(plan.baseNamespace, "alice", "base should be the overlaid namespace");
295
+ });
296
+
297
+ // ──────────────────────────────────────────────────────────────────────────
298
+ // Snapshot 10: defaultRecallNamespaces omits self → overlay LCM collapses
299
+ // ──────────────────────────────────────────────────────────────────────────
300
+
301
+ test("scope-plan: self not in defaultRecallNamespaces → LCM collapses to default", () => {
302
+ const config = withSelfPolicy(
303
+ baseConfig({
304
+ defaultRecallNamespaces: ["shared"],
305
+ } as Partial<PluginConfig>),
306
+ "alice",
307
+ );
308
+ const plan = resolveScopePlan({
309
+ config,
310
+ namespacesEnabled: config.namespacesEnabled,
311
+ sessionKey: "alice:sess-1",
312
+ codingContext: codingContext("myproj"),
313
+ });
314
+
315
+ // Coding overlay IS resolved (coding context + projectScope).
316
+ assert.notEqual(plan.codingOverlay, null);
317
+ // codingOverlaySelfReadable = false (self "alice" not in readable set).
318
+ // LCM collapses to default.
319
+ assert.deepEqual(plan.lcmReadNamespaces, ["default"]);
320
+ });
321
+
322
+ // ──────────────────────────────────────────────────────────────────────────
323
+ // Cross-check: resolveScopePlan is pure (same inputs → same outputs)
324
+ // ──────────────────────────────────────────────────────────────────────────
325
+
326
+ test("scope-plan: resolver is pure — identical inputs produce identical plans", () => {
327
+ const config = withSelfPolicy(baseConfig(), "alice");
328
+ const ctx = codingContext("myproj");
329
+ const opts = { config, namespacesEnabled: config.namespacesEnabled, sessionKey: "alice:sess-1", codingContext: ctx } as const;
330
+
331
+ const a = resolveScopePlan(opts);
332
+ const b = resolveScopePlan(opts);
333
+
334
+ assert.deepEqual(a.readNamespaces, b.readNamespaces);
335
+ assert.deepEqual(a.lcmReadNamespaces, b.lcmReadNamespaces);
336
+ assert.deepEqual([...a.lcmReadSessionIds], [...b.lcmReadSessionIds]);
337
+ assert.equal(a.baseNamespace, b.baseNamespace);
338
+ assert.equal(a.codingOverlay?.namespace, b.codingOverlay?.namespace);
339
+ });
340
+
341
+ // ──────────────────────────────────────────────────────────────────────────
342
+ // Parity invariant: LCM keys derived through lcmSessionKeyForNamespace
343
+ // (rule 22 — never hardcoded `:`-joins)
344
+ // ──────────────────────────────────────────────────────────────────────────
345
+
346
+ test("scope-plan: LCM session ids match lcmSessionKeyForNamespace encoding", () => {
347
+ const config = withSelfPolicy(baseConfig(), "alice");
348
+ const plan = resolveScopePlan({
349
+ config,
350
+ namespacesEnabled: config.namespacesEnabled,
351
+ sessionKey: "alice:sess-1",
352
+ codingContext: codingContext("myproj"),
353
+ });
354
+
355
+ // Each LCM read session id must equal lcmSessionKeyForNamespace(ns, sk, default).
356
+ const expected = plan.lcmReadNamespaces.map(
357
+ (ns) => lcmSessionKeyForNamespace(ns, "alice:sess-1", "default") ?? "alice:sess-1",
358
+ );
359
+ assert.deepEqual([...plan.lcmReadSessionIds], expected);
360
+ });
@@ -0,0 +1,320 @@
1
+ /**
2
+ * ScopePlan resolver (issue #1521).
3
+ *
4
+ * A single pure function that resolves every namespace-bearing read/write path
5
+ * to one {@link ScopePlan} value object. Consumers (recall tiers, QMD router,
6
+ * LCM reads, maintenance) never call the ad-hoc resolution helpers directly —
7
+ * they receive a resolved plan and read its fields.
8
+ *
9
+ * The resolver DELEGATES to the existing helpers (`resolvePrincipal`,
10
+ * `recallNamespacesForPrincipal`, `resolveCodingNamespaceOverlay`,
11
+ * `resolveScopeProfilePlan`, `expandScopeProfileReadNamespaces`,
12
+ * `combineNamespaces`, `lcmReadSessionIdsForNamespaces`) — no logic rewrite.
13
+ * The ad-hoc inline resolution that previously lived in the orchestrator's
14
+ * `recallInternal` and `enqueueDirectAnswerObservation` paths is replaced by a
15
+ * single call to {@link resolveScopePlan}, eliminating the duplicated
16
+ * namespace-set construction that produced the largest share of #1519's review
17
+ * threads (scope-profile recall paths missing `readFallbacks` appends).
18
+ *
19
+ * Migration tranches (issue #1521 step 4):
20
+ * - recall tiers (this PR): the two orchestrator recall entry points consume
21
+ * the plan instead of building `recallNamespaces`/`observationNamespaces`
22
+ * inline;
23
+ * - QMD router calls, LCM reads, maintenance: follow-up PRs.
24
+ *
25
+ * CLAUDE.md rules honoured (pitfalls from the issue):
26
+ * - rule 42: read/write resolve through the same layer; the coding overlay is
27
+ * COMBINED with the principal base via `combineNamespaces`;
28
+ * - rule 39: feature gates identical across every path the plan feeds;
29
+ * - rule 22/48: LCM keys derived through `lcmSessionKeyForNamespace` (via
30
+ * `lcmReadSessionIdsForNamespaces`), never hardcoded `:`-joins; unscoped LCM
31
+ * search stays suppressed when `namespacesEnabled`.
32
+ */
33
+
34
+ import {
35
+ canReadNamespace,
36
+ defaultNamespaceForPrincipal,
37
+ recallNamespacesForPrincipal,
38
+ resolvePrincipal,
39
+ } from "../namespaces/principal.js";
40
+ import type { ResolvedScopeProfilePlan } from "../namespaces/scope-profiles.js";
41
+ import {
42
+ expandScopeProfileReadNamespaces,
43
+ resolveScopeProfilePlan,
44
+ } from "../namespaces/scope-profiles.js";
45
+ import {
46
+ combineNamespaces,
47
+ lcmReadSessionIdsForNamespaces,
48
+ resolveCodingNamespaceOverlay,
49
+ type CodingNamespaceOverlay,
50
+ } from "../coding/coding-namespace.js";
51
+ import type { CodingContext, PluginConfig } from "../types.js";
52
+
53
+ /**
54
+ * A resolved scope plan: every namespace-bearing field a read or write path
55
+ * needs, produced by ONE call to {@link resolveScopePlan}.
56
+ *
57
+ * Consumers read these fields directly — they never re-resolve namespaces.
58
+ */
59
+ export interface ScopePlan {
60
+ /**
61
+ * Resolved principal under which the operation runs. `undefined` only when
62
+ * `namespacesEnabled` is false (collapses to `"default"`) or no session key
63
+ * was supplied in single-user mode.
64
+ */
65
+ readonly principal: string | undefined;
66
+
67
+ /**
68
+ * Explicit namespace override, trimmed, when the caller supplied one AND it
69
+ * is readable by the principal. `undefined` when no override was given or the
70
+ * override is not readable (the plan falls through to the coding/scope-profile
71
+ * resolution in that case, mirroring the pre-existing observe-path behavior).
72
+ */
73
+ readonly namespaceOverride: string | undefined;
74
+
75
+ /**
76
+ * Resolved base (self) namespace — the effective namespace absent an explicit
77
+ * override. This is the principal-self namespace, optionally substituted by a
78
+ * scope-profile write layer or a coding overlay.
79
+ *
80
+ * Callers that persisted this as the `selfNamespace` / response namespace
81
+ * read it here.
82
+ */
83
+ readonly baseNamespace: string;
84
+
85
+ /**
86
+ * Ordered, deduped read-namespace set. Includes the coding overlay fallbacks
87
+ * (branch → project → root) combined with the principal base exactly once, so
88
+ * the #1519 miss (scope-profile path omitted `readFallbacks` appends) cannot
89
+ * recur — the fallback appends live in ONE place.
90
+ */
91
+ readonly readNamespaces: string[];
92
+
93
+ /**
94
+ * Coding overlay fallback namespaces combined with the principal base (rule
95
+ * 42). Empty when no coding overlay applies. These are the SAME entries
96
+ * appended into {@link readNamespaces} for the coding-overlay branch,
97
+ * surfaced separately so consumers that need just the fallback set (e.g.
98
+ * LCM read-key derivation) can read them without re-deriving.
99
+ */
100
+ readonly readFallbacks: string[];
101
+
102
+ /**
103
+ * LCM read namespace set. May differ from {@link readNamespaces} for
104
+ * read-authorization reasons: when the principal self base is NOT in the
105
+ * readable recall set, the overlay LCM keys collapse to the default store
106
+ * (rule 42 read/write parity; rule 48 least-privilege) even though
107
+ * {@link readNamespaces} still searches the overlay for QMD/file recall.
108
+ */
109
+ readonly lcmReadNamespaces: string[];
110
+
111
+ /**
112
+ * LCM read `session_id` set, encoded via
113
+ * `lcmReadSessionIdsForNamespaces` (which delegates to
114
+ * `lcmSessionKeyForNamespace`). Ordered and deduped; the primary overlay key
115
+ * is first. Single-user / no-overlay recall collapses to `[sessionKey]` —
116
+ * byte-for-byte the pre-#1495 behavior.
117
+ *
118
+ * Empty (`[]`) when a scope-profile plan is active and no `sessionKey` was
119
+ * supplied (mirrors the pre-existing guard).
120
+ */
121
+ readonly lcmReadSessionIds: ReadonlyArray<string | undefined>;
122
+
123
+ /**
124
+ * Resolved coding overlay, or `null` when none applies (no coding context,
125
+ * `codingMode.projectScope` false, `namespacesEnabled` false, or an explicit
126
+ * readable namespace override is set).
127
+ */
128
+ readonly codingOverlay: { readonly namespace: string; readonly readFallbacks: readonly string[] } | null;
129
+
130
+ /**
131
+ * Resolved scope-profile plan, or `null` when no scope profile is active.
132
+ * Consumers that branched on "profile vs. non-profile" read this directly.
133
+ */
134
+ readonly scopeProfilePlan: ResolvedScopeProfilePlan | null;
135
+ }
136
+
137
+ /**
138
+ * Options for {@link resolveScopePlan}.
139
+ */
140
+ export interface ResolveScopePlanOptions {
141
+ /** Plugin config (namespace policies, coding mode, default namespace, …). */
142
+ readonly config: PluginConfig;
143
+ /** Session key (may derive the principal and/or coding context). */
144
+ readonly sessionKey?: string;
145
+ /**
146
+ * Explicit namespace override (raw — the resolver trims it). When supplied
147
+ * AND readable by the resolved principal, it wins over every other layer.
148
+ */
149
+ readonly namespace?: string;
150
+ /**
151
+ * Authenticated principal override. Access surfaces that already resolved
152
+ * identity at the transport layer pass it here so namespace ACL decisions
153
+ * use the same identity the surface authorized.
154
+ */
155
+ readonly principalOverride?: string;
156
+ /**
157
+ * Coding context for the session. Callers that track this on the orchestrator
158
+ * pass `getCodingContextForSession(sessionKey)` here; the resolver never
159
+ * reaches back into orchestrator state.
160
+ */
161
+ readonly codingContext?: CodingContext | null;
162
+ /**
163
+ * Whether namespace routing is enabled. Callers that have already read the
164
+ * namespaces-enabled flag pass it here so the resolver does NOT re-read it
165
+ * (keeps the scattered-read ratchet from growing, #1523).
166
+ */
167
+ readonly namespacesEnabled: boolean;
168
+ }
169
+
170
+ /**
171
+ * Resolve a {@link ScopePlan} from the inputs by delegating to the existing
172
+ * namespace-resolution helpers. Pure — no side effects, no orchestrator state.
173
+ *
174
+ * The caller is responsible for authorization checks that should THROW (e.g.
175
+ * `namespacesEnabled && !principal`, or an unreadable explicit override in the
176
+ * recall path). The resolver computes the plan; enforcement stays at the call
177
+ * site so error semantics are unchanged.
178
+ */
179
+ export function resolveScopePlan(options: ResolveScopePlanOptions): ScopePlan {
180
+ const { config, sessionKey } = options;
181
+
182
+ const namespaceOverride = options.namespace?.trim() || undefined;
183
+
184
+ const principal =
185
+ typeof options.principalOverride === "string" && options.principalOverride.length > 0
186
+ ? options.principalOverride
187
+ : resolvePrincipal(sessionKey, config);
188
+
189
+ // A namespace override gates the overlay/scope-profile layers. When it is
190
+ // readable it wins; when it is NOT readable the plan falls through to the
191
+ // coding/scope-profile/legacy branches (mirrors the observe-path behavior
192
+ // where an unreadable override does not throw but simply does not suppress
193
+ // the overlay). The recall path validates readability and throws BEFORE
194
+ // calling the resolver, so a reachable override is always readable there.
195
+ const namespaceOverrideReadable =
196
+ namespaceOverride !== undefined && canReadNamespace(principal, namespaceOverride, config);
197
+
198
+ const readableRecallNamespaces = recallNamespacesForPrincipal(principal, config);
199
+
200
+ // The orchestrator's `applyCodingRecallOverlay` gates on `namespacesEnabled`
201
+ // (returning null when disabled), so the resolver must too — otherwise
202
+ // single-store mode would produce apparent route separation with no actual
203
+ // storage isolation (false-isolation trap, rule 39). The caller passes the
204
+ // pre-read flag so the resolver does not add a scattered `config.*Enabled`
205
+ // read (ratchet, #1523).
206
+ const namespacesEnabled = options.namespacesEnabled;
207
+ const codingOverlay: CodingNamespaceOverlay | null =
208
+ namespaceOverrideReadable || !namespacesEnabled
209
+ ? null
210
+ : resolveCodingNamespaceOverlay(
211
+ options.codingContext ?? null,
212
+ config.codingMode,
213
+ config.defaultNamespace,
214
+ );
215
+
216
+ const principalSelfNamespace = defaultNamespaceForPrincipal(principal, config);
217
+ const codingSelfNamespace = codingOverlay
218
+ ? combineNamespaces(principalSelfNamespace, codingOverlay.namespace)
219
+ : null;
220
+
221
+ const scopeProfilePlan = namespaceOverrideReadable
222
+ ? null
223
+ : resolveScopeProfilePlan({
224
+ config,
225
+ principal,
226
+ codingContext: options.codingContext ?? null,
227
+ codingOverlay,
228
+ });
229
+
230
+ const profileEffectiveNamespace =
231
+ scopeProfilePlan?.writeNamespace || scopeProfilePlan?.readNamespaces[0];
232
+
233
+ const baseNamespace = namespaceOverrideReadable
234
+ ? namespaceOverride!
235
+ : profileEffectiveNamespace ?? codingSelfNamespace ?? principalSelfNamespace;
236
+
237
+ // ── Read namespace set ────────────────────────────────────────────────────
238
+ let readNamespaces: string[];
239
+ if (namespaceOverrideReadable) {
240
+ readNamespaces = [namespaceOverride!];
241
+ } else if (scopeProfilePlan) {
242
+ readNamespaces = expandScopeProfileReadNamespaces({
243
+ profilePlan: scopeProfilePlan,
244
+ principalSelfNamespace: scopeProfilePlan.baseNamespace,
245
+ config,
246
+ principal,
247
+ codingOverlay,
248
+ legacyRecallNamespaces: readableRecallNamespaces,
249
+ });
250
+ } else if (codingOverlay && codingSelfNamespace) {
251
+ // Substitute the principal's self namespace with the coding-scoped one, and
252
+ // append any read fallbacks (branch → project, rule 42) combined with the
253
+ // principal base so principal isolation is preserved on fallback entries.
254
+ const mapped = readableRecallNamespaces.map((ns) =>
255
+ ns === principalSelfNamespace ? codingSelfNamespace : ns,
256
+ );
257
+ const fallbackNs = codingOverlay.readFallbacks.map((fallback) =>
258
+ combineNamespaces(principalSelfNamespace, fallback),
259
+ );
260
+ readNamespaces = Array.from(new Set<string>([...mapped, ...fallbackNs]));
261
+ } else {
262
+ readNamespaces = readableRecallNamespaces;
263
+ }
264
+
265
+ const readFallbacks = codingOverlay
266
+ ? codingOverlay.readFallbacks.map((fb) => combineNamespaces(principalSelfNamespace, fb))
267
+ : [];
268
+
269
+ // ── LCM read namespace set ────────────────────────────────────────────────
270
+ // The LCM overlay keys are `<principal>-project-*` sub-namespaces authorized
271
+ // transitively by the principal SELF base. Include them ONLY when that base
272
+ // is in the readable recall set (rule 42 / 48). When it is NOT readable, the
273
+ // overlay rows are unauthorized for this reader, so the LCM read collapses to
274
+ // the default store — exactly what QMD/file recall surfaces for such a
275
+ // principal.
276
+ const codingOverlaySelfReadable =
277
+ codingOverlay !== null &&
278
+ (scopeProfilePlan
279
+ ? scopeProfilePlan.layers.some((layer) => layer.id === "userProject" && layer.readable)
280
+ : readableRecallNamespaces.includes(principalSelfNamespace));
281
+
282
+ let lcmReadNamespaces: string[];
283
+ if (namespaceOverrideReadable) {
284
+ lcmReadNamespaces = [namespaceOverride!];
285
+ } else if (scopeProfilePlan) {
286
+ // Scope profiles define a layered read stack; LCM-backed evidence uses the
287
+ // same namespace set as QMD/file recall so team/global/shared observations
288
+ // are not silently skipped.
289
+ lcmReadNamespaces = readNamespaces;
290
+ } else if (codingOverlay && codingSelfNamespace && codingOverlaySelfReadable) {
291
+ const fallbackNs = codingOverlay.readFallbacks.map((fallback) =>
292
+ combineNamespaces(principalSelfNamespace, fallback),
293
+ );
294
+ lcmReadNamespaces = [codingSelfNamespace, ...fallbackNs];
295
+ } else {
296
+ // No overlay, OR overlay present but self base unreadable → collapse to the
297
+ // default store (raw sessionKey). No `<principal>-project-*` overlay key is
298
+ // searched.
299
+ lcmReadNamespaces = [config.defaultNamespace];
300
+ }
301
+
302
+ const lcmReadSessionIds =
303
+ scopeProfilePlan && !sessionKey
304
+ ? []
305
+ : lcmReadSessionIdsForNamespaces(lcmReadNamespaces, sessionKey, config.defaultNamespace);
306
+
307
+ return {
308
+ principal,
309
+ namespaceOverride: namespaceOverrideReadable ? namespaceOverride : undefined,
310
+ baseNamespace,
311
+ readNamespaces,
312
+ readFallbacks,
313
+ lcmReadNamespaces,
314
+ lcmReadSessionIds,
315
+ codingOverlay: codingOverlay
316
+ ? { namespace: codingOverlay.namespace, readFallbacks: codingOverlay.readFallbacks }
317
+ : null,
318
+ scopeProfilePlan,
319
+ };
320
+ }