@cassiomc1/forgeloop 0.1.9 → 0.1.10

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/README.md CHANGED
@@ -61,11 +61,17 @@ flowchart TB
61
61
  root --> entry["native adapters<br/>+ .forgeloop/kit/"]
62
62
  entry --> migration{"target<br/>layout"}
63
63
  migration -->|legacy v1| plan["validate paths<br/>+ build plan"]
64
- plan --> hidden["write + verify<br/>hidden kit"]
65
- hidden --> authority["atomic manifest<br/>authority switch"]
64
+ plan --> hidden["write hidden<br/>kit"]
65
+ hidden --> verified["verify hidden<br/>bytes"]
66
+ verified --> authority["atomic manifest<br/>authority switch"]
66
67
  authority --> cleanup["hash-checked<br/>legacy cleanup"]
67
68
  cleanup --> recovered["healthy or<br/>recoverable"]
69
+ hidden -. interruption .-> incomplete["doctor: E_MIGRATION_INCOMPLETE"]
70
+ verified -. interruption .-> incomplete
71
+ authority -. interruption .-> incomplete
72
+ cleanup -. interruption .-> incomplete
68
73
  migration -->|layout v2| retry["update + retry<br/>owned cleanup"]
74
+ incomplete --> retry
69
75
  entry --> discovery["discovery +<br/>project profile"]
70
76
  discovery --> contract["current<br/>contract"]
71
77
  contract --> routing["deterministic<br/>route"]
@@ -115,7 +121,7 @@ flowchart TB
115
121
 
116
122
  class root root;
117
123
  class entry,discovery,delegation entry;
118
- class migration,plan,hidden,authority,cleanup,recovered,retry migration;
124
+ class migration,plan,hidden,verified,authority,cleanup,recovered,retry,incomplete migration;
119
125
  class routing,preflight,ready routing;
120
126
  class state,events,lifecycle state;
121
127
  class checks,receipt,audit evidence;
