Documentation: all sections

Sable Launchpad

A launchpad for backed tokens — tokenized stocks, bullion, credit, property. The problem it addresses is not fraud; it is a gap in the shape of the thing. A backed token is a promise that something real is sitting somewhere, and the evidence for that promise arrives a few times a year in an attestation letter. Between two letters nobody can check anything, and an honest issuer is in exactly the same position as a dishonest one.

Proof of Backing already closes that interval for an existing token. The Launchpad closes it from the beginning, and adds the half a backing check cannot reach on its own: what happens when a check fails.

An issuer locks a Launch Spec once. From then on:

  1. the backing is under continuous Proof of Backing — Sable reads the custodian and the chain itself, on a schedule;
  2. minting is authorized only against a check that covers the new supply — not the old one;
  3. transfers pause by rule after a shortfall has stood for the declared window;
  4. distributions and NAV are signed declarations, not blog posts.

Every outcome is a signed artifact anyone can verify at POST /v1/receipts/verify, and the mint and pause authorizations are EIP-712 typed messages a reference contract enforces on-chain with no issuer admin key.

What Sable is here, stated first

Sable does not issue, custody, hold funds for, sell, or endorse any token on this page. There is no sale, no allocation and no price anywhere on this site.

The issuer deploys the contract under their own key. Sable is the verdict oracle and the administrator: it reads two sources, applies a rule the issuer locked, and signs the outcome under a key anyone can pin at GET /v1/receipts/pubkey.

That makes a launch here provably administered and publicly verifiable. It does not make it trustless, and the docs, the API and the portal all say so rather than leaving the stronger reading available. What is proved is that the rules were applied to the readings and published; not that Sable could not have done otherwise.

Whether a particular token may be offered, and to whom, is a question for the issuer's own counsel. It is not one Sable answers, and a listing is not advice that it has been answered.

The sections

A Launch Spec has seven required sections and one optional eighth. All of them are locked: they are canonicalized, hashed, and the digest is stamped on the sealed-spec commitment, on every EIP-712 authorization, and on the contract as an immutable field. There is no route that edits them, because every already-signed artifact was produced under them.

AssetHoldsname, symbol, underlying, optional ISIN, chain idLockedyes
BackingHoldscustodian name, units per token, shortfall tolerance, pause-after windowLockedyes
SupplyHoldsceiling, mint rule (verified-only)Lockedyes
DistributionsHoldswhether income on the underlying is passed throughLockedyes
ComplianceHoldsjurisdictions, sanctions screening, transfer restrictionsLockedyes
RedemptionHoldssettlement window, notice period in secondsLockedyes
OperatorHoldswhich tools Sable may useLockedyes
Covenants (optional)Holdsminimum coverage, maximum pause, maximum check ageLockedyes — and omitted from the hash entirely when undeclared

Rotatable, and deliberately not in the hashed spec: the custodian's endpoint URL, its credential, the JSON pointer to the figure, that figure's decimals, and the check cadence. Changing a URL does not change what backing means, so those are operational fields the issuer can move without touching what a published verdict meant.

The compliance section is the issuer's declarations. Sable does not verify them and is not a regulator; hashing them means they cannot be quietly revised, not that they are being honoured.

The canonical form

The spec's digest is its identity, so the recipe is exact and pinned on both sides — the gateway holds three golden vectors in a Rust test, and the browser implementation (lib/launch-spec.ts) is diffed against the same three.

{"asset":{"chain_id":1,"isin":"US0378331005","name":"Acme Tokenized AAPL","symbol":"AAAPL","underlying":"Apple Inc. common stock"},"backing":{"custodian_name":"Acme Custody Ltd","pause_after_secs":3600,"tolerance_bps":25,"units_per_token_micro":1000000},"compliance":{"jurisdictions":["CH","EU","US"],"sanctions_screening":true,"transfer_restrictions":true},"distributions":{"passthrough":true},"operator":{"tools":["mint.authorize","nav.publish"]},"redemption":{"notice":86400,"settlement":"T+2"},"supply":{"max_micro":1000000000000,"mint_rule":"verified-only"},"v":1}

spec_sha256 = sha256(canonical). For the line above that is 9d7c7bcbd759a5cdc5aad74a584c3a98c3395de3fd268a7e814dfc548b38d69b — check it with printf '%s' '<the line>' | shasum -a 256 rather than taking this page's word for it.

