5d661ed. bun run check:docs fails the build when a file,
symbol, test or CI job named below no longer exists, or when a test it names is skipped. It checks names, not
meaning: it cannot tell whether a symbol still holds the rule or a test still asserts it. That stays a review
question.
How to read a row
- Enforced by names a file and, after
#, the symbol it declares that holds the rule. - Proved by names a test file and, after
#, the exact name passed totest(), or a CI job in.github/workflows/ci.yml. - Status is one of:
- Enforced: a test or CI job fails if the rule breaks.
- Enforced, with a residual: the rule is tested, but the guarantee is narrower than it first reads; the residual is stated and linked.
- Partly enforced: one half of the rule is tested and the other is not; the row says which.
- Design intent: the code is written this way, but no test or job would catch a change.
Transaction construction and verification
| Id | Invariant | Enforced by | Proved by | Status |
|---|---|---|---|---|
| INV-01 | The transaction builder is a pure module: state in, unsigned operations out, no network access. | apps/api/src/lib/stellar/tx-builder/close-operations.ts#assembleCloseOpsTagged | None. The builder’s unit tests (apps/api/tests/unit/closeOperations.test.ts) run it offline, but nothing fails if it gains a network import | Design intent |
| INV-02 | Every close round reads the account live, immediately before building, and never builds from indexer data alone. | apps/api/src/close/close.service.ts#transactions, apps/api/src/lib/close-api/build-transactions.ts#buildCloseTransactions | apps/api/tests/unit/close-controller.test.ts#"T15 a domain error is rethrown for the filter to map", apps/api/tests/adversarial/sponsoring-accounts.test.ts#"build-transactions re-reads sponsorship affordability live, independent of any plan-time result" | Partly enforced: the tests show each transactions call goes through the account reader and that sponsorship is re-read at build time; that the read is the live ledger rather than a cache is design intent |
| INV-03 | verify() rejects any operation it cannot describe. | apps/web/lib/stellar/verify.ts#assertCloseIntent | apps/web/tests/unit/verify.test.ts#"fails closed on an operation type the switch does not recognize at all", apps/web/tests/unit/verify.test.ts#"rejects a real transaction carrying an unrecognized operation" | Enforced |
| INV-04 | verify() takes the destination, the memo and the user’s per-asset choices from the user’s own inputs, never from the API response. | apps/web/lib/stellar/verify.ts#assertCloseIntent | apps/web/tests/unit/verify.test.ts#"rejects a merge to an unexpected destination", apps/web/tests/unit/verify.test.ts#"rejects a memo the user did not set" | Enforced, with a residual: other expected values come from the API’s own account read, which the user reviews but did not type: the native balance the mediator forward is held to, transfer amounts, the account’s signers and thresholds, a DeFi exit’s position contracts and an xBull route’s path (threat model, Section 11) |
| INV-05 | A SetOptions operation never adds a signer or raises a threshold. | apps/web/lib/stellar/verify.ts#assertCloseIntent | apps/web/tests/unit/verify.test.ts#"rejects a set_options that adds or empowers a signer", apps/web/tests/unit/verify.test.ts#"rejects a set_options that raises the high threshold" | Enforced, with a residual: that a removed signer exists is checked against the API’s read (threat model, Section 11) |
| INV-06 | Every operation is sourced from the account being closed. | apps/web/lib/stellar/verify.ts#assertCloseIntent | apps/web/tests/unit/verify.test.ts#"rejects a merge not sent by the account being closed", apps/web/tests/unit/verify.test.ts#"rejects an exit sourced from another account" | Enforced |
| INV-07 | A payment is only a return to the issuer, the mediator forward, or a transfer matching the user’s own asset, destination and amount choice. | apps/web/lib/stellar/verify.ts#assertCloseIntent | apps/web/tests/unit/verify.test.ts#"rejects a payment to an unexpected address", apps/web/tests/unit/verify.test.ts#"a payment for an asset with no transfer chosen is rejected" | Enforced |
| INV-08 | A conversion pays the account itself, settles in XLM, and carries a positive minimum. | apps/web/lib/stellar/verify.ts#assertCloseIntent | apps/web/tests/unit/verify.test.ts#"rejects a conversion that pays out of the account", apps/web/tests/unit/verify.test.ts#"rejects a conversion that would not settle in XLM", apps/web/tests/unit/verify.test.ts#"rejects a conversion with no minimum floor" | Enforced |
| INV-09 | A destination that needs a memo cannot be closed into without one. | apps/web/lib/stellar/verify.ts#assertCloseIntent | apps/web/tests/unit/verify.test.ts#"rejects a missing memo when the destination requires one (even if the user left it blank)" | Enforced |
| INV-10 | A DeFi exit calls only contracts listed in the account’s analysis, only that protocol’s exit function, pays a bounded fee, and authorizes nothing that pays a third party. | apps/web/lib/stellar/verify.ts#assertCloseIntent, apps/web/lib/stellar/exit-expectations.ts#exitExpectations | apps/web/tests/unit/verify.test.ts#"rejects an exit sub-invocation that spends a token's allowance to a third-party spender", apps/web/tests/unit/verify.test.ts#"rejects an exit sub-invocation calling a function on a token that no exit legitimately needs", apps/web/tests/unit/verify.test.ts#"rejects an exit whose fee is far above what any exit costs" | Enforced, with a residual: the analysis that lists the contracts comes from the API, and the amount an exit moves is not checked by the browser (threat model, Section 11) |
| INV-11 | A DeFi position or plan step that cannot be closed safely surfaces as a blocker with an explanation, never silently skipped. | apps/api/src/lib/defi-exits/run-exit.ts#runExitAdapter, apps/api/src/lib/stellar/tx-builder/index.ts#buildPlan | apps/api/tests/unit/exit-adapter-harness.test.ts#"a network failure while reading is a blocker, not an exception", apps/api/tests/unit/buildPlan.test.ts#"buildPlan › a stale defiPositions result surfaces as a blocker" | Partly enforced: DeFi exits and the cited plan blocker are tested; other plan steps rely on their own tests and review |
| INV-12 | A DeFi exit blocker reaches the user in plain language, with no raw host error or SDK code. | apps/api/src/lib/defi-exits/run-exit.ts#runExitAdapter | apps/api/tests/unit/exit-adapter-harness.test.ts#"blocker messages are plain language - no raw host errors reach the user" | Enforced. Other user-facing errors are plain language by review, not by a test |
| INV-13 | An exit is never built against contract code the registry has not verified for that network and protocol. | apps/api/src/lib/contract-registry/index.ts#resolveWasmHash, apps/api/src/lib/defi-exits/run-exit.ts#runExitAdapter | apps/api/tests/unit/exit-adapter-harness.test.ts#"a step against a contract the registry does not know halts, even when the pool is known", apps/api/tests/unit/exit-adapter-harness.test.ts#"a hash the registry knows only on another network is unknown here" | Enforced |
| INV-14 | The registries fail closed once past their validity date: no exit is built, and no exchange close is accepted. | apps/api/src/lib/contract-registry/index.ts#isRegistryFresh, apps/api/src/lib/exchange-registry/index.ts#isRegistryFresh, apps/api/src/lib/defi-exits/run-exit.ts#runExitAdapter | apps/api/tests/unit/fixtures/exit-adapter-harness.ts#"halts when the registry is past its verification window", apps/api/tests/unit/contract-registry.test.ts#"is fail-closed past validUntil", apps/api/tests/unit/close-controller.test.ts#"T8 an exchange close is refused once the registry has expired" | Enforced |
The two server-side signing keys
| Id | Invariant | Enforced by | Proved by | Status |
|---|---|---|---|---|
| INV-15 | The mediator is a structural conduit: the browser accepts the hand-off only when the account the merge pays into forwards the balance to the user’s address, and the API co-signs only that shape. | apps/web/lib/stellar/verify.ts#assertMergeShape, apps/web/lib/stellar/verify.ts#assertForwardCarriesTheBalance, apps/api/src/mediator/mediator-signing.service.ts#MediatorSigningService | apps/web/tests/unit/verify.test.ts#"a mediated close passes without the client knowing the intermediary's address", apps/web/tests/unit/verify.test.ts#"rejects an intermediary that forwards dust and keeps the balance", apps/api/tests/unit/mediator-controller.test.ts#"S5a a single operation is refused", apps/api/tests/unit/mediator-controller.test.ts#"S10 co-signs with the mediator key and changes nothing else" | Enforced, with two residuals: the forward window S4a.Elevation.1 (threat model, Section 5), and the balance the forward is held to comes from the API’s account read (INV-04) |
| INV-16 | The fee-bump sponsor pays only for a wind-down transaction that acts for a single account. | apps/api/src/fee-bump/fee-bump-validation.ts#isAllowedWindDownOperation, apps/api/src/fee-bump/fee-bump-validation.ts#actsForOneAccount | apps/api/tests/unit/fee-bump-validation.test.ts#"refuses opening or raising a trustline, not just removing one", apps/api/tests/unit/fee-bump-validation.test.ts#"refuses a transaction bundling an operation for a different account, even a wind-down-shaped one" | Enforced, with a residual: a merge’s or a strict-send payment’s destination is not pinned (threat model, Section 11) |
| INV-17 | The mediator and fee-bump secrets live only in the API’s environment and never reach the browser. | apps/api/src/lib/stellar/mediator-server.ts, apps/api/src/lib/stellar/fee-account.ts | None | Design intent. The web reads none of these variables, but nothing fails if it starts to |
Boundaries between the apps
| Id | Invariant | Enforced by | Proved by | Status |
|---|---|---|---|---|
| INV-18 | The web builds no transactions: it only parses, verifies, signs and submits what the API built. | apps/web/eslint.config.mjs#no-restricted-imports, scripts/check-docs-claims.ts#checkWebBuildsNoTransactions | .github/workflows/ci.yml#check, .github/workflows/ci.yml#docs-claims, scripts/check-docs-claims.test.ts#"a web file that builds a transaction is reported, in any of the usual forms" | Enforced, with a residual: the lint rule blocks the API’s closing modules, not the Stellar SDK’s builder, and the check over the source is a heuristic that a determined rewrite could slip past |
| INV-19 | The playground never imports the API’s or the web’s source; it closes through the SDK. | apps/playground/eslint.config.mjs#no-restricted-imports, scripts/check-docs-claims.ts#checkPlaygroundImports | .github/workflows/ci.yml#check, .github/workflows/ci.yml#docs-claims, scripts/check-docs-claims.test.ts#"a playground import from the api or web source is reported" | Enforced |
| INV-20 | The playground checks every transaction against its own, narrower trust anchor before signing. | apps/playground/lib/verify.ts#verifyDemolishTransaction | apps/playground/tests/unit/verify.test.ts#"rejects a merge to any destination other than the sink", apps/playground/tests/unit/verify.test.ts#"rejects a transaction whose source is not the demo account" | Enforced |
| INV-21 | The SDK is published without bundling the Stellar SDK. | scripts/check-docs-claims.ts#checkSdkDoesNotBundleStellarSdk | .github/workflows/ci.yml#docs-claims, scripts/check-docs-claims.test.ts#"an SDK that depends on or imports the Stellar SDK is reported" | Enforced |
Keys, confirmation and the page in the browser
| Id | Invariant | Enforced by | Proved by | Status |
|---|---|---|---|---|
| INV-22 | LumenWipe does not store, log or transmit the user’s secret key: the secret-key mode holds it only in memory and wipes it on completion, abort, navigation away or “Forget key”. | apps/web/lib/stellar/signer.ts#SecretKeySigner | None. apps/web/tests/unit/signer.test.ts covers signing, not persistence or wiping | Design intent, checked by code reading in the threat model, Section 2 |
| INV-23 | Nothing is built or signed before the user confirms the whole plan on the review page, and nothing is saved for resumption before that confirmation. | apps/web/lib/plan/confirm-plan.ts#goToReview | apps/web/tests/unit/confirm-plan.test.ts#"goToReview sets phase to PREFLIGHT_COMPLETE, never STEP_EXECUTING", apps/web/tests/unit/confirm-plan.test.ts#"goToReview navigates to /review, never directly to /execute" | Partly enforced: the navigation to review is tested; that /execute refuses a direct visit and that nothing is saved before confirmation is design intent |
| INV-24 | A destructive choice reaches the API only as the user’s own acknowledgement for that exact destination or account, and the tool never submits on its own. | apps/web/lib/api/close-decisions.ts#destinationAcknowledgementToDecisions | apps/web/tests/unit/close-decisions.test.ts#"destinationAcknowledgementToDecisions › does not carry an acknowledgement to a different destination", apps/web/tests/unit/close-decisions.test.ts#"destinationAcknowledgementToDecisions › emits nothing when nothing was acknowledged" | Partly enforced: how acknowledgements are carried is tested; that every submission and the merge’s full-screen confirmation wait for a user action is design intent |
| INV-25 | The API reports whether a destination exists and whether it needs the mediator or a memo, so the client can ask before it builds anything. | apps/api/src/mediator/mediator-signing.service.ts#MediatorSigningService | apps/api/tests/unit/mediator-controller.test.ts#"C1 a listed exchange reports the mediator and memo requirements", apps/api/tests/unit/mediator-controller.test.ts#"C3 an unlisted missing address is reported as to-be-created" | Partly enforced: the destination check is tested; showing the full address before the merge is design intent |
| INV-26 | In production the Content Security Policy allows only scripts carrying the per-request nonce, and those they load (strict-dynamic), with no unsafe-eval. | apps/web/proxy.ts#proxy | None. apps/web/tests/unit/proxy.test.ts covers connect-src only | Design intent for script-src |
Supply chain
| Id | Invariant | Enforced by | Proved by | Status |
|---|---|---|---|---|
| INV-27 | A high or critical dependency advisory that is not allowlisted with a reason fails CI, and every CI install uses the lockfile as is. | scripts/audit-gate.ts#main, .github/workflows/ci.yml#audit | scripts/audit-gate.test.ts#"fails on a high or critical advisory that is not allowlisted", .github/workflows/ci.yml#audit | Partly enforced: the advisory gate is tested; that every install passes --frozen-lockfile is design intent |
| INV-28 | No dependency that needs dynamic code execution runs in the production web app. | apps/web/proxy.ts#proxy | None | Design intent: a production policy without unsafe-eval (INV-26) would block it at runtime, and nothing checks for it at build time |
Keeping this page true
A new close operation, signing key, adapter or app boundary adds or changes its row here in the same pull request, following the maintenance rule in the threat model, Section 13. Runbun run check:docs before pushing; CI runs it on every pull request.