@evomap/evolver-core 2.0.0-beta.10 → 2.0.0-beta.11
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/assetstore/assetStoreHealth.d.ts +7 -0
- package/dist/assetstore/assetStoreHealth.js +34 -3
- package/dist/assetstore/provenance.d.ts +12 -0
- package/dist/assetstore/provenance.js +29 -1
- package/dist/assetstore/provider.d.ts +14 -0
- package/dist/assetstore/provider.js +13 -5
- package/package.json +1 -1
|
@@ -15,6 +15,12 @@ export interface AssetStoreFileHealth {
|
|
|
15
15
|
duplicateRows: number;
|
|
16
16
|
corruptRows: number;
|
|
17
17
|
hashMismatchRows: number;
|
|
18
|
+
/**
|
|
19
|
+
* Rows that fail content-hash verification but are provenance-marked as an unverified hub reuse
|
|
20
|
+
* (evolver-v2#570). These are EXPECTED — the hub rewrote the delivered bytes and reuse froze them untrusted —
|
|
21
|
+
* so they are a benign category, counted separately and never degrading the store.
|
|
22
|
+
*/
|
|
23
|
+
unverifiedRows: number;
|
|
18
24
|
schemaInvalidRows: number;
|
|
19
25
|
unterminated: boolean;
|
|
20
26
|
reason?: UnsafeAssetStorePathReason | 'base_directory' | 'lock_unavailable' | 'read_unavailable' | 'scan_limit_exceeded';
|
|
@@ -31,6 +37,7 @@ export interface AssetStoreHealthTotals {
|
|
|
31
37
|
duplicateRows: number;
|
|
32
38
|
corruptRows: number;
|
|
33
39
|
hashMismatchRows: number;
|
|
40
|
+
unverifiedRows: number;
|
|
34
41
|
schemaInvalidRows: number;
|
|
35
42
|
unterminatedFiles: number;
|
|
36
43
|
}
|
|
@@ -31,6 +31,7 @@ function emptyFile(kind, file, status, reason) {
|
|
|
31
31
|
duplicateRows: 0,
|
|
32
32
|
corruptRows: 0,
|
|
33
33
|
hashMismatchRows: 0,
|
|
34
|
+
unverifiedRows: 0,
|
|
34
35
|
schemaInvalidRows: 0,
|
|
35
36
|
unterminated: false,
|
|
36
37
|
...(reason ? { reason } : {}),
|
|
@@ -69,6 +70,7 @@ function summarize(files, sidecars) {
|
|
|
69
70
|
duplicateRows: files.reduce((sum, file) => sum + file.duplicateRows, 0),
|
|
70
71
|
corruptRows: files.reduce((sum, file) => sum + file.corruptRows, 0),
|
|
71
72
|
hashMismatchRows: files.reduce((sum, file) => sum + file.hashMismatchRows, 0),
|
|
73
|
+
unverifiedRows: files.reduce((sum, file) => sum + file.unverifiedRows, 0),
|
|
72
74
|
schemaInvalidRows: files.reduce((sum, file) => sum + file.schemaInvalidRows, 0),
|
|
73
75
|
unterminatedFiles: files.filter((file) => file.unterminated).length,
|
|
74
76
|
};
|
|
@@ -105,7 +107,7 @@ function summarize(files, sidecars) {
|
|
|
105
107
|
sidecars: [...sidecars],
|
|
106
108
|
};
|
|
107
109
|
}
|
|
108
|
-
function inspectFile(baseDir, kind, file, maxFileBytes) {
|
|
110
|
+
function inspectFile(baseDir, kind, file, maxFileBytes, unverifiedIds) {
|
|
109
111
|
try {
|
|
110
112
|
const path = join(baseDir, file);
|
|
111
113
|
const stat = assertOptionalRegularFile(path);
|
|
@@ -123,6 +125,7 @@ function inspectFile(baseDir, kind, file, maxFileBytes) {
|
|
|
123
125
|
let duplicateRows = 0;
|
|
124
126
|
let corruptRows = 0;
|
|
125
127
|
let hashMismatchRows = 0;
|
|
128
|
+
let unverifiedRows = 0;
|
|
126
129
|
let schemaInvalidRows = 0;
|
|
127
130
|
for (const line of rows) {
|
|
128
131
|
try {
|
|
@@ -142,7 +145,13 @@ function inspectFile(baseDir, kind, file, maxFileBytes) {
|
|
|
142
145
|
else
|
|
143
146
|
seen.add(assetId);
|
|
144
147
|
if (!verifyAssetId(record)) {
|
|
145
|
-
|
|
148
|
+
// A hash mismatch is corruption UNLESS provenance says this id is an unverified hub reuse: the hub
|
|
149
|
+
// rewrote the delivered bytes and reuse froze them untrusted on purpose (#570). That is expected, not
|
|
150
|
+
// store rot, so it lands in the benign unverifiedRows bucket and never degrades the store.
|
|
151
|
+
if (unverifiedIds.has(assetId))
|
|
152
|
+
unverifiedRows += 1;
|
|
153
|
+
else
|
|
154
|
+
hashMismatchRows += 1;
|
|
146
155
|
continue;
|
|
147
156
|
}
|
|
148
157
|
if (kind !== 'AntiGene' && !validateWire(record).ok) {
|
|
@@ -174,6 +183,7 @@ function inspectFile(baseDir, kind, file, maxFileBytes) {
|
|
|
174
183
|
duplicateRows,
|
|
175
184
|
corruptRows,
|
|
176
185
|
hashMismatchRows,
|
|
186
|
+
unverifiedRows,
|
|
177
187
|
schemaInvalidRows,
|
|
178
188
|
unterminated,
|
|
179
189
|
};
|
|
@@ -184,6 +194,24 @@ function inspectFile(baseDir, kind, file, maxFileBytes) {
|
|
|
184
194
|
return emptyFile(kind, file, 'unavailable', 'read_unavailable');
|
|
185
195
|
}
|
|
186
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Asset ids marked in the provenance sidecar as an unverified hub reuse (reason `unverified_*`, evolver-v2#570).
|
|
199
|
+
* Read directly from `<baseDir>/provenance.jsonl` — NOT via ProvenanceStore — because inspectLocalAssetStore
|
|
200
|
+
* already holds the shared `.assetstore.lock`, and ProvenanceStore would try to re-acquire it. A missing or
|
|
201
|
+
* unreadable sidecar yields an empty set: without provenance every mismatch stays classified as corruption.
|
|
202
|
+
*/
|
|
203
|
+
function readUnverifiedReuseIds(baseDir) {
|
|
204
|
+
const raw = readUtf8Regular(join(baseDir, 'provenance.jsonl'));
|
|
205
|
+
if (raw === null)
|
|
206
|
+
return new Set();
|
|
207
|
+
const ids = new Set();
|
|
208
|
+
for (const record of parseSidecarJsonl(raw, parseProvenanceRecord).records) {
|
|
209
|
+
if (record.trusted === false && typeof record.reason === 'string' && record.reason.startsWith('unverified_')) {
|
|
210
|
+
ids.add(record.assetId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return ids;
|
|
214
|
+
}
|
|
187
215
|
function inspectSidecar(baseDir, definition, maxFileBytes) {
|
|
188
216
|
const { kind, file, parseRecord } = definition;
|
|
189
217
|
try {
|
|
@@ -256,10 +284,13 @@ export function inspectLocalAssetStore(baseDir, opts = {}, deps = {}) {
|
|
|
256
284
|
return summarize(allFiles('unavailable', 'lock_unavailable'), allSidecars('unavailable', 'lock_unavailable'));
|
|
257
285
|
}
|
|
258
286
|
const maxFileBytes = healthScanLimit(opts.maxFileBytes);
|
|
287
|
+
// Read the unverified-reuse set once, under the lock we already hold, so every asset file classifies its
|
|
288
|
+
// hash mismatches consistently against the same provenance snapshot.
|
|
289
|
+
const unverifiedIds = readUnverifiedReuseIds(baseDir);
|
|
259
290
|
let report;
|
|
260
291
|
try {
|
|
261
292
|
report = summarize(Object.entries(LOCAL_ASSET_FILES)
|
|
262
|
-
.map(([kind, file]) => inspectFile(baseDir, kind, file, maxFileBytes)), LOCAL_ASSET_SIDECARS.map((definition) => inspectSidecar(baseDir, definition, maxFileBytes)));
|
|
293
|
+
.map(([kind, file]) => inspectFile(baseDir, kind, file, maxFileBytes, unverifiedIds)), LOCAL_ASSET_SIDECARS.map((definition) => inspectSidecar(baseDir, definition, maxFileBytes)));
|
|
263
294
|
}
|
|
264
295
|
catch {
|
|
265
296
|
report = summarize(allFiles('unavailable', 'read_unavailable'), allSidecars('unavailable', 'read_unavailable'));
|
|
@@ -55,6 +55,18 @@ export declare class ProvenanceStore {
|
|
|
55
55
|
* should bring hub-fetched assets into the local pool — trust-first from the first byte (#30.1).
|
|
56
56
|
*/
|
|
57
57
|
export declare function ingestUntrusted(store: AssetStoreProvider, prov: ProvenanceStore, record: AssetRecord, source?: ProvenanceSource): Promise<PutResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Hub → local-pool landing for an asset whose content does NOT hash to its declared asset_id. The hub
|
|
60
|
+
* demonstrably rewrites delivered payloads (injected `validation`, wholesale `payload_backfill_reason`
|
|
61
|
+
* synthesis — evolver-v2#570), which breaks {@link ingestUntrusted}'s normalizeForPut self-consistency check
|
|
62
|
+
* even though the loop's own in-run reuse already consumes hub content without re-verifying it (adapter
|
|
63
|
+
* `hubReuse.ts`). Rather than hard-reject a save the operator explicitly asked for, freeze the asset under its
|
|
64
|
+
* declared (network) asset_id via `putFrozen` and mark it untrusted in the sidecar with an explicit reason.
|
|
65
|
+
* Trust-first still holds (#30.1): selection defaults to trusted-only, so an unverified asset never silently
|
|
66
|
+
* enters the reasoning pool — it lands where the operator put it and stays flagged until an explicit,
|
|
67
|
+
* audited promotion. `reason` records WHY verification was waived (e.g. hub rewrite vs synthesized payload).
|
|
68
|
+
*/
|
|
69
|
+
export declare function ingestUnverified(store: AssetStoreProvider, prov: ProvenanceStore, record: AssetRecord, reason: string, source?: ProvenanceSource): Promise<PutResult>;
|
|
58
70
|
/**
|
|
59
71
|
* Conditional variant used by Hub sync to reject a logical-id collision without ever allowing a Hub record
|
|
60
72
|
* to become implicitly trusted. Providers that cannot make the condition atomically are rejected here.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// must not enter the content hash (#30.2), or it would break content-addressing. Trust-first by construction:
|
|
5
5
|
// selection defaults to trusted-only; an untrusted asset is promoted to trusted only by an explicit, logged act.
|
|
6
6
|
import { join, dirname } from 'node:path';
|
|
7
|
-
import { normalizeForPut, supportsAtomicConditionalPut, validateConditionalPutResult, } from './provider.js';
|
|
7
|
+
import { assertCapsuleGeneBinding, normalizeForPut, supportsAtomicConditionalPut, validateConditionalPutResult, } from './provider.js';
|
|
8
8
|
import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, truncateUtf8SuffixDurable, withAssetStoreLock, } from './assetStoreStorage.js';
|
|
9
9
|
import { assertTrustSidecarHealthy, parseProvenanceRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
|
|
10
10
|
function immutableRecord(record) {
|
|
@@ -143,6 +143,34 @@ export async function ingestUntrusted(store, prov, record, source = 'hub') {
|
|
|
143
143
|
prov.rollbackLast(mark);
|
|
144
144
|
return result;
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Hub → local-pool landing for an asset whose content does NOT hash to its declared asset_id. The hub
|
|
148
|
+
* demonstrably rewrites delivered payloads (injected `validation`, wholesale `payload_backfill_reason`
|
|
149
|
+
* synthesis — evolver-v2#570), which breaks {@link ingestUntrusted}'s normalizeForPut self-consistency check
|
|
150
|
+
* even though the loop's own in-run reuse already consumes hub content without re-verifying it (adapter
|
|
151
|
+
* `hubReuse.ts`). Rather than hard-reject a save the operator explicitly asked for, freeze the asset under its
|
|
152
|
+
* declared (network) asset_id via `putFrozen` and mark it untrusted in the sidecar with an explicit reason.
|
|
153
|
+
* Trust-first still holds (#30.1): selection defaults to trusted-only, so an unverified asset never silently
|
|
154
|
+
* enters the reasoning pool — it lands where the operator put it and stays flagged until an explicit,
|
|
155
|
+
* audited promotion. `reason` records WHY verification was waived (e.g. hub rewrite vs synthesized payload).
|
|
156
|
+
*/
|
|
157
|
+
export async function ingestUnverified(store, prov, record, reason, source = 'hub') {
|
|
158
|
+
if (typeof store.putFrozen !== 'function') {
|
|
159
|
+
// Only a content-addressed local pool receives hub reuse writes; a provider that cannot freeze a
|
|
160
|
+
// hash-inconsistent record cannot preserve the network id, so fail loudly rather than silently restamp it.
|
|
161
|
+
throw new Error('ingestUnverified requires a store that implements putFrozen');
|
|
162
|
+
}
|
|
163
|
+
// putFrozen bypasses normalizeForPut, so re-assert the M3-4 Capsule↔gene binding here — a hash-mismatched
|
|
164
|
+
// Capsule with an empty gene must still fail closed on the frozen path, exactly as it does on the verified one.
|
|
165
|
+
assertCapsuleGeneBinding(record);
|
|
166
|
+
const mark = prov.mark({ assetId: record.asset_id, source, trusted: false, reason });
|
|
167
|
+
const result = await store.putFrozen(record);
|
|
168
|
+
// Mirror ingestUntrusted: only an explicit no-write result (dedup) is safe to roll the marker back; a
|
|
169
|
+
// thrown write has an ambiguous on-disk outcome and must keep the untrusted marker.
|
|
170
|
+
if (!result.stored)
|
|
171
|
+
prov.rollbackLast(mark);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
146
174
|
/**
|
|
147
175
|
* Conditional variant used by Hub sync to reject a logical-id collision without ever allowing a Hub record
|
|
148
176
|
* to become implicitly trusted. Providers that cannot make the condition atomically are rejected here.
|
|
@@ -47,6 +47,14 @@ export interface AssetStoreProvider {
|
|
|
47
47
|
get(assetId: string): Promise<AssetRecord | null>;
|
|
48
48
|
/** Optional direct lookup for non-content-addressed logical ids. Callers must handle 0, 1, or multiple matches. */
|
|
49
49
|
findByLogicalId?(id: string, limit?: number): Promise<AssetRecord[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Optional frozen write: store the record under its OWN declared asset_id without recomputing or
|
|
52
|
+
* normalizing it (bypasses {@link normalizeForPut}'s self-consistency check). Only providers backing a
|
|
53
|
+
* content-addressed local pool implement it. Callers that must preserve a hash-inconsistent asset —
|
|
54
|
+
* v1 migration import, and unverified hub reuse of a hub-rewritten payload (see `ingestUnverified`) —
|
|
55
|
+
* feature-detect it rather than assuming it exists.
|
|
56
|
+
*/
|
|
57
|
+
putFrozen?(record: AssetRecord): Promise<PutResult>;
|
|
50
58
|
search(query: SearchQuery): Promise<AssetRecord[]>;
|
|
51
59
|
list(kind?: AssetKind, limit?: number): Promise<AssetRecord[]>;
|
|
52
60
|
}
|
|
@@ -62,6 +70,12 @@ export declare class AssetIdMismatchError extends Error {
|
|
|
62
70
|
export declare class CapsuleGeneBindingError extends Error {
|
|
63
71
|
constructor();
|
|
64
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* M3-4 强绑定校验: Capsule.gene 必须非空(否则一条无来源基因的经验会污染选择池).
|
|
75
|
+
* 抽成独立 helper 以便 normalizeForPut(校验落库路径)与 ingestUnverified(冻结落库路径,
|
|
76
|
+
* 绕过 normalizeForPut)共用同一条不变量,不让降级路径把绑定校验漏掉.
|
|
77
|
+
*/
|
|
78
|
+
export declare function assertCapsuleGeneBinding(asset: AssetRecord): void;
|
|
65
79
|
/**
|
|
66
80
|
* 落库前规范化(共享给各 provider): 计算/校验 asset_id + 强绑定校验.
|
|
67
81
|
* - 缺 asset_id → 计算填入(verified=false 表示非入参自带).
|
|
@@ -62,6 +62,18 @@ export class AssetIdMismatchError extends Error {
|
|
|
62
62
|
export class CapsuleGeneBindingError extends Error {
|
|
63
63
|
constructor() { super('Capsule.gene 必须非空或显式 "ad-hoc" 哨兵 (批注#28/M3-4)'); this.name = 'CapsuleGeneBindingError'; }
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* M3-4 强绑定校验: Capsule.gene 必须非空(否则一条无来源基因的经验会污染选择池).
|
|
67
|
+
* 抽成独立 helper 以便 normalizeForPut(校验落库路径)与 ingestUnverified(冻结落库路径,
|
|
68
|
+
* 绕过 normalizeForPut)共用同一条不变量,不让降级路径把绑定校验漏掉.
|
|
69
|
+
*/
|
|
70
|
+
export function assertCapsuleGeneBinding(asset) {
|
|
71
|
+
if (asset.type !== 'Capsule')
|
|
72
|
+
return;
|
|
73
|
+
const gene = asset.gene;
|
|
74
|
+
if (typeof gene !== 'string' || gene.length === 0)
|
|
75
|
+
throw new CapsuleGeneBindingError();
|
|
76
|
+
}
|
|
65
77
|
/**
|
|
66
78
|
* 落库前规范化(共享给各 provider): 计算/校验 asset_id + 强绑定校验.
|
|
67
79
|
* - 缺 asset_id → 计算填入(verified=false 表示非入参自带).
|
|
@@ -69,11 +81,7 @@ export class CapsuleGeneBindingError extends Error {
|
|
|
69
81
|
* - Capsule.gene 必须非空(M3-4 强绑定).
|
|
70
82
|
*/
|
|
71
83
|
export function normalizeForPut(asset) {
|
|
72
|
-
|
|
73
|
-
const gene = asset.gene;
|
|
74
|
-
if (typeof gene !== 'string' || gene.length === 0)
|
|
75
|
-
throw new CapsuleGeneBindingError();
|
|
76
|
-
}
|
|
84
|
+
assertCapsuleGeneBinding(asset);
|
|
77
85
|
const actual = computeAssetId(asset);
|
|
78
86
|
if (actual === null)
|
|
79
87
|
throw new Error('computeAssetId 失败: 资产非对象');
|