@evomap/evolver-core 2.0.0-beta.1 → 2.0.0-beta.3

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 (110) hide show
  1. package/assets/gep/genes.jsonl +5 -0
  2. package/dist/algo/candidateAssembly.js +23 -14
  3. package/dist/algo/capabilityCandidates.d.ts +2 -0
  4. package/dist/algo/capabilityCandidates.js +5 -0
  5. package/dist/algo/conversationSniffer.d.ts +18 -0
  6. package/dist/algo/conversationSniffer.js +132 -0
  7. package/dist/algo/cycleEngine.d.ts +11 -0
  8. package/dist/algo/cycleEngine.js +14 -6
  9. package/dist/algo/cycleFailureClassifier.d.ts +1 -1
  10. package/dist/algo/cycleFailureClassifier.js +13 -5
  11. package/dist/algo/geneIntake.d.ts +14 -4
  12. package/dist/algo/geneIntake.js +37 -9
  13. package/dist/algo/geneSelection.d.ts +18 -1
  14. package/dist/algo/geneSelection.js +42 -18
  15. package/dist/algo/index.d.ts +2 -0
  16. package/dist/algo/index.js +2 -0
  17. package/dist/algo/memoryGraph.d.ts +62 -0
  18. package/dist/algo/memoryGraph.js +86 -0
  19. package/dist/algo/orchestrator.d.ts +3 -0
  20. package/dist/algo/orchestrator.js +14 -2
  21. package/dist/assetstore/assetSidecarRecords.d.ts +23 -0
  22. package/dist/assetstore/assetSidecarRecords.js +142 -0
  23. package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
  24. package/dist/assetstore/assetSidecarRecovery.js +288 -0
  25. package/dist/assetstore/assetStoreHealth.d.ts +75 -0
  26. package/dist/assetstore/assetStoreHealth.js +277 -0
  27. package/dist/assetstore/assetStoreLayout.d.ts +2 -0
  28. package/dist/assetstore/assetStoreLayout.js +6 -0
  29. package/dist/assetstore/assetStoreStorage.d.ts +42 -0
  30. package/dist/assetstore/assetStoreStorage.js +318 -0
  31. package/dist/assetstore/assetSyncLedger.d.ts +32 -0
  32. package/dist/assetstore/assetSyncLedger.js +66 -0
  33. package/dist/assetstore/index.d.ts +5 -1
  34. package/dist/assetstore/index.js +5 -1
  35. package/dist/assetstore/localJsonl.d.ts +10 -2
  36. package/dist/assetstore/localJsonl.js +93 -37
  37. package/dist/assetstore/pendingSignals.js +3 -1
  38. package/dist/assetstore/provenance.d.ts +20 -2
  39. package/dist/assetstore/provenance.js +92 -56
  40. package/dist/assetstore/provider.d.ts +2 -0
  41. package/dist/assetstore/reviewFilter.js +5 -2
  42. package/dist/assetstore/reviewLedger.d.ts +14 -2
  43. package/dist/assetstore/reviewLedger.js +78 -43
  44. package/dist/assetstore/seedGenes.d.ts +3 -0
  45. package/dist/assetstore/seedGenes.js +134 -0
  46. package/dist/benchmark/antiGeneBenchmark.d.ts +2 -0
  47. package/dist/benchmark/antiGeneBenchmark.js +11 -1
  48. package/dist/benchmark/antiGeneImpact.d.ts +16 -0
  49. package/dist/benchmark/antiGeneImpact.js +34 -0
  50. package/dist/benchmark/antiGeneRollout.d.ts +2 -0
  51. package/dist/benchmark/antiGeneRollout.js +13 -1
  52. package/dist/benchmark/index.d.ts +2 -1
  53. package/dist/benchmark/index.js +2 -1
  54. package/dist/benchmark/triggerShift.d.ts +62 -0
  55. package/dist/benchmark/triggerShift.js +106 -0
  56. package/dist/events/eventArchive.d.ts +65 -0
  57. package/dist/events/eventArchive.js +343 -0
  58. package/dist/events/eventStore.js +2 -12
  59. package/dist/events/ingest.d.ts +1 -1
  60. package/dist/events/ingest.js +2 -0
  61. package/dist/events/paths.d.ts +1 -1
  62. package/dist/events/paths.js +2 -2
  63. package/dist/events/public.d.ts +3 -1
  64. package/dist/events/public.js +2 -1
  65. package/dist/events/retention.d.ts +24 -1
  66. package/dist/events/retention.js +133 -37
  67. package/dist/exec/autoExec.d.ts +6 -1
  68. package/dist/exec/autoExec.js +34 -0
  69. package/dist/exec/autonomousCycle.d.ts +2 -0
  70. package/dist/exec/autonomousCycle.js +5 -0
  71. package/dist/exec/claudeBridge.d.ts +7 -2
  72. package/dist/exec/claudeBridge.js +115 -14
  73. package/dist/exec/prompt.js +9 -0
  74. package/dist/exec/runnerRegistry.d.ts +64 -18
  75. package/dist/exec/runnerRegistry.js +341 -38
  76. package/dist/exec/selfPrObfuscation.d.ts +2 -1
  77. package/dist/exec/selfPrObfuscation.js +19 -6
  78. package/dist/hub/agentDirectory.d.ts +90 -0
  79. package/dist/hub/agentDirectory.js +104 -0
  80. package/dist/hub/bindings.js +23 -3
  81. package/dist/hub/capability.d.ts +14 -2
  82. package/dist/hub/fake.d.ts +4 -2
  83. package/dist/hub/fake.js +3 -2
  84. package/dist/hub/index.d.ts +1 -0
  85. package/dist/hub/index.js +1 -0
  86. package/dist/mailbox/catalog.js +3 -0
  87. package/dist/mailbox/ipcServer.js +2 -2
  88. package/dist/mailbox/store.d.ts +14 -0
  89. package/dist/mailbox/store.js +44 -2
  90. package/dist/material/consumer.js +9 -6
  91. package/dist/material/index.d.ts +1 -0
  92. package/dist/material/index.js +1 -0
  93. package/dist/material/materialArchive.d.ts +81 -0
  94. package/dist/material/materialArchive.js +466 -0
  95. package/dist/material/materialStore.d.ts +1 -0
  96. package/dist/material/materialStore.js +6 -13
  97. package/dist/ops/savingsCore.js +1 -2
  98. package/dist/ops/selfUpdate.d.ts +10 -1
  99. package/dist/ops/selfUpdate.js +64 -15
  100. package/dist/schema/common.d.ts +1 -1
  101. package/dist/schema/common.js +1 -1
  102. package/dist/schema/material.d.ts +5 -5
  103. package/dist/trace/trajectoryExport.js +2 -2
  104. package/dist/util/fileLock.d.ts +19 -2
  105. package/dist/util/fileLock.js +166 -31
  106. package/dist/wire/geneHints.d.ts +42 -1
  107. package/dist/wire/geneHints.js +52 -1
  108. package/dist/wire/index.d.ts +14 -2
  109. package/dist/wire/index.js +1 -1
  110. package/package.json +6 -1