@@ -127,9 +133,11 @@ flowchart TB
127
133
  ```
128
134
 
129
135
  Equivalent reading for text-only environments: adapters load the canonical kit;
130
- an older target follows validate paths → write and verify hidden files → switch
131
- manifest authority → hash-checked cleanup, while an interrupted migration is
132
- diagnosed and retried by `doctor`/`update`;
136
+ an older target follows validate paths → write hidden files → verify their bytes
137
+ → switch manifest authority atomically → hash-checked cleanup. An interruption
138
+ after hidden writes, after verification, after the authority switch, or during
139
+ cleanup is diagnosed by `doctor` as `E_MIGRATION_INCOMPLETE` and retried by
140
+ `update`; modified or unmanaged residual files remain preserved;
133
141
  discovery creates the contract and deterministic route; contract, route, and
134
142
  required gates must produce `PREFLIGHT_READY` before the resumable state and
135
143
  append-only event ledger authorize the lifecycle. Verification produces
@@ -150,6 +158,37 @@ Antigravity, OpenCode, Hermes, Pi, Command Code, and Freebuff use the shared
150
158
  documents; see [`AGENT_COMPATIBILITY.md`](./AGENT_COMPATIBILITY.md) for the
151
159
  official sources and precedence notes.
152
160
 
161
+ ### Migration recovery and release freeze
162
+
163
+ Legacy layout migration keeps `.forgeloop/kit/` as the canonical layout and
164
+ does not make `layoutVersion: 2` authoritative until every planned hidden file
165
+ has been written and byte-verified. Cleanup runs only after the manifest switch
166
+ and only for legacy files whose recorded ownership hash still matches when
167
+ ForgeLoop revalidates it immediately before deletion. A modified, unmanaged,
168
+ or `preserve=true` file is retained for manual review.
169
+
170
+ The interruption vocabulary is test-only and is not a runtime state machine:
171
+
172
+ ```text
173
+ VALIDATED → HIDDEN_WRITTEN → HIDDEN_VERIFIED → MANIFEST_SWITCHED
174
+ → LEGACY_CLEANED → COMPLETE
175
+ ```
176
+
177
+ The regression suite injects failures at these boundaries and verifies that
178
+ `doctor` explains the incomplete migration before a later `update` recovers
179
+ owned cleanup. The frozen published installation under
180
+ [`tests/fixtures/legacy-0.1.6/`](./tests/fixtures/legacy-0.1.6/) is derived
181
+ from the real npm tarball, includes provenance and digests, and is copied into
182
+ tests locally; CI does not download npm packages.
183
+
184
+ The current published baseline for a reproducible blind run is
185
+ `@cassiomc1/forgeloop@0.1.9`. The supplied hardening brief's `0.1.8` reference
186
+ is historical; never move `v0.1.8` or `v0.1.9`, and use the read-only release
187
+ identity verifier before a live run. The repository candidate is `0.1.10`, but
188
+ it is not published: its completion-validation and cleanup TOCTOU fixes are not
189
+ part of the frozen `0.1.9` tarball. Publish, tag, and repeat identity
190
+ verification before a blind run that needs those executable changes.
191
+
153
192
  ### Use with npm
154
193
 
155
194
  The npm CLI targets Node.js 20 or newer and installs the kit into an existing
package/THREAT_MODEL.md CHANGED
@@ -13,6 +13,7 @@ remaining trust boundaries and their executable evidence.
13
13
  | Path traversal | Writes or reads outside the selected target | Target path and every managed relative path | `ensureWithin`, safe-path checks, realpath containment, Windows-drive rejection | A separately privileged process can change the filesystem after validation | `tests/core.test.js`, `tests/portability.test.js`, `tests/fixtures/protocol/invalid/path-traversal.json` |
14
14
  | Symlink escape | Redirects a managed path to another directory | Existing target parents and artifact paths | Reject symlinked targets, parents, and destinations before access | The check is not a filesystem lock | `tests/core.test.js`, `tests/portability.test.js`, `tests/fixtures/protocol/invalid/symlink-target.json` |
15
15
  | Malicious manifest | Causes unsafe update or false ownership claims | `.forgeloop/manifest.json` | Schema, managed/legacy hash, safe-path, and preserve-flag validation; incomplete migration remains visible | A trusted operator can still deliberately edit the manifest | `tests/cli.test.js`, `tests/core.test.js`, `tests/hidden-layout.test.js` |
16
+ | Interrupted or concurrent migration cleanup | Leaves a new hidden kit, an old manifest, residual legacy files, or mutates a legacy file after planning | Hidden kit, manifest authority, and legacy root cleanup | Validate all paths, write and verify the hidden plan, atomically switch authority, then revalidate safe paths and recorded ownership hashes immediately before unlink; mismatches are preserved and `doctor` reports `E_MIGRATION_INCOMPLETE` | Hash-before-unlink narrows but cannot eliminate the final OS-level race without filesystem locking; a separately privileged process remains outside the trust boundary | `tests/hidden-layout.test.js`, `README.md` |
16
17
  | Malicious work-state | Resumes stale, secret-bearing, or invalid work | `.forgeloop/work-state.json` | Schema/semantic checks, version checks, transition checks, contract/HEAD/artifact freshness, secret scan, size/depth bounds | Freshness cannot prove that an external process did not alter a file immediately afterward | `tests/work-state.test.js`, `tests/checkpoint-freshness.test.js`, `tests/security-limits.test.js` |
17
18
  | Forged preparation | Makes an agent's prose claim look like a completed preflight | Contract, route, gate, and preflight artifacts | Canonical SHA-256 fingerprints, guide-declared gate requirements, stale-artifact checks, and `E_*` failures | The CLI cannot stop a separate process from writing project files before preflight | `tests/protocol-artifacts.test.js`, `tests/preflight.test.js` |
18
19
  | Chronology rewrite | Hides execution before route, gates, or verification | `.forgeloop/events.ndjson` | Append-only local ledger, sequence numbers, hash chaining, and chronology validation without prompts or hidden reasoning | A privileged process can still replace the ledger after validation | `tests/lifecycle.test.js` |
@@ -23,6 +24,7 @@ remaining trust boundaries and their executable evidence.
23
24
  | Artifact content exposure | Sensitive material enters portable artifacts or diagnostics | State, receipt, delegation, evidence, and repository text | Nested key/value detection, shaped marker patterns, scanner coverage, no content echo in errors | Content scanners cannot prove that an unknown encoding is harmless | `tests/security-limits.test.js`, `tests/test_scan_secrets.py`, `scripts/scan_secrets.py` |
24
25
  | Stale checkpoint replay | Repeats work against a changed contract or material file | Resume boundary between a saved state and current target | Contract fingerprint, repository fingerprint, required-artifact hashes, protocol version, and conservative revalidation | A contract author must provide the current contract file for comparison | `tests/checkpoint-freshness.test.js`, `tests/cli.test.js` |
25
26
  | Unsafe publication claim | Local output is mistaken for push, PR, merge, or deployment | Receipt publication fields and evidence | Publication starts false; each true claim requires matching evidence; no CLI publication action | Remote provider state is outside local verification | `tests/receipt-semantics.test.js`, `README.md` |
27
+ | Release identity drift | A blind run uses a package whose code, tag, commit, or tarball digest differs from the stated release | npm metadata, GitHub tag, and release commit | Read-only verifier requires package version, `gitHead`, tag, tarball URL, SHA-1, and SHA-512 to agree before the prompt starts | Registry and remote state require a fresh check for every release; a documentation commit is not a new npm package | `tests/release-identity.test.js`, `scripts/verify_release_identity.mjs`, `conformance/README.md` |
26
28
  | Schema confusion | A malformed or altered schema is treated as available | Shipped and target-local `schemas/` directory | Health checks parse every schema and classify valid, missing, invalid, or unsupported-version | Schema health does not replace review of semantic relationships | `tests/schema-health.test.js`, `src/core/schema-validation.js` |
27
29
  | Protocol-version confusion | Old/future artifacts are silently reinterpreted | Cross-artifact protocol boundary | Explicit v1 checks and conformance errors for unsupported or mixed versions | A future protocol needs a deliberate migration implementation | `tests/conformance.test.js`, `tests/fixtures/protocol/invalid/` |
28
30
  | Dependency-cycle denial of service | Coordination cannot make progress or spends unbounded work | Delegation dependency graph | Deterministic DFS cycle detection, unknown-reference rejection, and bounded JSON | A caller can still submit many valid tasks; host-level quotas remain external | `tests/delegation.test.js`, `tests/delegation-set.test.js` |
@@ -32,6 +34,9 @@ remaining trust boundaries and their executable evidence.
32
34
 
33
35
  - Safe paths are checked before reading or writing; no protocol field is a
34
36
  shell command.
37
+ - A migration never treats `layoutVersion: 2` as authoritative before the
38
+ hidden kit is complete and verified; cleanup follows the authority switch and
39
+ is limited by recorded ownership hashes.
35
40
  - A `NOT_VERIFIED` or `BLOCKED` evidence record is never upgraded to observed
36
41
  success by a formatter.
37
42
  - Local checks do not imply remote publication, merge, deployment, or provider
@@ -8,6 +8,26 @@ The current published baseline for new runs is
8
8
  `@cassiomc1/forgeloop@0.1.9`. Pin that version when preparing a reproducible
9
9
  blind run; historical reports retain the exact package version they used.
10
10
 
11
+ The frozen baseline was verified on 2026-08-13 with this identity:
12
+
13
+ ```text
14
+ package: @cassiomc1/forgeloop@0.1.9
15
+ npm gitHead: 0e48240894671ecda2458b15671020b0a7255e70
16
+ release commit: 0e48240894671ecda2458b15671020b0a7255e70
17
+ GitHub tag: v0.1.9 -> 0e48240894671ecda2458b15671020b0a7255e70
18
+ tarball URL: https://registry.npmjs.org/@cassiomc1/forgeloop/-/forgeloop-0.1.9.tgz
19
+ tarball SHA-1: fef7587ebd878e845e9746b4d049a6fe6ef333c5
20
+ npm SHA-512 integrity: sha512-sCvPEDOYT1svNnA8+zw57dJ97N6sNCUH8eDbM6f3cS6eXLCIFFXRPkCAASA+ZPu5mAKbcNaU1TaFKjojbL3DEA==
21
+ release identity: RELEASE_IDENTITY_VALID
22
+ ```
23
+
24
+ The repository may contain documentation or executable commits after this
25
+ frozen package. Those commits do not change the package used by the blind run;
26
+ the repository's `0.1.10` candidate includes completion-validation and cleanup
27
+ TOCTOU fixes that are not in `0.1.9`. If a blind run needs those executable
28
+ changes, publish and tag the candidate, then repeat the complete identity check
29
+ before starting the run.
30
+
11
31
  Run a scenario in a disposable target using the Standard profile first:
12
32
 
13
33
  ```bash
