@remnic/core 9.3.649 → 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.
- package/dist/access-cli.js +3 -3
- package/dist/access-http.d.ts +2 -2
- package/dist/access-http.js +4 -4
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-mcp.js +3 -3
- package/dist/{access-service-DFXIlGvZ.d.ts → access-service-DIZRHQ7Q.d.ts} +255 -2
- package/dist/access-service.d.ts +2 -2
- package/dist/access-service.js +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-XUGVP7ZU.js → chunk-23RYLGYA.js} +184 -54
- package/dist/chunk-23RYLGYA.js.map +1 -0
- package/dist/{chunk-CNRZ6WJU.js → chunk-3IJEQWQX.js} +4 -4
- package/dist/{chunk-6GIKAUTN.js → chunk-MMJANTJX.js} +33 -2
- package/dist/{chunk-6GIKAUTN.js.map → chunk-MMJANTJX.js.map} +1 -1
- package/dist/{chunk-FQYFMIKG.js → chunk-TUMH6EDV.js} +4 -4
- package/dist/{chunk-FUXV6HSO.js → chunk-TVOPSKOK.js} +3 -3
- package/dist/{chunk-5ETA6OAS.js → chunk-YAFSTKTH.js} +608 -80
- package/dist/chunk-YAFSTKTH.js.map +1 -0
- package/dist/{cli-DrL2Nv4j.d.ts → cli-BG4ybtJr.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/cli.js +5 -5
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +6 -6
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/{orchestrator-DEQW9j0Z.d.ts → orchestrator-CX-oqwJq.d.ts} +58 -0
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +2 -2
- package/package.json +1 -1
- package/src/access-service-lcm-forgery.test.ts +410 -0
- package/src/access-service-observe-lcm-parity.test.ts +1397 -0
- package/src/access-service-observe-scope.test.ts +599 -0
- package/src/access-service-raw-excerpt-read-gate.test.ts +443 -0
- package/src/access-service.ts +1270 -113
- package/src/coding/coding-namespace.test.ts +44 -0
- package/src/coding/coding-namespace.ts +163 -0
- package/src/orchestrator.ts +335 -77
- package/dist/chunk-5ETA6OAS.js.map +0 -1
- package/dist/chunk-XUGVP7ZU.js.map +0 -1
- /package/dist/{chunk-CNRZ6WJU.js.map → chunk-3IJEQWQX.js.map} +0 -0
- /package/dist/{chunk-FQYFMIKG.js.map → chunk-TUMH6EDV.js.map} +0 -0
- /package/dist/{chunk-FUXV6HSO.js.map → chunk-TVOPSKOK.js.map} +0 -0
package/src/access-service.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
1680
|
-
//
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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: {
|
|
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
|
|
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
|
|
1811
|
-
context.
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3557
|
+
...(rawExcerptNamespace
|
|
3558
|
+
? { namespace: rawExcerptNamespace }
|
|
3559
|
+
: {}),
|
|
3560
|
+
...(rawExcerptSessionIds
|
|
3561
|
+
? { lcmSessionIds: rawExcerptSessionIds }
|
|
3562
|
+
: {}),
|
|
3037
3563
|
})
|
|
3038
|
-
:
|
|
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
|
-
//
|
|
4297
|
-
//
|
|
4298
|
-
//
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
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
|
|
4332
|
-
//
|
|
4333
|
-
//
|
|
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
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
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
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
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
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
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
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
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
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
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
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
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,
|