Skip to content

Latest commit

 

History

History
144 lines (111 loc) · 82.3 KB

File metadata and controls

144 lines (111 loc) · 82.3 KB

CLAUDE.md

Refer to the Contributing and Architecture Highlights sections of README.md for development workflows, the release process, and repo conventions.

Verification before committing

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.

Non-obvious invariants

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.

Dependency pins / lockfile

  • 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's Format job runs grep -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 explicit package.json bump.

  • The CDM contract packages are now @parity/cdm-*, not @dotdm/* (migrated June 2026). The republish renamed @dotdm/cdm to @parity/cdm-codegen (pinned EXACT 0.6.23), @dotdm/contracts to @parity/cdm-builder (pinned EXACT 3.2.0), and @dotdm/env to @parity/cdm-env (^2.1.0); @parity/cdm-utils arrives transitively. cdm-builder/cdm-codegen stay EXACT because this line has shipped breaking changes in PATCH releases. The 3.1.4 line dropped resolveTargetRegistryAddress and computeTargetHash and flattened CdmJson: the targets/targetHash layer is gone, so dependencies (library to version) and contracts (library to contract) are now flat maps plus a single optional top-level registry string. 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. The 3.1.5 → 3.1.6 / 0.6.18 → 0.6.19 / 2.0.5 → 2.0.6 bump (June 2026) is a verified-safe patch: CdmJson/CdmJsonContract are 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 EXACT contracts@0.7.3, tx@0.2.10, cloud-storage@0.6.0, descriptors@0.6.0 (was 0.7.0/0.2.7/0.5.3/0.5.2). The descriptors@0.6.0 move is what realigns the asset-hub cast seam (see the cdm-builder cast-seams note up top). The 3.1.6 → 3.1.7 / 0.6.19 → 0.6.20 bump makes CDM contract metadata publish through direct Bulletin TransactionStorage.store, matching playground metadata uploads and bypassing product-sdk's stale remaining-quota preflight (need 1 transactions, have 0); cdm-env remains 2.0.6. getBulletinAllowanceSigner in src/commands/contract.ts still casts client.bulletin to CloudStorageApi (same runtime API, nominal codegen skew — unaffected by the version bump). The 3.1.7 → 3.2.0 / 0.6.20 → 0.6.23 / 2.0.6 → 2.1.0 bump (July 2026, part of the #465 upgrade) is CdmJson-safe: CdmJson/CdmJsonContract are byte-identical (no re-flattening), so the cdm.json import cast and read/write paths are unchanged. Its only substantive change is that cdm-builder's pinned product-sdk moved UP again — now contracts@0.9.0, descriptors@0.7.0, tx@0.3.0, cloud-storage@0.7.0 — which pulls polkadot-api@2.2.1 transitively (aligned to a single instance by bumping our root polkadot-api to ^2.2.1) and realigns the .tx() Result shape behind asCdmRegistryContract (see the cast-seams note up top). CI's ci.yml greps for ['"]@dotdm/ to block re-introduction. When bumping, run the contract tests + tsc first.

  • Two local pnpm patches remain — statement-store and sdk-statement. The patch on @novasamatech/statement-store@0.8.11 silences its unconditional console.error for the expected post-destroy DestroyedError / bare Error("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 in dist/adapter/rpc.js is byte-identical across that whole range (verified at 0.8.11 by git blob hash b6574289…, matching the diff's index line), so the same diff applies unchanged — only the version key + patch filename move with each bump. The patch on @novasamatech/sdk-statement@0.6.0 fixes a TDZ crash in getStatements (const unsubscribe referenced from its own callbacks — when the observable settles synchronously at subscribe time, e.g. a poll firing after client destroy, it threw ReferenceError: Cannot access 'unsubscribe' before initialization as 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 in persistAndNotify + 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 missing ssoEncPubKey, and a default-off __DEBUG gate on the logging). Patches are local-only (applied at install into node_modules, never published), version-pinned, marked PATCH(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's HandshakeSuccessV2 is a fixed 258-byte struct requiring rootEntropySource (RFC-0007); the matching mobile change merged as Android PR #754 (master 50dfcadb8, 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 new SsoSessionsV2 / UserSecretsV2_* storage keys with NO migration; host-papp 0.8.7 renamed the session list again SsoSessionsV2 → SsoSessionsV3 and added a required deviceEncPubKey: 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 covers SsoSessionsV2, SsoSessionsV3, and the orphaned secret blobs. @parity/product-sdk-terminal@0.3.2 was the historical floor (its codec mirror matched the 0.8.6 *V2 schemas). We pinned ^0.5.0 (June 2026): 0.5.0 bumps host-papp from ^0.8.6 to ^0.8.7 and updates the internal createTestSession codec mirror to the 0.8.7 SsoSessionsV3 shape; the ./host subpath we consume and every public signature are unchanged (0.5.0 is additive over 0.4.0's cache-only allowance probe helpers + AllowanceError re-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 the minimumReleaseAge gate would otherwise pin us to the older 0.8.7-N prerelease — pnpm-workspace.yaml::minimumReleaseAgeExclude opts 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.6 range 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; only product-sdk-terminal stays split (0.4.0 for pad, 0.5.0 for us). That dedup means pad's SSO-session probe now looks for dot-cli_SsoSessionsV3.json (which playground login DOES 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 requires host-papp@^0.8.9, which resolves to the 0.8.11 line); the statement-store patch is re-pinned to 0.8.11 and minimumReleaseAgeExclude lists the 0.8.11 family. The truAPI switch (#464) landed only in @parity/product-sdk-host (now 0.14.1, built on @parity/truapi instead of the former @novasamatech/host-api in-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.0 override is load-bearing. Removing it splits the lockfile across three versions of json-rpc-provider (0.0.1/0.0.4/0.2.0) — different PAPI 2.x transitive consumers ask for different versions. Forcing everyone onto 0.2.0 avoids subtle wire-shape divergence and reduces bundle/process memory.

  • @parity/dotns-cli (pinned EXACT 0.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 to stubs/papi-descriptors-stub/ (an empty {} export) via the version-agnostic @parity/dotns-cli>@polkadot-api/descriptors override, so the stub carries across version bumps. dotns-cli's dist/cli.js is a fully-bundled Bun build, so the stub is functionally correct. The break persists through every published 0.6.x, 0.7.2, AND 0.8.0 (verified: the 0.8.0 manifest still lists the file:.papi/descriptors dep — the prepare script generates .papi/descriptors locally but files is ["dist"], so the manifest keeps the unresolvable file: dep). The CLI consumes dotns-cli ONLY as the bundled dist/cli.js binary (auto-run on import under the dotns argv, see src/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-cli republishes a clean manifest.

  • 🔴 THE DOTNS CONTRACTS SHIP IN "GENERATIONS" AND A STALE bulletin-deploy PIN SILENTLY BREAKS EVERY DEPLOY — this is what 0.15.0 did (found 2026-09-17, fixed by bumping to 0.18.4). DotNS is redeployed in ABI generations behind the same contract addresses, so a version diff of environments.json shows 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 own src/dotns-protocol.ts warns "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 flat PopRules.startingPrice()), v0.5.8-rc1 (6-field tuple adding maxPrice/pricingVersion; deposit gate reads per-label PopRules.price(label); startingPrice() REMOVED — it now reverts), and v0.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 probing DotnsPopController.isPopIssued(label) on a different contract). 0.15.0 knows only generation 1, so on paseo-next-v2 every deploy — dev mode included — died at Contract 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 just deploy()'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.4 as of 2026-09-17; previously 0.15.0 — see the generations note above for why that was harmful), not latest. A previous latest (0.6.8) had a WebSocket-heartbeat bug that tore chunk uploads down mid-flight. The pin avoids ever silently sliding onto a broken latest. When bumping, read release notes for changes to deploy(), DotNS methods, or the DeployOptions we 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 in src/config.ts::CONFIGS. Do NOT downgrade below 0.8.3: 0.7.30-rc/0.8.0 changed storage routing to use the injected signer (so phone-mode chunk uploads would phone-sign and die with "message too big"), and 0.8.3 is the first release with the storageSigner slot-key escape hatch. The 0.8.3 → 0.9.0 bump (June 2026) was a code/API drop-in: index exports unchanged, deploy() signature unchanged, DeployOptions only additive, console log strings unchanged (so src/utils/deploy/progress.ts's banner/[N/M] parser still matches), storageSigner routing 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-matches dot-cli_SsoSessions* in its SSO-session probe (still defused because we always pass explicit auth/signer/storageSigner). 0.9.0 also ships a postinstall (patch-package || true) bundling an @novasamatech/sdk-statement@0.6.0 patch; we DENY that build script (allowBuilds: polkadot-app-deploy: false in pnpm-workspace.yaml) because our own pnpm patch on that exact package already applies the fix — pnpm 11 will hard-fail every pnpm <script> with ERR_PNPM_IGNORED_BUILDS if 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 explicit signer/mnemonic. The 0.9.0 → 0.10.0 bump (June 2026) was the same class of additive drop-in: deploy() signature + index exports unchanged, DeployOptions only additive (transferToSignedInUser/transferTo), console log strings unchanged (progress parser still matches), storageSigner routing precedence unchanged; it moves polkadot-app-deploy's own subtree onto @parity/product-sdk-terminal@^0.4.0 and still ships the denied patch-package postinstall. (Since our June 2026 terminal ^0.5.0 bump, pad's ^0.8.6 host-papp range dedupes onto our 0.8.7 — only its product-sdk-terminal stays at 0.4.0; see the @novasamatech/* bullet above.) The 0.10.0 → 0.11.0 bump (June 2026) is the same additive drop-in class: src/index.ts byte-identical (all exports unchanged), deploy() signature unchanged, DeployOptions only 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), storageSigner routing precedence unchanged (now via the resolveStorageSigner helper, precedence storageSigner > signer > mnemonic > session-slot > pool), terminal subtree still ^0.4.0 / host-papp override still 0.8.6 (dedup story intact), and still ships the denied patch-package postinstall. 0.11.0 FIXES the nonce-collision re-upload bug (paritytech/polkadot-app-deploy#946): the storeChunkedContent nonce-collision loop and the phase-B GRANDPA re-upload loop now call doReconnect() on a ChainHead disjointed/connection error (commit f519eed, gated by isConnectionError + 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.) The 0.11.0 → 0.13.1 bump (July 2026, part of #465) is the same additive drop-in class: deploy() signature + index exports unchanged, DeployOptions only additive (verified against every option we pass), storageSigner routing precedence unchanged, and pad still ships the denied patch-package postinstall (keep allowBuilds: polkadot-app-deploy: false). Its terminal subtree stays ^0.4.0 (its own split copy) and it no longer declares a direct host-papp dep. 0.13.x DROPS the summit environment from the bundled environments.json — this is what forced the summit/w3s retirement (the config.test.ts divergence guard fails on any wired CONFIGS env 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. The 0.13.1 → 0.15.0 bump (Aug 2026) RENAMED the npm package: @parity/polkadot-app-deploy stopped at 0.13.x, versions 0.14+ publish as bulletin-deploy (update imports, the allowBuilds denial key, and minimumReleaseAgeExclude together when bumping). The bump was validated on 0.15.0-rc.2 and the pin moved to 0.15.0 stable 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 applies minimumReleaseAgeExclude when RESOLVING a version, but the lockfile-verification pass failed to honor the exclude for the PRERELEASE entry (bulletin-deploy@0.15.0-rc.2 kept tripping ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION even while excluded; the stable 0.15.0 entry verifies fine) — if a young prerelease pin ever wedges installs like that again, rewrite the lockfile once with pnpm install --config.minimumReleaseAge=0, then confirm a plain pnpm install passes. 0.15 is the paseo-next-v2-wipe release: DotNS redeployed at new canonical addresses (owned by its environments.json, keyed by env id) and the DotNS TLD went per-network — environments.json gained a per-env tld field ("paseo" on paseo-next-v2; envs without one fall back to upstream DEFAULT_TLD "dot"), deploy() resolves the authoritative TLD on-chain at preflight (DotnsProtocolRegistry.tld(), dotns PR #218), and parseDomainName(input, tld) rejects wrong-TLD input. Our side: ChainConfig.tld + getEnvTld() in src/config.ts (divergence-guarded against upstream's catalog; the signing id PLAYGROUND_PRODUCT_ID = "playground.dot" is TLD-independent by convention and must NOT change), and normalizeDomain(domain, tld) mirrors the wrong-TLD rejection. API surface: deploy() signature + DeployOptions we 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 the Domain: echo to after the Preflight connect (prose the parser drops; test pins it). storageSigner > signer > mnemonic > pool routing precedence unchanged. Its subtree moved to @parity/product-sdk-terminal@^0.5.1 (resolves 0.5.4) whose host-papp ^0.8.x DEDUPES onto our 0.8.11 — one host-papp/statement-store tree, patches still apply; only product-sdk-terminal stays split (0.5.4 pad / 0.7.1 us). Still ships the denied patch-package postinstall (bundling the same sdk-statement@0.6.0 patch we carry). 0.15 also drops the @parity/dotns-cli subprocess path entirely (no references left), so the [polkadot-app-deploy] @parity/dotns-cli not found warning suppression in src/index.ts is now a dormant no-op; our own playground dotns passthrough keeps the direct dotns-cli dep.

  • polkadot-api is ^2.2.1 and effectively the only 2.x version in the runtime — keeping it a single instance is load-bearing. The lockfile contains polkadot-api@1.x only because @parity/dotns-cli declares it, and dotns-cli ships as a single fully-bundled dist/cli.js with all deps inlined — never resolved at runtime. The root was bumped ^2.1.6 → ^2.2.1 during #465: cdm-builder@3.2.0's newer product-sdk pins pulled a second 2.x copy (2.2.1) alongside 2.1.6, and in src/commands/contract.ts the cdm asset-hub client (2.2.1) met our descriptors (2.1.6) as "two different P types" (error TS2345). Aligning the root to ^2.2.1 collapses 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 metadata Vec 33 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 pinned host-papp ^0.8.9 (0.x caret ceiling 0.8.12, the last release of the old wire), so playground login failed at scan time for ~5 weeks: iOS PolkadotHandshakeProposal.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, then checkMapping fails 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 with grep -c 'Bytes(32)' node_modules/@novasamatech/host-papp/dist/sso/auth/scale/handshakeV2.js (expect non-zero) and by checking deriveProductPublicKey is async.

Network / env

  • ACTIVE_TESTNET_ENV (src/config.ts) is THE network switch — flipping it is the entire "change networks" PR. It feeds both DEFAULT_ENV and the legacy testnet alias in resolveLegacyEnv, so one constant moves the whole CLI. Only paseo-next-v2 is wired in CONFIGS today; other envs throw "not supported" from getChainConfig(). (Summit / w3s was retired in July 2026 — see the retirement note below.) The deploy --env flag accepts the new ids plus the legacy testnet|mainnet aliases. NOTE: the direct-chain layer (getConnection()) is hardwired to getChainConfig() (DEFAULT_ENV) and the paseo_* descriptors — --env only reroutes what we hand polkadot-app-deploy's deploy(), not our own reads. When adding an env, populate CONFIGS (every field, including cdmEnvName and tokenSymbol) and verify descriptors exist in @parity/product-sdk-descriptors. As of descriptors@0.8.0 the ONLY exported descriptor set is paseo-* (the summit-* subpaths were dropped upstream), so src/utils/descriptors.ts returns 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-only tokenSymbol (paseo-next-v2 → PAS); read it via getTokenSymbol() (the sole consumer is formatPas in src/utils/account/drip.ts), so flipping ACTIVE_TESTNET_ENV relabels every balance/drip amount automatically. It is NOT validated by the config.test.ts divergence 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-env and resolves per-env via getRegistryAddress(cfg.cdmEnvName). Never inline a websocket URL or 0x… address anywhere else. config.ts carries a cdmEnvName per env (the name cdm-env keys on — paseo passes through as paseo-next-v2; the split matters when an env's product-sdk name differs from cdm-env's key, as the retired summit/w3s pair did); src/utils/registry.ts and src/commands/contract.ts resolve the meta-registry root from it and inject it over cdm.json::registry (which is just whatever cdm i baked — do NOT hand-edit cdm.json). getRegistryAddress returns "" for an unknown/undeployed env, and registry.ts throws 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 bundled environments.json via its public loadEnvironments() and asserts every wired CONFIGS env's endpoints/network/gateway match upstream, AND that the default env's getRegistryAddress(cdmEnvName) is non-empty — so a network switch can't merge until the target is genuinely ready. To add/switch an env: populate CONFIGS (every field), confirm @parity/cdm-env ships a non-empty registry address for its cdmEnvName (node --input-type=module -e "import { getRegistryAddress } from '@parity/cdm-env'; console.log(getRegistryAddress('<name>'))" — cdm-env is ESM-only, the require() form throws ERR_PACKAGE_PATH_NOT_EXPORTED), confirm its endpoints match environments.json (the guard enforces this — bump @parity/polkadot-app-deploy if they drift), flip ACTIVE_TESTNET_ENV, then pnpm 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 @w3s there 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-env ipfsGatewayUrl may be "" and disagree with environments.json's ipfs — 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.x dropped summit from its bundled environments.json, and @parity/product-sdk-descriptors@0.8.0 dropped the summit-* descriptor subpaths — so the divergence guard failed on our still-wired summit CONFIGS entry. We removed the SUMMIT ChainConfig, the summit id from ENV_IDS, the getNetworkLabel case, and the cdmEnvName: "w3s" split (config.test.ts / descriptors.test.ts updated to match). @parity/cdm-env@2.1.0 still exposes a w3s registry address, but nothing consumes it now. Do NOT re-add summit unless upstream restores it in environments.json AND 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.

Deploy / Bulletin

  • 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, passing signer routes STORAGE through it too (not just DotNS), so phone mode must also pass storageSigner/storageSignerAddress (the local BulletInAllowance slot key, which takes precedence for storage routing only). src/utils/deploy/signerMode.ts::resolveStorageSignerOptions is the single place that resolves it; both runDeploy and runDecentralize thread it into runStorageDeploy. polkadot-app-deploy (0.8.3 through 0.13.1) can auto-resolve the same slot key from the shared dot-cli allowance cache, but silently falls back to phone-signing the chunks when it misses, so don't rely on it.
  • Deploy delegates to polkadot-app-deploy for everything storage-related — chunking, retries, pool accounts, nonce fallback, DAG-PB, DotNS commit-reveal. Don't reimplement. The one thing we own is registry.publish(). On the v2 registry that is publish(domain, metadata_uri, visibility, modded_from) — four args, and the SELECTOR CHANGED, so an older CLI reverts against the deployed contract. The owner parameter is GONE: the registry always records env::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, which src/commands/deploy/summary.ts now states outright instead of implying otherwise. is_moddable and is_dev_signer are gone too: moddability is expressed by metadata.repository in the off-chain metadata JSON (see the --moddable invariant below), not by a contract flag. See src/utils/deploy/playground.ts and src/utils/deploy/signerMode.ts::resolveSignerSetup.
  • The v2 registry returns getMetadataUri as a PLAIN STRING, not Option<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 with value.isSome ? value.value : null — against a plain string that reads undefined every time, so playground mod and playground init reported EVERY app as "not found in registry", including ones that demonstrably existed. Both call sites were typed any/unnarrowed, so neither tsc nor 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. getApps is {total, scanned, entries} (AppBrowser.tsx already 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 DotNS register() + setContenthash(), and for domainName: null invents a test-domain-<random> label and registers THAT — the side-trip reverts cryptically. For metadata storage we submit TransactionStorage.store directly via PAPI using calculateCid from @parity/product-sdk-bulletin. The metadata store is signed with the product-scoped RFC-0010 Bulletin allowance account cached in allowance-keys.json (not Alice, not the product account). Asset Hub registry.publish is 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). See src/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 no mnemonic, no signer, and no suri probes 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 our dot-cli storage namespace. Its DOT_DAPP_ID is "polkadot-app-deploy" — re-verify against whatever is pinned with grep -r DOT_DAPP_ID node_modules/bulletin-deploy/dist/; it holds at the pinned 0.18.4 (dist/auth-config.d.ts) and in the 0.18.2 source (src/auth-config.ts:22), with no dot-cli literal anywhere in the dist. So its session probe reads ~/.polkadot-apps/polkadot-app-deploy_SsoSessions*.json, NOT the dot-cli_* files playground login writes, and it can no longer pick up a logged-in playground user's session. The earlier claim that it "reuses DOT_DAPP_ID = "dot-cli"" was true of the older @parity/polkadot-app-deploy line 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 absent storageSigner makes 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. resolveSignerSetup therefore pins mnemonic: DEFAULT_MNEMONIC for dev mode and resolveStorageSignerOptions pins storageSigner to the dev bare-root (dev) or the --suri key (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 in signerMode.test.ts, run.test.ts, and decentralize/run.test.ts pin the contract.
  • The "dev signer" used in dev mode is polkadot-app-deploy's DEFAULT_MNEMONIC bare-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's createDevPublishSigner derives from the same (mnemonic, path="") pair via seedToAccount. Storage, DotNS, and registry publish all sign as one identity. Substrate's //Alice (5Grwva…) is a DIFFERENT account — createDevSigner("Alice") from @parity/product-sdk-tx returns that one. Don't mix them; the signerModeAlice.test.ts snapshot 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 the owner parameter, BOTH are just env::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 reverts Unauthorized; 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 rewrite owner.
  • Build a dedicated Bulletin client with heartbeatTimeout: 300_000 for the metadata upload. The shared client from getConnection() uses @parity/product-sdk-chain-client's default 40 s heartbeat; a single TransactionStorage.store round-trip can exceed that and the socket tears down as WS halt (3). We mirror polkadot-app-deploy's 300 s heartbeat with a one-off client that gets destroyed immediately after the upload.
  • playground deploy does NOT pass jsMerkle: true today. polkadot-app-deploy's pure-JS merkleizer produces CARs containing only raw leaves (DAG-PB blocks are silently dropped by blockstore-core/memory's getAll() under rawLeaves: 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 login installs ipfs. Trade-off: this temporarily breaks the RevX WebContainer story for the main storage upload — flip jsMerkle: true back once merkleizeJS is fixed.
  • The mobile app wraps signRaw data with <Bytes>…</Bytes> (anti-phishing envelope). On paseo-next-v2 this doesn't matter for tx signing: @parity/product-sdk-terminal@0.3.x's createSessionSignerForAccount routes transaction signing through session.createTransaction — the wallet builds and signs the full extrinsic from a structured ProductAccountTransaction, no <Bytes> envelope — so every signed extension declared by the chain (including paseo-next-v2's AsPgas) survives end-to-end. Don't reach for signRaw to sign extrinsic payloads from anywhere outside the signer; raw-message signing keeps the Bytes tag for arbitrary user data. (History: 0.2.1 used session.signRaw({ data: { tag: "Payload", … } }); the pre-0.2.1 PJS path failed on v2 with PJS 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 is resolveDotnsOwnerAddress(mode, userSigner) there — both deploy (availability preflight) and deploy-all (signing-gate key) call it; do NOT re-inline the ternary.
  • playground deploy-all parallelises ONLY builds; all on-chain work is serialized per signer account. runDeploy runs the build outside an optional signingGate (src/utils/deploy/signingGate.ts) and wraps the entire on-chain section — Bulletin upload + DotNS + playground publish — inside it. Every extrinsic re-reads system_accountNextIndex at 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-all always resolves ONE batch signer, so the gate fully serializes the on-chain phases and only tsc/vite builds 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. Keep runDeploy's build-outside / on-chain-inside phase split intact.
  • playground login does NOT fund or map the user's product account — the SmartContractAllowance grant does both (since the 2026-06-11 funding-removal change). The phone's Pgas.claim_pgas mints PGAS (a sufficient asset, id 2_000_000_000 on paseo-next-v2) to playground.dot/0, which creates the account in frame_system and fires pallet_revive::AutoMapper — zero native funding, zero map_account. The ensureMapped write 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 internal attemptTestnetTopUp) was deleted from the login path AND reinstated as the EXPLICIT playground drip command (src/utils/account/drip.ts::dripToProductAccount, UI in src/commands/drip/). Login must stay funding-free — never auto-call drip from it. drip is deliberately not a faucet: it funds ONLY the caller's own playground.dot/0 product account (resolved from the session via findSession(), no --address flag) and sends one DRIP_AMOUNT (1 PAS) per run up to a DRIP_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, dripToProductAccount throws DevFunderExhaustedError, 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 inside deploy(). The operator tools tools/print-bulletin-dev-address.ts / tools/check-bulletin-funder.ts stay (they probe polkadot-app-deploy's DEFAULT_MNEMONIC dev funder, the same account drip and deploy use).

Accounts: root, product, and what the mobile app shows

  • session.rootAccountId is whatever the mobile app published as rootUserAccountId in the SSO handshake. On current mobile builds (polkadot-app-android-v2, see feature/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 from HandshakeResponseSensitiveData.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 + //wallet and mnemonic + //candidate respectively (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_ID MUST 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:151 maps ProductSubtreeRequest with ProductId.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-throwing fromStoredValue). productIdPattern is ([a-z0-9-]+\.)+<tld>, so a phone whose TLD is paseo rejects every .dot id; the throw escapes through SsoSession.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 why playground login hangs at "paired, finalizing" on the #366 branch. Our config.ts:285 product id is playground.dot while config.ts:150 sets tld: "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 thread getEnvTld through it. That was wrong — the phone decides, and playground-app already shipped the env-suffixed form (defaultDotNsId, commit 6e9337c; it prints product id : playground.paseo at 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's product_manifest.rs treats dim2.dot, dim2.paseo and dim2 as the same node) and note it also rejects every localhost:* 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 for localhost:* 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 is paseo on a paseo-next-v2 pairing — getProductSubtree("playground.paseo") returns a valid 32-byte subtree while playground.dot / playground.testnet never 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.
  • AutoSigning returns NotAvailable on current mobile builds (measured 2026-09-15 via @dotli/host-cli against Android build 1026, alongside BulletInAllowance → Allocated on the same call). RFC-0010 says AutoSigning is 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 for account.sign_vrf anyway. So the "AutoSigning makes Bulletin chunk signing work under TrUAPI" plan in docs-internal/2026-09-14-truapi-host-cli-signing.md is blocked on BOTH a wallet change and #343. Do not assume it is merely a matter of adding the resource to PLAYGROUND_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 off session.rootAccountId could 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 are createSessionSignerForAccount / createSessionSigner; (2) the parent is the product SUBTREE key, fetched from the wallet via session.getProductSubtree (consent-free) and cached by the SDK at {appId}_ProductSubtrees.json — it reaches the phone only on a cold cache; (3) ProductAccountRef.derivationIndex stayed a plain number (the predicted tagged DerivationIndex exists only inside @parity/product-sdk-keys, whose deriveProductAccountPublicKey(subtree, {tag:"Index", value}) is the 2-arg form — our root must be on keys@^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 typecheck catches 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.publicKey is optional and we deliberately omit it, so the signer and the displayed address resolve through one cached path and cannot desync; (5) deriveSessionAddresses is async, rippling into ConnectResult / LoginStatus / SessionHandle. Test vectors are pinned at the primitive in sessionSigner.test.ts against host-rust-core's own cross-host vector (tests/wasm_crypto_vectors.rs, product_account_and_entropy_vectors_match_mobile, mirrored in product-sdk's product-account.test.ts) — never regenerate a fixture from our own output. ⚠️ Tests must MOCK deriveProductPublicKey/createSessionSignerForAccount: unmocked they hang waiting for a phone AND write a bogus key into the developer's real ~/.polkadot-apps subtree 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 both createPlaygroundSessionSigner (signer construction) and auth.ts::deriveSessionAddresses (display triple). The math is deriveProductAccountPublicKey(rootAccountId, "playground.dot", 0) from @parity/product-sdk-keys. Do NOT call deriveProductAccountPublicKey (or any helper that wraps it) on an already-product-derived SS58 — that yields a doubly-derived ghost account. The productAccountDisplay / productAccountAddresses helpers that used to live in src/commands/login/identityLine.ts had exactly this bug and were deleted; resist re-introducing them. A frozen-vector regression test in src/utils/auth.test.ts (deriveSessionAddresses block) locks the pubkey/H160 the playground-app expects.
  • SessionAddresses triples are computed once in auth.ts and threaded through. ConnectResult, LoginStatus.success, and SessionHandle all carry the { rootAddress, productAddress, productH160 } bundle. SessionHandle.address is kept as a back-compat alias for addresses.productAddress because signer.ts::resolveSigner spreads the handle into ResolvedSigner and downstream deploy code (signerMode.ts, playground.ts, registry.ts, DeployScreen.tsx) reads .address for the signing key. UI code should prefer addresses so the root vs product distinction stays explicit.

Allowances / session

  • Slot-account signers come straight from @parity/product-sdk-terminal/host — and the terminal floor is ^0.3.1 for exactly this reason. The mobile returns slotAccountKey as 64 bytes of schnorrkel SecretKey::to_bytes() material and grants the on-chain allowance to the address it derives natively (Android SlotAccountKey.kt::deriveAccountId). @scure/sr25519 expects 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 every TransactionStorage.store was unauthorized, and polkadot-app-deploy silently fell back to the shared pool account (nonce races → AncientBirthBlock chunk deaths). 0.3.1 fixed the derivation upstream (canonicalSr25519SecretToEd25519Bytes, same ×8 math), verified address-equivalent against the CLI's old frozen vectors before the local slotSigner.ts workaround 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 by deploy/bulletinAuthContext.ts::createBulletinAuthContext, threaded through resolveStorageSignerOptions's bulletinApi param). This is settled by the chain, not guesswork: pallet-transaction-storage::check_authorization rejects a store ONLY when the authorization is missing or expired; the transactions/bytes extent counters merely saturating_add upward and feed a mempool-priority boost (the hard per-account caps are gated behind if is_renew, and the CLI never calls renew). polkadot-app-deploy did the same in bulletin-deploy #767 ("the store extrinsic 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's cachedBulletinSlotAuthorization uses 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's checkAuthorization returns the raw expiration block and does NOT evaluate expiry (it has no current-block read), so getBulletinSlotAuthorization reads System.Number itself 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 / requestResourceAllocation all 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 + StmtStoreGraceWindow of 2 days, runtime PR individuality#1022). It CANNOT be renewed remotely: the renewal request itself rides SSS (circular dependency), so the only remedy is playground logout + playground login. There is NO on-chain query for SSS ring membership. When expired, the adapter logs NoAllowanceError to 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 no NoAllowanceError symbol — it has AllowanceError with 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) and loginStamp.ts + deploy preflight (warn-only when the recorded login is >2 days old; the stamp lives at ~/.polkadot-apps/dot-cli_LoginStamp.json so 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 returned destroy() when done. Forgetting it manifests as playground <cmd> hanging after the work visibly finishes.
  • requestResourceAllocation comes from @parity/product-sdk-terminal/host (the ./host subpath — 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 in src/utils/allowances/resources.ts. @parity/product-sdk-host's requestResourceAllocation is the in-container variant (browser globals required) and won't work from the CLI. Note the resource tag spelling on this path is still BulletInAllowance (capital I) — the BulletinAllowance rename in host-api 0.8 was only in the in-container protocol, not host-papp's SSO codec.
  • checkMapping must read Revive.OriginalAccount at { at: "best" } (src/utils/account/mapping.ts). PAPI's default at: "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 through getUnsafeApi() 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.0 on 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 — checkMapping returning false is the normal state for a plain Substrate signer, not a fault. Every AccountId32 already has a deterministic H160 with no storage lookup: an account whose last 12 bytes are all 0xEE uses its first 20 bytes, everything else is keccak256(accountId)[12..32] (deriveH160 in @parity/product-sdk-address, mirroring pallet-revive's AccountId32Mapper). env::caller() is that derived address. Revive.OriginalAccount is the REVERSE map (H160 → AccountId32), written by map_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 account 5DfhGyQd… has no OriginalAccount entry and zero PGAS, yet its keccak-derived 0x35Cdb23f… 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 E2E Invalid.Payment; see the task list).
  • Every RFC-0010 allowance call goes through productScopedAdapter (src/utils/allowances/resources.ts) — never hand the raw dot-cli adapter to the SDK's host allowance helpers. The SDK sends callingProductId: adapter.appId on the wire and the phone derives every per-product artifact from it; for SmartContractAllowance it MINTS PGAS ON-CHAIN to /product/<callingProductId>/<dest>. With the raw adapter id the 50-PGAS claim landed on dot-cli/0 (created + auto-mapped THAT account) while the CLI signs/deploys/checks as playground.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 (createSessionSignerForAccount takes an explicit productId); drop the wrapper only if product-sdk-terminal grows a callingProductId option.
  • Allowance grant markers live at ~/.polkadot/allowances.json (src/utils/allowances/marker.ts), mode 0600, sibling to accounts.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 skip playground login for slot resources — confirm the matching key exists too. Markers and keys are isolated per env. Keep source: "host" as the only value emitted from production code.
  • Bulletin IS requested through mobile resource allocation in playground login (since fb2b9e2, v0.28.0). PLAYGROUND_RESOURCES (src/utils/allowances/resources.ts) bundles BulletInAllowance + SmartContractAllowance into ONE requestResourceAllocation call 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/host now; the CLI side is getBulletinAllowanceSigner / getCachedBulletinAllowanceSigner / cachedBulletinSlotAuthorization in src/utils/allowances/bulletin.ts. Usability is existence + non-expiry only (isAuthorizationActive: status.authorized && status.expiration > currentBlock), NOT a quota gate, and we never request an Increase (see the Allowances invariant on soft limits). Per-resource outcomes are Allocated / 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. describeAllocationFailure in resources.ts splits them (re-approve vs update-the-app), and summarizeOutcomes buckets them. NotAvailable is the exact failure mode that got StatementStoreAllowance dropped from the request set (11d6a07); Bulletin can't be dropped because it is actually consumed. The pre-fb2b9e2 helpers (bulletinAuthorizationHelp, bulletinAuthorizationUrl, hasUsableBulletinSlotAuthorization, hasSlotAccountKey) and the locally-generated-slot-key approach are GONE; do not resurrect them.
  • playground login --yes auto-runs at the end of install.sh to skip the interactive QR-scan so non-interactive installers don't block. It installs prerequisites and prints "setup complete", then install.sh prints a hint to run playground login for the full mobile login. Dep-setup failures surface their exit code so CI runs don't silently pass.
  • A fresh QR playground login ROTATES the host device identity, and must keep doing so (src/utils/sessionReset.ts, called from auth.ts::connect() ONLY on the no-existing-session path). The mobile SSO channel is a statement-store topic createSessionId(sessionKey, phoneAccount, hostAccount). The phone derives its session account deterministically and reuses it across re-pairings, and hostAccount comes from the persisted dot-cli_DeviceIdentity.json (loadOrCreate() reuses it forever) — so the topic is CONSTANT for a given (phone, install). The phone posts a Disconnected request statement on that topic when it supersedes a session, and statements live 7 days (@novasamatech/statement-store's DEFAULT_EXPIRY_DURATION_SECS). On the next pairing, createSession.init() replays that unresponded Disconnected from the topic history and host-papp's session manager filters the just-paired session straight back out of SsoSessionsV3 (the per-session UserSecretsV2_* blobs are left orphaned). That is the "init succeeds but every later command fails with No signer available, and dot-cli_SsoSessionsV3.json is 0x00" bug. Rotating the device identity (deleting DeviceIdentity so the next adapter regenerates a fresh hostAccount) 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 ONLY DeviceIdentity + SsoSessionsV3 (they must rotate in lockstep — a session is bound to the identity that created it; the stale pre-0.8.7 SsoSessionsV2 blob is left orphaned, not rotated) and preserves AllowanceKeys (the Bulletin/SSS slot keys, which are not part of the topic and cost phone taps to re-request). Do NOT make init rotate unconditionally — gating on "no existing session" is what stops it from destroying a valid pairing on every run. Forensics: dot-cli_sso_processed_<sessionId>.json records ONLY processed Disconnected message-ids; UUID ids are phone-sent (the host only ever generates nanoid()), and the same id appearing across multiple session files proves the shared topic. Removing the login-time stale-session pruning loop from waitForLogin (it submitted its own Disconnected statements) was part of the same fix — don't re-add it.

CLI surface boundaries

  • src/utils/deploy/* and src/utils/build/* must not import React or Ink. They form the SDK surface RevX consumes from a WebContainer. TUI code lives in src/commands/*/.
  • playground mod runs signer-less. runModCommand does not call resolveSigner — it uses getReadOnlyRegistryContract(rawClient) (origin = pallet-revive's keyless pallet account, 5EYCAe5ij…, matching product-sdk's query fallback) for browse + metadata-uri lookup. The --suri flag is a deprecated no-op. Users browse + clone moddable apps without playground login / mapping their account. The signed getRegistryContract(rawClient, signer) is used only for registry.publish.tx(...) in src/utils/deploy/playground.ts. Don't drag a user signer back into playground mod.
  • playground init is a thin alias for playground mod playground-template. src/commands/init/index.ts just calls the exported runModCommand(TEMPLATE_DOMAIN) with TEMPLATE_DOMAIN = "playground-template" — no logic of its own — so it inherits mod's signer-less, GitHub-tarball-only behaviour automatically. Keep it a pure delegation: if init ever needs to diverge, change the constant, not the flow. It depends on a registry app published at domain playground-template; if that starter is renamed or unpublished, init breaks and the constant is the single place to update.
  • playground mod is GitHub-tarball-only and must stay that way. src/utils/mod/source.ts downloads from codeload.github.com (no auth, no git/gh for public repos) and extracts via node:zlib + the pure-JS tar package. Do NOT re-introduce git clone or gh 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); instead runModCommand lazy-probes the picked app once via assertPublicGitHubRepo() between picker dismount and SetupScreen mount.
  • playground never invokes gh. playground deploy --moddable reads an existing origin, validates it's a public GitHub URL via HEAD https://github.com/{o}/{r}, and records it in metadata. No auto-create path. Missing origin, private repos, and non-GitHub URLs all hard-fail with actionable messages from src/utils/deploy/moddable.ts::resolveRepositoryUrl(). We deliberately do NOT add an interactive gh auth login handoff — Ink owns stdout + raw-mode stdin and a stdio: "inherit" child would race useInput for keystrokes.
  • metadata.repository is set ONLY when --moddable is opted in. runDeploy takes an explicit repositoryUrl: string | null and publishToPlayground writes the field iff that param is non-null. Earlier code silently probed git remote get-url origin and surprised users — don't reintroduce that behaviour.