@@ -78,6 +98,21 @@ field, record `RELEASE_IDENTITY_NOT_VERIFIED` or
78
98
  The complete-website scenario deliberately fails when implementation starts
79
99
  before the contract, route, and required gates exist.
80
100
 
101
+ ## Migration compatibility evidence
102
+
103
+ The real published `0.1.6` fixture at
104
+ [`tests/fixtures/legacy-0.1.6/`](../tests/fixtures/legacy-0.1.6/) is frozen
105
+ with package, tarball, SHA-1, SHA-512, `gitHead`, and extraction-date metadata.
106
+ The migration tests run from those local bytes and cover interruptions after
107
+ hidden writes, after hidden verification, after the manifest authority switch,
108
+ and during legacy cleanup. An interrupted target must be diagnosed as
109
+ `E_MIGRATION_INCOMPLETE`; a later `update` may retry only hash-owned cleanup.
110
+ User-modified, unmanaged, and `preserve=true` files remain untouched even when
111
+ that leaves a root residual. ForgeLoop revalidates the recorded ownership hash
112
+ immediately before deleting each managed legacy file. This narrows the race
113
+ window but does not provide OS-level filesystem locking against a separately
114
+ privileged concurrent process.
115
+
81
116
  ## Autonomous blind-run isolation
82
117
 
