@remnic/core 9.3.648 → 9.3.650

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 (54) hide show
  1. package/dist/access-cli.js +4 -4
  2. package/dist/access-http.d.ts +2 -2
  3. package/dist/access-http.js +4 -4
  4. package/dist/access-mcp.d.ts +2 -2
  5. package/dist/access-mcp.js +3 -3
  6. package/dist/{access-service-DFXIlGvZ.d.ts → access-service-DIZRHQ7Q.d.ts} +255 -2
  7. package/dist/access-service.d.ts +2 -2
  8. package/dist/access-service.js +2 -2
  9. package/dist/bootstrap.d.ts +1 -1
  10. package/dist/{chunk-TWVRDGTX.js → chunk-23RYLGYA.js} +185 -55
  11. package/dist/chunk-23RYLGYA.js.map +1 -0
  12. package/dist/{chunk-CNRZ6WJU.js → chunk-3IJEQWQX.js} +4 -4
  13. package/dist/{chunk-XUGQQPGO.js → chunk-AGRPGAKR.js} +12 -1
  14. package/dist/chunk-AGRPGAKR.js.map +1 -0
  15. package/dist/{chunk-6GIKAUTN.js → chunk-MMJANTJX.js} +33 -2
  16. package/dist/{chunk-6GIKAUTN.js.map → chunk-MMJANTJX.js.map} +1 -1
  17. package/dist/{chunk-6BNFVP7Y.js → chunk-RZOBQ23O.js} +2 -2
  18. package/dist/{chunk-AEIZEAP7.js → chunk-TUMH6EDV.js} +12 -15
  19. package/dist/chunk-TUMH6EDV.js.map +1 -0
  20. package/dist/{chunk-FUXV6HSO.js → chunk-TVOPSKOK.js} +3 -3
  21. package/dist/{chunk-5ETA6OAS.js → chunk-YAFSTKTH.js} +608 -80
  22. package/dist/chunk-YAFSTKTH.js.map +1 -0
  23. package/dist/{cli-DrL2Nv4j.d.ts → cli-BG4ybtJr.d.ts} +2 -2
  24. package/dist/cli.d.ts +3 -3
  25. package/dist/cli.js +7 -7
  26. package/dist/explicit-capture.d.ts +1 -1
  27. package/dist/index.d.ts +4 -4
  28. package/dist/index.js +8 -8
  29. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  30. package/dist/{orchestrator-DEQW9j0Z.d.ts → orchestrator-CX-oqwJq.d.ts} +58 -0
  31. package/dist/orchestrator.d.ts +1 -1
  32. package/dist/orchestrator.js +3 -3
  33. package/dist/resume-bundles.js +2 -2
  34. package/dist/transcript.d.ts +18 -1
  35. package/dist/transcript.js +5 -3
  36. package/package.json +1 -1
  37. package/src/access-service-lcm-forgery.test.ts +410 -0
  38. package/src/access-service-observe-lcm-parity.test.ts +1397 -0
  39. package/src/access-service-observe-scope.test.ts +599 -0
  40. package/src/access-service-raw-excerpt-read-gate.test.ts +443 -0
  41. package/src/access-service.ts +1270 -113
  42. package/src/cli.ts +10 -12
  43. package/src/coding/coding-namespace.test.ts +44 -0
  44. package/src/coding/coding-namespace.ts +163 -0
  45. package/src/orchestrator.ts +335 -77
  46. package/src/transcript-day-range.test.ts +101 -0
  47. package/src/transcript.ts +26 -0
  48. package/dist/chunk-5ETA6OAS.js.map +0 -1
  49. package/dist/chunk-AEIZEAP7.js.map +0 -1
  50. package/dist/chunk-TWVRDGTX.js.map +0 -1
  51. package/dist/chunk-XUGQQPGO.js.map +0 -1
  52. /package/dist/{chunk-CNRZ6WJU.js.map → chunk-3IJEQWQX.js.map} +0 -0
  53. /package/dist/{chunk-6BNFVP7Y.js.map → chunk-RZOBQ23O.js.map} +0 -0
  54. /package/dist/{chunk-FUXV6HSO.js.map → chunk-TVOPSKOK.js.map} +0 -0
@@ -9,6 +9,7 @@ import type { AnomalyDetectorResult } from "./recall-audit-anomaly.js";
9
9
  import { resolveGitContext } from "./coding/git-context.js";
10
10
  import {
11
11
  combineNamespaces,
12
+ lcmSessionKeyForNamespace,
12
13
  projectTagProjectId,
13
14
  resolveCodingNamespaceOverlay,
14
15
  } from "./coding/coding-namespace.js";
@@ -822,6 +823,54 @@ export interface CodingScopedWriteInput {
822
823
  projectTag?: string;
823
824
  }
824
825
 
826
+ /**
827
+ * Internal, single-resolution plan describing the effective memory scope for a
828
+ * write-producing access request (#1495, seed for epic #1494). One plan is
829
+ * resolved per request and EVERY side effect (LCM archival, extraction replay,
830
+ * objective-state snapshot, response) consumes the same `writeNamespace`, so an
831
+ * observed turn and its extracted memories never drift away from the namespace a
832
+ * same-session project-scoped recall searches (rule 39 / 42).
833
+ *
834
+ * The resolver that produces this is READ-ONLY with respect to namespace
835
+ * authorization: an explicit namespace is authorized through the existing
836
+ * `canWriteNamespace` policy path, and a coding overlay is always REBUILT from
837
+ * the authenticated principal's base — never accepted as a caller string — so a
838
+ * caller can never reach another principal's overlay by forging an
839
+ * overlay-shaped namespace (rule 42 / 47 / 48).
840
+ */
841
+ export interface MemoryScopePlan {
842
+ /** Resolved request principal (auth precedence applied), or undefined. */
843
+ principal?: string;
844
+ /** Explicit `namespace` supplied by the caller, if any (already authorized). */
845
+ explicitNamespace?: string;
846
+ /** Principal self base namespace before any coding overlay. */
847
+ baseNamespace: string;
848
+ /** Effective write namespace — what every side effect must use. */
849
+ writeNamespace: string;
850
+ /**
851
+ * Effective namespace the objective-state snapshot writer must target.
852
+ *
853
+ * Objective-state has a STRICTER, pre-#1495 contract than the LCM/extraction
854
+ * write path (#928): an IMPLICIT (no explicit `namespace`) snapshot is based
855
+ * on the PRINCIPAL SELF namespace (`defaultNamespaceForPrincipal`) and is
856
+ * authorized against THAT base (rule 48, least-privilege) — never silently
857
+ * routed to `config.defaultNamespace`. Only the LCM/extraction/response path
858
+ * collapses an unqualified write to `config.defaultNamespace` (memory_store
859
+ * parity, rule 39). With an explicit namespace, or once a coding overlay
860
+ * applies, both targets converge: `objectiveStateNamespace === writeNamespace`.
861
+ *
862
+ * Keeping the two as separate fields of ONE plan preserves rule 22 (single
863
+ * resolution point) while honoring each consumer's historical contract.
864
+ */
865
+ objectiveStateNamespace: string;
866
+ /** Namespaces a same-session recall would read (cheap subset). */
867
+ readNamespaces: string[];
868
+ /** Whether the coding overlay changed the base namespace. */
869
+ codingOverlayApplied: boolean;
870
+ /** Non-fatal diagnostics surfaced during resolution. */
871
+ warnings: string[];
872
+ }
873
+
825
874
  export interface EngramAccessMemoryStoreRequest
826
875
  extends EngramAccessWriteEnvelope,
827
876
  ExplicitCaptureInput,
@@ -874,10 +923,45 @@ export interface EngramAccessObserveRequest {
874
923
  projectTag?: string;
875
924
  }
876
925
 
926
+ /**
927
+ * Additive diagnostic view of the effective {@link MemoryScopePlan} resolved for
928
+ * an `observe` request (#1495 / epic #1494). Lets callers and tests inspect
929
+ * which namespace the operation actually wrote to without changing the
930
+ * backward-compatible `namespace` field. Purely informational — never gates
931
+ * authorization.
932
+ */
933
+ export interface EngramAccessScopeDebug {
934
+ /** Resolved principal, or `undefined` when none could be derived. */
935
+ principal?: string;
936
+ /** Explicit `namespace` from the request, if one was supplied. */
937
+ explicitNamespace?: string;
938
+ /** Principal self base before any coding overlay. */
939
+ baseNamespace: string;
940
+ /** Effective write namespace every side effect of the request uses. */
941
+ writeNamespace: string;
942
+ /** Whether the coding (project/branch) overlay changed the base namespace. */
943
+ codingOverlayApplied: boolean;
944
+ /** Namespaces a same-session recall would read, when cheap to compute. */
945
+ readNamespaces?: string[];
946
+ }
947
+
877
948
  export interface EngramAccessObserveResponse {
878
949
  accepted: number;
879
950
  sessionKey: string;
951
+ /**
952
+ * Backward-compatible base writable namespace (pre-#1495 semantics). Kept
953
+ * unchanged so existing callers/tests are not broken. The namespace the
954
+ * operation ACTUALLY wrote to is {@link EngramAccessObserveResponse.effectiveNamespace}.
955
+ */
880
956
  namespace: string;
957
+ /**
958
+ * Effective write namespace every memory-producing side effect of this
959
+ * request used (LCM archival, extraction replay, objective-state snapshot).
960
+ * Equals the namespace a same-session project-scoped recall searches (#1495).
961
+ */
962
+ effectiveNamespace: string;
963
+ /** Additive diagnostic view of the resolved scope plan (#1495). */
964
+ scopeDebug?: EngramAccessScopeDebug;
881
965
  lcmArchived: boolean;
882
966
  extractionQueued: boolean;
883
967
  }
@@ -1326,6 +1410,233 @@ export class EngramAccessService {
1326
1410
  return combineNamespaces(base, overlay.namespace);
1327
1411
  }
1328
1412
 