Runtime / memory

  • Bun compiled-binary stdin quirk — Ink's useInput silently drops every keystroke in bun build --compile binaries unless process.stdin.on('readable', …) is touched before Ink's render(). We install a no-op readable listener at the top of src/index.ts as 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 the bun build --compile invocations (package.json build/cli:install + the three release workflows). bun 1.3's bundler unconditionally emits the development JSX automatic-runtime (jsxDEV from react/jsx-dev-runtime) regardless of --production, --compile (which implies --production), --minify, tsconfig jsx: "react-jsx", or a real NODE_ENV=production env var — this is bun issue #23959, fix pending in bun PR #31651 (unreleased). Forcing NODE_ENV=production flips React's inlined jsx-dev-runtime onto its production branch, which never assigns jsxDEV → jsxDEV is undefined → every UI-rendering command crashes at first Ink render (--version/--help survive — 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, turning dot into a zombie. We defend in depth: (1) installSignalHandlers() catches SIGINT/TERM/HUP + unhandledRejection and forces cleanup + exit within 3 s. The rejection handler runs each rejection through isBenignUnsubscriptionError, which suppresses four known post-destroy artifacts (rxjs UnsubscriptionError("Not connected"), PAPI DisjointError from a chainHead unfollow race, PAPI's DestroyedError("Client destroyed"), and — since the host-papp 0.8 stack — a BARE Error whose message is exactly "Not connected", the raw-client teardown throw escaping as a floating rejection; contextual "Not connected: …" messages still escalate). Our SessionHandle.destroy() returns void (so React useEffect cleanups can call it) and fires adapter.destroy().catch(() => {}) — fire-and-forget with the rejection silenced at the source. The source-side .catch() is load-bearing because Bun's SEA binary prints unhandledRejection events regardless of any process listener — the catch is the only way to suppress it. (2) scheduleHardExit() installs an unref'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. Set DOT_MEMORY_TRACE=1 to stream per-sample RSS/heap/external stats.
  • Telemetry bootstrap (src/bootstrap.ts) is the FIRST import in src/index.ts. It sets PAD_USE_AMBIENT_SENTRY=1 and PAD_HOST_APP=playground-cli before polkadot-app-deploy evaluates, then maps DOT_TELEMETRY/internal-context detection to PAD_TELEMETRY. Don't leave PAD_TELEMETRY unset while setting the host app: polkadot-app-deploy treats playground-cli as 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). RunningStage coalesces "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 returns null. A catch-all info emit 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's watchdog option 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, hardExit timers, and src/index.ts's final process.exit() all stop firing — the process looks finished but lingers invisibly and grows unbounded. We shipped exactly that in June 2026: playground login ran watchdog-less, and three zombie playground processes reached 40+ GB each, swapping the laptop to death. Do NOT opt a command out to save the worker thread — it costs one 1 Hz memoryUsage() sample. The related session-probe rule: every createAdapter() 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.ts pins the contract).
  • QueryResult<T> from @parity/product-sdk-contracts@0.5+ is a discriminated union. Narrow on .success before reading .value. On the failure branch .value is the runtime's dispatch-error payload (unknown). On the success branch gasRequired is non-optional. We apply this in src/utils/contractManifest.ts::resolveLiveContractAddresses, src/commands/mod/AppBrowser.tsx, and src/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 a Result and 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 (tx 0.3), Contract.tx() + ContractManager.fromLiveClient (contracts 0.9), and checkAuthorization (cloud-storage 0.8). The dangerous one is submitAndWatch: pre-0.3 it threw on dispatch failure/timeout/rejection; now it returns err(TxError) and never rejects. Every CLI call site relied on the throw (deploy aborts, drip/fund error surfacing, the Invalid-Payment retry loop in playground.ts), so a naive bump SILENTLY swallows failures and tsc can't see it (the return was ignored). src/utils/tx.ts::unwrapResult re-throws the err channel and MUST wrap every submitAndWatch/batch call (playground.ts, account/{drip,funding,allowance}.ts) as well as ContractManager.fromLiveClient (registry.ts). Contract .tx() surfaces a REVERT on the err channel (result.ok === false), not as an inner TxResult.ok=false — playground.ts's publish check relies on that. isInvalidPaymentError (allowances/bulletin.ts) now inspects .message + .formatted + .dispatchError + the cause chain, since TxError/TxDispatchError no longer carry the marker in .message alone. registry.publishDev was folded into publish(…, is_dev_signer) (contracts 0.9 dropped the separate method).

Repo conventions

  • Every user-facing PR must include a changeset. Releases are automated via .github/workflows/release.yml, which is a no-op unless a .changeset/*.md file exists on merge. Create one with pnpm changeset or by hand (frontmatter: "playground-cli": patch|minor|major, body: user-visible summary). Pure refactors / test-only changes can skip it.
  • Tests are *.test.ts next to the source. vitest.config.ts only picks up .test.ts; if you add .tsx tests update the config too.
  • Pure logic inside a .tsx should be lifted into a sibling .ts file (completion.ts next to LoginScreen.tsx; identityLine.ts next to IdentityLines.tsx; formatPas/formatMb exports in AccountSetup.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 explicit destroy() / destroyConnection() — always release them, especially from React useEffect cleanups. The WebSocket keeps the event loop alive; forgetting a destroy manifests as playground <cmd> hanging after the work is visibly finished.

Sentry telemetry

  • DSN: src/telemetry-config.ts::PLAYGROUND_SENTRY_DSN. Region: EU (https://de.sentry.io). Attribute prefix: cli.. Spec: sentry-instrumentation-spec.md at 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.ts exports withCommandTelemetry, withRootSpan, withSpan (3-arg (op, name, fn) and 4-arg (op, name, attrs, fn) overloads), captureWarning, captureException, errorMessage, sanitizedErrorMessage. src/utils/deploy/phase.ts exports withDeployPhase. src/cli-runtime.ts exports runCliCommand — every command's .action() body should be one runCliCommand(name, options, async () => { ... }) call. The memory watchdog is ON by default for every command (see the Runtime / memory invariant below — do not opt out); hardExit defaults to true and is currently disabled only for login (its event loop drains naturally after destroyConnection() + 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 filter cli.tag:e2e-*).
  • Workflow: run ./sentry/backup-dashboards.sh BEFORE 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 a projects field in POST payloads.
  • E2E tagging: every spawn from e2e/cli/helpers/dot.ts injects DOT_TAG=e2e-local (fallback), DOT_TELEMETRY=1, and DEPLOY_TAG=e2e-cli-local (derived from DOT_TAG with an e2e-cli- prefix). tools/e2e-local.sh overrides DOT_TAG to e2e-local-{smoke|pr|nightly}; CI sets DOT_TAG=e2e-ci-{pr|nightly|dispatch}. The e2e-cli- prefix on DEPLOY_TAG distinguishes 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 confirms captureWarning flips cli.sad="true" on the root transaction. If it fails, the SAD% dashboard widget will silently degrade to a duplicate of the unexpected-failure rate.

E2E Tests

  • Local launcher: tools/e2e-local.sh [smoke|pr|nightly], also pnpm 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 run max-parallel: 1 to avoid stomping a shared deployer account.
  • Release smoke: .github/workflows/e2e-release.yml (on release: prereleased) and .github/workflows/e2e-post-release.yml (on release: published) run published.test.ts against the SEA asset and the install.sh consumer path respectively.
  • Test files: e2e/cli/*.test.ts. Reports: e2e-reports/junit.xml + e2e-reports/dot-runs.log (gitignored). CI report job is E2E Report — sticky PR comment marker <!-- e2e-pr-report -->.
  • Guides: docs/e2e-running-tests.md (running + reading), docs/e2e-bootstrap.md (maintainer setup), design spec at docs-internal/2026-05-02-e2e-test-suite-design.md.

Product context: playground.dot

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.