Schema Versioning#
Surface schemas are product contracts. They should change more slowly than implementation details because adapters, agents, CI jobs, and future hosted services will depend on them.
Current version#
Surface writes TrustBundles as schema version 5, 7, 8 or 9 according to
their content, and writes TrustReports as version 5. On read, it accepts
schemaVersion: 2 through 9 (see
v3 to v5 migration for the one-release read-tolerance
shim that covers 2-4). Each version is a strict superset of the one before
it except for the deliberate v5 surface to facet wire rename documented
below. The v6 and v7 fields are additive and optional, so adapters can adopt
them on their own cadence. Version 8 adds one optional field and, for bundles
that declare it, two validation rules; see
v7 to v8 migration. Version 9 adds two optional
evidence fields; see v8 to v9 migration.
Version 7 adds runtime_observation evidence and the optional
execution.environment field (test, staging, or production). A policy
can require runtime_observation when passing tests alone must not verify a
claim about deployed behavior. Surface stamps emitted TrustBundles
content-sensitively: bundles using either v7 evidence field, or a policy that
requires runtime_observation, declare version 7; pure-v5 content remains at
version 5 so Hachure 0.14 and older receivers do not reject it. An explicitly
versioned TrustBundleBuilder fails when its declaration is too old for its
content.
Hachure's trust-report.schema.json (unchanged in 0.16 and 0.17) still permits only
top-level versions 5 and 6, even though its embedded claim, evidence and policy
references accept the v7 and v8 vocabulary. buildTrustReport therefore continues declaring version 5 while
carrying those widened pass-through records. This is an upstream schema
limitation, not a different evidence interpretation.
Version 3 covers everything v2 covers plus:
subjectAliaseson a claim and top-levelidentityLinksfor cross-system identity resolutionparentTypeon a verification policy, enabling claim-type families and most-specific policy resolutionincompatibleValuesandincompatibleStatuseson a verification policy, which the kernel uses to surface contradictions across same-subject claim pairsderivedFromon a claim, which bounds derived-claim status by the weakest input- a linked-data export envelope with a stable
@context
Versioning Rules#
- Additive optional fields can ship in minor releases.
- Required fields need a major schema version or a migration helper.
- Enum additions are version-sensitive because older validators may reject them.
- Removed fields require a documented replacement and example coverage.
- Adapter-specific records should stay outside the core schema unless the concept is portable across products.
v1 to v2 migration#
Version 2 intentionally chooses a clean contract over silent acceptance:
- add top-level
"schemaVersion": 2to Surface trust inputs and reports - add
methodto every evidence record - add
requiredMethodsandrequiresCorroborationto verification policies when method depth matters - read requirement data from report-level
evidenceRequirementsByClaimId, not duplicated claim fields - read typed report gaps from
transparencyGapsandsummary.transparencyGapsByType
Before:
{
"source": "example",
"evidence": [
{ "id": "e1", "claimId": "c1", "evidenceType": "test_output" }
]
}
After:
{
"schemaVersion": 2,
"source": "example",
"evidence": [
{ "id": "e1", "claimId": "c1", "evidenceType": "test_output", "method": "validation" }
]
}
v2 to v3 migration#
Version 3 is opt-in. To adopt it:
- bump the top-level field to
"schemaVersion": 3 - start using any of the new optional fields (aliases, parent types, incompatibility rules, derived claims, linked export)
- nothing else has to change — existing examples, policies, and adapters remain valid
When the kernel validates a v3 input, every v3-only field passes through the same strict validation as v2 fields: structural checks reject malformed shapes rather than silently dropping them.
v3 to v5 migration#
Version 5 is a deliberate hard break on the wire schema, following the
Hachure spec's facet rename: Claim.surface and ClaimDefinition.surface
are renamed to facet (and made optional). schemas/claim.schema.json,
schemas/trust-bundle.schema.json, and schemas/trust-report.schema.json
declared "schemaVersion": { "enum": [5] } when the rename shipped — the enum
was reset to a single value rather than widened, because every bundle or report
Surface wrote at that release self-declared schemaVersion: 5
(CURRENT_SCHEMA_VERSION in src/types.ts). There is no schemaVersion: 4
in the wild to widen the enum for; version 4 only ever existed as an
in-progress marker for the expiresAt/ttlSeconds validity-window fields
before this rename shipped.
The current Hachure schemas widen the readable TrustBundle contract to
[5, 6, 7, 8]; Surface-generated TrustReports remain version 5, and generated
TrustBundles follow the content-sensitive rule above.
To adopt v5 as a producer:
- rename
surfacetofaceton everyClaimandClaimDefinitionyou emit - bump the top-level field to
"schemaVersion": 5 - nothing else has to change —
facetis optional, exactly likesurfacewas, and every other v3 field is unchanged
Reading is more forgiving than writing. validateTrustBundle
(src/validate.ts), the local claim-authoring store reader (src/store.ts),
and mergeBundles/mergeBundlesDetailed (src/merge.ts) all carry an
owner-ratified TOLERANCE SHIM, kept until the next major release: a claim
that still carries surface (with no facet already present) has that
value copied onto facet, surface is stripped and never re-emitted, and a
deprecation warning is printed once per process (not once per claim) the
first time the shim actually does something. Bundles self-declaring
schemaVersion 2, 3, or 4 are still accepted on read for the same reason.
This shim is read-tolerance only — it exists so @kontourai/surface's own
reader keeps archived or not-yet-migrated bundles usable while producers
catch up. It is not a permanent feature: the next major release is expected
to drop it, and the wire schema itself never accepted surface again after
this bump.
The tolerance does not extend to unknown keys. validateTrustBundle rejects a
top-level key the Hachure trust-bundle schema does not define (the schema is
closed with additionalProperties: false) with trust bundle contains unsupported field: <key>, the same way the claim, evidence, and event
validators reject unknown record fields. It also rejects two claims or two
evidence items that share an id, since every reference in a bundle resolves by
id. A producer adding a new block must wait for a schema and Surface release
that define it; an older reader fails loudly instead of returning a bundle and
report that silently dropped the block.
Rollout note: this rejects some bundles that validated before. Two known
producer shapes added a root key. Fieldwork's reviewed export before 0.11.0
added reviewedGrounding and reviewRound at the bundle root, so the
exported file was refused when fed back into validateTrustBundle or surface report. From 0.11.0 the export wraps the bundle instead of extending it —
{ apiVersion, kind: "ReviewedExport", bundle, reviewedGrounding, reviewRound }
— and the bundle member validates on its own (extract it with jq .bundle reviewed.json). At least one CLI tool writes a
critique_resolution_events key at the root, which is now refused. Producers
should carry such data outside the bundle, as Fieldwork's wrapper does, or in
the record-level metadata fields of claims and evidence.
The Quickstart intentionally ships
examples/surface-example-bundle.json still in this legacy surface /
schemaVersion: 3 shape (rather than migrating it) so a first-run surface report doubles as a live demonstration of this exact read-tolerance
behavior — you will see the deprecation warning on stderr the first time you
run it.
v7 to v8 migration#
Version 8 arrives with Hachure 0.16. It changes one record: a claim's optional
conclusionConfidence.
- New field.
conclusionConfidence.calibrationnames the calibration table a calibrator applied to producevalue:tableRefandtableVersion(required, non-empty strings), and optionalmethod,sampleSize(an integer of at least 1) andboundMethod. No other key is allowed. The object is validated whenever it is present, at any schema version. - Rules for bundles that declare
schemaVersion: 8.valuerequirescalibration. A raw or self-reported score never goes invalue.interval.lowandinterval.highlie in[0, 1].interval.low <= interval.high, and whenvalueis present,interval.low <= value <= interval.high. JSON Schema cannot express these two;validateTrustBundlechecks them in code, as Hachure's own validator does.validateTrustBundlealso applies the rest of the claim schema'sconclusionConfidenceshape to a version 8 bundle (valuein[0, 1], no unknown keys,comfortZone.withinrequired). For versions 2-7 it keeps checking only thatconclusionConfidenceis an object, as before.
- Earlier versions are unchanged. A version 5-7 bundle may still carry
valuewithoutcalibration.
Emitters (TrustBundleBuilder, mergeBundles, the verification responder)
declare version 8 only when a claim carries conclusionConfidence.calibration.
Content without it stays at 5 or 7, so declaring 8 never makes existing
uncalibrated confidence invalid. An explicitly versioned TrustBundleBuilder
below 8 throws when its content carries calibration. Merging a bundle that
carries calibration with one that carries an uncalibrated value produces a
version 8 bundle that validateTrustBundle refuses; calibrate or drop the
uncalibrated value first.
To adopt v8 as a producer: add calibration beside every
conclusionConfidence.value you emit, keep interval bounds inside [0, 1] and
around value, and declare "schemaVersion": 8 (or let the builder infer it).
Version 8 is a schema change only. conclusionConfidence is carried, not
derived: the status function never reads it.
Status function version 3#
Hachure 0.16 also moves the status function from version "2" to "3". This
is independent of schemaVersion: it applies to every bundle Surface derives,
whatever version the bundle declares. The governing rule is that omission fails
closed — leaving out an input the policy depends on can only weaken a derived
status. A bundle derives a different status under "3" exactly when one of
these applies to a claim whose latest event is verified (or, where noted, to
any claim):
| Bundle shape | "2" |
"3" |
|---|---|---|
No policy resolves for the claim (also for an authority-gated resolution to verified) |
verified |
proposed |
verificationPolicyId names a policy that is not in the bundle |
policy resolved by claimType |
no policy: proposed |
The resolved policy has empty requiredEvidence and no requiredMethods |
verified |
proposed |
Required check evidence (test_output, calculation_trace, runtime_observation) has passing absent, or passing: false with blocking: false |
verified |
proposed |
Corroboration is met only by counting check evidence without passing: true |
verified |
proposed |
commit validity rule and no claim.currentIntegrityRef |
verified |
stale |
validityRule.kind missing or unknown |
verified |
stale |
duration rule with durationDays missing, negative or not finite; unparseable verification time |
verified (or stale, by now, for a negative window) |
stale |
ttlSeconds negative or not finite; unparseable expiresAt |
verified (or stale, by now, for a negative window) |
stale |
| A blocking failure and an unmet requirement together | proposed |
disputed |
No event and no evidence, under a policy that requires nothing (any claim without a proposed / assumed baseline) |
proposed |
unknown |
A dispute-resolution event whose AuthorityTrace window (revokedAt, validFrom, validUntil) is spelled differently from the event time: no milliseconds, or another UTC offset |
compared as strings, so the trace can read active when it is revoked or expired, or the reverse (until 6.0.1; see Authority-trace bounds compared as instants) | compared as instants |
An event whose createdAt cannot be parsed |
order left to a NaN comparison | sorts as the oldest event |
Evaluation with an invalid now (any claim) |
freshness checks pass | refused (RangeError) |
Two rules Hachure lists as new in "3" were already Surface's behaviour under
"2" and so change nothing here: an invalidation event with a non-terminal
status derives stale, and the derivation ceiling uses the ordering revoked <
rejected < disputed < superseded < stale < unknown < assumed <
proposed < verified with a missing input counted as unknown.
validateTrustBundle already refuses several of these shapes (a dangling
verificationPolicyId, a duration rule without a finite durationDays, an
unknown validity kind, an unparseable timestamp), so they reach derivation only
from typed or in-memory input.
By default validateTrustBundle no longer refuses a verified claim under a
commit rule without currentIntegrityRef: that claim now derives stale
instead of verified, so the refusal has nothing left to guard. Under "2" it
still derives verified, so a caller that derives under "2" must validate
for it too: validateTrustBundle(input, { statusFunctionVersion: "2" }) keeps
the refusal exactly as it was before this release.
The authority-window row has no Hachure 0.16.0 conformance vector; Surface
checks it against the hachure package's bundled implementation instead. As
in that implementation, a window bound that is present but cannot be parsed
does not exclude the trace.
To keep a claim verified under "3":
- attach a policy that names at least one required evidence type or method;
- set
passing: trueon check evidence that passed; - set
currentIntegrityRefon claims governed by acommitrule.
Producers of claims verified by a policy that requires nothing#
Some producers record a review, critique or sign-off as a claim with a
verified event, governed by a policy whose requiredEvidence is empty, often
with no evidence at all. Under "3" these claims derive proposed (or
unknown when there is no event either), and setting passing: true does not
help, because there is no check evidence to set it on. To keep them verified:
- Emit the attestation as evidence on the claim:
evidenceType: "human_attestation"(or"attestation"),method: "attestation", with the reviewer incollectedByand the review record insourceRef. - Require it: put that evidence type in the policy's
requiredEvidence(and the method inrequiredMethodsif it matters). - Link the evidence from the verified event's
evidenceIds.
Bundles already written do not change. A consumer that must read them as they
were derived can re-derive under "2", the version recorded on the report or
inquiry record made from them.
Selecting version 2#
Version "2" stays selectable so a record resolved under it can be re-derived:
pass statusFunctionVersion: "2" to buildTrustReport, deriveTrustSnapshot,
deriveClaimStatus, deriveTrustStatus, resolveInquiry,
evaluateDerivationRule or validateTrustBundle. The report, inquiry record
and checkpoint record the version used, and a checkpoint is only reused by a
derivation under the same version. Any other value is refused.
Version "2" is selectable through the library API only. The surface CLI
(report, console and the other commands), the console read model and the
MCP tools always derive under the current version, "4", and have no flag for
it.
explainClaim now reports each evidence item's own passing value when the
item has no execution record: true, false, or null when it reports no
result. It previously reported true for any such item that was not marked
disputed.
v8 to v9 migration#
Version 9 arrives with Hachure 0.17. It adds two optional evidence fields and
changes nothing else. Neither is a status-function input, so no
statusFunctionVersion change comes with it and a bundle derives the same
statuses with or without them.
evidence.inconclusive{ reason, detail? }records that the attempt to collect the evidence could not run or could not reach its source, as distinct from a check that ran and failed (passing: false).reasonis one ofunreachable,tool_error,permission_denied,timeoutorother;detailmust contain a non-whitespace character and is required whenreasonisother. An inconclusive item must besupportStrength: "cited"and must not carrypassing. Those two rules are what keep it out of status derivation: it satisfies no requirement, does not corroborate, anchors nocommitrule, and never disputes or blocks a claim.evidence.collectedByKindishuman,deterministicormodel. It is descriptive only; absent means not declared, never any particular kind.- Rules.
validateTrustBundlerefuses either field on a bundle that declares a version below 9, and applies theinconclusiverules above.execution.isErroralone, or a non-zeroexitCode, still means the check ran and failed.
Emitters (TrustBundleBuilder, mergeBundles, the verification responder)
declare version 9 only when evidence carries one of the two fields. An
explicitly versioned TrustBundleBuilder below 9 throws when its content
carries either.
Deriving without validation. The status function never reads
inconclusive; only the schema keeps such an item cited. buildTrustReport
and deriveTrustSnapshot accept bundles that were never validated, so they
first run checkBasisInvariants (exported, a port of the hachure function of
the same name) and throw when an inconclusive item is entailing or carries
passing, or when either field appears under a declared version below 9. A
bundle without the fields always passes, so existing bundles derive exactly as
before. The claim-level functions deriveClaimStatus and deriveTrustStatus
take evidence lists rather than a bundle and do not run the check; validate
first.
Display. An inconclusive item is shown as "Could not run": explainClaim
reports passing: null and a couldNotRun reason for it whatever its
execution record says. Those two are authoritative; execution is reported
exactly as the runner recorded it, so it never misstates what the runner
reported, and a reader keys on couldNotRun. It raises no
unsupported_inference gap: an attempt that never reached its source cites
nothing, and the requirement it leaves unmet is reported by the requirement
gaps. Further, claimBasisView puts a "could not run" caveat after
Model-derived, and the trust panel and console evidence rows show the result
and reason. collectedByKind appears as a "Collected by" label only when
declared.
Basis-annotations profile#
Hachure 0.17 also defines an optional
basis-annotations profile
with two typed shapes under the open metadata object. They need no schema
version and are never status inputs.
claim.metadata.estimate{ basis, low?, high? }: the claim's value is an estimate.validateBasisAnnotationschecks the shape (bounds together,low <= value <= high, a numeric value when bounded, no other key).claimBasisViewshows an Estimate detail row, and never shows bounds from a malformed estimate.evidence.metadata.sourceOfRecord{ authorityTraceId }: the observation came from the system of record named by anAuthorityTrace.resolveSourceOfRecord(bundle, evidence, { collisions })says whether the reference is backed (trace present, unique,systemororganization, same subject, active atobservedAt, evidence not inconclusive). Over a merged bundle pass the merge's collisions: a reference to a trace id two producers disagreed on is never backed. The console read model does this and shows "From the system of record · actor" or "Source-of-record label not backed".
validateTrustBundle does not apply the profile: a malformed profile value
does not make a bundle invalid. examples/basis-annotations-bundle.json
adapts Hachure's worked example of both versions' fields.
Status function version 4#
Hachure 0.18 defines status function version "4" and makes it Hachure's
default. From Surface 6.0 it is Surface's default too. Versions "3" and
"2" stay selectable and derive exactly as they did in 5.1 (see
Migrating from version 3).
Version "4" defines what a time is and compares times exactly:
- Timestamps. Every time the fold reads must be an RFC 3339
date-time: offset required (Zor±hh:mm), lower-caset/zaccepted, a date that exists in the calendar, any number of fractional digits, and second60only at23:59:60UTC (read as the next instant). A date with no time, a time with no offset, hour24, a space separator and prose are not timestamps.parseTimestamp(value)andcompareTimestamps(a, b)are exported; an instant is{ epochMilliseconds, subMillisecond }, never a floating-point number. - Exact comparison. Instants compare to every fractional digit:
00:00:00.0009Zis later than00:00:00.0001Z, and00:00:00.5Zequals00:00:00.500000Z. - Step 1 fails closed on unevaluable times. A
resolvesDisputeevent whosecreatedAtis absent or not a timestamp is not a resolution (it stays an ordinary event). A trace whoserevokedAt,validFromorvalidUntilis present but not a timestamp is not active; one evaluable active trace is enough, and an unevaluable one does not veto it. A blocking failure whoseobservedAtis absent or not a timestamp counts as newer than the resolution. - Ordering. An event whose
createdAtis not a timestamp sorts before every event with a timestamp, so it is the latest event only when no event has one. - Exact validity windows.
ttlSeconds × 1000anddurationDays × 86 400 000are exact decimal products of the number's shortest round-trip form, and "now is later than the window's end" is evaluated without rounding.durationDays: 0.7is exactly 60 480 000 ms. AnexpiresAtor verification time that is not a timestamp derivesstale. now. Surface takesnowonly as aDate(whole milliseconds). Under"3"and"4"any other value, including a string that is a valid timestamp, is refused withRangeError: invalid now: …by every derivation entry point (buildTrustReport,deriveTrustSnapshot,deriveClaimStatus,deriveTrustStatus, and the inquiry functions through them), so no stringnowis ever read loosely. Before 6.0,buildTrustReportandderiveTrustSnapshotfailed on a stringnowwith aTypeErrorinstead.
A status can strengthen under "4" as well as weaken: refusing an unevaluable
resolution to rejected lets a later verified event stand. Hachure's
"Migrating from version 3" table in status-function.md lists every bundle
shape that derives differently. A schema-valid bundle whose times are all RFC
3339 with an offset, at most three fractional digits and no leap second, and
whose now is not within a millisecond of a validity window's end, derives the
same under "3" and "4".
The conformance vectors run through buildTrustReport under every version they
apply to, without validation (several carry non-date-time strings on
purpose). Two unversioned vectors added in Hachure 0.18
(sf-unparseable-event-time, and one claim of sf-authority-window-before-v4)
are not met by Surface's "2", which orders an event whose createdAt cannot
be parsed by a NaN comparison. Surface keeps that unchanged so records
resolved under "2" re-derive as they were; the differing claims are pinned in
tests/spec-conformance.test.ts. (sf-authority-window-instants and the
unparseable-bound claim of sf-authority-window-before-v4 pass under "2"
since 6.0.1; see Authority-trace bounds compared as instants.)
Derivation rules' fresherThan predicate follows the version: under "4" it
reads times as the status function does (the latest verification whose time
is a timestamp; a verification time that is not one is not fresh) and compares
the window exactly; under "3" and "2" it reads them with Date.parse, as
before.
Authority-trace bounds compared as instants (6.0.1)#
Before 6.0.1, two places compared an AuthorityTrace's revokedAt,
validFrom and validUntil as ISO strings rather than as instants
(kontourai/surface#320):
Step 1 of status function "2" (whether a dispute resolution's actor had an
active trace at the resolution's createdAt), and the
requiresActiveAuthority predicate of a derivation rule
(checkAuthorityActive, compared with now) under every version. A bound
written with a non-Z offset was misread by up to the size of the offset, in
either direction: validUntil: "2026-10-10T01:00:00+02:00" (23:00 UTC) still
read as active at 23:30 UTC. A bound with a different number of fractional
digits than the time it was compared with was misread too: "…T00:00:00Z"
sorts after "…T00:00:00.000Z", so a trace revoked at that instant read as
revoked later.
Both now compare instants, read the way each version reads times:
"4"(the default): RFC 3339 timestamps, compared exactly. A bound that is present but is not a timestamp makes the trace not active, as Step 1 already did;checkAuthorityActivereports an unreadablerevokedAtasrevokedand an unreadablevalidUntilorvalidFromasexpired."3"and"2":Date.parse, to the millisecond, as Hachure's reference implementation of both versions does. A bound that cannot be parsed (including an empty string) excludes nothing (Step 1 under"3"already behaved this way).
A derivation rule's requiresActiveAuthority uses the rule's status function
version. checkAuthorityActive is exported; a direct caller that passes no
statusFunctionVersion gets the current version's rules ("4"). For a bound
that is not an RFC 3339 timestamp that is stricter than before: a date-only
validUntil: "2026-12-31" read as active on 6.0.0 (as a string it sorted
after now) and reads as expired now. This narrows a fail-open on input
validateTrustBundle already refuses (it requires bounds to be ISO-8601
date-times). Pass { statusFunctionVersion: "3" } for the Date.parse
reading.
Under "4", requiresActiveAuthority also picks the actor of the latest
status-bearing event in the version "4" event order (exact instants, an
unevaluable createdAt oldest), as fresherThan already did; under "3" and
"2" it keeps the Date.parse order.
This is a fix, not a new version, so it changes results under "2" and "3"
for these shapes only, and only where a trace bound decides the outcome:
| Bound shape | Path | Before | After |
|---|---|---|---|
Non-Z offset (+02:00, -05:00, …) |
Step 1 under "2"; requiresActiveAuthority under every version |
compared as a string | compared as the instant it names |
Same instant with a different number of fractional digits than the compared time (…:00Z vs …:00.000Z) |
the same | ordered by string | equal |
| Present but unparseable | Step 1 under "2"; requiresActiveAuthority under "2"/"3" |
ordered by string | excludes nothing |
| Present but unparseable | requiresActiveAuthority under "4" |
ordered by string | trace not active |
Every bound written as …Z with the same fractional digits as the time it is
compared with derives as before. Measured when this shipped: 109 committed
delivery trust bundles from eight repositories carry no authority trace at all,
and every report under "3" and "4", over those bundles, this repository's
corpus, the Hachure 0.18 vectors and the specification's examples, is
byte-identical to 6.0.0. Under "2", four Hachure vector reports change:
sf-authority-window-instants and sf-authority-window-before-v4, which now
match the specification's "2" results, and sf-v4-authority-window and
sf-v4-exact-instants, which apply to "4" only, so the specification defines
no "2" result for them (they still derive differently from their "4"
expectations under "2").
Migrating from version 3#
What changes. A bundle derives a different status under "4" only when
it has one of these shapes (Hachure's status-function.md, "Migrating from
version 3", lists them with the vectors that cover each):
| Bundle shape | "3" |
"4" |
|---|---|---|
A resolution whose every matching trace has a revokedAt, validFrom or validUntil that is present but not a timestamp |
resolution status | as if there were no resolution (either way) |
A resolution whose createdAt is absent or not a timestamp |
resolution status | as if there were no resolution (either way) |
A blocking failure whose observedAt is absent or not a timestamp, after a resolution |
resolution status | disputed |
A time the fold reads that Date.parse accepts but is not RFC 3339 with an offset (date only, no offset, hour 24, impossible day, space separator, prose) |
read as Date.parse reads it |
unevaluable: the event sorts first, the window is stale, the bound does not hold |
A leap second (23:59:60 UTC) |
unevaluable | the next instant |
| Two compared times less than a millisecond apart | equal | ordered exactly (either way) |
An unevaluable createdAt on a claim with an event at or before the epoch |
the unevaluable event is later | it is earlier (either way) |
now within a millisecond of a validity window's end, where the floating-point sum does not land exactly on it |
floating-point comparison | exact comparison (either way) |
"Either way" means the status can strengthen as well as weaken: refusing an
unevaluable resolution to rejected lets a later verified event stand.
Which claims flip. A bundle whose times are all RFC 3339 with an offset, at
most three fractional digits and no leap second, and whose now is not within
a millisecond of a validity window's end, derives the same under "3" and
"4". validateTrustBundle refuses dates without a time, times without an
offset, lower-case or space separators, prose, out-of-range offsets and leap
seconds, so a validated bundle can flip only through sub-millisecond times,
hour 24, an impossible calendar day (2027-02-30), or a now within a
millisecond of a validity window's end. The other shapes reach derivation only
from unvalidated or typed input. Measured when this default moved: every bundle in
this repository's examples, conformance cases and fixtures (17 bundles, 35
claims) and 54 committed delivery trust bundles from three consumer
repositories (771 claims) derive identically under "3" and "4". The
Hachure 0.18 conformance vectors, which exist to exercise these shapes, flip 42
of 143 claims.
Who is affected. Any caller that derives without passing
statusFunctionVersion: buildTrustReport, deriveTrustSnapshot,
deriveClaimStatus, deriveTrustStatus, resolveInquiry,
evaluateDerivationRule, the surface CLI, the console and the MCP tools. A
reader that re-derives a stored bundle under the version stamped on it (for
example a bundle whose source records statusFunctionVersion=2, or an
inquiry record, report or checkpoint that records its version) is unaffected:
each version still derives exactly as before. A report, inquiry record and
checkpoint record "4", and a checkpoint made under "3" is not reused by a
derivation under "4".
Keeping the old behaviour. Pass statusFunctionVersion: "3" to the
functions above. The CLI, console and MCP tools have no flag and derive under
"4".
now is taken only as a Date; a string is refused under "3" and "4", as
it already was under "3". Every derivation entry point now refuses it with
the same RangeError: invalid now: … (buildTrustReport and
deriveTrustSnapshot used to throw a TypeError).
The exported StatusFunctionVersion type gains "4", so an exhaustive
switch over it stops type-checking until it handles "4".
Times the report shows are read the same way as the status function reads
them: under "4" a claim's freshness.expiresAt is omitted when the expiry
(or the verification time a ttlSeconds window starts from) is not a
timestamp, so a claim never shows stale beside a future expiry the status
function could not read, and the governing verified event for freshness and
for the unevaluable-validity gap is chosen in the version "4" event order.
When the claim's own expiresAt is not a timestamp, or a ttlSeconds window
cannot be evaluated (an invalid ttlSeconds, or a verification time that is
not a timestamp), the report carries the blocking unevaluable-validity-rule
gap naming the value, its freshness_breach gap says the claim is treated as
stale because its validity could not be evaluated, and the console explains
it as "Validity could not be evaluated" rather than as outdated evidence.
Migration expectation#
Every schema change should include:
- updated JSON schema
- updated TypeScript types
- example demonstrating the new shape
- report-generation test coverage
- docs note explaining why the new field belongs in the trust model