Refer to the Contributing and Architecture Highlights sections of README.md for development workflows, the release process, and repo conventions.
Before claiming a task complete, opening a PR, or merging, run all four. The first three are enforced by CI; pnpm test also catches regressions:
pnpm format:check
pnpm lint:license
pnpm typecheck
pnpm test🔴 pnpm <script> can exit BEFORE running the tool, and it looks like a pass. pnpm 11 runs a deps-status check first; when that fails — most often ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION after bumping onto a freshly published release train — it prints a stack and exits non-zero without invoking tsc/vitest at all. Grepping the output for error TS then finds nothing and reads as a clean typecheck. This cost real bugs in Sept 2026: four type errors (a missed await in identityGate.ts that broke every gated command, the same in DripScreen.tsx, and two malformed allowance literals) all "passed" a typecheck that never ran, and shipped to a live phone test. Check the exit code, or run the tool directly — ./node_modules/.bin/tsc --noEmit, npx vitest run. CI is unaffected (a non-zero exit fails the job either way); this is a local-verification trap only.
pnpm build compiles with bun, which STRIPS types without checking them — it is NOT a type signal. The tsc baseline was burned to zero in June 2026 and pnpm typecheck (tsc --noEmit) now runs as a CI job, so any new type error fails the build — keep it at zero.
.cdm/cdm.d.ts and .cdm/contracts.d.ts are COMMITTED, and must stay so. They are generated by cdm install and are the module augmentation that gives contract handles their real per-method signatures. Without them the registry handle is a bare Contract<ContractDef>, and every .query()/.tx() call site goes silently unchecked — that gap let the 7-arg publish regression and a getMetadataUri shape bug through, and it also produces the inverse trap (code that compiles locally and fails only in CI, because a structural interface can't match the un-augmented type). .cdm/ was gitignored wholesale until Sept 2026; CI never ran cdm install (the cdm CLI is a global tool needing chain access, not a repo dep), so the Typecheck job was checking everything EXCEPT the contract calls. The rest of .cdm/ stays ignored on purpose: contracts/**/info.json snapshots per-version contract addresses that we never read (addresses resolve live from the CDM meta-registry), so committing them would add a second, stale source of truth. After any cdm i, commit the regenerated .d.ts files — a stale one type-checks against the previous ABI, which is exactly the failure this prevents. The cdm-builder version skew is bridged through THREE cast seams: asCloudStorageApi in src/utils/allowances/bulletin.ts (the TypedApi<Paseo_bulletin> vs BulletinTypedApi codegen skew — two codegens of the same chain), plus asCdmAssetHubApi/asCdmAssetHubDescriptor and asCdmRegistryContract in src/commands/contract.ts. The asset-hub seams bridge the paseo-asset-hub DotnsGateway/descriptor shape drift between cdm-builder's pinned @parity/product-sdk-descriptors and our root's (the contract pipeline never touches DotnsGateway, so the cast is runtime-safe). asCdmRegistryContract was added when our root @parity/product-sdk-contracts reached 0.9.x: Contract.tx() now resolves a Result<TxResult, …>, nominally unassignable to cdm-builder's older RegistryContract param — but the install-time registry is query-only (no signer wired in), so .tx() is never invoked through it and the cast is runtime-safe. At cdm-builder@3.2.0 the pinned product-sdk moved up to contracts@0.9.0 / descriptors@0.7.0 while our root is contracts@^0.9.2 / descriptors@^0.8.0 — so the .tx() Result shape realigns (making asCdmRegistryContract likely redundant) while a descriptors minor gap (0.7 vs 0.8) remains. Before deleting any seam, remove it and run pnpm typecheck. If lint:license flags a file you authored, run ./scripts/check-license-headers.sh --fix to prepend the standard Parity Apache-2.0 header (SPDX-License-Identifier: Apache-2.0 + Copyright (C) Parity Technologies (UK) Ltd., both lines required). The check script keeps shebangs on line 1 and places the header below them.
These aren't self-evident from reading the code and have bitten us before. Treat each one as a load-bearing gotcha — don't undo without checking the failure mode it prevents.
-
Import from
@parity/product-sdk-*, never@polkadot-apps/*. The CLI runtime is fully on product-sdk.@polkadot-apps/*is gone from the lockfile and CI'sFormatjob runsgrep -rnE "['\"]@polkadot-apps/" src/ e2e/ scripts/ tools/as a guard. Product-sdk uses caret ranges (^0.x.y); on a 0.x line^only widens patches, so a true breaking change still needs an explicitpackage.jsonbump. -
The CDM contract packages are now
@parity/cdm-*, not@dotdm/*(migrated June 2026). The republish renamed@dotdm/cdmto@parity/cdm-codegen(pinned EXACT0.6.23),@dotdm/contractsto@parity/cdm-builder(pinned EXACT3.2.0), and@dotdm/envto@parity/cdm-env(^2.1.0);@parity/cdm-utilsarrives transitively.cdm-builder/cdm-codegenstay EXACT because this line has shipped breaking changes in PATCH releases. The3.1.4line droppedresolveTargetRegistryAddressandcomputeTargetHashand flattenedCdmJson: thetargets/targetHashlayer is gone, sodependencies(library to version) andcontracts(library to contract) are now flat maps plus a single optional top-levelregistrystring. As a side effect, cdm.json no longer stores connection URLs (asset-hub/bulletin); those resolve only from CLI opts, the chain preset, or config defaults. The3.1.5 → 3.1.6/0.6.18 → 0.6.19/2.0.5 → 2.0.6bump (June 2026) is a verified-safe patch:CdmJson/CdmJsonContractare byte-identical (no re-flattening), the cdm-codegen dist is byte-identical, and the only substantive change is that cdm-builder's product-sdk pins moved UP toward our caret roots — now EXACTcontracts@0.7.3,tx@0.2.10,cloud-storage@0.6.0,descriptors@0.6.0(was0.7.0/0.2.7/0.5.3/0.5.2). Thedescriptors@0.6.0move is what realigns the asset-hub cast seam (see the cdm-builder cast-seams note up top). The3.1.6 → 3.1.7/0.6.19 → 0.6.20bump makes CDM contract metadata publish through direct BulletinTransactionStorage.store, matching playground metadata uploads and bypassing product-sdk's stale remaining-quota preflight (need 1 transactions, have 0);cdm-envremains2.0.6.getBulletinAllowanceSignerinsrc/commands/contract.tsstill castsclient.bulletintoCloudStorageApi(same runtime API, nominal codegen skew — unaffected by the version bump). The3.1.7 → 3.2.0/0.6.20 → 0.6.23/2.0.6 → 2.1.0bump (July 2026, part of the #465 upgrade) is CdmJson-safe:CdmJson/CdmJsonContractare byte-identical (no re-flattening), so thecdm.jsonimport cast and read/write paths are unchanged. Its only substantive change is that cdm-builder's pinned product-sdk moved UP again — nowcontracts@0.9.0,descriptors@0.7.0,tx@0.3.0,cloud-storage@0.7.0— which pullspolkadot-api@2.2.1transitively (aligned to a single instance by bumping our rootpolkadot-apito^2.2.1) and realigns the.tx()Result shape behindasCdmRegistryContract(see the cast-seams note up top). CI'sci.ymlgreps for['"]@dotdm/to block re-introduction. When bumping, run the contract tests +tscfirst. -
Two local pnpm patches remain — statement-store and sdk-statement. The patch on
@novasamatech/statement-store@0.8.11silences its unconditionalconsole.errorfor the expected post-destroyDestroyedError/ bareError("Not connected")in the subscription error callback (real subscription errors still log; still unfixed upstream at 0.8.11). It has been re-pinned across host-stack bumps (@0.8.6→@0.8.7→@0.8.11); the patched region indist/adapter/rpc.jsis byte-identical across that whole range (verified at 0.8.11 by git blob hashb6574289…, matching the diff'sindexline), so the same diff applies unchanged — only the version key + patch filename move with each bump. The patch on@novasamatech/sdk-statement@0.6.0fixes a TDZ crash ingetStatements(const unsubscribereferenced from its own callbacks — when the observable settles synchronously at subscribe time, e.g. a poll firing after client destroy, it threwReferenceError: Cannot access 'unsubscribe' before initializationas an uncaughtException; hit on Ctrl+C during an active pairing; that package lives in novasamatech/papi-sdks, not triangle-js-sdks). The former third patch —@novasamatech/host-papp@0.8.5(ECDH session-key derivation inpersistAndNotify+ gating the[sso-v2]console.info logging) — was deleted when we moved to 0.8.6: both fixes shipped upstream (the ECDH fix, which now also hard-rejects a missingssoEncPubKey, and a default-off__DEBUGgate on the logging). Patches are local-only (applied at install into node_modules, never published), version-pinned, markedPATCH(playground-cli), and should be dropped as upstream ships the fixes. -
@novasamatech/*resolves through@parity/product-sdk-terminal@^0.6.2(host-papp 0.8.11) — do NOT re-pin to 0.7.x, and do NOT slide back to 0.8.5. The mobile app's Handshake V2 rewrite accepts ONLY the V2 pairing offer (SCALE discriminant 1); host-papp 0.7.x emits V1 (discriminant 0), which the phone rejects as the generic "Invalid QR code". There is no version-negotiation knob anywhere in the stack — compatibility is purely which host-papp version resolves. host-papp 0.8.6'sHandshakeSuccessV2is a fixed 258-byte struct requiringrootEntropySource(RFC-0007); the matching mobile change merged as Android PR #754 (master50dfcadb8, 2026-06-05) and ships in builds v1.0.0-1231+. Phones on builds ≤ 1230 send the 226-byte body, which 0.8.6 cannot decode — pairing against an old phone fails at success-decode, and the remedy is updating the phone app, NOT downgrading host-papp (0.8.5's session key was broken without the local patch we deleted; see the patches bullet). 0.8.6 first moved stored SSO sessions/secrets to newSsoSessionsV2/UserSecretsV2_*storage keys with NO migration; host-papp 0.8.7 renamed the session list againSsoSessionsV2→SsoSessionsV3and added a requireddeviceEncPubKey: Bytes(65)field on the persisted session, also with no migration and no graceful-degrade (old blobs throw / are ignored at decode), so every user must re-pair once after the 0.5.0 upgrade (playground logout+playground login).UserSecretsV2_*is unchanged across 0.8.7.clearLocalAppStorage()deletes by${DAPP_ID}_prefix, so logout already coversSsoSessionsV2,SsoSessionsV3, and the orphaned secret blobs.@parity/product-sdk-terminal@0.3.2was the historical floor (its codec mirror matched the 0.8.6*V2schemas). We pinned^0.5.0(June 2026): 0.5.0 bumps host-papp from^0.8.6to^0.8.7and updates the internalcreateTestSessioncodec mirror to the 0.8.7SsoSessionsV3shape; the./hostsubpath we consume and every public signature are unchanged (0.5.0 is additive over 0.4.0's cache-only allowance probe helpers +AllowanceErrorre-export) — the frozen-vector tests (auth.test.ts,signerMode*.test.ts) pass unchanged across the bump. The statement-store patch is re-pinned to whatever version host-papp pins exactly (0.8.7 then, 0.8.11 now); pnpm fails the install loudly (ERR_PNPM_UNUSED_PATCH) if resolution drifts off a patched version (designed tripwire, not a bug). Stable 0.8.7 of the whole@novasamatech/*family publishes the same day as its prerelease line, so theminimumReleaseAgegate would otherwise pin us to the older0.8.7-Nprerelease —pnpm-workspace.yaml::minimumReleaseAgeExcludeopts the 0.8.7 family onto the stable line (drop those entries once the version ages past the window). polkadot-app-deploy's own subtree still pins@parity/product-sdk-terminal@^0.4.0, but its host-papp now DEDUPES onto our 0.8.7 (its^0.8.6range is satisfied by 0.8.7) — so the whole tree shares one host-papp/statement-store 0.8.7 and the patch covers it all; onlyproduct-sdk-terminalstays split (0.4.0 for pad, 0.5.0 for us). That dedup means pad's SSO-session probe now looks fordot-cli_SsoSessionsV3.json(whichplayground loginDOES write) — still fully defused because we pass explicit auth/signers into polkadot-app-deploy in every mode (see the "Dev mode must pass EXPLICIT auth options" invariant). As of July 2026 (#465) we pin@parity/product-sdk-terminal@^0.6.2, which moves the whole@novasamatech/*family (host-papp / statement-store / storage-adapter / host-api) to 0.8.11 (terminal 0.6.2 requireshost-papp@^0.8.9, which resolves to the 0.8.11 line); the statement-store patch is re-pinned to0.8.11andminimumReleaseAgeExcludelists the 0.8.11 family. The truAPI switch (#464) landed only in@parity/product-sdk-host(now0.14.1, built on@parity/truapiinstead of the former@novasamatech/host-apiin-container transport);product-sdk-terminal's SSO/pairing path STILL rides@novasamatech/host-papp, so the family stays in the tree until an upstream terminal moves SSO onto truAPI.deviceEncPubKey/ re-pair-once semantics carry over unchanged from the 0.8.7 note above (0.8.9→0.8.11 are patch-level on the same session schema). -
@polkadot-api/json-rpc-provider: ^0.2.0override is load-bearing. Removing it splits the lockfile across three versions ofjson-rpc-provider(0.0.1/0.0.4/0.2.0) — different PAPI 2.x transitive consumers ask for different versions. Forcing everyone onto0.2.0avoids subtle wire-shape divergence and reduces bundle/process memory. -
@parity/dotns-cli(pinned EXACT0.8.0) ships a broken publish manifest declaring"@polkadot-api/descriptors": "file:.papi/descriptors"— a workspace path missing from the tarball. pnpm refuses; we redirect that sub-dep tostubs/papi-descriptors-stub/(an empty{}export) via the version-agnostic@parity/dotns-cli>@polkadot-api/descriptorsoverride, so the stub carries across version bumps. dotns-cli'sdist/cli.jsis a fully-bundled Bun build, so the stub is functionally correct. The break persists through every published 0.6.x,0.7.2, AND0.8.0(verified: the0.8.0manifest still lists thefile:.papi/descriptorsdep — thepreparescript generates.papi/descriptorslocally butfilesis["dist"], so the manifest keeps the unresolvablefile:dep). The CLI consumes dotns-cli ONLY as the bundleddist/cli.jsbinary (auto-run on import under thedotnsargv, seesrc/dotns-cli-dispatch.ts) — we import no dotns-cli TYPES, so a version bump is a pure binary refresh with no compile surface. Remove the override + stub only once@parity/dotns-clirepublishes a clean manifest. -
🔴 THE DOTNS CONTRACTS SHIP IN "GENERATIONS" AND A STALE
bulletin-deployPIN SILENTLY BREAKS EVERY DEPLOY — this is what0.15.0did (found 2026-09-17, fixed by bumping to0.18.4). DotNS is redeployed in ABI generations behind the same contract addresses, so a version diff ofenvironments.jsonshows NOTHING while the on-chain ABI has moved underneath you. Generations go live per environment, not per release, so the fleet is deliberately mixed (previewnet sat on generation 1 while paseo-next-v2 was already on 3) — bulletin-deploy's ownsrc/dotns-protocol.tswarns "never assume a single live generation, and never read a date in a comment as current — probe." The three:poprules-startingPrice(4-field registration tuple; deposit gate reads flatPopRules.startingPrice()),v0.5.8-rc1(6-field tuple addingmaxPrice/pricingVersion; deposit gate reads per-labelPopRules.price(label);startingPrice()REMOVED — it now reverts), andv0.6.0(encoding byte-identical to v0.5.8-rc1, but a SEMANTIC change in name classification; signature-identical on every PopRules function, so it can only be told apart by probingDotnsPopController.isPopIssued(label)on a different contract).0.15.0knows only generation 1, so on paseo-next-v2 every deploy — dev mode included — died atContract execution would revert during startingPrice on POP_RULES, which reads like a chain/contract outage and is not. Diagnosis trap to avoid next time: the contract ADDRESS is identical across generations and across bulletin-deploy versions, so comparing addresses proves nothing; and a two-signer control (phone +--signer dev) failing identically means "our client is stale", NOT "the environment is down".0.16+added the runtime profile probe that handles all three. When bumping, check the DotNS profile support, not justdeploy()'s signature — and prefer staying near tip on this dep, since the old "explicit pin for stability" rationale below is exactly what caused a month of broken deploys. -
bulletin-deploy(formerly@parity/polkadot-app-deploy) is pinned to an explicit version (0.18.4as of 2026-09-17; previously0.15.0— see the generations note above for why that was harmful), notlatest. A previouslatest(0.6.8) had a WebSocket-heartbeat bug that tore chunk uploads down mid-flight. The pin avoids ever silently sliding onto a brokenlatest. When bumping, read release notes for changes todeploy(), DotNS methods, or theDeployOptionswe use (jsMerkle,signer,signerAddress,storageSigner,storageSignerAddress,mnemonic,rpc,attributes). Newer releases now also export environment helpers (loadEnvironments,resolveEndpoints, etc.); we don't consume them — our env table lives insrc/config.ts::CONFIGS. Do NOT downgrade below 0.8.3: 0.7.30-rc/0.8.0 changed storage routing to use the injectedsigner(so phone-mode chunk uploads would phone-sign and die with "message too big"), and 0.8.3 is the first release with thestorageSignerslot-key escape hatch. The0.8.3 → 0.9.0bump (June 2026) was a code/API drop-in: index exports unchanged,deploy()signature unchanged,DeployOptionsonly additive, console log strings unchanged (sosrc/utils/deploy/progress.ts's banner/[N/M]parser still matches),storageSignerrouting precedence unchanged. It aligns polkadot-app-deploy onto@novasamatech/host-papp@0.8.6/@parity/product-sdk-terminal@0.3.x(collapsing the prior split tree onto our root line), and prefix-matchesdot-cli_SsoSessions*in its SSO-session probe (still defused because we always pass explicit auth/signer/storageSigner). 0.9.0 also ships apostinstall(patch-package || true) bundling an@novasamatech/sdk-statement@0.6.0patch; we DENY that build script (allowBuilds: polkadot-app-deploy: falseinpnpm-workspace.yaml) because our own pnpm patch on that exact package already applies the fix — pnpm 11 will hard-fail everypnpm <script>withERR_PNPM_IGNORED_BUILDSif the entry is left undecided. The phone-tap-reduction (transferToSignedInUser, now shipped in 0.10.0 stable) is unreachable for us regardless: it activates only on polkadot-app-deploy's own signer-resolution (resolve) branch, and we always inject an explicitsigner/mnemonic. The0.9.0 → 0.10.0bump (June 2026) was the same class of additive drop-in:deploy()signature + index exports unchanged,DeployOptionsonly additive (transferToSignedInUser/transferTo), console log strings unchanged (progress parser still matches),storageSignerrouting precedence unchanged; it moves polkadot-app-deploy's own subtree onto@parity/product-sdk-terminal@^0.4.0and still ships the deniedpatch-packagepostinstall. (Since our June 2026 terminal^0.5.0bump, pad's^0.8.6host-papp range dedupes onto our 0.8.7 — only itsproduct-sdk-terminalstays at 0.4.0; see the@novasamatech/*bullet above.) The0.10.0 → 0.11.0bump (June 2026) is the same additive drop-in class:src/index.tsbyte-identical (all exports unchanged),deploy()signature unchanged,DeployOptionsonly additive (onPhoneSignaturePlan/confirmPhoneReady, both optional and unused by us), banner/[N/M]log strings unchanged (progress parser still matches — the #70 0-based chunk renumber only touched prose lines the parser drops),storageSignerrouting precedence unchanged (now via theresolveStorageSignerhelper, precedencestorageSigner > signer > mnemonic > session-slot > pool), terminal subtree still^0.4.0/ host-papp override still0.8.6(dedup story intact), and still ships the deniedpatch-packagepostinstall. 0.11.0 FIXES the nonce-collision re-upload bug (paritytech/polkadot-app-deploy#946): thestoreChunkedContentnonce-collision loop and the phase-B GRANDPA re-upload loop now calldoReconnect()on aChainHead disjointed/connection error (commitf519eed, gated byisConnectionError+reconnectionsUsed < MAX_RECONNECTIONS), so a WS halt mid-re-upload now recovers instead of failing the deploy. (The pre-0.11.0 remedy — re-run the deploy, incremental upload skips already-stored chunks — is no longer needed for that failure mode.) The0.11.0 → 0.13.1bump (July 2026, part of #465) is the same additive drop-in class:deploy()signature + index exports unchanged,DeployOptionsonly additive (verified against every option we pass),storageSignerrouting precedence unchanged, and pad still ships the deniedpatch-packagepostinstall (keepallowBuilds: polkadot-app-deploy: false). Its terminal subtree stays^0.4.0(its own split copy) and it no longer declares a directhost-pappdep. 0.13.x DROPS thesummitenvironment from the bundledenvironments.json— this is what forced the summit/w3s retirement (theconfig.test.tsdivergence guard fails on any wiredCONFIGSenv missing upstream).⚠️ The deploy log-string → progress-bar parser (src/utils/deploy/progress.ts) was NOT re-verified against 0.13.1's actual banner output (the bundled dist obscures the strings); the parser degrades gracefully (unknown banner → dropped, deploy still functions), so this is a cosmetic-only live-verify item. The0.13.1 → 0.15.0bump (Aug 2026) RENAMED the npm package:@parity/polkadot-app-deploystopped at 0.13.x, versions 0.14+ publish asbulletin-deploy(update imports, theallowBuildsdenial key, andminimumReleaseAgeExcludetogether when bumping). The bump was validated on0.15.0-rc.2and the pin moved to0.15.0stable the day upstream cut it — verified code-identical to rc.2 (git tag diff: version bump + an unshipped e2e script fix only; published dist byte-identical after normalizing tsup chunk hashes and the baked version string). Heads-up from that move: pnpm's min-age gate appliesminimumReleaseAgeExcludewhen RESOLVING a version, but the lockfile-verification pass failed to honor the exclude for the PRERELEASE entry (bulletin-deploy@0.15.0-rc.2kept trippingERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATIONeven while excluded; the stable0.15.0entry verifies fine) — if a young prerelease pin ever wedges installs like that again, rewrite the lockfile once withpnpm install --config.minimumReleaseAge=0, then confirm a plainpnpm installpasses. 0.15 is the paseo-next-v2-wipe release: DotNS redeployed at new canonical addresses (owned by itsenvironments.json, keyed by env id) and the DotNS TLD went per-network —environments.jsongained a per-envtldfield ("paseo" on paseo-next-v2; envs without one fall back to upstreamDEFAULT_TLD"dot"),deploy()resolves the authoritative TLD on-chain at preflight (DotnsProtocolRegistry.tld(), dotns PR #218), andparseDomainName(input, tld)rejects wrong-TLD input. Our side:ChainConfig.tld+getEnvTld()insrc/config.ts(divergence-guarded against upstream's catalog; the signing idPLAYGROUND_PRODUCT_ID = "playground.dot"is TLD-independent by convention and must NOT change), andnormalizeDomain(domain, tld)mirrors the wrong-TLD rejection. API surface:deploy()signature +DeployOptionswe pass unchanged (additive:transferTo,contracts,bulletinEndpoints/assetHubEndpoints,publish,dumpCar, …), banner titles (Preflight/Storage/DotNS/DEPLOYMENT COMPLETE!) and[N/M]chunk lines unchanged — verified against the 0.15 SOURCE this time; #1240 moved theDomain:echo to after the Preflight connect (prose the parser drops; test pins it).storageSigner > signer > mnemonic > poolrouting precedence unchanged. Its subtree moved to@parity/product-sdk-terminal@^0.5.1(resolves 0.5.4) whose host-papp^0.8.xDEDUPES onto our 0.8.11 — one host-papp/statement-store tree, patches still apply; onlyproduct-sdk-terminalstays split (0.5.4 pad / 0.7.1 us). Still ships the deniedpatch-packagepostinstall (bundling the same sdk-statement@0.6.0 patch we carry). 0.15 also drops the@parity/dotns-clisubprocess path entirely (no references left), so the[polkadot-app-deploy] @parity/dotns-cli not foundwarning suppression insrc/index.tsis now a dormant no-op; our ownplayground dotnspassthrough keeps the direct dotns-cli dep. -
polkadot-apiis^2.2.1and effectively the only 2.x version in the runtime — keeping it a single instance is load-bearing. The lockfile containspolkadot-api@1.xonly because@parity/dotns-clideclares it, and dotns-cli ships as a single fully-bundleddist/cli.jswith all deps inlined — never resolved at runtime. The root was bumped^2.1.6 → ^2.2.1during #465:cdm-builder@3.2.0's newer product-sdk pins pulled a second 2.x copy (2.2.1) alongside2.1.6, and insrc/commands/contract.tsthe cdm asset-hub client (2.2.1) met our descriptors (2.1.6) as "two differentPtypes" (error TS2345). Aligning the root to^2.2.1collapses the 2.x line to one instance. If a future bump reintroduces a split, re-align the root the same way rather than casting around it. -
✅ RESOLVED (2026-09-23): QR pairing works again on
@parity/product-sdk-terminal@^0.10.0. History, because the symptom is distinctive and may recur:@novasamatech/host-papp@0.9.0(2026-07-29) moved the SSO pairing envelope from P-256/AES-GCM to X25519/ChaCha20-Poly1305, shrinking the device encryption key from 65 to 32 bytes. The handshake codec is fixed-width SCALE (dist/sso/auth/scale/handshakeV2.js:PublicKeyCodec = Bytes(65)at 0.8.x →Bytes(32)at 0.9.0+), so there is no graceful degrade — a 32-byte reader starts the metadataVec33 bytes early and dies on a garbage enum discriminant. Both shipping mobile apps moved mid-August 2026, and every terminal release up to 0.8.2 pinnedhost-papp ^0.8.9(0.x caret ceiling 0.8.12, the last release of the old wire), soplayground loginfailed at scan time for ~5 weeks: iOSPolkadotHandshakeProposal.init():66 - Failed to decode host data: unexpectedDecodedValue→ "Something went wrong"; Android the generic "invalid QR code". Device-verified 2026-09-15 with an A/B pair of QRs identical except the key encoding: 65-byte → rejected, 32-byte → "Device linked". Fixed upstream by product-sdk#364 (merged 2026-09-07, released in terminal 0.9.0) — but 0.9.x is #364 WITHOUT #366, which is a trap: pairing succeeds, thencheckMappingfails forever because the phone mints PGAS to the RFC-0022 address while an unmigrated CLI checks the old one, and the UI reports it as transient. Take 0.10.0 or newer, never 0.9.x — 0.10.0 carries #364 and #366 together. Verify a candidate withgrep -c 'Bytes(32)' node_modules/@novasamatech/host-papp/dist/sso/auth/scale/handshakeV2.js(expect non-zero) and by checkingderiveProductPublicKeyis async.
ACTIVE_TESTNET_ENV(src/config.ts) is THE network switch — flipping it is the entire "change networks" PR. It feeds bothDEFAULT_ENVand the legacytestnetalias inresolveLegacyEnv, so one constant moves the whole CLI. Onlypaseo-next-v2is wired inCONFIGStoday; other envs throw "not supported" fromgetChainConfig(). (Summit / w3s was retired in July 2026 — see the retirement note below.) The deploy--envflag accepts the new ids plus the legacytestnet|mainnetaliases. NOTE: the direct-chain layer (getConnection()) is hardwired togetChainConfig()(DEFAULT_ENV) and thepaseo_*descriptors —--envonly reroutes what we hand polkadot-app-deploy'sdeploy(), not our own reads. When adding an env, populateCONFIGS(every field, includingcdmEnvNameandtokenSymbol) and verify descriptors exist in@parity/product-sdk-descriptors. As ofdescriptors@0.8.0the ONLY exported descriptor set ispaseo-*(thesummit-*subpaths were dropped upstream), sosrc/utils/descriptors.tsreturns the paseo descriptor for EVERY env — direct reads touch common pallets only, so that is safe; restore per-env selection there if another env regains dedicated descriptors. Each env also carries a display-onlytokenSymbol(paseo-next-v2 →PAS); read it viagetTokenSymbol()(the sole consumer isformatPasinsrc/utils/account/drip.ts), so flippingACTIVE_TESTNET_ENVrelabels every balance/drip amount automatically. It is NOT validated by theconfig.test.tsdivergence guard — environments.json carries no symbol — so set it from the chain's own token, not upstream.- All chain URLs / contract addresses live in
src/config.ts— EXCEPT the CDM meta-registry address, which lives in@parity/cdm-envand resolves per-env viagetRegistryAddress(cfg.cdmEnvName). Never inline a websocket URL or0x…address anywhere else.config.tscarries acdmEnvNameper env (the name cdm-env keys on — paseo passes through aspaseo-next-v2; the split matters when an env's product-sdk name differs from cdm-env's key, as the retiredsummit/w3spair did);src/utils/registry.tsandsrc/commands/contract.tsresolve the meta-registry root from it and inject it overcdm.json::registry(which is just whatevercdm ibaked — do NOT hand-editcdm.json).getRegistryAddressreturns""for an unknown/undeployed env, andregistry.tsthrows a clear "bump @parity/cdm-env" error on empty. - Adding a network — the divergence guard is the gate. A guard (
src/config.test.ts) reads polkadot-app-deploy's bundledenvironments.jsonvia its publicloadEnvironments()and asserts every wiredCONFIGSenv's endpoints/network/gateway match upstream, AND that the default env'sgetRegistryAddress(cdmEnvName)is non-empty — so a network switch can't merge until the target is genuinely ready. To add/switch an env: populateCONFIGS(every field), confirm@parity/cdm-envships a non-empty registry address for itscdmEnvName(node --input-type=module -e "import { getRegistryAddress } from '@parity/cdm-env'; console.log(getRegistryAddress('<name>'))"— cdm-env is ESM-only, therequire()form throwsERR_PACKAGE_PATH_NOT_EXPORTED), confirm its endpoints matchenvironments.json(the guard enforces this — bump@parity/polkadot-app-deployif they drift), flipACTIVE_TESTNET_ENV, thenpnpm typecheck && pnpm test. Prerequisites that land upstream BEFORE the CLI switch (not our PR): the env's CDM meta-registry contract + the playground-registry published to CDM (resolves by name@w3s/playground-registry— the@w3sthere is the CDM package namespace, unrelated to the retired w3s env), its DotNS contracts (owned by polkadot-app-deploy's env catalog), and its registry address in@parity/cdm-env. Heads-up: an env's cdm-envipfsGatewayUrlmay be""and disagree with environments.json'sipfs— we source the gateway from environments.json on purpose, so the guard checks against that. - Summit / w3s was RETIRED (July 2026 — part of the #465 bump; also the "retire summit" half of #466). polkadot-app-deploy
0.13.xdroppedsummitfrom its bundledenvironments.json, and@parity/product-sdk-descriptors@0.8.0dropped thesummit-*descriptor subpaths — so the divergence guard failed on our still-wired summitCONFIGSentry. We removed theSUMMITChainConfig, thesummitid fromENV_IDS, thegetNetworkLabelcase, and thecdmEnvName: "w3s"split (config.test.ts/descriptors.test.tsupdated to match).@parity/cdm-env@2.1.0still exposes aw3sregistry address, but nothing consumes it now. Do NOT re-add summit unless upstream restores it inenvironments.jsonAND ships summit descriptors again. Adding devnet (the other half of #466 — a new preset with its own registry + descriptors) is still open and was NOT done here.
- Bulletin storage chunks must NEVER sign with the phone session signer. Chunk txs carry up to 2 MiB of callData; the phone path (
session.createTransaction) forwards the full callData over the statement store, whose host-side request cap is 4 KiB (DEFAULT_MAX_REQUEST_SIZE = 4096, unchanged through host-papp 0.8.x; Android's own statement cap grew to 256 KiB in May 2026 but 2 MiB chunks exceed both), so every chunk dies client-side with "Mobile transaction signing rejected: message too big" and the phone never even shows a prompt. Since polkadot-app-deploy 0.8.x, passingsignerroutes STORAGE through it too (not just DotNS), so phone mode must also passstorageSigner/storageSignerAddress(the local BulletInAllowance slot key, which takes precedence for storage routing only).src/utils/deploy/signerMode.ts::resolveStorageSignerOptionsis the single place that resolves it; bothrunDeployandrunDecentralizethread it intorunStorageDeploy. polkadot-app-deploy (0.8.3 through 0.13.1) can auto-resolve the same slot key from the shareddot-cliallowance cache, but silently falls back to phone-signing the chunks when it misses, so don't rely on it. - Deploy delegates to
polkadot-app-deployfor everything storage-related — chunking, retries, pool accounts, nonce fallback, DAG-PB, DotNS commit-reveal. Don't reimplement. The one thing we own isregistry.publish(). On the v2 registry that ispublish(domain, metadata_uri, visibility, modded_from)— four args, and the SELECTOR CHANGED, so an older CLI reverts against the deployed contract. Theownerparameter is GONE: the registry always recordsenv::caller()as owner, and there is no way to publish on someone else's behalf. That kills the old "dev mode claims the user's identity" trick — a dev-mode deploy is owned by the dev signer and will NOT appear in the user's MyApps, whichsrc/commands/deploy/summary.tsnow states outright instead of implying otherwise.is_moddableandis_dev_signerare gone too: moddability is expressed bymetadata.repositoryin the off-chain metadata JSON (see the--moddableinvariant below), not by a contract flag. Seesrc/utils/deploy/playground.tsandsrc/utils/deploy/signerMode.ts::resolveSignerSetup. - The v2 registry returns
getMetadataUrias a PLAIN STRING, notOption<String>— an absent app is the EMPTY STRING. Decoding lives in exactly one place (src/utils/mod/metadataUri.ts::getAppMetadataUri); keep it there. The pre-v2 contract did return an Option, and the call sites narrowed withvalue.isSome ? value.value : null— against a plain string that readsundefinedevery time, soplayground modandplayground initreported EVERY app as "not found in registry", including ones that demonstrably existed. Both call sites were typedany/unnarrowed, so neithertscnor the tests could see it (same class as the cast seams above — and the reason CI must generate.cdm/and typecheck contract call sites). The decoder throws on an unexpected payload rather than degrading to "not found": a silent wrong-shape read is exactly this bug, and a missing app and a changed ABI need different responses.getAppsis{total, scanned, entries}(AppBrowser.tsxalready handles it) — verify a shape against the chain before trusting an older code path. - Do NOT call
polkadot-app-deploy.deploy()just to store a metadata JSON.deploy()unconditionally runs a DotNSregister()+setContenthash(), and fordomainName: nullinvents atest-domain-<random>label and registers THAT — the side-trip reverts cryptically. For metadata storage we submitTransactionStorage.storedirectly via PAPI usingcalculateCidfrom@parity/product-sdk-bulletin. The metadatastoreis signed with the product-scoped RFC-0010 Bulletin allowance account cached inallowance-keys.json(not Alice, not the product account). Asset Hubregistry.publishis signed with the user's product account in phone mode, and with a dev signer in dev mode — and since v2 the signer IS the recorded owner, so dev mode cannot publish under the user's identity (per the bullet above). Seesrc/utils/deploy/playground.ts::publishToPlayground. - Dev mode must pass EXPLICIT auth options to
polkadot-app-deploy.deploy()— never{}. Since 0.8.x (the "#411 login UX"),deploy()called with nomnemonic, nosigner, and nosuriprobes for a persisted SSO session file and, when found, loads the SSO stack and phone-signs DotNS with the user's session.⚠️ CORRECTED Sept 2026: bulletin-deploy no longer shares ourdot-clistorage namespace. ItsDOT_DAPP_IDis"polkadot-app-deploy"— re-verify against whatever is pinned withgrep -r DOT_DAPP_ID node_modules/bulletin-deploy/dist/; it holds at the pinned0.18.4(dist/auth-config.d.ts) and in the 0.18.2 source (src/auth-config.ts:22), with nodot-cliliteral anywhere in the dist. So its session probe reads~/.polkadot-apps/polkadot-app-deploy_SsoSessions*.json, NOT thedot-cli_*filesplayground loginwrites, and it can no longer pick up a logged-in playground user's session. The earlier claim that it "reusesDOT_DAPP_ID = "dot-cli"" was true of the older@parity/polkadot-app-deployline and is now stale — do not re-derive defenses from it. Keep passing explicit auth options anyway: the storageSigner half of this invariant (below) is unaffected by the namespace split, and a future rename could flip it back — turning a "0 taps" dev deploy into 3-4 phone approvals for every logged-in user. Independently, an absentstorageSignermakes it auto-read the user's cached BulletInAllowance slot key and burn their small phone-granted quota on chunk uploads, in every mode including--suri.resolveSignerSetuptherefore pinsmnemonic: DEFAULT_MNEMONICfor dev mode andresolveStorageSignerOptionspinsstorageSignerto the dev bare-root (dev) or the--surikey (suri) — the bare-root carries its own Bulletin authorization on paseo-next-v2, and polkadot-app-deploy's committed-signer wrapper falls back to the shared pool if it ever lapses. Tests insignerMode.test.ts,run.test.ts, anddecentralize/run.test.tspin the contract. - The "dev signer" used in dev mode is polkadot-app-deploy's
DEFAULT_MNEMONICbare-root account, not Substrate's//Alice. The bare-root SS58 (5DfhGyQd…) is what polkadot-app-deploy uses internally for its DEFAULT_MNEMONIC storage + DotNS signing, so the CLI'screateDevPublishSignerderives from the same(mnemonic, path="")pair viaseedToAccount. Storage, DotNS, and registry publish all sign as one identity. Substrate's//Alice(5Grwva…) is a DIFFERENT account —createDevSigner("Alice")from@parity/product-sdk-txreturns that one. Don't mix them; thesignerModeAlice.test.tssnapshot guards against regression. - Dev-mode re-publish only works on apps that were first published from dev mode. Re-publish authorization accepts
caller == owner OR caller == publisher. Since v2 dropped theownerparameter, BOTH are justenv::caller()at first publish — so a dev-mode deploy is owned+published by the dev bare-root (5DfhGyQd…) and a phone-mode deploy by the user's product H160. A dev-mode re-deploy of a phone-published app is therefore neither, and revertsUnauthorized; to iterate on it in dev mode the user must unpublish from phone mode first. Intentional asymmetry: once a user "owns" an app from their phone, a shared dev key can't touch it. Note this now cuts the other way too — an app you dev-deployed can't be claimed by your phone account later, because nothing can rewriteowner. - Build a dedicated Bulletin client with
heartbeatTimeout: 300_000for the metadata upload. The shared client fromgetConnection()uses@parity/product-sdk-chain-client's default 40 s heartbeat; a singleTransactionStorage.storeround-trip can exceed that and the socket tears down asWS halt (3). We mirror polkadot-app-deploy's 300 s heartbeat with a one-off client that gets destroyed immediately after the upload. playground deploydoes NOT passjsMerkle: truetoday. polkadot-app-deploy's pure-JS merkleizer produces CARs containing only raw leaves (DAG-PB blocks are silently dropped byblockstore-core/memory'sgetAll()underrawLeaves: true+wrapWithDirectory: true) → polkadot-desktop parses zero files → sites return 404. We rely on the Kubo binary path until the upstream merkleizer collects all blocks, not just leaves.playground logininstallsipfs. Trade-off: this temporarily breaks the RevX WebContainer story for the main storage upload — flipjsMerkle: trueback oncemerkleizeJSis fixed.- The mobile app wraps
signRawdata with<Bytes>…</Bytes>(anti-phishing envelope). On paseo-next-v2 this doesn't matter for tx signing:@parity/product-sdk-terminal@0.3.x'screateSessionSignerForAccountroutes transaction signing throughsession.createTransaction— the wallet builds and signs the full extrinsic from a structuredProductAccountTransaction, no<Bytes>envelope — so every signed extension declared by the chain (including paseo-next-v2'sAsPgas) survives end-to-end. Don't reach forsignRawto sign extrinsic payloads from anywhere outside the signer; raw-message signing keeps theBytestag for arbitrary user data. (History: 0.2.1 usedsession.signRaw({ data: { tag: "Payload", … } }); the pre-0.2.1 PJS path failed on v2 withPJS does not support this signed-extension: AsPgas.) - Signer mode selection lives in one file (
src/utils/deploy/signerMode.ts). The mainnet rewrite is a single-file swap; keep that boundary clean. The dev/phone DotNS-owner address derivation isresolveDotnsOwnerAddress(mode, userSigner)there — bothdeploy(availability preflight) anddeploy-all(signing-gate key) call it; do NOT re-inline the ternary. playground deploy-allparallelises ONLY builds; all on-chain work is serialized per signer account.runDeployruns the build outside an optionalsigningGate(src/utils/deploy/signingGate.ts) and wraps the entire on-chain section — Bulletin upload + DotNS + playground publish — inside it. Every extrinsic re-readssystem_accountNextIndexat submission time, so concurrent same-account deploys would collide on a nonce; the gate (a per-address FIFO mutex) makes that impossible by construction.deploy-allalways resolves ONE batch signer, so the gate fully serializes the on-chain phases and onlytsc/vitebuilds overlap (real on-chain parallelism needs distinct signer keys, which the registry already supports but the command does not yet wire up). polkadot-app-deploy may still route storage through a shared POOL account; the gate keys on the user signer, NOT the pool, so multi-signer parallelism would reintroduce a pool-nonce race. KeeprunDeploy's build-outside / on-chain-inside phase split intact.playground logindoes NOT fund or map the user's product account — theSmartContractAllowancegrant does both (since the 2026-06-11 funding-removal change). The phone'sPgas.claim_pgasmints PGAS (asufficientasset, id2_000_000_000on paseo-next-v2) toplayground.dot/0, which creates the account inframe_systemand firespallet_revive::AutoMapper— zero native funding, zeromap_account. TheensureMappedwrite path was deleted with that change; do not resurrect it. The old auto-at-login dev-funder mirror (bulletinTopUp.ts, mirroring polkadot-app-deploy's internalattemptTestnetTopUp) was deleted from the login path AND reinstated as the EXPLICITplayground dripcommand (src/utils/account/drip.ts::dripToProductAccount, UI insrc/commands/drip/). Login must stay funding-free — never auto-call drip from it.dripis deliberately not a faucet: it funds ONLY the caller's ownplayground.dot/0product account (resolved from the session viafindSession(), no--addressflag) and sends oneDRIP_AMOUNT(1 PAS) per run up to aDRIP_CAP(10 PAS), skipping once at/above the cap. It signs from the SAME bare-master dev account (standard dev mnemonic, EMPTY derivation path — NOT//Alice, which is unfunded on paseo-next-v2). When that funder runs dry,dripToProductAccountthrowsDevFunderExhaustedError, which the screen renders as a friendly yellow callout (never a stack trace); "not logged in" and "funder exhausted" both exit 0 (soft outcomes), only an unexpected error exits 1. Dev-mode deploys still get just-in-time funding from polkadot-app-deploy's own internal call insidedeploy(). The operator toolstools/print-bulletin-dev-address.ts/tools/check-bulletin-funder.tsstay (they probe polkadot-app-deploy'sDEFAULT_MNEMONICdev funder, the same accountdripanddeployuse).
session.rootAccountIdis whatever the mobile app published asrootUserAccountIdin the SSO handshake. On current mobile builds (polkadot-app-android-v2, seefeature/sso/impl/.../RealSsoHandshakeUseCase.kt:34→deriveRootAccount() = derivationPath = null) it's the bare-mnemonic sr25519 root with no junction. The host-papp SDK does not derive it — it just decodes the 32 bytes fromHandshakeResponseSensitiveData.rootUserAccountId(triangle-js-sdks/packages/host-papp/src/sso/auth/scale/handshake.ts:23-27) and forwards them. If a future mobile release changes the path, our display will silently change with it — the source of truth is the phone, not the CLI.- The mobile's "Wallet account address" and "Candidate account address" debug rows are NOT reachable from the host. They're sr25519 of mnemonic +
//walletand mnemonic +//candidaterespectively (feature/account/impl/.../RealAccountRepository.kt:166-173, hard junctions). Hard derivations can't be reproduced from a public key, so the CLI never sees those SS58s. Don't try to surface a "wallet address that matches mobile" — it isn't possible without the mnemonic. - 🔴
PLAYGROUND_PRODUCT_IDMUST FOLLOW THE ENV TLD — it is`playground.${getEnvTld()}`(config.ts), NOT a fixed.dot. The phone validates the product id against ITS OWN TLD and silently drops a mismatch (polkadot-android-community#123, root-caused by the Android team 2026-09-16).SsoSessionMessageMappers.kt:151mapsProductSubtreeRequestwithProductId.fromString(productId, tld).getOrThrow()— the ONLY request mapper that re-validates the product id against the PHONE's own TLD (every sibling uses the non-throwingfromStoredValue).productIdPatternis([a-z0-9-]+\.)+<tld>, so a phone whose TLD ispaseorejects every.dotid; the throw escapes throughSsoSession.kt:97, nothing is written back, and the host waits out its full 120-180 s deadline with no error. Their A/B on one session:headless-playground.dot→ timeout,headless-playground.testnet→ 2.9 s OK. This is exactly whyplayground loginhangs at "paired, finalizing" on the #366 branch. Ourconfig.ts:285product id isplayground.dotwhileconfig.ts:150setstld: "paseo"for the only wired env — so we are the failing case. An earlier version of this note said the id was "TLD-independent by convention" and told you NOT to threadgetEnvTldthrough it. That was wrong — the phone decides, and playground-app already shipped the env-suffixed form (defaultDotNsId, commit6e9337c; it printsproduct id : playground.paseoat boot). The two strings MUST match exactly or the CLI and the app derive different H160s for the same human, splitting app ownership, XP and dedupe across two identities.⚠️ Changing the TLD is an IDENTITY MIGRATION: every derived product account moves, and anything keyed on the old id — cached subtrees and the SDK allowance cache, both~/.polkadot-apps/{productId}_*.json— is simply not found under the new id (re-granted at next login, not corrupted). Android still flags their check as stricter than host-spec (host-rust-core'sproduct_manifest.rstreatsdim2.dot,dim2.paseoanddim2as the same node) and note it also rejects everylocalhost:*id, so local dev servers cannot resolve a product account at all. With the env-suffixed id in place, #123 no longer blocks us — it still matters forlocalhost:*ids and for any consumer hardcoding.dot. Two testing gotchas measured on our own handset (2026-09-16), both of which produce the SAME silent ~180 s timeout and are easily mistaken for each other: (a) the phone's TLD really ispaseoon a paseo-next-v2 pairing —getProductSubtree("playground.paseo")returns a valid 32-byte subtree whileplayground.dot/playground.testnetnever answer; (b) the mobile app must be OPEN AND IN THE FOREGROUND — backgrounded, even the correct TLD times out silently (confirmed 3/5 runs succeeded, the 2 failures being backgrounded). Successful replies took anywhere from 3 s to 30 s, so the variance is the statement round trip, not the derivation — do not read a slow reply as a failure. When debugging any SSO round trip, rule out app state BEFORE concluding anything about the wire. AutoSigningreturnsNotAvailableon current mobile builds (measured 2026-09-15 via@dotli/host-cliagainst Android build 1026, alongsideBulletInAllowance→Allocatedon the same call). RFC-0010 saysAutoSigningis the one resource that MUST be requested explicitly — but the wallet will not provision it today, and host-rust-core#343 records that the runtime consumes the capability only foraccount.sign_vrfanyway. So the "AutoSigning makes Bulletin chunk signing work under TrUAPI" plan indocs-internal/2026-09-14-truapi-host-cli-signing.mdis blocked on BOTH a wallet change and #343. Do not assume it is merely a matter of adding the resource toPLAYGROUND_RESOURCES.- ✅ DONE (2026-09-23): the RFC-0022 product-account derivation landed (product-sdk #366, released in
@parity/product-sdk-terminal@0.10.0; we pin^0.10.0). A product account now lives at//product//{productId}/{derivationIndex}where the two//product//{productId}junctions are HARD — a public key cannot cross a hard junction, so the old three-SOFT-junction derivation offsession.rootAccountIdcould never have matched a shipping host. What the migration actually required, recorded because the pre-merge predictions were partly wrong: (1)deriveProductPublicKey(session, ref)replaces the local math and is async, and so arecreateSessionSignerForAccount/createSessionSigner; (2) the parent is the product SUBTREE key, fetched from the wallet viasession.getProductSubtree(consent-free) and cached by the SDK at{appId}_ProductSubtrees.json— it reaches the phone only on a cold cache; (3)ProductAccountRef.derivationIndexstayed a plain number (the predicted taggedDerivationIndexexists only inside@parity/product-sdk-keys, whosederiveProductAccountPublicKey(subtree, {tag:"Index", value})is the 2-arg form — our root must be onkeys@^0.4.0— 0.3.x's signature is(parentPublicKey, productId: string, derivationIndex: number), so the 2-arg call is a tsc error there, and if you bypass tsc it derives from junctions built out of a stringified object.pnpm typecheckcatches it; a vitest-only run does not, because vitest transpiles without typechecking. 0.4.0 itself validates and throws on a bad subtree or an out-of-range index); (4)ProductAccountRef.publicKeyis optional and we deliberately omit it, so the signer and the displayed address resolve through one cached path and cannot desync; (5)deriveSessionAddressesis async, rippling intoConnectResult/LoginStatus/SessionHandle. Test vectors are pinned at the primitive insessionSigner.test.tsagainst host-rust-core's own cross-host vector (tests/wasm_crypto_vectors.rs,product_account_and_entropy_vectors_match_mobile, mirrored in product-sdk'sproduct-account.test.ts) — never regenerate a fixture from our own output.⚠️ Tests must MOCKderiveProductPublicKey/createSessionSignerForAccount: unmocked they hang waiting for a phone AND write a bogus key into the developer's real~/.polkadot-appssubtree cache, breaking their live session. Every product account address MOVED, so users pair again once (playground logout+playground login); the old address keeps anything already published under it. - The playground product account is derived by exactly one function (
src/utils/sessionSigner.ts::derivePlaygroundProductPublicKey), called by bothcreatePlaygroundSessionSigner(signer construction) andauth.ts::deriveSessionAddresses(display triple). The math isderiveProductAccountPublicKey(rootAccountId, "playground.dot", 0)from@parity/product-sdk-keys. Do NOT callderiveProductAccountPublicKey(or any helper that wraps it) on an already-product-derived SS58 — that yields a doubly-derived ghost account. TheproductAccountDisplay/productAccountAddresseshelpers that used to live insrc/commands/login/identityLine.tshad exactly this bug and were deleted; resist re-introducing them. A frozen-vector regression test insrc/utils/auth.test.ts(deriveSessionAddressesblock) locks the pubkey/H160 the playground-app expects. SessionAddressestriples are computed once inauth.tsand threaded through.ConnectResult,LoginStatus.success, andSessionHandleall carry the{ rootAddress, productAddress, productH160 }bundle.SessionHandle.addressis kept as a back-compat alias foraddresses.productAddressbecausesigner.ts::resolveSignerspreads the handle intoResolvedSignerand downstream deploy code (signerMode.ts,playground.ts,registry.ts,DeployScreen.tsx) reads.addressfor the signing key. UI code should preferaddressesso the root vs product distinction stays explicit.
- Slot-account signers come straight from
@parity/product-sdk-terminal/host— and the terminal floor is^0.3.1for exactly this reason. The mobile returnsslotAccountKeyas 64 bytes of schnorrkelSecretKey::to_bytes()material and grants the on-chain allowance to the address it derives natively (AndroidSlotAccountKey.kt::deriveAccountId).@scure/sr25519expects the ed25519-expanded form (scalar ×8); terminal < 0.3.1 fed the raw bytes through and derived a DIFFERENT address the chain never granted anything to — signatures "worked" but everyTransactionStorage.storewas unauthorized, and polkadot-app-deploy silently fell back to the shared pool account (nonce races →AncientBirthBlockchunk deaths). 0.3.1 fixed the derivation upstream (canonicalSr25519SecretToEd25519Bytes, same ×8 math), verified address-equivalent against the CLI's old frozen vectors before the localslotSigner.tsworkaround was deleted. If the derivation ever regresses, the symptom is grants landing on a different address than the signer uses; the live proof is on paseo-next-v2. - Bulletin authorization is checked by existence + non-expiry ONLY — the tx/byte allowance counters are NOT a gate and we never request an
Increase(allowances/bulletin.ts::isAuthorizationActive, fed a dedicated client bydeploy/bulletinAuthContext.ts::createBulletinAuthContext, threaded throughresolveStorageSignerOptions'sbulletinApiparam). This is settled by the chain, not guesswork:pallet-transaction-storage::check_authorizationrejects astoreONLY when the authorization is missing or expired; thetransactions/bytesextent counters merelysaturating_addupward and feed a mempool-priority boost (the hard per-account caps are gated behindif is_renew, and the CLI never callsrenew). polkadot-app-deploy did the same in bulletin-deploy #767 ("thestoreextrinsic uses soft limits"). So a quota-exhausted-but-unexpired slot stores fine — gating on quota only added a per-deploy "approve an Increase on your phone" tap for deploys that would have succeeded, which is why it's gone. Login'scachedBulletinSlotAuthorizationuses the same existence+non-expiry predicate, so it re-prompts only when the slot is genuinely missing/unauthorized/expired, never on low quota. product-sdk'scheckAuthorizationreturns the rawexpirationblock and does NOT evaluate expiry (it has no current-block read), sogetBulletinSlotAuthorizationreadsSystem.Numberitself to compare. The auth-context client is best-effort: a construction failure returns null and skips the up-front check (the slot signer is still used; polkadot-app-deploy reports per-chunk truth). - The statement-store (SSS) allowance is a 1-day renewable resource and is the CHANNEL for every phone interaction, not a signing permission.
session.createTransaction/signRaw/requestResourceAllocationall travel as statements on the People chain; the host's locally derived SSS account needs an on-chain ring slot to submit them. The slot is granted at QR login (the only flow with a direct WebSocket channel) and lapses ~2-3 days later (1-day period +StmtStoreGraceWindowof 2 days, runtime PR individuality#1022). It CANNOT be renewed remotely: the renewal request itself rides SSS (circular dependency), so the only remedy isplayground logout+playground login. There is NO on-chain query for SSS ring membership. When expired, the adapter logsNoAllowanceErrorto console.error but never rejects, so calls hang for the SDK's 180s queue timeout (observed on host-papp 0.7.9; 0.8.x has noNoAllowanceErrorsymbol — it hasAllowanceErrorwith reason codes — so the expired-SSS log line needs live re-verification on 0.8 and the fast-fail match strings may need extending). Defenses:sessionSigner.ts::wrapSignerWithSssFastFail(intercepts the log line, rejects in ~200ms with the logout/login message) andloginStamp.ts+ deploy preflight (warn-only when the recorded login is >2 days old; the stamp lives at~/.polkadot-apps/dot-cli_LoginStamp.jsonso logout clears it). Don't add a "renew SSS on error" path: it cannot work. getSessionSigner()returns an adapter that keeps the Node event loop alive. Every caller must invoke the returneddestroy()when done. Forgetting it manifests asplayground <cmd>hanging after the work visibly finishes.requestResourceAllocationcomes from@parity/product-sdk-terminal/host(the./hostsubpath — it is still NOT re-exported at the package root as of 0.3.1). The old CLI-local shim (src/utils/allowances/host.ts) is gone; only the playground's resource set + display helpers remain insrc/utils/allowances/resources.ts.@parity/product-sdk-host'srequestResourceAllocationis the in-container variant (browser globals required) and won't work from the CLI. Note the resource tag spelling on this path is stillBulletInAllowance(capital I) — theBulletinAllowancerename in host-api 0.8 was only in the in-container protocol, not host-papp's SSO codec.checkMappingmust readRevive.OriginalAccountat{ at: "best" }(src/utils/account/mapping.ts). PAPI's defaultat: "finalized"lags 13-14 blocks (~80 s on paseo-next-v2, measured live 2026-06-11) behind the best head the phone's in-block PGAS-claim confirmation refers to, so a finalized-head read seconds after a grant deterministically reported a freshly-mapped account as "NOT mapped" at login. The login call site additionally passes a bounded retry (attempts) to absorb cross-node block propagation right after a grant. The read goes throughgetUnsafeApi()as defence-in-depth against future descriptor staleness — but note there is NO actual descriptor drift today: typed reads of this entry were verified working live at@parity/product-sdk-descriptors@0.6.0on 2026-06-11, and the "Incompatible runtime entry" claim that circulated during that debugging traced back to a hypothetical code comment, never an observed error. Don't cite descriptor drift as fact without a reproduced error. Mapping is NOT required to CALL a contract —checkMappingreturning false is the normal state for a plain Substrate signer, not a fault. EveryAccountId32already has a deterministic H160 with no storage lookup: an account whose last 12 bytes are all0xEEuses its first 20 bytes, everything else iskeccak256(accountId)[12..32](deriveH160in@parity/product-sdk-address, mirroring pallet-revive'sAccountId32Mapper).env::caller()is that derived address.Revive.OriginalAccountis the REVERSE map (H160 → AccountId32), written bymap_account, and exists only because keccak is not invertible — it is needed when something must credit the full account FROM an address, not to call. Verified 2026-09-24: the dev publish account5DfhGyQd…has noOriginalAccountentry and zero PGAS, yet its keccak-derived0x35Cdb23f…is recorded on-chain as the owner of apps it published. Corollary: contract fees can be paid in PAS — PGAS is not required, so do not diagnose a rejected extrinsic as "needs PGAS" or "needs mapping" without evidence (both were wrongly blamed for the E2EInvalid.Payment; see the task list).- Every RFC-0010 allowance call goes through
productScopedAdapter(src/utils/allowances/resources.ts) — never hand the rawdot-cliadapter to the SDK's host allowance helpers. The SDK sendscallingProductId: adapter.appIdon the wire and the phone derives every per-product artifact from it; forSmartContractAllowanceit MINTS PGAS ON-CHAIN to/product/<callingProductId>/<dest>. With the raw adapter id the 50-PGAS claim landed ondot-cli/0(created + auto-mapped THAT account) while the CLI signs/deploys/checks asplayground.dot/0, which stayed unmapped — root-caused on paseo-next-v2, 2026-06-11. Scoping moves the SDK allowance cache to~/.polkadot-apps/playground.dot_AllowanceKeys.json(existing users re-grant once, single dialog). The signer side already had this split (createSessionSignerForAccounttakes an explicit productId); drop the wrapper only if product-sdk-terminal grows acallingProductIdoption. - Allowance grant markers live at
~/.polkadot/allowances.json(src/utils/allowances/marker.ts), mode 0600, sibling toaccounts.json. RFC-0010 has no on-chain query for allowance status, so we persist{ env: { ss58Address: { resourceTag: { grantedAt, source } } } }after a successful host grant. Slot-account private keys for Bulletin / Statement Store live separately in~/.polkadot/allowance-keys.json(src/utils/allowances/slotKeys.ts), also mode 0600. A marker alone isn't enough to skipplayground loginfor slot resources — confirm the matching key exists too. Markers and keys are isolated per env. Keepsource: "host"as the only value emitted from production code. - Bulletin IS requested through mobile resource allocation in
playground login(sincefb2b9e2, v0.28.0).PLAYGROUND_RESOURCES(src/utils/allowances/resources.ts) bundlesBulletInAllowance+SmartContractAllowanceinto ONErequestResourceAllocationcall so the user sees a single approval dialog. Slot allocation, key caching (~/.polkadot-apps/<appId>_AllowanceKeys.json), and slot-signer construction all live in@parity/product-sdk-terminal/hostnow; the CLI side isgetBulletinAllowanceSigner/getCachedBulletinAllowanceSigner/cachedBulletinSlotAuthorizationinsrc/utils/allowances/bulletin.ts. Usability is existence + non-expiry only (isAuthorizationActive:status.authorized && status.expiration > currentBlock), NOT a quota gate, and we never request anIncrease(see the Allowances invariant on soft limits). Per-resource outcomes areAllocated/Rejected(user declined on the phone) /NotAvailable(the wallet could not provision it at all, e.g. an out-of-date mobile build or a full on-chain slot ring); these need DIFFERENT remedies and must not be conflated.describeAllocationFailureinresources.tssplits them (re-approve vs update-the-app), andsummarizeOutcomesbuckets them.NotAvailableis the exact failure mode that gotStatementStoreAllowancedropped from the request set (11d6a07); Bulletin can't be dropped because it is actually consumed. The pre-fb2b9e2helpers (bulletinAuthorizationHelp,bulletinAuthorizationUrl,hasUsableBulletinSlotAuthorization,hasSlotAccountKey) and the locally-generated-slot-key approach are GONE; do not resurrect them. playground login --yesauto-runs at the end ofinstall.shto skip the interactive QR-scan so non-interactive installers don't block. It installs prerequisites and prints "setup complete", theninstall.shprints a hint to runplayground loginfor the full mobile login. Dep-setup failures surface their exit code so CI runs don't silently pass.- A fresh QR
playground loginROTATES the host device identity, and must keep doing so (src/utils/sessionReset.ts, called fromauth.ts::connect()ONLY on the no-existing-session path). The mobile SSO channel is a statement-store topiccreateSessionId(sessionKey, phoneAccount, hostAccount). The phone derives its session account deterministically and reuses it across re-pairings, andhostAccountcomes from the persisteddot-cli_DeviceIdentity.json(loadOrCreate()reuses it forever) — so the topic is CONSTANT for a given (phone, install). The phone posts aDisconnectedrequest statement on that topic when it supersedes a session, and statements live 7 days (@novasamatech/statement-store'sDEFAULT_EXPIRY_DURATION_SECS). On the next pairing,createSession.init()replays that unrespondedDisconnectedfrom the topic history and host-papp's session managerfilters the just-paired session straight back out ofSsoSessionsV3(the per-sessionUserSecretsV2_*blobs are left orphaned). That is the "initsucceeds but every later command fails withNo signer available, anddot-cli_SsoSessionsV3.jsonis0x00" bug. Rotating the device identity (deletingDeviceIdentityso the next adapter regenerates a freshhostAccount) moves each fresh pairing onto a pristine, history-free topic, and is the ONLY way to escape an already-poisoned topic without waiting out the 7-day TTL or re-pairing on a new device. Rotation deletes ONLYDeviceIdentity+SsoSessionsV3(they must rotate in lockstep — a session is bound to the identity that created it; the stale pre-0.8.7SsoSessionsV2blob is left orphaned, not rotated) and preservesAllowanceKeys(the Bulletin/SSS slot keys, which are not part of the topic and cost phone taps to re-request). Do NOT makeinitrotate unconditionally — gating on "no existing session" is what stops it from destroying a valid pairing on every run. Forensics:dot-cli_sso_processed_<sessionId>.jsonrecords ONLY processedDisconnectedmessage-ids; UUID ids are phone-sent (the host only ever generatesnanoid()), and the same id appearing across multiple session files proves the shared topic. Removing the login-time stale-session pruning loop fromwaitForLogin(it submitted its ownDisconnectedstatements) was part of the same fix — don't re-add it.
src/utils/deploy/*andsrc/utils/build/*must not import React or Ink. They form the SDK surface RevX consumes from a WebContainer. TUI code lives insrc/commands/*/.playground modruns signer-less.runModCommanddoes not callresolveSigner— it usesgetReadOnlyRegistryContract(rawClient)(origin = pallet-revive's keyless pallet account,5EYCAe5ij…, matching product-sdk's query fallback) for browse + metadata-uri lookup. The--suriflag is a deprecated no-op. Users browse + clone moddable apps withoutplayground login/ mapping their account. The signedgetRegistryContract(rawClient, signer)is used only forregistry.publish.tx(...)insrc/utils/deploy/playground.ts. Don't drag a user signer back intoplayground mod.playground initis a thin alias forplayground mod playground-template.src/commands/init/index.tsjust calls the exportedrunModCommand(TEMPLATE_DOMAIN)withTEMPLATE_DOMAIN = "playground-template"— no logic of its own — so it inheritsmod's signer-less, GitHub-tarball-only behaviour automatically. Keep it a pure delegation: ifinitever needs to diverge, change the constant, not the flow. It depends on a registry app published at domainplayground-template; if that starter is renamed or unpublished,initbreaks and the constant is the single place to update.playground modis GitHub-tarball-only and must stay that way.src/utils/mod/source.tsdownloads fromcodeload.github.com(no auth, nogit/ghfor public repos) and extracts vianode:zlib+ the pure-JStarpackage. Do NOT re-introducegit cloneorgh repo fork— both re-add a hard tooling dep, and the fork path was specifically removed because GitHub caps you to one fork per source-repo per account. The interactive picker filters out non-moddable apps. The picker does NOT pre-probe each app's repo visibility (would burn the 60 req/hr anonymous GitHub quota); insteadrunModCommandlazy-probes the picked app once viaassertPublicGitHubRepo()between picker dismount andSetupScreenmount.playgroundnever invokesgh.playground deploy --moddablereads an existingorigin, validates it's a public GitHub URL viaHEAD https://github.com/{o}/{r}, and records it in metadata. No auto-create path. Missingorigin, private repos, and non-GitHub URLs all hard-fail with actionable messages fromsrc/utils/deploy/moddable.ts::resolveRepositoryUrl(). We deliberately do NOT add an interactivegh auth loginhandoff — Ink owns stdout + raw-mode stdin and astdio: "inherit"child would raceuseInputfor keystrokes.metadata.repositoryis set ONLY when--moddableis opted in.runDeploytakes an explicitrepositoryUrl: string | nullandpublishToPlaygroundwrites the field iff that param is non-null. Earlier code silently probedgit remote get-url originand surprised users — don't reintroduce that behaviour.
- Bun compiled-binary stdin quirk — Ink's
useInputsilently drops every keystroke inbun build --compilebinaries unlessprocess.stdin.on('readable', …)is touched before Ink'srender(). We install a no-opreadablelistener at the top ofsrc/index.tsas a warm-up. Symptom if this breaks: TUI renders but nothing responds, including Ctrl+C. - NEVER add
--define process.env.NODE_ENV='"production"'(or any production NODE_ENV) to thebun build --compileinvocations (package.jsonbuild/cli:install+ the three release workflows). bun 1.3's bundler unconditionally emits the development JSX automatic-runtime (jsxDEVfromreact/jsx-dev-runtime) regardless of--production,--compile(which implies--production),--minify, tsconfigjsx: "react-jsx", or a realNODE_ENV=productionenv var — this is bun issue #23959, fix pending in bun PR #31651 (unreleased). ForcingNODE_ENV=productionflips React's inlinedjsx-dev-runtimeonto its production branch, which never assignsjsxDEV→jsxDEV is undefined→ every UI-rendering command crashes at first Ink render (--version/--helpsurvive — they don't render Ink). This shipped in v0.43.5/v0.43.6 before it was caught. The cost of NOT forcing production is React dev-mode warnings reaching end users (e.g. "Cannot update a component while rendering a different component" during deploy) and a slightly larger/slower React — an accepted trade-off until bun ships PR #31651, at which point the define can return. Unit/vitest tests run under Node and CANNOT catch this; it only manifests in the compiled binary. - Process-guard safety net (
src/utils/process-guard.ts) — deploy pipelines open long-lived WebSockets + child processes; any one can keep the event loop alive after the TUI finishes, turningdotinto a zombie. We defend in depth: (1)installSignalHandlers()catches SIGINT/TERM/HUP +unhandledRejectionand forces cleanup + exit within 3 s. The rejection handler runs each rejection throughisBenignUnsubscriptionError, which suppresses four known post-destroy artifacts (rxjsUnsubscriptionError("Not connected"), PAPIDisjointErrorfrom a chainHead unfollow race, PAPI'sDestroyedError("Client destroyed"), and — since the host-papp 0.8 stack — a BAREErrorwhose message is exactly "Not connected", the raw-client teardown throw escaping as a floating rejection; contextual "Not connected: …" messages still escalate). OurSessionHandle.destroy()returns void (so ReactuseEffectcleanups can call it) and firesadapter.destroy().catch(() => {})— fire-and-forget with the rejection silenced at the source. The source-side.catch()is load-bearing because Bun's SEA binary printsunhandledRejectionevents regardless of any process listener — the catch is the only way to suppress it. (2)scheduleHardExit()installs anunref'd timer that kills the process if the loop doesn't drain in time. (3)startMemoryWatchdog()aborts if RSS exceeds 4 GB. Do NOT re-add a per-window growth detector — we tried 300 MB / 3 s and it false-positived on the single-burst metadata-loading spike. SetDOT_MEMORY_TRACE=1to stream per-sample RSS/heap/external stats. - Telemetry bootstrap (
src/bootstrap.ts) is the FIRST import insrc/index.ts. It setsPAD_USE_AMBIENT_SENTRY=1andPAD_HOST_APP=playground-clibeforepolkadot-app-deployevaluates, then mapsDOT_TELEMETRY/internal-context detection toPAD_TELEMETRY. Don't leavePAD_TELEMETRYunset while setting the host app:polkadot-app-deploytreatsplayground-clias an internal host, which would enable deploy telemetry for external users. - Throttle TUI info updates. polkadot-app-deploy logs per-chunk, builds stream thousands of lines/sec.
setState-per-event floods React's reconciler with backpressure (can balloon past 20 GB and freeze the OS).RunningStagecoalesces "latest info" updates to ≤10/sec via a ref + timer and caps line length at 160 chars. Don't hook raw per-line streams directly into Ink state. DeployLogParser.feed()MUST NOT emit an event per log line. It's called for every console line polkadot-app-deploy prints. We emit only for phase-banner matches and[N/M]chunk progress; everything else returnsnull. A catch-allinfoemit allocates ~200 bytes × thousands of lines and was a measurable contributor to chunk-upload memory pressure.- The memory watchdog runs for EVERY command by default (
runCliCommand'swatchdogoption defaults to true). It is the only guard that survives event-loop starvation: when a leaked polkadot-api subscription enters the microtask-flood state, signal handlers,hardExittimers, andsrc/index.ts's finalprocess.exit()all stop firing — the process looks finished but lingers invisibly and grows unbounded. We shipped exactly that in June 2026:playground loginran watchdog-less, and three zombieplaygroundprocesses reached 40+ GB each, swapping the laptop to death. Do NOT opt a command out to save the worker thread — it costs one 1 HzmemoryUsage()sample. The related session-probe rule: everycreateAdapter()call site must destroy the adapter on EVERY path that doesn't transfer ownership to the caller (connect()'s existing-session and probe-throw paths leaked it;src/utils/auth.connect.test.tspins the contract). QueryResult<T>from@parity/product-sdk-contracts@0.5+is a discriminated union. Narrow on.successbefore reading.value. On the failure branch.valueis the runtime's dispatch-error payload (unknown). On the success branchgasRequiredis non-optional. We apply this insrc/utils/contractManifest.ts::resolveLiveContractAddresses,src/commands/mod/AppBrowser.tsx, andsrc/commands/mod/SetupScreen.tsx.- The product-sdk moved to a
Result-based error API (@parity/product-sdk@0.18.0) — several calls that used to THROW now return aResultand NEVER reject. The shape is@parity/result's plain tagged union{ ok: true; value } | { ok: false; error }(NOT neverthrow's class API — narrow on.ok, read.value/.error). Migrated in #465:submitAndWatch(tx0.3),Contract.tx()+ContractManager.fromLiveClient(contracts0.9), andcheckAuthorization(cloud-storage0.8). The dangerous one issubmitAndWatch: pre-0.3 it threw on dispatch failure/timeout/rejection; now it returnserr(TxError)and never rejects. Every CLI call site relied on the throw (deploy aborts, drip/fund error surfacing, the Invalid-Payment retry loop inplayground.ts), so a naive bump SILENTLY swallows failures and tsc can't see it (the return was ignored).src/utils/tx.ts::unwrapResultre-throws theerrchannel and MUST wrap everysubmitAndWatch/batch call (playground.ts,account/{drip,funding,allowance}.ts) as well asContractManager.fromLiveClient(registry.ts). Contract.tx()surfaces a REVERT on theerrchannel (result.ok === false), not as an innerTxResult.ok=false—playground.ts's publish check relies on that.isInvalidPaymentError(allowances/bulletin.ts) now inspects.message+.formatted+.dispatchError+ thecausechain, sinceTxError/TxDispatchErrorno longer carry the marker in.messagealone.registry.publishDevwas folded intopublish(…, is_dev_signer)(contracts0.9dropped the separate method).
- Every user-facing PR must include a changeset. Releases are automated via
.github/workflows/release.yml, which is a no-op unless a.changeset/*.mdfile exists on merge. Create one withpnpm changesetor by hand (frontmatter:"playground-cli": patch|minor|major, body: user-visible summary). Pure refactors / test-only changes can skip it. - Tests are
*.test.tsnext to the source.vitest.config.tsonly picks up.test.ts; if you add.tsxtests update the config too. - Pure logic inside a
.tsxshould be lifted into a sibling.tsfile (completion.tsnext toLoginScreen.tsx;identityLine.tsnext toIdentityLines.tsx;formatPas/formatMbexports inAccountSetup.tsx). Tests can then import it without dragging React + Ink into vitest. - Do NOT add AI/tool attribution (
Co-Authored-By: Claude, "Made with Cursor", emoji signatures) to commits, PRs, or generated files. Never embed your name, identity, or tooling provenance anywhere in the repo. - Do NOT commit design docs, brainstorming notes, or context dumps (e.g.
context.md) to the repo — tickets or scratch files outside the tree. - Don't mock primitives from
polkadot-api(Enum, encoders) in tests — doing so turns intended coverage into tautology. - Long-lived resources (
TerminalAdapter,PaseoClient) have explicitdestroy()/destroyConnection()— always release them, especially from ReactuseEffectcleanups. The WebSocket keeps the event loop alive; forgetting a destroy manifests asplayground <cmd>hanging after the work is visibly finished.
- DSN:
src/telemetry-config.ts::PLAYGROUND_SENTRY_DSN. Region: EU (https://de.sentry.io). Attribute prefix:cli.. Spec:sentry-instrumentation-spec.mdat the repo root (untracked). - The Sentry org slug and the local API-token location used by the dashboard scripts are recorded in
docs-internal/product-context.md(local only), not here. - Helpers — don't reimplement.
src/telemetry.tsexportswithCommandTelemetry,withRootSpan,withSpan(3-arg(op, name, fn)and 4-arg(op, name, attrs, fn)overloads),captureWarning,captureException,errorMessage,sanitizedErrorMessage.src/utils/deploy/phase.tsexportswithDeployPhase.src/cli-runtime.tsexportsrunCliCommand— every command's.action()body should be onerunCliCommand(name, options, async () => { ... })call. The memory watchdog is ON by default for every command (see the Runtime / memory invariant below — do not opt out);hardExitdefaults to true and is currently disabled only forlogin(its event loop drains naturally afterdestroyConnection()+ the QR login-adapter destroy). - Dashboards are JSON snapshots under
sentry/dashboards/<id>.json:2143100(Health, prod filter!cli.tag:e2e-*),2216067(Failures),2216096(E2E Health, inverse filtercli.tag:e2e-*). - Workflow: run
./sentry/backup-dashboards.shBEFORE any change. Use./sentry/patch-dashboard.py <id> <patch.json>for surgical edits or./sentry/create-dashboard.py <payload.json>for new dashboards. PUT replaces the whole widget list — backup first. Don't include aprojectsfield in POST payloads. - E2E tagging: every spawn from
e2e/cli/helpers/dot.tsinjectsDOT_TAG=e2e-local(fallback),DOT_TELEMETRY=1, andDEPLOY_TAG=e2e-cli-local(derived fromDOT_TAGwith ane2e-cli-prefix).tools/e2e-local.shoverridesDOT_TAGtoe2e-local-{smoke|pr|nightly}; CI setsDOT_TAG=e2e-ci-{pr|nightly|dispatch}. Thee2e-cli-prefix onDEPLOY_TAGdistinguishes our E2E traffic from polkadot-app-deploy's own. Production health widgets filter cleanly via!cli.tag:e2e-*. - SAD% propagation is verified by a regression test in
src/telemetry.test.ts("SAD% propagation through transaction envelope"). It confirmscaptureWarningflipscli.sad="true"on the root transaction. If it fails, the SAD% dashboard widget will silently degrade to a duplicate of the unexpected-failure rate.
- Local launcher:
tools/e2e-local.sh [smoke|pr|nightly], alsopnpm test:e2e:{smoke,pr,nightly}. - CI workflow:
.github/workflows/e2e.yml— runs on PR / push:main / cron 06:00 UTC / workflow_dispatch. 13 cells across four matrices (test-no-publish,test-publish,test-nightly-no-publish,test-nightly-publish); publish legs runmax-parallel: 1to avoid stomping a shared deployer account. - Release smoke:
.github/workflows/e2e-release.yml(onrelease: prereleased) and.github/workflows/e2e-post-release.yml(onrelease: published) runpublished.test.tsagainst the SEA asset and theinstall.shconsumer path respectively. - Test files:
e2e/cli/*.test.ts. Reports:e2e-reports/junit.xml+e2e-reports/dot-runs.log(gitignored). CI report job isE2E Report— sticky PR comment marker<!-- e2e-pr-report -->. - Guides:
docs/e2e-running-tests.md(running + reading),docs/e2e-bootstrap.md(maintainer setup), design spec atdocs-internal/2026-05-02-e2e-test-suite-design.md.
The product/business context (team, roadmap, content tiers, XP and prize mechanics, vocabulary,
CLI feature scope) lives in docs-internal/product-context.md — a local-only, gitignored file kept
out of the published repo. Read it for the full product picture; the canonical source is the
Playground Full Spec. The technical invariants above are the load-bearing part for working in this
codebase and stay here.