@remnic/core 9.3.675 → 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 (64) 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/schemas.d.ts +22 -22
  37. package/dist/search/factory.js +6 -6
  38. package/dist/search/index.js +11 -11
  39. package/dist/search/lancedb-backend.js +2 -2
  40. package/dist/search/meilisearch-backend.js +2 -2
  41. package/dist/search/orama-backend.js +2 -2
  42. package/dist/transfer/autodetect.js +1 -1
  43. package/dist/transfer/backup.js +1 -1
  44. package/dist/transfer/capsule-export.js +2 -2
  45. package/dist/transfer/types.d.ts +12 -12
  46. package/package.json +7 -2
  47. package/src/orchestrator.ts +50 -197
  48. package/src/scopes/scope-plan.test.ts +360 -0
  49. package/src/scopes/scope-plan.ts +320 -0
  50. package/dist/chunk-R37A3BEW.js.map +0 -1
  51. /package/dist/{chunk-OG7A6AZX.js.map → chunk-2DKXY243.js.map} +0 -0
  52. /package/dist/{chunk-Q5ZU3RNY.js.map → chunk-57ME5VSI.js.map} +0 -0
  53. /package/dist/{chunk-SDLJ2W7S.js.map → chunk-7UTCHQTB.js.map} +0 -0
  54. /package/dist/{chunk-T2AOOHDA.js.map → chunk-ACYX37IM.js.map} +0 -0
  55. /package/dist/{chunk-ZLINDOBG.js.map → chunk-CZMLLVU2.js.map} +0 -0
  56. /package/dist/{chunk-DOCTITOP.js.map → chunk-DGEZKYVI.js.map} +0 -0
  57. /package/dist/{chunk-Q6MIDQEL.js.map → chunk-EQYP3HA6.js.map} +0 -0
  58. /package/dist/{chunk-52LZ42LI.js.map → chunk-ERA5RSMZ.js.map} +0 -0
  59. /package/dist/{chunk-IPLYGWQF.js.map → chunk-KQAFEZQX.js.map} +0 -0
  60. /package/dist/{chunk-SF45RQDX.js.map → chunk-RP64QP7G.js.map} +0 -0
  61. /package/dist/{chunk-QLRYXOAD.js.map → chunk-UDJLF3BO.js.map} +0 -0
  62. /package/dist/{chunk-B55KFEGS.js.map → chunk-YJ4J2JJ2.js.map} +0 -0
  63. /package/dist/{chunk-XVVEKF5I.js.map → chunk-Z56KDLDK.js.map} +0 -0
  64. /package/dist/{chunk-OUWAQVDJ.js.map → chunk-Z6SEG36L.js.map} +0 -0
@@ -325,6 +325,7 @@ import {
325
325
  recallNamespacesForPrincipal,
326
326
  resolvePrincipal,
327
327
  } from "./namespaces/principal.js";
