@variance-authority/scenario 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # @variance-authority/scenario
2
+
3
+ ## 0.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 92f62d4: Name the `startScenario` option a host left out. `precondition` and `profile`
8
+ are required and were read before they were checked, so omitting `precondition`
9
+ answered `Cannot read properties of undefined (reading 'id')` from inside the
10
+ module while `id` — required in the same way, on the same options object —
11
+ already answered with a sentence. All three now do.
12
+ - Updated dependencies [e8fee66]
13
+ - Updated dependencies [5c34e6d]
14
+ - @variance-authority/core@0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machine Garden
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ <p align="center"><img src="./mark.svg" alt="Variance Authority mark" width="72"></p>
2
+
3
+ # @variance-authority/scenario
4
+
5
+ > Record runtime scenarios as AAA state machines and assess variance across witnessed transitions.
6
+
7
+ Use this package when a host can arrange a named UI state, perform meaningful
8
+ acts, and attempt a semantic snapshot after each one. A scenario is AAA
9
+ (Arrange-Act-Assert) as a state machine: Arrange is the initial state
10
+ observation, each Act labels a transition, and assessment compares the
11
+ variance across those transitions.
12
+
13
+ The host remains responsible for producing `page-loading`, `page-error`,
14
+ `page-one-article`, or any other precondition. If the host already resolved
15
+ this precondition's parent subject, pass that parent's id and whether it was
16
+ declared or named as `preconditionLink`; this package only records that
17
+ evidence and never tries to infer fixtures, mocks, cookies, routes, or flags.
18
+
19
+ The snapshots come from the host as well. A `SemanticSnapshot` is the normalized
20
+ capture of a rendered subject that `@variance-authority/core` produces from a
21
+ collector's raw capture; nothing here produces one. The optional archive writes
22
+ to a directory you give it.
23
+
24
+ ```bash
25
+ npm install --save-dev @variance-authority/scenario
26
+ ```
27
+
28
+ ## Record and assess two paths
29
+
30
+ Each `SemanticSnapshot` below is a `declare const` stand-in: a real one comes
31
+ from a collector capturing a rendered subject and `@variance-authority/core`
32
+ normalizing it, not from this package.
33
+
34
+ ```ts
35
+ import type { SemanticSnapshot } from '@variance-authority/core';
36
+ import {
37
+ assessScenarios,
38
+ defineScenario,
39
+ recordAct,
40
+ startScenario,
41
+ } from '@variance-authority/scenario';
42
+
43
+ declare const oneArticleBefore: SemanticSnapshot;
44
+ declare const oneArticleAfter: SemanticSnapshot;
45
+ declare const twoArticlesBefore: SemanticSnapshot;
46
+ declare const twoArticlesAfter: SemanticSnapshot;
47
+
48
+ const definition = defineScenario('delete-an-article', [{ key: 'delete-first' }]);
49
+
50
+ const oneArticle = recordAct(
51
+ startScenario(
52
+ definition,
53
+ {
54
+ id: 'run-1/one-article',
55
+ precondition: oneArticleBefore.subject,
56
+ profile: oneArticleBefore.profile.id,
57
+ },
58
+ oneArticleBefore,
59
+ ),
60
+ 'delete-first',
61
+ oneArticleAfter,
62
+ );
63
+
64
+ const twoArticles = recordAct(
65
+ startScenario(
66
+ definition,
67
+ {
68
+ id: 'run-1/two-articles',
69
+ precondition: twoArticlesBefore.subject,
70
+ profile: twoArticlesBefore.profile.id,
71
+ },
72
+ twoArticlesBefore,
73
+ ),
74
+ 'delete-first',
75
+ twoArticlesAfter,
76
+ );
77
+
78
+ const assessment = assessScenarios(oneArticle, twoArticles);
79
+ console.log(assessment.arrange, assessment.firstDivergence);
80
+ ```
81
+
82
+ Each `startScenario` or `recordAct` call above appends one frame to the
83
+ execution — Arrange's own outcome, or one Act's — pairing that frame's render
84
+ hash (the exact content digest of what rendered) with its semantic snapshot.
85
+
86
+ `arrange` compares the initial observations. Each transition then carries the
87
+ left effect, the right effect, and whether the two agree; an effect digest is
88
+ the digest of the classified semantic variance between an Act's before and
89
+ after snapshot, not the frame's own render hash. A shared token edit moves
90
+ every frame's render hash — its exact, absolute state — yet leaves the effect
91
+ digest stable, because the edit is already present in an Act's before and
92
+ after frame alike; changing the handler does move the digest at that Act,
93
+ because that is exactly the comparison the delta is taken from.
94
+
95
+ Acts align by `(key, occurrence)` along their common ordered prefix. An inserted,
96
+ missing, or repeated Act is reported in `unmatched` with its side; later ordinals are not
97
+ shifted into a plausible pair. A failed observation is created with
98
+ `unobserved(...)`, terminates the reachable prefix, and makes
99
+ `firstDivergence` unresolved rather than clean.
100
+
101
+ `startScenario` takes:
102
+
103
+ | option | required state | what it decides |
104
+ | --- | --- | --- |
105
+ | `id` | required, non-empty | the execution identity within its run |
106
+ | `precondition` | required | a `SubjectRef` — the host's stable subject id and kind — in the role of Arrange |
107
+ | `profile` | required | the observation profile every frame must match |
108
+ | `preconditionLink` | absent | the resolved parent and its declared or named evidence; omission means no link was supplied |
109
+
110
+ ## Fold executions into the machine
111
+
112
+ `foldScenarios` groups frames by render hash and adds witnessed transitions under
113
+ their Act keys. Paths reaching one hash converge on one node. If one state and
114
+ Act reach two destination hashes, both transitions remain and the machine reports
115
+ the divergence. An unobserved Act appears under `unknown`; an edge no execution
116
+ witnessed does not exist in the value and is never called impossible.
117
+
118
+ This does not replay anything: folding only turns already-recorded frames into
119
+ a graph of witnessed states and edges.
120
+
121
+ ## Retain semantic evidence explicitly
122
+
123
+ The root entrypoint is ephemeral and performs no I/O. Import
124
+ `createScenarioArchive` from `@variance-authority/scenario/archive` only when the
125
+ semantic evidence must survive the process.
126
+
127
+ The archive requires an address covering project, run, scenario, execution,
128
+ precondition, profile, and attempt — a caller-assigned label, such as a retry
129
+ count, that the archive never derives from the run itself. Its policy declares
130
+ expiry, access, deletion, and an admission function — a check that can refuse
131
+ to retain a given snapshot — for every semantic snapshot. Equal snapshots are
132
+ stored once by content digest. Expired or missing evidence reads as
133
+ `unobserved`; garbage collection removes expired manifests and semantic objects
134
+ no retained manifest references.
135
+
136
+ Only canonical `SemanticSnapshot` text and a versioned manifest enter the
137
+ archive — this does not store screenshots or make pass/fail decisions. Semantic
138
+ snapshots can still contain document text, accessible names, attributes, URLs,
139
+ and source evidence, so an admission policy that cannot retain those values
140
+ must refuse the snapshot instead of redacting it after hashing.
141
+
142
+ `createScenarioArchive` takes `root`, the writable archive directory, and an
143
+ optional `now` clock for deterministic expiry decisions. The system clock is the
144
+ default. Neither option enables archival by itself; only calling `put` writes.
@@ -0,0 +1,66 @@
1
+ import { type Digest, type SemanticSnapshot } from '@variance-authority/core';
2
+ import type { ScenarioDefinitionData, ScenarioExecutionData, ScenarioRun } from './contract.js';
3
+ export interface ScenarioArchiveAddress {
4
+ readonly project: string;
5
+ readonly run: string;
6
+ readonly scenario: string;
7
+ readonly execution: string;
8
+ readonly precondition: string;
9
+ readonly profile: string;
10
+ readonly attempt: string;
11
+ }
12
+ export type ScenarioArchiveAdmission = {
13
+ readonly kind: 'admitted';
14
+ } | {
15
+ readonly kind: 'refused';
16
+ readonly because: string;
17
+ };
18
+ export interface ScenarioArchivePolicy {
19
+ /** ISO-8601 instant after which the evidence is unavailable. */
20
+ readonly retainUntil: string;
21
+ /** Human-readable declaration of who may read the retained semantic text. */
22
+ readonly access: string;
23
+ /** Human-readable declaration of how retained evidence is deleted. */
24
+ readonly deletion: string;
25
+ readonly admit: (snapshot: SemanticSnapshot) => ScenarioArchiveAdmission;
26
+ }
27
+ /** Retention and identity envelope for one admitted, witnessed scenario execution. */
28
+ export interface ScenarioArchiveManifest {
29
+ readonly archiveVersion: 1;
30
+ readonly address: ScenarioArchiveAddress;
31
+ readonly retainUntil: string;
32
+ readonly access: string;
33
+ readonly deletion: string;
34
+ readonly definition: ScenarioDefinitionData;
35
+ readonly execution: ScenarioExecutionData;
36
+ readonly snapshots: readonly Digest[];
37
+ }
38
+ export type ArchivedScenario = {
39
+ readonly kind: 'observed';
40
+ readonly run: ScenarioRun;
41
+ readonly manifest: ScenarioArchiveManifest;
42
+ } | {
43
+ readonly kind: 'unobserved';
44
+ readonly because: string;
45
+ };
46
+ export interface ScenarioArchiveCollection {
47
+ readonly manifests: number;
48
+ readonly snapshots: number;
49
+ }
50
+ export interface ScenarioArchive {
51
+ put(address: ScenarioArchiveAddress, run: ScenarioRun, policy: ScenarioArchivePolicy): Promise<ScenarioArchiveManifest>;
52
+ read(address: ScenarioArchiveAddress): Promise<ArchivedScenario>;
53
+ collectExpired(): Promise<ScenarioArchiveCollection>;
54
+ }
55
+ export interface ScenarioArchiveOptions {
56
+ readonly root: string;
57
+ readonly now?: () => Date;
58
+ }
59
+ /**
60
+ * Open an opt-in, text-only archive. Nothing calls this from the default execution path.
61
+ *
62
+ * The archive accepts only `SemanticSnapshot` objects and a scenario manifest; its API has no
63
+ * raster, document, event payload, baseline, or history-row write.
64
+ */
65
+ export declare function createScenarioArchive(options: ScenarioArchiveOptions): ScenarioArchive;
66
+ //# sourceMappingURL=archive.d.ts.map
@@ -0,0 +1,222 @@
1
+ import { canonicalize, digestValue, } from '@variance-authority/core';
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { checkedDefinition, checkedExecution, checkedRun } from './contract.js';
6
+ import { assertScenarioRun, semanticSnapshotDigest } from './execution.js';
7
+ /**
8
+ * Open an opt-in, text-only archive. Nothing calls this from the default execution path.
9
+ *
10
+ * The archive accepts only `SemanticSnapshot` objects and a scenario manifest; its API has no
11
+ * raster, document, event payload, baseline, or history-row write.
12
+ */
13
+ export function createScenarioArchive(options) {
14
+ const root = options.root;
15
+ const now = options.now ?? (() => new Date());
16
+ const objects = join(root, 'objects');
17
+ const manifests = join(root, 'manifests');
18
+ return {
19
+ async put(address, run, policy) {
20
+ assertScenarioRun(run);
21
+ validateAddress(address, run);
22
+ const expires = parseExpiry(policy.retainUntil);
23
+ if (expires <= now().getTime()) {
24
+ throw new Error('refusing scenario archive policy whose retention has already expired');
25
+ }
26
+ requireDeclaration(policy.access, 'access');
27
+ requireDeclaration(policy.deletion, 'deletion');
28
+ const digests = observedDigests(run.execution);
29
+ for (const digest of digests) {
30
+ const snapshot = run.snapshots.get(digest);
31
+ if (snapshot === undefined) {
32
+ throw new Error(`refusing scenario archive with unavailable snapshot ${digest}`);
33
+ }
34
+ const admission = policy.admit(snapshot);
35
+ if (admission.kind === 'refused') {
36
+ throw new Error(`refusing semantic snapshot ${digest}: ${admission.because}`);
37
+ }
38
+ if (semanticSnapshotDigest(snapshot) !== digest) {
39
+ throw new Error(`refusing semantic snapshot ${digest}: its content address does not match`);
40
+ }
41
+ }
42
+ await mkdir(objects, { recursive: true });
43
+ await mkdir(manifests, { recursive: true });
44
+ for (const digest of digests) {
45
+ await writeObject(join(objects, `${fileDigest(digest)}.json`), run.snapshots.get(digest));
46
+ }
47
+ const manifest = {
48
+ archiveVersion: 1,
49
+ address,
50
+ retainUntil: policy.retainUntil,
51
+ access: policy.access,
52
+ deletion: policy.deletion,
53
+ definition: run.definition,
54
+ execution: run.execution,
55
+ snapshots: digests,
56
+ };
57
+ await writeAtomic(manifestPath(manifests, address), canonicalize(asCanonical(manifest)));
58
+ return manifest;
59
+ },
60
+ async read(address) {
61
+ const path = manifestPath(manifests, address);
62
+ const manifest = await readOptional(path);
63
+ if (manifest === undefined) {
64
+ return { kind: 'unobserved', because: 'no archived scenario exists at this address' };
65
+ }
66
+ if (parseExpiry(manifest.retainUntil) <= now().getTime()) {
67
+ return {
68
+ kind: 'unobserved',
69
+ because: `the archived scenario expired at ${manifest.retainUntil}`,
70
+ };
71
+ }
72
+ const snapshots = new Map();
73
+ for (const digest of manifest.snapshots) {
74
+ const snapshot = await readOptional(join(objects, `${fileDigest(digest)}.json`));
75
+ if (snapshot === undefined) {
76
+ return {
77
+ kind: 'unobserved',
78
+ because: `archived semantic snapshot ${digest} is unavailable`,
79
+ };
80
+ }
81
+ if (semanticSnapshotDigest(snapshot) !== digest) {
82
+ throw new Error(`archived semantic snapshot ${digest} failed its content address`);
83
+ }
84
+ snapshots.set(digest, snapshot);
85
+ }
86
+ const run = checkedRun({
87
+ definition: checkedDefinition(manifest.definition),
88
+ execution: checkedExecution(manifest.execution),
89
+ snapshots,
90
+ });
91
+ assertScenarioRun(run);
92
+ return {
93
+ kind: 'observed',
94
+ manifest,
95
+ run,
96
+ };
97
+ },
98
+ async collectExpired() {
99
+ const manifestFiles = await jsonFiles(manifests);
100
+ const retained = new Set();
101
+ let removedManifests = 0;
102
+ for (const file of manifestFiles) {
103
+ const manifest = await readRequired(join(manifests, file));
104
+ if (parseExpiry(manifest.retainUntil) <= now().getTime()) {
105
+ await rm(join(manifests, file));
106
+ removedManifests += 1;
107
+ }
108
+ else {
109
+ for (const digest of manifest.snapshots)
110
+ retained.add(digest);
111
+ }
112
+ }
113
+ let removedSnapshots = 0;
114
+ for (const file of await jsonFiles(objects)) {
115
+ const snapshot = await readRequired(join(objects, file));
116
+ const digest = semanticSnapshotDigest(snapshot);
117
+ if (!retained.has(digest)) {
118
+ await rm(join(objects, file));
119
+ removedSnapshots += 1;
120
+ }
121
+ }
122
+ return { manifests: removedManifests, snapshots: removedSnapshots };
123
+ },
124
+ };
125
+ }
126
+ function observedDigests(execution) {
127
+ return [...new Set(execution.frames.flatMap((frame) => frame.outcome.kind === 'observed' ? [frame.outcome.snapshot] : []))].sort(compare);
128
+ }
129
+ function validateAddress(address, run) {
130
+ const mismatches = [
131
+ address.scenario === run.definition.id ? undefined : 'scenario',
132
+ address.execution === run.execution.id ? undefined : 'execution',
133
+ address.precondition === run.execution.precondition.id ? undefined : 'precondition',
134
+ address.profile === run.execution.profile ? undefined : 'profile',
135
+ ].filter((field) => field !== undefined);
136
+ if (mismatches.length > 0) {
137
+ throw new Error(`refusing scenario archive address with mismatched ${mismatches.join(', ')}`);
138
+ }
139
+ for (const [field, value] of Object.entries(address))
140
+ requireDeclaration(value, field);
141
+ }
142
+ function parseExpiry(value) {
143
+ const timestamp = Date.parse(value);
144
+ if (!Number.isFinite(timestamp))
145
+ throw new Error(`invalid scenario retention instant: ${value}`);
146
+ return timestamp;
147
+ }
148
+ function requireDeclaration(value, what) {
149
+ if (value.trim() === '')
150
+ throw new Error(`scenario archive ${what} must not be empty`);
151
+ }
152
+ function manifestPath(root, address) {
153
+ return join(root, `${fileDigest(digestValue(asCanonical(address)))}.json`);
154
+ }
155
+ function fileDigest(digest) {
156
+ return digest.replace(':', '-');
157
+ }
158
+ function asCanonical(value) {
159
+ return value;
160
+ }
161
+ async function writeObject(path, snapshot) {
162
+ const content = canonicalize(asCanonical(snapshot));
163
+ const existing = await readText(path);
164
+ if (existing !== undefined) {
165
+ if (existing !== content)
166
+ throw new Error(`content-address collision at ${path}`);
167
+ return;
168
+ }
169
+ await writeAtomic(path, content);
170
+ }
171
+ async function writeAtomic(path, content) {
172
+ await mkdir(dirname(path), { recursive: true });
173
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
174
+ await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
175
+ try {
176
+ await rename(temporary, path);
177
+ }
178
+ catch (error) {
179
+ await rm(temporary, { force: true });
180
+ throw error;
181
+ }
182
+ }
183
+ async function readOptional(path) {
184
+ const text = await readText(path);
185
+ return text === undefined ? undefined : JSON.parse(text);
186
+ }
187
+ async function readRequired(path) {
188
+ const found = await readOptional(path);
189
+ if (found === undefined)
190
+ throw new Error(`archive entry disappeared while reading ${path}`);
191
+ return found;
192
+ }
193
+ async function readText(path) {
194
+ try {
195
+ return await readFile(path, 'utf8');
196
+ }
197
+ catch (error) {
198
+ if (isMissing(error))
199
+ return undefined;
200
+ throw error;
201
+ }
202
+ }
203
+ async function jsonFiles(path) {
204
+ try {
205
+ return (await readdir(path)).filter((file) => file.endsWith('.json')).sort(compare);
206
+ }
207
+ catch (error) {
208
+ if (isMissing(error))
209
+ return [];
210
+ throw error;
211
+ }
212
+ }
213
+ function isMissing(error) {
214
+ return (typeof error === 'object' &&
215
+ error !== null &&
216
+ 'code' in error &&
217
+ error.code === 'ENOENT');
218
+ }
219
+ function compare(left, right) {
220
+ return left < right ? -1 : left > right ? 1 : 0;
221
+ }
222
+ //# sourceMappingURL=archive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"archive.js","sourceRoot":"","sources":["../src/archive.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,WAAW,GAIZ,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACnF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAMzC,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChF,OAAO,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AA8D3E;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,OAA+B;IACnE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1B,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAC9C,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;IACtC,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;IAE1C,OAAO;QACL,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,MAAM;YAC5B,iBAAiB,CAAC,GAAG,CAAC,CAAC;YACvB,eAAe,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;YAC9B,MAAM,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;YAChD,IAAI,OAAO,IAAI,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC;gBAC/B,MAAM,IAAI,KAAK,CAAC,sEAAsE,CAAC,CAAC;YAC1F,CAAC;YACD,kBAAkB,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAC5C,kBAAkB,CAAC,MAAM,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;YAEhD,MAAM,OAAO,GAAG,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;YAC/C,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;gBAC7B,MAAM,QAAQ,GAAG,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;gBAC3C,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,MAAM,IAAI,KAAK,CAAC,uDAAuD,MAAM,EAAE,CAAC,CAAC;gBACnF,CAAC;gBACD,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;gBACzC,IAAI,SAAS,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;oBACjC,MAAM,IAAI,KAAK,CAAC,8BAA8B,MAAM,KAAK,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC;gBAChF,CAAC;gBACD,IAAI,sBAAsB,CAAC,QAAQ,CAAC,KAAK,MAAM,EAAE,CAAC;oBAChD,MAAM,IAAI,KAAK,CAAC,8BAA8B,MAAM,sCAAsC,CAAC,CAAC;gBAC9F,CAAC;YACH,CAAC;YAED,MAAM,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAC1C,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAC5C,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;gBAC7B,MAAM,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAE,CAAC,CAAC;YAC7F,CAAC;YAED,MAAM,QAAQ,GAA4B;gBACxC,cAAc,EAAE,CAAC;gBACjB,OAAO;gBACP,WAAW,EAAE,MAAM,CAAC,WAAW;gBAC/B,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,UAAU,EAAE,GAAG,CAAC,UAAU;gBAC1B,SAAS,EAAE,GAAG,CAAC,SAAS;gBACxB,SAAS,EAAE,OAAO;aACnB,CAAC;YACF,MAAM,WAAW,CAAC,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,EAAE,YAAY,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;YACzF,OAAO,QAAQ,CAAC;QAClB,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,OAAO;YAChB,MAAM,IAAI,GAAG,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;YAC9C,MAAM,QAAQ,GAAG,MAAM,YAAY,CAA0B,IAAI,CAAC,CAAC;YACnE,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;gBAC3B,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,EAAE,6CAA6C,EAAE,CAAC;YACxF,CAAC;YACD,IAAI,WAAW,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC;gBACzD,OAAO;oBACL,IAAI,EAAE,YAAY;oBAClB,OAAO,EAAE,oCAAoC,QAAQ,CAAC,WAAW,EAAE;iBACpE,CAAC;YACJ,CAAC;YAED,MAAM,SAAS,GAAG,IAAI,GAAG,EAA4B,CAAC;YACtD,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,SAAS,EAAE,CAAC;gBACxC,MAAM,QAAQ,GAAG,MAAM,YAAY,CACjC,IAAI,CAAC,OAAO,EAAE,GAAG,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAC5C,CAAC;gBACF,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,OAAO;wBACL,IAAI,EAAE,YAAY;wBAClB,OAAO,EAAE,8BAA8B,MAAM,iBAAiB;qBAC/D,CAAC;gBACJ,CAAC;gBACD,IAAI,sBAAsB,CAAC,QAAQ,CAAC,KAAK,MAAM,EAAE,CAAC;oBAChD,MAAM,IAAI,KAAK,CAAC,8BAA8B,MAAM,6BAA6B,CAAC,CAAC;gBACrF,CAAC;gBACD,SAAS,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAClC,CAAC;YAED,MAAM,GAAG,GAAG,UAAU,CAAC;gBACrB,UAAU,EAAE,iBAAiB,CAAC,QAAQ,CAAC,UAAU,CAAC;gBAClD,SAAS,EAAE,gBAAgB,CAAC,QAAQ,CAAC,SAAS,CAAC;gBAC/C,SAAS;aACV,CAAC,CAAC;YACH,iBAAiB,CAAC,GAAG,CAAC,CAAC;YACvB,OAAO;gBACL,IAAI,EAAE,UAAU;gBAChB,QAAQ;gBACR,GAAG;aACJ,CAAC;QACJ,CAAC;QAED,KAAK,CAAC,cAAc;YAClB,MAAM,aAAa,GAAG,MAAM,SAAS,CAAC,SAAS,CAAC,CAAC;YACjD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAC;YACnC,IAAI,gBAAgB,GAAG,CAAC,CAAC;YAEzB,KAAK,MAAM,IAAI,IAAI,aAAa,EAAE,CAAC;gBACjC,MAAM,QAAQ,GAAG,MAAM,YAAY,CAA0B,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;gBACpF,IAAI,WAAW,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,GAAG,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC;oBACzD,MAAM,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;oBAChC,gBAAgB,IAAI,CAAC,CAAC;gBACxB,CAAC;qBAAM,CAAC;oBACN,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,SAAS;wBAAE,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;gBAChE,CAAC;YACH,CAAC;YAED,IAAI,gBAAgB,GAAG,CAAC,CAAC;YACzB,KAAK,MAAM,IAAI,IAAI,MAAM,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC5C,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAmB,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;gBAC3E,MAAM,MAAM,GAAG,sBAAsB,CAAC,QAAQ,CAAC,CAAC;gBAChD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;oBAC1B,MAAM,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;oBAC9B,gBAAgB,IAAI,CAAC,CAAC;gBACxB,CAAC;YACH,CAAC;YACD,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,SAAS,EAAE,gBAAgB,EAAE,CAAC;QACtE,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CAAC,SAAgC;IACvD,OAAO,CAAC,GAAG,IAAI,GAAG,CAChB,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE,CACjC,KAAK,CAAC,OAAO,CAAC,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAClE,CACF,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AACnB,CAAC;AAED,SAAS,eAAe,CAAC,OAA+B,EAAE,GAAgB;IACxE,MAAM,UAAU,GAAG;QACjB,OAAO,CAAC,QAAQ,KAAK,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU;QAC/D,OAAO,CAAC,SAAS,KAAK,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW;QAChE,OAAO,CAAC,YAAY,KAAK,GAAG,CAAC,SAAS,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,cAAc;QACnF,OAAO,CAAC,OAAO,KAAK,GAAG,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS;KAClE,CAAC,MAAM,CAAC,CAAC,KAAK,EAAmB,EAAE,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC;IAC1D,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,qDAAqD,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAChG,CAAC;IACD,KAAK,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,kBAAkB,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;AACzF,CAAC;AAED,SAAS,WAAW,CAAC,KAAa;IAChC,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,KAAK,EAAE,CAAC,CAAC;IACjG,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,kBAAkB,CAAC,KAAa,EAAE,IAAY;IACrD,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,oBAAoB,IAAI,oBAAoB,CAAC,CAAC;AACzF,CAAC;AAED,SAAS,YAAY,CAAC,IAAY,EAAE,OAA+B;IACjE,OAAO,IAAI,CAAC,IAAI,EAAE,GAAG,UAAU,CAAC,WAAW,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;AAC7E,CAAC;AAED,SAAS,UAAU,CAAC,MAAc;IAChC,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;AAClC,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,OAAO,KAAuB,CAAC;AACjC,CAAC;AAED,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,QAA0B;IACjE,MAAM,OAAO,GAAG,YAAY,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC;IACpD,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;IACtC,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,IAAI,QAAQ,KAAK,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,IAAI,EAAE,CAAC,CAAC;QAClF,OAAO;IACT,CAAC;IACD,MAAM,WAAW,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AACnC,CAAC;AAED,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,OAAe;IACtD,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,MAAM,SAAS,GAAG,GAAG,IAAI,IAAI,OAAO,CAAC,GAAG,IAAI,UAAU,EAAE,MAAM,CAAC;IAC/D,MAAM,SAAS,CAAC,SAAS,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACtE,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IAChC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,EAAE,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACrC,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED,KAAK,UAAU,YAAY,CAAI,IAAY;IACzC,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;IAClC,OAAO,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAO,CAAC;AAClE,CAAC;AAED,KAAK,UAAU,YAAY,CAAI,IAAY;IACzC,MAAM,KAAK,GAAG,MAAM,YAAY,CAAI,IAAI,CAAC,CAAC;IAC1C,IAAI,KAAK,KAAK,SAAS;QAAE,MAAM,IAAI,KAAK,CAAC,2CAA2C,IAAI,EAAE,CAAC,CAAC;IAC5F,OAAO,KAAK,CAAC;AACf,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,IAAY;IAClC,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,SAAS,CAAC,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QACvC,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED,KAAK,UAAU,SAAS,CAAC,IAAY;IACnC,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtF,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,SAAS,CAAC,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAChC,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,KAAK,KAAK,IAAI;QACd,MAAM,IAAI,KAAK;QACd,KAAoC,CAAC,IAAI,KAAK,QAAQ,CACxD,CAAC;AACJ,CAAC;AAED,SAAS,OAAO,CAAC,IAAY,EAAE,KAAa;IAC1C,OAAO,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAClD,CAAC","sourcesContent":["import {\n canonicalize,\n digestValue,\n type CanonicalValue,\n type Digest,\n type SemanticSnapshot,\n} from '@variance-authority/core';\nimport { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport { randomUUID } from 'node:crypto';\nimport type {\n ScenarioDefinitionData,\n ScenarioExecutionData,\n ScenarioRun,\n} from './contract.js';\nimport { checkedDefinition, checkedExecution, checkedRun } from './contract.js';\nimport { assertScenarioRun, semanticSnapshotDigest } from './execution.js';\n\nexport interface ScenarioArchiveAddress {\n readonly project: string;\n readonly run: string;\n readonly scenario: string;\n readonly execution: string;\n readonly precondition: string;\n readonly profile: string;\n readonly attempt: string;\n}\n\nexport type ScenarioArchiveAdmission =\n | { readonly kind: 'admitted' }\n | { readonly kind: 'refused'; readonly because: string };\n\nexport interface ScenarioArchivePolicy {\n /** ISO-8601 instant after which the evidence is unavailable. */\n readonly retainUntil: string;\n /** Human-readable declaration of who may read the retained semantic text. */\n readonly access: string;\n /** Human-readable declaration of how retained evidence is deleted. */\n readonly deletion: string;\n readonly admit: (snapshot: SemanticSnapshot) => ScenarioArchiveAdmission;\n}\n\n/** Retention and identity envelope for one admitted, witnessed scenario execution. */\nexport interface ScenarioArchiveManifest {\n readonly archiveVersion: 1;\n readonly address: ScenarioArchiveAddress;\n readonly retainUntil: string;\n readonly access: string;\n readonly deletion: string;\n readonly definition: ScenarioDefinitionData;\n readonly execution: ScenarioExecutionData;\n readonly snapshots: readonly Digest[];\n}\n\nexport type ArchivedScenario =\n | { readonly kind: 'observed'; readonly run: ScenarioRun; readonly manifest: ScenarioArchiveManifest }\n | { readonly kind: 'unobserved'; readonly because: string };\n\nexport interface ScenarioArchiveCollection {\n readonly manifests: number;\n readonly snapshots: number;\n}\n\nexport interface ScenarioArchive {\n put(\n address: ScenarioArchiveAddress,\n run: ScenarioRun,\n policy: ScenarioArchivePolicy,\n ): Promise<ScenarioArchiveManifest>;\n read(address: ScenarioArchiveAddress): Promise<ArchivedScenario>;\n collectExpired(): Promise<ScenarioArchiveCollection>;\n}\n\nexport interface ScenarioArchiveOptions {\n readonly root: string;\n readonly now?: () => Date;\n}\n\n/**\n * Open an opt-in, text-only archive. Nothing calls this from the default execution path.\n *\n * The archive accepts only `SemanticSnapshot` objects and a scenario manifest; its API has no\n * raster, document, event payload, baseline, or history-row write.\n */\nexport function createScenarioArchive(options: ScenarioArchiveOptions): ScenarioArchive {\n const root = options.root;\n const now = options.now ?? (() => new Date());\n const objects = join(root, 'objects');\n const manifests = join(root, 'manifests');\n\n return {\n async put(address, run, policy) {\n assertScenarioRun(run);\n validateAddress(address, run);\n const expires = parseExpiry(policy.retainUntil);\n if (expires <= now().getTime()) {\n throw new Error('refusing scenario archive policy whose retention has already expired');\n }\n requireDeclaration(policy.access, 'access');\n requireDeclaration(policy.deletion, 'deletion');\n\n const digests = observedDigests(run.execution);\n for (const digest of digests) {\n const snapshot = run.snapshots.get(digest);\n if (snapshot === undefined) {\n throw new Error(`refusing scenario archive with unavailable snapshot ${digest}`);\n }\n const admission = policy.admit(snapshot);\n if (admission.kind === 'refused') {\n throw new Error(`refusing semantic snapshot ${digest}: ${admission.because}`);\n }\n if (semanticSnapshotDigest(snapshot) !== digest) {\n throw new Error(`refusing semantic snapshot ${digest}: its content address does not match`);\n }\n }\n\n await mkdir(objects, { recursive: true });\n await mkdir(manifests, { recursive: true });\n for (const digest of digests) {\n await writeObject(join(objects, `${fileDigest(digest)}.json`), run.snapshots.get(digest)!);\n }\n\n const manifest: ScenarioArchiveManifest = {\n archiveVersion: 1,\n address,\n retainUntil: policy.retainUntil,\n access: policy.access,\n deletion: policy.deletion,\n definition: run.definition,\n execution: run.execution,\n snapshots: digests,\n };\n await writeAtomic(manifestPath(manifests, address), canonicalize(asCanonical(manifest)));\n return manifest;\n },\n\n async read(address) {\n const path = manifestPath(manifests, address);\n const manifest = await readOptional<ScenarioArchiveManifest>(path);\n if (manifest === undefined) {\n return { kind: 'unobserved', because: 'no archived scenario exists at this address' };\n }\n if (parseExpiry(manifest.retainUntil) <= now().getTime()) {\n return {\n kind: 'unobserved',\n because: `the archived scenario expired at ${manifest.retainUntil}`,\n };\n }\n\n const snapshots = new Map<Digest, SemanticSnapshot>();\n for (const digest of manifest.snapshots) {\n const snapshot = await readOptional<SemanticSnapshot>(\n join(objects, `${fileDigest(digest)}.json`),\n );\n if (snapshot === undefined) {\n return {\n kind: 'unobserved',\n because: `archived semantic snapshot ${digest} is unavailable`,\n };\n }\n if (semanticSnapshotDigest(snapshot) !== digest) {\n throw new Error(`archived semantic snapshot ${digest} failed its content address`);\n }\n snapshots.set(digest, snapshot);\n }\n\n const run = checkedRun({\n definition: checkedDefinition(manifest.definition),\n execution: checkedExecution(manifest.execution),\n snapshots,\n });\n assertScenarioRun(run);\n return {\n kind: 'observed',\n manifest,\n run,\n };\n },\n\n async collectExpired() {\n const manifestFiles = await jsonFiles(manifests);\n const retained = new Set<Digest>();\n let removedManifests = 0;\n\n for (const file of manifestFiles) {\n const manifest = await readRequired<ScenarioArchiveManifest>(join(manifests, file));\n if (parseExpiry(manifest.retainUntil) <= now().getTime()) {\n await rm(join(manifests, file));\n removedManifests += 1;\n } else {\n for (const digest of manifest.snapshots) retained.add(digest);\n }\n }\n\n let removedSnapshots = 0;\n for (const file of await jsonFiles(objects)) {\n const snapshot = await readRequired<SemanticSnapshot>(join(objects, file));\n const digest = semanticSnapshotDigest(snapshot);\n if (!retained.has(digest)) {\n await rm(join(objects, file));\n removedSnapshots += 1;\n }\n }\n return { manifests: removedManifests, snapshots: removedSnapshots };\n },\n };\n}\n\nfunction observedDigests(execution: ScenarioExecutionData): readonly Digest[] {\n return [...new Set(\n execution.frames.flatMap((frame) =>\n frame.outcome.kind === 'observed' ? [frame.outcome.snapshot] : [],\n ),\n )].sort(compare);\n}\n\nfunction validateAddress(address: ScenarioArchiveAddress, run: ScenarioRun): void {\n const mismatches = [\n address.scenario === run.definition.id ? undefined : 'scenario',\n address.execution === run.execution.id ? undefined : 'execution',\n address.precondition === run.execution.precondition.id ? undefined : 'precondition',\n address.profile === run.execution.profile ? undefined : 'profile',\n ].filter((field): field is string => field !== undefined);\n if (mismatches.length > 0) {\n throw new Error(`refusing scenario archive address with mismatched ${mismatches.join(', ')}`);\n }\n for (const [field, value] of Object.entries(address)) requireDeclaration(value, field);\n}\n\nfunction parseExpiry(value: string): number {\n const timestamp = Date.parse(value);\n if (!Number.isFinite(timestamp)) throw new Error(`invalid scenario retention instant: ${value}`);\n return timestamp;\n}\n\nfunction requireDeclaration(value: string, what: string): void {\n if (value.trim() === '') throw new Error(`scenario archive ${what} must not be empty`);\n}\n\nfunction manifestPath(root: string, address: ScenarioArchiveAddress): string {\n return join(root, `${fileDigest(digestValue(asCanonical(address)))}.json`);\n}\n\nfunction fileDigest(digest: Digest): string {\n return digest.replace(':', '-');\n}\n\nfunction asCanonical(value: unknown): CanonicalValue {\n return value as CanonicalValue;\n}\n\nasync function writeObject(path: string, snapshot: SemanticSnapshot): Promise<void> {\n const content = canonicalize(asCanonical(snapshot));\n const existing = await readText(path);\n if (existing !== undefined) {\n if (existing !== content) throw new Error(`content-address collision at ${path}`);\n return;\n }\n await writeAtomic(path, content);\n}\n\nasync function writeAtomic(path: string, content: string): Promise<void> {\n await mkdir(dirname(path), { recursive: true });\n const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;\n await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });\n try {\n await rename(temporary, path);\n } catch (error) {\n await rm(temporary, { force: true });\n throw error;\n }\n}\n\nasync function readOptional<T>(path: string): Promise<T | undefined> {\n const text = await readText(path);\n return text === undefined ? undefined : (JSON.parse(text) as T);\n}\n\nasync function readRequired<T>(path: string): Promise<T> {\n const found = await readOptional<T>(path);\n if (found === undefined) throw new Error(`archive entry disappeared while reading ${path}`);\n return found;\n}\n\nasync function readText(path: string): Promise<string | undefined> {\n try {\n return await readFile(path, 'utf8');\n } catch (error) {\n if (isMissing(error)) return undefined;\n throw error;\n }\n}\n\nasync function jsonFiles(path: string): Promise<readonly string[]> {\n try {\n return (await readdir(path)).filter((file) => file.endsWith('.json')).sort(compare);\n } catch (error) {\n if (isMissing(error)) return [];\n throw error;\n }\n}\n\nfunction isMissing(error: unknown): boolean {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'code' in error &&\n (error as { readonly code?: string }).code === 'ENOENT'\n );\n}\n\nfunction compare(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n"]}
@@ -0,0 +1,4 @@
1
+ import type { ScenarioAssessment, ScenarioRun } from './contract.js';
2
+ /** Compare two witnessed paths without writing a verdict or mutating either execution. */
3
+ export declare function assessScenarios(left: ScenarioRun, right: ScenarioRun): ScenarioAssessment;
4
+ //# sourceMappingURL=assessment.d.ts.map