Conformance
The protocol facts that fail silently, as checks you can run against your own app.
A handful of things about this protocol are easy to get wrong and fail silently - no error, no warning, no crash. Just a wrong number on a screen, an inset that computes to zero, or a button that cannot work.
You would not learn any of them from the SDK's types, and you would only find them by reading protocol source you have no reason to have. So they ship as checks instead.
npm i -D @rising-tide/conformance
npx rt-conformWhat each rule protects against
Every one of these is a real, dated defect from apps built on this protocol. None of them announced itself.
| Rule | What breaks if you ignore it |
|---|---|
sbtc-no-hardcoded-principal | The sBTC deployer differs per network and moves. A stale address renders a balance of 0, or aborts the transaction under a deny post-condition |
sbtc-faucet-network-gate | faucet is a testnet mock function. It does not exist on mainnet sBTC, so a correct address for it is still a call to nothing |
sbtc-display-formatter | A bitcoin amount must read in sats, however large. 0.00001000 sBTC is a price nobody can read, and sBTC is only how Stacks wraps bitcoin - sats are the unit every chain shares |
safe-area-viewport-fit-cover | Without viewportFit: "cover", every env(safe-area-inset-*) in your app computes to 0px - silently, with the markup still looking correct |
safe-area-imports-shared-layer | Rolling your own inset layer means taking on that precondition yourself, with nothing to remind you |
safe-area-no-shadowing | A local utility with the same name as a shared one: both exist, and which wins depends on import order |
install-persist-implies-restore | A persisted install dismissal outlives the session. With no restore surface, the user has removed your app's only install entry point permanently |
chrome-retint-states-ink | Retinting the testnet strip's fill without its ink puts lettering at whatever ratio happens to fall out. Measured before the fix: 1.40:1, and it looked deliberate |
chrome-sources-shell-bundle | Tailwind v4 does not scan node_modules. Protocol chrome renders with no touch floor and no safe-area padding, and nothing errors |
chrome-no-inline-class-list | A transcribed class list freezes at the moment it was copied, so every class added later is silently absent |
scan-reads-this-app | If the scan reads nothing, every rule above passes. This is what tells a misconfiguration apart from a clean app |
sbtc-usd-is-secondary | A dollar figure rendered ahead of the sats it approximates. Sats first, dollars in parentheses - the ordering is the only teaching surface most people get |
credits-is-not-a-unit | "Credits" was scrip the protocol issued, and it is retired; creation is priced in the marketplace's own token. Turbo's winston credits are somebody else's ledger and pass |
registry-supplies-cross-app-routes | A path into another app written by hand is a claim about a routing table your repo cannot see. It renders as a link to a 404 that looks identical to a good one; the registry supplies the route |
registry-app-launch-defers-to-platform | A hardcoded target on a link to another app decides at build time what only the running browser knows - installed, it needs same-tab; in a tab, it needs a new one. useAppLaunchTarget answers |
no-metadata-convention-files | An opengraph-image.tsx beside a route handler silently outranks openGraph.images config in its segment, and Next resolves it against the deployment host, so every deploy mints a new card URL. Cards come from card.png/route.tsx |
card-url-ends-png | X's crawler rejects an extensionless image URL whatever the content type says. A share-image URL ends in .png |
web-host-normalized | NEXT_PUBLIC_WEB_HOST is typed by hand and some environments carry a trailing slash; ${host}/path then emits host//path on one deploy and not the next. Read it in one module that strips the slash |
entity-routes-own-metadata | A segment serving a share card with no layout.tsx or page.tsx exporting metadata shows that person's card beside the site blurb |
chain-text-is-folded | Text that reaches a Clarity (string-ascii) literal is folded in the box (foldToAscii), so an accent comes off while the person watches; what will not fold is refused by the API with a sentence naming the field. A person's or contributor's display name input must fold; offers.chainText lists the app's other compose surfaces |
touch-floor-keyed-on-pointer | A 44px floor released at a width breakpoint (min-h-11 sm:min-h-9) hands the mouse-sized control to every tablet, which is a wide screen with a finger on it. Release on [@media(pointer:fine)]: instead |
paid-write-consults-the-gate | A file that dispatches a protocol write (useCommand, useDeployProduct, rtClient.privy.*) and never asks whether the user can pay. The chain refuses with abort_by_post_condition, which reads as a protocol fault rather than an empty wallet; twenty controls shipped this way. Gate on usePaidAction or a hook of your own that wraps it |
no-hand-rolled-affordability | A balance read compared to a cost by hand. Six of the eight hand-written gates in the estate priced in sats and never read the STX balance, so a wallet funded only in sats passed every one and still could not transact - gas is always STX |
push-worker-shows-every-push | Safari revokes push permission from a site whose push shows nothing, with no error anywhere. The service worker must show a notification for every push, falling back to the payload's fallback for a kind it does not recognise |
push-worker-handles-taps | A notification that opens nothing reads as broken, and a tap is the moment the person came back for. The worker needs a notificationclick handler |
push-unsubscribes-on-sign-out | A subscription belongs to a device, not a session. Sign-out must call disable() from usePushSubscription before the session ends, or a handed-over phone keeps receiving the previous person's news |
declares-install-surface / declares-chrome-surface / declares-safe-area-surface / declares-share-cards-surface / declares-chain-text-surface / declares-paid-actions-surface / declares-push-surface | Your offers declaration says a surface exists and the scan cannot find it, or the reverse. A conditional rule whose condition quietly became false looks exactly like a pass. paidActions is inferred from the write surface when undeclared: a repo that dispatches a protocol write has paid actions whether or not anybody wrote it down |
Two more ship as opt-in extension sets rather than in recommended, because they are Rising Tide house rules rather than protocol facts. Turn one on with "extends" in the config:
| Set | Rule | What it holds |
|---|---|---|
rising-tide-design-system | ds-button-extension-contract | An app that forks a Rising Tide button and declares the extension still satisfies the shared contract - four behaviour rules, nothing about the look |
rising-tide-comment-hygiene | comments-are-point-in-time | A comment states a constraint the current code cannot show. Dates, ticket ids and what something replaced belong in a log, where they cannot rot against the code |
Adopting it
One file at your repo root:
{
"app": "your-app",
"src": ["src"],
"globalsCss": "src/app/globals.css",
"viewportSource": "src/app/layout.tsx"
}Then npx rt-conform. That is the whole adoption - no test runner, no config beyond this file.
Declaring your surfaces
Some rules are conditional: if you offer an install prompt, then namespace your storage key. A conditional rule whose condition silently becomes false is a rule that stops applying without anyone noticing - and a run where it applied to nothing looks exactly like a run where it passed.
{
"offers": {
"install": true,
"testnetBanner": true,
"safeArea": true,
"shareCards": true,
"paidActions": true,
"push": true,
"chainText": ["src/components/auth/WelcomeDialog.tsx", "src/components/write/WritePageForm.tsx"]
}
}push is declared rather than inferred because half of what can break is not in src/: the service worker lives in public/. The worker is read from public/sw.js; set serviceWorker in the config if it lives elsewhere.
chainText is a list rather than a flag: the files in which a person types text that reaches a
Clarity string. Each must fold it (foldToAscii), a file that folds and is not listed fails, and a
listed file that no longer exists fails. With this, a renamed hook fails loudly rather than quietly
retiring the rules behind it. Declaring false is
normal - not every app installs, not every app mounts protocol chrome. Leaving offers out entirely
is also fine: the conditional rules are inferred from what is present, and the report says so rather
than pretending to a guarantee it does not have.
If you disagree with a rule
{
"allow": { "sbtc-display-formatter": "migrating off a vendored formatter, ticket ABC-123" }
}The rule drops to a warning. It still prints and is still counted, and there is no way to silence it entirely. The reason is required because it is the only thing that makes the exemption reviewable in six months.
Running it from a test instead
If you already have a test runner, the CLI is a thin wrapper over a function:
import { runConformance, loadConfig } from "@rising-tide/conformance";
const report = runConformance(process.cwd(), loadConfig(process.cwd()));
expect(report.results.filter((r) => r.status === "fail")).toEqual([]);There is no second implementation and no ESLint plugin beside it. Several rules are cross-file by nature and several are CSS, so a lint rule reaches about half the set and would need this anyway.
Exit codes
| Code | Meaning |
|---|---|
0 | Nothing failed. Warnings may have printed |
1 | Your app has a conformance failure |
2 | The tool is broken - a missing config, or a rule that failed its own control |
The third one is the interesting one. A rule whose matcher quietly stopped matching would report a clean app and look identical to a clean app. So every rule carries positive and negative controls, they run before every real check, and a failed control aborts rather than producing a report that means nothing. If you ever see exit 2, that is our bug - please report it.
What we will not do with this
Conformance is not a gate on access. It is not an access grant, it does not gate an API key, and it must never become one. The moment it is enforced against anybody it stops being a developer tool and becomes a compliance regime, which would contradict protocol, not product far more thoroughly than having no checks at all.
You run it for your own benefit, or not at all.
What is deliberately not a rule
Candidates were considered and rejected, and the reasoning is worth knowing because it tells you what this package will and will not grow into.
“Use our faucet hook.” The fact that the faucet is testnet-only ships. Our chosen means of respecting it does not - you are entitled to write your own faucet UI against the SDK.
“Do not hardcode a sentiment colour.” That is one app's product concept in our palette. Your colours are yours.
Anything about the install-dismissal storage key. Two rules shipped here and both were deleted.
localStorage is scoped to an origin, so two apps on different domains cannot collide however
they key, and subdomains of one registrable domain do not share it either - that is cookies. The
narrower survivor, “namespace your key in case you serve several apps from one origin”,
was kept as a warning for two versions and then deleted too: a warning whose precondition nobody
meets is a line nobody can act on, and a report with lines like that in it stops being read.
If you do serve several apps from one origin, key your install dismissal to your app. There is no rule about it, because we would be guessing about your deployment.
The general form is the part worth stealing: controls prove a matcher matches; they cannot prove the hazard exists. Both of those rules had positive and negative controls, and neither had a defect behind it.
The discriminator, if you ever want to propose a rule:
Does violating this make an app wrong about the protocol - wrong money, wrong chain, wrong address, a broken install, a silently-zero inset? Or does it make the app look unlike ours?
Only the first kind ships.
Versioning
@rising-tide/conformance is not in the SDK/UI tarball lockstep. It is a devDependency, nothing
imports it at runtime, and no rule needs a value from the SDK - every rule needs a name, never an
address. So you can upgrade it on its own schedule without tracking protocol runtime releases.
A new rule enters as a warning and is promoted to an error a minor version later. An upgrade should never be a surprise failure.
That is the only thing warn is for. It is a notice period, not a resting place - a rule that
cannot justify becoming an error gets deleted instead, and the package's own test suite fails
if a warning ships without a version at which it turns into one. A rule set is only worth reading
if every line in it is actionable.