@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.
- package/dist/access-cli.js +8 -8
- package/dist/access-http-query.js +2 -2
- package/dist/access-http.js +3 -3
- package/dist/{chunk-FCAXGYDT.js → chunk-CTNDFKEX.js} +2 -2
- package/dist/{chunk-CC2ESKHP.js → chunk-E2YAGEFR.js} +7 -7
- package/dist/{chunk-CZS7UTXL.js → chunk-KCTL5EY7.js} +2 -2
- package/dist/{chunk-5C44AMRV.js → chunk-L5UB5BIY.js} +2 -2
- package/dist/{chunk-S3OI5DP5.js → chunk-P5FYZH5G.js} +235 -124
- package/dist/chunk-P5FYZH5G.js.map +1 -0
- package/dist/{chunk-OGR52N7A.js → chunk-W2KH27MQ.js} +3 -3
- package/dist/{chunk-GMRP3HN3.js → chunk-W3R77WHT.js} +2 -2
- package/dist/{chunk-VZ7VJGPL.js → chunk-W4QTA2H5.js} +2 -2
- package/dist/cli.js +6 -6
- package/dist/index.js +8 -8
- package/dist/namespaces/catalog-read-boundary.d.ts +42 -0
- package/dist/namespaces/catalog-read-boundary.d.ts.map +1 -0
- package/dist/namespaces/catalog-repair.d.ts +35 -0
- package/dist/namespaces/catalog-repair.d.ts.map +1 -0
- package/dist/namespaces/catalog.d.ts +31 -27
- package/dist/namespaces/catalog.d.ts.map +1 -1
- package/dist/operator-toolkit.js +3 -3
- package/dist/orchestration/maintenance.js +3 -3
- package/dist/orchestrator.js +8 -8
- package/package.json +2 -2
- package/src/namespaces/catalog-read-boundary.test.ts +121 -0
- package/src/namespaces/catalog-read-boundary.ts +159 -0
- package/src/namespaces/catalog-repair.ts +69 -0
- package/src/namespaces/catalog.test.ts +493 -10
- package/src/namespaces/catalog.ts +112 -110
- package/src/testing/subjects/catalog-read-repair.test.ts +124 -0
- package/dist/chunk-S3OI5DP5.js.map +0 -1
- /package/dist/{chunk-FCAXGYDT.js.map → chunk-CTNDFKEX.js.map} +0 -0
- /package/dist/{chunk-CC2ESKHP.js.map → chunk-E2YAGEFR.js.map} +0 -0
- /package/dist/{chunk-CZS7UTXL.js.map → chunk-KCTL5EY7.js.map} +0 -0
- /package/dist/{chunk-5C44AMRV.js.map → chunk-L5UB5BIY.js.map} +0 -0
- /package/dist/{chunk-OGR52N7A.js.map → chunk-W2KH27MQ.js.map} +0 -0
- /package/dist/{chunk-GMRP3HN3.js.map → chunk-W3R77WHT.js.map} +0 -0
- /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":"
|
|
1
|
+
{"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../../src/namespaces/catalog.ts"],"names":[],"mappings":"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"}
|
package/dist/operator-toolkit.js
CHANGED
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
summarizeProvenanceCoverage,
|
|
15
15
|
summarizeTierDistribution,
|
|
16
16
|
summarizeTombstoneStatus
|
|
17
|
-
} from "./chunk-
|
|
18
|
-
import "./chunk-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
17
|
+
import "../chunk-P5FYZH5G.js";
|
|
18
18
|
import "../chunk-OGXXGHW6.js";
|
|
19
19
|
import "../chunk-WR5POHQ3.js";
|
|
20
20
|
import "../chunk-TTTPTQ6S.js";
|
package/dist/orchestrator.js
CHANGED
|
@@ -4,13 +4,13 @@ import {
|
|
|
4
4
|
graphPathRelativeToStorage,
|
|
5
5
|
mergeGraphExpandedResults,
|
|
6
6
|
qmdStartupCollectionChecksWithTimeout
|
|
7
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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-
|
|
168
|
-
import "./chunk-
|
|
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-
|
|
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-
|
|
287
|
-
import "./chunk-
|
|
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.
|
|
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.
|
|
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
|
+
}
|