@henols/vice-mcp 0.2.2 → 0.2.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 (77) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1674 -99
  5. package/anno-enum-gen.ts +416 -30
  6. package/anno-export-asm.ts +1175 -89
  7. package/anno-graphics.ts +338 -0
  8. package/anno-hazard-report.ts +1367 -0
  9. package/anno-import.ts +495 -0
  10. package/anno-join.ts +480 -0
  11. package/anno-provenance-ledger.ts +472 -0
  12. package/anno-register.ts +159 -0
  13. package/anno-store-export.ts +661 -0
  14. package/anno-store.ts +518 -2
  15. package/anno-tools.ts +1169 -16
  16. package/anno-types.ts +275 -2
  17. package/backend-detect.mts +124 -312
  18. package/build.ts +3 -1
  19. package/capture-predicate.ts +597 -0
  20. package/channel-lock.ts +349 -0
  21. package/evid-ingest.ts +217 -0
  22. package/evid-reconcile.ts +316 -0
  23. package/host-tool-client.ts +430 -0
  24. package/incident-record.ts +23 -12
  25. package/install-resources.ts +29 -13
  26. package/memmap-lookup.ts +285 -0
  27. package/package.json +27 -8
  28. package/prg-image.ts +1 -2
  29. package/repo-root.ts +87 -3
  30. package/resources/backend-detect.mjs +98 -236
  31. package/resources/broker-control.mjs +189 -16
  32. package/resources/broker-epoch.mjs +1 -1
  33. package/resources/broker-kill.mjs +8 -2
  34. package/resources/broker-launch.mjs +365 -210
  35. package/resources/broker-state.mjs +64 -18
  36. package/resources/container-guard.mjs +1 -1
  37. package/resources/ghidra-project.mjs +790 -0
  38. package/resources/host-tool.mjs +2561 -0
  39. package/resources/vice-broker.mjs +330 -184
  40. package/resources/vice-launcher.sh +127 -9
  41. package/stock-address.ts +1 -1
  42. package/stock-condition.ts +1 -1
  43. package/stock-connect.ts +9 -5
  44. package/stock-derived.ts +29 -37
  45. package/stock-diagnose.ts +200 -36
  46. package/stock-dispatch.ts +179 -77
  47. package/stock-handler.ts +1 -1
  48. package/stock-paths.ts +18 -14
  49. package/stock-petscii.ts +1 -1
  50. package/stock-protocol.ts +1 -1
  51. package/stock-recycle.ts +83 -2
  52. package/stock-reproducible-run.ts +811 -0
  53. package/stock-run-until.ts +100 -1
  54. package/stock-symbols.ts +4 -4
  55. package/stock-timing.ts +1 -1
  56. package/stop-oracle.ts +167 -0
  57. package/text-capability-probe.ts +660 -0
  58. package/text-connect.ts +157 -0
  59. package/text-protocol.ts +810 -0
  60. package/text-tools.ts +778 -0
  61. package/textmon-backtrace.ts +385 -0
  62. package/textmon-cpuhistory.ts +335 -0
  63. package/textmon-memmap.ts +494 -0
  64. package/textmon-profile.ts +458 -0
  65. package/textmon-registers.ts +748 -0
  66. package/tools-manifest.stock.json +864 -3
  67. package/vice-broker-client.ts +189 -42
  68. package/vice-errors.ts +268 -0
  69. package/vice-proxy.ts +339 -2144
  70. package/vsf-slice.ts +640 -0
  71. package/anno-d64.ts +0 -310
  72. package/capability-registry.ts +0 -390
  73. package/refresh-manifest.ts +0 -124
  74. package/tools-manifest.json +0 -1223
  75. package/vice-probe.ts +0 -278
  76. package/vice-sync.ts +0 -336
  77. package/vice.ts +0 -772
