@integraledger/lcp-verify 0.9.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/src/steps.ts ADDED
@@ -0,0 +1,517 @@
1
+ /**
2
+ * The verification walk as an ordered table of typed steps. At STRUCTURAL depth each step is
3
+ * presence/absence → outcome plus the pure checks it can do over supplied inputs (no live ports):
4
+ * `proved | failed(haltClass) | indeterminate | not-attempted(depth)`. A step whose inputs are absent is
5
+ * `not-attempted(<why>)` — coverage is honest depth, never a silent pass. These outcomes are depth-agnostic;
6
+ * `verified` is computed FROM them by `computeVerified` (index.ts); the live-port gathering that raises
7
+ * coverage to mechanical depth is the buyer gate's.
8
+ *
9
+ * Three rules hold across every step, and all three exist so the walk can never flatter a record:
10
+ * - ABSENT INPUTS NEVER PROVE. An empty authority chain, an acceptance with no verifier to check its
11
+ * signature, an identity with no resolution chain — each is `not-attempted`, never `proved`.
12
+ * - CONTRADICTIONS FAIL, GAPS DO NOT. `failed` is reserved for a record that contradicts itself (a
13
+ * fingerprint that does not match, a link that widens its parent, a signature that does not verify);
14
+ * it impeaches `supportedClass` to TC-0. Missing evidence is a gap: `not-attempted`, which leaves
15
+ * `verified` false without impeaching a record that simply did not carry that rung.
16
+ * - EVERY STEP IS TOTAL. Steps read their slots through `present` and shape-check before dereferencing,
17
+ * because the callers they exist for are untyped — a foreign conformance subject, an unvalidated
18
+ * intake. A malformed slot reads out as a gap, never as a `failed`: the caller's shape error says
19
+ * nothing about whether the record is self-consistent, and a walk that throws cannot report the
20
+ * malformation it was handed.
21
+ *
22
+ * The authority step is the load-bearing one. Every link must have been PERMITTED by its parent (ATA-3 —
23
+ * the parent was delegable, it had depth left, and the link stated a depth that fits beneath what the
24
+ * parent held; an unstated depth is unbounded and therefore an escalation like any other), must ATTENUATE
25
+ * (`authority.isWithin`), and must be UNREVOKED and UNEXPIRED as-of settlement. Each failure is
26
+ * `failed(verification-failure)`, never `proved`, so the walk can never affirm a forged chain — whether the
27
+ * forgery widened the bounds or the authority to delegate them. These are the same four gates
28
+ * `authority.linkAttenuates` enforces at issuance; producer and verifier must not diverge, and
29
+ * `vectors/{authority/link-attenuates,verify/authority-walk}.json` pin both halves against each other.
30
+ *
31
+ * `authorityStep` reads a FLATTENED chain, every field of which is a derived fact it must take on trust.
32
+ * `authorityStepFromWalk` is the path that removes that trust — it consumes `authority.walkChain`'s
33
+ * readout directly, so the caller never flattens anything. Both are exported: the flattened door stays
34
+ * open because a foreign conformance subject may legitimately derive its links some other way, and this
35
+ * module's totality rule means such a caller gets an honest readout rather than a compile wall.
36
+ */
37
+ import {
38
+ type Bounds,
39
+ type ChainWalkResult,
40
+ commitmentWithinLeaf,
41
+ type IdentityResolution,
42
+ isWithin,
43
+ type SignatureVerifier,
44
+ type SignedAcceptance,
45
+ verifyAcceptance,
46
+ } from "@integraledger/lcp-authority";
47
+ import { atrHashEquals, hashAtr } from "@integraledger/lcp-kernel";
48
+ import type { StepOutcome } from "./report.js";
49
+
50
+ /**
51
+ * One link in the ATA chain, pre-resolved to what the structural walk checks — the verification-time
52
+ * mirror of `authority.linkAttenuates`, whose four issuance gates it must all carry: the parent permitted
53
+ * delegation, the parent had depth left, this link's depth fits beneath it, and the bounds are contained.
54
+ *
55
+ * `parentDelegable` is REQUIRED, not optional, and that is the point: `GrantSubject.delegable` is
56
+ * non-delegable by default, so "the caller did not thread the flag" and "the parent refused delegation"
57
+ * would be the same absent value — the exact ambiguity that turns an unverified rung into a proved one.
58
+ * Requiring it makes the unstated case a compile error at the callsite instead of a silent pass.
59
+ */
60
+ export interface AuthorityLink {
61
+ bounds: Bounds;
62
+ parentBounds: Bounds;
63
+ /** ATA-3: did the PARENT grant permit delegation at all? A link below a non-delegable parent is forged. */
64
+ parentDelegable: boolean;
65
+ /** The parent's remaining delegation depth (0 = leaf, nothing may descend). Absent = unbounded. */
66
+ parentMaxDepth?: number;
67
+ /** This link's OWN remaining depth. It may not exceed `parentMaxDepth - 1` — a link cannot mint itself
68
+ * more latitude to delegate onward than the parent it descends from held.
69
+ *
70
+ * Absent = unbounded, which is why omitting it BELOW A DEPTH-BOUNDED PARENT is itself an escalation and
71
+ * fails. Inferring `parentMaxDepth - 1` from the parent instead would be a silent fallback, and it would
72
+ * also leave the next hop's `parentMaxDepth` unstated, disengaging this gate for the rest of the chain.
73
+ * Under an unbounded parent the link may hold any depth, including none. */
74
+ maxDepth?: number;
75
+ /** Revoked as-of settlement, from the hash-pinned status-list snapshot.
76
+ *
77
+ * REQUIRED, for the same reason `parentDelegable` is: "the flattener never consulted a status list"
78
+ * and "the walk checked the pinned snapshot and the link is unrevoked" are otherwise the same absent
79
+ * value, and one of them proves. `authority.WalkedLink` already states it always, never defaulted, so
80
+ * a walk-fed caller satisfies this for free and only a hand-flattener feels it — which is the point.
81
+ *
82
+ * The compile error is not the whole gate, and must not be relied on as one — at runtime an absent value reads as
83
+ * unrevoked and PROVED, on the grounds that the corpus pinned that reading cross-implementation. The
84
+ * corpus was the thing to change, and on 2026-08-08 it was: absence now reads `not-attempted` with
85
+ * depth `no-revocation-stated`, and a non-boolean reads `malformed-authority-chain`. Type and runtime
86
+ * agree, so the untyped caller this step exists for gets the same answer a typed one is prevented from
87
+ * asking. */
88
+ revoked: boolean;
89
+ /** Temporally active as-of settlement (expiry). REQUIRED on the same grounds as `revoked` — an
90
+ * unstated liveness and a checked-and-live one must not be the same value. Runtime matches: absent is
91
+ * `not-attempted` with depth `no-liveness-stated`, its OWN token rather than revocation's, so a report
92
+ * never describes an expiry gap as something about revocation. */
93
+ active: boolean;
94
+ }
95
+
96
+ /** Both parties' resolutions as the record's `identity` slot carries them (IDN-1/IDN-3). */
97
+ export interface RecordIdentity {
98
+ seller: IdentityResolution;
99
+ buyer: IdentityResolution;
100
+ }
101
+
102
+ /**
103
+ * Is the ATR's `terms` slot an `lcp:sha256:` REFERENCE rather than the inlined document?
104
+ *
105
+ * Narrow on purpose. Only the content-addressed form means "the document lives elsewhere and must travel
106
+ * with the package": an inlined string IS the document, and `lcp:ipfs:`/`lcp:ar:` are not what
107
+ * `kernel.assemble` emits for the terms slot. A non-string slot is not a ref — it reads as absent here and
108
+ * is the fingerprint step's business, not this one's.
109
+ */
110
+ function isRefTerms(terms: unknown): boolean {
111
+ return typeof terms === "string" && terms.startsWith("lcp:sha256:");
112
+ }
113
+
114
+ /**
115
+ * RCS-4's required evidence roles — the specification's own enumeration of a self-contained package: the terms
116
+ * artifact and fingerprint; the acceptance signature and its authority chain (ATA-6); the spend-authorization
117
+ * artifact or a verifiable reference to it (ASP-5); the identity attestations relied on and their assurance
118
+ * levels (IDN-3); the settlement reference and weld; timestamps. Its own conditional clause — fulfillment and
119
+ * order-state, "where performance is disputed" (OPS-2) — is conditional and therefore NOT required here.
120
+ */
121
+ export const RCS4_REQUIRED_ROLES: readonly string[] = [
122
+ "atr",
123
+ "signed acceptance",
124
+ "authority chain",
125
+ "spend artifact",
126
+ "attestation",
127
+ "settlement",
128
+ "weld",
129
+ "timestamp",
130
+ ];
131
+
132
+ /**
133
+ * Is a slot supplied at all? `=== undefined` is not enough over an untyped caller — a foreign conformance
134
+ * subject or an unvalidated intake can hand in an explicitly `null` slot, which is *present* to that check
135
+ * and then dereferences to a TypeError. `typeof null === "object"` slips it past shape guards too. Every
136
+ * step reads its own slots through this, so a malformed record gets an honest `not-attempted` readout
137
+ * rather than crashing the walk that exists to report on it. Typed callers cannot express the null case.
138
+ */
139
+ function present<T>(value: T | null | undefined): value is T {
140
+ return value !== undefined && value !== null;
141
+ }
142
+
143
+ /** A bounds-shaped slot: an object `isWithin` can walk. Mirrors `authority.walkableBounds`'s own gate, which
144
+ * exists for the same reason — `isWithin` reads its arguments with `Object.keys`, which THROWS on `undefined`
145
+ * and `null`. `typeof` alone is not that check (`typeof null === "object"`), and an array is not it either:
146
+ * `Object.keys([])` answers indices, so an array would read as a bounds with no dimensions instead of being
147
+ * refused. Only the shape is screened here — UNKNOWN keys are `isWithin`'s fail-closed refusal to make, not
148
+ * a shape gap for this step to pre-empt. */
149
+ function boundsShaped(value: unknown): value is Bounds {
150
+ return typeof value === "object" && value !== null && !Array.isArray(value);
151
+ }
152
+
153
+ /** A string that actually STATES something. `present` is not enough where the value IS the claim: `""` is
154
+ * a present string asserting nothing, and a walk that accepts it reports a fact nobody supplied. Blank
155
+ * runs count as empty — whitespace is not a subject and not a resolution method. */
156
+ function nonBlank(value: unknown): boolean {
157
+ return typeof value === "string" && value.trim().length > 0;
158
+ }
159
+
160
+ /** Recompute the fingerprint over the retrieved ATR bytes and compare to what the settlement committed. */
161
+ export async function fingerprintStep(
162
+ atrBytes: Uint8Array | undefined,
163
+ settledAtrHash: string | undefined,
164
+ ): Promise<StepOutcome> {
165
+ if (!present(atrBytes)) return { status: "indeterminate" }; // unretrievable ATR — not a failure
166
+ if (!present(settledAtrHash))
167
+ return { status: "not-attempted", depth: "no-settled-hash" };
168
+ const recomputed = await hashAtr(atrBytes);
169
+ // Decoded-byte comparison per LCP §2.5. This is the rung that proves the settlement committed to THIS
170
+ // document, so a comparison that could answer `true` for two malformed strings is the wrong primitive.
171
+ return atrHashEquals(recomputed, settledAtrHash)
172
+ ? { status: "proved" }
173
+ : { status: "failed", haltClass: "verification-failure" };
174
+ }
175
+
176
+ /** Settlement enumeration across supplied forward-indexable bindings (multiply-settled flag). */
177
+ export function settlementStep(
178
+ settlements: unknown[] | undefined,
179
+ ): StepOutcome {
180
+ if (!present(settlements))
181
+ return { status: "not-attempted", depth: "no-enumeration-port" };
182
+ if (settlements.length === 0)
183
+ return { status: "not-attempted", depth: "no-settlement-found" };
184
+ return { status: "proved" };
185
+ }
186
+
187
+ /**
188
+ * TRM-6: the buyer's acceptance over the fingerprint, SIGNED and TIMESTAMPED. The signature is checked
189
+ * through `authority.verifyAcceptance` over the injected `SignatureVerifier` port (the EVM implementation
190
+ * — EOA EIP-191/712 and smart-account ERC-1271/6492 — is `binding-evm-common`'s).
191
+ *
192
+ * WITHOUT A VERIFIER THIS STEP CANNOT PROVE. A signature nobody checked is not evidence of a signature, so
193
+ * an acceptance presented with no verifier is `not-attempted("no-signature-verifier")` — the honest readout
194
+ * of "this record carries an acceptance whose cryptography this verifier was not equipped to check".
195
+ * `signedAt` needs no separate gate: `SignedAcceptance` requires it, so an untimestamped acceptance is
196
+ * unrepresentable rather than rejected at runtime — illegal states are not constructible.
197
+ */
198
+ export async function acceptanceStep(
199
+ acceptance: SignedAcceptance | undefined,
200
+ settledAtrHash: string | undefined,
201
+ verifier: SignatureVerifier | undefined,
202
+ ): Promise<StepOutcome> {
203
+ if (!present(acceptance))
204
+ return { status: "not-attempted", depth: "no-acceptance" };
205
+ if (!present(settledAtrHash))
206
+ return { status: "not-attempted", depth: "no-settled-hash" };
207
+ if (!present(verifier))
208
+ return { status: "not-attempted", depth: "no-signature-verifier" };
209
+ const outcome = await verifyAcceptance(acceptance, settledAtrHash, verifier);
210
+ return "refused" in outcome
211
+ ? { status: "failed", haltClass: outcome.haltClass }
212
+ : { status: "proved" };
213
+ }
214
+
215
+ /** Walk the ATA chain: every link attenuates (isWithin), is unrevoked, and is unexpired as-of settlement. */
216
+ export function authorityStep(chain: AuthorityLink[] | undefined): StepOutcome {
217
+ // A non-array in the slot is not a chain — `.length` on it is meaningless and `for…of` throws.
218
+ if (!present(chain) || !Array.isArray(chain))
219
+ return { status: "not-attempted", depth: "no-authority-chain" };
220
+ // An empty chain walks nothing. Proving it would let a record with no delegated authority whatsoever
221
+ // clear the ATA rung — the gap that let placeholder chains carry a class they never earned.
222
+ if (chain.length === 0)
223
+ return { status: "not-attempted", depth: "empty-authority-chain" };
224
+ for (const link of chain) {
225
+ // An element that is not a link makes the chain UNWALKABLE. That is a gap, not a contradiction: the
226
+ // caller handed over something malformed, which says nothing about whether the record is self-
227
+ // consistent. Reporting it as `failed` would impeach a record to TC-0 over the caller's shape error.
228
+ if (!present(link) || typeof link !== "object")
229
+ return { status: "not-attempted", depth: "malformed-authority-chain" };
230
+ // ATA-3, gate one: the parent must have permitted delegation at all. Impeccably attenuated bounds
231
+ // re-issued by a holder who was never authorized to re-issue them are still a forged link.
232
+ if (!link.parentDelegable)
233
+ return { status: "failed", haltClass: "verification-failure" };
234
+ // ATA-3, gate two: depth. A depth-exhausted parent admits no link below it, and a link may not mint
235
+ // itself more onward-delegation latitude than the parent held (`isWithin` governs bounds, not depth).
236
+ // An UNSTATED child depth means unbounded, so under a bounded parent it is an escalation like any
237
+ // other — the same reason `parentDelegable` is required rather than defaulted.
238
+ if (present(link.parentMaxDepth)) {
239
+ // FINITE, not merely present. `NaN` compares false on BOTH sides of the arithmetic below
240
+ // (`NaN <= 0`, `x > NaN - 1`), so a non-finite depth on either side disengages this gate and the
241
+ // link reaches `proved` with its depth never actually checked. It reads out as a GAP, not a
242
+ // failure, under this step's own discipline: a caller's type corruption is a malformed slot, and
243
+ // says nothing about whether the RECORD contradicts itself — the same ruling the walk makes on the
244
+ // same value (`authority.walkChainStructure`, which refuses it as `malformed-authority-chain`), so
245
+ // walk and step stay categorically aligned. JSON cannot express NaN, so no corpus vector can pin
246
+ // this; the pins are in this package's tests. Negative and fractional depths stay deliberately
247
+ // un-screened: the arithmetic already fails closed on them.
248
+ if (!Number.isFinite(link.parentMaxDepth))
249
+ return { status: "not-attempted", depth: "malformed-authority-chain" };
250
+ if (link.parentMaxDepth <= 0)
251
+ return { status: "failed", haltClass: "verification-failure" };
252
+ // Absent and non-finite part company HERE, and only here: an unstated child depth is unbounded,
253
+ // which under a bounded parent is a real escalation the record committed (`failed`), while a
254
+ // non-finite one is the caller's corruption (`not-attempted`).
255
+ if (!present(link.maxDepth))
256
+ return { status: "failed", haltClass: "verification-failure" };
257
+ if (!Number.isFinite(link.maxDepth))
258
+ return { status: "not-attempted", depth: "malformed-authority-chain" };
259
+ if (link.maxDepth > link.parentMaxDepth - 1)
260
+ return { status: "failed", haltClass: "verification-failure" };
261
+ }
262
+ // The bounds slots must be WALKABLE before they can be compared. `isWithin` reads both sides with
263
+ // `Object.keys`, so an absent, null or non-object slot throws there — and this module's contract is
264
+ // totality over its inputs, not over well-formed inputs. `not-attempted` rather than `failed`, for the
265
+ // same reason a non-finite depth is: a caller's shape corruption says nothing about whether the RECORD
266
+ // contradicts itself, and answering `failed` would impeach a record to TC-0 over the caller's mistake.
267
+ // A well-formed readout always carries objects — `authority.walkChainStructure` supplies `{}` for a root
268
+ // link's `parentBounds` — and it refuses this same value as `malformed-authority-chain`, so walk and step
269
+ // stay categorically aligned.
270
+ if (!boundsShaped(link.bounds) || !boundsShaped(link.parentBounds))
271
+ return { status: "not-attempted", depth: "malformed-authority-chain" };
272
+ if (!isWithin(link.bounds, link.parentBounds))
273
+ return { status: "failed", haltClass: "verification-failure" }; // forged widening
274
+ // Revocation and liveness, and the ONE place this step used to flatter a record. Both slots are
275
+ // compile-time REQUIRED, so a typed caller cannot reach the absent arm and `authorityStepFromWalk`
276
+ // never could — but this step's whole contract is totality over UNTYPED input, and for that caller an
277
+ // omitted `revoked` used to read as unrevoked and PROVE. That is the permissive direction of the one
278
+ // rule this module states in capitals: "the flattener never consulted a status list" and "the pinned
279
+ // snapshot says unrevoked" are not the same fact, and only one of them may prove.
280
+ //
281
+ // Absence is therefore a gap with its own name, a contradiction still fails, and a non-boolean is the
282
+ // caller's shape error — the same three-way split the depth gate above already makes. The order also
283
+ // matters and is pinned: a link that BOTH widens and omits its status fails, because `isWithin` runs
284
+ // first and a contradiction outranks a gap.
285
+ if (!present(link.revoked))
286
+ return { status: "not-attempted", depth: "no-revocation-stated" };
287
+ if (typeof link.revoked !== "boolean")
288
+ return { status: "not-attempted", depth: "malformed-authority-chain" };
289
+ if (link.revoked)
290
+ return { status: "failed", haltClass: "verification-failure" };
291
+ if (!present(link.active))
292
+ return { status: "not-attempted", depth: "no-liveness-stated" };
293
+ if (typeof link.active !== "boolean")
294
+ return { status: "not-attempted", depth: "malformed-authority-chain" };
295
+ if (!link.active)
296
+ return { status: "failed", haltClass: "verification-failure" }; // expired
297
+ }
298
+ return { status: "proved" };
299
+ }
300
+
301
+ /**
302
+ * The WALK-FED authority step — `authority.walkChain`'s readout mapped onto a `StepOutcome`. Prefer it.
303
+ *
304
+ * `authorityStep` accepts any `AuthorityLink[]`, which makes whoever produced that array a trusted
305
+ * oracle: every field on a link is a DERIVED fact (`parentDelegable`, `parentMaxDepth`, `parentBounds`,
306
+ * `revoked`, `active`), and the step takes each at face value. A flattener that never verified custody —
307
+ * never checked that link N+1 was signed by link N's subject, never dereferenced a status list — yields a
308
+ * confident `proved`. A caller that walks first constructs no link at all and cannot make that mistake.
309
+ *
310
+ * The mapping needs no interpretation, because `ChainWalkResult` already draws this module's own line:
311
+ * `refused` is a reasoned CONTRADICTION (a spliced link, an issuer discontinuity, a forged widening, a
312
+ * revoked grant) and carries the halt class with it; `not-attempted` is the walk's honest GAP, its depth
313
+ * passed through verbatim; `walked` hands over links whose every field the walk STATED rather than
314
+ * defaulted. Re-proving those links through `authorityStep` is deliberate rather than redundant — it is
315
+ * what stops the custody walk and the verification step from drifting apart, and
316
+ * `packages/conformance/the repository's walk-readout tests` pins that the walk's output is exactly what the step
317
+ * proves. Total over untyped input like every step here: an unrecognized readout carries no links, so it
318
+ * falls through to `authorityStep`'s own gap rather than throwing.
319
+ */
320
+ export function authorityStepFromWalk(
321
+ walk: ChainWalkResult | undefined,
322
+ ): StepOutcome {
323
+ if (!present(walk) || typeof walk !== "object")
324
+ return { status: "not-attempted", depth: "no-authority-walk" };
325
+ if (walk.status === "refused")
326
+ return { status: "failed", haltClass: walk.haltClass };
327
+ if (walk.status === "not-attempted")
328
+ return { status: "not-attempted", depth: walk.depth };
329
+ return authorityStep(walk.links);
330
+ }
331
+
332
+ /** ATA-4: the accepted commitment must be contained by the leaf grant's bounds. */
333
+ export function commitmentStep(
334
+ c: { commitment: Bounds; leafBounds: Bounds } | undefined,
335
+ ): StepOutcome {
336
+ // Both halves must be usable bounds objects. `isWithin` is the pure ATA-2 predicate and is deliberately
337
+ // strict — it does `Object.keys` on what it is given — so the walk, not the predicate, owns the totality
338
+ // at this boundary. A half-supplied commitment slot is no commitment: a gap, never a contradiction.
339
+ if (
340
+ !present(c) ||
341
+ typeof c !== "object" ||
342
+ !present(c.commitment) ||
343
+ typeof c.commitment !== "object" ||
344
+ !present(c.leafBounds) ||
345
+ typeof c.leafBounds !== "object"
346
+ )
347
+ return { status: "not-attempted", depth: "no-commitment" };
348
+ return commitmentWithinLeaf(c.commitment, c.leafBounds)
349
+ ? { status: "proved" }
350
+ : { status: "failed", haltClass: "verification-failure" };
351
+ }
352
+
353
+ /**
354
+ * RCS-1/2/4: the elections the record carries and the package it produced.
355
+ *
356
+ * The elections are read from THE HASHED RECORD ITSELF — `atrBytes` parsed as the LCP envelope — never from
357
+ * a caller-supplied side channel, because RCS-1 requires the forum designation "recorded inside the terms
358
+ * record" (TRM-9). What this step proves is therefore what was welded.
359
+ *
360
+ * This step NEVER returns `failed`. An unelected forum is a record that did not reach for the rung, not a
361
+ * record that contradicts itself, and impeaching `supportedClass` to TC-0 over it would misreport a
362
+ * perfectly coherent TC-2. Likewise a non-machine-readable ATR (a ratified prose template, a PDF) is an
363
+ * honest coverage gap: a human forum can read a governing-law clause a verifier cannot.
364
+ */
365
+ export function recourseStep(
366
+ atrBytes: Uint8Array | undefined,
367
+ evidenceRoles: readonly string[] | undefined,
368
+ ): StepOutcome {
369
+ if (!present(atrBytes))
370
+ return { status: "not-attempted", depth: "no-atr-bytes" };
371
+ const envelope = parseEnvelope(atrBytes);
372
+ if (!present(envelope))
373
+ return { status: "not-attempted", depth: "atr-not-machine-readable" };
374
+ const recourse = envelope["recourse"];
375
+ if (!present(recourse) || typeof recourse !== "object")
376
+ return { status: "not-attempted", depth: "no-elections-recorded" };
377
+ const elections = recourse as Record<string, unknown>;
378
+ if (!stated(elections["forum"]))
379
+ return { status: "not-attempted", depth: "no-forum-elected" }; // RCS-1
380
+ if (!stated(elections["governingLaw"]))
381
+ return { status: "not-attempted", depth: "no-governing-law-elected" }; // RCS-2
382
+ if (!present(evidenceRoles))
383
+ return { status: "not-attempted", depth: "no-evidence-package" }; // RCS-4
384
+ const supplied = new Set(evidenceRoles);
385
+ if (!RCS4_REQUIRED_ROLES.every((role) => supplied.has(role)))
386
+ return { status: "not-attempted", depth: "evidence-package-incomplete" };
387
+ // CONDITIONAL, and this is the rung the role list alone cannot express. An ATR may carry its terms
388
+ // INLINE — then the ATR artifact is the document, and the list above is already complete — or by
389
+ // `lcp:sha256:` REFERENCE, in which case the package holds a fingerprint of a document it does not
390
+ // contain. A hash without a document is a proof without evidence (LCP §5.5, TRM-7), and the forum that
391
+ // opens the package years later receives the fingerprint of something nobody retained.
392
+ //
393
+ // Required IFF the terms slot is a ref, because an unconditional role would fail every honest
394
+ // inline-terms package. `manifest.schema.json` already stated this rule; nothing enforced it.
395
+ if (
396
+ isRefTerms(envelope["terms"]) &&
397
+ !supplied.has("referenced terms document")
398
+ )
399
+ return { status: "not-attempted", depth: "referenced-terms-not-retained" };
400
+ return { status: "proved" };
401
+ }
402
+
403
+ /**
404
+ * IDN-1/IDN-3: both parties resolve, each at a STATED assurance level, over a non-empty resolution chain.
405
+ *
406
+ * IDN-2 — does the chain terminate in an accountable party rather than a bare key? — is deliberately NOT a
407
+ * gate here. It is the counterparty-trust question a BUYER's policy asks (`authority.isConsequentialConformant`,
408
+ * applied by the gate), not a property of the record's class: a record that honestly states
409
+ * `wallet-signature-only` is conformant at its level, and IDN-3's whole discipline is that the low level
410
+ * stated honestly passes while an unstated level does not.
411
+ */
412
+ export function resolvePartyStep(
413
+ identity: RecordIdentity | undefined,
414
+ ): StepOutcome {
415
+ if (!present(identity))
416
+ return { status: "not-attempted", depth: "no-identity" };
417
+ // A party that is absent or carries no resolution chain is an UNRESOLVED party, not a crash: the step is
418
+ // total over its input, so a foreign conformance subject feeding a half-shaped identity gets an honest
419
+ // readout instead of a TypeError. Typed callers cannot express this — `RecordIdentity` requires both.
420
+ for (const party of [identity.seller, identity.buyer]) {
421
+ if (!present(party) || !present(party.chain))
422
+ return { status: "not-attempted", depth: "no-resolution" };
423
+ if (party.chain.length === 0)
424
+ return { status: "not-attempted", depth: "no-resolution-chain" };
425
+ // IDN-1 — the statement of WHO. A party resolved to nobody is unresolved.
426
+ if (!nonBlank(party.subject))
427
+ return { status: "not-attempted", depth: "no-subject" };
428
+ // IDN-3 — the statement of HOW. Array LENGTH is not evidence: `[{}]` and `[{via:""}]` are non-empty
429
+ // arrays that record nothing, and honouring them proved attribution for an identity naming no method
430
+ // at all. Every entry must say how, or the chain is present in shape only.
431
+ if (!party.chain.every((step) => nonBlank(step?.via)))
432
+ return { status: "not-attempted", depth: "no-resolution-method" };
433
+ // IDN-3 — the statement of AT WHAT LEVEL. A party whose resolution names no assurance stated nothing
434
+ // to pass honestly at: absence is a gap, exactly as a blank `subject` or `via` is. Typed callers cannot
435
+ // omit it (`IdentityResolution` requires `assurance`); this gate exists for the untyped ones.
436
+ if (!nonBlank(party.assurance))
437
+ return { status: "not-attempted", depth: "no-assurance-stated" };
438
+ }
439
+ return { status: "proved" };
440
+ }
441
+
442
+ /** The placement slot: what a commerce protocol's own document yielded, and nothing else. `extracted` is
443
+ * the `LegalContextRef` a `ReferencePlacementAdapter.extract` returned. Absent ⇒ the step is a gap. */
444
+ export interface PlacementInput {
445
+ readonly extracted?: unknown;
446
+ }
447
+
448
+ /**
449
+ * The reference-placement step — did the reference found in the protocol's native field match this record?
450
+ *
451
+ * REPORTED, NEVER REQUIRED: no class lists it in `REQUIRED_STEPS`, so its absence never blocks, and it
452
+ * impeaches only when it FAILS — the `frc-non-gating` pattern exactly. A protocol that never settles has
453
+ * nothing to enumerate, and letting a placement stand in for a settlement rung would let a record that
454
+ * moved no money read as classed.
455
+ *
456
+ * Total, like every step: the shape checks stay even though the arguments are typed, because the callers
457
+ * this exists for are untyped — a foreign conformance subject, an unvalidated intake. A malformed
458
+ * extraction is a GAP, never a `failed`: the caller's shape error says nothing about whether the record
459
+ * contradicts itself.
460
+ */
461
+ export function referencePlacementStep(
462
+ placement: PlacementInput | undefined,
463
+ settledAtrHash: string | undefined,
464
+ ): StepOutcome {
465
+ if (!present(placement))
466
+ return { status: "not-attempted", depth: "no-placement-input" };
467
+ const extracted: unknown = placement.extracted;
468
+ if (extracted === undefined)
469
+ return { status: "not-attempted", depth: "no-reference-extracted" };
470
+ if (
471
+ typeof extracted !== "object" ||
472
+ extracted === null ||
473
+ typeof (extracted as { value?: unknown }).value !== "string"
474
+ )
475
+ return { status: "not-attempted", depth: "malformed-extracted-reference" };
476
+ if (typeof settledAtrHash !== "string")
477
+ return { status: "not-attempted", depth: "no-record-fingerprint" };
478
+
479
+ // Both sides are NORMALIZED to the canonical 0x-prefixed lower-case form, not stripped to bare digits.
480
+ // Hex is case-insensitive, so a conformant uppercase counterparty must not be false-negatived. And a
481
+ // missing `0x` is a CARRIER-FORM defect that `binding-core`'s decoder already refuses upstream at
482
+ // extract time — failing here would drive `supportedClass` to TC-0 over formatting, impeaching a record
483
+ // whose reference names it correctly. Impeachment is reserved for a reference naming a DIFFERENT record.
484
+ const canonical = (h: string): string => {
485
+ const lower = h.toLowerCase();
486
+ return lower.startsWith("0x") ? lower : `0x${lower}`;
487
+ };
488
+ if (
489
+ canonical((extracted as { value: string }).value) !==
490
+ canonical(settledAtrHash)
491
+ )
492
+ return { status: "failed", haltClass: "verification-failure" };
493
+ return { status: "proved" };
494
+ }
495
+
496
+ /** A non-empty, non-blank string statement — an empty election is not an election. */
497
+ function stated(value: unknown): boolean {
498
+ return typeof value === "string" && value.trim().length > 0;
499
+ }
500
+
501
+ /** Parse the ATR bytes as an LCP envelope, or `undefined` when they are not one (prose, PDF, foreign JSON). */
502
+ function parseEnvelope(
503
+ atrBytes: Uint8Array,
504
+ ): Record<string, unknown> | undefined {
505
+ let parsed: unknown;
506
+ try {
507
+ parsed = JSON.parse(new TextDecoder().decode(atrBytes));
508
+ } catch {
509
+ return undefined; // not JSON at all — a prose or binary terms artifact
510
+ }
511
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
512
+ return undefined;
513
+ const envelope = parsed as Record<string, unknown>;
514
+ // `lcp` is engine-stamped by kernel.assemble on every record — its absence means these bytes are not an
515
+ // LCP envelope, whatever else they may be.
516
+ return typeof envelope["lcp"] === "string" ? envelope : undefined;
517
+ }