83
118
  External workflows may help with local planning, review, tests, or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cassiomc1/forgeloop",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Portable, verifiable instruction kit for AI agents and developers",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,8 +41,8 @@ async function removeEmptyLegacyDirectory(target, relativePath, dryRun) {
41
41
  }
42
42
  }
43
43
 
44
- function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath) {
45
- cleanupFiles.add(relativePath);
44
+ function addLegacyCleanup(cleanupFiles, cleanupDirectories, relativePath, expectedLegacySha256) {
45
+ cleanupFiles.set(relativePath, expectedLegacySha256);
46
46
  addLegacyCleanupDirectory(cleanupDirectories, relativePath);
47
47
  }
48
48
 
@@ -66,14 +66,25 @@ async function notifyStage(hooks, stage, context) {
66
66
  if (typeof hooks?.afterStage === "function") await hooks.afterStage(stage, context);
67
67
  }
68
68
 
69
- async function cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks }) {
70
- for (const relativePath of cleanupFiles) {
71
- await assertSafePath(target, relativePath);
69
+ async function cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts }) {
70
+ for (const [relativePath, expectedLegacySha256] of cleanupFiles) {
72
71
  if (typeof hooks?.beforeCleanup === "function") {
73
72
  await hooks.beforeCleanup(relativePath);
74
73
  }
74
+ await assertSafePath(target, relativePath);
75
75
  const legacyPath = ensureWithin(target, relativePath);
76
- if (!dryRun && await fileExists(legacyPath)) await unlink(legacyPath);
76
+ if (dryRun || !(await fileExists(legacyPath))) continue;
77
+ const currentLegacySha256 = sha256(await readBytes(legacyPath));
78
+ if (currentLegacySha256 !== expectedLegacySha256) {
79
+ conflicts.push(migrationConflict(
80
+ "E_LEGACY_FILE_MIGRATION_CONFLICT",
81
+ relativePath,
82
+ "Legacy file changed before cleanup; it was preserved.",
83
+ ));
84
+ actions.push({ action: "preserve-conflict", path: relativePath, reason: "changed-before-cleanup" });
85
+ continue;
86
+ }
87
+ await unlink(legacyPath);
77
88
  }
78
89
 
79
90
  for (const relativePath of cleanupDirectories) {
@@ -89,7 +100,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
89
100
  const actions = [];
90
101
  const conflicts = [];
91
102
  const writes = [];
92
- const cleanupFiles = new Set();
103
+ const cleanupFiles = new Map();
93
104
  const cleanupDirectories = new Set();
94
105
 
95
106
  // Validate every path before creating the migration plan or touching data.
@@ -163,7 +174,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
163
174
  && legacyRecord
164
175
  && currentHash === legacyHash) {
165
176
  actions.push({ action: "skip", path: entry.relativePath, reason: "profile-move-resumed" });
166
- addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
177
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
167
178
  nextManifest.files[entry.relativePath] = manifestRecord(currentHash, true, legacyHash);
168
179
  continue;
169
180
  }
@@ -177,7 +188,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
177
188
  addAction(actions, dryRun, "preserve-conflict", entry.relativePath, { reason: "hidden-unmanaged" });
178
189
  } else if (hiddenMatchesSource && legacyExists && unchangedManagedLegacy) {
179
190
  actions.push({ action: "skip", path: entry.relativePath, reason: "hidden-ready" });
180
- addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
191
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
181
192
  nextManifest.files[entry.relativePath] = manifestRecord(
182
193
  sourceHash,
183
194
  entry.sourcePath === PROFILE_PATH,
@@ -224,7 +235,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
224
235
  if (entry.sourcePath === PROFILE_PATH && legacyRecord) {
225
236
  addAction(actions, dryRun, "move-profile", entry.legacyRelativePath, { to: entry.relativePath });
226
237
  writes.push({ destination, bytes: legacyBytes });
227
- addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
238
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
228
239
  nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
229
240
  continue;
230
241
  }
@@ -232,7 +243,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
232
243
  if (legacyRecord?.preserve && unchangedManaged) {
233
244
  addAction(actions, dryRun, "move-preserved", entry.legacyRelativePath, { to: entry.relativePath });
234
245
  writes.push({ destination, bytes: legacyBytes });
235
- addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
246
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
236
247
  nextManifest.files[entry.relativePath] = manifestRecord(legacyHash, true, legacyHash);
237
248
  continue;
238
249
  }
@@ -240,7 +251,7 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
240
251
  if (unchangedManaged) {
241
252
  addAction(actions, dryRun, "migrate", entry.legacyRelativePath, { to: entry.relativePath });
242
253
  writes.push({ destination, bytes: entry.bytes });
243
- addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
254
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, legacyHash);
244
255
  nextManifest.files[entry.relativePath] = manifestRecord(sourceHash, false, legacyHash);
245
256
  continue;
246
257
  }
@@ -278,10 +289,10 @@ async function migrateLegacyLayout({ target, dryRun, packageVersion, currentMani
278
289
  // Atomic manifest replacement is the authority switch. Cleanup follows it and
279
290
  // remains recoverable because managed legacy hashes are retained in the record.
280
291
  await writeManifest(target, nextManifest, { dryRun });
281
- await notifyStage(hooks, "MANIFEST_SWITCHED", { cleanupFiles: [...cleanupFiles] });
282
- await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks });
283
- await notifyStage(hooks, "LEGACY_CLEANED", { cleanupFiles: [...cleanupFiles] });
284
- await notifyStage(hooks, "COMPLETE", { cleanupFiles: [...cleanupFiles] });
292
+ await notifyStage(hooks, "MANIFEST_SWITCHED", { cleanupFiles: [...cleanupFiles.keys()] });
293
+ await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts });
294
+ await notifyStage(hooks, "LEGACY_CLEANED", { cleanupFiles: [...cleanupFiles.keys()] });
295
+ await notifyStage(hooks, "COMPLETE", { cleanupFiles: [...cleanupFiles.keys()] });
285
296
  return { actions, conflicts, manifest: nextManifest };
