Rising Tide Developers
Reference

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-conform

What each rule protects against

Every one of these is a real, dated defect from apps built on this protocol. None of them announced itself.

RuleWhat breaks if you ignore it
sbtc-no-hardcoded-principalThe 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-gatefaucet 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-formatterA 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-coverWithout viewportFit: "cover", every env(safe-area-inset-*) in your app computes to 0px - silently, with the markup still looking correct
safe-area-imports-shared-layerRolling your own inset layer means taking on that precondition yourself, with nothing to remind you
safe-area-no-shadowingA local utility with the same name as a shared one: both exist, and which wins depends on import order
install-persist-implies-restoreA 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-inkRetinting 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-bundleTailwind 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-listA transcribed class list freezes at the moment it was copied, so every class added later is silently absent
scan-reads-this-appIf the scan reads nothing, every rule above passes. This is what tells a misconfiguration apart from a clean app
sbtc-usd-is-secondaryA 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-routesA 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-platformA 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-filesAn 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-pngX's crawler rejects an extensionless image URL whatever the content type says. A share-image URL ends in .png
web-host-normalizedNEXT_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-metadataA 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-foldedText 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-pointerA 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-gateA 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-affordabilityA 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-pushSafari 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-tapsA 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-outA 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-surfaceYour 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:

SetRuleWhat it holds
rising-tide-design-systemds-button-extension-contractAn 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-hygienecomments-are-point-in-timeA 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:

conformance.config.json
{
  "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.

conformance.config.json
{
  "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

conformance.config.json
{
  "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

CodeMeaning
0Nothing failed. Warnings may have printed
1Your app has a conformance failure
2The 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.