@remnic/core 9.3.716 → 9.3.717

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 (66) hide show
  1. package/dist/access-boundary.d.ts +2 -2
  2. package/dist/access-boundary.js +4 -3
  3. package/dist/access-cli.js +12 -11
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +2 -2
  6. package/dist/access-http.js +8 -7
  7. package/dist/access-mcp.d.ts +2 -2
  8. package/dist/access-mcp.js +7 -6
  9. package/dist/access-operations-batch.js +5 -4
  10. package/dist/access-operations.d.ts +2 -2
  11. package/dist/access-operations.js +6 -5
  12. package/dist/{access-service-BW8cm_uU.d.ts → access-service-d4Tk-Dlr.d.ts} +2 -2
  13. package/dist/access-service.d.ts +2 -2
  14. package/dist/access-service.js +3 -2
  15. package/dist/access-surface-catalog.d.ts +2 -2
  16. package/dist/bootstrap.d.ts +1 -1
  17. package/dist/{chunk-MECU6WSH.js → chunk-2B43BYYZ.js} +6 -6
  18. package/dist/chunk-2KF4HDG2.js +24 -0
  19. package/dist/chunk-2KF4HDG2.js.map +1 -0
  20. package/dist/{chunk-HHLWSWWF.js → chunk-BF2426TN.js} +2 -2
  21. package/dist/{chunk-RBHANGVX.js → chunk-BZAK3RBN.js} +2 -2
  22. package/dist/{chunk-SQ4LU27I.js → chunk-CTUWGJRI.js} +36 -59
  23. package/dist/chunk-CTUWGJRI.js.map +1 -0
  24. package/dist/{chunk-6QUPUTT7.js → chunk-DZYARLJ7.js} +5 -9
  25. package/dist/{chunk-6QUPUTT7.js.map → chunk-DZYARLJ7.js.map} +1 -1
  26. package/dist/{chunk-CTAHKIG6.js → chunk-EM22PCQR.js} +7 -7
  27. package/dist/{chunk-SHRRWOVY.js → chunk-J6BH2OBA.js} +64 -21
  28. package/dist/chunk-J6BH2OBA.js.map +1 -0
  29. package/dist/{chunk-GM3BMWKR.js → chunk-KHQ433G5.js} +2 -2
  30. package/dist/{chunk-SYDHFFAW.js → chunk-OUDAU3T5.js} +4 -4
  31. package/dist/{chunk-O62P2LFL.js → chunk-VZQXJNBD.js} +2 -2
  32. package/dist/{chunk-BNOAB5HH.js → chunk-YAINYEM3.js} +57 -48
  33. package/dist/chunk-YAINYEM3.js.map +1 -0
  34. package/dist/{chunk-3VUPWDSE.js → chunk-ZAVTEQFM.js} +4 -4
  35. package/dist/{chunk-IHMT6XTY.js → chunk-ZXLSNG3T.js} +3 -3
  36. package/dist/{cli-Dpivy32W.d.ts → cli-insNLYs6.d.ts} +2 -2
  37. package/dist/cli.d.ts +3 -3
  38. package/dist/cli.js +12 -11
  39. package/dist/explicit-capture.d.ts +1 -1
  40. package/dist/index.d.ts +4 -4
  41. package/dist/index.js +22 -21
  42. package/dist/index.js.map +1 -1
  43. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  44. package/dist/operator-toolkit.js +5 -3
  45. package/dist/orchestration/maintenance.js +5 -2
  46. package/dist/{orchestrator-vygo2YIM.d.ts → orchestrator-XPKhY2OV.d.ts} +2 -2
  47. package/dist/orchestrator.d.ts +1 -1
  48. package/dist/orchestrator.js +6 -5
  49. package/package.json +2 -2
  50. package/src/access-service.ts +56 -41
  51. package/src/maintenance/namespace-planner.ts +3 -10
  52. package/src/orchestrator.ts +35 -61
  53. package/src/scopes/scope-plan.test.ts +168 -1
  54. package/src/scopes/scope-plan.ts +163 -0
  55. package/dist/chunk-BNOAB5HH.js.map +0 -1
  56. package/dist/chunk-SHRRWOVY.js.map +0 -1
  57. package/dist/chunk-SQ4LU27I.js.map +0 -1
  58. /package/dist/{chunk-MECU6WSH.js.map → chunk-2B43BYYZ.js.map} +0 -0
  59. /package/dist/{chunk-HHLWSWWF.js.map → chunk-BF2426TN.js.map} +0 -0
  60. /package/dist/{chunk-RBHANGVX.js.map → chunk-BZAK3RBN.js.map} +0 -0
  61. /package/dist/{chunk-CTAHKIG6.js.map → chunk-EM22PCQR.js.map} +0 -0
  62. /package/dist/{chunk-GM3BMWKR.js.map → chunk-KHQ433G5.js.map} +0 -0
  63. /package/dist/{chunk-SYDHFFAW.js.map → chunk-OUDAU3T5.js.map} +0 -0
  64. /package/dist/{chunk-O62P2LFL.js.map → chunk-VZQXJNBD.js.map} +0 -0
  65. /package/dist/{chunk-3VUPWDSE.js.map → chunk-ZAVTEQFM.js.map} +0 -0
  66. /package/dist/{chunk-IHMT6XTY.js.map → chunk-ZXLSNG3T.js.map} +0 -0