package/anno-store.ts CHANGED
@@ -138,11 +138,17 @@ import {
138
138
  assertCommentType,
139
139
  assertDataType,
140
140
  assertEnumName,
141
+ assertEvidSourceBank,
141
142
  assertLabelKind,
142
143
  assertLegalLabel,
143
144
  assertRangeShape,
145
+ assertRunIdentityDigest,
146
+ assertRunIdentitySeed,
144
147
  isSplitDataType,
145
148
  splitEntryAddressPairs,
149
+ ADDRESS_MAX,
150
+ ADDRESS_MIN,
151
+ AnnoCommentError,
146
152
  AnnoCommentGradeError,
147
153
  AnnoLabelError,
148
154
  AnnoRangeShapeError,
@@ -162,9 +168,13 @@ import {
162
168
  type CommentType,
163
169
  type ContradictedComment,
164
170
  type DataType,
171
+ type EvidExecRow,
172
+ type EvidSourceBank,
173
+ type ExcludedRangeRow,
165
174
  type LabelKind,
166
175
  type LabelRow,
167
176
  type EnumUsageRow,
177
+ type ObservedRunRow,
168
178
  type ProjectEnumRow,
169
179
  type RangeRow,
170
180
  type ScopeRow,
@@ -179,7 +189,7 @@ import { CONFIDENCE_GRADES, parseConfidencePrefix, AnnoConfidenceGradeError } fr
179
189
  // decide "rethrow unchanged" versus "wrap". Every `Anno*Error` in `anno-types.ts`
180
190
  // already extends it, so nothing new enters the module graph -- `anno-types.ts`
181
191
  // imports the same class from the same file.
182
- import { ViceError } from "./vice.ts";
192
+ import { ViceError } from "./vice-errors.ts";
183
193
 
184
194
  /**
185
195
  * What every write entry point in this module returns.
@@ -241,6 +251,29 @@ export interface AnnoWriteResult {
241
251
  * that NOTHING in this module interprets and no code path reads except the row
242
252
  * mapper in `listRanges()`. It is reserved, and every row written today has it
243
253
  * null.
254
+ *
255
+ * AT `SCHEMA_VERSION` 4 (EVID-01/EVID-02), ONE MORE TABLE IS ADDED:
256
+ * `anno_evid_exec`, the durable runtime-execution evidence table. See
257
+ * `anno-types.ts`'s `SCHEMA_VERSION` doc comment for what the bump buys and
258
+ * the decided, dated fate of an existing version-3 store (`reaffirm-refusal`
259
+ * -- no migration arm). `anno_evid_exec` carries NO `bank` column and NO
260
+ * nullable column at all: unlike the annotation tables above, every field on
261
+ * a row here is a fact the runtime evidence layer is licensed to assert, or
262
+ * the row does not exist. Its run-identity key is the bare triple
263
+ * `(image_sha256, argv_digest, seed)`, selected by plan 43-01's live A/B
264
+ * (`docs/phase43-instrumentation-perturbation-ab.md`, verdict
265
+ * `no-perturbation`) -- there is deliberately no `run_class` column.
266
+ *
267
+ * AT `SCHEMA_VERSION` 5 (`BUILD-07`), ONE MORE TABLE IS ADDED:
268
+ * `anno_excluded_range`, the durable record of a user-requested exclusion --
269
+ * its extent and the reason the user gave. See `anno-types.ts`'s
270
+ * `SCHEMA_VERSION` doc comment for what the bump buys, why a table was chosen
271
+ * over a column on `anno_range`, and the decided, dated fate of an existing
272
+ * version-4 store (`reaffirm-refusal` -- no migration arm). `reason` is `not
273
+ * null`: an exclusion with no reason is a hole with a row in front of it.
274
+ * `anno_excluded_range` carries NO `bank` column, for the same reason
275
+ * `ExcludedRangeRow`'s own doc comment gives -- an exclusion is a statement
276
+ * about the subject program, not a memory view.
244
277
  */
245
278
  export const DDL = `
246
279
  create table anno_meta (
@@ -307,11 +340,31 @@ create table anno_snapshot (
307
340
  revision integer primary key
308
341
  );
309
342
 
343
+ create table anno_evid_exec (
344
+ id integer primary key autoincrement,
345
+ image_sha256 text not null,
346
+ argv_digest text not null,
347
+ seed text not null,
348
+ address integer not null,
349
+ source_bank text not null,
350
+ unique(image_sha256, argv_digest, seed, address, source_bank)
351
+ );
352
+
353
+ create table anno_excluded_range (
354
+ id integer primary key autoincrement,
355
+ start integer not null,
356
+ end_inclusive integer not null,
357
+ reason text not null,
358
+ unique(start, end_inclusive)
359
+ );
360
+
310
361
  create index anno_range_end_start on anno_range(end_inclusive, start);
311
362
  create index anno_label_address on anno_label(address);
312
363
  create index anno_comment_address on anno_comment(address);
313
364
  create index anno_enum_usage_address on anno_enum_usage(address);
314
365
  create index anno_xref_to on anno_xref(to_address);
366
+ create index anno_evid_exec_address on anno_evid_exec(address);
367
+ create index anno_excluded_range_start on anno_excluded_range(start);
315
368
  `;
316
369
 
317
370
  /** An open store: the connection, the resolved store path, and the directory
@@ -539,7 +592,19 @@ export function openStore(
539
592
 
540
593
  if (meta.schema_version !== SCHEMA_VERSION) {
541
594
  db.close();
542
- throw new AnnoStoreCorruptError(`${resolved}: schema_version ${meta.schema_version}, expected ${SCHEMA_VERSION}`, { path: resolved });
595
+ // NAMES THE REMEDY AND DENIES NOTHING IS LOST (EVID-02's checkpoint,
596
+ // condition 2). This build refuses rather than upgrades -- see
597
+ // `anno-types.ts`'s `SCHEMA_VERSION` doc comment for the decided,
598
+ // dated reason -- and the refusal happens BEFORE any write, so the
599
+ // file on disk is exactly what it was a moment ago: its labels,
600
+ // comments and enums are not lost, only unreadable by this build.
601
+ throw new AnnoStoreCorruptError(
602
+ `${resolved}: schema_version ${meta.schema_version}, expected ${SCHEMA_VERSION} -- refusing to open rather than upgrade. This file is ` +
603
+ `left exactly as it was: nothing on it is read, rewritten or deleted by this refusal. Open it with a build whose SCHEMA_VERSION is ` +
604
+ `${meta.schema_version} to read it (see anno-types.ts's SCHEMA_VERSION doc comment for what changed at each version), or hand-copy ` +
605
+ `its rows into a fresh store at this build's version.`,
606
+ { path: resolved },
607
+ );
543
608
  }
544
609
 
545
610
  // WR-04, THE LAST KNOWN FAMILY ESCAPE IN THIS FUNCTION. The two blocks either
@@ -3091,6 +3156,197 @@ export function removeScope(
3091
3156
  return { revision, changed: result };
3092
3157
  }
3093
3158
 
3159
+ /**
3160
+ * Records a user-requested exclusion of `start..endInclusive`, with `reason`
3161
+ * stating WHY the user asked for it -- added at `SCHEMA_VERSION` 5
3162
+ * (`BUILD-07`).
3163
+ *
3164
+ * RECORDING AN EXCLUSION CHANGES NOTHING ABOUT WHICH BYTES THE EXPORT EMITS.
3165
+ * The exporter still walks this range's full byte span and emits a real
3166
+ * block, tagged with a visible marker comment, rather than a hole -- that is
3167
+ * `BUILD-07`'s whole invariant. An exporter implementation that skipped the
3168
+ * block on seeing an exclusion row would satisfy the word "exclude" and fail
3169
+ * the requirement outright: this table is a RECORD, never a filter, and the
3170
+ * store answers "what did the user record", never "should this range be
3171
+ * excluded".
3172
+ *
3173
+ * `reason` is validated through `assertCommentText()` -- the ONE comment-text
3174
+ * vocabulary this store has -- BEFORE the write opens, and an empty or
3175
+ * whitespace-only reason is refused with its own message: a `not null`
3176
+ * column satisfied by `""` records that something was excluded and loses WHY,
3177
+ * which is precisely the half of criterion 2 this record exists to carry.
3178
+ *
3179
+ * IDEMPOTENCE FIRST, inside the transaction and BEFORE the overlap check, the
3180
+ * same shape `addScope` uses: an identical repeat -- same extent, same reason
3181
+ * -- is an accepted NO-OP reporting `changed: false`. The SAME extent with a
3182
+ * DIFFERENT reason is REFUSED rather than silently overwritten -- the stored
3183
+ * reason is left exactly as it was, and the route to change it is to remove
3184
+ * the record with `removeExcludedRange` and add it again. Silently replacing
3185
+ * what somebody wrote and reporting success is the failure mode this store's
3186
+ * comment and label verbs already refuse.
3187
+ *
3188
+ * OVERLAP IS REFUSED using `addScope()`'s EXACT predicate --
3189
+ * `start <= ? and end_inclusive >= ?` with the two arguments TRANSPOSED, and
3190
+ * `order by id limit 1` so the message is reproducible -- so adjacency falls
3191
+ * out of the `>=` rather than a second rule: two exclusion records that
3192
+ * merely TOUCH at a boundary are disjoint and both accepted, and they stay
3193
+ * TWO records. The incoming record is NEVER trimmed or split; a caller
3194
+ * wanting a disjoint span reads `listExcludedRanges()` first, or removes the
3195
+ * conflicting record with `removeExcludedRange`.
3196
+ */
3197
+ export function addExcludedRange(
3198
+ handle: AnnoStoreHandle,
3199
+ args: { start: number | string; endInclusive: number | string; reason: string; baseRevision?: number },
3200
+ ): AnnoWriteResult {
3201
+ const start = parseStoreAddress(args.start, { what: "start" });
3202
+ const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
3203
+ assertRangeShape(start, endInclusive, "byte");
3204
+
3205
+ const reason = assertCommentText(args.reason, { what: "exclusion reason" });
3206
+ if (reason.trim() === "") {
3207
+ throw new AnnoCommentError(
3208
+ `exclusion reason is empty or whitespace-only -- a "reason" column satisfied by an empty string records that something was excluded ` +
3209
+ `and loses WHY, which is precisely the half of BUILD-07's criterion 2 this record exists to carry. Supply the reason the user gave.`,
3210
+ { reason: "empty reason" },
3211
+ );
3212
+ }
3213
+
3214
+ const { revision, result } = applyWrite(
3215
+ handle,
3216
+ (db) => {
3217
+ // IDEMPOTENCE FIRST, `addScope`'s own shape: read the existing row
3218
+ // inside the transaction and BEFORE the overlap check, because a
3219
+ // byte-identical exclusion overlaps itself and would otherwise be
3220
+ // refused rather than accepted as the no-op an identical repeat requires.
3221
+ const identical = db
3222
+ .prepare("select id, reason from anno_excluded_range where start = ? and end_inclusive = ?")
3223
+ .get(start, endInclusive) as { id: number; reason: string } | undefined;
3224
+ if (identical) {
3225
+ if (identical.reason === reason) return false;
3226
+ throw new AnnoRangeShapeError(
3227
+ `exclusion ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..$${endInclusive.toString(16).padStart(4, "0")}) ` +
3228
+ `is already recorded (id=${identical.id}) with a DIFFERENT reason -- the stored reason is left EXACTLY as it was. Refusing rather ` +
3229
+ `than silently overwriting what somebody wrote: remove the record with removeExcludedRange and add it again to change the reason.`,
3230
+ { start, endInclusive },
3231
+ );
3232
+ }
3233
+
3234
+ // TWO EXCLUSIONS OVERLAP IFF each starts at or before the other ends.
3235
+ // ADJACENCY FALLS OUT OF THE `>=`, `addScope()`'s exact predicate: an
3236
+ // existing exclusion ending at exactly `start - 1` fails
3237
+ // `end_inclusive >= start`, so touching is not overlapping. `order by id
3238
+ // limit 1` reports the FIRST conflicting row so the message is
3239
+ // reproducible.
3240
+ const overlapper = db
3241
+ .prepare("select id, start, end_inclusive from anno_excluded_range where start <= ? and end_inclusive >= ? order by id limit 1")
3242
+ .get(endInclusive, start) as { id: number; start: number; end_inclusive: number } | undefined;
3243
+ if (overlapper) {
3244
+ throw new AnnoRangeShapeError(
3245
+ `exclusion ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..$${endInclusive.toString(16).padStart(4, "0")}) ` +
3246
+ `overlaps the existing exclusion id=${overlapper.id} ${overlapper.start}..${overlapper.end_inclusive} ` +
3247
+ `($${overlapper.start.toString(16).padStart(4, "0")}..$${overlapper.end_inclusive.toString(16).padStart(4, "0")}) -- the incoming ` +
3248
+ `record is NOT trimmed and NOT split: supply a range disjoint from every existing exclusion. Two exclusions that merely TOUCH at a ` +
3249
+ `boundary are disjoint and both accepted. Read listExcludedRanges() first, or removeExcludedRange the conflicting record.`,
3250
+ { start, endInclusive },
3251
+ );
3252
+ }
3253
+
3254
+ db.prepare("insert into anno_excluded_range(start, end_inclusive, reason) values (?, ?, ?)").run(start, endInclusive, reason);
3255
+ return true;
3256
+ },
3257
+ { baseRevision: args.baseRevision },
3258
+ );
3259
+ return { revision, changed: result };
3260
+ }
3261
+
3262
+ /** Every recorded exclusion, in ascending `id` order -- insertion order,
3263
+ * matching `listScopes()` and `listRanges()`. A consumer needing address
3264
+ * order sorts it itself, because a second ordering in the store would be a
3265
+ * second answer to the same question. `anno_excluded_range` has no `bank`
3266
+ * column, in the same shape `listScopes()`'s own doc comment uses for the
3267
+ * same absence: an exclusion is a statement about the subject program, not a
3268
+ * memory view. */
3269
+ export function listExcludedRanges(handle: AnnoStoreHandle): ExcludedRangeRow[] {
3270
+ const rows = handle.db.prepare("select id, start, end_inclusive, reason from anno_excluded_range order by id").all() as {
3271
+ id: number;
3272
+ start: number;
3273
+ end_inclusive: number;
3274
+ reason: string;
3275
+ }[];
3276
+ return rows.map((row) => ({ id: row.id, start: row.start, endInclusive: row.end_inclusive, reason: row.reason }));
3277
+ }
3278
+
3279
+ /**
3280
+ * Removes the exclusion whose span is EXACTLY `start..endInclusive`, and
3281
+ * returns `changed: false` when NO exclusion overlaps that span at all --
3282
+ * the exact inverse of `addExcludedRange`, following `removeScope()`.
3283
+ *
3284
+ * THE SPAN MUST MATCH EXACTLY -- both ends, as stored. A record is never
3285
+ * trimmed, split, or partially removed: a span that PARTIALLY OVERLAPS an
3286
+ * existing record (but does not match it end-for-end) is REFUSED BY NAME
3287
+ * rather than silently ignored, because a partial removal would leave a
3288
+ * shape nothing downstream can express, while reporting success. This is
3289
+ * stricter than `removeScope()`, which reports a mismatched span as a plain
3290
+ * no-op -- an exclusion's reason makes a near-miss removal more dangerous to
3291
+ * treat as "nothing happened", since a caller who meant to clear the record
3292
+ * would otherwise walk away believing it gone. A caller that does not know
3293
+ * the stored span reads it from `listExcludedRanges()` first.
3294
+ *
3295
+ * REMOVING A SPAN THAT DOES NOT OVERLAP ANYTHING STORED IS AN ACCEPTED NO-OP
3296
+ * reporting `changed: false`, matching `removeScope`'s own direction: an
3297
+ * inverse that refuses when there is genuinely nothing to undo makes "undo
3298
+ * this" conditional on knowing whether it was ever done.
3299
+ */
3300
+ export function removeExcludedRange(
3301
+ handle: AnnoStoreHandle,
3302
+ args: { start: number | string; endInclusive: number | string; baseRevision?: number },
3303
+ ): AnnoWriteResult {
3304
+ const start = parseStoreAddress(args.start, { what: "start" });
3305
+ const endInclusive = parseStoreAddress(args.endInclusive, { what: "endInclusive" });
3306
+ // The SAME non-split shape check `addExcludedRange`/`removeScope` use, and
3307
+ // for the same reason: an exclusion is not a table, so the split-table
3308
+ // even-count rule must not apply to it.
3309
+ assertRangeShape(start, endInclusive, "byte");
3310
+
3311
+ const { revision, result } = applyWrite(
3312
+ handle,
3313
+ (db) => {
3314
+ const existing = db.prepare("select id from anno_excluded_range where start = ? and end_inclusive = ?").get(start, endInclusive) as
3315
+ | { id: number }
3316
+ | undefined;
3317
+ if (existing) {
3318
+ db.prepare("delete from anno_excluded_range where id = ?").run(existing.id);
3319
+ return true;
3320
+ }
3321
+
3322
+ // NO EXACT MATCH. Before reporting the ordinary "nothing to undo"
3323
+ // no-op, check whether the incoming span PARTIALLY overlaps a stored
3324
+ // record -- the same overlap predicate `addExcludedRange` uses. That
3325
+ // case is refused BY NAME rather than treated as a no-op, because the
3326
+ // caller plainly meant to remove something that exists and a silent
3327
+ // no-op would misreport the outcome.
3328
+ const overlapper = db
3329
+ .prepare("select id, start, end_inclusive from anno_excluded_range where start <= ? and end_inclusive >= ? order by id limit 1")
3330
+ .get(endInclusive, start) as { id: number; start: number; end_inclusive: number } | undefined;
3331
+ if (overlapper) {
3332
+ throw new AnnoRangeShapeError(
3333
+ `removeExcludedRange: ${start}..${endInclusive} ($${start.toString(16).padStart(4, "0")}..${endInclusive
3334
+ .toString(16)
3335
+ .padStart(4, "0")}) does not EXACTLY match the existing exclusion id=${overlapper.id} ${overlapper.start}..${overlapper.end_inclusive} ` +
3336
+ `($${overlapper.start.toString(16).padStart(4, "0")}..$${overlapper.end_inclusive.toString(16).padStart(4, "0")}), which it ` +
3337
+ `partially overlaps -- a record is never trimmed, split, or partially removed, because that would leave a shape nothing downstream ` +
3338
+ `can express while reporting success. Read listExcludedRanges() first to find the exact stored span.`,
3339
+ { start, endInclusive },
3340
+ );
3341
+ }
3342
+
3343
+ return false;
3344
+ },
3345
+ { baseRevision: args.baseRevision },
3346
+ );
3347
+ return { revision, changed: result };
3348
+ }
3349
+
3094
3350
  /**
3095
3351
  * Validates one project enum's variants mapping and returns it with its KEYS
3096
3352
  * VERBATIM.
@@ -3406,6 +3662,266 @@ export function listEnumUsage(handle: AnnoStoreHandle): EnumUsageRow[] {
3406
3662
  }));
3407
3663
  }
3408
3664
 
3665
+ // ---------------------------------------------------------------------------
3666
+ // THE RUNTIME EVIDENCE TABLE (`anno_evid_exec`, `SCHEMA_VERSION` 4, EVID-01).
3667
+ // One raw shape used by all three functions below.
3668
+ // ---------------------------------------------------------------------------
3669
+
3670
+ /** One `anno_evid_exec` row exactly as the column names read on disk --
3671
+ * `snake_case`, matching every other raw-row shape in this module.
3672
+ *
3673
+ * A `type` ALIAS RATHER THAN AN `interface`, and that is load-bearing rather
3674
+ * than stylistic: `node:sqlite`'s `all()` returns `Record<string,
3675
+ * SQLOutputValue>[]`, and casting that to a NAMED `interface` fails TS's
3676
+ * type-assertion comparability check ("neither type sufficiently overlaps")
3677
+ * even though the shapes are identical -- a `type` alias to the same object
3678
+ * shape is accepted. Measured against this exact query shape during this
3679
+ * plan's own implementation. */
3680
+ type RawEvidExecRow = {
3681
+ id: number;
3682
+ image_sha256: string;
3683
+ argv_digest: string;
3684
+ seed: string;
3685
+ address: number;
3686
+ source_bank: string;
3687
+ };
3688
+
3689
+ /** `RawEvidExecRow` -> `EvidExecRow`, the one mapping site both read functions
3690
+ * below share, so the two never drift into disagreeing about the shape. */
3691
+ function toEvidExecRow(row: RawEvidExecRow): EvidExecRow {
3692
+ return {
3693
+ id: row.id,
3694
+ imageSha256: row.image_sha256,
3695
+ argvDigest: row.argv_digest,
3696
+ seed: row.seed,
3697
+ address: row.address,
3698
+ sourceBank: row.source_bank as EvidSourceBank,
3699
+ };
3700
+ }
3701
+
3702
+ /**
3703
+ * Inserts one or more runtime-execution observations for one run identity,
3704
+ * keyed `(imageSha256, argvDigest, seed, address, sourceBank)` -- the
3705
+ * `no-change` composite plan 43-01's live A/B selected, with no `run_class`
3706
+ * discriminator (`docs/phase43-instrumentation-perturbation-ab.md`).
3707
+ *
3708
+ * EVERY FIELD IS VALIDATED BEFORE THE FIRST STATEMENT RUNS, and every
3709
+ * refusal is a named `AnnoTypeError`/`AnnoAddressError` carrying the
3710
+ * offending value and the valid domain -- this module's existing refusal
3711
+ * register, never a fresh one. A caller-supplied `imageSha256`/`argvDigest`
3712
+ * that is not exactly 64 lowercase hex characters, a `seed` that is empty,
3713
+ * an `address` outside `ADDRESS_MIN..ADDRESS_MAX`, or a `sourceBank` outside
3714
+ * the frozen three is refused BEFORE the write transaction opens, so a bad
3715
+ * argument never reaches SQL and never partially inserts the rest of the
3716
+ * batch.
3717
+ *
3718
+ * THE WHOLE INSERT IS ONE `applyWrite` CALLBACK (T-43-09): every observation
3719
+ * in `args.observations` is written -- or skipped -- inside the SAME
3720
+ * transaction that `commitTransaction` commits, so a kill mid-ingest leaves
3721
+ * the set fully committed or fully absent, never a partial row set.
3722
+ *
3723
+ * AN OBSERVATION ALREADY PRESENT IS SKIPPED, NOT RE-INSERTED (EVID-01's
3724
+ * idempotent re-ingest): the existing row is selected first, by the full
3725
+ * unique key, and the insert only runs when it is absent. `changed` is
3726
+ * `false` exactly when every observation in this call was already present --
3727
+ * the same `changed`-is-the-only-no-op-signal contract `AnnoWriteResult`
3728
+ * states for every other write entry point in this module. `revision` still
3729
+ * advances by exactly one on every accepted call, no-op or not, for the same
3730
+ * reason.
3731
+ *
3732
+ * `insertedCount` (WR-02) IS COUNTED INSIDE THIS SAME TRANSACTION, never
3733
+ * derived from a separate read taken before `applyWrite` opens it: a caller
3734
+ * that wants "how many of these rows were actually new" must not be handed a
3735
+ * number computed from a `listExecObservations()` snapshot that a concurrent
3736
+ * writer to the SAME run identity could have moved past between that read
3737
+ * and this insert's own commit. Counting the per-row `existing`/insert
3738
+ * branch already taken above is the one place this number can be exact.
3739
+ */
3740
+ export interface InsertExecObservationsResult extends AnnoWriteResult {
3741
+ insertedCount: number;
3742
+ }
3743
+
3744
+ export function insertExecObservations(
3745
+ handle: AnnoStoreHandle,
3746
+ args: {
3747
+ imageSha256: unknown;
3748
+ argvDigest: unknown;
3749
+ seed: unknown;
3750
+ observations: readonly { address: unknown; sourceBank: unknown }[];
3751
+ baseRevision?: number;
3752
+ },
3753
+ ): InsertExecObservationsResult {
3754
+ const imageSha256 = assertRunIdentityDigest(args.imageSha256, "imageSha256");
3755
+ const argvDigest = assertRunIdentityDigest(args.argvDigest, "argvDigest");
3756
+ const seed = assertRunIdentitySeed(args.seed);
3757
+
3758
+ if (!Array.isArray(args.observations) || args.observations.length === 0) {
3759
+ throw new AnnoTypeError(`observations must be a non-empty array, got ${JSON.stringify(args.observations)}`, {
3760
+ dataType: args.observations,
3761
+ });
3762
+ }
3763
+ // VALIDATED IN FULL BEFORE THE FIRST STATEMENT, per this function's own
3764
+ // doc comment: a bad entry at index 9 must not leave entries 0..8 written.
3765
+ const parsedObservations = args.observations.map((obs) => ({
3766
+ address: parseStoreAddress((obs as { address: unknown }).address, { what: "address" }),
3767
+ sourceBank: assertEvidSourceBank((obs as { sourceBank: unknown }).sourceBank),
3768
+ }));
3769
+
3770
+ const { revision, result } = applyWrite(
3771
+ handle,
3772
+ (db) => {
3773
+ let insertedCount = 0;
3774
+ for (const obs of parsedObservations) {
3775
+ const existing = db
3776
+ .prepare("select id from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ? and address = ? and source_bank = ?")
3777
+ .get(imageSha256, argvDigest, seed, obs.address, obs.sourceBank) as { id: number } | undefined;
3778
+ if (existing) continue;
3779
+ db.prepare("insert into anno_evid_exec(image_sha256, argv_digest, seed, address, source_bank) values (?, ?, ?, ?, ?)").run(
3780
+ imageSha256,
3781
+ argvDigest,
3782
+ seed,
3783
+ obs.address,
3784
+ obs.sourceBank,
3785
+ );
3786
+ insertedCount++;
3787
+ }
3788
+ return insertedCount;
3789
+ },
3790
+ { baseRevision: args.baseRevision },
3791
+ );
3792
+ return { revision, changed: result > 0, insertedCount: result };
3793
+ }
3794
+
3795
+ /** One shape used by `listExecObservations`'s four fixed queries below. */
3796
+ type EvidRunIdentityFilter = { imageSha256: string; argvDigest: string; seed: string };
3797
+
3798
+ /**
3799
+ * Every runtime-execution observation, in ascending ADDRESS then `id` order,
3800
+ * with optional filters on `address` and on the full run identity. Never a
3801
+ * `select *` -- every column is named.
3802
+ *
3803
+ * A RUN-IDENTITY FILTER IS ALL THREE FIELDS TOGETHER OR NONE. A partial
3804
+ * identity (the seed alone, say) would silently widen the match to every
3805
+ * image/argv pair that happens to share it, which is not what "filter by run
3806
+ * identity" means -- refused BY NAME rather than accepted as a wider query
3807
+ * nobody asked for.
3808
+ */
3809
+ export function listExecObservations(
3810
+ handle: AnnoStoreHandle,
3811
+ opts: { address?: number | string; imageSha256?: unknown; argvDigest?: unknown; seed?: unknown } = {},
3812
+ ): EvidExecRow[] {
3813
+ const hasAddress = opts.address !== undefined;
3814
+ const identityFieldsGiven = [opts.imageSha256, opts.argvDigest, opts.seed].filter((v) => v !== undefined).length;
3815
+ if (identityFieldsGiven > 0 && identityFieldsGiven < 3) {
3816
+ throw new AnnoTypeError(
3817
+ "listExecObservations: a run-identity filter requires imageSha256, argvDigest AND seed together -- a partial identity would silently widen the match",
3818
+ { dataType: { imageSha256: opts.imageSha256, argvDigest: opts.argvDigest, seed: opts.seed } },
3819
+ );
3820
+ }
3821
+ const hasIdentity = identityFieldsGiven === 3;
3822
+
3823
+ const address = hasAddress ? parseStoreAddress(opts.address as number | string, { what: "address" }) : undefined;
3824
+ const identity: EvidRunIdentityFilter | undefined = hasIdentity
3825
+ ? {
3826
+ imageSha256: assertRunIdentityDigest(opts.imageSha256, "imageSha256"),
3827
+ argvDigest: assertRunIdentityDigest(opts.argvDigest, "argvDigest"),
3828
+ seed: assertRunIdentitySeed(opts.seed),
3829
+ }
3830
+ : undefined;
3831
+
3832
+ let rows: RawEvidExecRow[];
3833
+ if (address !== undefined && identity !== undefined) {
3834
+ rows = handle.db
3835
+ .prepare(
3836
+ "select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where address = ? and image_sha256 = ? and argv_digest = ? and seed = ? order by address, id",
3837
+ )
3838
+ .all(address, identity.imageSha256, identity.argvDigest, identity.seed) as RawEvidExecRow[];
3839
+ } else if (address !== undefined) {
3840
+ rows = handle.db
3841
+ .prepare("select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where address = ? order by address, id")
3842
+ .all(address) as RawEvidExecRow[];
3843
+ } else if (identity !== undefined) {
3844
+ rows = handle.db
3845
+ .prepare(
3846
+ "select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ? order by address, id",
3847
+ )
3848
+ .all(identity.imageSha256, identity.argvDigest, identity.seed) as RawEvidExecRow[];
3849
+ } else {
3850
+ rows = handle.db
3851
+ .prepare("select id, image_sha256, argv_digest, seed, address, source_bank from anno_evid_exec order by address, id")
3852
+ .all() as RawEvidExecRow[];
3853
+ }
3854
+
3855
+ return rows.map(toEvidExecRow);
3856
+ }
3857
+
3858
+ /**
3859
+ * Every distinct run identity with an `anno_evid_exec` row, its accumulated
3860
+ * observation count, and the `denominator` those counts are a fraction of
3861
+ * (EVID-04).
3862
+ *
3863
+ * WHY A DENOMINATOR IS RETURNED AT ALL, AND WHY IT NEVER FORMS A PERCENTAGE
3864
+ * ITSELF. A bare count invites the reading "the rest is data" -- exactly the
3865
+ * soundness violation EVID-04 forbids (see `RuntimeExecClass`'s own doc
3866
+ * comment in `anno-types.ts`). `denominator` is `ADDRESS_MAX - ADDRESS_MIN +
3867
+ * 1`, read from `anno-types.ts`'s own constants rather than the literal
3868
+ * `65536` -- a caller comparing a run's `observationCount` against it forms
3869
+ * its own fraction, and there is deliberately no rounding site in this
3870
+ * module to do that division for them (see this file's `toFixed` census in
3871
+ * this task's own acceptance criteria).
3872
+ */
3873
+ export function listObservedRuns(handle: AnnoStoreHandle): { runs: ObservedRunRow[]; denominator: number } {
3874
+ const rows = handle.db
3875
+ .prepare(
3876
+ "select image_sha256, argv_digest, seed, count(*) as observation_count from anno_evid_exec group by image_sha256, argv_digest, seed order by image_sha256, argv_digest, seed",
3877
+ )
3878
+ .all() as { image_sha256: string; argv_digest: string; seed: string; observation_count: number }[];
3879
+ return {
3880
+ runs: rows.map((row) => ({
3881
+ imageSha256: row.image_sha256,
3882
+ argvDigest: row.argv_digest,
3883
+ seed: row.seed,
3884
+ observationCount: row.observation_count,
3885
+ })),
3886
+ denominator: ADDRESS_MAX - ADDRESS_MIN + 1,
3887
+ };
3888
+ }
3889
+
3890
+ /**
3891
+ * Deletes every `anno_evid_exec` row for one run identity -- a bracket
3892
+ * reset (EVID-05). A run identity holding no rows returns `changed: false`
3893
+ * and is NOT an error: resetting an empty bracket is the ordinary thing,
3894
+ * matching `clearEnumUsage`'s own direction for the identical case.
3895
+ *
3896
+ * THIS IS THE MODULE'S FIFTH ROW-DELETING STATEMENT. `removeScope`'s doc
3897
+ * block (`anno-store.ts`) states the count as four; this one supersedes it.
3898
+ * The count is written in prose, deliberately without spelling the SQL
3899
+ * prefix a census greps for, so a `grep` over this module counts STATEMENTS
3900
+ * and not the sentences describing them. This statement runs inside the
3901
+ * write sequence's transaction, so a refusal raised anywhere in the
3902
+ * sequence rolls it back with everything else -- and a refusal here can
3903
+ * only come from validating the run-identity arguments themselves, since
3904
+ * deleting zero rows is success, not an error.
3905
+ */
3906
+ export function deleteExecObservationsForRun(
3907
+ handle: AnnoStoreHandle,
3908
+ args: { imageSha256: unknown; argvDigest: unknown; seed: unknown; baseRevision?: number },
3909
+ ): AnnoWriteResult {
3910
+ const imageSha256 = assertRunIdentityDigest(args.imageSha256, "imageSha256");
3911
+ const argvDigest = assertRunIdentityDigest(args.argvDigest, "argvDigest");
3912
+ const seed = assertRunIdentitySeed(args.seed);
3913
+
3914
+ const { revision, result } = applyWrite(
3915
+ handle,
3916
+ (db) => {
3917
+ const info = db.prepare("delete from anno_evid_exec where image_sha256 = ? and argv_digest = ? and seed = ?").run(imageSha256, argvDigest, seed);
3918
+ return Number(info.changes) > 0;
3919
+ },
3920
+ { baseRevision: args.baseRevision },
3921
+ );
3922
+ return { revision, changed: result };
3923
+ }
3924
+
3409
3925
  /**
3410
3926
  * Records ONE NON-DERIVABLE cross-reference.
3411
3927
  *