capacity-attest 0.1.2 → 0.3.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/dist/schema.js CHANGED
@@ -16,6 +16,10 @@ export const ASSET_TYPES = ["gpu-hours", "storage", "api-credits", "bandwidth"];
16
16
  export const DELIVERED_VALUES = ["yes", "no", "partial"];
17
17
  const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
18
18
  const SHA256_HEX_RE = /^[0-9a-fA-F]{64}$/;
19
+ // Lower-case-only sha256 hex. Used for readingsHash (new in 0.2.0, so it can
20
+ // be strict from day one) and, at ingest only, for evidenceHash — see
21
+ // StrictClaimContentSchema below and the 0.1.3 section of CHANGELOG.md (S-3).
22
+ const LOWER_SHA256_HEX_RE = /^[0-9a-f]{64}$/;
19
23
  const CLAIM_ID_RE = /^0x[0-9a-fA-F]{64}$/;
20
24
  // 65-byte ECDSA signature (r ++ s ++ v), hex-encoded with 0x prefix — the
21
25
  // shape ethers.Signer#signMessage() / ethers.verifyMessage() produce.
@@ -48,10 +52,31 @@ const MAX_DEPTH = 32;
48
52
  // instead of language-level recursion, closes that gap for good.
49
53
  function exceedsMaxDepth(value, maxDepth) {
50
54
  const stack = [{ v: value, d: 0 }];
55
+ // TENTH FIX (2026-09-06, found by the same adversarial audit as the
56
+ // ledger.ts SEVENTH/EIGHTH/NINTH fixes): promisedSpecIngestProblem() below
57
+ // was hardened with exactly this `visited` guard after a real, measured
58
+ // hang (an acyclic DAG of 41 shared-reference objects at depth 40 explored
59
+ // ~2^40 nodes and ran past 55 seconds — see that function's own comment).
60
+ // This sibling walker had the identical unguarded shape and was never
61
+ // updated to match, even though it runs on the exact same promisedSpec
62
+ // input, just earlier (this is the schema-level `.refine()`, which zod
63
+ // runs before checkIngestHardening's superRefine). Not reachable over the
64
+ // MCP wire protocol (JSON.parse of a JSON-RPC message can never produce
65
+ // aliased object references — only a direct library caller building
66
+ // ClaimContent by hand with real JS reference sharing can trigger it), but
67
+ // left inconsistent otherwise: two functions walking the same data, one
68
+ // hardened and one not, is exactly the kind of drift that turns into a
69
+ // real bug the next time either one is copied as a template for a third.
70
+ const visited = new Set();
51
71
  while (stack.length > 0) {
52
72
  const { v, d } = stack.pop();
53
73
  if (d > maxDepth)
54
74
  return true;
75
+ if (v !== null && typeof v === "object") {
76
+ if (visited.has(v))
77
+ continue;
78
+ visited.add(v);
79
+ }
55
80
  if (Array.isArray(v)) {
56
81
  for (const item of v)
57
82
  stack.push({ v: item, d: d + 1 });
@@ -64,7 +89,338 @@ function exceedsMaxDepth(value, maxDepth) {
64
89
  }
65
90
  return false;
66
91
  }
67
- export const ClaimContentSchema = z.object({
92
+ // ---------------------------------------------------------------------------
93
+ // `measured` — the optional quantitative block, added in 0.2.0.
94
+ //
95
+ // Full, language-neutral rules: the 0.1.3 section of CHANGELOG.md. The
96
+ // short version of the architecture, because it is the thing that is easy to
97
+ // break by accident:
98
+ //
99
+ // THE PREIMAGE FUNCTION IS FROZEN. canonicalize(), sortKeysDeep() and the
100
+ // sha256 step do not change. Everything that gets stricter is a REFUSAL AT
101
+ // INPUT, never a transformation on the hash route.
102
+ //
103
+ // `measured` is added to ClaimContentSchema (the schema computeClaimId()
104
+ // parses through) as exactly one `.optional()` key — no .default(), no
105
+ // .passthrough(), no .catchall(). A .default() would materialize the key and
106
+ // change the preimage of every already-recorded claim. Its validation lives
107
+ // here rather than only at ingest because it IS preimage-relevant: a
108
+ // non-canonical decimal must never be hashable.
109
+ //
110
+ // Its presence is the version marker; there is deliberately no `scheme`
111
+ // string inside the block. A 0.1.x node reading a 0.2 claim strips the
112
+ // unknown `measured` key, computes the v1 id, and answers claimId_mismatch —
113
+ // it refuses rather than silently accepting unverified measurement data,
114
+ // which is the safe failure direction and is the reason `measured` sits
115
+ // INSIDE the hashed content instead of next to it.
116
+ // ---------------------------------------------------------------------------
117
+ export const MEASURED_UNITS = ["gpu-second", "byte", "byte-second", "call", "token", "credit"];
118
+ export const MEASURED_BASIS_VALUES = ["supplied", "consumed"];
119
+ // Provenance of the number, and nothing else. This enum is deliberately NOT
120
+ // ranked: nowhere in this codebase or its docs does "third-party" mean
121
+ // "better than seller". There is no reliability/quality/trust field and there
122
+ // will not be one — every field in a claim is an assertion by the buyer.
123
+ // `undisclosed` exists because attribution is mandatory: without an escape
124
+ // hatch, someone unwilling to name the source would just pick an untrue
125
+ // value. Withholding should be visible, not silent.
126
+ export const MEASURED_ATTRIBUTION_VALUES = ["buyer", "seller", "third-party", "undisclosed"];
127
+ /**
128
+ * Which units are meaningful for which assetType. A literal 4-row table, not
129
+ * a derivation: there is nothing here to infer, and therefore nothing that
130
+ * can differ between reimplementations in other languages.
131
+ */
132
+ export const UNITS_BY_ASSET_TYPE = {
133
+ "gpu-hours": ["gpu-second"],
134
+ storage: ["byte", "byte-second"],
135
+ bandwidth: ["byte"],
136
+ "api-credits": ["call", "token", "credit"],
137
+ };
138
+ // CDEC — canonical decimal string. ASCII [0-9], never \d: Python's `re`
139
+ // matches \d on U+0664 (Arabic-Indic four) while JavaScript does not, so a
140
+ // spec written with \d is impossible to implement identically in both.
141
+ // No sign (quantities are non-negative, which also removes -0 entirely), no
142
+ // exponent, no leading zeros, integer part mandatory and at most 30 digits,
143
+ // optional fraction of 1..18 digits whose last digit is not 0. Exactly one
144
+ // legal string per value, in both directions.
145
+ const CDEC_RE = /^(0|[1-9][0-9]{0,29})(\.[0-9]{0,17}[1-9])?$/;
146
+ // CINST — canonical UTC instant, exactly 20 characters. No offsets (those are
147
+ // refused, not converted: converting would be a transformation on the hash
148
+ // route), no fractional seconds, no 24:00:00, no leap second :60, no
149
+ // date-only form. Every field is fixed-width and pinned to Z, so a plain
150
+ // lexicographic byte comparison of two CINST strings is identical to
151
+ // chronological ordering — which is why no time rule in this schema needs a
152
+ // date library.
153
+ const CINST_RE = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]Z$/;
154
+ // The form `timestamp` must have when `measured` is present (and, at ingest,
155
+ // the form it must have for every new claim — see S-2).
156
+ //
157
+ // EXACTLY three fractional digits, not one-to-nine. An earlier version of
158
+ // this rule allowed `(\.[0-9]{1,9})?`, which closed the four spellings named
159
+ // in the original defect report but left the whole family around them open:
160
+ // `…:00Z`, `…:00.0Z`, `…:00.00Z`, `…:00.000Z` and `…:00.000000000Z` are five
161
+ // spellings of one instant, and each hashed to its own claimId. That was
162
+ // demonstrated end to end, not theorised: one buyer, one seller, one
163
+ // settlementRef, one moment, five accepted ledger rows, and the duplicate
164
+ // check never fired because it keys on bytes the schema had not made
165
+ // canonical. A seller could inflate a delivery history tenfold without
166
+ // breaking a single rule. It is precisely the failure this file's own S-3
167
+ // message describes for evidence hashes ("upper-case hex mints a second
168
+ // claimId for the same evidence"), one field over.
169
+ //
170
+ // Three digits costs nothing: the only claim producer that has ever existed
171
+ // here is `new Date().toISOString()`, which always emits exactly three,
172
+ // including the real production claim in data-selftest/claims.jsonl
173
+ // (2026-08-31T18:06:48.102Z). One spelling per millisecond, no producer
174
+ // broken. `measured.period` already had this discipline via CINST; the
175
+ // claim's own timestamp did not.
176
+ const MEASURED_TIMESTAMP_RE = /^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}Z$/;
177
+ const MAX_INSTRUMENT_LENGTH = 200;
178
+ /**
179
+ * Real calendar check for a YYYY-MM-DD prefix. The regexes above still let
180
+ * 2026-02-30 through; this closes that without pulling in a date library, so
181
+ * a reimplementation in any language can copy these six lines verbatim.
182
+ */
183
+ function isRealCalendarDate(isoPrefix) {
184
+ const y = Number(isoPrefix.slice(0, 4));
185
+ const m = Number(isoPrefix.slice(5, 7));
186
+ const d = Number(isoPrefix.slice(8, 10));
187
+ const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
188
+ const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
189
+ return d >= 1 && d <= daysInMonth;
190
+ }
191
+ /**
192
+ * True if `s` contains a C0 control character (U+0000-U+001F), U+007F, or a
193
+ * lone surrogate. Hygiene and log-injection safety, not a determinism
194
+ * requirement — the escape rules in the spec already cover determinism
195
+ * completely.
196
+ */
197
+ function hasForbiddenTextChars(s) {
198
+ for (let i = 0; i < s.length; i++) {
199
+ const c = s.charCodeAt(i);
200
+ if (c <= 0x1f || c === 0x7f)
201
+ return true;
202
+ if (c >= 0xd800 && c <= 0xdbff) {
203
+ const next = s.charCodeAt(i + 1);
204
+ if (!(next >= 0xdc00 && next <= 0xdfff))
205
+ return true;
206
+ i++; // valid surrogate pair — skip the low half
207
+ }
208
+ else if (c >= 0xdc00 && c <= 0xdfff) {
209
+ return true; // lone low surrogate
210
+ }
211
+ }
212
+ return false;
213
+ }
214
+ /**
215
+ * True if `s` contains a LONE surrogate: an unpaired U+D800..U+DFFF code
216
+ * unit. Deliberately NARROWER than hasForbiddenTextChars above, and used for
217
+ * a different reason.
218
+ *
219
+ * hasForbiddenTextChars is hygiene (log-injection safety). This is a
220
+ * PORTABILITY rule, and it is the only text property that makes a string
221
+ * genuinely unrepresentable outside JavaScript: a lone surrogate has no UTF-8
222
+ * encoding at all, so `Buffer.from(s, "utf8").toString("utf8") !== s` (this
223
+ * package measured that directly). JavaScript hides the problem because
224
+ * well-formed JSON.stringify escapes it to "\ud800" and JS strings are
225
+ * UTF-16, but a verifier written in Rust (`String` is UTF-8 by definition),
226
+ * Go, or Python cannot reconstruct the preimage bytes at all. That is the
227
+ * same failure mode S-5 closes for object KEYS, left open for string VALUES.
228
+ * See S-6 below.
229
+ */
230
+ function hasLoneSurrogate(s) {
231
+ for (let i = 0; i < s.length; i++) {
232
+ const c = s.charCodeAt(i);
233
+ if (c >= 0xd800 && c <= 0xdbff) {
234
+ const next = s.charCodeAt(i + 1);
235
+ if (!(next >= 0xdc00 && next <= 0xdfff))
236
+ return true;
237
+ i++; // valid surrogate pair — skip the low half
238
+ }
239
+ else if (c >= 0xdc00 && c <= 0xdfff) {
240
+ return true; // lone low surrogate
241
+ }
242
+ }
243
+ return false;
244
+ }
245
+ const CinstSchema = z
246
+ .string()
247
+ .regex(CINST_RE, "must be a canonical UTC instant: YYYY-MM-DDTHH:MM:SSZ, exactly 20 chars, no offset, no fractional seconds")
248
+ .refine(isRealCalendarDate, "must be a real calendar date (e.g. 2026-02-30 does not exist)");
249
+ const CdecSchema = z
250
+ .string()
251
+ .regex(CDEC_RE, "must be a canonical decimal string: no sign, no exponent, no leading zeros, at most 30 integer digits, optional 1..18 fraction digits not ending in 0");
252
+ /**
253
+ * The `measured` block. Strict: unknown keys are REFUSED, not stripped —
254
+ * stripping would let a producer believe it is sending data that silently
255
+ * falls out of the preimage. `readingsHash` is the only optional key in the
256
+ * block, because its absence itself carries meaning (there either is a pinned
257
+ * meter dump or there is not); making any other field optional would add a
258
+ * branch to the canonicalization without absence saying anything.
259
+ */
260
+ export const MeasuredSchema = z.strictObject({
261
+ unit: z.enum(MEASURED_UNITS).describe("Unit of the quantities below — must be in this assetType's row of UNITS_BY_ASSET_TYPE"),
262
+ basis: z
263
+ .enum(MEASURED_BASIS_VALUES)
264
+ .describe("Does the number count what the seller made available (`supplied`) or what the buyer actually drew (`consumed`)"),
265
+ promisedAmount: CdecSchema.refine((v) => v !== "0", 'promisedAmount must not be "0" — a promise of nothing is meaningless').describe("How much was promised, as a canonical decimal string"),
266
+ deliveredAmount: CdecSchema.describe("How much was measured, as a canonical decimal string"),
267
+ period: z
268
+ .strictObject({
269
+ start: CinstSchema.describe("Start of the closed measurement window, canonical UTC instant"),
270
+ end: CinstSchema.describe("End of the closed measurement window, canonical UTC instant"),
271
+ })
272
+ .refine((p) => p.start < p.end, "period.start must be strictly before period.end — a zero-length window is meaningless")
273
+ .describe("The closed window this measurement covers"),
274
+ method: z
275
+ .strictObject({
276
+ attribution: z
277
+ .enum(MEASURED_ATTRIBUTION_VALUES)
278
+ .describe("WHO produced the number — provenance only, never a quality or trust signal"),
279
+ instrument: z
280
+ .string()
281
+ .min(1, "instrument is required and must not be empty")
282
+ .max(MAX_INSTRUMENT_LENGTH)
283
+ .refine((v) => !hasForbiddenTextChars(v), "instrument must not contain C0 control characters, U+007F, or lone surrogates")
284
+ .describe('Free text about the measuring instrument, e.g. "nvidia-smi accounting, 10s polling"'),
285
+ readingsHash: z
286
+ .string()
287
+ .regex(LOWER_SHA256_HEX_RE, "readingsHash must be a lower-case sha256 hex digest (64 chars, no 0x prefix)")
288
+ .optional()
289
+ .describe("sha256 of the raw meter dump — the dump itself is not stored. Separate from evidenceHash, which is about delivery evidence"),
290
+ })
291
+ .describe("How the number was obtained"),
292
+ });
293
+ function checkMeasuredConsistency(content, ctx) {
294
+ const measured = content.measured;
295
+ if (!measured)
296
+ return;
297
+ // §5.2 — unit must sit in this assetType's row. Pure lookup.
298
+ const allowed = UNITS_BY_ASSET_TYPE[content.assetType];
299
+ if (!allowed.includes(measured.unit)) {
300
+ ctx.addIssue({
301
+ code: "custom",
302
+ path: ["measured", "unit"],
303
+ message: `unit "${measured.unit}" is not valid for assetType "${content.assetType}" (allowed: ${allowed.join(", ")})`,
304
+ });
305
+ }
306
+ // §5.3 — the period must be closed, i.e. it ended no later than the moment
307
+ // the claim was made. Two parts.
308
+ if (!MEASURED_TIMESTAMP_RE.test(content.timestamp) || !isRealCalendarDate(content.timestamp)) {
309
+ ctx.addIssue({
310
+ code: "custom",
311
+ path: ["timestamp"],
312
+ message: "when `measured` is present, timestamp must be a strict UTC instant YYYY-MM-DDTHH:MM:SS[.fff]Z with a real calendar date",
313
+ });
314
+ return;
315
+ }
316
+ // The 19-CHARACTER PREFIX RULE, and it has to be the prefix, not the whole
317
+ // string. Comparing full strings is wrong the moment `timestamp` carries
318
+ // fractional seconds inside the same second the period closed:
319
+ // "2026-09-01T08:00:00Z" <= "2026-09-01T08:00:00.102Z" is FALSE, because
320
+ // "." (0x2E) sorts before "Z" (0x5A). YYYY-MM-DDTHH:MM:SS is fixed-width
321
+ // and zero-padded, so prefix-lexicographic IS chronological, and truncating
322
+ // the fraction can only err conservatively (the fraction can only push the
323
+ // timestamp later).
324
+ if (measured.period.end.slice(0, 19) > content.timestamp.slice(0, 19)) {
325
+ ctx.addIssue({
326
+ code: "custom",
327
+ path: ["measured", "period", "end"],
328
+ message: "period.end must not be after timestamp (compared on the first 19 characters) — a claim covers a closed past window",
329
+ });
330
+ }
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // `externalRefs` — optional, unverified pointers into other agent-economy
334
+ // infrastructure, added in 0.3.0.
335
+ //
336
+ // Background: a 2026-09-06 workflow checked the six layers that sit next to
337
+ // this package's own Evidence layer (Identity, Authority, Intent, Execution,
338
+ // Settlement, Discovery, Liability) against what the wider agent-economy
339
+ // ecosystem (ERC-8004, Google AP2, the x402 Foundation, the Legal Context
340
+ // Protocol, and others) already ships in production. Every one of them is
341
+ // already being built by parties with far more reach than this project —
342
+ // see knowledge/al-mizaan/... in wazir-al-ghanima for the sourced writeup.
343
+ // Building any of those layers ourselves would duplicate infrastructure that
344
+ // already exists and is better resourced. What DOES fit this package's own
345
+ // "verify yourself, we assert facts, never authority" philosophy: a place to
346
+ // CITE one of those external systems from inside a claim, without this
347
+ // package ever validating, resolving, or trusting what is cited. Same
348
+ // posture as `evidenceHash` (a hash of evidence that is never itself
349
+ // checked) and `settlementRef` (a payment reference that is never itself
350
+ // resolved on-chain).
351
+ //
352
+ // Same preimage discipline as `measured`: `externalRefs` is exactly one
353
+ // `.optional()` key on ClaimContentObject, never `.default()`. An older
354
+ // parser that does not know this key strips it and computes a different
355
+ // (mismatching) claimId rather than silently trusting an unverified
356
+ // reference — the same safe failure direction `measured`'s own comment
357
+ // describes. Claims that omit the key hash bit-for-bit identically to
358
+ // before this field existed; enforced by the frozen regression anchor in
359
+ // external-refs.test.ts.
360
+ //
361
+ // Every leaf string below is deliberately format-light: these reference
362
+ // external systems (DIDs, CAIP-10-style chain identifiers, AP2 mandate ids,
363
+ // LCP terms hashes) whose own syntax this package has no business policing
364
+ // beyond basic length and hygiene. `mandateIssuerDid` is the one exception —
365
+ // a real, narrow DID-syntax check — because W3C DID Core defines that
366
+ // syntax precisely and a malformed DID here is unambiguously a caller bug,
367
+ // not a valid-but-unusual value this package should let through.
368
+ // ---------------------------------------------------------------------------
369
+ const MAX_EXTERNAL_REF_LENGTH = 512;
370
+ const MAX_PROTOCOL_LABEL_LENGTH = 64;
371
+ // Minimal W3C DID Core syntax: `did:<method-name>:<method-specific-id>`.
372
+ // method-name is ASCII lowercase letters/digits; method-specific-id is left
373
+ // broad (colons separate additional segments across DID methods) but still
374
+ // restricted to a safe, portable character set — the same reasoning as
375
+ // hasForbiddenTextChars elsewhere in this file, applied via character class
376
+ // instead of a separate refine, since the regex already forbids the
377
+ // offending bytes outright.
378
+ const DID_RE = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/;
379
+ function externalRefIssue(v) {
380
+ if (hasForbiddenTextChars(v)) {
381
+ return "must not contain C0 control characters, U+007F, or lone surrogates";
382
+ }
383
+ return null;
384
+ }
385
+ const ExternalRefSchema = z
386
+ .string()
387
+ .min(1, "must not be empty — omit the field entirely instead")
388
+ .max(MAX_EXTERNAL_REF_LENGTH)
389
+ .refine((v) => externalRefIssue(v) === null, {
390
+ message: "must not contain C0 control characters, U+007F, or lone surrogates",
391
+ });
392
+ const MandateIssuerDidSchema = z
393
+ .string()
394
+ .max(MAX_EXTERNAL_REF_LENGTH)
395
+ .regex(DID_RE, "mandateIssuerDid must be a syntactically valid DID: did:<method>:<method-specific-id>");
396
+ const DisputeContextSchema = z.strictObject({
397
+ protocol: z
398
+ .string()
399
+ .min(1, "protocol is required and must not be empty")
400
+ .max(MAX_PROTOCOL_LABEL_LENGTH)
401
+ .refine((v) => externalRefIssue(v) === null, {
402
+ message: "must not contain C0 control characters, U+007F, or lone surrogates",
403
+ })
404
+ .describe('Short label for the external dispute/liability protocol this claim can be evidence for, e.g. "LCP"'),
405
+ termsHash: z
406
+ .string()
407
+ .regex(LOWER_SHA256_HEX_RE, "termsHash must be a lower-case sha256 hex digest (64 chars, no 0x prefix)")
408
+ .describe("sha256 of the governing terms both parties accepted at settlement time — the terms themselves are not stored here"),
409
+ resolutionRef: ExternalRefSchema.optional().describe("Optional pointer to a resolution/case record in the external protocol named by `protocol`, once one exists"),
410
+ });
411
+ export const ExternalRefsSchema = z
412
+ .strictObject({
413
+ sellerAgentRef: ExternalRefSchema.optional().describe("Unverified pointer to an external agent-identity record for the seller (e.g. an ERC-8004 agent id or a DID). Tokenizen does not resolve or verify this"),
414
+ buyerAgentRef: ExternalRefSchema.optional().describe("Unverified pointer to an external agent-identity record for the buyer. Tokenizen does not resolve or verify this"),
415
+ mandateRef: ExternalRefSchema.optional().describe("Unverified pointer to an externally issued authority/mandate object (e.g. an AP2 Payment/Cart Mandate) that the buyer claims covers this delivery. Tokenizen does not check scope, budget, or validity against it"),
416
+ mandateIssuerDid: MandateIssuerDidSchema.optional().describe("DID of the principal who is claimed to have issued the mandate referenced by `mandateRef`. Not verified against the mandate itself"),
417
+ intentRef: ExternalRefSchema.optional().describe("Unverified pointer to an externally issued, pre-signed intent object (e.g. an AP2 IntentMandate) describing what the buyer's principal originally asked for"),
418
+ disputeContext: DisputeContextSchema.optional().describe("Present only if buyer and seller had already agreed to external dispute terms at settlement time. Makes a delivered:no claim usable as evidence in that external protocol instead of Tokenizen adjudicating anything itself"),
419
+ })
420
+ .refine((v) => Object.keys(v).length > 0, {
421
+ message: "externalRefs must not be an empty object — omit the field entirely if there is nothing to reference",
422
+ });
423
+ export const ClaimContentObject = z.object({
68
424
  // .transform(toLowerCase): a fuzz-and-benchmark audit found that
69
425
  // computeClaimId() hashed addresses exactly as submitted while every other
70
426
  // address comparison in this codebase (signature recovery, seller
@@ -108,20 +464,44 @@ export const ClaimContentSchema = z.object({
108
464
  .describe("x402 payment reference or on-chain tx hash for the settlement this claim is about"),
109
465
  timestamp: z
110
466
  .string()
467
+ // FROZEN, deliberately loose. Date.parse accepts "August 30, 2026" and
468
+ // date-only forms, which is far more permissive than the field's own
469
+ // documentation. It stays this way here so historical ledger lines remain
470
+ // recomputable; StrictClaimContentSchema (S-2) tightens it for everything
471
+ // new. This inconsistency is intentional and documented.
111
472
  .refine((v) => !Number.isNaN(Date.parse(v)), "timestamp must be a valid ISO-8601 date string")
112
473
  .describe("ISO-8601 timestamp of when this claim was made"),
474
+ // The one and only addition of 0.2.0. Optional, never defaulted: an absent
475
+ // key is absent from the preimage, so every claim recorded before 0.2.0
476
+ // hashes bit-for-bit identically (proven in schema.test.ts against the real
477
+ // production claim in data-selftest/claims.jsonl).
478
+ measured: MeasuredSchema.optional().describe("Optional quantitative record of how much was promised and how much was measured. Presence is the version marker; absence is the only encoding of 'not measured'"),
479
+ // Added in 0.3.0, same optional/never-defaulted discipline as `measured`
480
+ // above — see the ExternalRefsSchema comment block for the full rationale.
481
+ externalRefs: ExternalRefsSchema.optional().describe("Optional, unverified pointers into other agent-economy infrastructure (identity, authority, intent, dispute). Tokenizen never resolves or trusts these — they are citations, not verified facts"),
113
482
  });
114
- /** The full, stored, signed claim — ClaimContent plus its content-address and signature. */
115
- export const DeliveryClaimSchema = ClaimContentSchema.extend({
116
- claimId: z
117
- .string()
118
- .regex(CLAIM_ID_RE, "claimId must be a 0x-prefixed sha256 hex digest")
119
- .describe("Content-addressed id: sha256 of the canonical JSON of this claim's content fields — see computeClaimId()"),
120
- signature: z
121
- .string()
122
- .regex(SIGNATURE_RE, "signature must be a 0x-prefixed 65-byte ECDSA signature")
123
- .describe("Buyer's EIP-191 personal-sign signature over claimId"),
483
+ const claimIdField = z
484
+ .string()
485
+ .regex(CLAIM_ID_RE, "claimId must be a 0x-prefixed sha256 hex digest")
486
+ .describe("Content-addressed id: sha256 of the canonical JSON of this claim's content fields — see computeClaimId()");
487
+ const signatureField = z
488
+ .string()
489
+ .regex(SIGNATURE_RE, "signature must be a 0x-prefixed 65-byte ECDSA signature")
490
+ .describe("Buyer's EIP-191 personal-sign signature over claimId");
491
+ export const DeliveryClaimObject = ClaimContentObject.extend({
492
+ claimId: claimIdField,
493
+ signature: signatureField,
124
494
  });
495
+ // The cross-field checks are attached to each schema separately rather than
496
+ // inherited, because zod v4's .extend() DROPS checks from the schema it
497
+ // extends (measured directly: a .superRefine()'d object that is then
498
+ // .extend()ed no longer runs the refinement). Attaching per schema is the
499
+ // only shape that keeps ClaimContentSchema and DeliveryClaimSchema in
500
+ // agreement. Both keep a working `.shape` (index.ts's MCP tool registration
501
+ // depends on that), which .superRefine() on a ZodObject does preserve.
502
+ export const ClaimContentSchema = ClaimContentObject.superRefine(checkMeasuredConsistency);
503
+ /** The full, stored, signed claim — ClaimContent plus its content-address and signature. */
504
+ export const DeliveryClaimSchema = DeliveryClaimObject.superRefine(checkMeasuredConsistency);
125
505
  /**
126
506
  * Deterministic JSON stringify: object keys sorted recursively so hashing
127
507
  * and signing are stable regardless of the original key insertion order.
@@ -160,6 +540,20 @@ function sortKeysDeep(value, depth) {
160
540
  }
161
541
  return value;
162
542
  }
543
+ /**
544
+ * The EXACT bytes that get hashed for a claim, as a string.
545
+ *
546
+ * Exported because canonicalize() on its own is NOT the preimage, and that is
547
+ * the single most likely thing to get wrong when reimplementing this in
548
+ * another language: the preimage is `canonicalize(ClaimContentSchema.parse(content))`,
549
+ * not `canonicalize(content)`. The parse step is what lower-cases the two
550
+ * address fields and strips unknown top-level keys. Port only canonicalize()
551
+ * and you will silently produce wrong ids for any claim submitted with
552
+ * mixed-case addresses.
553
+ */
554
+ export function claimPreimage(content) {
555
+ return canonicalize(ClaimContentSchema.parse(content));
556
+ }
163
557
  /**
164
558
  * Content-addressed id for a claim: sha256 of the canonical JSON of its
165
559
  * content fields (everything except claimId and signature themselves).
@@ -167,8 +561,190 @@ function sortKeysDeep(value, depth) {
167
561
  * over a shape that wouldn't itself pass ClaimContentSchema.
168
562
  */
169
563
  export function computeClaimId(content) {
170
- const parsed = ClaimContentSchema.parse(content);
171
- const hash = createHash("sha256").update(canonicalize(parsed)).digest("hex");
564
+ const hash = createHash("sha256").update(claimPreimage(content)).digest("hex");
172
565
  return `0x${hash}`;
173
566
  }
567
+ // ---------------------------------------------------------------------------
568
+ // StrictClaimContentSchema — ingest hardening (S-1 .. S-6).
569
+ //
570
+ // None of this touches the preimage and none of it changes any existing
571
+ // claimId. These rules apply ONLY to newly submitted claims, via
572
+ // recordDelivery(). Historical ledger lines are always recomputed through the
573
+ // FROZEN ClaimContentSchema above and therefore stay verifiable even when
574
+ // they contain something (a float in promisedSpec, a loose timestamp) that
575
+ // S-1/S-2 now refuse for new claims.
576
+ //
577
+ // Second reason these are refusals rather than normalizations: if the schema
578
+ // guarantees the incoming bytes are already canonical, a verifier in Rust or
579
+ // Go needs no arithmetic at all — no decimal library, no date parser, no ICU.
580
+ // It hashes what it gets. The only transformation left anywhere on the hash
581
+ // route is the ASCII lower-casing of the two address fields, which is v1
582
+ // legacy.
583
+ // ---------------------------------------------------------------------------
584
+ const MAX_SAFE_SPEC_INT = Number.MAX_SAFE_INTEGER; // 2^53 - 1
585
+ /** True if `key` contains a surrogate code unit — i.e. a lone surrogate OR any astral character (S-5). */
586
+ function keyHasSurrogate(key) {
587
+ for (let i = 0; i < key.length; i++) {
588
+ const c = key.charCodeAt(i);
589
+ if (c >= 0xd800 && c <= 0xdfff)
590
+ return true;
591
+ }
592
+ return false;
593
+ }
594
+ function isPlainObject(v) {
595
+ const proto = Object.getPrototypeOf(v);
596
+ return proto === Object.prototype || proto === null;
597
+ }
598
+ /**
599
+ * S-1 + S-5 + S-6, walked iteratively (never recursively — same reasoning as
600
+ * exceedsMaxDepth above) over the object branch of promisedSpec.
601
+ *
602
+ * S-1 closes a REAL, MEASURED collision in 0.1.x, not a theoretical one:
603
+ * today NaN, Infinity, -Infinity and null in promisedSpec all produce the
604
+ * SAME claimId (JSON.stringify renders all four as `null`), and -0 and 0 do
605
+ * too. Allowed value types are therefore: string, boolean, null, array,
606
+ * plain object, and a number that is an INTEGER within [-(2^53-1), 2^53-1].
607
+ * Anything needing a fraction goes in as a decimal string.
608
+ *
609
+ * S-5 refuses object keys containing surrogate code units — lone surrogates
610
+ * and anything at or above U+10000. That kills the UTF-16-versus-code-point
611
+ * key-sorting divergence at the source for everything new: JavaScript sorts
612
+ * U+1F600 before U+FF01, while a code-point sort (Python `sorted()`, Go
613
+ * string ordering, Rust BTreeMap) gives the opposite. The sort rule stays
614
+ * normative for READING historical lines back.
615
+ *
616
+ * S-6 refuses LONE surrogates in promisedSpec STRINGS — both the string
617
+ * branch of the union and every string value inside the object branch. S-5
618
+ * closed this for keys only, which left the identical portability hole open
619
+ * one field over: a lone surrogate has no UTF-8 encoding, so a Rust/Go/Python
620
+ * verifier cannot reconstruct the preimage bytes even though JavaScript
621
+ * happily round-trips it. Note what S-6 does NOT do: astral characters
622
+ * (U+1F600 and friends) stay ALLOWED in string values, because they encode
623
+ * fine in UTF-8 and only KEY ordering was ever ambiguous; and C0 control
624
+ * characters stay ALLOWED in promisedSpec, unlike in settlementRef (S-4),
625
+ * because JSON.stringify escapes them deterministically, they cost nothing in
626
+ * portability, and promisedSpec is documented free text where a newline is a
627
+ * legitimate thing for a caller to write. Refusing them here would be
628
+ * unjustified scope creep with a real usability cost.
629
+ *
630
+ * Returns null when fine, or a human-readable reason.
631
+ */
632
+ const LONE_SURROGATE_PROBLEM = "promisedSpec strings must not contain lone surrogates — an unpaired UTF-16 half has no UTF-8 encoding, so a verifier in another language cannot reconstruct the preimage bytes";
633
+ function promisedSpecIngestProblem(spec) {
634
+ // The string branch: S-1/S-5 do not apply to it, but S-6 does.
635
+ if (typeof spec === "string")
636
+ return hasLoneSurrogate(spec) ? LONE_SURROGATE_PROBLEM : null;
637
+ // Depth AND visited tracking are both load-bearing, for a reason that is
638
+ // easy to miss: in zod v4 a failing `.refine()` is NON-ABORTING, so the
639
+ // MAX_DEPTH refine on promisedSpec does NOT stop this walker from also
640
+ // running on the same raw, already-rejected input. Without the bounds
641
+ // below, two ordinary inputs hang the process:
642
+ // - a cyclic object: infinite stack growth, RangeError after ~20s of a
643
+ // fully blocked event loop;
644
+ // - an acyclic DAG of just 41 objects at depth 40 (`let n={leaf:1};
645
+ // for(i<40) n={x:n,y:n}`): shared references get re-expanded once per
646
+ // path, so the walker explores ~2^40 nodes and never returns.
647
+ // Both were measured against a real build; the frozen route rejects the
648
+ // second in 4ms while this walker ran past 55 seconds. Bounding depth at
649
+ // MAX_DEPTH costs nothing (anything deeper is rejected anyway) and the
650
+ // visited set collapses shared references back to one visit each.
651
+ const stack = [{ v: spec, d: 0 }];
652
+ const visited = new Set();
653
+ while (stack.length > 0) {
654
+ const { v, d } = stack.pop();
655
+ if (d > MAX_DEPTH)
656
+ continue; // the frozen route's own depth refine reports this
657
+ if (v === null)
658
+ continue;
659
+ if (typeof v === "object") {
660
+ if (visited.has(v))
661
+ continue;
662
+ visited.add(v);
663
+ }
664
+ const t = typeof v;
665
+ if (t === "string") {
666
+ // S-6, for every string value at any depth.
667
+ if (hasLoneSurrogate(v))
668
+ return LONE_SURROGATE_PROBLEM;
669
+ continue;
670
+ }
671
+ if (t === "boolean")
672
+ continue;
673
+ if (t === "number") {
674
+ const n = v;
675
+ if (!Number.isFinite(n))
676
+ return "promisedSpec must not contain NaN or Infinity (all of NaN, Infinity, -Infinity and null hash identically)";
677
+ if (!Number.isInteger(n))
678
+ return "promisedSpec numbers must be integers — use a decimal string for fractional values";
679
+ if (Object.is(n, -0))
680
+ return "promisedSpec must not contain -0 (it hashes identically to 0)";
681
+ if (n > MAX_SAFE_SPEC_INT || n < -MAX_SAFE_SPEC_INT)
682
+ return "promisedSpec integers must be within [-(2^53-1), 2^53-1]";
683
+ continue;
684
+ }
685
+ if (Array.isArray(v)) {
686
+ for (const item of v)
687
+ stack.push({ v: item, d: d + 1 });
688
+ continue;
689
+ }
690
+ if (t === "object") {
691
+ const obj = v;
692
+ if (!isPlainObject(obj))
693
+ return "promisedSpec must contain only plain objects and arrays (no Date, Map, Set, RegExp or class instances)";
694
+ if (typeof obj.toJSON === "function")
695
+ return "promisedSpec must not contain an object with its own toJSON";
696
+ for (const key of Object.keys(obj)) {
697
+ if (keyHasSurrogate(key)) {
698
+ return "promisedSpec object keys must not contain surrogate code units (no lone surrogates and no characters at or above U+10000)";
699
+ }
700
+ stack.push({ v: obj[key], d: d + 1 });
701
+ }
702
+ continue;
703
+ }
704
+ return `promisedSpec must not contain a value of type ${t}`;
705
+ }
706
+ return null;
707
+ }
708
+ function checkIngestHardening(content, ctx) {
709
+ // S-1 + S-5 + S-6
710
+ const specProblem = promisedSpecIngestProblem(content.promisedSpec);
711
+ if (specProblem)
712
+ ctx.addIssue({ code: "custom", path: ["promisedSpec"], message: specProblem });
713
+ // S-2 — new claims must carry a strict UTC timestamp with a real calendar
714
+ // date. Closes the gap where "August 30, 2026" and date-only forms were
715
+ // accepted. Every producer in this codebase already emits
716
+ // new Date().toISOString(), which satisfies this unchanged.
717
+ if (!MEASURED_TIMESTAMP_RE.test(content.timestamp) || !isRealCalendarDate(content.timestamp)) {
718
+ ctx.addIssue({
719
+ code: "custom",
720
+ path: ["timestamp"],
721
+ message: "timestamp must be a strict UTC instant YYYY-MM-DDTHH:MM:SS[.fff]Z with a real calendar date",
722
+ });
723
+ }
724
+ // S-3 — hex case. evidenceHash in upper case produces a DIFFERENT claimId
725
+ // for the same evidence: exactly the duplicate-bypass that was closed for
726
+ // addresses on 2026-08-31 and left open for evidence. Closed here for new
727
+ // claims; the frozen schema still accepts either case so older lines keep
728
+ // recomputing.
729
+ if (!LOWER_SHA256_HEX_RE.test(content.evidenceHash)) {
730
+ ctx.addIssue({
731
+ code: "custom",
732
+ path: ["evidenceHash"],
733
+ message: "evidenceHash must be lower-case hex — upper-case hex mints a second claimId for the same evidence",
734
+ });
735
+ }
736
+ // S-4 — settlementRef hygiene (log-line injection); the fuzz suite already
737
+ // documented this gap.
738
+ if (hasForbiddenTextChars(content.settlementRef)) {
739
+ ctx.addIssue({
740
+ code: "custom",
741
+ path: ["settlementRef"],
742
+ message: "settlementRef must not contain C0 control characters, U+007F, or lone surrogates",
743
+ });
744
+ }
745
+ }
746
+ /** ClaimContentSchema plus the ingest hardening of S-1..S-6. Used by recordDelivery(), never by computeClaimId(). */
747
+ export const StrictClaimContentSchema = ClaimContentObject.superRefine(checkMeasuredConsistency).superRefine(checkIngestHardening);
748
+ /** DeliveryClaimSchema plus the ingest hardening of S-1..S-6. This is what record_delivery validates against. */
749
+ export const StrictDeliveryClaimSchema = DeliveryClaimObject.superRefine(checkMeasuredConsistency).superRefine(checkIngestHardening);
174
750
  //# sourceMappingURL=schema.js.map