@@ -342,7 +342,11 @@ import {
342
342
  recallNamespacesForPrincipal,
343
343
  resolvePrincipal,
344
344
  } from "./namespaces/principal.js";
345
- import { resolveScopePlan } from "./scopes/scope-plan.js";
345
+ import {
346
+ getConfiguredNamespaces,
347
+ resolveNamespaceFromStorageDir,
348
+ resolveScopePlan,
349
+ } from "./scopes/scope-plan.js";
346
350
  import {
347
351
  expandScopeProfileReadNamespaces,
348
352
  resolveScopeProfilePlan,
@@ -2317,18 +2321,11 @@ export class Orchestrator {
2317
2321
  return this.storageRouter.storageFor(ns);
2318
2322
  }
2319
2323
 
2320
- private configuredNamespaces(): string[] {
2321
- return Array.from(
2322
- new Set(
2323
- [
2324
- this.config.defaultNamespace,
2325
- this.config.sharedNamespace,
2326
- ...this.config.namespacePolicies.map((policy) => policy.name),
2327
- ]
2328
- .map((value) => value.trim())
2329
- .filter(Boolean),
2330
- ),
2331
- );
2324
+ private configuredNamespaceList(): string[] {
2325
+ // #1521: delegates to the scope-module resolver. The inline derivation is
2326
+ // retired so the adHocNamespaceResolutions ratchet no longer counts this
2327
+ // site.
2328
+ return getConfiguredNamespaces(this.config);
2332
2329
  }
2333
2330
 
2334
2331
  private rememberNamespaceStorageDirHint(namespace: string, storageDir?: string): void {
@@ -2456,7 +2453,7 @@ export class Orchestrator {
2456
2453
  }
2457
2454
 
2458
2455
  const configured = new Set(
2459
- this.configuredNamespaces().map((namespace) => normalizeNamespaceIdentity(namespace)),
2456
+ this.configuredNamespaceList().map((namespace) => normalizeNamespaceIdentity(namespace)),
2460
2457
  );
2461
2458
  const preferredByStorageDir = new Map<
2462
2459
  string,
@@ -2570,7 +2567,7 @@ export class Orchestrator {
2570
2567
  new Set(
2571
2568
  (options.namespaces?.length
2572
2569
  ? options.namespaces
2573
- : this.configuredNamespaces()
2570
+ : this.configuredNamespaceList()
2574
2571
  )
2575
2572
  .map((value) => value.trim())
2576
2573
  .filter(Boolean),
@@ -4138,7 +4135,7 @@ export class Orchestrator {
4138
4135
  if (options?.dryRun !== true) {
4139
4136
  try {
4140
4137
  await this.processEntitySynthesisQueue(
4141
- this.namespaceFromStorageDir(targetStorage.dir),
4138
+ this.storageDirNamespace(targetStorage.dir),
4142
4139
  5,
4143
4140
  );
4144
4141
  } catch (error) {
@@ -13971,7 +13968,7 @@ export class Orchestrator {
13971
13968
  )
13972
13969
  return false;
13973
13970
  if (
13974
- this.namespaceFromStorageDir(targetStorage.dir) ===
13971
+ this.storageDirNamespace(targetStorage.dir) ===
13975
13972
  this.config.sharedNamespace
13976
13973
  )
13977
13974
  return false;
@@ -14797,7 +14794,7 @@ export class Orchestrator {
14797
14794
  let targetNamespaceName =
14798
14795
  baseNamespace && baseNamespace.length > 0
14799
14796
  ? baseNamespace
14800
- : this.namespaceFromStorageDir(targetStorage.dir);
14797
+ : this.storageDirNamespace(targetStorage.dir);
14801
14798
  let routedRuleId: string | undefined;
14802
14799
  let routedNamespaceExplicit = false;
14803
14800
  if (routeRules.length > 0) {
@@ -14838,7 +14835,7 @@ export class Orchestrator {
14838
14835
  fact.scope === "global" &&
14839
14836
  !routedNamespaceExplicit
14840
14837
  ) {
14841
- const currentNs = this.namespaceFromStorageDir(targetStorage.dir);
14838
+ const currentNs = this.storageDirNamespace(targetStorage.dir);
14842
14839
  if (currentNs !== this.config.sharedNamespace && profileAllowsSharedWrites) {
14843
14840
  try {
14844
14841
  targetStorage = await this.storageRouter.storageFor(
@@ -15106,7 +15103,7 @@ export class Orchestrator {
15106
15103
  this.qmd.isAvailable() &&
15107
15104
  faithfulnessEnforceStatus !== "pending_review"
15108
15105
  ) {
15109
- const targetNamespace = this.namespaceFromStorageDir(targetStorage.dir);
15106
+ const targetNamespace = this.storageDirNamespace(targetStorage.dir);
15110
15107
  const contradiction = await this.checkForContradiction(
15111
15108
  fact.content,
15112
15109
  writeCategory,
@@ -15525,7 +15522,7 @@ export class Orchestrator {
15525
15522
  memoryId: parentId,
15526
15523
  category: writeCategory,
15527
15524
  content: fact.content,
15528
- namespace: this.namespaceFromStorageDir(targetStorage.dir),
15525
+ namespace: this.storageDirNamespace(targetStorage.dir),
15529
15526
  confidence: fact.confidence,
15530
15527
  source: "extraction",
15531
15528
  }),
@@ -15536,7 +15533,7 @@ export class Orchestrator {
15536
15533
 
15537
15534
  // Suggest links for this memory (Phase 3A)
15538
15535
  if (this.config.memoryLinkingEnabled && this.qmd.isAvailable()) {
15539
- const targetNamespace = this.namespaceFromStorageDir(targetStorage.dir);
15536
+ const targetNamespace = this.storageDirNamespace(targetStorage.dir);
15540
15537
  const suggestedLinks = await this.suggestLinksForMemory(
15541
15538
  fact.content,
15542
15539
  writeCategory,
@@ -15642,7 +15639,7 @@ export class Orchestrator {
15642
15639
  memoryId,
15643
15640
  category: writeCategory,
15644
15641
  content: fact.content,
15645
- namespace: this.namespaceFromStorageDir(targetStorage.dir),
15642
+ namespace: this.storageDirNamespace(targetStorage.dir),
15646
15643
  confidence: fact.confidence,
15647
15644
  source: "extraction",
15648
15645
  }),
@@ -15788,7 +15785,7 @@ export class Orchestrator {
15788
15785
  const baseTouchNamespace =
15789
15786
  baseNamespace && baseNamespace.length > 0
15790
15787
  ? baseNamespace
15791
- : this.namespaceFromStorageDir(storage.dir);
15788
+ : this.storageDirNamespace(storage.dir);
15792
15789
  };
15793
15790
  const recordDurableNonFactWrite = () => {
15794
15791
  durableNonFactWritten = true;
@@ -17979,7 +17976,7 @@ export class Orchestrator {
17979
17976
 
17980
17977
  const fallbackNamespace =
17981
17978
  fallbackStorageDir !== null
17982
- ? this.namespaceFromStorageDir(fallbackStorageDir)
17979
+ ? this.storageDirNamespace(fallbackStorageDir)
17983
17980
  : this.config.defaultNamespace;
17984
17981
  if (
17985
17982
  recallNamespaces.length === 0 ||
@@ -18169,7 +18166,7 @@ export class Orchestrator {
18169
18166
 
18170
18167
  const fallbackNamespace =
18171
18168
  fallbackStorageDir !== null
18172
- ? this.namespaceFromStorageDir(fallbackStorageDir)
18169
+ ? this.storageDirNamespace(fallbackStorageDir)
18173
18170
  : this.config.defaultNamespace;
18174
18171
  maybeAddStorage(fallbackStorage, fallbackNamespace);
18175
18172
 
@@ -20034,41 +20031,18 @@ export class Orchestrator {
20034
20031
  return namespaceIdentityFromToken(m[1]) ?? m[1];
20035
20032
  }
20036
20033
 
20037
- private namespaceFromStorageDir(storageDir: string): string {
20038
- if (!this.config.namespacesEnabled) return this.config.defaultNamespace;
20039
- const resolvedStorageDir = path.resolve(storageDir);
20040
- const resolvedMemoryDir = path.resolve(this.config.memoryDir);
20041
- if (resolvedStorageDir === resolvedMemoryDir)
20042
- return this.config.defaultNamespace;
20043
- const m = resolvedStorageDir.match(/[\\/]namespaces[\\/]([^\\/]+)$/);
20044
- if (!m?.[1]) return this.config.defaultNamespace;
20045
- const dirName = m[1];
20046
- // Token-shaped raw names (round 6, codex P2 — NBsFz): a dir name might be a
20047
- // tokenized identity OR a literal raw namespace name that merely LOOKS like a
20048
- // token (e.g. a configured or dynamic name `ns-616c706861`). The round-trip check below
20049
- // (`namespaceIdentityToken(decoded) === dirName`) is TAUTOLOGICAL for a
20050
- // canonical token string, so it cannot tell a tokenized dir for `alpha` apart
20051
- // from the legacy raw root of a namespace literally named `ns-616c706861`
20052
- // (codex NRCve). A dir name that is itself a KNOWN namespace (configured or
20053
- // catalog-owned at this exact storage root) is therefore preserved as the
20054
- // literal namespace BEFORE attempting to decode it.
20055
- if (this.configuredNamespaces().includes(dirName)) {
20056
- return dirName;
20057
- }
20058
- this.loadNamespaceStorageDirHintsFromCatalog();
20059
- const hintedNamespaces = this.namespaceStorageDirHints.get(resolvedStorageDir);
20060
- if (hintedNamespaces?.has(dirName)) {
20061
- return dirName;
20062
- }
20063
- if (hintedNamespaces?.size === 1) {
20064
- const [hintedNamespace] = hintedNamespaces;
20065
- if (hintedNamespace) return hintedNamespace;
20066
- }
20067
- const decoded = namespaceIdentityFromToken(dirName);
20068
- if (decoded && namespaceIdentityToken(decoded) === dirName) {
20069
- return decoded;
20070
- }
20071
- return dirName;
20034
+ private storageDirNamespace(storageDir: string): string {
20035
+ // #1521: delegates to the scope-module resolver. The inline dir→namespace
20036
+ // derivation (token round-trip guard, catalog hints) is retired so the
20037
+ // adHocNamespaceResolutions ratchet no longer counts this site. Hints are
20038
+ // loaded lazily via the callback (only after early returns, matching the
20039
+ // original behavior — codex P2).
20040
+ return resolveNamespaceFromStorageDir(storageDir, {
20041
+ config: this.config,
20042
+ configuredNamespaces: this.configuredNamespaceList(),
20043
+ hints: this.namespaceStorageDirHints,
20044
+ loadHints: () => this.loadNamespaceStorageDirHintsFromCatalog(),
20045
+ });
20072
20046
  }
20073
20047
 
20074
20048
  // #1522: catalog touch methods removed — touches now happen at the storage chokepoint.
@@ -21,7 +21,12 @@
21
21
  import assert from "node:assert/strict";
22
22
  import test from "node:test";
23
23
 
24
- import { resolveScopePlan } from "./scope-plan.js";
24
+ import {
25
+ getConfiguredNamespaces,
26
+ resolveNamespaceFromStorageDir,
27
+ resolveScopePlan,
28
+ resolveWritableNamespaceValue,
29
+ } from "./scope-plan.js";
25
30
  import {
26
31
  combineNamespaces,
27
32
  lcmSessionKeyForNamespace,
@@ -358,3 +363,165 @@ test("scope-plan: LCM session ids match lcmSessionKeyForNamespace encoding", ()
358
363
  );
359
364
  assert.deepEqual([...plan.lcmReadSessionIds], expected);
360
365
  });
366
+
367
+ // ──────────────────────────────────────────────────────────────────────────
368
+ // Fitness tests: centralized namespace-resolution helpers (#1521 step 3–4)
369
+ //
370
+ // These pin the three pure helpers that replace the ad-hoc resolution scattered
371
+ // across orchestrator.ts and access-service.ts. They MUST match the pre-migration
372
+ // behavior byte-for-byte (the snapshots were derived from the original inline
373
+ // implementations).
374
+ // ──────────────────────────────────────────────────────────────────────────
375
+
376
+ test("getConfiguredNamespaces: default + shared + policies, trimmed and deduped", () => {
377
+ const config = baseConfig({
378
+ defaultNamespace: "default",
379
+ sharedNamespace: "shared",
380
+ namespacePolicies: [
381
+ { name: "team-a", readPrincipals: ["*"], writePrincipals: ["*"] },
382
+ { name: "team-b", readPrincipals: ["*"], writePrincipals: ["*"] },
383
+ ],
384
+ });
385
+ const ns = getConfiguredNamespaces(config);
386
+ assert.deepEqual(ns, ["default", "shared", "team-a", "team-b"]);
387
+ });
388
+
389
+ test("getConfiguredNamespaces: trims whitespace and drops empty", () => {
390
+ const config = baseConfig({
391
+ defaultNamespace: " default ",
392
+ sharedNamespace: "",
393
+ namespacePolicies: [
394
+ { name: "team-a ", readPrincipals: ["*"], writePrincipals: ["*"] },
395
+ ],
396
+ });
397
+ const ns = getConfiguredNamespaces(config);
398
+ assert.deepEqual(ns, ["default", "team-a"]);
399
+ });
400
+
401
+ test("getConfiguredNamespaces: deduplicates identical names", () => {
402
+ const config = baseConfig({
403
+ defaultNamespace: "default",
404
+ sharedNamespace: "default",
405
+ namespacePolicies: [
406
+ { name: "default", readPrincipals: ["*"], writePrincipals: ["*"] },
407
+ ],
408
+ });
409
+ const ns = getConfiguredNamespaces(config);
410
+ assert.deepEqual(ns, ["default"]);
411
+ });
412
+
413
+ // ── resolveNamespaceFromStorageDir ──────────────────────────────────────────
414
+
415
+ test("resolveNamespaceFromStorageDir: namespaces disabled → always default", () => {
416
+ const config = baseConfig({ namespacesEnabled: false });
417
+ const ns = resolveNamespaceFromStorageDir("/some/path/namespaces/team-a", {
418
+ config,
419
+ configuredNamespaces: getConfiguredNamespaces(config),
420
+ });
421
+ assert.equal(ns, "default");
422
+ });
423
+
424
+ test("resolveNamespaceFromStorageDir: memory root → default", () => {
425
+ const config = baseConfig({ memoryDir: "/data/memory" });
426
+ const ns = resolveNamespaceFromStorageDir("/data/memory", {
427
+ config,
428
+ configuredNamespaces: getConfiguredNamespaces(config),
429
+ });
430
+ assert.equal(ns, "default");
431
+ });
432
+
433
+ test("resolveNamespaceFromStorageDir: configured namespace dir → that namespace", () => {
434
+ const config = baseConfig({
435
+ memoryDir: "/data/memory",
436
+ namespacePolicies: [
437
+ { name: "team-a", readPrincipals: ["*"], writePrincipals: ["*"] },
438
+ ],
439
+ });
440
+ const ns = resolveNamespaceFromStorageDir("/data/memory/namespaces/team-a", {
441
+ config,
442
+ configuredNamespaces: getConfiguredNamespaces(config),
443
+ });
444
+ assert.equal(ns, "team-a");
445
+ });
446
+
447
+ test("resolveNamespaceFromStorageDir: tokenized dir → decoded namespace", () => {
448
+ const config = baseConfig({ memoryDir: "/data/memory" });
449
+ // "alpha" tokenizes to ns-616c706861
450
+ const ns = resolveNamespaceFromStorageDir("/data/memory/namespaces/ns-616c706861", {
451
+ config,
452
+ configuredNamespaces: getConfiguredNamespaces(config),
453
+ });
454
+ assert.equal(ns, "alpha");
455
+ });
456
+
457
+ test("resolveNamespaceFromStorageDir: unknown dir → literal dir name", () => {
458
+ const config = baseConfig({ memoryDir: "/data/memory" });
459
+ const ns = resolveNamespaceFromStorageDir("/data/memory/namespaces/custom-ns", {
460
+ config,
461
+ configuredNamespaces: getConfiguredNamespaces(config),
462
+ });
463
+ assert.equal(ns, "custom-ns");
464
+ });
465
+
466
+ test("resolveNamespaceFromStorageDir: catalog hint resolves single-hint dir", () => {
467
+ const config = baseConfig({ memoryDir: "/data/memory" });
468
+ const resolvedDir = "/data/memory/namespaces/dynamic-xyz";
469
+ const hints = new Map([[resolvedDir, new Set(["my-dynamic-ns"])]]);
470
+ const ns = resolveNamespaceFromStorageDir(resolvedDir, {
471
+ config,
472
+ configuredNamespaces: getConfiguredNamespaces(config),
473
+ hints,
474
+ });
475
+ assert.equal(ns, "my-dynamic-ns");
476
+ });
477
+
478
+ // ── resolveWritableNamespaceValue ───────────────────────────────────────────
479
+
480
+ test("resolveWritableNamespaceValue: undefined namespace → default, ok", () => {
481
+ const config = baseConfig();
482
+ const result = resolveWritableNamespaceValue(undefined, "sess-1", undefined, config);
483
+ assert.equal(result.ok, true);
484
+ if (result.ok) assert.equal(result.namespace, "default");
485
+ });
486
+
487
+ test("resolveWritableNamespaceValue: explicit namespace, writable principal → ok", () => {
488
+ const config = baseConfig({
489
+ namespacePolicies: [
490
+ { name: "team-a", readPrincipals: ["alice"], writePrincipals: ["alice"] },
491
+ ],
492
+ });
493
+ const result = resolveWritableNamespaceValue("team-a", "alice:sess-1", "alice", config);
494
+ assert.equal(result.ok, true);
495
+ if (result.ok) assert.equal(result.namespace, "team-a");
496
+ });
497
+
498
+ test("resolveWritableNamespaceValue: explicit namespace, non-writable principal → not_writable", () => {
499
+ const config = baseConfig({
500
+ namespacePolicies: [
501
+ { name: "team-a", readPrincipals: ["alice"], writePrincipals: ["alice"] },
502
+ ],
503
+ });
504
+ const result = resolveWritableNamespaceValue("team-a", "bob:sess-1", "bob", config);
505
+ assert.equal(result.ok, false);
506
+ if (!result.ok) {
507
+ assert.equal(result.reason, "not_writable");
508
+ assert.equal(result.namespace, "team-a");
509
+ }
510
+ });
511
+
512
+ test("resolveWritableNamespaceValue: non-default namespace with namespaces disabled → unsupported", () => {
513
+ const config = baseConfig({ namespacesEnabled: false });
514
+ const result = resolveWritableNamespaceValue("custom-ns", "sess-1", undefined, config);
515
+ assert.equal(result.ok, false);
516
+ if (!result.ok) {
517
+ assert.equal(result.reason, "unsupported");
518
+ assert.equal(result.namespace, "custom-ns");
519
+ }
520
+ });
521
+
522
+ test("resolveWritableNamespaceValue: namespaces disabled, default namespace → ok", () => {
523
+ const config = baseConfig({ namespacesEnabled: false });
524
+ const result = resolveWritableNamespaceValue("default", "sess-1", undefined, config);
525
+ assert.equal(result.ok, true);
526
+ if (result.ok) assert.equal(result.namespace, "default");
527
+ });
@@ -33,10 +33,16 @@
33
33
 
34
34
  import {
35
35
  canReadNamespace,
36
+ canWriteNamespace,
36
37
  defaultNamespaceForPrincipal,
37
38
  recallNamespacesForPrincipal,
38
39
  resolvePrincipal,
39
40
  } from "../namespaces/principal.js";
41
+ import {
42
+ namespaceIdentityFromToken,
43
+ namespaceIdentityToken,
44
+ } from "../namespaces/identity.js";
45
+ import path from "node:path";
40
46
  import type { ResolvedScopeProfilePlan } from "../namespaces/scope-profiles.js";
41
47
  import {
42
48
  expandScopeProfileReadNamespaces,
@@ -318,3 +324,160 @@ export function resolveScopePlan(options: ResolveScopePlanOptions): ScopePlan {
318
324
  scopeProfilePlan,
319
325
  };
320
326
  }
327
+
328
+ // ────────────────────────────────────────────────────────────────────────────
329
+ // Centralized namespace-resolution helpers (issue #1521 step 3–4)
330
+ //
331
+ // The three functions below consolidate the ad-hoc namespace-resolution logic
332
+ // that was previously scattered across orchestrator.ts (namespaceFromStorageDir,
333
+ // configuredNamespaces) and access-service.ts (resolveWritableNamespace). By
334
+ // living in THIS module they are excluded from the adHocNamespaceResolutions
335
+ // ratchet, and every consumer reaches them through a single well-known import
336
+ // instead of reimplementing the derivation inline (#1519's failure mode).
337
+ // ────────────────────────────────────────────────────────────────────────────
338
+
339
+ /**
340
+ * The configured namespace set: default + shared + every namespace-policy
341
+ * name, trimmed and de-duplicated. Pure — reads only from `config`.
342
+ *
343
+ * Replaces the `configuredNamespaces()` private method that lived on both
344
+ * `Orchestrator` and the maintenance namespace-planner (two copies of the
345
+ * same logic).
346
+ */
347
+ export function getConfiguredNamespaces(config: PluginConfig): string[] {
348
+ return Array.from(
349
+ new Set(
350
+ [
351
+ config.defaultNamespace,
352
+ config.sharedNamespace,
353
+ ...config.namespacePolicies.map((policy) => policy.name),
354
+ ]
355
+ .map((value) => value.trim())
356
+ .filter(Boolean),
357
+ ),
358
+ );
359
+ }
360
+
361
+ /**
362
+ * Options for {@link resolveNamespaceFromStorageDir}.
363
+ */
364
+ export interface ResolveNamespaceFromStorageDirOptions {
365
+ /** Plugin config (memoryDir, defaultNamespace, namespacesEnabled, …). */
366
+ readonly config: PluginConfig;
367
+ /**
368
+ * Pre-computed configured-namespace set (from {@link getConfiguredNamespaces}).
369
+ * Passed by the caller so this function does not re-derive it on every call.
370
+ */
371
+ readonly configuredNamespaces: readonly string[];
372
+ /**
373
+ * Catalog-owned dir→namespace hints (best-effort union of configured +
374
+ * catalog-discovered). May be `undefined` when no catalog hints are loaded.
375
+ */
376
+ readonly hints?: ReadonlyMap<string, ReadonlySet<string>> | undefined;
377
+ /**
378
+ * Lazy catalog-hint loader. Called ONLY after the early returns (disabled,
379
+ * memory root, no namespace segment, configured namespace) — matching the
380
+ * original lazy behavior so an eager load does not mark hints as loaded
381
+ * before the catalog file exists (codex P2).
382
+ */
383
+ readonly loadHints?: () => void;
384
+ }
385
+
386
+ /**
387
+ * Derive the namespace a storage directory belongs to.
388
+ *
389
+ * Pure (no orchestrator state): the caller passes `config`, the configured set,
390
+ * and any catalog hints. The logic is byte-identical to the private
391
+ * `namespaceFromStorageDir` method that previously lived on `Orchestrator`
392
+ * (including the token round-trip guard from codex round 6).
393
+ */
394
+ export function resolveNamespaceFromStorageDir(
395
+ storageDir: string,
396
+ options: ResolveNamespaceFromStorageDirOptions,
397
+ ): string {
398
+ const { config } = options;
399
+ if (!config.namespacesEnabled) return config.defaultNamespace;
400
+ const resolvedStorageDir = path.resolve(storageDir);
401
+ const resolvedMemoryDir = path.resolve(config.memoryDir);
402
+ if (resolvedStorageDir === resolvedMemoryDir) return config.defaultNamespace;
403
+ const m = resolvedStorageDir.match(/[\\/]namespaces[\\/]([^\\/]+)$/);
404
+ if (!m?.[1]) return config.defaultNamespace;
405
+ const dirName = m[1];
406
+ // Token-shaped raw names (codex P2 — NBsFz): a dir name might be a tokenized
407
+ // identity OR a literal raw namespace name that merely LOOKS like a token.
408
+ // A dir name that is itself a KNOWN namespace is preserved BEFORE decoding.
409
+ if (options.configuredNamespaces.includes(dirName)) {
410
+ return dirName;
411
+ }
412
+ // Load catalog hints ONLY after the early returns (codex P2: an eager load
413
+ // before the catalog file exists would mark hints as loaded and skip later
414
+ // catalog-derived rows).
415
+ options.loadHints?.();
416
+ const hints = options.hints;
417
+ const hintedNamespaces = hints?.get(resolvedStorageDir);
418
+ if (hintedNamespaces?.has(dirName)) {
419
+ return dirName;
420
+ }
421
+ if (hintedNamespaces?.size === 1) {
422
+ const [hintedNamespace] = hintedNamespaces;
423
+ if (hintedNamespace) return hintedNamespace;
424
+ }
425
+ const decoded = namespaceIdentityFromToken(dirName);
426
+ if (decoded && namespaceIdentityToken(decoded) === dirName) {
427
+ return decoded;
428
+ }
429
+ return dirName;
430
+ }
431
+
432
+ /**
433
+ * Result of resolving a writable namespace: either the authorized namespace
434
+ * or a structured rejection. The caller decides how to surface the rejection
435
+ * (e.g. as an `EngramAccessInputError` in the access-service layer), keeping
436
+ * this module free of transport-specific error types.
437
+ */
438
+ export type WritableNamespaceResult =
439
+ | { readonly ok: true; readonly namespace: string }
440
+ | { readonly ok: false; readonly reason: "unsupported" | "not_writable"; readonly namespace: string };
441
+
442
+ /**
443
+ * Resolve and authorize a writable namespace.
444
+ *
445
+ * Combines the three steps that `AccessService.resolveWritableNamespace`
446
+ * performed inline: (1) resolve the namespace value (trim → default fallback →
447
+ * validate against `namespacesEnabled`), (2) resolve the principal, (3) check
448
+ * `canWriteNamespace`. Returns a structured result so this module stays free of
449
+ * `EngramAccessInputError` (avoiding a circular dependency on access-service.ts).
450
+ *
451
+ * The caller throws `EngramAccessInputError` when `ok === false` — the access-
452
+ * service wrapper does this in one place.
453
+ */
454
+ export function resolveWritableNamespaceValue(
455
+ namespace: string | undefined,
456
+ sessionKey: string | undefined,
457
+ authenticatedPrincipal: string | undefined,
458
+ config: PluginConfig,
459
+ ): WritableNamespaceResult {
460
+ const requested = namespace?.trim();
461
+ let resolved: string;
462
+ if (!requested) {
463
+ resolved = config.defaultNamespace;
464
+ } else if (
465
+ !config.namespacesEnabled &&
466
+ requested !== config.defaultNamespace
467
+ ) {
468
+ return { ok: false, reason: "unsupported", namespace: requested };
469
+ } else {
470
+ resolved = requested;
471
+ }
472
+
473
+ const trusted = authenticatedPrincipal?.trim();
474
+ const principal =
475
+ typeof trusted === "string" && trusted.length > 0
476
+ ? trusted
477
+ : resolvePrincipal(sessionKey, config);
478
+
479
+ if (!canWriteNamespace(principal, resolved, config)) {
480
+ return { ok: false, reason: "not_writable", namespace: resolved };
481
+ }
482
+ return { ok: true, namespace: resolved };
483
+ }