@remnic/core 10.0.56 → 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 (38) 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-FCAXGYDT.js → chunk-CTNDFKEX.js} +2 -2
  5. package/dist/{chunk-CC2ESKHP.js → chunk-E2YAGEFR.js} +7 -7
  6. package/dist/{chunk-CZS7UTXL.js → chunk-KCTL5EY7.js} +2 -2
  7. package/dist/{chunk-5C44AMRV.js → chunk-L5UB5BIY.js} +2 -2
  8. package/dist/{chunk-S3OI5DP5.js → chunk-P5FYZH5G.js} +235 -124
  9. package/dist/chunk-P5FYZH5G.js.map +1 -0
  10. package/dist/{chunk-OGR52N7A.js → chunk-W2KH27MQ.js} +3 -3
  11. package/dist/{chunk-GMRP3HN3.js → chunk-W3R77WHT.js} +2 -2
  12. package/dist/{chunk-VZ7VJGPL.js → chunk-W4QTA2H5.js} +2 -2
  13. package/dist/cli.js +6 -6
  14. package/dist/index.js +8 -8
  15. package/dist/namespaces/catalog-read-boundary.d.ts +42 -0
  16. package/dist/namespaces/catalog-read-boundary.d.ts.map +1 -0
  17. package/dist/namespaces/catalog-repair.d.ts +35 -0
  18. package/dist/namespaces/catalog-repair.d.ts.map +1 -0
  19. package/dist/namespaces/catalog.d.ts +31 -27
  20. package/dist/namespaces/catalog.d.ts.map +1 -1
  21. package/dist/operator-toolkit.js +3 -3
  22. package/dist/orchestration/maintenance.js +3 -3
  23. package/dist/orchestrator.js +8 -8
  24. package/package.json +2 -2
  25. package/src/namespaces/catalog-read-boundary.test.ts +121 -0
  26. package/src/namespaces/catalog-read-boundary.ts +159 -0
  27. package/src/namespaces/catalog-repair.ts +69 -0
  28. package/src/namespaces/catalog.test.ts +493 -10
  29. package/src/namespaces/catalog.ts +112 -110
  30. package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
  31. package/dist/chunk-S3OI5DP5.js.map +0 -1
  32. /package/dist/{chunk-FCAXGYDT.js.map → chunk-CTNDFKEX.js.map} +0 -0
  33. /package/dist/{chunk-CC2ESKHP.js.map → chunk-E2YAGEFR.js.map} +0 -0
  34. /package/dist/{chunk-CZS7UTXL.js.map → chunk-KCTL5EY7.js.map} +0 -0
  35. /package/dist/{chunk-5C44AMRV.js.map → chunk-L5UB5BIY.js.map} +0 -0
  36. /package/dist/{chunk-OGR52N7A.js.map → chunk-W2KH27MQ.js.map} +0 -0
  37. /package/dist/{chunk-GMRP3HN3.js.map → chunk-W3R77WHT.js.map} +0 -0
  38. /package/dist/{chunk-VZ7VJGPL.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"}
@@ -89,6 +89,15 @@ export interface NamespaceCatalogRebuildResult {
89
89
  }
90
90
  export declare function hasMemoryData(rootDir: string): Promise<boolean>;
91
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;
92
101
  export declare class NamespaceCatalog {
93
102
  private readonly config;
94
103
  private readonly memoryDir;
@@ -107,38 +116,15 @@ export declare class NamespaceCatalog {
107
116
  private readonly compactBytesLimit;
108
117
  private readonly readCoalesceMs;
109
118
  private readonly writeCoalesceMs;
119
+ private readonly malformedRepairGate;
120
+ /** Test-only observability: how many repair attempts actually entered the gate. */
121
+ get malformedRepairAttemptsForTest(): number;
110
122
  private lastCompactedSize;
111
123
  private readonly pendingTouches;
112
124
  constructor(config: PluginConfig);
113
125
  /** Whether the catalog is active (namespaces enabled and catalog not opted out). */
114
126
  get enabled(): boolean;
115
- /**
116
- * Sanitize a record at the enumeration boundary (round 5, cursor Medium + codex
117
- * P2; round 6 — NDXHe). Reads return whatever is in `namespaces.jsonl` after
118
- * schema checks only, so a tampered or pre-fix row could surface unsafe data to
119
- * maintenance/QMD until a rewrite occurs. Two distinct defenses:
120
- *
121
- * 1. UNSAFE NAMESPACE NAME (NGZqr, codex P2): an unsafe non-default namespace
122
- * (e.g. `../evil`, a name with separators, or >64 chars) is REJECTED outright
123
- * — return `null` so the caller drops it. The disk SCAN and the hot touch
124
- * path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
125
- * gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
126
- * would expose a namespace those paths reject (note `isStorageDirForNamespace`
127
- * can still build a tokenized root even for `../evil`, so storageDir sanitation
128
- * alone does not catch it). The default namespace is exempt (it may be a
129
- * non-route literal), matching every other validation site.
130
- *
131
- * 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
132
- * as the write path — full containment (`isContainedStorageDir`: lexical +
133
- * symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
134
- * a record fails EITHER check we substitute the trusted resolved-and-safe root
135
- * for that namespace (rule 42: read and write stay symmetric).
136
- */
137
- private sanitizeRecordForRead;
138
- private storageRootOwnershipRank;
139
- private configuredNamespaceIdentities;
140
- private preferStorageRootOwner;
141
- private dropDuplicateStorageRootAliases;
127
+ private catalogReadBoundaryPort;
142
128
  private loadSanitizedRecords;
143
129
  listNamespaces(filter?: NamespaceCatalogFilter, options?: NamespaceCatalogReadOptions): Promise<NamespaceRecord[]>;
144
130
  getNamespaceRecord(namespace: string): Promise<NamespaceRecord | null>;
@@ -357,6 +343,24 @@ export declare class NamespaceCatalog {
357
343
  * suppressing its retry.
358
344
  */
359
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;
360
364
  /**
361
365
  * Fold-and-rewrite the JSONL log when it exceeds `compactBytesLimit`
362
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-GMRP3HN3.js";
18
- import "./chunk-CZS7UTXL.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-S3OI5DP5.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-5C44AMRV.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-CZS7UTXL.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-S3OI5DP5.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-CC2ESKHP.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-5C44AMRV.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-OGR52N7A.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-GMRP3HN3.js";
168
- import "./chunk-CZS7UTXL.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-FCAXGYDT.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-VZ7VJGPL.js";
287
- import "./chunk-S3OI5DP5.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.56",
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.56"
3349
+ "@remnic/coding-graph": "^10.0.57"
3350
3350
  },
3351
3351
  "peerDependenciesMeta": {
3352
3352
  "@remnic/coding-graph": {
@@ -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
+ }
@@ -0,0 +1,69 @@
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
+
7
+ import { createHash } from "node:crypto";
8
+
9
+ /** After a dropped attempt, wait this long before another read may schedule one. */
10
+ export const MALFORMED_REPAIR_DROP_BACKOFF_MS = 250;
11
+
12
+ /**
13
+ * Bounds repairs triggered by malformed reads (#3298 review): one attempt in
14
+ * flight at a time, and a short backoff after an attempt was dropped (another
15
+ * process held the lock). The guard is what stops concurrent malformed reads
16
+ * from queueing serial lock waits ahead of touch(); the 5 s lock wait is itself
17
+ * the rate limiter under sustained contention, which is why the backoff is
18
+ * short — a longer window would delay the legitimate warm-cache retry after
19
+ * contention clears. A fast failure holds no lock budget and does not back off.
20
+ */
21
+ export class MalformedCatalogRepairGate {
22
+ private inFlight: Promise<void> | undefined;
23
+ private nextAttemptAt = 0;
24
+ private attempts = 0;
25
+
26
+ /** Test-only observability: attempts that actually entered the gate. */
27
+ get attemptCount(): number {
28
+ return this.attempts;
29
+ }
30
+
31
+ /**
32
+ * Run `attempt` unless one is already in flight or the backoff window is on.
33
+ * Resolves when the attempt settles; never rejects (the repair is fail-open).
34
+ */
35
+ run(attempt: () => Promise<"repaired" | "dropped" | "failed">): Promise<void> {
36
+ if (this.inFlight || Date.now() < this.nextAttemptAt) return Promise.resolve();
37
+ this.attempts += 1;
38
+ // Start the attempt as a microtask so run() returns before any of its work
39
+ // begins, keeping the guard decision synchronous for every caller.
40
+ this.inFlight = Promise.resolve().then(async () => {
41
+ let outcome: "repaired" | "dropped" | "failed" = "failed";
42
+ try {
43
+ outcome = await attempt();
44
+ } catch {
45
+ outcome = "failed";
46
+ } finally {
47
+ this.inFlight = undefined;
48
+ if (outcome === "dropped") {
49
+ this.nextAttemptAt = Date.now() + MALFORMED_REPAIR_DROP_BACKOFF_MS;
50
+ }
51
+ }
52
+ });
53
+ return this.inFlight;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Quarantine sidecar path for the rows a repair drops (#3298 review): named by a
59
+ * hash of the dropped lines, so repeated attempts over the same bad rows reuse
60
+ * one file instead of piling one sidecar per failed attempt.
61
+ */
62
+ export function malformedSidecarPath(catalogPath: string, dropped: string[]): string {
63
+ return `${catalogPath}.malformed-${contentHash(dropped.join("\n"))}`;
64
+ }
65
+
66
+ // sha256 of the dropped lines, truncated for a filename; stable across processes.
67
+ function contentHash(input: string): string {
68
+ return createHash("sha256").update(input, "utf8").digest("hex").slice(0, 16);
69
+ }