Note "v":1 sorts last, after supply. It reads wrong and it is right: the sort is by code point, and v > s. An implementation that put the version first because versions come first would produce a different digest for the same spec.

Two details worth naming, because each is a way a re-implementation drifts without anyone noticing: tolerance_bps: 0, pause_after_secs: 0, notice: 0, "jurisdictions":[] and "tools":[] must all survive canonicalization — a port that filters on falsiness drops them and renders a digest no launch could carry. And an empty isin must be absent, not "".

Every public record publishes spec_canonical — the exact bytes the gateway hashed — beside spec_sha256, so a reader recomputes rather than trusts. The record page does exactly that in your browser, and renders a red MISMATCH if they differ.

The optional eighth section

A spec may also carry covenants, with any of three integer keys:

"covenants":{"max_check_age_secs":7200,"max_pause_secs":86400,"min_coverage_bps":10500}

It sorts between compliance and distributionscom < cov < dis, by code point — and inside it max_check_age_secs < max_pause_secs < min_coverage_bps.

Each key is omitted when undeclared, and the whole section is omitted when all three are. That is not a tidiness rule, it is the compatibility rule: because a spec with no covenants emits no covenants key at all, every digest written before covenants existed is byte-identical to what it always was, and all three original golden vectors still hash to the same values. A build that emitted "covenants":{} would change the identity of every launch on the platform.

0 is a declared covenant, not an absence. min_coverage_bps: 0 is a floor of zero — hashed, locked, and trivially kept. min_coverage_bps absent is no promise at all. An implementation that filters covenants on falsiness drops the first one out of the digest; one that treats absence as zero invents promises nobody made. Three further golden vectors pin exactly this, in the browser and in the gateway.

Sealed launches

A launch can commit to a spec before revealing it: reveal_at in the future means the public record carries the commitment and the digest and returns spec: null until the reveal.

The commitment is signed and timestamped now, so when the spec is published anyone can hash it and confirm it is the same one. Nothing can be edited in between. Until then the record shows a countdown and no terms — which is a real state with its own page, not a loading state.

EIP-712 — the on-chain interface

Domain:

EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)

with name = "Sable Launchpad", version = "1", chainId the launch's chain, and verifyingContract the bound token address.

That binding is why no mint can be authorized before the issuer binds the deployed contract: the authorization is a message signed against one exact address, so before there is an address there is nothing to sign for.

MintAuthorization(bytes32 specHash,bytes32 checkHash,uint64 checkSeq,address to,uint256 amount,uint256 newSupply,uint256 custodianUnits,uint256 nonce,uint64 expiresAt)
PauseAuthorization(bytes32 specHash,bytes32 checkHash,uint64 checkSeq,bool paused,uint64 issuedAt)

checkHash is the Proof of Backing check's sha256 as bytes32; specHash is spec_sha256. Amounts are in token base units, per the contract's decimals.

The reference contract

SableBackedToken is a hand-written ERC-20 with immutable specHash, maxSupply, verdictSigner and decimals.

Submitting a pause is permissionless: any holder can submit a valid signed pause. Sable does not deploy the contract and does not submit transactions — auto-submission through an EVM publisher is not built, and this page will say so until it is.

The operator rules

The pause rule

Find the latest VERIFIED check. If a DISCREPANCY exists after it and now − its checked_at ≥ pause_after_secs, the token is paused.

UNAVAILABLE never pauses. A gap is not a shortfall. A custodian endpoint that times out is not evidence that assets are missing, and treating it as such would let an outage freeze a healthy token.

A leader-gated pass recomputes this for every live launch. On a transition — not on every pass — it signs a PauseAuthorization, appends the action, and fires launch.paused or launch.resumed.

The mint rule

A mint authorization is refused unless all of these hold: the launch is live, a contract is bound, the latest check is VERIFIED, the token is not paused, new_supply ≤ max_micro, and

custodian_units ≥ ceil(new_supply × units_per_token)

The ceiling and the new supply are both deliberate. Holdings must cover what will exist after the mint, not what existed before it, and the requirement rounds up so an issuer cannot sit a fraction short and still read as fully backed.

When it is refused, the reason is available before you click. GET /v1/launches/:id carries mint_eligibility with a stable machine reason (paused, backing_would_not_cover, …) and a message that says it in a sentence; the portal renders the sentence under the disabled button rather than letting you discover it in a 409.

Distributions and NAV

A distribution is refused unless distributions.passthrough is declared in the locked spec. When it is, Sable signs a dated declaration naming the amount per token. Sable records and signs the declaration; it moves no money.

