@typedstandards/verify-core 0.12.0 → 0.13.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 CHANGED
@@ -6,6 +6,80 @@ references are to `npstorey/civic-ai-tools-website` (#119 is the offline-crypto
6
6
  hardening arc; #116 is the standalone-verifier arc this package was extracted
7
7
  in).
8
8
 
9
+ ## 0.13.0 — 2026-09-27
10
+
11
+ The core-minors release (typedstandards#113, shipped in #114, #115 and #118). **A minor bump** —
12
+ `LIFECYCLE_STATUSES` gains `superseded`, so a consumer with an exhaustive `switch` or `Record` over
13
+ `LifecycleStatus` needs the new case, and `verifyLifecycleChain` now moves status only on a node
14
+ whose key is bound to the signer it names and that carries its required fields.
15
+
16
+ - **A lifecycle attestation moves status only when its signing key is bound to the signer it names
17
+ (typedstandards#113).** `verifyLifecycleChain` takes an optional fourth argument, a
18
+ `LifecycleKeyBinding` (`targetPublicKey`, `registry`, `registryProvenance`), and a carried node
19
+ signer-matches only when its `signer.identifier` equals the target's and its key is bound:
20
+ - a key-derived identifier (`did:key:`) binds by derivation from the node's own public key, the rule
21
+ check #14 applies to a record;
22
+ - any other identifier binds when the node is signed by the target record's own key
23
+ (`targetPublicKey`), or when a registry fetched from its declared URL (`registryProvenance:
24
+ 'declared-url'`) lists the node's `(kid, publicKey)` under that identifier, read by the rules of
25
+ checks #5 and #14. A registry carried in a bundle, or one with no stated provenance, never binds.
26
+
27
+ A node that is not bound stays in `chain` and does not move status. A caller that passes no binding
28
+ material gets a status moved only by `did:key` signers. `LifecycleAttestationView` gains an optional
29
+ `keyBound`, set by `verifyLifecycleChain`, and `CarriedLifecycleNode.signature` gains an optional
30
+ `kid`. `resolveLifecycleFromChain` is unchanged.
31
+ - **A carried lifecycle node that lacks a required payload field moves no status (typedstandards#113,
32
+ spec §8.12.3).** In `verifyLifecycleChain`, a node that lacks a field `ATTESTATION_REQUIRED_FIELDS`
33
+ lists for its sub-type (absent, null or the empty string: a `withdraws` with no `reason`, a
34
+ `reinstates` with no `priorWithdrawalNodeId`, a `supersedes` or `revises` with no
35
+ `successorNodeId`) stays in `chain` with `signerMatchesTarget` false, whoever signed it, and does not
36
+ move the status. `LifecycleAttestationView` gains an optional `missingFields`, set by
37
+ `verifyLifecycleChain` on every view: the fields the node lacks, by name, empty when it lacks none.
38
+ `resolveLifecycleFromChain` and `checkAttestationNode` are unchanged.
39
+ - **On typedstandards.org** (the site's verifier; not part of this package's API): the carried lifecycle
40
+ chain is resolved with the record's own signing key, and with the trust registry only when it was
41
+ fetched from the record's declared URL. A carried lifecycle event that names the record's signer and
42
+ lacks a required payload field adds one #10 line at `attention`, whose detail names the field, and
43
+ the #10 row lists the event under the fields it lacks. A third party's event that lacks one reads as
44
+ any third-party event does and adds no line (the owner's correction at the P5-fix gate, per G0 D6 as
45
+ corrected).
46
+ - **`superseded` is a lifecycle status (typedstandards#113, G0 D2).** `LIFECYCLE_STATUSES` is
47
+ `active`, `withdrawn`, `superseded`. `resolveLifecycleFromChain` reads the status from the latest
48
+ signer-matched node among `withdraws`, `reinstates` and `supersedes`: a `supersedes` reads
49
+ `superseded`; a `reinstates` reads `superseded` when a signer-matched `supersedes` precedes it and
50
+ `active` otherwise; a later `withdraws` reads `withdrawn`. `revises` is surfaced in `chain` and never
51
+ moves the status. `LifecycleResolution` gains `supersededAt` and `successorNodeId`, from the latest
52
+ counting `supersedes`. For a chain of `withdraws` and `reinstates` only, the output is unchanged.
53
+ `resolveLifecycleFromLegacyColumns` never derives `superseded`.
54
+ - `LifecycleAttestationView` gains optional `successorNodeId` and `namesTarget` (set by
55
+ `verifyLifecycleChain`: the node's `signer.identifier` equals the target's, bound or not).
56
+ - `CommitmentLifecycleState.status` widens to `'superseded'`, with optional `supersededAt` and
57
+ `successorNodeId`. `verifyRecord`'s STATE path still reads only the withdrawal columns.
58
+ - New constants `ATTESTATION_SUPERSEDES`, `ATTESTATION_REVISES`, `ATTESTATION_CORROBORATES`,
59
+ `ATTESTATION_CONTRADICTS`, and the lists `LIFECYCLE_STATUS_ATTESTATION_TYPES` (withdraws,
60
+ reinstates, supersedes) and `LIFECYCLE_CHAIN_ATTESTATION_TYPES` (those and revises).
61
+ `LifecycleAttestationType` and `LIFECYCLE_ATTESTATION_TYPES` stay `withdraws` and `reinstates`,
62
+ the withdrawal/reinstatement pair. produce-core also emits `supersedes` and `revises`, so they are
63
+ not the set it emits, and no verify-core check reads them.
64
+ - **A per-node authorization check, `checkAttestationNode` (typedstandards#113, G0 D6 and D4).** For
65
+ `withdraws`, `reinstates`, `supersedes`, `revises`, `corroborates` and `contradicts`, in order:
66
+ integrity and signature; the key binding, by `verifyLifecycleChain`'s rule; then the sub-type's
67
+ authorization rule (spec §8.12.3). Publisher-only (the four lifecycle sub-types) runs when the
68
+ attested record's signer and signing key are supplied, and otherwise reads `not_checked`; a node
69
+ naming another signer reads `other_signer`, and one naming the record's signer under a key not bound
70
+ to it reads `publisher_key_unbound`. Any-with-binding (`corroborates`, `contradicts`) needs the key
71
+ bound to the node's own identifier (a `did:key` by derivation; any other identifier only by a
72
+ registry fetched from its declared URL) and a `signer.bindingTier` in `BINDING_TIERS`
73
+ (`pseudonymous`, `oauth`, `orcid`, `did-web`, `notarized`, `platform`). The result names each
74
+ required §8.12.1 payload field the node lacks, in `missingFields`; it moves no status. New exports:
75
+ `checkAttestationNode`, `ATTESTATION_AUTHORIZATION_RULES`, `ATTESTATION_REQUIRED_FIELDS`,
76
+ `ATTESTATION_AUTHORIZATION_STATUSES`, `BINDING_TIERS`, and the types
77
+ `AttestationAuthorizationRule`, `AttestationAuthorizationStatus`, `AttestationCheckContext`,
78
+ `AttestationCheck`.
79
+ - **On typedstandards.org:** `superseded` reads `normal` ("Superseded by the publisher") and the #10
80
+ row names the successor. A carried lifecycle event that names the record's signer, under a key not
81
+ bound to it, adds one #10 line at `attention`; a third party's event adds nothing.
82
+
9
83
  ## 0.12.0 — 2026-09-24
10
84
 
11
85
  The certificate-chain split (typedstandards#100, shipped in #106). **A minor bump** — `ChainFailReason`
@@ -1,5 +1,23 @@
1
1
  export declare const ATTESTATION_WITHDRAWS = "attestation/withdraws/v1";
2
2
  export declare const ATTESTATION_REINSTATES = "attestation/reinstates/v1";
3
+ export declare const ATTESTATION_SUPERSEDES = "attestation/supersedes/v1";
4
+ export declare const ATTESTATION_REVISES = "attestation/revises/v1";
5
+ export declare const ATTESTATION_CORROBORATES = "attestation/corroborates/v1";
6
+ export declare const ATTESTATION_CONTRADICTS = "attestation/contradicts/v1";
7
+ /** The withdrawal/reinstatement pair: the two lifecycle sub-types the legacy
8
+ * `withdrawnAt` / `reinstatedAt` columns also record (spec §8.10.4). produce-core's
9
+ * `EmittableAttestationType` includes this union beside the other sub-types it
10
+ * emits, `supersedes` and `revises` among them, so it is not the set produce-core
11
+ * emits. No verify-core check reads it: the lifecycle reads
12
+ * `LIFECYCLE_STATUS_ATTESTATION_TYPES` and `LIFECYCLE_CHAIN_ATTESTATION_TYPES`. */
3
13
  export type LifecycleAttestationType = typeof ATTESTATION_WITHDRAWS | typeof ATTESTATION_REINSTATES;
4
- /** The lifecycle sub-type URIs the verify side resolves (spec §8.12.1). */
14
+ /** The values of `LifecycleAttestationType`, the withdrawal/reinstatement pair. Not
15
+ * the set produce-core emits, and not read by any verify-core check (see
16
+ * `LifecycleAttestationType`). */
5
17
  export declare const LIFECYCLE_ATTESTATION_TYPES: readonly string[];
18
+ /** The sub-types that move a record's lifecycle status (spec §8.10.6: `active`,
19
+ * `withdrawn`, `superseded`). `revises` and `publishes` never do. */
20
+ export declare const LIFECYCLE_STATUS_ATTESTATION_TYPES: readonly string[];
21
+ /** The publisher-only lifecycle sub-types surfaced in a record's lifecycle chain:
22
+ * the status-moving three, and `revises`, which is shown but moves nothing. */
23
+ export declare const LIFECYCLE_CHAIN_ATTESTATION_TYPES: readonly string[];
@@ -1,12 +1,31 @@
1
- // Lifecycle attestation sub-type URIs (spec §8.10, §8.12.1) — browser-safe
2
- // constants. The server `attestation.ts` builder (which mints nodes and so needs
3
- // Node's `crypto.randomUUID`) re-exports these from verify-core, keeping a single
1
+ // Attestation sub-type URIs (spec §8.10, §8.12.1) — browser-safe constants. The
2
+ // server `attestation.ts` builder (which mints nodes and so needs Node's
3
+ // `crypto.randomUUID`) re-exports these from verify-core, keeping a single
4
4
  // definition of the URIs the verify side dispatches on. verify-core never builds
5
5
  // nodes — it only verifies and orders them — so it carries the constants alone.
6
6
  export const ATTESTATION_WITHDRAWS = 'attestation/withdraws/v1';
7
7
  export const ATTESTATION_REINSTATES = 'attestation/reinstates/v1';
8
- /** The lifecycle sub-type URIs the verify side resolves (spec §8.12.1). */
8
+ export const ATTESTATION_SUPERSEDES = 'attestation/supersedes/v1';
9
+ export const ATTESTATION_REVISES = 'attestation/revises/v1';
10
+ export const ATTESTATION_CORROBORATES = 'attestation/corroborates/v1';
11
+ export const ATTESTATION_CONTRADICTS = 'attestation/contradicts/v1';
12
+ /** The values of `LifecycleAttestationType`, the withdrawal/reinstatement pair. Not
13
+ * the set produce-core emits, and not read by any verify-core check (see
14
+ * `LifecycleAttestationType`). */
9
15
  export const LIFECYCLE_ATTESTATION_TYPES = [
10
16
  ATTESTATION_WITHDRAWS,
11
17
  ATTESTATION_REINSTATES,
12
18
  ];
19
+ /** The sub-types that move a record's lifecycle status (spec §8.10.6: `active`,
20
+ * `withdrawn`, `superseded`). `revises` and `publishes` never do. */
21
+ export const LIFECYCLE_STATUS_ATTESTATION_TYPES = [
22
+ ATTESTATION_WITHDRAWS,
23
+ ATTESTATION_REINSTATES,
24
+ ATTESTATION_SUPERSEDES,
25
+ ];
26
+ /** The publisher-only lifecycle sub-types surfaced in a record's lifecycle chain:
27
+ * the status-moving three, and `revises`, which is shown but moves nothing. */
28
+ export const LIFECYCLE_CHAIN_ATTESTATION_TYPES = [
29
+ ...LIFECYCLE_STATUS_ATTESTATION_TYPES,
30
+ ATTESTATION_REVISES,
31
+ ];
@@ -1,5 +1,8 @@
1
1
  import type { SignerIdentity } from './types.ts';
2
- export declare const LIFECYCLE_STATUSES: readonly ["active", "withdrawn"];
2
+ import type { TrustRegistry, TrustRegistryProvenance } from './trust-registry.ts';
3
+ /** Lifecycle status (spec §8.10.6). Only the attestation chain derives
4
+ * `superseded`; the legacy columns (§8.10.4) know no supersession. */
5
+ export declare const LIFECYCLE_STATUSES: readonly ["active", "withdrawn", "superseded"];
3
6
  export type LifecycleStatus = (typeof LIFECYCLE_STATUSES)[number];
4
7
  /** Which representation determined the lifecycle status. `none` = never
5
8
  * withdrawn, no attestations and no legacy columns. */
@@ -15,6 +18,8 @@ export interface LifecycleAttestationView {
15
18
  reason?: string;
16
19
  effectiveAt?: string;
17
20
  priorWithdrawalNodeId?: string;
21
+ /** `supersedes` / `revises`: the successor node's id. */
22
+ successorNodeId?: string;
18
23
  /** Ed25519ph signature over the recomputed nodeId; null when unsigned. */
19
24
  signatureValid: boolean | null;
20
25
  /** Recomputed envelope hash equals the stored nodeId (integrity). */
@@ -25,9 +30,24 @@ export interface LifecycleAttestationView {
25
30
  /** Rekor inclusion-proof entry present (presence only; per-attestation Rekor
26
31
  * cryptographic verification is a follow-up). */
27
32
  hasRekor: boolean;
28
- /** Publisher-only conformance (§8.12.3): the attestation's signer.identifier
29
- * matches the target content node's signer.identifier. */
33
+ /** Whether the node counts toward the status (§8.12.3). The attestation's
34
+ * signer.identifier matches the target content node's signer.identifier. On a
35
+ * view built by `verifyLifecycleChain`, it also requires `keyBound`, and that the
36
+ * node carries every payload field §8.12.1 requires of its sub-type
37
+ * (`missingFields` empty): §8.12.3's conformance covers the payload fields. */
30
38
  signerMatchesTarget: boolean;
39
+ /** Set by `verifyLifecycleChain`: the node's signing key is bound to the
40
+ * signer it names (see `verifyLifecycleChain`). Absent on views built
41
+ * elsewhere. */
42
+ keyBound?: boolean;
43
+ /** Set by `verifyLifecycleChain`: the node's `signer.identifier` equals the
44
+ * target's, whether or not its key is bound. Absent on views built elsewhere. */
45
+ namesTarget?: boolean;
46
+ /** Set by `verifyLifecycleChain`: each payload field §8.12.1 requires of the
47
+ * node's sub-type (`ATTESTATION_REQUIRED_FIELDS`) that the node lacks (absent,
48
+ * null or the empty string), by name; empty when it lacks none. A node that
49
+ * lacks one does not count toward the status. Absent on views built elsewhere. */
50
+ missingFields?: string[];
31
51
  }
32
52
  export interface LifecycleResolution {
33
53
  status: LifecycleStatus;
@@ -40,6 +60,10 @@ export interface LifecycleResolution {
40
60
  withdrawnReason?: string;
41
61
  reinstatedAt?: string;
42
62
  reinstatedReason?: string;
63
+ /** The latest counting `supersedes`: its envelope timestamp. */
64
+ supersededAt?: string;
65
+ /** The latest counting `supersedes`: its `successorNodeId`. */
66
+ successorNodeId?: string;
43
67
  }
44
68
  export interface AttestationVerifyResult {
45
69
  /** The recomputed envelope hash (= nodeId by construction). */
@@ -62,18 +86,33 @@ export declare function verifyAttestationNode(node: Record<string, unknown>, sto
62
86
  } | null): AttestationVerifyResult;
63
87
  /**
64
88
  * Check #10 (chain path) — derive lifecycle status from a set of verified
65
- * attestation views (spec §8.10.1, §8.10.3). The current status is the latest
66
- * signer-matched lifecycle attestation by envelope timestamp: `withdraws` →
67
- * withdrawn, `reinstates` → active. Non-signer-matched attestations are kept in
68
- * the surfaced chain for transparency but do NOT move the status (retention
69
- * asymmetry, §8.10.3).
89
+ * attestation views (spec §8.10.1, §8.10.3, §8.10.6; typedstandards#113 G0 D2).
90
+ *
91
+ * The chain holds the publisher-only lifecycle sub-types
92
+ * (`LIFECYCLE_CHAIN_ATTESTATION_TYPES`: withdraws, reinstates, supersedes,
93
+ * revises), ordered by envelope timestamp, ties by nodeId. The status is read
94
+ * from the latest signer-matched node among withdraws, reinstates and
95
+ * supersedes (`LIFECYCLE_STATUS_ATTESTATION_TYPES`):
96
+ * - `withdraws` → withdrawn;
97
+ * - `supersedes` → superseded;
98
+ * - `reinstates` → superseded when a signer-matched `supersedes` precedes it in
99
+ * chain order, active otherwise.
100
+ * `revises` is surfaced in the chain and never moves the status (§8.10.5: no
101
+ * deprecation signal). Non-signer-matched attestations are kept in the surfaced
102
+ * chain for transparency but do NOT move the status (retention asymmetry,
103
+ * §8.10.3). The latest signer-matched `supersedes` names the successor and the
104
+ * supersession time.
105
+ *
106
+ * For a chain of withdraws and reinstates only, the output is what it was before
107
+ * `superseded` existed.
70
108
  */
71
109
  export declare function resolveLifecycleFromChain(views: LifecycleAttestationView[]): LifecycleResolution;
72
110
  /**
73
111
  * Check #10 (legacy fallback) — derive lifecycle status from the pre-PR3
74
112
  * `withdrawnAt` / `reinstatedAt` columns (spec §8.10.4). Used when a content node
75
113
  * has no attestation envelopes. Withdrawn iff `withdrawnAt` is set and
76
- * `reinstatedAt` is not.
114
+ * `reinstatedAt` is not. It never derives `superseded`: the columns record no
115
+ * supersession.
77
116
  */
78
117
  export declare function resolveLifecycleFromLegacyColumns(columns: {
79
118
  withdrawnAt?: string | null;
@@ -94,11 +133,24 @@ export interface CarriedLifecycleNode {
94
133
  signature?: string;
95
134
  publicKey?: string;
96
135
  algorithm?: string;
136
+ kid?: string;
97
137
  } | null;
98
138
  /** Presence flags (surfaced; per-attestation TSA/Rekor depth is a follow-up). */
99
139
  hasTimestamp?: boolean;
100
140
  hasRekor?: boolean;
101
141
  }
142
+ /** What a caller holds that can bind a lifecycle attestation's signing key to
143
+ * the signer it names (see `verifyLifecycleChain`). */
144
+ export interface LifecycleKeyBinding {
145
+ /** The target record's own signing key (the base64 SPKI `publicKey` of its
146
+ * signature envelope). */
147
+ targetPublicKey?: string;
148
+ /** A parsed trust registry. */
149
+ registry?: TrustRegistry;
150
+ /** Where `registry` came from. Only `declared-url` can bind a key; absent is
151
+ * treated as `bundle`. */
152
+ registryProvenance?: TrustRegistryProvenance;
153
+ }
102
154
  /**
103
155
  * Resolve lifecycle (#10) INDEPENDENTLY from carried signed attestation nodes —
104
156
  * the browser/offline path that reaches `source: 'attestation-chain'` with no
@@ -112,9 +164,107 @@ export interface CarriedLifecycleNode {
112
164
  * - INTEGRITY: the recomputed envelope hash MUST equal the stored `nodeId`.
113
165
  * - SIGNATURE: the Ed25519(ph) signature MUST verify over that hash.
114
166
  * A node failing any of these is EXCLUDED (a forged/tampered/misdirected transition
115
- * cannot move the status). Surviving nodes go to `resolveLifecycleFromChain`, which
116
- * applies the §8.10.3 retention asymmetry (a valid but non-signer-matched attestation
117
- * is surfaced in the chain yet does NOT move the publisher's status). That shared
118
- * resolver is unchanged, so the server route's output stays byte-identical.
167
+ * cannot move the status). A surviving node is signer-matched only when its
168
+ * `signer.identifier` equals the target's AND its signing key is bound to that
169
+ * identifier (`keyBound`), by what `binding` supplies:
170
+ * - a key-derived identifier (`did:key:`) binds by derivation from the node's own
171
+ * public key, the rule check #14 applies to a record;
172
+ * - any other identifier binds by `binding.targetPublicKey` (the node is signed by
173
+ * the target record's own key), or by `binding.registry` when
174
+ * `binding.registryProvenance` is `declared-url` and it lists the node's
175
+ * `(kid, publicKey)` under that identifier, read by the rules of checks #5 and
176
+ * #14. A registry carried in a bundle, or with no stated provenance, never binds.
177
+ * Each view records `namesTarget` (the identifiers are equal) beside `keyBound`, so a
178
+ * reader can tell a third party's node from one that names the publisher under a key
179
+ * not bound to it.
180
+ * A surviving node that lacks a payload field §8.12.1 requires of its sub-type
181
+ * (§8.12.3; `ATTESTATION_REQUIRED_FIELDS`, absent, null or the empty string) is not
182
+ * signer-matched either, whoever signed it: it stays in the chain, names the fields
183
+ * in `missingFields`, and does not move the status.
184
+ * Surviving nodes go to `resolveLifecycleFromChain`, which applies the §8.10.3
185
+ * retention asymmetry (a valid but non-signer-matched attestation is surfaced in the
186
+ * chain yet does NOT move the publisher's status).
187
+ */
188
+ export declare function verifyLifecycleChain(carried: CarriedLifecycleNode[], contentNodeId: string, targetSignerIdentifier: string, binding?: LifecycleKeyBinding): LifecycleResolution;
189
+ /** A sub-type's authorization rule (spec §8.12.1). */
190
+ export type AttestationAuthorizationRule = 'publisher-only' | 'any-with-binding';
191
+ /** The rule of each sub-type `checkAttestationNode` checks. `publishes`,
192
+ * `locatedAt` and the specific-role-required sub-types have none here. */
193
+ export declare const ATTESTATION_AUTHORIZATION_RULES: Readonly<Record<string, AttestationAuthorizationRule>>;
194
+ /** Each checked sub-type's required payload fields (spec §8.12.1), beyond the
195
+ * structural primitive. Optional fields (`effectiveAt`, a reinstatement's
196
+ * `reason`, `reasoning`) are not listed. */
197
+ export declare const ATTESTATION_REQUIRED_FIELDS: Readonly<Record<string, readonly string[]>>;
198
+ /** The `signer.bindingTier` values an any-with-binding node may carry: the §8.5
199
+ * graded identity ladder, and `platform` (spec §8.1.1). */
200
+ export declare const BINDING_TIERS: readonly ["pseudonymous", "oauth", "orcid", "did-web", "notarized", "platform"];
201
+ /**
202
+ * What `checkAttestationNode` reads for a node, first failure first:
203
+ * - `node_id_mismatch` — the recomputed envelope hash is not the stored nodeId;
204
+ * - `signature_invalid` — the signature does not verify over it;
205
+ * - `unsigned` — the node carries no signature, so nothing can be bound;
206
+ * - `not_checked` — publisher-only with no attested record supplied, or a
207
+ * sub-type this check has no rule for;
208
+ * - `other_signer` — publisher-only: the node names a signer other than the
209
+ * attested record's, a third party's event;
210
+ * - `publisher_key_unbound` — publisher-only: the node names the attested
211
+ * record's signer, but its signing key is not bound to that signer;
212
+ * - `key_unbound` — any-with-binding: the signing key is not bound to the signer
213
+ * the node names;
214
+ * - `binding_tier_off_ladder` — any-with-binding: `signer.bindingTier` is not in
215
+ * `BINDING_TIERS`;
216
+ * - `authorized` — the sub-type's rule holds.
217
+ */
218
+ export declare const ATTESTATION_AUTHORIZATION_STATUSES: readonly ["authorized", "not_checked", "other_signer", "publisher_key_unbound", "key_unbound", "binding_tier_off_ladder", "unsigned", "signature_invalid", "node_id_mismatch"];
219
+ export type AttestationAuthorizationStatus = (typeof ATTESTATION_AUTHORIZATION_STATUSES)[number];
220
+ /** What a caller holds for `checkAttestationNode`. */
221
+ export interface AttestationCheckContext {
222
+ /** The attested record: its `signer.identifier`, and the base64 SPKI
223
+ * `publicKey` of its signature envelope. The publisher-only rule runs only
224
+ * when this is supplied. */
225
+ target?: {
226
+ signerIdentifier: string;
227
+ publicKey: string;
228
+ };
229
+ /** A parsed trust registry that may bind the node's key to the identifier it
230
+ * names. */
231
+ registry?: TrustRegistry;
232
+ /** Where `registry` came from. Only `declared-url` can bind a key; absent is
233
+ * treated as `bundle`. */
234
+ registryProvenance?: TrustRegistryProvenance;
235
+ }
236
+ export interface AttestationCheck {
237
+ /** The recomputed envelope hash. */
238
+ nodeId: string;
239
+ type: string;
240
+ /** The sub-type's rule; null for a sub-type this check has none for. */
241
+ rule: AttestationAuthorizationRule | null;
242
+ nodeIdMatches: boolean;
243
+ signatureValid: boolean | null;
244
+ /** The signing key is bound to the signer the node names (see `status`). */
245
+ keyBound: boolean;
246
+ status: AttestationAuthorizationStatus;
247
+ /** Each required §8.12.1 payload field the node lacks (absent, null or the
248
+ * empty string), by name. Reported only; it does not change `status`. */
249
+ missingFields: string[];
250
+ }
251
+ /**
252
+ * Check one `attestation/*` node on its own (spec §8.12.3), for `withdraws`,
253
+ * `reinstates`, `supersedes`, `revises`, `corroborates` and `contradicts`, in
254
+ * order:
255
+ * 1. integrity and signature, as `verifyAttestationNode` reads them;
256
+ * 2. the key binding, by the rule `verifyLifecycleChain` applies: a key-derived
257
+ * identifier binds by derivation; any other identifier binds by a registry
258
+ * fetched from its declared URL, and, for a publisher-only node only, by the
259
+ * attested record's own signing key;
260
+ * 3. the sub-type's authorization rule:
261
+ * - publisher-only (the four lifecycle sub-types) runs when `context.target`
262
+ * is supplied, and otherwise reads `not_checked`. A node naming another
263
+ * signer reads `other_signer`; one naming the target's signer under a key
264
+ * not bound to it reads `publisher_key_unbound`;
265
+ * - any-with-binding (`corroborates`, `contradicts`) needs the key bound and a
266
+ * `signer.bindingTier` in `BINDING_TIERS`.
267
+ * It also names each required §8.12.1 payload field the node lacks. Pure; it moves
268
+ * no status: `resolveLifecycleFromChain` alone derives the lifecycle.
119
269
  */
120
- export declare function verifyLifecycleChain(carried: CarriedLifecycleNode[], contentNodeId: string, targetSignerIdentifier: string): LifecycleResolution;
270
+ export declare function checkAttestationNode(carried: CarriedLifecycleNode, context?: AttestationCheckContext): AttestationCheck;
package/dist/lifecycle.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Lifecycle attestation checks (spec §9.2 check #10, §8.10) — browser-safe pure
2
2
  // logic.
3
3
  //
4
- // Lifecycle state (withdrawn / active) is derived from a chain of separately-
5
- // signed `attestation/*` nodes referencing the content node by `targetNodeId`,
6
- // each verified independently. Backwards-compat (§8.10.4): when no attestation
7
- // envelopes are present, the legacy `withdrawnAt` / `reinstatedAt` columns are
8
- // honored instead.
4
+ // Lifecycle state (active / withdrawn / superseded) is derived from a chain of
5
+ // separately-signed `attestation/*` nodes referencing the content node by
6
+ // `targetNodeId`, each verified independently. Backwards-compat (§8.10.4): when
7
+ // no attestation envelopes are present, the legacy `withdrawnAt` /
8
+ // `reinstatedAt` columns are honored instead.
9
9
  //
10
10
  // This file holds only the PURE ordering / status-derivation / per-node
11
11
  // verification logic (factored from the server `verify.ts`). The DB + blob fetch
@@ -15,9 +15,14 @@
15
15
  // status/withdrawnAt/reason) rather than re-running the chain — verifying the
16
16
  // signed attestation chain independently in the browser is civic-ai-tools-website#119.
17
17
  import { computeEnvelopeHash } from "./canonicalization.js";
18
- import { verifySignature } from "./signature.js";
19
- import { ATTESTATION_WITHDRAWS, ATTESTATION_REINSTATES, LIFECYCLE_ATTESTATION_TYPES, } from "./attestation.js";
20
- export const LIFECYCLE_STATUSES = ['active', 'withdrawn'];
18
+ import { extractRawPublicKey, verifySignature } from "./signature.js";
19
+ import { checkSignerIdentity } from "./checks.js";
20
+ import { isKeyDerivedIdentifier } from "./did-key.js";
21
+ import { verifyKeyTrust } from "./trust-registry.js";
22
+ import { ATTESTATION_WITHDRAWS, ATTESTATION_REINSTATES, ATTESTATION_SUPERSEDES, ATTESTATION_REVISES, ATTESTATION_CORROBORATES, ATTESTATION_CONTRADICTS, LIFECYCLE_STATUS_ATTESTATION_TYPES, LIFECYCLE_CHAIN_ATTESTATION_TYPES, } from "./attestation.js";
23
+ /** Lifecycle status (spec §8.10.6). Only the attestation chain derives
24
+ * `superseded`; the legacy columns (§8.10.4) know no supersession. */
25
+ export const LIFECYCLE_STATUSES = ['active', 'withdrawn', 'superseded'];
21
26
  /** Which representation determined the lifecycle status. `none` = never
22
27
  * withdrawn, no attestations and no legacy columns. */
23
28
  export const LIFECYCLE_SOURCES = [
@@ -50,20 +55,46 @@ function compareLifecycleOrder(a, b) {
50
55
  }
51
56
  /**
52
57
  * Check #10 (chain path) — derive lifecycle status from a set of verified
53
- * attestation views (spec §8.10.1, §8.10.3). The current status is the latest
54
- * signer-matched lifecycle attestation by envelope timestamp: `withdraws` →
55
- * withdrawn, `reinstates` → active. Non-signer-matched attestations are kept in
56
- * the surfaced chain for transparency but do NOT move the status (retention
57
- * asymmetry, §8.10.3).
58
+ * attestation views (spec §8.10.1, §8.10.3, §8.10.6; typedstandards#113 G0 D2).
59
+ *
60
+ * The chain holds the publisher-only lifecycle sub-types
61
+ * (`LIFECYCLE_CHAIN_ATTESTATION_TYPES`: withdraws, reinstates, supersedes,
62
+ * revises), ordered by envelope timestamp, ties by nodeId. The status is read
63
+ * from the latest signer-matched node among withdraws, reinstates and
64
+ * supersedes (`LIFECYCLE_STATUS_ATTESTATION_TYPES`):
65
+ * - `withdraws` → withdrawn;
66
+ * - `supersedes` → superseded;
67
+ * - `reinstates` → superseded when a signer-matched `supersedes` precedes it in
68
+ * chain order, active otherwise.
69
+ * `revises` is surfaced in the chain and never moves the status (§8.10.5: no
70
+ * deprecation signal). Non-signer-matched attestations are kept in the surfaced
71
+ * chain for transparency but do NOT move the status (retention asymmetry,
72
+ * §8.10.3). The latest signer-matched `supersedes` names the successor and the
73
+ * supersession time.
74
+ *
75
+ * For a chain of withdraws and reinstates only, the output is what it was before
76
+ * `superseded` existed.
58
77
  */
59
78
  export function resolveLifecycleFromChain(views) {
60
79
  const chain = views
61
- .filter((v) => LIFECYCLE_ATTESTATION_TYPES.includes(v.type))
80
+ .filter((v) => LIFECYCLE_CHAIN_ATTESTATION_TYPES.includes(v.type))
62
81
  .slice()
63
82
  .sort(compareLifecycleOrder);
64
- const signerMatched = chain.filter((v) => v.signerMatchesTarget);
83
+ const signerMatched = chain.filter((v) => v.signerMatchesTarget && LIFECYCLE_STATUS_ATTESTATION_TYPES.includes(v.type));
65
84
  const latest = signerMatched[signerMatched.length - 1];
66
- const status = latest && latest.type === ATTESTATION_WITHDRAWS ? 'withdrawn' : 'active';
85
+ const latestSupersede = [...signerMatched]
86
+ .reverse()
87
+ .find((v) => v.type === ATTESTATION_SUPERSEDES);
88
+ let status = 'active';
89
+ if (latest?.type === ATTESTATION_WITHDRAWS)
90
+ status = 'withdrawn';
91
+ else if (latest?.type === ATTESTATION_SUPERSEDES)
92
+ status = 'superseded';
93
+ // A reinstatement returns the node to where it stood before the withdrawal:
94
+ // superseded if a counting supersedes precedes it, which it does whenever one
95
+ // exists, since the reinstatement is the latest counting node.
96
+ else if (latest?.type === ATTESTATION_REINSTATES && latestSupersede)
97
+ status = 'superseded';
67
98
  const latestWithdraw = [...signerMatched]
68
99
  .reverse()
69
100
  .find((v) => v.type === ATTESTATION_WITHDRAWS);
@@ -86,13 +117,22 @@ export function resolveLifecycleFromChain(views) {
86
117
  reinstatedReason: latestReinstate.reason,
87
118
  }
88
119
  : {}),
120
+ ...(latestSupersede
121
+ ? {
122
+ supersededAt: latestSupersede.createdAt,
123
+ ...(latestSupersede.successorNodeId
124
+ ? { successorNodeId: latestSupersede.successorNodeId }
125
+ : {}),
126
+ }
127
+ : {}),
89
128
  };
90
129
  }
91
130
  /**
92
131
  * Check #10 (legacy fallback) — derive lifecycle status from the pre-PR3
93
132
  * `withdrawnAt` / `reinstatedAt` columns (spec §8.10.4). Used when a content node
94
133
  * has no attestation envelopes. Withdrawn iff `withdrawnAt` is set and
95
- * `reinstatedAt` is not.
134
+ * `reinstatedAt` is not. It never derives `superseded`: the columns record no
135
+ * supersession.
96
136
  */
97
137
  export function resolveLifecycleFromLegacyColumns(columns) {
98
138
  if (!columns.withdrawnAt) {
@@ -114,6 +154,43 @@ export function resolveLifecycleFromLegacyColumns(columns) {
114
154
  function pickString(v) {
115
155
  return typeof v === 'string' && v.length > 0 ? v : undefined;
116
156
  }
157
+ function sameKey(a, b) {
158
+ try {
159
+ const x = extractRawPublicKey(a);
160
+ const y = extractRawPublicKey(b);
161
+ return x.length === y.length && x.every((byte, i) => byte === y[i]);
162
+ }
163
+ catch {
164
+ return false;
165
+ }
166
+ }
167
+ /**
168
+ * Whether a lifecycle attestation's signing key is bound to the signer it names.
169
+ * - A key-derived identifier (`did:key:`) binds by derivation from `publicKey`,
170
+ * under the rule check #14 applies to a record: it binds when #14 reads
171
+ * `key_derived_match`.
172
+ * - Any other identifier binds when `publicKey` is the target record's own
173
+ * signing key, or when a registry fetched from its declared URL lists it
174
+ * under that identifier: check #14 reads `ok` for the node's `kid`, and
175
+ * check #5 reads the `(kid, publicKey)` pair as verified. The node carries
176
+ * no verified signing time, so a deprecated key does not bind.
177
+ * A registry carried in a bundle, or one with no stated provenance, never binds.
178
+ */
179
+ function isKeyBound(node, publicKey, kid, binding) {
180
+ const signer = node['signer'];
181
+ if (!signer || typeof signer !== 'object' || typeof signer.identifier !== 'string')
182
+ return false;
183
+ if (isKeyDerivedIdentifier(signer.identifier)) {
184
+ return checkSignerIdentity(node, kid, binding.registry, publicKey).status === 'key_derived_match';
185
+ }
186
+ if (binding.targetPublicKey && sameKey(publicKey, binding.targetPublicKey))
187
+ return true;
188
+ if (!kid || !binding.registry || binding.registryProvenance !== 'declared-url')
189
+ return false;
190
+ if (checkSignerIdentity(node, kid, binding.registry).status !== 'ok')
191
+ return false;
192
+ return verifyKeyTrust(publicKey, kid, undefined, binding.registry).verified;
193
+ }
117
194
  /**
118
195
  * Resolve lifecycle (#10) INDEPENDENTLY from carried signed attestation nodes —
119
196
  * the browser/offline path that reaches `source: 'attestation-chain'` with no
@@ -127,12 +204,28 @@ function pickString(v) {
127
204
  * - INTEGRITY: the recomputed envelope hash MUST equal the stored `nodeId`.
128
205
  * - SIGNATURE: the Ed25519(ph) signature MUST verify over that hash.
129
206
  * A node failing any of these is EXCLUDED (a forged/tampered/misdirected transition
130
- * cannot move the status). Surviving nodes go to `resolveLifecycleFromChain`, which
131
- * applies the §8.10.3 retention asymmetry (a valid but non-signer-matched attestation
132
- * is surfaced in the chain yet does NOT move the publisher's status). That shared
133
- * resolver is unchanged, so the server route's output stays byte-identical.
207
+ * cannot move the status). A surviving node is signer-matched only when its
208
+ * `signer.identifier` equals the target's AND its signing key is bound to that
209
+ * identifier (`keyBound`), by what `binding` supplies:
210
+ * - a key-derived identifier (`did:key:`) binds by derivation from the node's own
211
+ * public key, the rule check #14 applies to a record;
212
+ * - any other identifier binds by `binding.targetPublicKey` (the node is signed by
213
+ * the target record's own key), or by `binding.registry` when
214
+ * `binding.registryProvenance` is `declared-url` and it lists the node's
215
+ * `(kid, publicKey)` under that identifier, read by the rules of checks #5 and
216
+ * #14. A registry carried in a bundle, or with no stated provenance, never binds.
217
+ * Each view records `namesTarget` (the identifiers are equal) beside `keyBound`, so a
218
+ * reader can tell a third party's node from one that names the publisher under a key
219
+ * not bound to it.
220
+ * A surviving node that lacks a payload field §8.12.1 requires of its sub-type
221
+ * (§8.12.3; `ATTESTATION_REQUIRED_FIELDS`, absent, null or the empty string) is not
222
+ * signer-matched either, whoever signed it: it stays in the chain, names the fields
223
+ * in `missingFields`, and does not move the status.
224
+ * Surviving nodes go to `resolveLifecycleFromChain`, which applies the §8.10.3
225
+ * retention asymmetry (a valid but non-signer-matched attestation is surfaced in the
226
+ * chain yet does NOT move the publisher's status).
134
227
  */
135
- export function verifyLifecycleChain(carried, contentNodeId, targetSignerIdentifier) {
228
+ export function verifyLifecycleChain(carried, contentNodeId, targetSignerIdentifier, binding = {}) {
136
229
  const views = [];
137
230
  for (const entry of carried) {
138
231
  // Reachability: the attestation must reference THIS content node.
@@ -144,20 +237,154 @@ export function verifyLifecycleChain(carried, contentNodeId, targetSignerIdentif
144
237
  continue;
145
238
  const signer = entry.node['signer'];
146
239
  const metadata = entry.node['metadata'];
240
+ const keyBound = isKeyBound(entry.node, entry.signature?.publicKey ?? '', pickString(entry.signature?.kid), binding);
241
+ const namesTarget = !!signer && signer.identifier === targetSignerIdentifier;
242
+ const type = pickString(entry.node['type']) ?? '';
243
+ const missingFields = missingRequiredFields(entry.node, type);
147
244
  views.push({
148
245
  nodeId: entry.nodeId,
149
- type: pickString(entry.node['type']) ?? '',
246
+ type,
150
247
  signer,
151
248
  createdAt: pickString(metadata?.['createdAt']) ?? '',
152
249
  reason: pickString(entry.node['reason']),
153
250
  effectiveAt: pickString(entry.node['effectiveAt']),
154
251
  priorWithdrawalNodeId: pickString(entry.node['priorWithdrawalNodeId']),
252
+ successorNodeId: pickString(entry.node['successorNodeId']),
155
253
  signatureValid: verdict.signatureValid,
156
254
  nodeIdMatches: verdict.nodeIdMatches,
157
255
  hasTimestamp: !!entry.hasTimestamp,
158
256
  hasRekor: !!entry.hasRekor,
159
- signerMatchesTarget: !!signer && signer.identifier === targetSignerIdentifier,
257
+ signerMatchesTarget: namesTarget && keyBound && missingFields.length === 0,
258
+ keyBound,
259
+ namesTarget,
260
+ missingFields,
160
261
  });
161
262
  }
162
263
  return resolveLifecycleFromChain(views);
163
264
  }
265
+ /** The rule of each sub-type `checkAttestationNode` checks. `publishes`,
266
+ * `locatedAt` and the specific-role-required sub-types have none here. */
267
+ export const ATTESTATION_AUTHORIZATION_RULES = Object.freeze({
268
+ [ATTESTATION_WITHDRAWS]: 'publisher-only',
269
+ [ATTESTATION_REINSTATES]: 'publisher-only',
270
+ [ATTESTATION_SUPERSEDES]: 'publisher-only',
271
+ [ATTESTATION_REVISES]: 'publisher-only',
272
+ [ATTESTATION_CORROBORATES]: 'any-with-binding',
273
+ [ATTESTATION_CONTRADICTS]: 'any-with-binding',
274
+ });
275
+ /** Each checked sub-type's required payload fields (spec §8.12.1), beyond the
276
+ * structural primitive. Optional fields (`effectiveAt`, a reinstatement's
277
+ * `reason`, `reasoning`) are not listed. */
278
+ export const ATTESTATION_REQUIRED_FIELDS = Object.freeze({
279
+ [ATTESTATION_WITHDRAWS]: ['targetNodeId', 'reason'],
280
+ [ATTESTATION_REINSTATES]: ['targetNodeId', 'priorWithdrawalNodeId'],
281
+ [ATTESTATION_SUPERSEDES]: ['targetNodeId', 'successorNodeId'],
282
+ [ATTESTATION_REVISES]: ['targetNodeId', 'successorNodeId'],
283
+ [ATTESTATION_CORROBORATES]: ['targetNodeId', 'scope'],
284
+ [ATTESTATION_CONTRADICTS]: ['targetNodeId', 'scope'],
285
+ });
286
+ /** The `signer.bindingTier` values an any-with-binding node may carry: the §8.5
287
+ * graded identity ladder, and `platform` (spec §8.1.1). */
288
+ export const BINDING_TIERS = ['pseudonymous', 'oauth', 'orcid', 'did-web', 'notarized', 'platform'];
289
+ /**
290
+ * What `checkAttestationNode` reads for a node, first failure first:
291
+ * - `node_id_mismatch` — the recomputed envelope hash is not the stored nodeId;
292
+ * - `signature_invalid` — the signature does not verify over it;
293
+ * - `unsigned` — the node carries no signature, so nothing can be bound;
294
+ * - `not_checked` — publisher-only with no attested record supplied, or a
295
+ * sub-type this check has no rule for;
296
+ * - `other_signer` — publisher-only: the node names a signer other than the
297
+ * attested record's, a third party's event;
298
+ * - `publisher_key_unbound` — publisher-only: the node names the attested
299
+ * record's signer, but its signing key is not bound to that signer;
300
+ * - `key_unbound` — any-with-binding: the signing key is not bound to the signer
301
+ * the node names;
302
+ * - `binding_tier_off_ladder` — any-with-binding: `signer.bindingTier` is not in
303
+ * `BINDING_TIERS`;
304
+ * - `authorized` — the sub-type's rule holds.
305
+ */
306
+ export const ATTESTATION_AUTHORIZATION_STATUSES = [
307
+ 'authorized',
308
+ 'not_checked',
309
+ 'other_signer',
310
+ 'publisher_key_unbound',
311
+ 'key_unbound',
312
+ 'binding_tier_off_ladder',
313
+ 'unsigned',
314
+ 'signature_invalid',
315
+ 'node_id_mismatch',
316
+ ];
317
+ function isAbsent(v) {
318
+ return v === undefined || v === null || v === '';
319
+ }
320
+ /** Each payload field §8.12.1 requires of `type` that `node` lacks, by name. */
321
+ function missingRequiredFields(node, type) {
322
+ return (ATTESTATION_REQUIRED_FIELDS[type] ?? []).filter((f) => isAbsent(node[f]));
323
+ }
324
+ /**
325
+ * Check one `attestation/*` node on its own (spec §8.12.3), for `withdraws`,
326
+ * `reinstates`, `supersedes`, `revises`, `corroborates` and `contradicts`, in
327
+ * order:
328
+ * 1. integrity and signature, as `verifyAttestationNode` reads them;
329
+ * 2. the key binding, by the rule `verifyLifecycleChain` applies: a key-derived
330
+ * identifier binds by derivation; any other identifier binds by a registry
331
+ * fetched from its declared URL, and, for a publisher-only node only, by the
332
+ * attested record's own signing key;
333
+ * 3. the sub-type's authorization rule:
334
+ * - publisher-only (the four lifecycle sub-types) runs when `context.target`
335
+ * is supplied, and otherwise reads `not_checked`. A node naming another
336
+ * signer reads `other_signer`; one naming the target's signer under a key
337
+ * not bound to it reads `publisher_key_unbound`;
338
+ * - any-with-binding (`corroborates`, `contradicts`) needs the key bound and a
339
+ * `signer.bindingTier` in `BINDING_TIERS`.
340
+ * It also names each required §8.12.1 payload field the node lacks. Pure; it moves
341
+ * no status: `resolveLifecycleFromChain` alone derives the lifecycle.
342
+ */
343
+ export function checkAttestationNode(carried, context = {}) {
344
+ const node = carried.node;
345
+ const type = pickString(node['type']) ?? '';
346
+ const rule = ATTESTATION_AUTHORIZATION_RULES[type] ?? null;
347
+ const verdict = verifyAttestationNode(node, carried.nodeId, carried.signature ?? null);
348
+ const missingFields = missingRequiredFields(node, type);
349
+ const publicKey = carried.signature?.publicKey ?? '';
350
+ const signed = verdict.nodeIdMatches && verdict.signatureValid === true;
351
+ const keyBound = signed &&
352
+ isKeyBound(node, publicKey, pickString(carried.signature?.kid), {
353
+ ...(rule === 'publisher-only' && context.target ? { targetPublicKey: context.target.publicKey } : {}),
354
+ ...(context.registry ? { registry: context.registry } : {}),
355
+ ...(context.registryProvenance ? { registryProvenance: context.registryProvenance } : {}),
356
+ });
357
+ const result = (status) => ({
358
+ nodeId: verdict.nodeId,
359
+ type,
360
+ rule,
361
+ nodeIdMatches: verdict.nodeIdMatches,
362
+ signatureValid: verdict.signatureValid,
363
+ keyBound,
364
+ status,
365
+ missingFields,
366
+ });
367
+ if (!verdict.nodeIdMatches)
368
+ return result('node_id_mismatch');
369
+ if (verdict.signatureValid === false)
370
+ return result('signature_invalid');
371
+ if (verdict.signatureValid === null)
372
+ return result('unsigned');
373
+ if (rule === null)
374
+ return result('not_checked');
375
+ const signer = node['signer'];
376
+ const identifier = signer && typeof signer === 'object' ? signer.identifier : undefined;
377
+ if (rule === 'publisher-only') {
378
+ if (!context.target)
379
+ return result('not_checked');
380
+ if (identifier !== context.target.signerIdentifier)
381
+ return result('other_signer');
382
+ return result(keyBound ? 'authorized' : 'publisher_key_unbound');
383
+ }
384
+ if (!keyBound)
385
+ return result('key_unbound');
386
+ const tier = signer && typeof signer === 'object' ? signer.bindingTier : undefined;
387
+ if (!BINDING_TIERS.includes(tier))
388
+ return result('binding_tier_off_ladder');
389
+ return result('authorized');
390
+ }
package/dist/verify.d.ts CHANGED
@@ -39,13 +39,20 @@ export interface EnvelopeIntegrityResult {
39
39
  /** Present only when `status === 'unavailable'`. */
40
40
  reason?: ContentUnavailableReason;
41
41
  }
42
- /** Lifecycle STATE as carried by the WS1 sidecar (`CommitmentLifecycle`). */
42
+ /** Lifecycle STATE as carried by the WS1 sidecar (`CommitmentLifecycle`). The
43
+ * STATE path (`resolveLifecycleFromLegacyColumns`) reads only the withdrawal and
44
+ * reinstatement columns, so a sidecar's `superseded` never reaches #10 without
45
+ * the signed chain. */
43
46
  export interface CommitmentLifecycleState {
44
- status: 'active' | 'withdrawn';
47
+ status: 'active' | 'withdrawn' | 'superseded';
45
48
  withdrawnAt?: string;
46
49
  withdrawnReason?: string;
47
50
  reinstatedAt?: string;
48
51
  reinstatedReason?: string;
52
+ /** When the record was superseded (the latest counting `supersedes`). */
53
+ supersededAt?: string;
54
+ /** The superseding record's nodeId. */
55
+ successorNodeId?: string;
49
56
  }
50
57
  /**
51
58
  * Everything `verifyRecord` needs, aligned to the WS1 commitment sidecar +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Portable, browser-safe verification core for Typed Standards record packages — the §9.2 check suite, single-sourced so it verifies identically on a server and in the browser.",
5
5
  "license": "MIT",
6
6
  "author": "Nathan Storey",