286
297
  }
287
298
 
@@ -300,7 +311,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion, h
300
311
  const actions = [];
301
312
  const conflicts = [];
302
313
  const plans = [];
303
- const cleanupFiles = new Set();
314
+ const cleanupFiles = new Map();
304
315
  const cleanupDirectories = new Set();
305
316
  const pruneActions = [];
306
317
  const shippedPaths = new Set(entries.map((entry) => entry.relativePath));
@@ -333,7 +344,7 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion, h
333
344
  if (hasLegacyAlternative && await fileExists(legacyDestination)) {
334
345
  const legacyHash = sha256(await readBytes(legacyDestination));
335
346
  if (record?.legacySha256 && legacyHash === record.legacySha256) {
336
- addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath);
347
+ addLegacyCleanup(cleanupFiles, cleanupDirectories, entry.legacyRelativePath, record.legacySha256);
337
348
  } else {
338
349
  conflicts.push({
339
350
  code: "E_LEGACY_FILE_MIGRATION_CONFLICT",
@@ -412,6 +423,6 @@ export async function runUpdate({ target, dryRun, packageRoot, packageVersion, h
412
423
  nextManifest.packageName = PACKAGE_NAME;
413
424
  nextManifest.packageVersion = packageVersion;
414
425
  await writeManifest(target, nextManifest, { dryRun });
415
- await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks });
426
+ await cleanupLegacyFiles({ target, dryRun, cleanupFiles, cleanupDirectories, hooks, actions, conflicts });
416
427
  return { actions, conflicts, manifest: nextManifest };
417
428
  }
@@ -92,24 +92,35 @@ export function assertCheckList(value, label = "checks") {
92
92
  return value;
93
93
  }
94
94
 
95
- export function requiredChecksSatisfied(checks, requiredIds, { allowInferred = false } = {}) {
95
+ function requiredChecksSatisfiedBy(checks, requiredValues, selector, { allowInferred = false } = {}) {
96
96
  assertCheckList(checks);
97
- if (!Array.isArray(requiredIds)) throw checkError("E_CHECK_INVALID", "requiredIds must be an array");
98
- const byId = new Map(checks.map((check) => [check.id, check]));
97
+ if (!Array.isArray(requiredValues)) throw checkError("E_CHECK_INVALID", "required values must be an array");
99
98
  const errors = [];
100
- for (const id of requiredIds) {
101
- const check = byId.get(id);
99
+ for (const value of requiredValues) {
100
+ const candidates = checks.filter((check) => selector(check) === value);
101
+ const check = candidates.find((candidate) => candidate.status === "passed"
102
+ && (allowInferred || candidate.evidenceKind === "OBSERVED"))
103
+ ?? candidates.find((candidate) => candidate.status === "passed")
104
+ ?? candidates[0];
102
105
  if (!check) {
103
- errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is missing: ${id}`, [id]));
106
+ errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is missing: ${value}`, [value]));
104
107
  continue;
105
108
  }
106
109
  if (check.status !== "passed") {
107
- errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is not passed: ${id}`, [id]));
110
+ errors.push(checkError("E_EVIDENCE_REQUIRED", `Required check is not passed: ${value}`, [value]));
108
111
  continue;
109
112
  }
110
113
  if (!allowInferred && check.evidenceKind !== "OBSERVED") {
111
- errors.push(checkError("E_EVIDENCE_KIND_INVALID", `Required check must be observed: ${id}`, [id]));
114
+ errors.push(checkError("E_EVIDENCE_KIND_INVALID", `Required check must be observed: ${value}`, [value]));
112
115
  }
113
116
  }
114
117
  return errors;
115
118
  }
119
+
120
+ export function requiredChecksSatisfied(checks, requiredIds, options = {}) {
121
+ return requiredChecksSatisfiedBy(checks, requiredIds, (check) => check.id, options);
122
+ }
123
+
124
+ export function requiredChecksSatisfiedForRequirements(checks, requiredRequirements, options = {}) {
125
+ return requiredChecksSatisfiedBy(checks, requiredRequirements, (check) => check.requirement, options);
126
+ }
@@ -1,5 +1,5 @@
1
1
  import { ARTIFACT_PATHS, canonicalFingerprint } from "./artifacts.js";
2
- import { assertCheckList, requiredChecksSatisfied } from "./checks.js";
2
+ import { assertCheckList, requiredChecksSatisfiedForRequirements } from "./checks.js";
3
3
  import { assertCoverageList, coverageForRequirements } from "./coverage.js";
4
4
  import { assertEvidenceList } from "./evidence.js";
5
5
 
@@ -86,7 +86,7 @@ export function completionRelationshipErrors({
86
86
  addAssertion(errors, () => assertCheckList(receipt.checks, "receipt.checks"), "E_CHECK_INVALID", [ARTIFACT_PATHS.receipt]);
87
87
  addAssertion(errors, () => assertEvidenceList(receipt.evidence ?? [], "receipt.evidence"), "E_EVIDENCE_INVALID", [ARTIFACT_PATHS.receipt]);
88
88
  if (requireRequiredChecks) {
89
- for (const error of requiredChecksSatisfied(receipt.checks, requiredEvidence)) {
89
+ for (const error of requiredChecksSatisfiedForRequirements(receipt.checks, requiredEvidence)) {
90
90
  errors.push(issue(error.code, error.message, [ARTIFACT_PATHS.receipt]));
91
91
  }
92
92
  }