A NAV publication names the check it was computed against. The figures are the issuer's; the signature says they published them, not that they are correct.

An action whose kind is not in operator.tools is refused, not silently skipped — and the tool list is hashed into the spec, so it cannot be widened on an existing launch.

Seals

Seals are recomputed from the chain and the actions on every read, never stored. A seal that stops being true stops being lit, and there is no counter anyone can bump. Unearned seals are shown beside earned ones, each with the sentence that makes it true.

The token carries its own proof

Everything above lives around the token: Sable's record, Sable's rules, Sable's signatures. The five features below move the proof into the token and into a holder's hands, so that checking a backed token does not require trusting — or even visiting — this site.

The contract is its own oracle

A third EIP-712 type, signed by the same key as the mint and pause authorizations:

CheckAttestation(bytes32 specHash,bytes32 checkHash,uint64 checkSeq,uint8 status,uint32 coverageBps,uint64 checkedAt)

status is 0 = UNAVAILABLE, 1 = VERIFIED, 2 = DISCREPANCY, and coverageBps is 0 when the check published no figure — so read status before you read coverageBps, never the other way round.

GET /v1/launches/public/:slug/attestation returns the signed struct, its digest and the signature. It appends no action and changes nothing: it is a signed view of a check that was already published, not a decision, which is why a public read writes nothing. postCheck(a, sig) is permissionless and monotonic on checkSeq — anyone may submit it, a stale attestation can never overwrite a newer one, and Sable does not submit it. The person who wants the chain to know is the person who pays the gas.

The contract then answers, on-chain, with no oracle call and no trip through this API:

token.isBacked()               // status == VERIFIED && not stale
token.isBackedWithin(1 hours)  // …and the attestation is younger than this
token.coverageBps()
token.lastCheck()

which is what makes it composable. A lending market can require isBackedWithin(1 hours) before accepting the token as collateral, and gets a freshness guarantee rather than a promise: if nobody posts, the market stops accepting it, automatically. A pool can halt on DISCREPANCY. A wallet can render coverage from the chain alone.

⚠️ isBacked() means: the last attestation POSTED to this contract said VERIFIED, and it is not stale. It does not mean the backing is sound right now, and it does not mean the custodian is honest. It fails closed on stalenessstatus == VERIFIED && block.timestamp <= checkedAt + maxCheckAge — so a token nobody attests to stops claiming to be backed, which is the safe direction to be wrong in.

It is also independent of paused(). The pause rule has its own window (pause_after_secs), and the staleness window is a different number, so the two can legitimately disagree: a token can be paused while its last posted attestation still reads VERIFIED, and a token can be unpaused while isBacked() is false because nobody posted. Both surfaces say so rather than picking whichever reads better.

Anyone can demand a check

POST /v1/launches/public/:slug/check

Public. The issuer cannot refuse it and cannot schedule around it. The resulting check joins the same hash chain as every scheduled one, with the same verdict it would have had whoever asked.

A cooldown always applies (SABLE_LAUNCH_DEMAND_CHECK_COOLDOWN_SECS, default 300) because the cost is an outbound dial of someone else's custodian — it applies to everyone equally, including the issuer. Over it, the answer is 429 with retry_after_secs, which is a countdown rather than a failure.

A price (SABLE_LAUNCH_DEMAND_CHECK_MICRO_USD, default 0) is settled inline with x402 when set: the 402 carries an accepts array an agent can settle against and retry, with no account needed. The fee pays for the outbound reading. It is never an input to the verdict, and a paid check is published identically to a free one — including when it fails.

Sable records that a check was demanded rather than scheduled, as origin = "demand". ⚠️ origin is deliberately NOT part of the canonical line the check's hash is computed over. Every hash already published keeps exactly the meaning it had, and the verdict does not depend on who asked — the origin is Sable's own record about the check, not part of the signed chain, and this page says that rather than implying a stronger provenance.

A redemption is receipted

POST /v1/launches/public/:slug/redemptions  {chain_id, tx_hash}

Sable reads the transaction from the chain itself, finds the burn emitted by the bound token, and signs a receipt naming the burner, the amount, the burn transaction, the check sequence current at the time, and the settlement terms from the locked spec — not from the request. Recording is idempotent on (launch, chain, tx_hash, log_index), so a retry returns the same receipt with replayed: true rather than minting a second one.

