@remnic/core 10.0.55 → 10.0.57

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 (43) hide show
  1. package/dist/access-cli.js +8 -8
  2. package/dist/access-http-query.js +2 -2
  3. package/dist/access-http.js +3 -3
  4. package/dist/{chunk-4BUJICMI.js → chunk-CTNDFKEX.js} +2 -2
  5. package/dist/{chunk-2GU6XS4L.js → chunk-E2YAGEFR.js} +7 -7
  6. package/dist/{chunk-R3WZ74B2.js → chunk-KCTL5EY7.js} +5 -3
  7. package/dist/chunk-KCTL5EY7.js.map +1 -0
  8. package/dist/{chunk-PVG7TCUG.js → chunk-L5UB5BIY.js} +2 -2
  9. package/dist/{chunk-XVTB6BZS.js → chunk-P5FYZH5G.js} +253 -130
  10. package/dist/chunk-P5FYZH5G.js.map +1 -0
  11. package/dist/{chunk-Y56D5FSD.js → chunk-W2KH27MQ.js} +3 -3
  12. package/dist/{chunk-GGFQAGOT.js → chunk-W3R77WHT.js} +2 -2
  13. package/dist/{chunk-WGFEGBFB.js → chunk-W4QTA2H5.js} +2 -2
  14. package/dist/cli.js +6 -6
  15. package/dist/index.js +8 -8
  16. package/dist/maintenance/namespace-planner.d.ts.map +1 -1
  17. package/dist/namespaces/catalog-read-boundary.d.ts +42 -0
  18. package/dist/namespaces/catalog-read-boundary.d.ts.map +1 -0
  19. package/dist/namespaces/catalog-repair.d.ts +35 -0
  20. package/dist/namespaces/catalog-repair.d.ts.map +1 -0
  21. package/dist/namespaces/catalog.d.ts +40 -28
  22. package/dist/namespaces/catalog.d.ts.map +1 -1
  23. package/dist/operator-toolkit.js +3 -3
  24. package/dist/orchestration/maintenance.js +3 -3
  25. package/dist/orchestrator.js +8 -8
  26. package/package.json +2 -2
  27. package/src/maintenance/namespace-planner.test.ts +41 -1
  28. package/src/maintenance/namespace-planner.ts +8 -1
  29. package/src/namespaces/catalog-read-boundary.test.ts +121 -0
  30. package/src/namespaces/catalog-read-boundary.ts +159 -0
  31. package/src/namespaces/catalog-repair.ts +69 -0
  32. package/src/namespaces/catalog.test.ts +566 -0
  33. package/src/namespaces/catalog.ts +155 -117
  34. package/src/orchestration/maintenance.test.ts +33 -1
  35. package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
  36. package/dist/chunk-R3WZ74B2.js.map +0 -1
  37. package/dist/chunk-XVTB6BZS.js.map +0 -1
  38. /package/dist/{chunk-4BUJICMI.js.map → chunk-CTNDFKEX.js.map} +0 -0
  39. /package/dist/{chunk-2GU6XS4L.js.map → chunk-E2YAGEFR.js.map} +0 -0
  40. /package/dist/{chunk-PVG7TCUG.js.map → chunk-L5UB5BIY.js.map} +0 -0
  41. /package/dist/{chunk-Y56D5FSD.js.map → chunk-W2KH27MQ.js.map} +0 -0
  42. /package/dist/{chunk-GGFQAGOT.js.map → chunk-W3R77WHT.js.map} +0 -0
  43. /package/dist/{chunk-WGFEGBFB.js.map → chunk-W4QTA2H5.js.map} +0 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Single-flight gate for the malformed-catalog repair (#3298 review), extracted
3
+ * from catalog.ts so that file stays under its structural-ratchet ceiling
4
+ * (issue #1995). Pure scheduling state plus the quarantine-sidecar path helper.
5
+ */
6
+ /** After a dropped attempt, wait this long before another read may schedule one. */
7
+ export declare const MALFORMED_REPAIR_DROP_BACKOFF_MS = 250;
8
+ /**
9
+ * Bounds repairs triggered by malformed reads (#3298 review): one attempt in
10
+ * flight at a time, and a short backoff after an attempt was dropped (another
11
+ * process held the lock). The guard is what stops concurrent malformed reads
12
+ * from queueing serial lock waits ahead of touch(); the 5 s lock wait is itself
13
+ * the rate limiter under sustained contention, which is why the backoff is
14
+ * short — a longer window would delay the legitimate warm-cache retry after
15
+ * contention clears. A fast failure holds no lock budget and does not back off.
16
+ */
17
+ export declare class MalformedCatalogRepairGate {
18
+ private inFlight;
19
+ private nextAttemptAt;
20
+ private attempts;
21
+ /** Test-only observability: attempts that actually entered the gate. */
22
+ get attemptCount(): number;
23
+ /**
24
+ * Run `attempt` unless one is already in flight or the backoff window is on.
25
+ * Resolves when the attempt settles; never rejects (the repair is fail-open).
26
+ */
27
+ run(attempt: () => Promise<"repaired" | "dropped" | "failed">): Promise<void>;
28
+ }
29
+ /**
30
+ * Quarantine sidecar path for the rows a repair drops (#3298 review): named by a
31
+ * hash of the dropped lines, so repeated attempts over the same bad rows reuse
32
+ * one file instead of piling one sidecar per failed attempt.
33
+ */
34
+ export declare function malformedSidecarPath(catalogPath: string, dropped: string[]): string;
35
+ //# sourceMappingURL=catalog-repair.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog-repair.d.ts","sourceRoot":"","sources":["../../src/namespaces/catalog-repair.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,oFAAoF;AACpF,eAAO,MAAM,gCAAgC,MAAM,CAAC;AAEpD;;;;;;;;GAQG;AACH,qBAAa,0BAA0B;IACrC,OAAO,CAAC,QAAQ,CAA4B;IAC5C,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,QAAQ,CAAK;IAErB,wEAAwE;IACxE,IAAI,YAAY,IAAI,MAAM,CAEzB;IAED;;;OAGG;IACH,GAAG,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;CAoB9E;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,MAAM,CAEnF"}
@@ -51,6 +51,14 @@ export interface NamespaceCatalogFilter {
51
51
  /** Only include namespaces written since this instant (inclusive lower bound). */
52
52
  writtenSince?: Date;
53
53
  }
54
+ export interface NamespaceCatalogReadOptions {
55
+ /**
56
+ * Called when the read could not see the whole catalog (an unreadable file, or malformed rows
57
+ * that were skipped). The records that were readable are still returned. A catalog that was
58
+ * never written is normal and does not call this.
59
+ */
60
+ onIncomplete?: (reason: string) => void;
61
+ }
54
62
  export interface NamespaceTouchMetadata {
55
63
  discoveredBy?: NamespaceDiscoverySource;
56
64
  kind?: NamespaceKind;
@@ -81,6 +89,15 @@ export interface NamespaceCatalogRebuildResult {
81
89
  }
82
90
  export declare function hasMemoryData(rootDir: string): Promise<boolean>;
83
91
  export declare function isNamespaceKind(value: unknown): value is NamespaceKind;
92
+ /**
93
+ * Fold the touch fields (lastReadAt / lastWriteAt / lastMaintenanceAt) from a
94
+ * freshly re-read on-disk record into the rebuilt record, taking the LATER
95
+ * timestamp per field (round 5 cross-process re-merge). Disk-derived fields
96
+ * (storageDir, kind, discoveredBy, createdAt, principal hints) are owned by the
97
+ * rebuilt record and left untouched — we only recover touch recency that a
98
+ * concurrent (possibly cross-process) writer recorded after our initial load.
99
+ */
100
+ export declare function mergeNewerTouchFields(base: NamespaceRecord, fresh: NamespaceRecord): NamespaceRecord;
84
101
  export declare class NamespaceCatalog {
85
102
  private readonly config;
86
103
  private readonly memoryDir;
@@ -99,40 +116,17 @@ export declare class NamespaceCatalog {
99
116
  private readonly compactBytesLimit;
100
117
  private readonly readCoalesceMs;
101
118
  private readonly writeCoalesceMs;
119
+ private readonly malformedRepairGate;
120
+ /** Test-only observability: how many repair attempts actually entered the gate. */
121
+ get malformedRepairAttemptsForTest(): number;
102
122
  private lastCompactedSize;
103
123
  private readonly pendingTouches;
104
124
  constructor(config: PluginConfig);
105
125
  /** Whether the catalog is active (namespaces enabled and catalog not opted out). */
106
126
  get enabled(): boolean;
107
- /**
108
- * Sanitize a record at the enumeration boundary (round 5, cursor Medium + codex
109
- * P2; round 6 — NDXHe). Reads return whatever is in `namespaces.jsonl` after
110
- * schema checks only, so a tampered or pre-fix row could surface unsafe data to
111
- * maintenance/QMD until a rewrite occurs. Two distinct defenses:
112
- *
113
- * 1. UNSAFE NAMESPACE NAME (NGZqr, codex P2): an unsafe non-default namespace
114
- * (e.g. `../evil`, a name with separators, or >64 chars) is REJECTED outright
115
- * — return `null` so the caller drops it. The disk SCAN and the hot touch
116
- * path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
117
- * gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
118
- * would expose a namespace those paths reject (note `isStorageDirForNamespace`
119
- * can still build a tokenized root even for `../evil`, so storageDir sanitation
120
- * alone does not catch it). The default namespace is exempt (it may be a
121
- * non-route literal), matching every other validation site.
122
- *
123
- * 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
124
- * as the write path — full containment (`isContainedStorageDir`: lexical +
125
- * symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
126
- * a record fails EITHER check we substitute the trusted resolved-and-safe root
127
- * for that namespace (rule 42: read and write stay symmetric).
128
- */
129
- private sanitizeRecordForRead;
130
- private storageRootOwnershipRank;
131
- private configuredNamespaceIdentities;
132
- private preferStorageRootOwner;
133
- private dropDuplicateStorageRootAliases;
127
+ private catalogReadBoundaryPort;
134
128
  private loadSanitizedRecords;
135
- listNamespaces(filter?: NamespaceCatalogFilter): Promise<NamespaceRecord[]>;
129
+ listNamespaces(filter?: NamespaceCatalogFilter, options?: NamespaceCatalogReadOptions): Promise<NamespaceRecord[]>;
136
130
  getNamespaceRecord(namespace: string): Promise<NamespaceRecord | null>;
137
131
  markRead(namespace: string, metadata?: NamespaceTouchMetadata): Promise<void>;
138
132
  markWrite(namespace: string, metadata?: NamespaceTouchMetadata): Promise<void>;
@@ -349,6 +343,24 @@ export declare class NamespaceCatalog {
349
343
  * suppressing its retry.
350
344
  */
351
345
  private touch;
346
+ /**
347
+ * Rewrite the catalog after a read skipped malformed rows (#3297). Same fold-and-rewrite as the
348
+ * size-triggered compaction — the readable rows survive, the malformed ones do not — so a stuck
349
+ * bad row stops keeping every completion request owed. Best-effort and fail-open (rule #40): a
350
+ * failed rewrite leaves the catalog unchanged and the next read reports and retries.
351
+ *
352
+ * Locking: the fold runs inside `queueCritical` (in-process serialization, like every other
353
+ * read), and the rewrite's load→rename window runs under the held catalog file lock, so it can
354
+ * never interleave with another process's touch append or rebuild. If another process already
355
+ * holds the lock, the rewrite is dropped (not queued): that process's own rewrite or rebuild is
356
+ * the likeliest repair, and the next incomplete read retries anyway.
357
+ *
358
+ * Data safety: the locked re-read must itself reach the end of the file and still find
359
+ * malformed rows, and must leave at least one readable row. A failed re-read folds to an empty
360
+ * map, and a catalog with nothing readable is left for `rebuildFromDisk`: rewriting either
361
+ * would replace a healthy or recoverable catalog with an empty file.
362
+ */
363
+ private rewriteAfterIncompleteRead;
352
364
  /**
353
365
  * Fold-and-rewrite the JSONL log when it exceeds `compactBytesLimit`
354
366
  * (issue #1903). MUST be called only from inside `touch`'s held-lock +
@@ -1 +1 @@
1
- {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/namespaces/catalog.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/namespaces/catalog.ts"],"names":[],"mappings":"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"}
@@ -14,8 +14,8 @@ import {
14
14
  summarizeProvenanceCoverage,
15
15
  summarizeTierDistribution,
16
16
  summarizeTombstoneStatus
17
- } from "./chunk-GGFQAGOT.js";
18
- import "./chunk-R3WZ74B2.js";
17
+ } from "./chunk-W3R77WHT.js";
18
+ import "./chunk-KCTL5EY7.js";
19
19
  import "./chunk-KFY3SGN7.js";
20
20
  import "./chunk-CMTINOFS.js";
21
21
  import "./chunk-M4U25HLR.js";
@@ -54,7 +54,7 @@ import "./chunk-G2XTGEGL.js";
54
54
  import "./chunk-27NO2KVB.js";
55
55
  import "./chunk-KFZY2OFH.js";
56
56
  import "./chunk-B32WYRXX.js";
57
- import "./chunk-XVTB6BZS.js";
57
+ import "./chunk-P5FYZH5G.js";
58
58
  import "./chunk-QS5DUSBM.js";
59
59
  import "./chunk-B6SPPYGJ.js";
60
60
  import "./chunk-TTTPTQ6S.js";
@@ -1,20 +1,20 @@
1
1
  import {
2
2
  MaintenanceScheduler
3
- } from "../chunk-PVG7TCUG.js";
3
+ } from "../chunk-L5UB5BIY.js";
4
4
  import "../chunk-666A3MOW.js";
5
5
  import "../chunk-B43P3DTC.js";
6
6
  import "../chunk-6CDNOREO.js";
7
7
  import "../chunk-53OFDW3P.js";
8
8
  import "../chunk-5KBIOI4G.js";
9
9
  import "../chunk-IFOCK3OJ.js";
10
- import "../chunk-R3WZ74B2.js";
10
+ import "../chunk-KCTL5EY7.js";
11
11
  import "../chunk-QAHP5SBG.js";
12
12
  import "../chunk-KJTKLXTH.js";
13
13
  import "../chunk-2DQ2XREU.js";
14
14
  import "../chunk-J5WI4QRP.js";
15
15
  import "../chunk-EWMRFW3F.js";
16
16
  import "../chunk-V4VCBFAL.js";
17
- import "../chunk-XVTB6BZS.js";
17
+ import "../chunk-P5FYZH5G.js";
18
18
  import "../chunk-OGXXGHW6.js";
19
19
  import "../chunk-WR5POHQ3.js";
20
20
  import "../chunk-TTTPTQ6S.js";
@@ -4,13 +4,13 @@ import {
4
4
  graphPathRelativeToStorage,
5
5
  mergeGraphExpandedResults,
6
6
  qmdStartupCollectionChecksWithTimeout
7
- } from "./chunk-2GU6XS4L.js";
7
+ } from "./chunk-E2YAGEFR.js";
8
8
  import "./chunk-YDIOMDBW.js";
9
9
  import "./chunk-HM3ZTDAX.js";
10
10
  import {
11
11
  formatCompressionGuidelinesForRecall
12
12
  } from "./chunk-5PHOU5QI.js";
13
- import "./chunk-PVG7TCUG.js";
13
+ import "./chunk-L5UB5BIY.js";
14
14
  import "./chunk-I74SUMNI.js";
15
15
  import "./chunk-ILNPU5W5.js";
16
16
  import "./chunk-VZAKYDMJ.js";
@@ -121,7 +121,7 @@ import "./chunk-PYIR4SSB.js";
121
121
  import "./chunk-7G5NTS7F.js";
122
122
  import "./chunk-FFQNNFHX.js";
123
123
  import "./chunk-3I4FUAVJ.js";
124
- import "./chunk-Y56D5FSD.js";
124
+ import "./chunk-W2KH27MQ.js";
125
125
  import "./chunk-MMACM5G2.js";
126
126
  import "./chunk-54BGIBCR.js";
127
127
  import "./chunk-H4CKS5B2.js";
@@ -164,8 +164,8 @@ import "./chunk-HDN7IVIO.js";
164
164
  import "./chunk-LR7RW4T6.js";
165
165
  import "./chunk-3T74IZB3.js";
166
166
  import "./chunk-2LA3H54D.js";
167
- import "./chunk-GGFQAGOT.js";
168
- import "./chunk-R3WZ74B2.js";
167
+ import "./chunk-W3R77WHT.js";
168
+ import "./chunk-KCTL5EY7.js";
169
169
  import "./chunk-KFY3SGN7.js";
170
170
  import "./chunk-CMTINOFS.js";
171
171
  import "./chunk-M4U25HLR.js";
@@ -238,7 +238,7 @@ import "./chunk-27NO2KVB.js";
238
238
  import "./chunk-KFZY2OFH.js";
239
239
  import "./chunk-B32WYRXX.js";
240
240
  import "./chunk-QW27DOHG.js";
241
- import "./chunk-4BUJICMI.js";
241
+ import "./chunk-CTNDFKEX.js";
242
242
  import "./chunk-V7IZKJI3.js";
243
243
  import "./chunk-TB6CBZ5V.js";
244
244
  import "./chunk-XW2T4QAW.js";
@@ -283,8 +283,8 @@ import "./chunk-2FLFJTCI.js";
283
283
  import "./chunk-JATHBCAV.js";
284
284
  import "./chunk-ZQUVBZI4.js";
285
285
  import "./chunk-5R627MYY.js";
286
- import "./chunk-WGFEGBFB.js";
287
- import "./chunk-XVTB6BZS.js";
286
+ import "./chunk-W4QTA2H5.js";
287
+ import "./chunk-P5FYZH5G.js";
288
288
  import "./chunk-AB3A7EFH.js";
289
289
  import "./chunk-KIUVEMBI.js";
290
290
  import "./chunk-HJ4X4BGS.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "10.0.55",
3
+ "version": "10.0.57",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -3346,7 +3346,7 @@
3346
3346
  "core"
3347
3347
  ],
3348
3348
  "peerDependencies": {
3349
- "@remnic/coding-graph": "^10.0.55"
3349
+ "@remnic/coding-graph": "^10.0.57"
3350
3350
  },
3351
3351
  "peerDependenciesMeta": {
3352
3352
  "@remnic/coding-graph": {
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
6
 
7
- import type { NamespaceCatalog, NamespaceRecord } from "../namespaces/catalog.js";
7
+ import type { NamespaceCatalog, NamespaceCatalogFilter, NamespaceCatalogReadOptions, NamespaceRecord } from "../namespaces/catalog.js";
8
8
  import { namespaceIdentityToken } from "../namespaces/identity.js";
9
9
  import type { PluginConfig } from "../types.js";
10
10
  import {
@@ -145,6 +145,46 @@ test("planner fans out to live cataloged project and team namespaces while skipp
145
145
  }
146
146
  });
147
147
 
148
+ test("planner records catalog_read_failed for an incomplete catalog read and still plans what it found (#3293)", async () => {
149
+ const memoryDir = await mkMemoryDir();
150
+ try {
151
+ await createNamespaceData(memoryDir, "project-alpha");
152
+ // Object.create(null) yields an untyped value, so no assertion is needed: the planner only
153
+ // reads enabled and calls listNamespaces.
154
+ const catalog: NamespaceCatalog = Object.assign(Object.create(null), {
155
+ enabled: true,
156
+ async listNamespaces(_filter?: NamespaceCatalogFilter, options?: NamespaceCatalogReadOptions) {
157
+ options?.onIncomplete?.("1 malformed catalog line skipped");
158
+ return [record(memoryDir, "project-alpha", "project", "2026-06-30T10:00:00.000Z")];
159
+ },
160
+ });
161
+
162
+ const plan = await planNamespaceMaintenance(makeConfig(memoryDir), { jobName: "qmd", catalog });
163
+
164
+ assert.ok(
165
+ plan.namespaces.some((candidate) => candidate.namespace === "project-alpha"),
166
+ "the namespaces that were read are still maintained"
167
+ );
168
+ const failed = plan.skipped.filter((skipped) => skipped.reason === "catalog_read_failed");
169
+ assert.equal(failed.length, 1);
170
+ assert.equal(failed[0].namespace, "*");
171
+ assert.match(failed[0].detail ?? "", /malformed/);
172
+ } finally {
173
+ await rm(memoryDir, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ test("planner without a catalog plans the configured namespaces and reports nothing (#3293)", async () => {
178
+ const memoryDir = await mkMemoryDir();
179
+ try {
180
+ const plan = await planNamespaceMaintenance(makeConfig(memoryDir), { jobName: "qmd" });
181
+ assert.deepEqual(plan.namespaces.map((candidate) => candidate.namespace), ["default", "shared"]);
182
+ assert.equal(plan.skipped.some((skipped) => skipped.reason === "catalog_read_failed"), false);
183
+ } finally {
184
+ await rm(memoryDir, { recursive: true, force: true });
185
+ }
186
+ });
187
+
148
188
  test("planner includes branch namespaces only when branch maintenance is enabled", async () => {
149
189
  const memoryDir = await mkMemoryDir();
150
190
  try {
@@ -229,7 +229,14 @@ export async function planNamespaceMaintenance(
229
229
  if (resolveNamespaceCapabilities(config).namespaces && resolveConversationContextCapabilities(config).maintenanceNamespaceFanout !== false) {
230
230
  const configuredSet = new Set(configured);
231
231
  try {
232
- const records = options.catalog?.enabled ? await options.catalog.listNamespaces() : [];
232
+ // An incomplete read (unreadable file, malformed rows) still returns the readable records,
233
+ // so they are planned as before; the callback records that the catalog was not whole.
234
+ const records = options.catalog?.enabled
235
+ ? await options.catalog.listNamespaces(undefined, {
236
+ onIncomplete: (reason) =>
237
+ skipped.push({ namespace: "*", reason: "catalog_read_failed", detail: reason }),
238
+ })
239
+ : [];
233
240
  for (const record of records) {
234
241
  const namespace = record.namespace.trim();
235
242
  if (!namespace) continue;
@@ -0,0 +1,121 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { PluginConfig } from "../types.js";
4
+ import type { NamespaceRecord } from "./catalog.js";
5
+ import { namespaceIdentityToken } from "./identity.js";
6
+ import {
7
+ configuredNamespaceIdentities,
8
+ dropDuplicateStorageRootAliases,
9
+ preferStorageRootOwner,
10
+ sanitizeRecordForRead,
11
+ storageRootOwnershipRank,
12
+ type CatalogReadBoundaryPort,
13
+ } from "./catalog-read-boundary.js";
14
+
15
+ /**
16
+ * Config for the name-only boundary functions. Object.create(null) yields an untyped value, so
17
+ * no assertion is needed: the functions read only the three namespace-name fields.
18
+ */
19
+ function namesOnlyConfig(
20
+ defaultNamespace: string,
21
+ sharedNamespace: string,
22
+ policyNames: string[],
23
+ ): PluginConfig {
24
+ return Object.assign(Object.create(null), {
25
+ defaultNamespace,
26
+ sharedNamespace,
27
+ namespacePolicies: policyNames.map((name) => ({ name, readPrincipals: [], writePrincipals: [] })),
28
+ });
29
+ }
30
+
31
+ const config = namesOnlyConfig("default", "shared", ["project-a"]);
32
+
33
+ const port: CatalogReadBoundaryPort = {
34
+ defaultNamespaceIdentity: "default",
35
+ memoryDir: "/mem",
36
+ async isContainedStorageDir(candidate) {
37
+ return candidate.startsWith("/mem/namespaces/");
38
+ },
39
+ async isStorageDirForNamespace() {
40
+ return true;
41
+ },
42
+ async resolveSafeStorageDir(namespace) {
43
+ return `/mem/namespaces/${namespaceIdentityToken(namespace)}`;
44
+ },
45
+ };
46
+
47
+ function rec(namespace: string, storageDir: string): NamespaceRecord {
48
+ // SAFETY: this literal is a complete NamespaceRecord; the cast only supplies the optional
49
+ // timestamp fields (lastReadAt/lastWriteAt/lastMaintenanceAt) as undefined.
50
+ return {
51
+ namespace,
52
+ identityToken: namespaceIdentityToken(namespace),
53
+ kind: "project",
54
+ createdAt: "2026-01-01T00:00:00.000Z",
55
+ storageDir,
56
+ discoveredBy: "write",
57
+ };
58
+ }
59
+
60
+ test("storageRootOwnershipRank orders the ownership tiers", () => {
61
+ const configured = configuredNamespaceIdentities(config);
62
+ const tokenA = namespaceIdentityToken("project-a");
63
+ // Tier 0: the catalog-owned literal for a configured namespace.
64
+ assert.equal(storageRootOwnershipRank(port, rec("project-a", `/mem/namespaces/${tokenA}`), `/mem/namespaces/${tokenA}`, configured), 0);
65
+ // Tier 1: the namespace name is the root's leaf.
66
+ assert.equal(storageRootOwnershipRank(port, rec("leaf", "/mem/namespaces/leaf"), "/mem/namespaces/leaf", configured), 1);
67
+ // Tier 2: token-shaped root for an unconfigured namespace.
68
+ const tokenOther = namespaceIdentityToken("other");
69
+ assert.equal(storageRootOwnershipRank(port, rec("other", `/mem/namespaces/${tokenOther}`), `/mem/namespaces/${tokenOther}`, configured), 2);
70
+ // Tier 3: neither, and the memoryDir root is only owned by the default namespace.
71
+ assert.equal(storageRootOwnershipRank(port, rec("other", "/mem/unrelated-root"), "/mem/unrelated-root", configured), 3);
72
+ assert.equal(storageRootOwnershipRank(port, rec("default", "/mem"), "/mem", configured), 0, "the default namespace owns the memoryDir root");
73
+ assert.equal(storageRootOwnershipRank(port, rec("other", "/mem"), "/mem", configured), 3);
74
+ });
75
+
76
+ test("configuredNamespaceIdentities drops empty names and keeps configured ones", () => {
77
+ const cfg = namesOnlyConfig(" default ", "", [" ", "project-a"]);
78
+ assert.deepEqual([...configuredNamespaceIdentities(cfg)].sort(), ["default", "project-a"]);
79
+ // A single-character namespace is a valid identity and must survive the empty filter.
80
+ const shortCfg = namesOnlyConfig("x", "y", []);
81
+ assert.deepEqual([...configuredNamespaceIdentities(shortCfg)].sort(), ["x", "y"]);
82
+ });
83
+
84
+ test("preferStorageRootOwner breaks ties by name then identityToken", () => {
85
+ const configured = configuredNamespaceIdentities(config);
86
+ const a = rec("beta", "/mem/x");
87
+ const b = rec("alpha", "/mem/x");
88
+ assert.equal(preferStorageRootOwner(port, a, b, "/mem/x", configured), b);
89
+ assert.equal(preferStorageRootOwner(port, b, a, "/mem/x", configured), b);
90
+ // Same name: the lower identityToken wins, whichever side it arrives on.
91
+ const t1 = { ...rec("alpha", "/mem/x"), identityToken: "token-one" };
92
+ const t2 = { ...rec("alpha", "/mem/x"), identityToken: "token-two" };
93
+ assert.equal(preferStorageRootOwner(port, t2, t1, "/mem/x", configured), t1);
94
+ assert.equal(preferStorageRootOwner(port, t1, t2, "/mem/x", configured), t1);
95
+ // Exact identity tie: the CURRENT wins (localeCompare returns 0, and 0 < 0 is false).
96
+ const same1 = rec("alpha", "/mem/x");
97
+ const same2 = { ...rec("alpha", "/mem/x"), lastWriteAt: "2026-02-02T00:00:00.000Z" };
98
+ assert.equal(preferStorageRootOwner(port, same1, same2, "/mem/x", configured), same1);
99
+ assert.equal(preferStorageRootOwner(port, same2, same1, "/mem/x", configured), same2);
100
+ // Candidate sorts AFTER the current name: the current wins on the name comparison alone.
101
+ const zeta = rec("zeta", "/mem/x");
102
+ assert.equal(preferStorageRootOwner(port, b, zeta, "/mem/x", configured), b);
103
+ assert.equal(preferStorageRootOwner(port, zeta, b, "/mem/x", configured), b);
104
+ });
105
+
106
+ test("dropDuplicateStorageRootAliases keeps one owner per root and merges the alias's recency", () => {
107
+ const tokenA = namespaceIdentityToken("project-a");
108
+ const configured = rec("project-a", `/mem/namespaces/${tokenA}`);
109
+ const alias = { ...rec("other", `/mem/namespaces/${tokenA}`), lastWriteAt: "2026-02-02T00:00:00.000Z" };
110
+ const out = dropDuplicateStorageRootAliases(port, [alias, configured], config);
111
+ assert.equal(out.length, 1);
112
+ assert.equal(out[0].namespace, "project-a");
113
+ assert.equal(out[0].lastWriteAt, "2026-02-02T00:00:00.000Z", "the dropped alias's recency survives in the owner");
114
+ });
115
+
116
+ test("sanitizeRecordForRead drops unsafe names and substitutes unsafe storage dirs", async () => {
117
+ assert.equal(await sanitizeRecordForRead(port, rec("../evil", "/mem/x")), null);
118
+ const safe = await sanitizeRecordForRead(port, rec("alpha", "/mem/elsewhere"));
119
+ assert.ok(safe);
120
+ assert.equal(safe.storageDir, `/mem/namespaces/${namespaceIdentityToken("alpha")}`);
121
+ });
@@ -0,0 +1,159 @@
1
+ import path from "node:path";
2
+ import type { PluginConfig } from "../types.js";
3
+ import { namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
4
+ import { mergeNewerTouchFields } from "./catalog.js";
5
+ import type { NamespaceRecord } from "./catalog.js";
6
+ import { isSafeRouteNamespace } from "../routing/engine.js";
7
+
8
+ /**
9
+ * Read-boundary sanitation for NamespaceCatalog (extracted in #3297): the two
10
+ * defenses against a tampered or pre-fix `namespaces.jsonl` row, and the
11
+ * storage-root alias collapse that picks one owner per physical root.
12
+ */
13
+ export interface CatalogReadBoundaryPort {
14
+ defaultNamespaceIdentity: string;
15
+ memoryDir: string;
16
+ isContainedStorageDir(candidate: string): Promise<boolean>;
17
+ isStorageDirForNamespace(
18
+ namespace: string,
19
+ candidate: string,
20
+ ): Promise<boolean>;
21
+ resolveSafeStorageDir(namespace: string): Promise<string>;
22
+ }
23
+
24
+ /**
25
+ * Sanitize a record at the enumeration boundary (round 5, cursor Medium + codex
26
+ * P2; round 6 — NDXHe). Reads return whatever is in `namespaces.jsonl` after
27
+ * schema checks only, so a tampered or pre-fix row could surface unsafe data to
28
+ * maintenance/QMD until a rewrite occurs. Two distinct defenses:
29
+ *
30
+ * 1. UNSAFE NAMESPACE NAME (NGZqr, codex P2): an unsafe non-default namespace
31
+ * (e.g. `../evil`, a name with separators, or >64 chars) is REJECTED outright
32
+ * — return `null` so the caller drops it. The disk SCAN and the hot touch
33
+ * path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
34
+ * gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
35
+ * would expose a namespace those paths reject (note `isStorageDirForNamespace`
36
+ * can still build a tokenized root even for `../evil`, so storageDir sanitation
37
+ * alone does not catch it). The default namespace is exempt (it may be a
38
+ * non-route literal), matching every other validation site.
39
+ *
40
+ * 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
41
+ * as the write path — full containment (`isContainedStorageDir`: lexical +
42
+ * symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
43
+ * a record fails EITHER check we substitute the trusted resolved-and-safe root
44
+ * for that namespace (rule 42: read and write stay symmetric).
45
+ */
46
+ export async function sanitizeRecordForRead(
47
+ port: CatalogReadBoundaryPort,
48
+ record: NamespaceRecord,
49
+ ): Promise<NamespaceRecord | null> {
50
+ // Defense 1: drop an unsafe non-default namespace name entirely. Compare
51
+ // against the NORMALIZED default identity — record keys are trimmed, so a raw
52
+ // whitespace-padded config default would never match the default row (NH-FH).
53
+ if (
54
+ record.namespace !== port.defaultNamespaceIdentity &&
55
+ !isSafeRouteNamespace(record.namespace)
56
+ ) {
57
+ return null;
58
+ }
59
+ // Defense 2: keep the record but substitute a safe storageDir when needed.
60
+ if (
61
+ (await port.isContainedStorageDir(record.storageDir)) &&
62
+ (await port.isStorageDirForNamespace(record.namespace, record.storageDir))
63
+ ) {
64
+ return record;
65
+ }
66
+ const safe = await port.resolveSafeStorageDir(record.namespace);
67
+ return { ...record, storageDir: safe };
68
+ }
69
+
70
+ export function storageRootOwnershipRank(
71
+ port: CatalogReadBoundaryPort,
72
+ record: NamespaceRecord,
73
+ resolvedStorageDir: string,
74
+ configured: Set<string>,
75
+ ): number {
76
+ if (resolvedStorageDir === path.resolve(port.memoryDir)) {
77
+ return record.namespace === port.defaultNamespaceIdentity ? 0 : 3;
78
+ }
79
+
80
+ const leaf = path.basename(resolvedStorageDir);
81
+ const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
82
+ if (tokenOwnsRoot && configured.has(record.namespace)) {
83
+ return 0;
84
+ }
85
+ if (record.namespace === leaf) return 1;
86
+ if (tokenOwnsRoot) return 2;
87
+ return 3;
88
+ }
89
+
90
+ export function configuredNamespaceIdentities(
91
+ config: PluginConfig,
92
+ ): Set<string> {
93
+ return new Set(
94
+ [
95
+ config.defaultNamespace,
96
+ config.sharedNamespace,
97
+ ...config.namespacePolicies.map((p) => p.name),
98
+ ]
99
+ .map((n) => normalizeNamespaceIdentity(n))
100
+ .filter((n) => n.length > 0),
101
+ );
102
+ }
103
+
104
+ export function preferStorageRootOwner(
105
+ port: CatalogReadBoundaryPort,
106
+ current: NamespaceRecord,
107
+ candidate: NamespaceRecord,
108
+ resolvedStorageDir: string,
109
+ configured: Set<string>,
110
+ ): NamespaceRecord {
111
+ const currentRank = storageRootOwnershipRank(
112
+ port,
113
+ current,
114
+ resolvedStorageDir,
115
+ configured,
116
+ );
117
+ const candidateRank = storageRootOwnershipRank(
118
+ port,
119
+ candidate,
120
+ resolvedStorageDir,
121
+ configured,
122
+ );
123
+ if (candidateRank < currentRank) return candidate;
124
+ if (candidateRank > currentRank) return current;
125
+
126
+ const byName = candidate.namespace.localeCompare(current.namespace);
127
+ if (byName < 0) return candidate;
128
+ if (byName > 0) return current;
129
+ return candidate.identityToken.localeCompare(current.identityToken) < 0
130
+ ? candidate
131
+ : current;
132
+ }
133
+
134
+ export function dropDuplicateStorageRootAliases(
135
+ port: CatalogReadBoundaryPort,
136
+ records: NamespaceRecord[],
137
+ config: PluginConfig,
138
+ ): NamespaceRecord[] {
139
+ const byStorageDir = new Map<string, NamespaceRecord>();
140
+ const configured = configuredNamespaceIdentities(config);
141
+ for (const record of records) {
142
+ const resolvedStorageDir = path.resolve(record.storageDir);
143
+ const current = byStorageDir.get(resolvedStorageDir);
144
+ if (!current) {
145
+ byStorageDir.set(resolvedStorageDir, record);
146
+ continue;
147
+ }
148
+ const owner = preferStorageRootOwner(
149
+ port,
150
+ current,
151
+ record,
152
+ resolvedStorageDir,
153
+ configured,
154
+ );
155
+ const alias = owner === current ? record : current;
156
+ byStorageDir.set(resolvedStorageDir, mergeNewerTouchFields(owner, alias));
157
+ }
158
+ return [...byStorageDir.values()];
159
+ }