1413
+ /**
1414
+ * Resolve ONE effective memory scope plan for a write-producing request
1415
+ * (#1495 / seed for epic #1494). The returned {@link MemoryScopePlan} is the
1416
+ * single source of truth `observe` (and, later, other write surfaces) consume
1417
+ * so every side effect lands in `plan.writeNamespace`.
1418
+ *
1419
+ * Authorization mirrors {@link resolveCodingScopedWriteNamespace} EXACTLY so
1420
+ * `observe`'s scoping is identical to `memory_store`/`suggestion_submit`
1421
+ * (rule 39 — feature gates identical across code paths):
1422
+ * - an explicit `namespace` always wins and is authorized strictly through
1423
+ * `resolveWritableNamespace` → `canWriteNamespace`; an overlay-shaped string
1424
+ * is never a writable target (rule 42 / 47 / 48);
1425
+ * - with NO overlay, the base stays on `config.defaultNamespace` (pre-#1434
1426
+ * behavior), auth-checked;
1427
+ * - WITH an overlay, the base is the principal self namespace and the overlay
1428
+ * is REBUILT from that authorized base — never accepted as a caller string.
1429
+ *
1430
+ * READ-ONLY: this never mutates session coding context. Callers that need the
1431
+ * `cwd`/`projectTag` bound to the session (so a later bare recall is scoped)
1432
+ * must attach it via `maybeAttachCodingContext` BEFORE calling this, which
1433
+ * also preserves the no-orphan-context guard (attach only after auth passes).
1434
+ * The overlay here reads the session's attached context first (matching recall
1435
+ * precedence), falling back to the per-call `cwd`/`projectTag`.
1436
+ */
1437
+ private async resolveMemoryScopePlan(
1438
+ request: CodingScopedWriteInput & {
1439
+ namespace?: string;
1440
+ sessionKey?: string;
1441
+ authenticatedPrincipal?: string;
1442
+ },
1443
+ ): Promise<MemoryScopePlan> {
1444
+ const warnings: string[] = [];
1445
+ const principal = this.resolveRequestPrincipal(
1446
+ request.sessionKey,
1447
+ request.authenticatedPrincipal,
1448
+ );
1449
+ const hasExplicitNamespace =
1450
+ typeof request.namespace === "string" && request.namespace.trim().length > 0;
1451
+
1452
+ if (hasExplicitNamespace) {
1453
+ // Explicit namespace wins; authorized through the existing policy path.
1454
+ // The overlay never applies, so base == write == the explicit namespace.
1455
+ // Objective-state converges on the same explicit target (the stricter
1456
+ // principal-self contract only governs the IMPLICIT path).
1457
+ const writeNamespace = this.resolveWritableNamespace(
1458
+ request.namespace,
1459
+ request.sessionKey,
1460
+ request.authenticatedPrincipal,
1461
+ );
1462
+ return {
1463
+ principal,
1464
+ explicitNamespace: request.namespace!.trim(),
1465
+ baseNamespace: writeNamespace,
1466
+ writeNamespace,
1467
+ objectiveStateNamespace: writeNamespace,
1468
+ readNamespaces: [writeNamespace],
1469
+ codingOverlayApplied: false,
1470
+ warnings,
1471
+ };
1472
+ }
1473
+
1474
+ // No explicit namespace → principal self base, optionally overlaid with the
1475
+ // session's coding (project/branch) context — the SAME resolution recall and
1476
+ // the orchestrator buffer-flush write path use (rule 42 symmetry).
1477
+ const baseNamespace = defaultNamespaceForPrincipal(
1478
+ principal,
1479
+ this.orchestrator.config,
1480
+ );
1481
+
1482
+ // Resolve the coding overlay through the SAME orchestrator method recall and
1483
+ // the buffer-flush write path use (`applyCodingNamespaceOverlay`), NOT a
1484
+ // private re-implementation. Routing through the one shared method (rule 22 /
1485
+ // 42) means a project-scoped observe writes to byte-for-byte the namespace a
1486
+ // same-session recall reads — and that the read/write overlay logic cannot
1487
+ // drift (the #1495 drift this PR exists to close). The orchestrator method
1488
+ // already gates on `namespacesEnabled` + a bound/derivable coding context, so
1489
+ // it returns the base unchanged when no overlay applies.
1490
+ //
1491
+ // `applyCodingNamespaceOverlay` reads the session's ATTACHED context. The
1492
+ // scope plan runs BEFORE `maybeAttachCodingContext` (resolve-before-mutate),
1493
+ // so when nothing is attached yet we seed the per-call cwd/projectTag context
1494
+ // first — identical to what attach would bind — so the overlay is the same
1495
+ // either way (Codex review precedence: session context first, per-call
1496
+ // fallback).
1497
+ const hasSession =
1498
+ typeof request.sessionKey === "string" && request.sessionKey.length > 0;
1499
+ const overlayEligible =
1500
+ hasSession &&
1501
+ this.orchestrator.config.namespacesEnabled === true &&
1502
+ this.orchestrator.config.codingMode?.projectScope === true;
1503
+
1504
+ // Resolve the coding context the overlay must use: the session's ATTACHED
1505
+ // context first (so a bound session wins), else the per-call cwd/projectTag —
1506
+ // identical precedence to recall and to `resolveCodingScopedWriteNamespace`.
1507
+ let attachedContext = hasSession
1508
+ ? this.orchestrator.getCodingContextForSession(request.sessionKey)
1509
+ : null;
1510
+ // Track whether WE seeded the per-call context so we can leave the session
1511
+ // exactly as we found it on any rejection (read-only contract / no-orphan
1512
+ // guard, observe-scope "unauthorized overlay self-base" test).
1513
+ let seededContext = false;
1514
+ if (overlayEligible && !attachedContext) {
1515
+ attachedContext = await this.resolveCodingContextFromOptions(request);
1516
+ if (attachedContext) {
1517
+ // Seed the per-call context so the shared
1518
+ // `applyCodingNamespaceOverlay` (which reads ATTACHED session context)
1519
+ // overlays it through the ONE method recall/buffer-flush use (rule 22 /
1520
+ // 42) — no private re-implementation that could drift. On the happy
1521
+ // path `maybeAttachCodingContext` re-binds the identical context after
1522
+ // auth passes; on a rejection we clear the seed below, so the session is
1523
+ // untouched either way.
1524
+ this.orchestrator.setCodingContextForSession(
1525
+ request.sessionKey!,
1526
+ attachedContext,
1527
+ );
1528
+ seededContext = true;
1529
+ }
1530
+ }
1531
+
1532
+ // Clear a seed we added, used only on the rejection paths so a failed
1533
+ // observe never leaves an orphaned project binding (read-only contract).
1534
+ const clearSeededContext = (): void => {
1535
+ if (seededContext && hasSession) {
1536
+ this.orchestrator.setCodingContextForSession(request.sessionKey!, null);
1537
+ }
1538
+ };
1539
+
1540
+ const overlaidBase = this.orchestrator.applyCodingNamespaceOverlay(
1541
+ request.sessionKey,
1542
+ baseNamespace,
1543
+ );
1544
+ const codingOverlayApplied = overlaidBase !== baseNamespace;
1545
+
1546
+ if (!codingOverlayApplied) {
1547
+ // No overlay → the LCM/extraction/response write namespace mirrors the
1548
+ // legacy memory_store path (resolveWritableNamespace with no explicit
1549
+ // namespace), collapsing the namespaces-disabled / no-session /
1550
+ // projectScope-off cases to config.defaultNamespace exactly as before
1551
+ // (rule 39 parity with resolveCodingScopedWriteNamespace).
1552
+ const writeNamespace = this.resolveWritableNamespace(
1553
+ undefined,
1554
+ request.sessionKey,
1555
+ request.authenticatedPrincipal,
1556
+ );
1557
+ // Objective-state keeps its STRICTER pre-#1495 contract (#928): an implicit
1558
+ // snapshot is based on the PRINCIPAL SELF namespace and authorized against
1559
+ // THAT base (rule 48 least-privilege). This rejection MUST stay (security):
1560
+ // an implicit observe by a principal that cannot write its own self
1561
+ // namespace must not silently snapshot objective-state to the default
1562
+ // store.
1563
+ //
1564
+ // GATED on objective-state writes being ENABLED, exactly like the pre-#1495
1565
+ // code (`if (shouldWriteObjectiveState && !hasExplicitNamespace && …)`).
1566
+ // The general LCM/extraction write path collapses an unqualified write to
1567
+ // config.defaultNamespace (always writable), so when objective-state writes
1568
+ // are OFF there is no self-base write to authorize and observe must NOT
1569
+ // reject (the "skips … when writes are disabled" invariant). When namespaces
1570
+ // are off the self base collapses to config.defaultNamespace, so this is a
1571
+ // no-op for single-store deployments either way.
1572
+ const willWriteObjectiveState =
1573
+ this.orchestrator.config.objectiveStateMemoryEnabled === true &&
1574
+ this.orchestrator.config.objectiveStateSnapshotWritesEnabled === true;
1575
+ if (
1576
+ willWriteObjectiveState &&
1577
+ this.orchestrator.config.namespacesEnabled === true &&
1578
+ !canWriteNamespace(principal, baseNamespace, this.orchestrator.config)
1579
+ ) {
1580
+ clearSeededContext();
1581
+ throw new EngramAccessInputError(
1582
+ `namespace is not writable: ${baseNamespace}`,
1583
+ );
1584
+ }
1585
+ return {
1586
+ principal,
1587
+ // scopeDebug.baseNamespace must report the principal SELF base
1588
+ // (`defaultNamespaceForPrincipal`), NOT the general write namespace —
1589
+ // which collapses to config.defaultNamespace on this implicit no-overlay
1590
+ // path (#1505 cursor "Wrong scopeDebug base namespace"). It already
1591
+ // matches `objectiveStateNamespace` below; `writeNamespace` is unchanged.
1592
+ baseNamespace,
1593
+ writeNamespace,
1594
+ // Implicit objective-state stays on the principal self base, NOT the
1595
+ // (possibly default) general write namespace — preserving the #928
1596
+ // semantics the objective-state suite asserts.
1597
+ objectiveStateNamespace: baseNamespace,
1598
+ readNamespaces: [writeNamespace],
1599
+ codingOverlayApplied: false,
1600
+ warnings,
1601
+ };
1602
+ }
1603
+
1604
+ // Overlay applied → both the general write namespace AND objective-state
1605
+ // converge on the overlaid principal self base. Authorize the self base
1606
+ // (the overlay is a principal-owned `project-*` sub-namespace derived from
1607
+ // it, so it needs no separate write policy — rule 42 / 47 / 48).
1608
+ if (!canWriteNamespace(principal, baseNamespace, this.orchestrator.config)) {
1609
+ clearSeededContext();
1610
+ throw new EngramAccessInputError(
1611
+ `namespace is not writable: ${baseNamespace}`,
1612
+ );
1613
+ }
1614
+ const writeNamespace = overlaidBase;
1615
+ const readNamespaces = [writeNamespace];
1616
+ // Include read fallbacks (branch→project→root) so the diagnostic readNamespaces
1617
+ // matches what a same-session recall searches. Resolved through the pure
1618
+ // overlay helper to enumerate fallbacks; the write namespace itself already
1619
+ // came from `applyCodingNamespaceOverlay` so the two agree.
1620
+ const overlay = resolveCodingNamespaceOverlay(
1621
+ attachedContext,
1622
+ this.orchestrator.config.codingMode,
1623
+ this.orchestrator.config.defaultNamespace,
1624
+ );
1625
+ for (const fallback of overlay?.readFallbacks ?? []) {
1626
+ const ns = combineNamespaces(baseNamespace, fallback);
1627
+ if (!readNamespaces.includes(ns)) readNamespaces.push(ns);
1628
+ }
1629
+ return {
1630
+ principal,
1631
+ baseNamespace,
1632
+ writeNamespace,
1633
+ objectiveStateNamespace: writeNamespace,
1634
+ readNamespaces,
1635
+ codingOverlayApplied: true,
1636
+ warnings,
1637
+ };
1638
+ }
1639
+
1329
1640
  private async objectiveStateStoreLocationForNamespace(namespace: string): Promise<{
1330
1641
  memoryDir: string;
1331
1642
  objectiveStateStoreDir?: string;
@@ -1471,6 +1782,26 @@ export class EngramAccessService {
1471
1782
  startedAt: number;
1472
1783
  requestedMode?: RecallPlanMode | "auto";
1473
1784
  normalizedMode?: RecallPlanMode;
1785
+ /**
1786
+ * Read-authorization-gated namespace for the raw-excerpt LCM lookup (#1505
1787
+ * thread 2f7). Threaded through to `serializeRecallResults` so the
1788
+ * `includeRecall` x-ray path honours the SAME read gate as normal recall and
1789
+ * never attaches overlay transcript rows the gate excludes.
1790
+ */
1791
+ rawExcerptNamespace?: string;
1792
+ /**
1793
+ * Ordered, read-authorized LCM read session_id SET (#1505 fallback
1794
+ * unification). Threaded through to `serializeRecallResults` so the x-ray raw
1795
+ * disclosure path also finds excerpts archived at the coding read fallbacks.
1796
+ */
1797
+ rawExcerptSessionIds?: string[];
1798
+ /**
1799
+ * Force NO raw excerpts (#1505 thread NBHWz). Set when the IMPLICIT
1800
+ * raw-excerpt read gate found NO readable LCM namespace, so the x-ray
1801
+ * includeRecall path degrades to empty excerpts rather than falling back to
1802
+ * the write/overlay namespace the read gate excludes.
1803
+ */
1804
+ rawExcerptsSuppressed?: boolean;
1474
1805
  }): Promise<EngramAccessRecallResponse> {
1475
1806
  const memoryIds = options.snapshot.results.map((result) => result.memoryId);
1476
1807
  const resultPaths = options.snapshot.results.map((result) => result.path);
@@ -1510,6 +1841,15 @@ export class EngramAccessService {
1510
1841
  {
1511
1842
  query: options.query,
1512
1843
  ...(options.sessionKey ? { sessionKey: options.sessionKey } : {}),
1844
+ ...(options.rawExcerptNamespace
1845
+ ? { rawExcerptNamespace: options.rawExcerptNamespace }
1846
+ : {}),
1847
+ ...(options.rawExcerptSessionIds
1848
+ ? { rawExcerptSessionIds: options.rawExcerptSessionIds }
1849
+ : {}),
1850
+ ...(options.rawExcerptsSuppressed
1851
+ ? { rawExcerptsSuppressed: options.rawExcerptsSuppressed }
1852
+ : {}),
1513
1853
  },
1514
1854
  );
1515
1855
  const context = results
@@ -1545,7 +1885,40 @@ export class EngramAccessService {
1545
1885
  private async serializeRecallResults(
1546
1886
  snapshot: LastRecallSnapshot | null,
1547
1887
  disclosure: RecallDisclosure,
1548
- rawContext: { query: string; sessionKey?: string } | null = null,
1888
+ rawContext:
1889
+ | {
1890
+ query: string;
1891
+ sessionKey?: string;
1892
+ /**
1893
+ * Read-authorization-gated namespace for the raw-excerpt LCM lookup
1894
+ * (#1505 thread 2f7). When the caller supplies it, the raw lookup uses
1895
+ * THIS namespace prefix instead of `snapshot.namespace` (the
1896
+ * write/overlay namespace), so raw disclosure honours the SAME read
1897
+ * gate as normal recall + `lcmSearch`. Omitted ⇒ falls back to the
1898
+ * snapshot namespace (single-store / sessionless callers, unchanged).
1899
+ */
1900
+ rawExcerptNamespace?: string;
1901
+ /**
1902
+ * Ordered, read-authorized LCM read session_id SET (#1505 fallback
1903
+ * unification). When supplied, raw disclosure queries each key (primary
1904
+ * coding overlay → read fallbacks) and merges rows so a branch-scoped
1905
+ * session finds excerpts archived at project/root scope. Already
1906
+ * read-gated, so no unauthorized overlay key is present. Omitted ⇒ the
1907
+ * legacy single `rawExcerptNamespace`-prefixed key (unchanged).
1908
+ */
1909
+ rawExcerptSessionIds?: string[];
1910
+ /**
1911
+ * Force NO raw excerpts even when `disclosure === "raw"` (#1505 thread
1912
+ * NBHWz). Set by callers when the IMPLICIT raw-excerpt read gate found
1913
+ * NO readable LCM namespace (a restrictive `default` READ policy with
1914
+ * no readable overlay/self namespace). The lookup must NOT fall back to
1915
+ * `snapshot.namespace` (the write/overlay namespace the read gate
1916
+ * excludes) — it returns empty excerpts so raw recall degrades
1917
+ * gracefully instead of leaking unreadable rows or throwing.
1918
+ */
1919
+ rawExcerptsSuppressed?: boolean;
1920
+ }
1921
+ | null = null,
1549
1922
  ): Promise<EngramAccessMemorySummary[]> {
1550
1923
  if (!snapshot) return [];
1551
1924
  const namespace = snapshot.namespace ? this.resolveNamespace(snapshot.namespace) : this.orchestrator.config.defaultNamespace;
@@ -1676,12 +2049,33 @@ export class EngramAccessService {
1676
2049
  // for a future PR if/when the LCM index can be joined to memory ids.
1677
2050
  // Coerce `null` (non-raw disclosure) to `undefined` so the optional
1678
2051
  // serializer field is never explicitly `null`.
1679
- // Pass the resolved namespace so the LCM lookup can mirror the
1680
- // `${namespace}:${sessionKey}` prefix that `observe()` writes.
1681
- const rawExcerptsResult = await this.fetchRawExcerpts(
1682
- disclosure,
1683
- rawContext ? { ...rawContext, namespace } : null,
1684
- );
2052
+ // Namespace for the LCM `${namespace}:${sessionKey}` prefix: prefer the
2053
+ // caller-supplied READ-AUTHORIZATION-GATED `rawExcerptNamespace` (#1505
2054
+ // thread 2f7) so raw disclosure honours the same read gate as normal recall
2055
+ // + `lcmSearch` and never attaches `<principal>-project-*` overlay rows the
2056
+ // gate excludes. Fall back to the snapshot's resolved namespace only when no
2057
+ // gated namespace was threaded (sessionless / legacy callers) — unchanged.
2058
+ const rawExcerptsResult =
2059
+ rawContext?.rawExcerptsSuppressed === true
2060
+ ? // Implicit raw recall with NO readable LCM namespace (#1505 thread
2061
+ // NBHWz): emit empty excerpts rather than falling back to the
2062
+ // write/overlay `namespace` the read gate excludes.
2063
+ []
2064
+ : await this.fetchRawExcerpts(
2065
+ disclosure,
2066
+ rawContext
2067
+ ? {
2068
+ query: rawContext.query,
2069
+ ...(rawContext.sessionKey
2070
+ ? { sessionKey: rawContext.sessionKey }
2071
+ : {}),
2072
+ namespace: rawContext.rawExcerptNamespace ?? namespace,
2073
+ ...(rawContext.rawExcerptSessionIds
2074
+ ? { lcmSessionIds: rawContext.rawExcerptSessionIds }
2075
+ : {}),
2076
+ }
2077
+ : null,
2078
+ );
1685
2079
  const rawExcerpts = rawExcerptsResult ?? undefined;
1686
2080
 
1687
2081
  for (const memoryPath of snapshot.resultPaths ?? []) {
@@ -1787,7 +2181,23 @@ export class EngramAccessService {
1787
2181
  */
1788
2182
  private async fetchRawExcerpts(
1789
2183
  disclosure: RecallDisclosure,
1790
- context: { query: string; sessionKey?: string; namespace?: string } | null,
2184
+ context: {
2185
+ query: string;
2186
+ sessionKey?: string;
2187
+ namespace?: string;
2188
+ /**
2189
+ * Pre-resolved, ordered, read-authorized LCM read session_id SET (#1505
2190
+ * fallback unification). When supplied, raw disclosure queries each key in
2191
+ * order (primary coding overlay → read fallbacks) and merges rows, exactly
2192
+ * as the orchestrator recall path and `lcmSearch` do, so a branch-scoped
2193
+ * session finds excerpts archived at project/root scope. Already
2194
+ * read-gated by `resolveLcmReadSessionIds`, so an unauthorized
2195
+ * `<principal>-project-*` key is never present. Falls back to the legacy
2196
+ * single `namespace`-prefixed key when absent (sessionless / legacy
2197
+ * callers).
2198
+ */
2199
+ lcmSessionIds?: string[];
2200
+ } | null,
1791
2201
  ): Promise<EngramAccessMemorySummary["rawExcerpts"] | null> {
1792
2202
  if (disclosure !== "raw") return null;
1793
2203
  if (!context || !context.query) return [];
@@ -1802,26 +2212,44 @@ export class EngramAccessService {
1802
2212
  const lcm = this.orchestrator.lcmEngine;
1803
2213
  if (!lcm || !lcm.enabled) return [];
1804
2214
  try {
1805
- const lcmSessionKey =
2215
+ const legacyKey =
1806
2216
  context.namespace &&
1807
2217
  context.namespace !== this.orchestrator.config.defaultNamespace
1808
2218
  ? `${context.namespace}:${context.sessionKey}`
1809
2219
  : context.sessionKey;
1810
- const rows = await lcm.searchContextFull(
1811
- context.query,
1812
- // Cap the excerpt fanout so recall responses stay bounded. Five
1813
- // matches is enough to anchor the model in the raw transcript
1814
- // without ballooning token spend; raw is meant as the escape
1815
- // hatch, not the default.
1816
- 5,
1817
- lcmSessionKey,
1818
- );
1819
- return rows.map((r) => ({
1820
- turnIndex: r.turn_index,
1821
- role: r.role,
1822
- content: r.content,
1823
- sessionId: r.session_id,
1824
- }));
2220
+ const lcmSessionIds =
2221
+ context.lcmSessionIds && context.lcmSessionIds.length > 0
2222
+ ? context.lcmSessionIds
2223
+ : [legacyKey];
2224
+ // Cap the excerpt fanout so recall responses stay bounded. Five matches
2225
+ // is enough to anchor the model in the raw transcript without ballooning
2226
+ // token spend; raw is meant as the escape hatch, not the default. The cap
2227
+ // is applied across the MERGED result set so adding fallback keys never
2228
+ // inflates the excerpt budget.
2229
+ const limit = 5;
2230
+ const seenRows = new Set<string>();
2231
+ const excerpts: NonNullable<EngramAccessMemorySummary["rawExcerpts"]> = [];
2232
+ for (const lcmSessionKey of lcmSessionIds) {
2233
+ if (excerpts.length >= limit) break;
2234
+ const rows = await lcm.searchContextFull(
2235
+ context.query,
2236
+ limit,
2237
+ lcmSessionKey,
2238
+ );
2239
+ for (const r of rows) {
2240
+ const dedupeKey = `${r.session_id} ${r.turn_index}`;
2241
+ if (seenRows.has(dedupeKey)) continue;
2242
+ seenRows.add(dedupeKey);
2243
+ excerpts.push({
2244
+ turnIndex: r.turn_index,
2245
+ role: r.role,
2246
+ content: r.content,
2247
+ sessionId: r.session_id,
2248
+ });
2249
+ if (excerpts.length >= limit) break;
2250
+ }
2251
+ }
2252
+ return excerpts;
1825
2253
  } catch {
1826
2254
  // CLAUDE.md rule 13: never let an external subsystem (LCM/SQLite)
1827
2255
  // crash the primary recall flow.
@@ -2533,9 +2961,61 @@ export class EngramAccessService {
2533
2961
  topKConfidence,
2534
2962
  });
2535
2963
  const disclosure = escalationDecision.effective;
2964
+ // Gate the raw-excerpt LCM read with the SAME read-authorization namespace
2965
+ // `lcmSearch` + the in-prompt LCM sections use (#1505 thread 2f7), so
2966
+ // `disclosure: "raw"` never attaches `<principal>-project-*` overlay rows
2967
+ // when the principal can WRITE but not READ its self base (or
2968
+ // `defaultRecallNamespaces` omits `self`). Computed ONLY for raw disclosure:
2969
+ // it is the sole consumer, and resolving the overlay on every chunk/section
2970
+ // recall would be wasted work — keeping non-raw recall byte-for-byte
2971
+ // unchanged.
2972
+ // Trim the sessionKey to match what `orchestrator.recall(...)` already does
2973
+ // (`request.sessionKey?.trim() || undefined`) and what the x-ray raw-excerpt
2974
+ // path uses (cursor "Raw excerpt key not trimmed"). A whitespace-padded key
2975
+ // otherwise drives recall under one identity but resolves the raw-excerpt
2976
+ // overlay namespace + LCM `session_id` under a DIFFERENT (untrimmed) prefix,
2977
+ // so excerpts are gated/queried inconsistently with recall and the x-ray path.
2978
+ const trimmedSessionKey = request.sessionKey?.trim() || undefined;
2979
+ const rawExcerptNamespace =
2980
+ disclosure === "raw"
2981
+ ? this.resolveRawExcerptReadNamespace(
2982
+ request.namespace,
2983
+ trimmedSessionKey,
2984
+ authenticatedPrincipal,
2985
+ )
2986
+ : undefined;
2987
+ // `undefined` for an IMPLICIT raw recall means NO readable LCM namespace
2988
+ // exists (restrictive `default` READ policy, no readable overlay/self) —
2989
+ // suppress excerpts rather than fall back to the write/overlay namespace the
2990
+ // read gate excludes (#1505 thread NBHWz). An EXPLICIT namespace always
2991
+ // resolves (or throws) above, so suppression only applies to the implicit
2992
+ // path.
2993
+ const hasExplicitNamespace =
2994
+ typeof request.namespace === "string" &&
2995
+ request.namespace.trim().length > 0;
2996
+ const rawExcerptsSuppressed =
2997
+ disclosure === "raw" &&
2998
+ !hasExplicitNamespace &&
2999
+ rawExcerptNamespace === undefined;
3000
+ // Ordered, read-authorized LCM read key SET (#1505 fallback unification) so
3001
+ // raw disclosure finds excerpts a branch-scoped session archived at
3002
+ // project/root scope — exactly as recall + `lcmSearch` do. Only with a
3003
+ // concrete sessionKey; already read-gated.
3004
+ const rawExcerptSessionIds =
3005
+ disclosure === "raw" && rawExcerptNamespace && trimmedSessionKey
3006
+ ? this.resolveLcmReadSessionIds(
3007
+ request.namespace,
3008
+ rawExcerptNamespace,
3009
+ trimmedSessionKey,
3010
+ authenticatedPrincipal,
3011
+ )
3012
+ : undefined;
2536
3013
  let results = await this.serializeRecallResults(snapshot, disclosure, {
2537
3014
  query,
2538
- sessionKey: request.sessionKey,
3015
+ sessionKey: trimmedSessionKey,
3016
+ ...(rawExcerptNamespace ? { rawExcerptNamespace } : {}),
3017
+ ...(rawExcerptSessionIds ? { rawExcerptSessionIds } : {}),
3018
+ ...(rawExcerptsSuppressed ? { rawExcerptsSuppressed } : {}),
2539
3019
  });
2540
3020
 
2541
3021
  // Tag filter (issue #689). Applied post-recall, post-serialization so
@@ -3028,14 +3508,62 @@ export class EngramAccessService {
3028
3508
  // identity but probes LCM under a different prefix and
3029
3509
  // misses stored excerpts (Cursor Low review on PR #699).
3030
3510
  const trimmedSessionKey = request.sessionKey?.trim() || undefined;
3031
- const rawExcerpts =
3511
+ // Read-authorization-gated namespace for the raw-excerpt LCM lookup
3512
+ // (#1505 thread 2f7). NOT `snapshot.namespace` (the write/overlay
3513
+ // namespace), which would attach `<principal>-project-*` overlay rows
3514
+ // when the principal can WRITE but not READ its self base. Mirrors the
3515
+ // recall + `lcmSearch` read gate. Resolved ONLY for raw disclosure (its
3516
+ // sole consumer); the `namespace` above is still used for the
3517
+ // memory-FILE reads below (a separate, snapshot-scoped read), so non-raw
3518
+ // x-ray decoration stays byte-for-byte unchanged.
3519
+ const rawExcerptNamespace =
3032
3520
  disclosure === "raw"
3521
+ ? this.resolveRawExcerptReadNamespace(
3522
+ request.namespace,
3523
+ trimmedSessionKey,
3524
+ authenticatedPrincipal,
3525
+ )
3526
+ : namespace;
3527
+ // `undefined` for an IMPLICIT raw recall means NO readable LCM namespace
3528
+ // exists (restrictive `default` READ policy, no readable overlay/self)
3529
+ // — suppress excerpts rather than fall back to the write/overlay
3530
+ // namespace the read gate excludes (#1505 thread NBHWz).
3531
+ const xrayHasExplicitNamespace =
3532
+ typeof request.namespace === "string" &&
3533
+ request.namespace.trim().length > 0;
3534
+ const rawExcerptsSuppressed =
3535
+ disclosure === "raw" &&
3536
+ !xrayHasExplicitNamespace &&
3537
+ rawExcerptNamespace === undefined;
3538
+ // Ordered, read-authorized LCM read key SET (#1505 fallback
3539
+ // unification) so raw disclosure finds excerpts a branch-scoped session
3540
+ // archived at project/root scope — exactly as recall + `lcmSearch` do.
3541
+ // Only meaningful with a concrete sessionKey + a readable namespace;
3542
+ // already read-gated so no unauthorized overlay key is present.
3543
+ const rawExcerptSessionIds =
3544
+ disclosure === "raw" && trimmedSessionKey && rawExcerptNamespace
3545
+ ? this.resolveLcmReadSessionIds(
3546
+ request.namespace,
3547
+ rawExcerptNamespace,
3548
+ trimmedSessionKey,
3549
+ authenticatedPrincipal,
3550
+ )
3551
+ : undefined;
3552
+ const rawExcerpts =
3553
+ disclosure === "raw" && !rawExcerptsSuppressed
3033
3554
  ? await this.fetchRawExcerpts(disclosure, {
3034
3555
  query,
3035
3556
  ...(trimmedSessionKey ? { sessionKey: trimmedSessionKey } : {}),
3036
- namespace,
3557
+ ...(rawExcerptNamespace
3558
+ ? { namespace: rawExcerptNamespace }
3559
+ : {}),
3560
+ ...(rawExcerptSessionIds
3561
+ ? { lcmSessionIds: rawExcerptSessionIds }
3562
+ : {}),
3037
3563
  })
3038
- : null;
3564
+ : disclosure === "raw"
3565
+ ? []
3566
+ : null;
3039
3567
  const rawExcerptText =
3040
3568
  rawExcerpts && rawExcerpts.length > 0
3041
3569
  ? rawExcerpts.map((e) => e.content).join("\n")
@@ -3134,6 +3662,40 @@ export class EngramAccessService {
3134
3662
  xrayResponse.snapshotFound === true &&
3135
3663
  xrayResponse.snapshot
3136
3664
  ) {
3665
+ // Same read-authorization-gated raw-excerpt namespace the recall path uses
3666
+ // (#1505 thread 2f7), so the includeRecall x-ray path can't leak overlay
3667
+ // transcript rows via raw disclosure. Resolved ONLY for raw disclosure (the
3668
+ // sole consumer) so non-raw x-ray recall stays byte-for-byte unchanged. The
3669
+ // ordered LCM read key SET (#1505 fallback unification) adds the coding read
3670
+ // fallbacks so a branch-scoped session also finds excerpts at project/root
3671
+ // scope.
3672
+ const xrayRawExcerptNamespace =
3673
+ disclosure === "raw"
3674
+ ? this.resolveRawExcerptReadNamespace(
3675
+ request.namespace,
3676
+ recallSessionKey,
3677
+ authenticatedPrincipal,
3678
+ )
3679
+ : undefined;
3680
+ // `undefined` for an IMPLICIT raw recall means NO readable LCM namespace
3681
+ // exists — suppress excerpts rather than fall back to the write/overlay
3682
+ // namespace the read gate excludes (#1505 thread NBHWz).
3683
+ const xrayHasExplicitNamespace =
3684
+ typeof request.namespace === "string" &&
3685
+ request.namespace.trim().length > 0;
3686
+ const xrayRawExcerptsSuppressed =
3687
+ disclosure === "raw" &&
3688
+ !xrayHasExplicitNamespace &&
3689
+ xrayRawExcerptNamespace === undefined;
3690
+ const xrayRawExcerptSessionIds =
3691
+ disclosure === "raw" && xrayRawExcerptNamespace && recallSessionKey
3692
+ ? this.resolveLcmReadSessionIds(
3693
+ request.namespace,
3694
+ xrayRawExcerptNamespace,
3695
+ recallSessionKey,
3696
+ authenticatedPrincipal,
3697
+ )
3698
+ : undefined;
3137
3699
  return {
3138
3700
  ...xrayResponse,
3139
3701
  recall: await this.buildRecallResponseFromXraySnapshot({
@@ -3144,6 +3706,15 @@ export class EngramAccessService {
3144
3706
  startedAt: recallStartedAt,
3145
3707
  requestedMode: request.mode,
3146
3708
  normalizedMode: mode,
3709
+ ...(xrayRawExcerptNamespace
3710
+ ? { rawExcerptNamespace: xrayRawExcerptNamespace }
3711
+ : {}),
3712
+ ...(xrayRawExcerptSessionIds
3713
+ ? { rawExcerptSessionIds: xrayRawExcerptSessionIds }
3714
+ : {}),
3715
+ ...(xrayRawExcerptsSuppressed
3716
+ ? { rawExcerptsSuppressed: xrayRawExcerptsSuppressed }
3717
+ : {}),
3147
3718
  }),
3148
3719
  };
3149
3720
  }
@@ -4293,67 +4864,81 @@ export class EngramAccessService {
4293
4864
  }
4294
4865
  }
4295
4866
 
4296
- // Validate namespace authorization BEFORE attaching coding context so
4297
- // a failed auth check doesn't leave orphaned context on the session
4298
- // (Codex review P2).
4299
- const hasExplicitNamespace =
4300
- typeof request.namespace === "string" &&
4301
- request.namespace.trim().length > 0;
4302
- const principal = this.resolveRequestPrincipal(
4303
- request.sessionKey,
4304
- request.authenticatedPrincipal,
4305
- );
4306
- const namespace = this.resolveWritableNamespace(
4307
- request.namespace,
4308
- request.sessionKey,
4309
- request.authenticatedPrincipal,
4310
- );
4867
+ // 1. Resolve the FULL effective scope plan BEFORE any session mutation
4868
+ // (Codex P2 / Cursor "orphan context after overlay auth"). The plan is
4869
+ // read-only and re-runs the SAME authorization as
4870
+ // memory_store/suggestion_submit (rule 39): the explicit-namespace check
4871
+ // AND the coding-overlay self-base `canWriteNamespace` check both run
4872
+ // here. Because `maybeAttachCodingContext` has NOT run yet, the plan's
4873
+ // overlay resolves from the per-call `cwd`/`projectTag` fallback
4874
+ // (`resolveCodingContextFromOptions`) — identical to the context that
4875
+ // would be attached — so the scope is the same either way. Running the
4876
+ // plan first means an `observe` that ultimately throws on a non-writable
4877
+ // self base leaves NO coding context bound to the session, matching how
4878
+ // `memory_store` resolves its full scoped write namespace before any
4879
+ // session mutation.
4880
+ const scope = await this.resolveMemoryScopePlan(request);
4881
+ const writeNamespace = scope.writeNamespace;
4882
+
4883
+ // Backward-compatible BASE writable namespace (pre-#1495 response semantics)
4884
+ // for the legacy `namespace` response field. DERIVED from the already-resolved
4885
+ // scope plan — NOT a second `resolveWritableNamespace(request.namespace,...)`
4886
+ // call (#1505 thread jvO). The fresh call re-authorized `undefined ⇒
4887
+ // config.defaultNamespace` a SECOND time; under a restrictive default-namespace
4888
+ // write policy that re-auth could REJECT an otherwise valid project-scoped
4889
+ // observe whose effective self/project write target the scope plan already
4890
+ // authorized (the same target memory_store/suggestion_submit accept). Worse,
4891
+ // that post-plan rejection fired AFTER `resolveMemoryScopePlan` may have seeded
4892
+ // the coding context, leaving an orphaned session binding behind. The plan is
4893
+ // the single authorization point (rule 22 / 39); the legacy field must reuse it
4894
+ // and never re-authorize. Pre-#1495 semantics were exactly
4895
+ // `resolveWritableNamespace(request.namespace)` (overlay-agnostic): the explicit
4896
+ // namespace when supplied, else `config.defaultNamespace`. `scope.explicitNamespace`
4897
+ // carries the authorized explicit value; the no-overlay implicit
4898
+ // `scope.writeNamespace` IS `config.defaultNamespace`, so an unqualified observe
4899
+ // stays byte-for-byte identical to the legacy response.
4900
+ const namespace = scope.explicitNamespace
4901
+ ? scope.writeNamespace
4902
+ : scope.codingOverlayApplied
4903
+ ? this.orchestrator.config.defaultNamespace
4904
+ : scope.writeNamespace;
4311
4905
  const shouldWriteObjectiveState =
4312
4906
  this.orchestrator.config.objectiveStateMemoryEnabled === true &&
4313
4907
  this.orchestrator.config.objectiveStateSnapshotWritesEnabled === true;
4314
- const objectiveStateBaseNamespace = hasExplicitNamespace
4315
- ? namespace
4316
- : defaultNamespaceForPrincipal(principal, this.orchestrator.config);
4317
- if (
4318
- shouldWriteObjectiveState &&
4319
- !hasExplicitNamespace &&
4320
- !canWriteNamespace(
4321
- principal,
4322
- objectiveStateBaseNamespace,
4323
- this.orchestrator.config,
4324
- )
4325
- ) {
4326
- throw new EngramAccessInputError(
4327
- `namespace is not writable: ${objectiveStateBaseNamespace}`,
4328
- );
4329
- }
4330
4908
 
4331
- // Auto-resolve coding context from cwd/projectTag so observe writes
4332
- // route to the correct project namespace (rule 42: same namespace layer
4333
- // as recall).
4909
+ // 2. Auto-resolve coding context from cwd/projectTag so a LATER bare recall
4910
+ // on the same session is project-scoped (rule 42: same namespace layer as
4911
+ // recall). Done AFTER the scope plan authorized the write, so a rejected
4912
+ // request never leaves orphaned context on the session.
4334
4913
  await this.maybeAttachCodingContext(request.sessionKey, {
4335
4914
  cwd: request.cwd,
4336
4915
  projectTag: request.projectTag,
4337
4916
  });
4338
4917
 
4339
- const objectiveStateNamespace = hasExplicitNamespace
4340
- ? namespace
4341
- : this.orchestrator.applyCodingNamespaceOverlay(
4342
- request.sessionKey,
4343
- objectiveStateBaseNamespace,
4344
- );
4345
-
4346
- // Prefix sessionKey with namespace for LCM archival so turns are namespace-scoped.
4347
- // This ensures multi-tenant isolation in the LCM archive.
4348
- const lcmSessionKey = namespace !== this.orchestrator.config.defaultNamespace
4349
- ? `${namespace}:${request.sessionKey}`
4350
- : request.sessionKey;
4351
-
4918
+ // Prefix sessionKey with the EFFECTIVE write namespace for LCM archival so
4919
+ // observed turns are scoped to the same namespace project-scoped recall
4920
+ // reads. The SAME `lcmSessionKeyForNamespace` helper is used by the
4921
+ // orchestrator recall readers and by compaction flush/record, so the LCM
4922
+ // write key and every read/flush key agree (#1495, rule 42). Only prefixes
4923
+ // when the namespace diverges from the default store; a single-store
4924
+ // deployment keeps the raw sessionKey unchanged.
4925
+ const lcmSessionKey =
4926
+ lcmSessionKeyForNamespace(
4927
+ writeNamespace,
4928
+ request.sessionKey,
4929
+ this.orchestrator.config.defaultNamespace,
4930
+ ) ?? request.sessionKey;
4931
+
4932
+ // 4. Objective-state snapshots → the scope plan's objective-state namespace.
4933
+ // For explicit-namespace and coding-overlay writes this equals
4934
+ // writeNamespace; for an IMPLICIT write it is the principal SELF base
4935
+ // (#928 contract, already auth-checked inside the scope plan), not the
4936
+ // general default-store write namespace.
4352
4937
  if (shouldWriteObjectiveState) {
4353
4938
  try {
4354
4939
  const objectiveStateLocation =
4355
4940
  await this.objectiveStateStoreLocationForNamespace(
4356
- objectiveStateNamespace,
4941
+ scope.objectiveStateNamespace,
4357
4942
  );
4358
4943
  await recordObjectiveStateSnapshotsFromObservedMessages({
4359
4944
  memoryDir: objectiveStateLocation.memoryDir,
@@ -4370,6 +4955,7 @@ export class EngramAccessService {
4370
4955
  }
4371
4956
  }
4372
4957
 
4958
+ // 5. LCM archival → effective write namespace.
4373
4959
  // lcmArchived in the response means "LCM archival was queued" (not
4374
4960
  // "completed"), matching extractionQueued semantics. Both run async.
4375
4961
  let lcmArchived = false;
@@ -4385,11 +4971,22 @@ export class EngramAccessService {
4385
4971
  }
4386
4972
  }
4387
4973
 
4974
+ // 6. Extraction/replay → effective write namespace for STORAGE, ORIGINAL
4975
+ // sessionKey for IDENTITY (provenance + threading).
4388
4976
  let extractionQueued = false;
4389
4977
  if (request.skipExtraction !== true) {
4390
4978
  const turns = request.messages.map((m) => ({
4391
4979
  source: "openclaw" as const,
4392
- sessionKey: lcmSessionKey,
4980
+ // Identity-vs-routing separation (#1505 thread 1, cursor): extraction
4981
+ // derives the provenance principal via `resolvePrincipal(turn.sessionKey)`
4982
+ // and threads `turn.sessionKey` into conversation threading. Feeding the
4983
+ // namespace-PREFIXED `lcmSessionKey` here mis-derived the principal to
4984
+ // `default` (a `<ns>:<key>` string matches no prefix/map rule and fails
4985
+ // the `agent:` heuristic). Pass the ORIGINAL sessionKey so identity is
4986
+ // correct; storage routing is pinned separately via
4987
+ // writeNamespaceOverride below, and the authenticated principal is pinned
4988
+ // via principalOverride.
4989
+ sessionKey: request.sessionKey,
4393
4990
  role: m.role,
4394
4991
  content: m.content,
4395
4992
  parts: m.parts,
@@ -4397,6 +4994,38 @@ export class EngramAccessService {
4397
4994
  sourceFormat: m.sourceFormat,
4398
4995
  timestamp: new Date().toISOString(),
4399
4996
  }));
4997
+ // Pin extraction STORAGE to the effective namespace rather than letting the
4998
+ // orchestrator re-derive one from the session key + coding overlay — that
4999
+ // re-derivation would have to reparse identity and could miss the overlay
5000
+ // (the #1495 drift). Passing writeNamespaceOverride makes the extraction
5001
+ // target deterministic and identical to LCM/objective-state (rule 39).
5002
+ //
5003
+ // Pin WHENEVER namespaces are enabled, not only when writeNamespace differs
5004
+ // from the default store (#1505 round 3, codex "Pin default-store extraction
5005
+ // writes too"). For an unqualified/no-overlay observe by a principal that
5006
+ // HAS a self namespace, writeNamespace is `config.defaultNamespace` but an
5007
+ // unpinned `runExtraction` would fall back to
5008
+ // `defaultNamespaceForPrincipal(principal)` = the SELF namespace — diverging
5009
+ // from where LCM/objective-state/response wrote (`default`). Pinning the
5010
+ // resolved writeNamespace forces all side effects onto the one scope-plan
5011
+ // namespace. When namespaces are DISABLED the router collapses every
5012
+ // namespace to one store, so leaving the override undefined preserves the
5013
+ // existing single-store routing byte-for-byte.
5014
+ const writeNamespaceOverride =
5015
+ this.orchestrator.config.namespacesEnabled === true
5016
+ ? writeNamespace
5017
+ : undefined;
5018
+ // Pin provenance PRINCIPAL to the scope plan's resolved principal (#1505
5019
+ // thread 1). The scope plan already applied auth precedence
5020
+ // (authenticatedPrincipal/principalOverride > resolvePrincipal(original
5021
+ // sessionKey)), so this is the same identity the surface authorized — never
5022
+ // a `default` fallback parsed from a prefixed key. Omitted when no principal
5023
+ // resolved (namespaces-disabled / unauthenticated single-store), preserving
5024
+ // existing behavior.
5025
+ const principalOverride =
5026
+ typeof scope.principal === "string" && scope.principal.length > 0
5027
+ ? scope.principal
5028
+ : undefined;
4400
5029
  // Fire-and-forget: queue extraction in the background so the HTTP
4401
5030
  // response returns immediately. LCM archival (above) is also
4402
5031
  // enqueue-only; extraction involves LLM calls that can take
@@ -4409,6 +5038,8 @@ export class EngramAccessService {
4409
5038
  try {
4410
5039
  const extractionPromise = this.orchestrator.ingestReplayBatch(turns, {
4411
5040
  archiveLcm: false,
5041
+ writeNamespaceOverride,
5042
+ principalOverride,
4412
5043
  });
4413
5044
  extractionPromise.catch((err) => {
4414
5045
  log.error(`access-observe background extraction failed: ${err}`);
@@ -4421,13 +5052,22 @@ export class EngramAccessService {
4421
5052
  }
4422
5053
 
4423
5054
  log.info(
4424
- `access-observe namespace=${namespace} sessionKey=${request.sessionKey} messages=${request.messages.length} lcm=${lcmArchived} extraction=${extractionQueued}`,
5055
+ `access-observe namespace=${namespace} effectiveNamespace=${writeNamespace} sessionKey=${request.sessionKey} messages=${request.messages.length} lcm=${lcmArchived} extraction=${extractionQueued}`,
4425
5056
  );
4426
5057
 
4427
5058
  return {
4428
5059
  accepted: request.messages.length,
4429
5060
  sessionKey: request.sessionKey,
4430
5061
  namespace,
5062
+ effectiveNamespace: writeNamespace,
5063
+ scopeDebug: {
5064
+ principal: scope.principal,
5065
+ explicitNamespace: scope.explicitNamespace,
5066
+ baseNamespace: scope.baseNamespace,
5067
+ writeNamespace: scope.writeNamespace,
5068
+ codingOverlayApplied: scope.codingOverlayApplied,
5069
+ readNamespaces: scope.readNamespaces,
5070
+ },
4431
5071
  lcmArchived,
4432
5072
  extractionQueued,
4433
5073
  };
@@ -4439,37 +5079,140 @@ export class EngramAccessService {
4439
5079
  }
4440
5080
 
4441
5081
  const principal = this.resolveRequestPrincipal(request.sessionKey, request.authenticatedPrincipal);
4442
- const namespace = this.resolveReadableNamespace(request.namespace, principal);
5082
+ const hasExplicitNamespace =
5083
+ typeof request.namespace === "string" &&
5084
+ request.namespace.trim().length > 0;
5085
+ // Resolve the readable base namespace WITHOUT pre-authorizing `default`
5086
+ // (#1505 thread NBHWz). An EXPLICIT namespace is still authorized strictly
5087
+ // via `resolveReadableNamespace` (explicit reads must pass the ACL — throws
5088
+ // on an unreadable explicit namespace). For an IMPLICIT read, derive the
5089
+ // fallback from the ALREADY read-authorized recall namespace set instead of
5090
+ // read-authorizing `config.defaultNamespace`: under a restrictive `default`
5091
+ // READ policy where the principal's self namespace is readable, normal recall
5092
+ // still succeeds via `recallNamespacesForPrincipal`, so `lcmSearch` must too
5093
+ // (the same defect class the raw-excerpt path fixes). `undefined` ⇒ no
5094
+ // readable LCM namespace exists, so return NO rows rather than throwing.
5095
+ const namespace = hasExplicitNamespace
5096
+ ? this.resolveReadableNamespace(request.namespace, principal)
5097
+ : this.resolveImplicitLcmReadFallbackNamespace(principal);
4443
5098
 
4444
5099
  if (!this.orchestrator.lcmEngine || !this.orchestrator.lcmEngine.enabled) {
4445
5100
  return {
4446
5101
  query: request.query,
4447
- namespace,
5102
+ namespace: namespace ?? this.orchestrator.config.defaultNamespace,
4448
5103
  results: [],
4449
5104
  count: 0,
4450
5105
  lcmEnabled: false,
4451
5106
  };
4452
5107
  }
4453
5108
 
5109
+ // No readable LCM namespace for an IMPLICIT read (restrictive `default` READ
5110
+ // policy, no readable overlay/self) ⇒ return NO rows instead of pre-
5111
+ // authorizing the denied default (#1505 thread NBHWz). Normal recall still
5112
+ // succeeds through the readable self namespace; LCM search degrades to empty.
5113
+ if (namespace === undefined) {
5114
+ return {
5115
+ query: request.query,
5116
+ namespace: this.orchestrator.config.defaultNamespace,
5117
+ results: [],
5118
+ count: 0,
5119
+ lcmEnabled: true,
5120
+ };
5121
+ }
5122
+
4454
5123
  const limit = Math.max(1, Math.min(request.limit ?? 10, 100));
4455
- const lcmSessionKey = request.sessionKey && namespace !== this.orchestrator.config.defaultNamespace
4456
- ? `${namespace}:${request.sessionKey}`
4457
- : request.sessionKey;
4458
- const lcmSessionPrefix = request.sessionPrefix && namespace !== this.orchestrator.config.defaultNamespace
4459
- ? `${namespace}:${request.sessionPrefix}`
4460
- : request.sessionPrefix;
4461
- const rawResults = await this.orchestrator.lcmEngine.searchContextFull(
4462
- request.query,
4463
- limit,
4464
- lcmSessionKey,
4465
- lcmSessionPrefix,
5124
+ // Route the LCM read session_id AND prefix through the SAME overlay-aware
5125
+ // namespace `observe`'s write key and compaction use (#1505 round 3). A
5126
+ // project-scoped `observe` with no explicit namespace archived under the
5127
+ // coding-overlay namespace; deriving the prefix only from the readable
5128
+ // `namespace` (as before) would search the raw key and miss those turns.
5129
+ // The effective namespace is resolved ONCE from the real session
5130
+ // (`request.sessionKey`) the prefix is a search fragment with no bound
5131
+ // coding context, so it inherits the same namespace. Collapses to the raw key
5132
+ // for single-store / no-overlay / explicit-default flows (existing behavior).
5133
+ const lcmReadNamespace = this.resolveLcmReadNamespace(
5134
+ request.namespace,
5135
+ namespace,
5136
+ request.sessionKey,
5137
+ request.authenticatedPrincipal,
4466
5138
  );
4467
-
4468
- const results = rawResults.map((r: { session_id: string; content: string; turn_index: number }) => ({
4469
- sessionId: r.session_id,
4470
- content: r.content,
4471
- turnIndex: r.turn_index,
4472
- }));
5139
+ // Ordered, read-authorized LCM read key SET for a concrete `sessionKey`
5140
+ // (#1505 fallback unification). A branch-scoped session whose rows were
5141
+ // archived at project/root scope is found by querying the primary overlay key
5142
+ // first, then each coding read fallback — exactly as the orchestrator recall
5143
+ // path does. Collapses to a single key for explicit-namespace / no-overlay /
5144
+ // unreadable-self flows. The `sessionPrefix` search fragment stays on the
5145
+ // primary overlay namespace (its own coding context can't be looked up).
5146
+ const lcmSessionKeyIds = request.sessionKey
5147
+ ? this.resolveLcmReadSessionIds(
5148
+ request.namespace,
5149
+ namespace,
5150
+ request.sessionKey,
5151
+ request.authenticatedPrincipal,
5152
+ )
5153
+ : [undefined];
5154
+ const lcmSessionPrefix = request.sessionPrefix
5155
+ ? lcmSessionKeyForNamespace(
5156
+ lcmReadNamespace,
5157
+ request.sessionPrefix,
5158
+ this.orchestrator.config.defaultNamespace,
5159
+ ) ?? request.sessionPrefix
5160
+ : request.sessionPrefix;
5161
+ // SECURITY (#1495 P1 + codex P1 r2 "Require a scoped LCM filter before
5162
+ // archive searches"): a sessionless, prefixless `lcmSearch` issues
5163
+ // `searchContextFull(query, limit, undefined, undefined)`, an archive-wide
5164
+ // FTS scan over EVERY `session_id`, including the sentinel-framed
5165
+ // `<ns>`-scoped overlay/tenant rows. The LCM archive is keyed by the
5166
+ // `session_id` STRING and is NOT partitioned by namespace, so an unscoped
5167
+ // scan CANNOT be constrained to the caller's authorized namespace — neither
5168
+ // an explicit `namespace` nor a readable `default` confines its results to
5169
+ // rows the caller may read. The scan is therefore safe ONLY in single-store
5170
+ // mode (namespaces disabled, one shared archive owned by the caller). When
5171
+ // namespaces are ENABLED, an unscoped `lcmSearch` (no `sessionKey` AND no
5172
+ // `sessionPrefix`) must be SUPPRESSED — return EMPTY — regardless of an
5173
+ // explicit namespace or default-readability, so a caller authorized for
5174
+ // `default` (or for one explicit namespace) cannot read other namespaces'
5175
+ // transcript rows via the archive-wide scan (cross-tenant read leak). A
5176
+ // SCOPED call (sessionKey or sessionPrefix present) is unaffected: it carries
5177
+ // a namespace-framed `session_id` / prefix filter that already constrains the
5178
+ // search to the caller's authorized, read-gated namespace.
5179
+ const hasScopedSession =
5180
+ (typeof request.sessionKey === "string" &&
5181
+ request.sessionKey.length > 0) ||
5182
+ (typeof lcmSessionPrefix === "string" && lcmSessionPrefix.length > 0);
5183
+ if (!hasScopedSession && this.orchestrator.config.namespacesEnabled === true) {
5184
+ return {
5185
+ query: request.query,
5186
+ namespace,
5187
+ results: [],
5188
+ count: 0,
5189
+ lcmEnabled: true,
5190
+ };
5191
+ }
5192
+ // Query each LCM read key in order, merging + deduping rows (by
5193
+ // sessionId+turnIndex) and preserving first-seen order, capped at `limit`.
5194
+ const seenRows = new Set<string>();
5195
+ const results: Array<{ sessionId: string; content: string; turnIndex: number }> = [];
5196
+ for (const lcmSessionKey of lcmSessionKeyIds) {
5197
+ if (results.length >= limit) break;
5198
+ const rawResults = await this.orchestrator.lcmEngine.searchContextFull(
5199
+ request.query,
5200
+ limit,
5201
+ lcmSessionKey,
5202
+ lcmSessionPrefix,
5203
+ );
5204
+ for (const r of rawResults as Array<{ session_id: string; content: string; turn_index: number }>) {
5205
+ const dedupeKey = `${r.session_id}${r.turn_index}`;
5206
+ if (seenRows.has(dedupeKey)) continue;
5207
+ seenRows.add(dedupeKey);
5208
+ results.push({
5209
+ sessionId: r.session_id,
5210
+ content: r.content,
5211
+ turnIndex: r.turn_index,
5212
+ });
5213
+ if (results.length >= limit) break;
5214
+ }
5215
+ }
4473
5216
 
4474
5217
  return {
4475
5218
  query: request.query,
@@ -4480,6 +5223,370 @@ export class EngramAccessService {
4480
5223
  };
4481
5224
  }
4482
5225
 
5226
+ /**
5227
+ * Resolve the LCM `session_id` a same-session READER (compaction flush/record,
5228
+ * `lcmSearch`, raw-excerpt lookup) must target so it matches the key `observe`
5229
+ * archived under (#1495 thread 2 + #1505 round 3, rule 42). One helper for
5230
+ * EVERY access-surface LCM read so the read key cannot drift from the write key
5231
+ * (rule 22).
5232
+ *
5233
+ * Precedence mirrors `observe`'s effective write namespace:
5234
+ * - With an explicit `request.namespace`, use the already-authorized
5235
+ * `resolvedNamespace` (the overlay never applies to an explicit write).
5236
+ * - With NO explicit namespace, an auto-scoped session was archived under
5237
+ * its coding-overlay namespace, so overlay the session's bound coding
5238
+ * context onto the principal self base — the SAME resolution
5239
+ * `resolveMemoryScopePlan`/recall use. `applyCodingNamespaceOverlay`
5240
+ * returns the base unchanged when projectScope/namespaces are off or no
5241
+ * context is bound, so single-store / no-overlay flows collapse to the raw
5242
+ * sessionKey exactly as before.
5243
+ *
5244
+ * Then encode the `${namespace}:${sessionKey}` prefix via the shared helper
5245
+ * so the read key is byte-for-byte what the LCM write and the recall readers
5246
+ * use.
5247
+ */
5248
+ /**
5249
+ * Resolve the effective LCM NAMESPACE a same-session operation must prefix
5250
+ * with (the namespace half of {@link resolveLcmReadSessionKey}). Split out so
5251
+ * `lcmSearch` can apply ONE namespace to BOTH its `sessionKey` and its
5252
+ * `sessionPrefix` — the prefix is a search fragment, not a real session, so its
5253
+ * own coding context can't be looked up; it must inherit the namespace resolved
5254
+ * from the real session (`sessionKeyForOverlay`).
5255
+ *
5256
+ * `purpose` selects the AUTHORIZATION gate applied before honouring the
5257
+ * coding overlay (#1505 round 3 + round 4, codex P2):
5258
+ *
5259
+ * - `"read"` (`lcmSearch` / raw-excerpt recall): the overlay rows are only
5260
+ * visible when the principal SELF base is in the READABLE RECALL SET — the
5261
+ * same gate the orchestrator's `lcmReadNamespaceForSession` and the recall
5262
+ * namespace set use (`recallNamespacesForPrincipal`, gated by both
5263
+ * `defaultRecallNamespaces.includes("self")` AND `canReadNamespace`). A
5264
+ * caller that passed the default read check must NOT receive
5265
+ * `<principal>-project-*` rows the policy never granted (cross-tenant read
5266
+ * leak). When the self base is not readable, keep the just-authorized
5267
+ * namespace (collapses to the raw key on the default store).
5268
+ *
5269
+ * - `"write"` (`lcmCompactionFlush` / `lcmCompactionRecord`): these are
5270
+ * write/maintenance operations on the SAME queue `observe` just wrote, so
5271
+ * the gate must mirror observe's WRITE authorization (`canWriteNamespace`
5272
+ * on the self base), NOT readability. A principal that can WRITE but not
5273
+ * READ its self namespace (or whose `defaultRecallNamespaces` omits `self`)
5274
+ * archived under the overlay key via `observe`; gating compaction by
5275
+ * readability would fall back to the default/raw key and leave that queue
5276
+ * never flushed/recorded (round-4 codex P2). Write-authorized ⇒ overlay
5277
+ * key, matching the observe write key (rule 42 read/write parity; rule 39
5278
+ * identical gates across paths).
5279
+ */
5280
+ private resolveLcmReadNamespace(
5281
+ explicitNamespace: string | undefined,
5282
+ resolvedNamespace: string,
5283
+ sessionKeyForOverlay: string | undefined,
5284
+ authenticatedPrincipal: string | undefined,
5285
+ purpose: "read" | "write" = "read",
5286
+ ): string {
5287
+ const hasExplicitNamespace =
5288
+ typeof explicitNamespace === "string" && explicitNamespace.trim().length > 0;
5289
+ if (hasExplicitNamespace) return resolvedNamespace;
5290
+ // Mirror observe's write resolution: use the coding-overlay namespace when
5291
+ // one applies, else the default store. NOT the principal self base — an
5292
+ // unqualified observe archives under the default store, so a self-base prefix
5293
+ // here would target a queue observe never wrote to (#1495).
5294
+ const principal = this.resolveRequestPrincipal(
5295
+ sessionKeyForOverlay,
5296
+ authenticatedPrincipal,
5297
+ );
5298
+ const base = defaultNamespaceForPrincipal(principal, this.orchestrator.config);
5299
+ const overlaid = this.orchestrator.applyCodingNamespaceOverlay(
5300
+ sessionKeyForOverlay,
5301
+ base,
5302
+ );
5303
+ // No overlay → the default store (raw sessionKey), as before.
5304
+ if (overlaid === base) return this.orchestrator.config.defaultNamespace;
5305
+ // Overlay applied. Authorize access to the principal's `<principal>-project-*`
5306
+ // overlay base before switching the LCM key to it. The gate differs by
5307
+ // operation purpose (see the doc comment): reads use the readable-recall-set
5308
+ // gate (no cross-tenant read leak), writes use observe's write authorization
5309
+ // (so compaction targets the same overlay queue observe wrote to).
5310
+ const authorized =
5311
+ purpose === "write"
5312
+ ? canWriteNamespace(principal, base, this.orchestrator.config)
5313
+ : recallNamespacesForPrincipal(
5314
+ principal,
5315
+ this.orchestrator.config,
5316
+ ).includes(base);
5317
+ if (authorized) return overlaid;
5318
+ // Unauthorized overlay base. For READS, collapse to the DEFAULT STORE (the
5319
+ // raw sessionKey) EXACTLY like the orchestrator's `lcmReadNamespaceForSession`
5320
+ // (rule 39 / 42) — NOT the caller's `resolvedNamespace`, which for an implicit
5321
+ // read can be a readable recall namespace (e.g. `shared`). Returning that
5322
+ // would prefix LCM reads with `shared:sessionKey` while in-prompt recall uses
5323
+ // the raw `sessionKey`, diverging `lcmSearch`/raw disclosure from orchestrator
5324
+ // LCM reads (cursor "LCM read gate wrong fallback"). For an explicit read the
5325
+ // method already returned at the top, so this only affects implicit reads.
5326
+ // The (currently unused) write purpose preserves its prior `resolvedNamespace`
5327
+ // fallback for backward compatibility.
5328
+ if (purpose === "read") return this.orchestrator.config.defaultNamespace;
5329
+ return resolvedNamespace;
5330
+ }
5331
+
5332
+ /**
5333
+ * Resolve the namespace the raw-disclosure excerpt lookup
5334
+ * ({@link fetchRawExcerpts}) must prefix its LCM `session_id` with (#1505
5335
+ * thread 2f7). Raw disclosure reads the SAME LCM archive `lcmSearch` and the
5336
+ * in-prompt LCM sections read, so it MUST pass through the identical
5337
+ * read-authorization gate — NOT `snapshot.namespace`, which records the
5338
+ * effective WRITE/overlay namespace (`<principal>-project-*`) even when the
5339
+ * principal can WRITE but not READ its self base (or `defaultRecallNamespaces`
5340
+ * omits `self`). Routing through `resolveLcmReadNamespace(..., "read")` makes
5341
+ * raw disclosure fall back to the default store exactly like normal recall +
5342
+ * `lcmSearch`, so it never attaches overlay transcript rows the read gate
5343
+ * excludes (cross-tenant read leak). Collapses to the default store / raw
5344
+ * sessionKey for single-store / no-overlay / explicit-default flows, so
5345
+ * single-user recall is byte-for-byte unchanged.
5346
+ *
5347
+ * Returns `undefined` when NO readable LCM namespace exists for an IMPLICIT
5348
+ * (no explicit `namespace`) raw recall — i.e. a restrictive `default` READ
5349
+ * policy denies the principal `default` AND no overlay/self namespace is
5350
+ * readable. In that case the caller emits NO excerpts rather than throwing
5351
+ * `namespace is not readable: default` (#1505 thread NBHWz): normal recall
5352
+ * still succeeds via `recallNamespacesForPrincipal`, so `disclosure: "raw"`
5353
+ * must degrade gracefully (empty excerpts), never pre-authorize `default`.
5354
+ *
5355
+ * IMPLICIT-namespace fallback selection derives from the ALREADY
5356
+ * read-authorized recall namespace set (`recallNamespacesForPrincipal` +
5357
+ * `canReadNamespace`) — the principal's self base when it is in the readable
5358
+ * recall set, else `config.defaultNamespace` ONLY when the principal may read
5359
+ * it. It NEVER pre-authorizes `default`. An EXPLICIT `namespace` is still
5360
+ * authorized strictly via `resolveReadableNamespace` (explicit reads must pass
5361
+ * the ACL — no behavior change).
5362
+ */
5363
+ private resolveRawExcerptReadNamespace(
5364
+ explicitNamespace: string | undefined,
5365
+ sessionKey: string | undefined,
5366
+ authenticatedPrincipal: string | undefined,
5367
+ ): string | undefined {
5368
+ const principal = this.resolveRequestPrincipal(
5369
+ sessionKey,
5370
+ authenticatedPrincipal,
5371
+ );
5372
+ const hasExplicitNamespace =
5373
+ typeof explicitNamespace === "string" &&
5374
+ explicitNamespace.trim().length > 0;
5375
+ if (hasExplicitNamespace) {
5376
+ // Explicit reads must pass the ACL — authorize strictly, exactly as
5377
+ // `lcmSearch` does (throws on an unreadable explicit namespace).
5378
+ const resolvedNamespace = this.resolveReadableNamespace(
5379
+ explicitNamespace,
5380
+ principal,
5381
+ );
5382
+ return this.resolveLcmReadNamespace(
5383
+ explicitNamespace,
5384
+ resolvedNamespace,
5385
+ sessionKey,
5386
+ authenticatedPrincipal,
5387
+ "read",
5388
+ );
5389
+ }
5390
+ // IMPLICIT raw recall: derive the read fallback from the ALREADY
5391
+ // read-authorized recall namespace set — NEVER pre-authorize `default`
5392
+ // (#1505 thread NBHWz). When namespaces are disabled the default store is the
5393
+ // only namespace and is always readable (byte-for-byte single-user path).
5394
+ const fallbackNamespace =
5395
+ this.resolveImplicitLcmReadFallbackNamespace(principal);
5396
+ // No readable LCM namespace at all ⇒ no excerpts (caller short-circuits).
5397
+ if (fallbackNamespace === undefined) return undefined;
5398
+ return this.resolveLcmReadNamespace(
5399
+ explicitNamespace,
5400
+ fallbackNamespace,
5401
+ sessionKey,
5402
+ authenticatedPrincipal,
5403
+ "read",
5404
+ );
5405
+ }
5406
+
5407
+ /**
5408
+ * The base `resolvedNamespace` an IMPLICIT (no explicit `namespace`)
5409
+ * same-session LCM READER (`resolveRawExcerptReadNamespace`, `lcmSearch`)
5410
+ * passes into {@link resolveLcmReadNamespace} — WITHOUT pre-authorizing
5411
+ * `default` (#1505 thread NBHWz). It decides PROCEED vs SUPPRESS only; the
5412
+ * actual LCM prefix is then resolved by `resolveLcmReadNamespace`, which
5413
+ * mirrors the orchestrator's `lcmReadNamespaceForSession` EXACTLY (rule 39 /
5414
+ * 42): the coding overlay when the principal SELF base is in the readable
5415
+ * recall set, else `config.defaultNamespace` (the raw key).
5416
+ *
5417
+ * Returns `config.defaultNamespace` (PROCEED) whenever the principal has ANY
5418
+ * readable LCM access — either `default` itself is readable, OR a coding
5419
+ * overlay / self base is in the readable recall set. The returned value is
5420
+ * ALWAYS `config.defaultNamespace`, NEVER an arbitrary readable recall
5421
+ * namespace (e.g. `shared`): `resolveLcmReadNamespace` returns this fallback
5422
+ * verbatim only on the overlay-applies-but-self-unreadable branch, where the
5423
+ * orchestrator collapses to the default store — so returning anything but the
5424
+ * default store there would prefix LCM reads with `shared:sessionKey` while
5425
+ * in-prompt recall uses the raw `sessionKey`, diverging the two (cursor
5426
+ * "LCM read gate wrong fallback").
5427
+ *
5428
+ * Returns `undefined` (SUPPRESS) only when NO readable LCM namespace exists —
5429
+ * a restrictive `default` READ policy AND no readable overlay/self — so the
5430
+ * caller emits NO rows instead of throwing `namespace is not readable:
5431
+ * default`. Normal recall still succeeds through the readable self namespace.
5432
+ *
5433
+ * Single-store / namespaces-disabled deployments resolve to
5434
+ * `config.defaultNamespace`, keeping single-user recall byte-for-byte
5435
+ * unchanged.
5436
+ */
5437
+ private resolveImplicitLcmReadFallbackNamespace(
5438
+ principal: string | undefined,
5439
+ ): string | undefined {
5440
+ const config = this.orchestrator.config;
5441
+ if (!config.namespacesEnabled) return config.defaultNamespace;
5442
+ // PROCEED when `default` is readable (single-store / readable-default flows)
5443
+ // — the LCM prefix resolves to the overlay when self-authorized, else the
5444
+ // default raw key.
5445
+ if (canReadNamespace(principal, config.defaultNamespace, config)) {
5446
+ return config.defaultNamespace;
5447
+ }
5448
+ // Restrictive `default` READ policy. The ONLY way the implicit LCM read can
5449
+ // target an AUTHORIZED key is via the coding OVERLAY, which
5450
+ // `resolveLcmReadNamespace` switches to ONLY when the principal SELF base is
5451
+ // in the readable recall set (the SAME gate the orchestrator's
5452
+ // `lcmReadNamespaceForSession` uses). So PROCEED here ONLY when the SELF base
5453
+ // is readable-in-recall — NOT when merely some OTHER recall namespace (e.g.
5454
+ // `shared`) is readable: the LCM read can never legitimately target `shared`,
5455
+ // and returning `config.defaultNamespace` for that case would let a sessionless
5456
+ // `lcmSearch`/raw recall scan the DENIED default LCM store (codex P1 "Don't
5457
+ // treat any readable namespace as default LCM access"). The downstream
5458
+ // overlay-unreadable READ branch and `lcmSearch`'s sessionless guard both
5459
+ // collapse to the default raw key, so a self-readable PROCEED still yields the
5460
+ // scoped overlay key (with a session) or empty (sessionless). SUPPRESS
5461
+ // (`undefined`) when the self base is not readable-in-recall.
5462
+ const selfBase = defaultNamespaceForPrincipal(principal, config);
5463
+ const selfReadableInRecall = recallNamespacesForPrincipal(
5464
+ principal,
5465
+ config,
5466
+ ).includes(selfBase);
5467
+ return selfReadableInRecall ? config.defaultNamespace : undefined;
5468
+ }
5469
+
5470
+ private resolveLcmReadSessionKey(
5471
+ explicitNamespace: string | undefined,
5472
+ resolvedNamespace: string,
5473
+ sessionKey: string,
5474
+ authenticatedPrincipal: string | undefined,
5475
+ purpose: "read" | "write" = "read",
5476
+ ): string {
5477
+ const effectiveNamespace = this.resolveLcmReadNamespace(
5478
+ explicitNamespace,
5479
+ resolvedNamespace,
5480
+ sessionKey,
5481
+ authenticatedPrincipal,
5482
+ purpose,
5483
+ );
5484
+ return (
5485
+ lcmSessionKeyForNamespace(
5486
+ effectiveNamespace,
5487
+ sessionKey,
5488
+ this.orchestrator.config.defaultNamespace,
5489
+ ) ?? sessionKey
5490
+ );
5491
+ }
5492
+
5493
+ /**
5494
+ * Resolve the ORDERED, read-authorized set of LCM `session_id`s a same-session
5495
+ * READER (`lcmSearch`, raw-excerpt disclosure) must query so it matches every
5496
+ * key `observe` archived under across the coding scope (#1505 thread "Include
5497
+ * coding fallback namespaces in LCM reads").
5498
+ *
5499
+ * Mirrors the orchestrator recall path exactly (rule 39): `observe` archives
5500
+ * each turn under `${effectiveNamespace}:${sessionKey}` for whichever namespace
5501
+ * was effective at write time, and normal QMD/file recall searches the primary
5502
+ * coding-overlay namespace AND `codingOverlay.readFallbacks` (project → root).
5503
+ * A single overlay key therefore MISSES rows a branch-scoped session archived at
5504
+ * project/root scope. This returns the primary overlay LCM key first, then one
5505
+ * per read fallback, deduped + ordered so the caller can short-circuit on the
5506
+ * first hit.
5507
+ *
5508
+ * READ-AUTHORIZATION (preserved from the round-3..5 `resolveLcmReadNamespace`
5509
+ * "read" gate; rule 42 / 48): the overlay + fallbacks are `<principal>-project-*`
5510
+ * sub-namespaces authorized transitively by the principal SELF base. They are
5511
+ * included ONLY when the self base is in the readable recall set
5512
+ * (`recallNamespacesForPrincipal`). When the self base is NOT readable (write-
5513
+ * only / self-omitted principal), or when an explicit namespace was supplied,
5514
+ * or no overlay applies, this collapses to the single key
5515
+ * {@link resolveLcmReadSessionKey} returns — byte-for-byte the prior behavior
5516
+ * (single-store / no-overlay flows stay the raw `sessionKey`). No
5517
+ * `<principal>-project-*` key is ever searched for an unauthorized reader (no
5518
+ * cross-tenant read leak).
5519
+ */
5520
+ private resolveLcmReadSessionIds(
5521
+ explicitNamespace: string | undefined,
5522
+ resolvedNamespace: string,
5523
+ sessionKey: string,
5524
+ authenticatedPrincipal: string | undefined,
5525
+ ): string[] {
5526
+ const primary = this.resolveLcmReadSessionKey(
5527
+ explicitNamespace,
5528
+ resolvedNamespace,
5529
+ sessionKey,
5530
+ authenticatedPrincipal,
5531
+ "read",
5532
+ );
5533
+ const hasExplicitNamespace =
5534
+ typeof explicitNamespace === "string" &&
5535
+ explicitNamespace.trim().length > 0;
5536
+ // Explicit namespace → no overlay fallbacks (the overlay never applies to an
5537
+ // explicit read). Single key, unchanged.
5538
+ if (hasExplicitNamespace) return [primary];
5539
+
5540
+ const principal = this.resolveRequestPrincipal(
5541
+ sessionKey,
5542
+ authenticatedPrincipal,
5543
+ );
5544
+ const base = defaultNamespaceForPrincipal(
5545
+ principal,
5546
+ this.orchestrator.config,
5547
+ );
5548
+ const overlaid = this.orchestrator.applyCodingNamespaceOverlay(
5549
+ sessionKey,
5550
+ base,
5551
+ );
5552
+ // No overlay → single default-store key, unchanged.
5553
+ if (overlaid === base) return [primary];
5554
+ // Overlay present but self base unreadable → the "read" gate already
5555
+ // collapsed `primary` to the default store; do NOT add overlay fallbacks
5556
+ // (they would be unauthorized `<principal>-project-*` keys). Single key.
5557
+ const selfReadableInRecall = recallNamespacesForPrincipal(
5558
+ principal,
5559
+ this.orchestrator.config,
5560
+ ).includes(base);
5561
+ if (!selfReadableInRecall) return [primary];
5562
+ // Self base readable → overlay rows authorized. Append one LCM key per coding
5563
+ // read fallback (project → root), combined with the principal base for
5564
+ // isolation — the SAME ordered set the orchestrator recall path searches.
5565
+ const overlay = resolveCodingNamespaceOverlay(
5566
+ this.orchestrator.getCodingContextForSession(sessionKey),
5567
+ this.orchestrator.config.codingMode,
5568
+ this.orchestrator.config.defaultNamespace,
5569
+ );
5570
+ const fallbackNamespaces = (overlay?.readFallbacks ?? []).map((fallback) =>
5571
+ combineNamespaces(base, fallback),
5572
+ );
5573
+ const out = [primary];
5574
+ const seen = new Set<string>([primary]);
5575
+ for (const ns of fallbackNamespaces) {
5576
+ const key =
5577
+ lcmSessionKeyForNamespace(
5578
+ ns,
5579
+ sessionKey,
5580
+ this.orchestrator.config.defaultNamespace,
5581
+ ) ?? sessionKey;
5582
+ if (!seen.has(key)) {
5583
+ seen.add(key);
5584
+ out.push(key);
5585
+ }
5586
+ }
5587
+ return out;
5588
+ }
5589
+
4483
5590
  async lcmCompactionFlush(
4484
5591
  request: EngramAccessLcmCompactionFlushRequest,
4485
5592
  ): Promise<EngramAccessLcmCompactionFlushResponse> {
@@ -4487,11 +5594,30 @@ export class EngramAccessService {
4487
5594
  throw new EngramAccessInputError("sessionKey is required and must be a non-empty string");
4488
5595
  }
4489
5596
 
4490
- const namespace = this.resolveWritableNamespace(
4491
- request.namespace,
4492
- request.sessionKey,
4493
- request.authenticatedPrincipal,
4494
- );
5597
+ // Authorize compaction against the SCOPED WRITE TARGET — the SAME effective
5598
+ // write namespace `observe` archived the LCM queue under — NOT a premature
5599
+ // `resolveWritableNamespace(undefined ⇒ config.defaultNamespace)` (#1505
5600
+ // thread NBHWs). Under a restrictive `default` WRITE policy where the
5601
+ // principal can still write its self/project overlay, that premature default
5602
+ // write-auth threw `namespace is not writable: default` BEFORE the scoped key
5603
+ // was computed, so the overlay queue `observe` just wrote could never be
5604
+ // flushed. `resolveMemoryScopePlan` is the ONE write-scoped plan/gate observe
5605
+ // uses (rule 22 / 39 / 42): it authorizes the principal self base for an
5606
+ // overlay write and only collapses to `config.defaultNamespace` (always
5607
+ // writable) when no overlay applies — so it never throws `not writable:
5608
+ // default` for a validly scoped observe's queue.
5609
+ const scope = await this.resolveMemoryScopePlan(request);
5610
+ // Legacy `namespace` response field: pre-#1505 semantics were exactly
5611
+ // `resolveWritableNamespace(request.namespace)` (overlay-agnostic) — the
5612
+ // authorized explicit namespace when supplied, else `config.defaultNamespace`.
5613
+ // DERIVED from the scope plan (NOT a second auth pass, #1505 thread jvO):
5614
+ // explicit ⇒ writeNamespace; coding overlay ⇒ defaultNamespace; no overlay ⇒
5615
+ // writeNamespace (== defaultNamespace). Identical to observe's legacy field.
5616
+ const namespace = scope.explicitNamespace
5617
+ ? scope.writeNamespace
5618
+ : scope.codingOverlayApplied
5619
+ ? this.orchestrator.config.defaultNamespace
5620
+ : scope.writeNamespace;
4495
5621
  if (!this.orchestrator.lcmEngine || !this.orchestrator.lcmEngine.enabled) {
4496
5622
  return {
4497
5623
  enabled: false,
@@ -4502,9 +5628,19 @@ export class EngramAccessService {
4502
5628
  };
4503
5629
  }
4504
5630
 
4505
- const lcmSessionKey = namespace !== this.orchestrator.config.defaultNamespace
4506
- ? `${namespace}:${request.sessionKey}`
4507
- : request.sessionKey;
5631
+ // Flush the SAME LCM session_id `observe` archived under: encode the scope
5632
+ // plan's EFFECTIVE write namespace (the coding overlay when one applies, else
5633
+ // the default store) through the SAME `lcmSessionKeyForNamespace` helper
5634
+ // observe uses for archival, so the flush key is byte-for-byte the write key
5635
+ // (#1495 thread 2 / #1505 thread NBHWs, rule 42). A write-only / self-omitted
5636
+ // principal still flushes the overlay queue because the scope plan authorized
5637
+ // the write target by WRITE policy, not readability.
5638
+ const lcmSessionKey =
5639
+ lcmSessionKeyForNamespace(
5640
+ scope.writeNamespace,
5641
+ request.sessionKey,
5642
+ this.orchestrator.config.defaultNamespace,
5643
+ ) ?? request.sessionKey;
4508
5644
  await this.orchestrator.lcmEngine.waitForSessionObserveIdle(lcmSessionKey);
4509
5645
  await this.orchestrator.lcmEngine.preCompactionFlush(lcmSessionKey);
4510
5646
  return {
@@ -4528,11 +5664,23 @@ export class EngramAccessService {
4528
5664
  throw new EngramAccessInputError("tokensAfter must be a non-negative integer");
4529
5665
  }
4530
5666
 
4531
- const namespace = this.resolveWritableNamespace(
4532
- request.namespace,
4533
- request.sessionKey,
4534
- request.authenticatedPrincipal,
4535
- );
5667
+ // Authorize compaction against the SCOPED WRITE TARGET — the SAME effective
5668
+ // write namespace `observe` archived the LCM queue under — NOT a premature
5669
+ // `resolveWritableNamespace(undefined ⇒ config.defaultNamespace)` (#1505
5670
+ // thread NBHWs). See `lcmCompactionFlush` for the full rationale: under a
5671
+ // restrictive `default` WRITE policy where the principal can still write its
5672
+ // self/project overlay, the old premature default write-auth threw `namespace
5673
+ // is not writable: default` before the scoped key was computed, leaving the
5674
+ // overlay queue observe wrote unrecordable. `resolveMemoryScopePlan` is the
5675
+ // ONE write-scoped plan/gate observe uses; it authorizes the self base for an
5676
+ // overlay write and never throws `not writable: default` for a validly scoped
5677
+ // observe's queue.
5678
+ const scope = await this.resolveMemoryScopePlan(request);
5679
+ const namespace = scope.explicitNamespace
5680
+ ? scope.writeNamespace
5681
+ : scope.codingOverlayApplied
5682
+ ? this.orchestrator.config.defaultNamespace
5683
+ : scope.writeNamespace;
4536
5684
  if (!this.orchestrator.lcmEngine || !this.orchestrator.lcmEngine.enabled) {
4537
5685
  return {
4538
5686
  enabled: false,
@@ -4543,9 +5691,18 @@ export class EngramAccessService {
4543
5691
  };
4544
5692
  }
4545
5693
 
4546
- const lcmSessionKey = namespace !== this.orchestrator.config.defaultNamespace
4547
- ? `${namespace}:${request.sessionKey}`
4548
- : request.sessionKey;
5694
+ // Record against the SAME LCM session_id `observe` archived under — encode
5695
+ // the scope plan's EFFECTIVE write namespace through the SAME
5696
+ // `lcmSessionKeyForNamespace` helper observe uses, so the record key is
5697
+ // byte-for-byte the write key (#1495 thread 2 / #1505 thread NBHWs, rule 42).
5698
+ // A write-only / self-omitted principal still records on the overlay queue
5699
+ // because the scope plan authorized the write target by WRITE policy.
5700
+ const lcmSessionKey =
5701
+ lcmSessionKeyForNamespace(
5702
+ scope.writeNamespace,
5703
+ request.sessionKey,
5704
+ this.orchestrator.config.defaultNamespace,
5705
+ ) ?? request.sessionKey;
4549
5706
  await this.orchestrator.lcmEngine.waitForSessionObserveIdle(lcmSessionKey);
4550
5707
  await this.orchestrator.lcmEngine.recordCompaction(
4551
5708
  lcmSessionKey,