Refusals are fixed classes, not free text: tx_not_found, tx_reverted, no_burn_in_tx, wrong_token, chain_not_configured.

Burning is allowed while the token is paused, deliberately: a redemption reduces supply, which can never weaken backing. A pause stops trading and never the exit.

⚠️ A redemption receipt proves tokens were destroyed under stated terms. It is NOT proof that the issuer delivered anything, and Sable neither holds nor moves the underlying. What it gives a holder is a signed, portable, verifiable record that they exited and when — which is precisely what is missing when a dispute starts months later.

Covenants are graded, and a breach is permanent

The optional covenants section locks up to three promises:

min_coverage_bpsMeansCoverage may never fall below this. Above 10000 is an overcollateralization promise.
max_pause_secsMeansThe token may never stay paused by rule for longer than this.
max_check_age_secsMeansNo check may ever be older than this — and this becomes the contract's maxCheckAge.

A pure grader runs over the check chain and the action log and returns kept, broken or not_yet_gradable per covenant, with the check sequence that broke it.

broken is permanent. A later recovery shows as currently_kept beside the breach, never instead of it — on the public record, in the portal, and in the API. There is no route, at any role, that clears one. A promise that un-breaks itself is not a promise.

Covenants are derived from checks that are already anchored, so they need no anchoring of their own, and this page says that rather than implying a second proof exists.

⚠️ A covenant is graded from Sable's own readings, so a covenant kept over a lying custodian is still kept over a false figure. It tightens what the issuer promised; it does not improve what the custodian reported.

Binding verifies the contract

POST /v1/launches/:id/token reads the deployed contract before binding it and refuses unless specHash(), maxSupply(), verdictSigner(), decimals() and maxCheckAge() all agree with the locked spec and this deployment's signer. A chain with no configured RPC cannot verify and therefore refuses, rather than binding blind.

That is why max_check_age_secs has to be decided before the lock: it is written into the contract as an immutable, and a contract deployed with a different number will not bind.

The public record then reports verified_onchain and renders the two states distinctly. "Bound" on its own means an address was submitted — the issuer's claim. "Verified" means Sable read the chain. ⚠️ Verification covers the contract's immutables only. It is not a statement that the contract is otherwise safe, audited, or does what its name suggests.

Subscriptions — a primary sale that cannot settle unbacked

The rail is off by default and its go-live is counsel-gated, so this section describes it in that tense. With the flag off, the terms of a bound escrow are still published — a deployed contract is a fact about the token whether or not Sable will sign for it today — and only the authorize call answers 501.

What Sable is here, again and more narrowly

Sable does not sell the token, set the price, hold the escrow, run KYC, promote the sale, or run any secondary market. It does not decide who may buy: the allowlist root is the issuer's, built by the issuer and their counsel. Sable's entire part is one signature, and it is withheld when the backing does not cover the mint.

Whether an offering is lawful where a reader lives is not a question Sable answers, and nothing on the record is an answer to it.

How it works

SableSubscription is issuer-deployed and immutable: no owner, no admin, no upgrade path, no pause, no sweep, and no function that can move the escrow anywhere but to the buyer or to the two payees.

  1. subscribe(amountTokens, proof) pulls payment into escrow while the window is open, under the issuer's optional Merkle allowlist. Nothing is minted yet.
  2. settle(buyer, MintAuthorization, sig) is permissionless and calls token.mint(...) first, before any money moves. An authorization the token refuses — unbacked, paused, expired, past the ceiling, wrongly signed — reverts the whole settlement, and the buyer's payment is exactly where it was. That is the property: an unbacked sale cannot complete.
  3. refund() is buyer self-service after closesAt or at any time while the token is paused. A backing failure before delivery returns the money without anyone's permission and without waiting out the window. There is no owner to approve it and no pause that stops it.

The platform fee is capped at 500 basis points by a constant enforced in the constructor, so no deployment can exceed it. It is paid on-chain out of a settled subscription and Sable never touches the escrow.

⚠️ Why the mint is addressed to the escrow, not to the buyer

This is the least obvious line in the whole system and it is load-bearing.

SableBackedToken.mint is permissionless — anyone holding a valid authorization may relay it straight to the token. If the authorization named the buyer as recipient, the buyer could relay it themselves, take delivery, and leave settle to revert on the consumed nonce with their pending uncleared. After closesAt they would then refund and walk away with the tokens and the money, with the issuer paid nothing against supply that now exists. That is not a theoretical ordering concern — it was reproduced against that shape.

