Preflight composite schema: preflight_v2

Published 2026-09-01. Binding specification, frozen after publication.

Superseded by preflight_v3 at a version boundary announced 2026-09-01. v2 fixtures remain for the audit trail.

preflight_v2 supersedes preflight_v1 at a version boundary announced 2026-09-01, after an independent fixture review by Maha Strategies and before any consumer had integrated v1. The v1 schema and fixtures remain published for the audit trail.

This document is the binding specification for the composite preflight envelope, schema version preflight_v2. It is frozen after publication. Changes happen only at an announced version boundary with a new schema_version.

The endpoint that returns these envelopes is not built yet. This is the schema and a set of locked, really signed fixtures, published so an integrator can build against a fixed target before anything is paid. When the endpoint ships, the price is 0.015 dollars per call.

Changelog / version boundary

v2, 2026-09-01: tightened after Maha Strategies' independent fixture review, before any consumer integrated v1. Changes: per component verdict enums, additionalProperties false, non empty limitations, 503 refusal shape, manifest binding to a dedicated preflight service entry authorizing the composite signer.

What each change means in practice:

What it is

One call returns three independent checks of a single subject address, each as its own signed sub result: payability, sanctions, and trust. Nothing collapses into a single score. The envelope carries a components_evaluated count and that is the only top level tally.

The three checks run sequentially within one request, in a fixed order: payability, then sanctions, then trust. All three always run. The envelope records the observation mode and the spread in milliseconds between the first and last observation, so the small time gap is visible rather than hidden.

The envelope

A component

All three components share one structure: component, verdict, reason_codes (a non empty array; not_evaluated always carries at least one), observed_at, freshness_bound_seconds, limitations (non empty, always present), subject_echo, signer (the constant composite signer), manifest with url, entry the constant "preflight", and an optional source_service, and component_digest and component_signature so each component verifies on its own, offline.

Verdict vocabulary and freshness bound, per component, locked in v2:

Reason codes, locked

The list is locked at this version. Where a source service already emits a code, we reuse its literal rather than invent a new one. New codes require a version boundary.

Reused from the source services. payability, from the address preflight transfer simulation and option verdicts: simulated_ok, simulated_revert, EVM_NO_ATA_CHECK_NEEDED, ATA_MISSING_NEVER_EXISTED, ATA_CLOSED_AFTER_USE, NOT_SPL_SETTLEMENT. trust, from the reputation scorer status: insufficient_data.

Derived, because those services expose no reason code taxonomy of their own. sanctions: ofac_sdn_match, ofac_sdn_no_match. trust: erc8004_scored.

Parameterized codes attach a value after a colon: revert_reason:<reason string>, matched_label:<OFAC chain label>.

Operational, shared across all components and the refusal shapes: oracle_unreachable, rpc_timeout, invalid_subject, signing_unavailable.

Fail closed behavior

The envelope is always complete and signed. Every component is present and signed even when its verdict is not_evaluated. There is never a partially signed envelope.

Total refusal happens in exactly two cases, and only before any component runs:

In every other case the caller gets a full signed envelope, with not_evaluated components where a check could not complete.

Signatures and offline verification

Signer: 0x57fF0F084Cba33e6761503f90eEF0Da9F159350c, published in the proof manifest as the signer of the preflight service entry, authorized for both the components and the envelope. Signing is Ethereum EIP-191 personal_sign over canonical JSON, the same discipline as our sanctions screen: json.dumps with sort_keys true and separators of a comma and a colon, which escapes non ASCII as backslash u sequences.

To verify a single component offline:

  1. Take the component object. Remove component_digest and component_signature.
  2. Canonicalize the remainder: keys sorted, compact separators, non ASCII escaped.
  3. sha256 of that string must equal component_digest.
  4. Recover the signer from the EIP-191 signature over that same string. It must equal the component signer.

To verify the whole envelope:

  1. Take the envelope. Remove envelope.signature only. Keep envelope.signer.
  2. Canonicalize the whole body.
  3. Recover the signer from the EIP-191 signature over that string. It must equal envelope.signer.

What the schema cannot express, verify these yourself

JSON Schema validation is necessary but not sufficient. A document can be schema valid and still be wrong or tampered. A consumer MUST also perform these checks in code, because the schema cannot express them:

If any of these fail, treat the envelope as untrusted, the same as a signature mismatch.

Point in time, not a guarantee

Every verdict is point in time evidence at its observed_at, bounded by its freshness_bound_seconds. This is not a guarantee of future settlement. A payable destination can stop being payable, an OFAC snapshot can change, and reputation data can move. Read the freshness bound before relying on a verdict.

Example envelope

This is the clean-pass fixture, our own payee address, all three checks really run and really signed. Digests and signatures are truncated here; the published fixture carries them in full.

{
 "schema_version": "preflight_v2",
 "request": {
  "request_id": "pf_...",
  "request_time": "2026-09-01T...Z",
  "subject": { "address": "0xc87a06DEE4c0E85912296002617120BBfd5EF990", "chain": "eip155:8453", "role": "payee" }
 },
 "components": [
  { "component": "payability", "verdict": "PAYABLE", "reason_codes": ["simulated_ok"], "observed_at": "2026-09-01T...Z", "freshness_bound_seconds": 300, "limitations": ["..."], "subject_echo": {"address":"0xc87a06DEE4c0E85912296002617120BBfd5EF990","chain":"eip155:8453","role":"payee"}, "signer": "0x57fF0F...350c", "manifest": {"url":"https://x402.nsgoods.org/proof/index.json","entry":"preflight","source_service":"payable-address"}, "component_digest": "...", "component_signature": "0x..." },
  { "component": "sanctions", "verdict": "CLEAR", "reason_codes": ["ofac_sdn_no_match"], "freshness_bound_seconds": 86400, "manifest": {"entry":"preflight","source_service":"sanctions"}, "...": "..." },
  { "component": "trust", "verdict": "NO_DATA", "reason_codes": ["insufficient_data"], "freshness_bound_seconds": 3600, "manifest": {"entry":"preflight","source_service":"trust"}, "...": "..." }
 ],
 "observation": { "mode": "sequential-within-one-request", "spread_ms": 65 },
 "components_evaluated": 3,
 "envelope": { "signer": "0x57fF0F...350c", "signature": "0x..." }
}

Fixtures and schema

clean-pass is our payee address: payable, clear, no trust data. sanctions-match is a live OFAC SDN address: an OFAC match, and its transfer simulation also reverts. partial-not-evaluated shows a real not_evaluated trust component; the live trust oracle returns NO_DATA for an address with no ERC-8004 registration, so that one component is marked synthetic_condition true with a one line note, because a genuine oracle outage could not be forced honestly from outside. invalid-subject is the real HTTP 400 refusal body, no envelope. signing-unavailable is the HTTP 503 refusal body, no envelope.

The paid /payable and /screen endpoints are unchanged by this document. This composite lands there only at an announced version boundary. Signed report manifest at /proof/index.json.