@evomap/evolver-core 2.0.0-beta.10 → 2.0.0-beta.12

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.
@@ -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
- hashMismatchRows += 1;
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
- if (asset.type === 'Capsule') {
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 失败: 资产非对象');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver-core",
3
- "version": "2.0.0-beta.10",
3
+ "version": "2.0.0-beta.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "hub-无关核心: 算法引擎/原材料/mailbox/资产库/workflow",