@typedstandards/verify-core 0.13.0 → 0.15.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
@@ -1,11 +1,101 @@
1
1
  # Changelog — @typedstandards/verify-core
2
2
 
3
- Factual record of what changed per published version. Check numbers (#1–#16)
3
+ Factual record of what changed per published version. Check numbers (#1–#17)
4
4
  refer to the Typed Standards specification §9.2 verification sequence. Issue
5
5
  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.15.0 — 2026-10-30
10
+
11
+ The R10 release (civic-ai-tools#289; hub ADR-0038 §3, §5 and §6, specification v0.1.13 §8.1.1,
12
+ §8.6 and §9.2). **A minor bump**: new exports, a new `VerifyResult` field, and one combination
13
+ check #16 newly reads `ok`.
14
+
15
+ - **The subtype table (new exports `PRODUCER_PROFILE_SUBTYPES`, `isContentProfileConsistent`, and
16
+ the types `ProducerProfileSubtypeRow` and `ProducerProfileBundleRef`).** Beside the profile-type
17
+ table, one frozen row per built subtype of `ai-assisted-analysis`, keyed by the full
18
+ `producerProfile` value:
19
+ - `ai-assisted-analysis/datHere` admits `datHere`, with no bundle pinned;
20
+ - `ai-assisted-analysis/civicaitools-v1` admits `datHere` and `default`, with its guidance
21
+ bundle pinned by URL
22
+ (`https://raw.githubusercontent.com/npstorey/civic-ai-tools/v0.1.13-typed-standards-spec/docs/profiles/ai-assisted-analysis/civicaitools-v1/bundle.json`)
23
+ and SHA-256 (`724e399e02db8993041b64982eec731505084bec2ec5e6ca58fda2de8087a281`), revision 1.
24
+
25
+ `isContentProfileConsistent(producerProfile, contentProfile)` is the §8.1.1 invariant over the
26
+ table: the content profile is one the subtype's row admits; a subtype with no row admits
27
+ `"datHere"` exactly when it begins with `ai-assisted-analysis/datHere`, and `"default"` exactly
28
+ when it does not. Only the table's own keys are rows. The row does not change the capture
29
+ vocabulary: `PROFILE_CAPTURE_VOCAB`, `resolveProfileType` and `captureVocabForProfile` (check #15)
30
+ are unchanged.
31
+ - **Check #16 reads the predicate.** `checkContentProfile` reports `contentProfile_inconsistent`
32
+ when `isContentProfileConsistent` is false, in place of the `startsWith` comparison. The no-row
33
+ clause is that comparison, so every status is unchanged except one: `datHere` under
34
+ `ai-assisted-analysis/civicaitools-v1` reads `ok` (it read `contentProfile_inconsistent`). No
35
+ published record carries that combination.
36
+ - **Check #3's implicit rule reads the predicate.** For a package with no
37
+ `contentCanonicalization`, a `producerProfile` reads as datHere when its subtype admits
38
+ `"datHere"` and not `"default"`, which is the `startsWith` reading for the `datHere` row and every
39
+ subtype with no row; under `ai-assisted-analysis/civicaitools-v1` the choice is
40
+ `metadata.contentProfile`'s. Every result is unchanged.
41
+ - **Check #17, the content-profile system prompt (new exports `checkSystemPrompt`,
42
+ `SYSTEM_PROMPT_STATUSES`, and the types `SystemPromptStatus` and `SystemPromptCheck`).** When
43
+ `metadata.contentProfile` is `"datHere"`, `skillMetadata.skillText` as a non-empty string reads
44
+ `ok` (`form: 'string'`) and as a BlobRef `ok` (`form: 'blobRef'`); absent, empty, or neither
45
+ reads `skillText_absent`. Any other content profile (absent, `"default"` or unknown) reads
46
+ `not_applicable`. `skillText_absent` is an attention status and never a failure. The check does
47
+ not resolve a BlobRef (check #9 does).
48
+ - **`VerifyResult` gains `systemPrompt`** (`SystemPromptCheck | null`, null when the package is
49
+ absent), set by `verifyRecord` beside `contentProfile`. Every other field of the result is
50
+ unchanged. A consumer that builds a `VerifyResult` literal needs the new field.
51
+ - **Every earlier fixture reads as before.** Over the signed bundles, signed documents and package
52
+ objects in this repository's fixtures and the hub harness's `reference-golden.json` and
53
+ `in-loop-golden.json`, `verifyRecord`, `checkContentProfile`, `resolveContentCanonicalization`
54
+ and `captureVocabForProfile` give byte-identical output to 0.14.0 once `systemPrompt` is removed.
55
+ Check #17 reads `skillText_absent` on the five fixture packages that declare `datHere` without
56
+ `skillText` (the hub reference golden's `v01-dathere-empty-notebook` and
57
+ `v01-dathere-executed-notebook`, their copies in produce-core's `reference-golden.json`, and the
58
+ hub's `in-loop-golden.json`), `ok` on the site's `q15-d67b8e` record, and `not_applicable` on
59
+ every other.
60
+ - **Every lookup of a verify-core table by a value a record or bundle supplies reads only the
61
+ table's own entries;** a malformed value reads as an unknown one. Every well-formed value reads as
62
+ before.
63
+ - **A local copy of the bundle**, `src/__fixtures__/civicaitools-v1-guidance-bundle.json` (not
64
+ published), byte-identical to the hub file at tree `7060f5e3`. A test asserts its SHA-256,
65
+ `subtype`, `url` and admitted set against the row, without the network. Its provenance is in
66
+ `src/__fixtures__/README.md`.
67
+
68
+ ## 0.14.0 — 2026-10-16
69
+
70
+ The W5 release (civic-ai-tools#260, shipped in #146 and #147). **A minor bump**: new exports
71
+ (`CONTENT_LAYOUT`; `parseStrictJson` and `RepeatedMemberNameError`), and a type URI check #12 newly
72
+ reads `ok`.
73
+
74
+ - **`content/layout/v1` is registered (hub ADR-0034; spec §8.14).** New export `CONTENT_LAYOUT`
75
+ (`'content/layout/v1'`). `KNOWN_TYPE_URIS` holds eighteen entries: `content/analysis/v1`, then
76
+ `content/layout/v1`, then the sixteen attestation sub-types in the §8.12.1 table order, unchanged.
77
+ Check #12 reads `ok` for a layout node, where it read `unknown_type`. No payload check is added: a
78
+ layout node's envelope verifies as any node's (§8.14.5).
79
+ - **`parseStrictJson` and `RepeatedMemberNameError` (new exports; typedstandards#145,
80
+ specification §8.2 and §9.2 check #1).** `parseStrictJson(text: string | Uint8Array)`
81
+ refuses a JSON text that repeats a member name within an object, which is not I-JSON
82
+ (RFC 7493), the input RFC 8785 JCS requires. It throws `RepeatedMemberNameError`
83
+ (`name` `'RepeatedMemberNameError'`), carrying `memberName` (unescaped) and
84
+ `pointer`, the JSON Pointer (RFC 6901) of the object that holds it. Names compare
85
+ after unescaping; the same name in two different objects is not a repeat. A text with
86
+ no repeated name returns exactly what `JSON.parse` returns, and a text that is not
87
+ JSON throws what `JSON.parse` throws, unchanged. Bytes are decoded as UTF-8 with
88
+ `fatal`. The walk keeps its own stack, so nesting the platform parser accepts does
89
+ not overflow it. No dependency is added.
90
+ - **`verifyRecord` is unchanged:** it takes a parsed package, so the refusal belongs to
91
+ whoever reads the text. `JSON.parse` keeps the last occurrence of a repeated name, and
92
+ the envelope hash is computed over the parsed object, so a caller that parses a
93
+ package with it accepts a text the specification refuses.
94
+ - **A negative vector**, `src/__fixtures__/repeated-member-name.bundle.json` (not
95
+ published): the site's minted interop bundle with a second, earlier `summary` member in
96
+ its package, which under `JSON.parse` gives the signed package's envelope and content
97
+ hashes. Its provenance is in `src/__fixtures__/README.md`.
98
+
9
99
  ## 0.13.0 — 2026-09-27
10
100
 
11
101
  The core-minors release (typedstandards#113, shipped in #114, #115 and #118). **A minor bump** —
package/README.md CHANGED
@@ -64,6 +64,14 @@ const result = await verifyRecord(input, {
64
64
  // contentHash, typeResolution, signerIdentity, lifecycle, ...
65
65
  ```
66
66
 
67
+ > **Parse the package's text with `parseStrictJson`.** A record package's JSON text is
68
+ > I-JSON (RFC 7493), so a verifier refuses a text that repeats a member name within an
69
+ > object, before any hash is computed (spec §8.2, §9.2 check #1). `JSON.parse` keeps the
70
+ > last occurrence and drops the others, and `verifyRecord` takes the parsed object, so it
71
+ > cannot see a repeat. `parseStrictJson(text)` (a string, or bytes decoded as UTF-8)
72
+ > throws `RepeatedMemberNameError`, with `memberName` and `pointer` (the JSON Pointer of
73
+ > the object), on a repeated name, and otherwise returns what `JSON.parse` returns.
74
+
67
75
  > **`verifyEvidence` is the same function.** The 2026-08-19 vocabulary settlement
68
76
  > renamed the entry point to `verifyRecord` (spec Appendix J) — "evidence" is
69
77
  > retired from the artifact/infrastructure brand role and kept only for the
package/dist/checks.d.ts CHANGED
@@ -22,7 +22,12 @@ export interface ContentCanonicalizationResolution {
22
22
  * `unknown_canonicalization_rule` (renders; check #4 then cannot recompute). An
23
23
  * absent field on a pre-v0.1 package → `implicit`, inferring the rule from
24
24
  * contentProfile / producerProfile (datHere → dathere-ag-jupyter/v1; otherwise
25
- * legacy-json/v1).
25
+ * legacy-json/v1). A `producerProfile` reads as datHere when the subtype admits
26
+ * `"datHere"` and not `"default"` under the §8.1.1 invariant
27
+ * (`isContentProfileConsistent`): with no row, exactly when it begins with
28
+ * `ai-assisted-analysis/datHere`, as before; a subtype whose row admits both, as
29
+ * `ai-assisted-analysis/civicaitools-v1` does, leaves the choice to
30
+ * `contentProfile`.
26
31
  */
27
32
  export declare function resolveContentCanonicalization(pkg: Record<string, unknown>): ContentCanonicalizationResolution;
28
33
  export declare const CONTENT_HASH_STATUSES: readonly ["ok", "content_hash_mismatch", "contentHash_no_supported_algorithm", "unresolved_rule", "legacy_relabeled", "content_bytes_unavailable"];
@@ -94,14 +99,39 @@ export interface ContentProfileCheck {
94
99
  * - key absent → `contentProfile_absent` (read as `"default"`, spec §8.1.2).
95
100
  * - present and neither `"default"` nor `"datHere"` → `contentProfile_unknown`
96
101
  * (reported, not passed; consistency is not judged).
97
- * - present, known, `producerProfile` present, and the ADR-0006 §2 invariant
98
- * fails (`contentProfile === "datHere"` iff `producerProfile` starts with
102
+ * - present, known, `producerProfile` present, and the §8.1.1 invariant fails
103
+ * (ADR-0006 §2 as amended by hub ADR-0038 §3: the content profile is not one
104
+ * the subtype's row admits, `isContentProfileConsistent`; a subtype with no
105
+ * row admits `"datHere"` exactly when it begins with
99
106
  * `ai-assisted-analysis/datHere`) → `contentProfile_inconsistent`.
100
107
  * - otherwise → `ok`.
101
108
  * The invariant is compared only when both fields are present. No status is an
102
109
  * integrity failure: both labels are signature-covered.
103
110
  */
104
111
  export declare function checkContentProfile(pkg: Record<string, unknown>): ContentProfileCheck;
112
+ export declare const SYSTEM_PROMPT_STATUSES: readonly ["ok", "skillText_absent", "not_applicable"];
113
+ export type SystemPromptStatus = (typeof SYSTEM_PROMPT_STATUSES)[number];
114
+ export interface SystemPromptCheck {
115
+ status: SystemPromptStatus;
116
+ /** On `ok`, how the text is carried: inline, or a BlobRef (which check #9 resolves). */
117
+ form?: 'string' | 'blobRef';
118
+ }
119
+ /**
120
+ * Check #17 — the system prompt §8.7.1 requirement 2 requires of a
121
+ * `datHere`-content-profile package (hub ADR-0038 §6).
122
+ * - `metadata.contentProfile` not `"datHere"` (absent, `"default"` or unknown)
123
+ * → `not_applicable`, and nothing else is read.
124
+ * - `"datHere"` and `skillMetadata.skillText` a non-empty string → `ok`
125
+ * (`form: 'string'`), or a BlobRef → `ok` (`form: 'blobRef'`).
126
+ * - `"datHere"` and the key absent, empty, or neither a string nor a BlobRef
127
+ * → `skillText_absent`.
128
+ * `skillText_absent` is an attention status, never a failure: it says nothing
129
+ * about the envelope, the signature or the notebook. The check does not resolve
130
+ * a BlobRef (check #9 does), and cannot check that the text is the prompt the
131
+ * model ran under.
132
+ */
133
+ export declare function checkSystemPrompt(pkg: Record<string, unknown>): SystemPromptCheck;
134
+ export declare const CONTENT_LAYOUT = "content/layout/v1";
105
135
  export declare const KNOWN_TYPE_URIS: readonly string[];
106
136
  export declare const TYPE_RESOLUTION_STATUSES: readonly ["ok", "implicit", "unknown_type"];
107
137
  export type TypeResolutionStatus = (typeof TYPE_RESOLUTION_STATUSES)[number];
package/dist/checks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Content-canonicalization, content-hash, and typed-standards envelope checks
2
- // (spec §9.2 checks #3, #4, #6, #12, #13, #14, #15, #16) + the package-level
2
+ // (spec §9.2 checks #3, #4, #6, #12, #13, #14, #15, #16, #17) + the package-level
3
3
  // blob-ref walker (#9) — browser-safe.
4
4
  //
5
5
  // Factored verbatim from the server `verify.ts` (WS2); these functions were
@@ -8,7 +8,7 @@
8
8
  // for pre-v0.1 packages that omit the corresponding field.
9
9
  import { computeEnvelopeHash, computeContentHashSha256, isMultihashContentHash, KNOWN_CANONICALIZATION_RULES, LEGACY_JSON_CANONICALIZATION, DATHERE_AG_JUPYTER_CANONICALIZATION, RAW_BYTES_CANONICALIZATION, } from "./canonicalization.js";
10
10
  import { sha256Hex } from "./primitives.js";
11
- import { captureVocabForProfile, resolveProfileType } from "./profiles.js";
11
+ import { captureVocabForProfile, isContentProfileConsistent, resolveProfileType } from "./profiles.js";
12
12
  import { isBlobRef, parseBlobRef, verifyBlobRef, } from "./blob-ref.js";
13
13
  import { isKeyDerivedIdentifier } from "./did-key.js";
14
14
  import { compareKeyDerivedIdentifier } from "./self-certifying.js";
@@ -34,7 +34,12 @@ export const CONTENT_CANONICALIZATION_STATUSES = [
34
34
  * `unknown_canonicalization_rule` (renders; check #4 then cannot recompute). An
35
35
  * absent field on a pre-v0.1 package → `implicit`, inferring the rule from
36
36
  * contentProfile / producerProfile (datHere → dathere-ag-jupyter/v1; otherwise
37
- * legacy-json/v1).
37
+ * legacy-json/v1). A `producerProfile` reads as datHere when the subtype admits
38
+ * `"datHere"` and not `"default"` under the §8.1.1 invariant
39
+ * (`isContentProfileConsistent`): with no row, exactly when it begins with
40
+ * `ai-assisted-analysis/datHere`, as before; a subtype whose row admits both, as
41
+ * `ai-assisted-analysis/civicaitools-v1` does, leaves the choice to
42
+ * `contentProfile`.
38
43
  */
39
44
  export function resolveContentCanonicalization(pkg) {
40
45
  const raw = pkg['contentCanonicalization'];
@@ -54,7 +59,9 @@ export function resolveContentCanonicalization(pkg) {
54
59
  ? pkg['producerProfile']
55
60
  : undefined;
56
61
  const isDatHere = contentProfile === 'datHere' ||
57
- (producerProfile?.startsWith('ai-assisted-analysis/datHere') ?? false);
62
+ (producerProfile !== undefined &&
63
+ isContentProfileConsistent(producerProfile, 'datHere') &&
64
+ !isContentProfileConsistent(producerProfile, 'default'));
58
65
  return {
59
66
  status: 'implicit',
60
67
  rule: isDatHere
@@ -197,7 +204,7 @@ export async function verifyContentHashWithFetch(pkg, resolution, legacyExternal
197
204
  return first;
198
205
  return verifyContentHash(pkg, resolution, legacyExternalHash, bytes);
199
206
  }
200
- // --- Content-profile check (spec §9.2 check #16; hub ADR-0029 §5) ---
207
+ // --- Content-profile check (spec §9.2 check #16; hub ADR-0029 §5, ADR-0038 §3) ---
201
208
  /** The known `metadata.contentProfile` values (spec §8.1.2). */
202
209
  export const KNOWN_CONTENT_PROFILES = ['default', 'datHere'];
203
210
  export const CONTENT_PROFILE_STATUSES = [
@@ -211,8 +218,10 @@ export const CONTENT_PROFILE_STATUSES = [
211
218
  * - key absent → `contentProfile_absent` (read as `"default"`, spec §8.1.2).
212
219
  * - present and neither `"default"` nor `"datHere"` → `contentProfile_unknown`
213
220
  * (reported, not passed; consistency is not judged).
214
- * - present, known, `producerProfile` present, and the ADR-0006 §2 invariant
215
- * fails (`contentProfile === "datHere"` iff `producerProfile` starts with
221
+ * - present, known, `producerProfile` present, and the §8.1.1 invariant fails
222
+ * (ADR-0006 §2 as amended by hub ADR-0038 §3: the content profile is not one
223
+ * the subtype's row admits, `isContentProfileConsistent`; a subtype with no
224
+ * row admits `"datHere"` exactly when it begins with
216
225
  * `ai-assisted-analysis/datHere`) → `contentProfile_inconsistent`.
217
226
  * - otherwise → `ok`.
218
227
  * The invariant is compared only when both fields are present. No status is an
@@ -234,25 +243,61 @@ export function checkContentProfile(pkg) {
234
243
  if (typeof raw !== 'string' || !KNOWN_CONTENT_PROFILES.includes(raw)) {
235
244
  return { status: 'contentProfile_unknown', ...base };
236
245
  }
237
- if (producerProfile !== undefined) {
238
- const isDatHere = raw === 'datHere';
239
- const profileIsDatHere = producerProfile.startsWith('ai-assisted-analysis/datHere');
240
- if (isDatHere !== profileIsDatHere) {
241
- return { status: 'contentProfile_inconsistent', ...base };
242
- }
246
+ if (producerProfile !== undefined && !isContentProfileConsistent(producerProfile, raw)) {
247
+ return { status: 'contentProfile_inconsistent', ...base };
243
248
  }
244
249
  return { status: 'ok', ...base };
245
250
  }
251
+ // --- Content-profile system prompt (spec §9.2 check #17; hub ADR-0038 §6) ---
252
+ export const SYSTEM_PROMPT_STATUSES = ['ok', 'skillText_absent', 'not_applicable'];
253
+ /**
254
+ * Check #17 — the system prompt §8.7.1 requirement 2 requires of a
255
+ * `datHere`-content-profile package (hub ADR-0038 §6).
256
+ * - `metadata.contentProfile` not `"datHere"` (absent, `"default"` or unknown)
257
+ * → `not_applicable`, and nothing else is read.
258
+ * - `"datHere"` and `skillMetadata.skillText` a non-empty string → `ok`
259
+ * (`form: 'string'`), or a BlobRef → `ok` (`form: 'blobRef'`).
260
+ * - `"datHere"` and the key absent, empty, or neither a string nor a BlobRef
261
+ * → `skillText_absent`.
262
+ * `skillText_absent` is an attention status, never a failure: it says nothing
263
+ * about the envelope, the signature or the notebook. The check does not resolve
264
+ * a BlobRef (check #9 does), and cannot check that the text is the prompt the
265
+ * model ran under.
266
+ */
267
+ export function checkSystemPrompt(pkg) {
268
+ const metadata = pkg['metadata'];
269
+ const contentProfile = typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)
270
+ ? metadata['contentProfile']
271
+ : undefined;
272
+ if (contentProfile !== 'datHere')
273
+ return { status: 'not_applicable' };
274
+ const skillMetadata = pkg['skillMetadata'];
275
+ const skillText = typeof skillMetadata === 'object' && skillMetadata !== null && !Array.isArray(skillMetadata)
276
+ ? skillMetadata['skillText']
277
+ : undefined;
278
+ if (typeof skillText === 'string' && skillText.length > 0)
279
+ return { status: 'ok', form: 'string' };
280
+ if (isBlobRef(skillText))
281
+ return { status: 'ok', form: 'blobRef' };
282
+ return { status: 'skillText_absent' };
283
+ }
246
284
  // --- Typed-standards envelope checks (spec §9.2 checks #12, #14, #15) ---
247
- // v0.1 ratified node type URIs (spec §8.12.1). Recognized so check #12 resolves
248
- // them; only `content/analysis/v1` (+ the lifecycle sub-types) are operationalized
249
- // today, but the full ratified set is registered so conformant packages don't
250
- // render as `unknown_type`. The sixteen attestation sub-types are in the order
251
- // of the §8.12.1 table; `type-resolution.test.ts` holds that table's copy.
252
- // Exported and frozen so a check outside this package can compare the registered
253
- // set with the specification's list without being able to change it.
285
+ // The `content/layout/v1` sub-type (spec §8.14, hub ADR-0034): a standalone
286
+ // assertion of how a host's page arranges a node's parts. Registered for check
287
+ // #12; verify-core runs no payload check on it (§8.14.5).
288
+ export const CONTENT_LAYOUT = 'content/layout/v1';
289
+ // v0.1 ratified node type URIs. Recognized so check #12 resolves them; only
290
+ // `content/analysis/v1` (+ the lifecycle sub-types) are operationalized today,
291
+ // but the full ratified set is registered so conformant packages don't render
292
+ // as `unknown_type`. The two `content/*` sub-types come first:
293
+ // `content/analysis/v1`, then `content/layout/v1` (§8.14). The sixteen
294
+ // attestation sub-types follow in the order of the §8.12.1 table;
295
+ // `type-resolution.test.ts` holds that table's copy. Exported and frozen so a
296
+ // check outside this package can compare the registered set with the
297
+ // specification's list without being able to change it.
254
298
  export const KNOWN_TYPE_URIS = Object.freeze([
255
299
  'content/analysis/v1',
300
+ CONTENT_LAYOUT,
256
301
  'attestation/withdraws/v1',
257
302
  'attestation/reinstates/v1',
258
303
  'attestation/supersedes/v1',
package/dist/index.d.ts CHANGED
@@ -15,3 +15,4 @@ export * from './asn1.ts';
15
15
  export * from './x509.ts';
16
16
  export * from './rfc3161.ts';
17
17
  export * from './verify.ts';
18
+ export * from './strict-json.ts';
package/dist/index.js CHANGED
@@ -29,3 +29,4 @@ export * from "./asn1.js";
29
29
  export * from "./x509.js";
30
30
  export * from "./rfc3161.js";
31
31
  export * from "./verify.js";
32
+ export * from "./strict-json.js";
package/dist/lifecycle.js CHANGED
@@ -319,7 +319,8 @@ function isAbsent(v) {
319
319
  }
320
320
  /** Each payload field §8.12.1 requires of `type` that `node` lacks, by name. */
321
321
  function missingRequiredFields(node, type) {
322
- return (ATTESTATION_REQUIRED_FIELDS[type] ?? []).filter((f) => isAbsent(node[f]));
322
+ const required = Object.hasOwn(ATTESTATION_REQUIRED_FIELDS, type) ? ATTESTATION_REQUIRED_FIELDS[type] : [];
323
+ return required.filter((f) => isAbsent(node[f]));
323
324
  }
324
325
  /**
325
326
  * Check one `attestation/*` node on its own (spec §8.12.3), for `withdraws`,
@@ -343,7 +344,7 @@ function missingRequiredFields(node, type) {
343
344
  export function checkAttestationNode(carried, context = {}) {
344
345
  const node = carried.node;
345
346
  const type = pickString(node['type']) ?? '';
346
- const rule = ATTESTATION_AUTHORIZATION_RULES[type] ?? null;
347
+ const rule = Object.hasOwn(ATTESTATION_AUTHORIZATION_RULES, type) ? ATTESTATION_AUTHORIZATION_RULES[type] : null;
347
348
  const verdict = verifyAttestationNode(node, carried.nodeId, carried.signature ?? null);
348
349
  const missingFields = missingRequiredFields(node, type);
349
350
  const publicKey = carried.signature?.publicKey ?? '';
@@ -18,3 +18,32 @@ export declare function resolveProfileType(producerProfile?: string | null, cont
18
18
  * `producerProfile_bundle_unresolved` degraded case).
19
19
  */
20
20
  export declare function captureVocabForProfile(producerProfile?: string | null, contentProfile?: string | null): readonly CaptureMethod[] | undefined;
21
+ /** A guidance bundle pinned by its address and the SHA-256 of its bytes. */
22
+ export interface ProducerProfileBundleRef {
23
+ readonly url: string;
24
+ /** Lowercase hex SHA-256 of the bundle file's bytes. */
25
+ readonly sha256: string;
26
+ }
27
+ /** One built subtype's row. */
28
+ export interface ProducerProfileSubtypeRow {
29
+ /** The `metadata.contentProfile` values the subtype admits. */
30
+ readonly admits: readonly string[];
31
+ /** The subtype's guidance bundle, when one is pinned. */
32
+ readonly bundle?: ProducerProfileBundleRef;
33
+ }
34
+ /**
35
+ * The subtype table, keyed by the full `producerProfile` value. The
36
+ * `ai-assisted-analysis/civicaitools-v1` bundle is revision 1 (2026-10-30), as
37
+ * hub ADR-0038 §5 records its address and digest; a later revision moves the
38
+ * digest and this pin, and nothing else.
39
+ */
40
+ export declare const PRODUCER_PROFILE_SUBTYPES: Readonly<Record<string, ProducerProfileSubtypeRow>>;
41
+ /**
42
+ * The §8.1.1 consistency invariant, for a package that carries both
43
+ * `producerProfile` and `metadata.contentProfile`: whether the content profile is
44
+ * one the subtype's row admits. A subtype with no row admits `"datHere"` exactly
45
+ * when it begins with `ai-assisted-analysis/datHere`, and `"default"` exactly when
46
+ * it does not: the reading every earlier release applied, so a record whose
47
+ * subtype has no row reads as before. Check #16 reads it.
48
+ */
49
+ export declare function isContentProfileConsistent(producerProfile: string, contentProfile: string): boolean;
package/dist/profiles.js CHANGED
@@ -40,5 +40,38 @@ export function resolveProfileType(producerProfile, contentProfile) {
40
40
  * `producerProfile_bundle_unresolved` degraded case).
41
41
  */
42
42
  export function captureVocabForProfile(producerProfile, contentProfile) {
43
- return PROFILE_CAPTURE_VOCAB[resolveProfileType(producerProfile, contentProfile)];
43
+ const profileType = resolveProfileType(producerProfile, contentProfile);
44
+ return Object.hasOwn(PROFILE_CAPTURE_VOCAB, profileType) ? PROFILE_CAPTURE_VOCAB[profileType] : undefined;
45
+ }
46
+ /**
47
+ * The subtype table, keyed by the full `producerProfile` value. The
48
+ * `ai-assisted-analysis/civicaitools-v1` bundle is revision 1 (2026-10-30), as
49
+ * hub ADR-0038 §5 records its address and digest; a later revision moves the
50
+ * digest and this pin, and nothing else.
51
+ */
52
+ export const PRODUCER_PROFILE_SUBTYPES = Object.freeze({
53
+ // No bundle pinned: the integration partner's, not this table's to name.
54
+ 'ai-assisted-analysis/datHere': Object.freeze({ admits: Object.freeze(['datHere']) }),
55
+ 'ai-assisted-analysis/civicaitools-v1': Object.freeze({
56
+ admits: Object.freeze(['datHere', 'default']),
57
+ bundle: Object.freeze({
58
+ url: 'https://raw.githubusercontent.com/npstorey/civic-ai-tools/v0.1.13-typed-standards-spec/docs/profiles/ai-assisted-analysis/civicaitools-v1/bundle.json',
59
+ sha256: '724e399e02db8993041b64982eec731505084bec2ec5e6ca58fda2de8087a281',
60
+ }),
61
+ }),
62
+ });
63
+ const DATHERE_PREFIX = 'ai-assisted-analysis/datHere';
64
+ /**
65
+ * The §8.1.1 consistency invariant, for a package that carries both
66
+ * `producerProfile` and `metadata.contentProfile`: whether the content profile is
67
+ * one the subtype's row admits. A subtype with no row admits `"datHere"` exactly
68
+ * when it begins with `ai-assisted-analysis/datHere`, and `"default"` exactly when
69
+ * it does not: the reading every earlier release applied, so a record whose
70
+ * subtype has no row reads as before. Check #16 reads it.
71
+ */
72
+ export function isContentProfileConsistent(producerProfile, contentProfile) {
73
+ if (Object.hasOwn(PRODUCER_PROFILE_SUBTYPES, producerProfile)) {
74
+ return PRODUCER_PROFILE_SUBTYPES[producerProfile].admits.includes(contentProfile);
75
+ }
76
+ return producerProfile.startsWith(DATHERE_PREFIX) ? contentProfile === 'datHere' : contentProfile === 'default';
44
77
  }
package/dist/rekor.js CHANGED
@@ -31,7 +31,7 @@ export async function verifyRekorEntry(entryId, packageHash, options = {}) {
31
31
  return { verified: false };
32
32
  }
33
33
  const result = (await response.json());
34
- const entry = result[entryId] || Object.values(result)[0];
34
+ const entry = (Object.hasOwn(result, entryId) ? result[entryId] : undefined) || Object.values(result)[0];
35
35
  if (!entry)
36
36
  return { verified: false };
37
37
  // Decode the base64 body and cross-check both hash algorithm and value.
@@ -0,0 +1,24 @@
1
+ /** A JSON text repeats a member name within one object, so it is not I-JSON (RFC 7493). */
2
+ export declare class RepeatedMemberNameError extends Error {
3
+ /** The repeated member name, unescaped. */
4
+ readonly memberName: string;
5
+ /** The JSON Pointer (RFC 6901) of the object that holds it: `""` for the top-level value. */
6
+ readonly pointer: string;
7
+ constructor(memberName: string, pointer: string);
8
+ }
9
+ /**
10
+ * Parse a record's JSON text strictly: refuse a text that repeats a member name
11
+ * within an object (I-JSON, RFC 7493; specification §8.2), before any hash is
12
+ * computed over what it holds.
13
+ *
14
+ * - Bytes are decoded as UTF-8 with `fatal`, so malformed UTF-8 throws the
15
+ * decoder's `TypeError` rather than being replaced. A leading byte order mark is
16
+ * removed, as the decoder does by default.
17
+ * - A text that is not JSON throws what `JSON.parse` throws, unchanged.
18
+ * - A text that repeats a member name within one object throws
19
+ * `RepeatedMemberNameError`, naming the member and the JSON Pointer of the
20
+ * object. Names compare after unescaping, so `"a"` repeats `"a"`; the same
21
+ * name in two different objects is not a repeat.
22
+ * - Otherwise it returns `JSON.parse`'s value.
23
+ */
24
+ export declare function parseStrictJson(text: string | Uint8Array): unknown;
@@ -0,0 +1,109 @@
1
+ // The strict parse of a record's JSON text (typedstandards#145; specification §8.2,
2
+ // §9.2 check #1; hub ADR-0035).
3
+ //
4
+ // A record package's JSON text is I-JSON (RFC 7493), the input RFC 8785 JCS
5
+ // requires, and I-JSON allows no member name to repeat within an object. The
6
+ // platform's `JSON.parse` keeps the last occurrence of a repeated name and drops
7
+ // the earlier ones without a word, and the envelope hash is computed over the
8
+ // parsed object, so a text with a repeated name hashes as the text without it while
9
+ // a reader that keeps another occurrence sees other content. `parseStrictJson`
10
+ // refuses such a text, and otherwise returns exactly what `JSON.parse` returns.
11
+ //
12
+ // Pure: no I/O, no dependency, browser-safe. The walk keeps its own stack, so any
13
+ // nesting the platform parser accepts is walked without recursion.
14
+ /** A JSON text repeats a member name within one object, so it is not I-JSON (RFC 7493). */
15
+ export class RepeatedMemberNameError extends Error {
16
+ /** The repeated member name, unescaped. */
17
+ memberName;
18
+ /** The JSON Pointer (RFC 6901) of the object that holds it: `""` for the top-level value. */
19
+ pointer;
20
+ constructor(memberName, pointer) {
21
+ super(`JSON text is not I-JSON (RFC 7493): the member name ${JSON.stringify(memberName)} is repeated in the object at JSON Pointer ${JSON.stringify(pointer)}`);
22
+ this.name = 'RepeatedMemberNameError';
23
+ this.memberName = memberName;
24
+ this.pointer = pointer;
25
+ }
26
+ }
27
+ const QUOTE = 0x22;
28
+ const BACKSLASH = 0x5c;
29
+ const OPEN_OBJECT = 0x7b;
30
+ const CLOSE_OBJECT = 0x7d;
31
+ const OPEN_ARRAY = 0x5b;
32
+ const CLOSE_ARRAY = 0x5d;
33
+ const COMMA = 0x2c;
34
+ const escapeSegment = (s) => s.replace(/~/g, '~0').replace(/\//g, '~1');
35
+ /** The index of the quote that closes the string opening at `start`, in text known to be JSON. */
36
+ function stringEnd(text, start) {
37
+ let i = start + 1;
38
+ for (;;) {
39
+ const c = text.charCodeAt(i);
40
+ if (c === BACKSLASH)
41
+ i += 2;
42
+ else if (c === QUOTE)
43
+ return i;
44
+ else
45
+ i += 1;
46
+ }
47
+ }
48
+ /** Throw `RepeatedMemberNameError` at the first repeated name, in text `JSON.parse` has accepted. */
49
+ function refuseRepeatedNames(text) {
50
+ const stack = [];
51
+ const segments = [];
52
+ const n = text.length;
53
+ for (let i = 0; i < n; i++) {
54
+ const c = text.charCodeAt(i);
55
+ if (c === QUOTE) {
56
+ const end = stringEnd(text, i);
57
+ const top = stack[stack.length - 1];
58
+ if (top?.names && top.expectName) {
59
+ const raw = text.slice(i + 1, end);
60
+ const name = raw.includes('\\') ? JSON.parse(text.slice(i, end + 1)) : raw;
61
+ if (top.names.has(name))
62
+ throw new RepeatedMemberNameError(name, segments.map((s) => `/${escapeSegment(s)}`).join(''));
63
+ top.names.add(name);
64
+ top.segment = name;
65
+ top.expectName = false;
66
+ }
67
+ i = end;
68
+ }
69
+ else if (c === OPEN_OBJECT || c === OPEN_ARRAY) {
70
+ const parent = stack[stack.length - 1];
71
+ if (parent)
72
+ segments.push(parent.names ? parent.segment : String(parent.index));
73
+ stack.push({ names: c === OPEN_OBJECT ? new Set() : null, expectName: c === OPEN_OBJECT, segment: '', index: 0 });
74
+ }
75
+ else if (c === CLOSE_OBJECT || c === CLOSE_ARRAY) {
76
+ stack.pop();
77
+ if (stack.length > 0)
78
+ segments.pop();
79
+ }
80
+ else if (c === COMMA) {
81
+ const top = stack[stack.length - 1];
82
+ if (top.names)
83
+ top.expectName = true;
84
+ else
85
+ top.index += 1;
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Parse a record's JSON text strictly: refuse a text that repeats a member name
91
+ * within an object (I-JSON, RFC 7493; specification §8.2), before any hash is
92
+ * computed over what it holds.
93
+ *
94
+ * - Bytes are decoded as UTF-8 with `fatal`, so malformed UTF-8 throws the
95
+ * decoder's `TypeError` rather than being replaced. A leading byte order mark is
96
+ * removed, as the decoder does by default.
97
+ * - A text that is not JSON throws what `JSON.parse` throws, unchanged.
98
+ * - A text that repeats a member name within one object throws
99
+ * `RepeatedMemberNameError`, naming the member and the JSON Pointer of the
100
+ * object. Names compare after unescaping, so `"a"` repeats `"a"`; the same
101
+ * name in two different objects is not a repeat.
102
+ * - Otherwise it returns `JSON.parse`'s value.
103
+ */
104
+ export function parseStrictJson(text) {
105
+ const source = typeof text === 'string' ? text : new TextDecoder('utf-8', { fatal: true }).decode(text);
106
+ const value = JSON.parse(source);
107
+ refuseRepeatedNames(source);
108
+ return value;
109
+ }
package/dist/verify.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type RekorInclusionProof, type RekorInclusionResult } from './rekor-inclusion.ts';
2
2
  import { type Rfc3161VerifyResult } from './rfc3161.ts';
3
3
  import { type KeyTrustResult, type TrustRegistry, type TrustRegistryProvenance } from './trust-registry.ts';
4
- import { type ContentCanonicalizationResolution, type ContentHashCheck, type TypeResolution, type SignerIdentityCheck, type SigningKeyIdConsistencyCheck, type CaptureMethodVocabCheck, type ContentProfileCheck, type BlobRefVerification } from './checks.ts';
4
+ import { type ContentCanonicalizationResolution, type ContentHashCheck, type TypeResolution, type SignerIdentityCheck, type SigningKeyIdConsistencyCheck, type CaptureMethodVocabCheck, type ContentProfileCheck, type SystemPromptCheck, type BlobRefVerification } from './checks.ts';
5
5
  import { type LifecycleResolution } from './lifecycle.ts';
6
6
  import type { FetchLike } from './types.ts';
7
7
  /** The parsed signature envelope (`buildCommitmentView`'s `signature`). */
@@ -163,6 +163,9 @@ export interface VerifyResult {
163
163
  captureMethodVocab: CaptureMethodVocabCheck | null;
164
164
  /** Check #16 — `metadata.contentProfile` (hub ADR-0029 §5). */
165
165
  contentProfile: ContentProfileCheck | null;
166
+ /** Check #17 — whether a `datHere`-content-profile package carries
167
+ * `skillMetadata.skillText` (hub ADR-0038 §6). Null when the package is absent. */
168
+ systemPrompt: SystemPromptCheck | null;
166
169
  lifecycle: LifecycleResolution;
167
170
  }
168
171
  /**
package/dist/verify.js CHANGED
@@ -35,7 +35,7 @@ import { verifyRekorInclusion, } from "./rekor-inclusion.js";
35
35
  import { verifyRfc3161Timestamp } from "./rfc3161.js";
36
36
  import { verifyKeyTrust, legacyEmbeddedKeyTrust, } from "./trust-registry.js";
37
37
  import { applyKeyDerivedRegistryRule, applySelfCertifiedKeyTrust, compareKeyDerivedIdentifier, } from "./self-certifying.js";
38
- import { resolveContentCanonicalization, verifyContentHashWithFetch, resolvePackageType, checkSignerIdentity, checkSigningKeyIdConsistency, checkCaptureMethodVocab, checkContentProfile, verifyPackageBlobRefs, } from "./checks.js";
38
+ import { resolveContentCanonicalization, verifyContentHashWithFetch, resolvePackageType, checkSignerIdentity, checkSigningKeyIdConsistency, checkCaptureMethodVocab, checkContentProfile, checkSystemPrompt, verifyPackageBlobRefs, } from "./checks.js";
39
39
  import { resolveLifecycleFromLegacyColumns, } from "./lifecycle.js";
40
40
  /**
41
41
  * A fetcher that sends at most one request per URL and replays that request's
@@ -204,7 +204,7 @@ export async function verifyRecord(input, deps) {
204
204
  : applyKeyDerivedRegistryRule(keyTrust, deps.registryProvenance);
205
205
  }
206
206
  // Step 5 — canonicalization, content-hash, and envelope checks
207
- // (#3/#4/#6/#12/#14/#15/#16). #4 fetches only for a raw-bytes/v1 BlobRef output.
207
+ // (#3/#4/#6/#12/#14/#15/#16/#17). #4 fetches only for a raw-bytes/v1 BlobRef output.
208
208
  let contentCanonicalization = null;
209
209
  let contentHashCheck = null;
210
210
  let typeResolution = null;
@@ -212,6 +212,7 @@ export async function verifyRecord(input, deps) {
212
212
  let signingKeyIdConsistency = null;
213
213
  let captureMethodVocab = null;
214
214
  let contentProfile = null;
215
+ let systemPrompt = null;
215
216
  if (pkg) {
216
217
  contentCanonicalization = resolveContentCanonicalization(pkg);
217
218
  contentHashCheck = await verifyContentHashWithFetch(pkg, contentCanonicalization, input.legacyExternalHash ?? packageHash, { fetch: blobFetch });
@@ -222,6 +223,7 @@ export async function verifyRecord(input, deps) {
222
223
  }
223
224
  captureMethodVocab = checkCaptureMethodVocab(pkg);
224
225
  contentProfile = checkContentProfile(pkg);
226
+ systemPrompt = checkSystemPrompt(pkg);
225
227
  }
226
228
  // Step 6 — lifecycle (check #10). Server-deeper resolution wins when supplied;
227
229
  // otherwise derive STATE from the sidecar's lifecycle (the portable depth).
@@ -257,6 +259,7 @@ export async function verifyRecord(input, deps) {
257
259
  signingKeyIdConsistency,
258
260
  captureMethodVocab,
259
261
  contentProfile,
262
+ systemPrompt,
260
263
  lifecycle,
261
264
  };
262
265
  }
package/dist/x509.js CHANGED
@@ -162,7 +162,7 @@ function isImplementedCertSignatureAlgorithm(oid) {
162
162
  * apart before it calls this.
163
163
  */
164
164
  export async function verifyCertSignatureRsa(cert, issuerSpkiDer) {
165
- const hash = RSA_SIG_HASH[cert.sigAlgOid];
165
+ const hash = isImplementedCertSignatureAlgorithm(cert.sigAlgOid) ? RSA_SIG_HASH[cert.sigAlgOid] : undefined;
166
166
  if (!hash)
167
167
  return false;
168
168
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",
@@ -31,12 +31,12 @@
31
31
  "prepublishOnly": "npm run clean && npm run build"
32
32
  },
33
33
  "dependencies": {
34
- "@noble/curves": "^2.2.0",
34
+ "@noble/curves": "^2.4.0",
35
35
  "@noble/hashes": "^2.2.0",
36
- "canonicalize": "^3.0.0"
36
+ "canonicalize": "^5.1.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/node": "^20",
39
+ "@types/node": "^22",
40
40
  "typescript": "^5.9.3"
41
41
  },
42
42
  "engines": {