328
+ import { resolveScopePlan } from "./scopes/scope-plan.js";
328
329
  import {
329
330
  expandScopeProfileReadNamespaces,
330
331
  resolveScopeProfilePlan,
@@ -5770,7 +5771,8 @@ export class Orchestrator {
5770
5771
  options.principalOverride.length > 0
5771
5772
  ? options.principalOverride
5772
5773
  : resolvePrincipal(sessionKey, this.config);
5773
- if (this.config.namespacesEnabled && !principal) {
5774
+ const namespacesEnabled = this.config.namespacesEnabled;
5775
+ if (namespacesEnabled && !principal) {
5774
5776
  throw new Error("authentication required: namespaces are enabled and no principal was supplied");
5775
5777
  }
5776
5778
 
@@ -5862,6 +5864,7 @@ export class Orchestrator {
5862
5864
  options.namespace?.trim() || undefined,
5863
5865
  options.principalOverride,
5864
5866
  caps,
5867
+ namespacesEnabled,
5865
5868
  );
5866
5869
  } catch (err) {
5867
5870
  log.debug(`direct-answer observation setup failed: ${err}`);
@@ -5931,62 +5934,27 @@ export class Orchestrator {
5931
5934
  namespaceOverride: string | undefined,
5932
5935
  principalOverride: string | undefined,
5933
5936
  caps: CapabilitySet,
5937
+ namespacesEnabled: boolean,
5934
5938
  ): void {
5935
5939
  const expectedSnapshot = this.lastRecall.get(sessionKey);
5936
5940
  if (expectedSnapshot === null) return;
5937
5941
  if (expectedSnapshot.plannerMode === "no_recall") return;
5938
5942
 
5939
- const principal = principalOverride ?? resolvePrincipal(sessionKey, this.config);
5940
- // Coding-agent overlay (issue #569) is applied when the session has a
5941
- // coding context and there is no explicit namespaceOverride mirrors
5942
- // the main recall path above.
5943
- const observationCodingOverlay =
5944
- namespaceOverride && canReadNamespace(principal, namespaceOverride, this.config)
5945
- ? null
5946
- : this.applyCodingRecallOverlay(sessionKey);
5947
- const observationPrincipalSelf = defaultNamespaceForPrincipal(principal, this.config);
5948
- const observationCodingSelf = observationCodingOverlay
5949
- ? combineNamespaces(observationPrincipalSelf, observationCodingOverlay.namespace)
5950
- : null;
5951
- const observationScopeProfilePlan =
5952
- namespaceOverride && canReadNamespace(principal, namespaceOverride, this.config)
5953
- ? null
5954
- : resolveScopeProfilePlan({
5955
- config: this.config,
5956
- principal,
5957
- codingContext: sessionKey
5958
- ? this.getCodingContextForSession(sessionKey)
5959
- : null,
5960
- codingOverlay: observationCodingOverlay,
5961
- });
5962
- let observationNamespaces: string[];
5963
- if (namespaceOverride && canReadNamespace(principal, namespaceOverride, this.config)) {
5964
- observationNamespaces = [namespaceOverride];
5965
- } else if (observationScopeProfilePlan) {
5966
- observationNamespaces = expandScopeProfileReadNamespaces({
5967
- profilePlan: observationScopeProfilePlan,
5968
- principalSelfNamespace: observationScopeProfilePlan.baseNamespace,
5969
- config: this.config,
5970
- principal,
5971
- codingOverlay: observationCodingOverlay,
5972
- legacyRecallNamespaces: recallNamespacesForPrincipal(principal, this.config),
5973
- });
5974
- } else if (observationCodingOverlay && observationCodingSelf) {
5975
- // Rule 42 / parity with the main recall path: substitute the self
5976
- // namespace within the principal's recall list rather than
5977
- // replacing the full list. Preserves shared and policy-include
5978
- // namespaces for direct-answer observation queries.
5979
- const base = recallNamespacesForPrincipal(principal, this.config);
5980
- const mapped = base.map((ns) =>
5981
- ns === observationPrincipalSelf ? observationCodingSelf : ns,
5982
- );
5983
- const fallbackNs = observationCodingOverlay.readFallbacks.map((fallback) =>
5984
- combineNamespaces(observationPrincipalSelf, fallback),
5985
- );
5986
- observationNamespaces = Array.from(new Set<string>([...mapped, ...fallbackNs]));
5987
- } else {
5988
- observationNamespaces = recallNamespacesForPrincipal(principal, this.config);
5989
- }
5943
+ // Resolve the observation namespace set through the SAME ScopePlan resolver
5944
+ // the main recall path uses (#1521). The observe path does NOT throw on an
5945
+ // unreadable override (it falls through to the coding/legacy branches), so
5946
+ // we skip the readability gate the recall path enforces.
5947
+ const observationScopePlan = resolveScopePlan({
5948
+ config: this.config,
5949
+ sessionKey,
5950
+ namespace: namespaceOverride,
5951
+ principalOverride,
5952
+ codingContext: sessionKey
5953
+ ? this.getCodingContextForSession(sessionKey)
5954
+ : null,
5955
+ namespacesEnabled,
5956
+ });
5957
+ const observationNamespaces = observationScopePlan.readNamespaces;
5990
5958
  const observationQueryPolicy = buildRecallQueryPolicy(prompt, sessionKey, {
5991
5959
  cronRecallPolicyEnabled: this.config.cronRecallPolicyEnabled,
5992
5960
  cronRecallNormalizedQueryMaxChars:
@@ -7587,14 +7555,11 @@ export class Orchestrator {
7587
7555
  && options.principalOverride.length > 0
7588
7556
  ? options.principalOverride
7589
7557
  : resolvePrincipal(sessionKey, this.config);
7590
- if (this.config.namespacesEnabled && !principal) {
7558
+ const namespacesEnabled = this.config.namespacesEnabled;
7559
+ if (namespacesEnabled && !principal) {
7591
7560
  throw new Error("authentication required: namespaces are enabled and no principal was supplied");
7592
7561
  }
7593
7562
  const namespaceOverride = options.namespace?.trim() || undefined;
7594
- const readableRecallNamespaces = recallNamespacesForPrincipal(
7595
- principal,
7596
- this.config,
7597
- );
7598
7563
  if (
7599
7564
  namespaceOverride &&
7600
7565
  !canReadNamespace(principal, namespaceOverride, this.config)
@@ -7603,147 +7568,35 @@ export class Orchestrator {
7603
7568
  `namespace override is not readable: ${namespaceOverride}`,
7604
7569
  );
7605
7570
  }
7606
- // Recall path overlay the coding-agent namespace (issue #569) when
7607
- // the session has a codingContext and `codingMode.projectScope` is true.
7608
- // Explicit `namespace` option still wins, preserving pre-#569 semantics.
7609
- //
7610
- // Rule 42: the overlay substitutes the SELF namespace within the
7611
- // principal's recall list — it does NOT replace the full list. Shared
7612
- // and `includeInRecallByDefault` policy namespaces stay in the recall
7613
- // set so coding sessions continue to see team/shared memories. The
7614
- // overlay is combined with the principal base through `combineNamespaces`
7615
- // to preserve principal isolation (cross-tenant leakage guard).
7616
- const codingOverlay = namespaceOverride ? null : this.applyCodingRecallOverlay(sessionKey);
7617
- const principalSelfNamespace = defaultNamespaceForPrincipal(principal, this.config);
7618
- const codingSelfNamespace = codingOverlay
7619
- ? combineNamespaces(principalSelfNamespace, codingOverlay.namespace)
7620
- : null;
7621
- const scopeProfilePlan = namespaceOverride
7622
- ? null
7623
- : resolveScopeProfilePlan({
7624
- config: this.config,
7625
- principal,
7626
- codingContext: sessionKey
7627
- ? this.getCodingContextForSession(sessionKey)
7628
- : null,
7629
- codingOverlay,
7630
- });
7631
- const profileEffectiveNamespace = scopeProfilePlan?.writeNamespace || scopeProfilePlan?.readNamespaces[0];
7632
- const selfNamespace =
7633
- namespaceOverride ??
7634
- profileEffectiveNamespace ??
7635
- codingSelfNamespace ??
7636
- principalSelfNamespace;
7637
- let recallNamespaces: string[];
7638
- if (namespaceOverride) {
7639
- recallNamespaces = [namespaceOverride];
7640
- } else if (scopeProfilePlan) {
7641
- recallNamespaces = expandScopeProfileReadNamespaces({
7642
- profilePlan: scopeProfilePlan,
7643
- principalSelfNamespace: scopeProfilePlan.baseNamespace,
7644
- config: this.config,
7645
- principal,
7646
- codingOverlay,
7647
- legacyRecallNamespaces: readableRecallNamespaces,
7648
- });
7649
- } else if (codingOverlay && codingSelfNamespace) {
7650
- // Substitute the principal's self namespace with the coding-scoped
7651
- // one, and append any read fallbacks (branch→project, PR 3) combined
7652
- // with the principal base so principal isolation is preserved on
7653
- // fallback entries as well.
7654
- const mapped = readableRecallNamespaces.map((ns) =>
7655
- ns === principalSelfNamespace ? codingSelfNamespace : ns,
7656
- );
7657
- const fallbackNs = codingOverlay.readFallbacks.map((fallback) =>
7658
- combineNamespaces(principalSelfNamespace, fallback),
7659
- );
7660
- recallNamespaces = Array.from(new Set<string>([...mapped, ...fallbackNs]));
7661
- } else {
7662
- recallNamespaces = readableRecallNamespaces;
7663
- }
7664
- // Catalog touch (issue #1499): record reads against the recalled namespaces
7665
- // so the catalog reflects active read scopes. Best-effort, failure-tolerant.
7666
- // Round 3 (codex P2): gate behind the no_recall guard — when the planner
7667
- // selects `no_recall` retrieval is skipped entirely (see the early return at
7668
- // `recallMode === "no_recall"` below), so marking every readable namespace as
7669
- // read would falsely inflate `lastReadAt` / catalog recency.
7670
- // Round 4 (codex P2): also skip when the effective memory result limit is
7671
- // zero (`topK: 0`, a disabled/zero `memories` recall section, etc.). The QMD
7672
- // path explicitly returns before searching when `recallResultLimit <= 0`, so
7673
- // no namespace is actually read and the touch would be spurious.
7674
- // NOTE: the catalog read touch is recorded LATER, immediately after the
7675
- // Phase 1 `throwIfRecallAborted` gate (round 6, codex P2 / cursor Medium —
7676
- // NDXHa/NDmle), so it fires only once retrieval is actually about to run.
7677
- // Recording it here (recall entry) would set `lastReadAt` for recalls that
7678
- // are aborted, error out, or short-circuit before any QMD/filesystem read.
7679
-
7680
- // Effective LCM read NAMESPACE SET (#1505 thread "Include coding fallback
7681
- // namespaces in LCM reads"). `observe` archives LCM / structured history
7682
- // under `${effectiveNamespace}:${sessionKey}` for whichever namespace was
7683
- // effective at write time. A branch-scoped session whose evidence was
7684
- // archived at project / root scope must still surface it, exactly as normal
7685
- // QMD/file recall does — QMD/file recall searches the primary overlay key AND
7686
- // `codingOverlay.readFallbacks` (project / root), NOT just the primary
7687
- // overlay key. The prior single `lcmReadSessionId` only targeted the primary
7688
- // overlay, so branch-scoped sessions missed fallback LCM evidence.
7571
+ // Resolve every namespace-bearing field through ONE ScopePlan (#1521): the
7572
+ // read set, the LCM read keys, the coding overlay, and the scope-profile plan
7573
+ // all come from a single pure resolver that delegates to the same helpers
7574
+ // the inline code used. Parity snapshots in scope-plan.test.ts pin the
7575
+ // outputs so this migration cannot change behavior.
7689
7576
  //
7690
- // READ-AUTHORIZATION (preserved from the prior round's
7691
- // `lcmReadNamespaceForSession` gate; rule 39 / 42 / 48): the coding-overlay
7692
- // namespace AND its fallbacks are `<principal>-project-*` sub-namespaces of
7693
- // the principal SELF base, authorized transitively by that base. They are
7694
- // included ONLY when the principal self base is in the readable recall set
7695
- // (`readableRecallNamespaces` — gated by `defaultRecallNamespaces.includes
7696
- // ("self")` AND `canReadNamespace`). When the self base is NOT readable (e.g.
7697
- // a write-only / self-omitted principal), the overlay rows are unauthorized
7698
- // for this reader, so the LCM read collapses to the default store — exactly
7699
- // what an unqualified, unauthorized recall resolves to — and NEVER searches a
7700
- // `<principal>-project-*` key (no cross-tenant read leak). This mirrors what
7701
- // the rest of recall surfaces for such a principal (its readable
7702
- // shared/policy namespaces have no per-session LCM key, so they contribute
7703
- // nothing here). `recallNamespaces` itself appends fallbacks unconditionally
7704
- // for QMD/file recall; the LCM read keys apply the stricter, self-base gate
7705
- // so the prior round's authorization invariant is preserved.
7706
- const codingOverlaySelfReadable =
7707
- codingOverlay !== null &&
7708
- (scopeProfilePlan
7709
- ? scopeProfilePlan.layers.some((layer) => layer.id === "userProject" && layer.readable)
7710
- : readableRecallNamespaces.includes(principalSelfNamespace));
7711
- let lcmReadNamespaces: string[];
7712
- if (namespaceOverride) {
7713
- // Explicit namespace already read-authorized above (canReadNamespace gate).
7714
- lcmReadNamespaces = [namespaceOverride];
7715
- } else if (scopeProfilePlan) {
7716
- // Scope profiles define a layered read stack; LCM-backed evidence uses the
7717
- // same namespace set as QMD/file recall so team/global/shared observations
7718
- // are not silently skipped.
7719
- lcmReadNamespaces = recallNamespaces;
7720
- } else if (codingOverlay && codingSelfNamespace && codingOverlaySelfReadable) {
7721
- // Self base readable → overlay rows authorized. Read the primary overlay
7722
- // key first, then each coding read fallback (project → root), combined with
7723
- // the principal base for isolation — the SAME ordered set QMD/file recall
7724
- // searches for this authorized coding session.
7725
- const fallbackNs = codingOverlay.readFallbacks.map((fallback) =>
7726
- combineNamespaces(principalSelfNamespace, fallback),
7727
- );
7728
- lcmReadNamespaces = [codingSelfNamespace, ...fallbackNs];
7729
- } else {
7730
- // No overlay, OR overlay present but self base unreadable → collapse to the
7731
- // default store (raw sessionKey), exactly as the prior round did. No
7732
- // `<principal>-project-*` overlay key is searched.
7733
- lcmReadNamespaces = [this.config.defaultNamespace];
7734
- }
7735
- // Map the ordered, read-authorized namespace set → ordered, deduped LCM read
7736
- // session_id set. Single-user / no-overlay recall passes a single-namespace
7737
- // set that collapses to the raw `sessionKey`, so this is `[sessionKey]` —
7738
- // byte-for-byte the pre-#1495 single-key behavior.
7739
- const lcmReadSessionIds =
7740
- scopeProfilePlan && !sessionKey
7741
- ? []
7742
- : lcmReadSessionIdsForNamespaces(
7743
- lcmReadNamespaces,
7744
- sessionKey,
7745
- this.config.defaultNamespace,
7746
- );
7577
+ // Catalog read touch (issue #1499) is recorded LATER — after the Phase 1
7578
+ // abort gate so it fires only when retrieval actually runs, not for
7579
+ // aborted / short-circuited recalls.
7580
+ const scopePlan = resolveScopePlan({
7581
+ config: this.config,
7582
+ sessionKey,
7583
+ namespace: options.namespace,
7584
+ principalOverride:
7585
+ typeof options.principalOverride === "string"
7586
+ && options.principalOverride.length > 0
7587
+ ? options.principalOverride
7588
+ : undefined,
7589
+ codingContext: sessionKey
7590
+ ? this.getCodingContextForSession(sessionKey)
7591
+ : null,
7592
+ namespacesEnabled,
7593
+ });
7594
+ const {
7595
+ readNamespaces: recallNamespaces,
7596
+ baseNamespace: selfNamespace,
7597
+ scopeProfilePlan,
7598
+ lcmReadSessionIds,
7599
+ } = scopePlan;
7747
7600
  // Query an LCM-backed read across the ordered read key set and return the
7748
7601
  // FIRST non-empty result (#1505 fallback-namespace unification). The primary
7749
7602
  // overlay key is tried first; if a branch-scoped session has no rows under its
@@ -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
+ });