So the authorization names the escrow and settle delivers onward. A front-run then merely pre-delivers the units into the escrow, and settle completes anyway: it notices the nonce is spent, verifies the authorization was genuine regardless, finds the units, and finishes.

The consequence a reader has to be told rather than left to infer: units held by the escrow are fungible, so settle pairs an authorization to a buyer by AMOUNT, not by identity — a valid, unconsumed authorization for exactly N units can settle any pending buyer for N units. Nothing is lost by that (the buyer pays, the buyer receives, totals reconcile, and settling was permissionless anyway), but the pairing of an authorization to a buyer lives in Sable's record, not on the chain, and every surface that renders an authorization says so.

The allowlist is the issuer's

When allowlistRoot is non-zero, subscribe requires a Merkle proof:

leaf(account) = keccak256(abi.encodePacked(account))   // 20 bytes
node(a, b)    = keccak256(abi.encodePacked(min, max))  // sorted pair

That is the conventional EVM allowlist tree — merkletreejs with {sortPairs: true}, or OpenZeppelin's StandardMerkleTree over a single address — and deliberately not Sable's own SableMerkle, which is the SHA-256 hex-text tree used for anchoring receipts. An issuer builds this list with ordinary tooling and nothing about it touches the gateway. The full construction, and why a 20-byte leaf preimage is safe under sorted pairs, is in contracts/README.md.

⚠️ Sable does no KYC here. The root is the issuer's. Membership is not a statement by Sable that anyone is eligible to hold the instrument where they live.

The endpoints

GET /v1/launches/public/:slug/subscription is public and answers even when the rail is off — enabled: false, authorizing: false, terms included. 404 means nothing is bound. The live block carries reserved, remaining and is_open read from the chain, and all three are null when the escrow could not be read — the record publishes no figure rather than a default, because "sold out" or "closed" printed on the strength of an RPC timeout is a claim about someone else's offering.

POST /v1/launches/public/:slug/subscription/authorize {buyer} is public. Sable reads pendingOf(buyer) from the chain itself, screens the address, runs the same mint rule as everywhere else, and signs. Its refusals are four different facts and the portal and record render them as four different things: 501 the rail is off, 403 screened, 409 nothing_pending (no escrowed subscription for that address), and 409 mint_refused with a fixed reason — which is the rail working: an unbacked sale stopped before any money moved, with the buyer's payment untouched.

The authorization is recorded as an ordinary mint_authorized action with via: "subscription". There is no separate authorization table and the action sequence stays the EIP-712 nonce.

Issuer-side, POST /v1/launches/:id/subscription {chain_id, address} binds a deployed escrow once, verified against the chain exactly as the token binding is (409 wrong_token / already_closed / fee_not_wired / already_bound), and GET returns it.

What a subscription does not prove

It proves the sale could not settle unbacked. It says nothing about price, value, suitability, or whether the offering is lawful anywhere. It cannot know whether the custodian told Sable the truth — a custodian that misreports produces a VERIFIED check, an authorization signed against it, and a settlement that completes. And it cannot deliver the underlying: redemption terms are the issuer's, off-chain, and the contract holds no assets but the escrowed payment and, for the length of one call, the units it is delivering.

For agents

The MCP tool sable_backing_status(slug) returns the verdict, the covenants and the contract's own answers in one call.

Fees

All fees ride the existing prepaid USDT credit ledger. There is no token, no trading venue, no custody. The sheet is public at GET /v1/launches/fees so no page ever prints a placeholder price, and both fees default to zero.

Launch feeWhendebited once, at lockGateSABLE_LAUNCH_FEE_MICRO_USD
Monthly asset feeWhenhourly worker, per periodGateSABLE_LAUNCH_MONTHLY_FEE_MICRO_USD
Metered operator computeWhennot built — arrives with the narration agentGate

A fee never touches a verdict. If the monthly fee goes unpaid the launch is marked fee_status: "overdue" publicly and a webhook fires — and the checks keep running, the verdict is unaffected, and nothing on the record changes. The verdict is not for sale, and the holders' record is not a hostage.

Not built, and counsel-gated: a basis-point fee on assets under proof (fund-administration territory), any per-transfer fee (it would make Sable a venue), and any bridge into the Vault as an issuance rail.

The whole write surface sits behind SABLE_LAUNCHPAD_ENABLED, which defaults to off. With it off, POST /v1/launches answers 501 and charges nothing; reads always work.

What a launch record proves, and what it does not