@@ -1,8 +1,8 @@
1
- import { appendFileSync, existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
2
1
  import { join } from 'node:path';
3
2
  import { acquireLock, releaseLock } from '../util/fileLock.js';
3
+ import { appendUtf8Durable, assertAssetStoreDirectory, assertOptionalRegularFile, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, replaceUtf8Durable, } from './assetStoreStorage.js';
4
+ import { LOCAL_ASSET_FILES } from './assetStoreLayout.js';
4
5
  import { normalizeForPut, } from './provider.js';
5
- const FILES = { Gene: 'genes.jsonl', Capsule: 'capsules.jsonl', EvolutionEvent: 'events.jsonl', AntiGene: 'anti-genes.jsonl' };
6
6
  function signalsOf(a) {
7
7
  const out = [];
8
8
  for (const key of ['signals_match', 'signals', 'trigger', 'trigger_signals']) {
@@ -17,56 +17,98 @@ function signalsOf(a) {
17
17
  /**
18
18
  * 本地 jsonl 资产库(M3-2, 移植 v1 src/gep/assetStore.js 单写锁).
19
19
  * 每 kind 一文件(genes/capsules/events.jsonl); append-only; O_EXCL 文件锁防并发写撕裂;
20
- * 内存索引(asset_id→record)供 get/search; 同 asset_id 去重(内容寻址天然幂等).
20
+ * 内存索引(asset_id→record)供 get/search; 文件指纹变化时在共享锁内重建索引,保证多个
21
+ * CLI/daemon 进程之间可见; 写入也在锁内刷新后再按 asset_id 去重(内容寻址天然幂等).
21
22
  */
22
23
  export class LocalJsonlProvider {
23
24
  baseDir;
24
25
  index = new Map();
25
26
  lockPath;
27
+ fileState = new Map();
26
28
  loaded = false;
27
29
  // `baseDir` is public-readonly so callers that inject a store (e.g. the CLI under test) can co-locate sidecars
28
30
  // — the ReviewLedger/ProvenanceStore — in the SAME directory, instead of defaulting to the real ~/.evomap.
29
31
  constructor(baseDir) {
30
32
  this.baseDir = baseDir;
31
- mkdirSync(baseDir, { recursive: true });
33
+ ensureAssetStoreDirectory(baseDir);
32
34
  this.lockPath = join(baseDir, '.assetstore.lock');
33
35
  }
34
- ensureLoaded() {
35
- if (this.loaded)
36
- return;
37
- for (const file of Object.values(FILES)) {
36
+ captureFileState() {
37
+ assertAssetStoreDirectory(this.baseDir);
38
+ const state = new Map();
39
+ for (const [kind, file] of Object.entries(LOCAL_ASSET_FILES)) {
40
+ state.set(kind, regularFileFingerprint(join(this.baseDir, file)));
41
+ }
42
+ return state;
43
+ }
44
+ stateChanged(next) {
45
+ if (!this.loaded || next.size !== this.fileState.size)
46
+ return true;
47
+ for (const [kind, fingerprint] of next) {
48
+ if (this.fileState.get(kind) !== fingerprint)
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+ rebuildIndex(state) {
54
+ const next = new Map();
55
+ for (const file of Object.values(LOCAL_ASSET_FILES)) {
38
56
  const p = join(this.baseDir, file);
39
- if (!existsSync(p))
57
+ const raw = readUtf8Regular(p);
58
+ if (raw === null)
40
59
  continue;
41
- for (const line of readFileSync(p, 'utf8').split('\n')) {
60
+ for (const line of raw.split('\n')) {
42
61
  if (!line.trim())
43
62
  continue;
44
63
  try {
45
64
  const r = JSON.parse(line);
46
65
  if (r.asset_id)
47
- this.index.set(r.asset_id, r);
66
+ next.set(r.asset_id, r);
48
67
  }
49
68
  catch { /* skip 坏行 */ }
50
69
  }
51
70
  }
71
+ this.index.clear();
72
+ for (const [assetId, record] of next)
73
+ this.index.set(assetId, record);
74
+ this.fileState = state;
75
+ this.loaded = true;
76
+ }
77
+ refreshUnderLock() {
78
+ const state = this.captureFileState();
79
+ if (this.stateChanged(state))
80
+ this.rebuildIndex(state);
81
+ }
82
+ ensureFresh() {
83
+ const state = this.captureFileState();
84
+ if (!this.stateChanged(state))
85
+ return;
86
+ assertOptionalRegularFile(this.lockPath, 'lock_file');
87
+ acquireLock(this.lockPath);
88
+ try {
89
+ this.refreshUnderLock();
90
+ }
91
+ finally {
92
+ releaseLock(this.lockPath);
93
+ }
94
+ }
95
+ updateFileStateAfterWrite() {
96
+ this.fileState = this.captureFileState();
52
97
  this.loaded = true;
53
98
  }
54
99
  async put(asset) {
55
- this.ensureLoaded();
56
100
  const { record, verified } = normalizeForPut(asset);
57
- if (this.index.has(record.asset_id))
58
- return { asset_id: record.asset_id, stored: false, verified };
59
- const file = join(this.baseDir, FILES[record.type]);
101
+ const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
102
+ assertOptionalRegularFile(this.lockPath, 'lock_file');
60
103
  acquireLock(this.lockPath);
61
104
  try {
62
- // 锁内复检(另一进程可能刚写)
63
- if (!this.index.has(record.asset_id)) {
64
- appendFileSync(file, `${JSON.stringify(record)}\n`);
65
- this.index.set(record.asset_id, record);
66
- }
67
- else {
105
+ // Refresh under the shared lock so another process cannot append between reload and dedupe.
106
+ this.refreshUnderLock();
107
+ if (this.index.has(record.asset_id))
68
108
  return { asset_id: record.asset_id, stored: false, verified };
69
- }
109
+ appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
110
+ this.index.set(record.asset_id, record);
111
+ this.updateFileStateAfterWrite();
70
112
  }
71
113
  finally {
72
114
  releaseLock(this.lockPath);
@@ -78,18 +120,18 @@ export class LocalJsonlProvider {
78
120
  * 仅 v1→v2 导入用(硬化 A6 存量冻结); 普通写一律走 put(). record 必须自带 asset_id.
79
121
  */
80
122
  async putFrozen(record) {
81
- this.ensureLoaded();
82
123
  if (!record.asset_id)
83
124
  throw new Error('putFrozen 需 record 自带冻结 asset_id');
84
- if (this.index.has(record.asset_id))
85
- return { asset_id: record.asset_id, stored: false, verified: false };
86
- const file = join(this.baseDir, FILES[record.type]);
125
+ const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
126
+ assertOptionalRegularFile(this.lockPath, 'lock_file');
87
127
  acquireLock(this.lockPath);
88
128
  try {
129
+ this.refreshUnderLock();
89
130
  if (this.index.has(record.asset_id))
90
131
  return { asset_id: record.asset_id, stored: false, verified: false };
91
- appendFileSync(file, `${JSON.stringify(record)}\n`);
132
+ appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
92
133
  this.index.set(record.asset_id, record);
134
+ this.updateFileStateAfterWrite();
93
135
  }
94
136
  finally {
95
137
  releaseLock(this.lockPath);
@@ -97,11 +139,24 @@ export class LocalJsonlProvider {
97
139
  return { asset_id: record.asset_id, stored: true, verified: false };
98
140
  }
99
141
  async get(assetId) {
100
- this.ensureLoaded();
142
+ this.ensureFresh();
101
143
  return this.index.get(assetId) ?? null;
102
144
  }
145
+ async findByLogicalId(id, limit = 2) {
146
+ this.ensureFresh();
147
+ const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(1_000, Math.floor(limit))) : 2;
148
+ const out = [];
149
+ for (const record of this.index.values()) {
150
+ if (record['id'] !== id)
151
+ continue;
152
+ out.push(record);
153
+ if (out.length >= boundedLimit)
154
+ break;
155
+ }
156
+ return out;
157
+ }
103
158
  async list(kind, limit = 1000) {
104
- this.ensureLoaded();
159
+ this.ensureFresh();
105
160
  const out = [];
106
161
  for (const r of this.index.values()) {
107
162
  if (!kind || r.type === kind)
@@ -112,7 +167,7 @@ export class LocalJsonlProvider {
112
167
  return out;
113
168
  }
114
169
  async search(q) {
115
- this.ensureLoaded();
170
+ this.ensureFresh();
116
171
  const out = [];
117
172
  for (const r of this.index.values()) {
118
173
  if (q.kind && r.type !== q.kind)
@@ -148,15 +203,17 @@ export class LocalJsonlProvider {
148
203
  * the write lock. Returns kept/removed line counts.
149
204
  */
150
205
  async compact() {
151
- this.ensureLoaded();
206
+ assertOptionalRegularFile(this.lockPath, 'lock_file');
152
207
  acquireLock(this.lockPath);
153
208
  try {
209
+ this.refreshUnderLock();
154
210
  let kept = 0, removed = 0;
155
- for (const [kind, file] of Object.entries(FILES)) {
211
+ for (const [kind, file] of Object.entries(LOCAL_ASSET_FILES)) {
156
212
  const p = join(this.baseDir, file);
157
- if (!existsSync(p))
213
+ const raw = readUtf8Regular(p);
214
+ if (raw === null)
158
215
  continue;
159
- const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
216
+ const lines = raw.split('\n').filter((l) => l.trim());
160
217
  const byId = new Map(); // asset_id → raw line (last wins, matching load semantics)
161
218
  for (const line of lines) {
162
219
  try {
@@ -167,12 +224,11 @@ export class LocalJsonlProvider {
167
224
  catch { /* drop corrupt line */ }
168
225
  }
169
226
  const out = byId.size ? `${[...byId.values()].join('\n')}\n` : '';
170
- const tmp = `${p}.compact.tmp`;
171
- writeFileSync(tmp, out);
172
- renameSync(tmp, p); // atomic replace; a crash mid-compact leaves the original intact
227
+ replaceUtf8Durable(p, out);
173
228
  kept += byId.size;
174
229
  removed += lines.length - byId.size;
175
230
  }
231
+ this.rebuildIndex(this.captureFileState());
176
232
  return { kept, removed };
177
233
  }
178
234
  finally {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
3
  import { acquireLock, releaseLock } from '../util/fileLock.js';
4
4
  import { LocalJsonlProvider } from './localJsonl.js';
5
5
  const MAX_SIGNAL_LENGTH = 200;
@@ -33,6 +33,8 @@ function findRepoRoot(start) {
33
33
  while (current) {
34
34
  if (existsSync(join(current, '.git')))
35
35
  return current;
36
+ if (basename(current) === 'node_modules')
37
+ return null;
36
38
  const parent = dirname(current);
37
39
  if (parent === current)
38
40
  return null;
@@ -1,13 +1,21 @@
1
1
  import { type AssetStoreProvider, type AssetRecord, type PutResult } from './provider.js';
2
2
  export type ProvenanceSource = 'local' | 'migrated' | 'hub';
3
+ export type ProvenanceDecision = 'promoted' | 'revoked';
3
4
  export interface ProvenanceRecord {
4
5
  assetId: string;
5
6
  source: ProvenanceSource;
6
7
  trusted: boolean;
7
8
  at: string;
9
+ decision?: ProvenanceDecision;
10
+ decidedBy?: string;
11
+ /** Legacy promotion actor field kept for existing sidecar readers. */
8
12
  promotedBy?: string;
9
13
  reason?: string;
10
14
  }
15
+ export interface ProvenanceTrustChange {
16
+ changed: boolean;
17
+ record: ProvenanceRecord;
18
+ }
11
19
  /**
12
20
  * Append-only JSONL sidecar (last-write-wins) at <baseDir>/provenance.jsonl. Default for an asset with NO
13
21
  * record = trusted: the only local writers (cycleEngine self-produce, v1 migration) are trusted and never
@@ -16,10 +24,14 @@ export interface ProvenanceRecord {
16
24
  export declare class ProvenanceStore {
17
25
  private readonly now;
18
26
  private readonly path;
27
+ private readonly lockPath;
19
28
  private readonly index;
20
- private loaded;
29
+ private fileState;
21
30
  constructor(baseDir: string, now?: () => number);
22
- private load;
31
+ private rebuildIndex;
32
+ private refreshUnderLock;
33
+ private withFreshRead;
34
+ private appendUnderLock;
23
35
  /** Record provenance for an asset_id (append-only; the JSONL history is the audit trail). */
24
36
  mark(rec: Omit<ProvenanceRecord, 'at'> & {
25
37
  at?: string;
@@ -28,8 +40,14 @@ export declare class ProvenanceStore {
28
40
  get(assetId: string): ProvenanceRecord | null;
29
41
  /** No record → trusted (local default); a record → its trusted flag. */
30
42
  isTrusted(assetId: string): boolean;
43
+ /** One linearizable trust snapshot for bounded batch readers. */
44
+ snapshot(): ReadonlyMap<string, ProvenanceRecord>;
45
+ /** Compare and append one trust decision under the same cross-process lock. */
46
+ changeTrust(assetId: string, trusted: boolean, by: string, reason: string): ProvenanceTrustChange;
31
47
  /** Explicit, audited untrusted→trusted promotion. Appends a new trusted record carrying who/why. */
32
48
  promote(assetId: string, by: string, reason: string): ProvenanceRecord;
49
+ /** Explicit, audited trusted-to-untrusted revocation. A record-less asset is local by default. */
50
+ revoke(assetId: string, by: string, reason: string): ProvenanceRecord;
33
51
  }
34
52
  /**
35
53
  * The sanctioned hub→local-pool landing: store the asset (store.put recomputes/normalizes the asset_id, so a
@@ -3,9 +3,13 @@
3
3
  // by asset_id, NOT a field on the asset: asset_id = sha256(canonicalize(asset)), and "how we got it" metadata
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
- import { appendFileSync, closeSync, existsSync, openSync, readFileSync, readSync, mkdirSync, statSync, truncateSync } from 'node:fs';
7
6
  import { join, dirname } from 'node:path';
8
7
  import { normalizeForPut } from './provider.js';
8
+ import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, truncateUtf8SuffixDurable, withAssetStoreLock, } from './assetStoreStorage.js';
9
+ import { assertTrustSidecarHealthy, parseProvenanceRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
10
+ function immutableRecord(record) {
11
+ return Object.freeze({ ...record });
12
+ }
9
13
  /**
10
14
  * Append-only JSONL sidecar (last-write-wins) at <baseDir>/provenance.jsonl. Default for an asset with NO
11
15
  * record = trusted: the only local writers (cycleEngine self-produce, v1 migration) are trusted and never
@@ -14,81 +18,113 @@ import { normalizeForPut } from './provider.js';
14
18
  export class ProvenanceStore {
15
19
  now;
16
20
  path;
21
+ lockPath;
17
22
  index = new Map();
18
- loaded = false;
23
+ fileState = null;
19
24
  constructor(baseDir, now = Date.now) {
20
25
  this.now = now;
26
+ ensureAssetStoreDirectory(baseDir);
21
27
  this.path = join(baseDir, 'provenance.jsonl');
28
+ this.lockPath = join(baseDir, '.assetstore.lock');
22
29
  }
23
- load() {
24
- if (this.loaded)
25
- return;
26
- if (existsSync(this.path)) {
27
- for (const line of readFileSync(this.path, 'utf8').split('\n')) {
28
- if (!line.trim())
29
- continue;
30
- try {
31
- const r = JSON.parse(line);
32
- if (r.assetId)
33
- this.index.set(r.assetId, r);
34
- }
35
- catch { /* skip corrupt line */ }
36
- }
30
+ rebuildIndex(state) {
31
+ const next = new Map();
32
+ const raw = state === 'missing' ? null : readUtf8Regular(this.path);
33
+ if (raw !== null) {
34
+ const parsed = parseSidecarJsonl(raw, parseProvenanceRecord);
35
+ assertTrustSidecarHealthy('provenance', parsed);
36
+ for (const record of parsed.records)
37
+ next.set(record.assetId, immutableRecord(record));
37
38
  }
38
- this.loaded = true;
39
+ this.index.clear();
40
+ for (const [assetId, record] of next)
41
+ this.index.set(assetId, record);
42
+ this.fileState = state;
43
+ }
44
+ refreshUnderLock() {
45
+ const state = regularFileFingerprint(this.path);
46
+ if (state !== this.fileState)
47
+ this.rebuildIndex(state);
48
+ }
49
+ withFreshRead(read) {
50
+ assertAssetStoreDirectory(dirname(this.path));
51
+ return withAssetStoreLock(this.lockPath, () => {
52
+ this.refreshUnderLock();
53
+ return read(this.index);
54
+ });
55
+ }
56
+ appendUnderLock(full) {
57
+ const stored = immutableRecord(full);
58
+ appendUtf8Durable(this.path, `${JSON.stringify(stored)}\n`);
59
+ this.index.set(stored.assetId, stored);
60
+ this.fileState = regularFileFingerprint(this.path);
61
+ return stored;
39
62
  }
40
63
  /** Record provenance for an asset_id (append-only; the JSONL history is the audit trail). */
41
64
  mark(rec) {
42
- this.load();
43
65
  const full = { ...rec, at: rec.at ?? new Date(this.now()).toISOString() };
44
- mkdirSync(dirname(this.path), { recursive: true });
45
- appendFileSync(this.path, `${JSON.stringify(full)}\n`);
46
- this.index.set(full.assetId, full);
47
- return full;
66
+ assertAssetStoreDirectory(dirname(this.path));
67
+ return withAssetStoreLock(this.lockPath, () => {
68
+ this.refreshUnderLock();
69
+ return this.appendUnderLock(full);
70
+ });
48
71
  }
49
72
  rollbackLast(rec) {
50
73
  const line = `${JSON.stringify(rec)}\n`;
51
- const lineBytes = Buffer.byteLength(line, 'utf8');
52
74
  try {
53
- if (!existsSync(this.path))
54
- return;
55
- const stat = statSync(this.path);
56
- if (!stat.isFile() || stat.size < lineBytes)
57
- return;
58
- const offset = stat.size - lineBytes;
59
- const buf = Buffer.alloc(lineBytes);
60
- const fd = openSync(this.path, 'r');
61
- try {
62
- readSync(fd, buf, 0, lineBytes, offset);
63
- }
64
- finally {
65
- closeSync(fd);
66
- }
67
- if (buf.toString('utf8') !== line)
68
- return;
69
- truncateSync(this.path, offset);
70
- this.index.clear();
71
- this.loaded = false;
75
+ assertAssetStoreDirectory(dirname(this.path));
76
+ withAssetStoreLock(this.lockPath, () => {
77
+ if (!truncateUtf8SuffixDurable(this.path, line)) {
78
+ this.refreshUnderLock();
79
+ return;
80
+ }
81
+ this.rebuildIndex(regularFileFingerprint(this.path));
82
+ });
72
83
  }
73
84
  catch {
74
85
  this.index.clear();
75
- this.loaded = false;
86
+ this.fileState = null;
76
87
  }
77
88
  }
78
89
  get(assetId) {
79
- this.load();
80
- return this.index.get(assetId) ?? null;
90
+ return this.withFreshRead((index) => index.get(assetId) ?? null);
81
91
  }
82
92
  /** No record → trusted (local default); a record → its trusted flag. */
83
93
  isTrusted(assetId) {
84
- this.load();
85
- const r = this.index.get(assetId);
86
- return r ? r.trusted : true;
94
+ return this.withFreshRead((index) => index.get(assetId)?.trusted ?? true);
95
+ }
96
+ /** One linearizable trust snapshot for bounded batch readers. */
97
+ snapshot() {
98
+ return this.withFreshRead((index) => new Map(index));
99
+ }
100
+ /** Compare and append one trust decision under the same cross-process lock. */
101
+ changeTrust(assetId, trusted, by, reason) {
102
+ assertAssetStoreDirectory(dirname(this.path));
103
+ return withAssetStoreLock(this.lockPath, () => {
104
+ this.refreshUnderLock();
105
+ const current = this.index.get(assetId) ?? null;
106
+ if (current?.trusted === trusted)
107
+ return { changed: false, record: current };
108
+ const full = {
109
+ assetId,
110
+ source: current?.source ?? 'local',
111
+ trusted,
112
+ at: new Date(this.now()).toISOString(),
113
+ decision: trusted ? 'promoted' : 'revoked',
114
+ decidedBy: by,
115
+ ...(trusted ? { promotedBy: by } : {}),
116
+ reason,
117
+ };
118
+ return { changed: true, record: this.appendUnderLock(full) };
119
+ });
87
120
  }
88
121
  /** Explicit, audited untrusted→trusted promotion. Appends a new trusted record carrying who/why. */
89
122
  promote(assetId, by, reason) {
90
- const cur = this.get(assetId);
91
- return this.mark({ assetId, source: cur?.source ?? 'hub', trusted: true, promotedBy: by, reason });
123
+ return this.changeTrust(assetId, true, by, reason).record;
124
+ }
125
+ /** Explicit, audited trusted-to-untrusted revocation. A record-less asset is local by default. */
126
+ revoke(assetId, by, reason) {
127
+ return this.changeTrust(assetId, false, by, reason).record;
92
128
  }
93
129
  }
94
130
  /**
@@ -99,11 +135,11 @@ export class ProvenanceStore {
99
135
  export async function ingestUntrusted(store, prov, record, source = 'hub') {
100
136
  const normalized = normalizeForPut(record);
101
137
  const mark = prov.mark({ assetId: normalized.record.asset_id, source, trusted: false });
102
- try {
103
- return await store.put(record);
104
- }
105
- catch (err) {
138
+ const result = await store.put(record);
139
+ // A thrown write has an ambiguous outcome: the asset may have reached disk before the acknowledgement was
140
+ // lost. Keep the untrusted marker in that case so a persisted Hub asset can never fall through the default
141
+ // no-record => trusted policy. Only an explicit no-write result is safe to roll back.
142
+ if (!result.stored)
106
143
  prov.rollbackLast(mark);
107
- throw err;
108
- }
144
+ return result;
109
145
  }
@@ -25,6 +25,8 @@ export interface SearchQuery {
25
25
  export interface AssetStoreProvider {
26
26
  put(asset: AssetRecord): Promise<PutResult>;
27
27
  get(assetId: string): Promise<AssetRecord | null>;
28
+ /** Optional direct lookup for non-content-addressed logical ids. Callers must handle 0, 1, or multiple matches. */
29
+ findByLogicalId?(id: string, limit?: number): Promise<AssetRecord[]>;
28
30
  search(query: SearchQuery): Promise<AssetRecord[]>;
29
31
  list(kind?: AssetKind, limit?: number): Promise<AssetRecord[]>;
30
32
  }
@@ -30,11 +30,14 @@ export function provenanceStoreForStore(store) {
30
30
  */
31
31
  export async function listApprovedGenes(store, review, maxGenes, provenance = provenanceStoreForStore(store)) {
32
32
  const all = await store.list('Gene', GENE_SCAN_LIMIT);
33
+ const trust = provenance.snapshot();
34
+ const reviewed = review.snapshot();
33
35
  const approved = [];
34
36
  for (const g of all) {
35
- if (!provenance.isTrusted(String(g.asset_id)))
37
+ if (trust.get(String(g.asset_id))?.trusted === false)
36
38
  continue; // hub-untrusted → withhold until promoted
37
- if (!review.isApproved(String(g.asset_id)))
39
+ const reviewRecord = reviewed.get(String(g.asset_id));
40
+ if (reviewRecord !== undefined && reviewRecord.state !== 'approved')
38
41
  continue; // quarantined/rejected draft → withhold
39
42
  approved.push(g);
40
43
  if (approved.length >= maxGenes)
@@ -18,13 +18,17 @@ export interface ReviewRecord {
18
18
  export declare class ReviewLedger {
19
19
  private readonly now;
20
20
  private readonly path;
21
+ private readonly lockPath;
21
22
  private readonly index;
22
- private sig;
23
+ private fileState;
23
24
  constructor(baseDir: string, now?: () => number);
24
25
  private static isHuman;
25
26
  /** Which record wins for an asset_id: a human decision beats a quarantine; otherwise the later one wins. */
26
27
  private static keep;
27
- private load;
28
+ private rebuildIndex;
29
+ private refreshUnderLock;
30
+ private withFreshRead;
31
+ private appendUnderLock;
28
32
  /** Record a review-state for an asset_id (append-only; the JSONL history is the audit trail). */
29
33
  mark(rec: Omit<ReviewRecord, 'at'> & {
30
34
  at?: string;
@@ -50,6 +54,14 @@ export declare class ReviewLedger {
50
54
  * asset list, so a draft awaiting approval is never missed behind a store-list cutoff.
51
55
  */
52
56
  records(): ReviewRecord[];
57
+ /** One linearizable review snapshot for bounded batch readers. */
58
+ snapshot(): ReadonlyMap<string, ReviewRecord>;
53
59
  /** No record → approved (default eligible); a record → approved only when its state is 'approved'. */
54
60
  isApproved(assetId: string): boolean;
61
+ /**
62
+ * True only when a human approval record exists. Safety-sensitive consumers such as AntiGene warning
63
+ * injection must fail closed when review state is absent; unlike cycle/migrate-authored Genes, an unreviewed
64
+ * negative-memory asset must never inherit the ledger's backward-compatible "no record = eligible" default.
65
+ */
66
+ isExplicitlyApproved(assetId: string): boolean;
55
67
  }