An issuer locked a spec, Sable has been reading the named custodian and the chain against it on a schedule, and every mint, pause and declaration since was produced by a deterministic rule over those readings and signed.

That sentence travels inside the signature, so a reader who never opens this page still meets the limits when they verify the receipt.

It does not prove:

Two more things that are true of every one of these, and are worth stating where they cannot be missed: a demanded check has exactly the same standing as a scheduled one, and its origin is Sable's own record rather than part of the signed chain. And Sable does not sell, promote, price, rate or endorse any of it, and runs no secondary market.

Endpoints

GETPath/v1/launchesAuthPublic
GETPath/v1/launches/public/:slugAuthPublic
GETPath/v1/launches/public/:slug/actionsAuthPublic
GETPath/v1/launches/public/:slug/attestationAuthPublic
GETPath/v1/launches/public/:slug/onchainAuthPublic
POSTPath/v1/launches/public/:slug/checkAuthPublic (cooldown; x402 when priced)
POSTPath/v1/launches/public/:slug/redemptionsAuthPublic
GETPath/v1/launches/public/:slug/redemptionsAuthPublic
GETPath/v1/launches/public/:slug/subscriptionAuthPublic (answers when the rail is off)
POSTPath/v1/launches/public/:slug/subscription/authorizeAuthPublic (501 when the rail is off)
POSTPath/v1/launches/:id/subscriptionAuthSession (Admin), once
GETPath/v1/launches/:id/subscriptionAuthSession (Viewer+)
GETPath/v1/launches/feesAuthPublic
POSTPath/v1/launchesAuthSession (Admin)
GETPath/v1/launches/mineAuthSession (Viewer+)
GETPath/v1/launches/:idAuthSession (Viewer+)
PATCHPath/v1/launches/:idAuthSession (Admin)
POSTPath/v1/launches/:id/tokenAuthSession (Admin)
POSTPath/v1/launches/:id/checkAuthSession (Member+)
POSTPath/v1/launches/:id/mint-authorizationsAuthSession (Admin)
POSTPath/v1/launches/:id/distributionsAuthSession (Admin)
POSTPath/v1/launches/:id/navAuthSession (Admin)
POSTPath/v1/launches/:id/retireAuthSession (Admin)

Locking a launch and every action that signs something are Admin, because each one writes something permanent and the launch fee comes out of the organization's shared balance. Reading a launch is Viewer, and taking a check is Member — neither produces a signed artifact. A role refusal is a 403; the portal renders it as "your role cannot lock a launch" with the role named, rather than as a failure.

PATCH accepts the rotatable fields only: the custodian endpoint, its credential, the pointer, the decimals and the interval. The backing rule and the asset are not among them, at any role.

POST /v1/launches/:id/token succeeds once; a second call answers 409 already_bound, because re-binding would invalidate every authorization already signed against the first address.

Webhooks: launch.paused, launch.resumed, launch.mint_authorized, launch.fee_overdue. Payloads are content-free — ids, the public labels, the check sequence and the figures that are already on the public page.

Verify a launch yourself

Nothing below asks you to trust the page you are reading it on.

SLUG=acme-tokenized-aapl

# 1. the record, including the exact bytes the gateway hashed
curl -s https://api.buildsable.com/v1/launches/public/$SLUG > record.json

# 2. the spec digest is sha256 over those bytes
python3 - <<'PY'
import hashlib, json
r = json.load(open("record.json"))
c = r["spec_canonical"]
if c is None:
    print("sealed: this launch has not revealed its spec yet")
else:
    print("recomputed:", hashlib.sha256(c.encode()).hexdigest())
    print("published :", r["spec_sha256"])
PY

# 3. the commitment is Sable's signature over that digest
curl -s https://api.buildsable.com/v1/receipts/verify \
  -H 'content-type: application/json' \
  -d "$(python3 -c 'import json;c=json.load(open("record.json"))["commitment"];print(json.dumps({"receipt":c["receipt"],"signature":c["signature"]}))')"

# 4. the signer it recovers to is the same key the contract enforces
curl -s https://api.buildsable.com/v1/receipts/pubkey

# 5. the backing chain, check by check — failures included
curl -s https://api.buildsable.com/v1/pob/public/$SLUG/checks

Step 4 is the one people skip. The contract's verdictSigner is an immutable field; if it does not equal the address at /v1/receipts/pubkey, then whatever signed the authorizations was not this gateway, and nothing else on the page means what it appears to mean.