← docs

Decision log

Newest first. Each entry: what was decided, by whom, why, and what it invalidates.


D-002 — Target chain changes from Litecoin to Pepecoin (2026-09-10)

Decided by: Martin.

"Cancel the light coin install. Let's work on Pepe coin but not inscriptions like Doge's. Inscriptions like using Omni. I think there are better features with Omni like the check sum validator and built-in token exchange"

Action taken: Litecoin Core install cancelled and fully removed (binaries, datadir, staged tarballs). Nothing was left on disk; 416 GiB free, same as before we started.

What this invalidates: - Phase 2 as originally written (recovering Martin's 2021-2023 Omni inscriptions from Litecoin) is suspended, not impossible. See "Recovery is not lost" below. - Phase 3's plan to validate our consensus engine against a live OmniLite reference node.

What survives unchanged: Phase 1 Omni research. The Omni protocol sources (OmniLite, Omni Core, the spec) remain the ground truth regardless of which chain we deploy on.


D-001 — Proceed on the 5400 RPM HDD; blockfilterindex disabled (2026-09-10)

Superseded in part by D-002. Machine survey remains valid.


Feasibility: can Omni run on Pepecoin?

Verdict: yes. Omni is a meta-layer, not a chain feature — it is a payload encoding plus off-chain consensus rules, interpreted by an indexer. It needs no forked node, only a node whose transaction policy will relay the payload-carrying outputs.

All of the following are verified in source code, from github.com/pepecoinppc/pepecoin at tag v1.1.0 (commit 4fb5a0cd, 2024-12-16), cloned to /home/martin/reference/pepecoin.

Requirement Pepecoin v1.1.0 Source Verdict
Class COP_RETURN data output MAX_OP_RETURN_RELAY = 83 bytes (80 usable payload) src/script/standard.h:30 ✅ identical to BTC/LTC
Data carrier relayed by default DEFAULT_ACCEPT_DATACARRIER = true src/script/standard.h:16
Data size tunable -datacarrier, -datacarriersize src/init.cpp:483-484,1094-1095
Class B — bare multisig relayed DEFAULT_PERMIT_BAREMULTISIG = true src/validation.h:143
Bare multisig standardness x-of-3 accepted as standard src/policy/policy.cpp:41-48 ✅ exactly what Class B needs (1-of-3)
Dust limit (soft) DEFAULT_DUST_LIMIT = RECOMMENDED_MIN_TX_FEE = 0.01 PEP src/policy/policy.h:70, :23 ✅ cheap
Dust limit (hard, standardness) DEFAULT_HARD_DUST_LIMIT = DUST/10 = 0.001 PEP src/policy/policy.h:81 ✅ tunable via -harddustlimit
Recommended min fee RECOMMENDED_MIN_TX_FEE = COIN/100 = 0.01 PEP src/policy/policy.h:23
Coin precision COIN = 100000000 (8 decimals) src/amount.h:18 ✅ same as BTC/LTC

Other chain parameters (verified in source code, src/chainparams.cpp):

Parameter Value Line
Block spacing 60 seconds (Litecoin is 150 s, Bitcoin 600 s) :89
Subsidy halving interval 100,000 blocks :78
AuxPoW (merged mining) from height 42,000 :136
AuxPoW chain ID 0x003f (63) :119
Mainnet P2P port 33874 :153
Testnet P2P port 44874 :282
Regtest P2P port 18444 :392

Consequence of 1-minute blocks: inscription chunks confirm ~2.5x faster than on Litecoin and ~10x faster than Bitcoin. Good for multi-chunk file inscription, which is inherently serial across many transactions.


Corrections to two assumptions in the pivot

Martin cited "the check sum validator and built-in token exchange" as reasons to prefer Omni. Both features are real, but one does not do what the name suggests.

1. Omni's "consensus hash" validates ledger state, not file content

omni_getcurrentconsensushash hashes the entire Omni ledger (balances, properties, offers) at a given block, so two independent implementations can prove they agree. It is an implementation-agreement check. It is not a per-inscription content checksum, and it will not tell you whether a recovered JPEG is intact.

A content hash is still the right idea — it was already in the Phase 4 v2 spec ("a SHA-256 hash of the content"). That is ours to design; Omni does not supply it. Label: assumption pending Phase 1 verification against consensushash.cpp.

2. The "built-in exchange" trades token ↔ base currency, probably not token ↔ token

Omni's DEx (types 20 offer / 22 accept) sells a token for the chain's native coin — so on Pepecoin, token ↔ PEP. That works and transfers cleanly.

Omni Core on Bitcoin additionally has MetaDEx (types 25-28) for token ↔ token trading. The transaction-type list Martin compiled from OmniLite does not include 25-28, which suggests OmniLite lacks MetaDEx. If token-to-token trading matters, that capability may have to be ported from Omni Core rather than OmniLite. Label: assumption — must be confirmed in Phase 1 against src/omnicore/omnicore.h.


The significant new risk: no reference implementation

On Litecoin, OmniLite exists. Our engine could be proven correct by replaying real mainnet history and comparing omni_getcurrentconsensushash against a reference node at every block.

On Pepecoin there is no Omni, so there is no oracle. We would be the first and only implementation, defining truth with no independent way to catch our own consensus bugs. Also: on day one the DEx has no counterparties, and we must define our own genesis parameters (activation height, Exodus-equivalent address, ecosystem IDs).

Mitigation — and it removes the need for any 217 GiB sync: OmniLite ships unit tests (src/omnicore/test/) and functional tests (test/functional/omni_*.py) that run on regtest. Those are executable, authoritative test vectors for the Omni rules, and they need no mainnet chain at all. Building our engine against that suite gives real correctness validation on this modest hardware. This was already the Phase 3 plan; it now carries more weight because it is our only oracle.

Recovery is not lost

Martin's 2021-2023 files are still in Litecoin blocks and are not going anywhere. Omni's encoding (Class B / Class C, the payload layouts) is chain-independent, so if the extractor is written chain-agnostically from the start — chain params, genesis height and Exodus address as configuration, not constants — recovering them later costs a Litecoin sync and a config file, not a rewrite.

Design requirement adopted: every component we build takes chain parameters as config. No hard-coded Pepecoin assumptions.


D-003 — App scope decisions (2026-09-10)

Decided by Martin:

Question Decision
First feature after the M0-M2 foundation M3 MetaDEx exchange
Protocol marker (Class C, replaces Omni's omni) pepo
Crowdsales, types 51/53 In scope

Marker note: pepo is consensus-critical. It can change freely before launch; after the first mainnet inscription it cannot change without splitting state.


⚠️ MetaDEx constraints that affect the order-book design

Verified in source code, omnicore/src/omnicore/tx.cpp:1643-1666 (logicMath_MetaDExTrade):

Rule Code Consequence
Both sides are property IDs (u32) payload layout, createpayload.cpp:486 The native coin is not a property. MetaDEx cannot quote PEP.
Non-fungible properties rejected isPropertyNonFungible(...)PKT_ERROR_TOKENS -27, :1653 NFTs cannot trade on MetaDEx at all
property != desired_property :1658PKT_ERROR_METADEX -29 no self-pairs
Both sides same ecosystem isTestEcosystemProperty(...), :1666 main and test books never mix
Type must be permitted for that property at that block IsTransactionTypeAllowed(...), :1643 activation rules are per-property

What this means in practice

A TOKEN/PEP market with real bids and asks is not possible on MetaDEx. Token ↔ native coin is DEx only (types 20/22), which is one-sided sell offers — an offers list, not a book.

This is not an oversight in Omni; it is how Omni is structured. Omni's answer was a base property: property 1 (OMNI) is a token, so every pair TOKEN/OMNI is a genuine two-sided MetaDEx book, while OMNI/BTC is handled by the one-sided DEx.

For us the same shape applies: we need a base property — call it property 1 — against which all MetaDEx pairs quote. Which raises the question Omni answered in 2013 with the Exodus crowdsale: how does property 1 come into existence and get distributed?

Options: 1. Crowdsale (type 51). Send PEP to the issuer during a window, receive base tokens. This is literally what Omni did, and crowdsales are now in scope (D-003). 2. Burn-to-mint. Send PEP to the Exodus-equivalent address, receive base tokens at a fixed ratio. Simple, permissionless, ongoing, no issuer trust. 3. Fixed issuance (type 50) to a treasury address, distributed manually. Simplest to build, most centralised, weakest story. 4. No base property. Accept that only DEx (one-sided, token↔PEP) exists. Much weaker than what Martin asked for.

Second open problem: NFTs cannot trade on MetaDEx (:1653). Selling an NFT therefore needs either the DEx path or a mechanism we design. Flagged for M4.


D-004 — Protocol shape (2026-09-10)

Decided by Martin:

Question Decision Consequence
Base property (property 1) origin Burn-to-mint Send PEP to the Exodus-equivalent address, receive base tokens at a fixed ratio, permanently open. No issuer trust, no deadline, no premine. Gives the base token a hard PEP cost floor.
NFT sales Extend DEx (20/22) to accept non-fungible ranges Deliberate, documented divergence from Omni Core, which rejects NFTs on MetaDEx (tx.cpp:1653) and has no NFT DEx path. Must be spec'd and tested as our own rule.
Activation height Set at launch, ~tip + 1 day Left unset in config until M7. Indexer backlog stays at zero through development.
Ecosystems Keep both (main + test) Mirrors Omni ID ranges; gives an on-mainnet sandbox that never mixes with the main book.

Consequences that now need specifying

  1. Burn-to-mint is a new transaction path with no Omni equivalent. Omni's Exodus output is a marker for Class B, not a value sink. We are giving it a second meaning: PEP sent to it mints base tokens. These two roles must not collide — a Class B marker output must not accidentally mint. Needs an explicit, tested rule (e.g. minting only on an output that is the sole payment to Exodus in a transaction carrying no Omni payload, or a dedicated mint transaction type).
  2. NFT-on-DEx is our own consensus rule. No reference implementation exists for it, so it carries the same "no oracle" risk as everything else, and needs its own test vectors.

D-005 — Name, economics, signing, scope (2026-09-10)

Decided by Martin:

Question Decision
Project name Ribbit
Class C marker rbit (0x72 0x62 0x69 0x74) — supersedes pepo from D-003
Base token 1 PEP = 1 RBIT, divisible (8 dp), uncapped, minted by burn-to-mint only. No premine.
Signing Node wallet RPC — app builds payload, pepecoind funds and signs via fundrawtransaction / signrawtransaction. Keys never enter our process.
Litecoin recovery (old Phase 2) Dropped.

Effect of dropping Litecoin recovery

This is a real simplification, not just a deletion:

Burn-to-mint, settled parameters


D-006 — Openness, access, feature scope (2026-09-10)

Question Decision
Spec and code Public from the start
Web UI binding 127.0.0.1 only — reach remotely via SSH port-forward
Base token ticker RBIT
Delegates (73/74) In scope
Freezing (71/72/185/186) In scope
Send-to-owners (3) In scope
Restricted send (2) Out of scope

Why public matters more than it looks: a second independent implementation is the only genuine oracle for consensus correctness, and that can only exist if the spec is public. This partially answers the "no reference implementation" risk from D-002 — not immediately, but it is the only path that ever resolves it.

Why localhost-only matters: the app can trigger signing through the node wallet (D-005), so exposing the UI exposes spending authority. Localhost is the correct default and costs nothing, since SSH port-forwarding covers remote use.


D-007 — No base token; burn-to-mint removed (2026-09-10)

Decided by Martin: no base token at all. Supersedes D-004 and D-005 on this point.

What prompted it

Martin asked: "If the Pepe is burned when the rbit is created, how would somebody convert their RBIT back to Pepe?"

They cannot. Ribbit is a sidecar meta-layer with no consensus power over PEP — only a private-key holder can move PEP. Burning means sending to an address with no key, so the protocol can never release it. This is structural. No design gives trustless redemption of the base coin; any redemption requires a custodian holding real PEP. (Omni itself was custodial here: its Exodus address held real BTC controlled by the foundation, not burned.)

Correction to an earlier claim of mine

D-005 recorded that burn-to-mint gives RBIT "a hard floor of 1 burned PEP." That was wrong, and backwards. Under uncapped 1:1 minting:

A quote asset is precisely what traders hold between trades, so this would have taxed every user of the order book. Dropping it is the right call.

What this removes

Removed Effect
RBIT the token, property 1 no base/quote asset ships with the protocol
Burn-to-mint the Exodus address reverts to being purely a Class B marker, as in Omni
Exodus dual-meaning rule the "marker vs value sink" collision problem (D-004) disappears entirely
One of two unreferenced divergences only NFT-on-DEx remains as novel consensus code

Net: meaningfully less consensus surface, and less of it unvalidated. "Ribbit" and the rbit marker are unaffected — they name the protocol, not a token.

What the exchange looks like now

Pair Mechanism Book shape
token ↔ token MetaDEx (25-28) genuine two-sided book, bids and asks
token ↔ PEP DEx (20/22), payment window one-sided sell offers
NFT ↔ PEP DEx, extended for ranges (D-004) one-sided listings

No canonical quote asset ships. If the market converges on one — a wrapped-PEP or a stablecoin issued by a participant — it becomes the de-facto quote asset without us blessing it. That is a defensible position: we provide the venue, not the money.

Implementation notes

  1. Property IDs 1 and 2 are special-cased in Omni as OMNI_PROPERTY_MSC / OMNI_PROPERTY_TMSC (verified in source, omnicore.h:123-124). With no base token these are free. Decision: leave 1 and 2 permanently reserved and unassigned, user properties start at 3. Costs nothing and keeps a slot if a base token is ever revisited.
  2. Ported Omni test vectors assume OMNI/TOMNI exist. Any vector that funds an account with property 1 or 2 needs adaptation. Expect friction in M2-M3 test porting; budget for it.

D-008 — Crowdsales dropped (2026-09-10)

Decided by Martin: remove types 51 (create property variable / crowdsale) and 53 (close crowdsale) from scope. Supersedes D-003.

Rationale: a crowdsale's payload carries propertyIdDesired (verified in source, createpayload.cpp:228-239) — it can only be funded with another property, never the native coin. With no base token (D-007), there is nothing to fund the first crowdsale with. Keeping them would have meant either shipping a feature that cannot be used, or adding a second novel consensus rule (native-coin crowdsale funding) immediately after deleting one.

Removed: bonus math, deadlines, early-close rules, issuer-percentage handling, and the crowdsale-participation code path in state processing. A meaningful slice of M6.

Consensus-hash note: GetConsensusHash() has a crowdsale section (verified in source, consensushash.cpp:232-234). We keep the section in our implementation but it will always be empty, so it contributes nothing. Documented so that re-adding crowdsales later does not change the hash of existing state.

Reversible? Yes, cleanly. Crowdsales touch property creation and balances but nothing else depends on them.


D-009 — Encrypted messaging: integration and carrier (2026-09-11)

Decided by Martin.

Question Decision
Where it lives Inside Ribbit — a Ribbit message type sharing the payload codec, Class B/C encoding, indexer, reorg handling and database
Primary carrier Class B (7,646 bytes/tx), Class C for messages fitting in 72 bytes
Messages > 7,646 bytes Chained Class B with a countdown index, using the Doginals UTXO-chaining mechanism

Correction: the 25-transaction limit is not a ceiling

I had cited Pepecoin's 25-ancestor mempool limit as an argument against OP_RETURN chunking. Martin pointed out this is wrong, and he is right: the limits are on in-mempool ancestors (init.cpp:437, :439), and removeForBlock (txmempool.h:554) drops mined transactions from the mempool, so a confirmation resets the count. Send 25, wait one block (~60 s), send 25 more. It is a throughput constraint, not a ceiling.

The real arguments for Class B are atomicity and cost:

A size limit that binds before the count does

Verified in source, validation.h:76,80: DEFAULT_ANCESTOR_SIZE_LIMIT = DEFAULT_DESCENDANT_SIZE_LIMIT = 101 kB.

An unconfirmed chain is capped at ~101 kB regardless of transaction count. Irrelevant on the OP_RETURN path (25 × ~250 B ≈ 6 kB, so the count binds first), but decisive for chained Class B: at ~14.7 kB per full transaction only ~6 can chain unconfirmed, not 25. Martin's batch-and-wait strategy applies exactly here.


What Doginals does, and what we take from it

Read from Martin's own github.com/martinseeger2002/doginals, doginals.js:434-560.

MAX_CHUNK_LEN   = 240    // bytes per data push
MAX_PAYLOAD_LEN = 1500   // bytes per transaction
  1. Data is split into 240-byte chunks. The inscription script is 'ord', chunk count, content type, then (index, chunk) pairs where the index counts down: parts.length - n - 1, so the final chunk carries index 0.
  2. Chunks are packed into ~1500-byte partials, one per transaction.
  3. Each transaction creates a P2SH output committing to its partial. The redeem script is only <pubkey> OP_CHECKSIGVERIFY <OP_DROP × n> OP_TRUE.
  4. The next transaction spends it, and the data rides in that transaction's scriptSig as plain pushes, which the OP_DROPs discard.

Why 1500 is not arbitrary

Verified in source, pepecoin/src/policy/policy.cpp:78-93:

scriptSig.size() > 1650   ->  "scriptsig-size", non-standard
!scriptSig.IsPushOnly()   ->  "scriptsig-not-pushonly"

plus MAX_SCRIPT_ELEMENT_SIZE = 520 (script.h:22). So ~1500 bytes of data, a ~72-byte signature and a ~40-byte redeem script land just under 1650. The constant is tuned precisely to that ceiling. The technique is mainnet-standard on Pepecoin.

The two properties worth stealing

Carrier comparison

Carrier Bytes/tx Atomic Ordering Mainnet-standard
Class C (OP_RETURN) 72 needs index
Class B (multisig) 7,646 ✅ ≤7,646 n/a, single tx
Doginals P2SH ~1,500 UTXO chain

Decision: borrow the mechanism, not the carrier. Chained Class B with a countdown index gives Doginals' structural ordering and self-describing completion while carrying 5× more data per transaction.

Resulting shape

Message size Carrier Chunking machinery
≤ 72 bytes Class C, one transaction none
≤ 7,646 bytes Class B, one transaction none
larger chained Class B, countdown index chunk header only on this path

D-010 — The Messenger is testnet-only, permanently (2026-09-11)

Decided by Martin: "The Messenger part of the application should always use testnet."

This is a permanent product rule, not a testing phase. Encrypted messaging never operates on mainnet.

What this changes

Area Effect
Network selection The messaging code path refuses to run against mainnet, enforced in code rather than by configuration. A mainnet params object passed to a messaging function is an error, not an option.
Mainnet standardness Still designed for. Testnet does not enforce standardness (fRequireStandard = false, chainparams.cpp:309), so a transaction that only works there would be silently non-standard. Keeping to mainnet rules means the format stays honest and portable.
Threat model Testnet chains can be reset or deep-reorged. Messages are not durable. This must be stated plainly to users — it is a property of the medium, not a bug.
Coins Testnet PEP has no value, so fee estimates are informational only.
Node Requires a second node alongside the mainnet Ribbit node.

Why designing to mainnet limits still matters

Testnet accepts non-standard transactions. Without the discipline of mainnet limits we would not discover that a format is unrelayable until someone tried it somewhere real. The regtest standardness check (-acceptnonstdtxn=0) in Step 4 is what actually proves it.


D-011 — Renamed to DogecoinArcade; multi-chain (2026-09-11)

Decided by Martin: "Let's change the name of the application to DogecoinArcade. I know it's for Pepe, but it should also work on Doge in the exact same way."

Item Old New
Project Ribbit DogecoinArcade
Class C marker rbit arcd (0x61 0x72 0x63 0x64)
Python package ribbit arcade
Distribution name ribbit dogecoin-arcade
Repo path ~/ribbit ~/dogecoin-arcade

Entries D-001 through D-010 above still say "Ribbit". They are left as written: a decision log that gets retroactively edited is not a record. Read "Ribbit" as "DogecoinArcade" in anything dated before 2026-09-11.

The marker change was free because nothing has launched. After the first mainnet transaction it would be impossible — a marker change splits state.

Relationship to the existing GitHub repo

github.com/martinseeger2002/DogecoinArcade already exists and already contains encrypted messaging (SendSms.py, DecryptSmsData.py, encrypt_data.py, callGetPubKey.py). Martin's decision: keep them entirely distinct. This is a new project that shares a name; nothing is merged and nothing is pushed.


Dogecoin support: verified, and nearly free

Verified against github.com/dogecoin/dogecoin at 1.14.99, cloned to /home/martin/reference/dogecoin.

Every constant this protocol depends on is identical between Dogecoin and Pepecoin — same values, and the same source line numbers, because Pepecoin is a Dogecoin fork:

Constant Value Source (identical in both)
MAX_OP_RETURN_RELAY 83 script/standard.h:30
DEFAULT_ACCEPT_DATACARRIER true script/standard.h:16
DEFAULT_PERMIT_BAREMULTISIG true validation.h:143
x-of-3 bare multisig standard yes policy/policy.cpp:41
COIN 100000000 amount.h:18
RECOMMENDED_MIN_TX_FEE COIN/100 policy/policy.h:23
DEFAULT_DUST_LIMIT = min fee policy/policy.h:70
DEFAULT_HARD_DUST_LIMIT DUST/10 policy/policy.h:81
scriptSig standardness limit 1650 policy/policy.cpp:86
DEFAULT_ANCESTOR_LIMIT / size 25 / 101 kB validation.h:74,76
Block spacing 60 s chainparams.cpp

Only chain identity differs:

Dogecoin Pepecoin
mainnet P2PKH version 30 (D…) 56 (P…)
mainnet P2SH version 22 22
mainnet P2P / RPC 22556 / 22555 33874 / 33873
testnet P2P / RPC 44556 / 44555 44874 / 44873

Because Params already holds exactly these fields, Dogecoin support cost three new Params instances and zero code changes.

⚠️ Testnet addresses are ambiguous between the two chains

Dogecoin testnet and Pepecoin testnet both use PUBKEY_ADDRESS = 113 and SCRIPT_ADDRESS = 196. A testnet address cannot tell you which chain it is for. Never infer the chain from an address; record it explicitly alongside any key announcement or message. Noted in arcade/config.py beside the params.

Messaging guard

require_messaging_network() enforces D-010 in code: messaging functions refuse any mainnet params object. MESSAGING_NETWORKS is {test, regtest, doge-test, doge-regtest}.


Martin's existing encryption, compared to the new brief

Read from github.com/martinseeger2002/DogecoinArcade, encrypt_data.py.

Existing scheme: secp256k1 ECDH → HKDF-SHA256 → AES-256-GCM key-wrap → AES-256-GCM data.

Existing Brief requires
Curve secp256k1, using wallet keys X25519, separate from wallet
Construction ECDH + HKDF + AES-GCM keywrap + AES-GCM libsodium sealed box
Envelope overhead 93 bytes (33 pubkey + 12 IV + 32 wrapped key + 16 tag) 48 bytes (32 ephemeral pubkey + 16 MAC)

The existing construction is cryptographically sound — ECDH+HKDF+AES-GCM is a legitimate KEM/DEM. Two reasons the new design still differs:

  1. 45 bytes of pure overhead. A sealed box derives the data key directly from the ECDH output instead of wrapping a separate AES key. On an 80-byte OP_RETURN that difference decides whether a short message fits at all.
  2. Wallet keys are the wrong keys. Encrypting to a spending key welds messaging identity to spending authority: the key is exposed by every signature, cannot be rotated without moving funds, and its compromise reads every past message. Separate X25519 identity keys fix all three.

D-012 — The application is permanently dual-chain (2026-09-11)

Decided by Martin: "the page should have both testnet and mainnet on it forever because we will be using testnet for messages and main for tokens and nfts and also pep transactions."

Chain Role Sections
Testnet Messenger Inbox, Compose, Keys
Mainnet Ledger Tokens, NFTs, Exchange, PEP transactions, Explorer

This is a permanent split, not a migration path. It supersedes the implicit assumption in the web UI that one network was selected at launch.

Why the split makes sense

Messages are not durable: testnet chains get reset and deep-reorged (D-010), and messages are conversational rather than assets. Tokens, NFTs and PEP balances are assets and belong on a chain nobody resets. Each side gets the chain whose properties match what it holds.

It also means a bug in the messaging code can never touch real value, which is a stronger guarantee than any amount of care in the messaging code itself.

The immediate constraint

The mainnet node runs with disablewallet=1 (docs/00-node-setup.md), chosen in Phase 0 because nothing then needed to sign. So mainnet is currently read-only: it can serve blocks, indexed token state and NFT state, but cannot send PEP or create a token.

Enabling a mainnet wallet is a deliberate step Martin has to take, because that wallet holds real money. Until then the UI must show mainnet sections as read-only rather than failing obscurely at signing time.

Guards that must hold

  1. require_messaging_network() continues to refuse mainnet for anything in arcade.messaging. The dual-chain UI makes it easier to point a messaging call at the wrong node, not harder, so this matters more now, not less.
  2. verify_connected_chain() runs on every connection, so a misconfigured datadir cannot silently cross the streams.
  3. Every section states which chain it is acting on, in the interface itself. A user who cannot tell whether they are about to spend testnet or real coins is one misclick from a bad day.

D-013 — The wallet is the identity; there is no passphrase (2026-09-11)

Decision. The messaging identity is derived from a wallet address. There is no passphrase, no key file, and nothing for a user to write down. wallet.dat is the only thing they have to keep.

What this replaces

D-009 gave the messaging key its own passphrase, encrypted at rest with Argon2id. That was cryptographically sound and a poor product. The failure mode was absolute: forget the passphrase and every message ever sent to that identity is unreadable, by anyone, forever. Two attempts were made to soften it — generating the passphrase rather than letting the user invent one, then saving it on the machine so it could be looked up later — and the second undoes most of the point of the first. Storing a secret next to the thing it protects is not protection; it is a passphrase-shaped ritual.

Martin's instruction was direct: "I don't want a pass phrase to be saved or shown. The only thing that the user should need is their wallet.dat files."

How it works

signature = wallet.signmessage(address, "DogecoinArcade messaging identity v1")
seed      = HKDF-SHA256(signature)
identity  = X25519 keypair from that seed

signmessage is deterministic — RFC 6979 — so the same address always produces the same signature and therefore the same identity. Verified on Pepecoin: three calls, byte-identical.

The address is filed in the wallet under the account arcade-identity. Accounts live inside wallet.dat, which is what makes a restore self-sufficient: the application finds the address again without needing its own database.

The subtle part, which was a bug first

getaccountaddress returns a fresh address as soon as the current one has been used. Calling it on each startup would therefore have changed the identity silently, orphaning every message already received — the exact failure the passphrase was removed to avoid. Caught in testing, before it shipped.

The rule is now: take the account's existing addresses, sort them, use the first. getaccountaddress is reached only when the account is genuinely empty. Three independent installations against one wallet derive the same identity, and the CLI and the web interface agree with each other.

What it costs, stated plainly

Whoever can use the wallet can re-derive the identity. The wallet's security becomes the messaging security; if the wallet is unencrypted, so is the identity in practice. Against a standalone key with its own passphrase this is a real reduction, and it is the trade being made deliberately: the passphrase's failure mode was certain and common, this one requires an attacker to already have the wallet — at which point they have the coins too.

A spending key is still never used for encryption. Deriving a separate X25519 key keeps encryption away from spending, so a compromise of one is not automatically a compromise of the other, and the messaging key can be rotated by moving to a different address without moving funds.

Consequences


D-014 — Public group posts may run on mainnet (2026-09-11)

Decision. A public, unencrypted group channel runs on both chains. D-010 is unchanged: nothing encrypted ever touches mainnet.

Why this is not a reversal of D-010

D-010 says the Messenger is testnet-only, permanently, and the reasons were specific: messaging code is young, a bug in it must never be able to touch real value, and an encrypted message carries key material that would tie a mainnet identity to a correspondence. None of that applies to a public post.

A group post is not sealed to anybody. It carries no key material. It reveals nothing that publishing it does not already reveal, because publishing it is the act. So the question "may this run on mainnet?" stops being a safety question and becomes a cost question, which is the user's to answer. Martin answered it.

How the boundary is kept

MessageSender still calls require_messaging_network() on construction. The only way past it is public_only=True, which is:

Carriage, and why a post is short

Class C: one OP_RETURN output. One output means no dust, so a post costs a fee and nothing else. That caps a post at about 60 characters.

That is a real limit and the right trade. Class B would carry 7.5 KB, but it pays roughly one unspendable output per 30 bytes — on testnet that is free coins, and on mainnet it is somebody's money burnt to say something in public. A 60-character public post costs about 0.0028 in fees; the same text carried Class B would cost that plus several coins of dust forever.

What the interface must say

The rest of the application trains the expectation that messages are private, so the public half has to contradict that expectation loudly rather than quietly:

  1. Every screen where a post can be written states that nothing there is encrypted, that it is permanent, and that it cannot be deleted.
  2. The two chains are chosen explicitly, labelled free coins, nothing at stake and real coins, permanent. There is no single control that silently spends money.
  3. A mainnet post shows the post's text back to the user inside the confirmation, with the fee, before anything is broadcast — the ground rule for mainnet transactions, applied here.

Channels have no membership

A channel is a name. Anyone using the same name is in the same room; there is no invitation, no member list, no way to remove anyone, and no moderation. Saying so plainly is the whole of the access-control design, and pretending otherwise would be worse than the limitation.


D-015 — One starting block height per version (2026-09-11)

Decision. Every installation reads messages from the same block, declared in code per network and changed only by a release.

The problem it fixes

Each machine began scanning at whatever height its own identity happened to be created. Two people running the same version therefore saw different histories, and neither could tell why: one would see a public post or a key announcement the other had simply never scanned. Nothing was broken, and nothing said anything — the machines were reading different parts of the same chain.

That is a poor property for a shared medium. A public channel in particular is supposed to be the same room for everyone in it.

How it works

Params.messaging_start_height is a floor, applied first:

floor = max(messaging_start_height, activation_height)
start = max(recorded identity height, floor)

A local record can push the start later, never earlier. Later is legitimate — nothing written before a key existed can be addressed to it, so reading it is wasted work. Earlier is not: it would let one machine read history the protocol says is not there.

Testnet is set to 1,483,184, the tip on 2026-09-11. Everything before it is development traffic from building the application, and is deliberately unread.

What changing it means

Raising it declares a clean slate for everyone at once. Lowering it asks every installation to rescan, which is cheap only if it is not lowered far. Either is a release decision and belongs in a commit, not in a setting — the value has to be the same everywhere or it is not doing its job.

Clearing history locally ("Start fresh") resets to this height rather than to the machine's current block, so clearing brings an installation back into step with everyone else instead of pinning it wherever it happened to be.

What it does not do

It is not consensus. Nothing validates that a peer used the same value, and a modified client can read from anywhere it likes — the chain is public. It removes an accidental divergence between honest installations, which is what it was costing.


D-016 — Tokens go live: the start block, and tokens on testnet for everyone (2026-09-12)

Decision. The mainnet token index starts at block 1,208,000 (Pepecoin). Until the first token exists on mainnet the number may be moved by a release; after that it is fixed for ever. Tokens are also indexed on testnet, from 1,484,661, on every installation, and the Tokens page switches between the two chains by clicking the chain tag beside its heading.

What "activation height" now means

D-004 left it "set at launch, ~tip + 1 day", and M2 through M5 ran with mainnet set to None, which disables the index outright. The tokens interface is the launch: it creates, sends, grants and revokes on whichever chain is the ledger, and an index that never starts cannot show any of it. The value was chosen on 2026-09-12 at tip 1,206,546 — about a day ahead, as D-004 asked.

It is still free to move. Nothing before the first mainnet token transaction depends on where reading began, so a later release may raise or lower it without changing what anyone holds. From the first token on, moving it would change history: raising it would un-create that token, lowering it could create another. So the rule is the one from D-015, sharpened: changeable until it matters, then never.

Dogecoin mainnet stays at None. Nothing has been exercised there.

Tokens on testnet, for everyone

D-012 put the ledger on mainnet permanently. That stands: mainnet is where a token is worth anything. It does not mean testnet tokens are useless — they are how anyone tries the thing for nothing, and how the two machines check each other before real coin is involved. The alternative was trusting regtest alone: one node, no reorgs anyone did not stage, no second machine.

So both chains are indexed, always, whether or not anyone is looking, and the Tokens page shows one at a time. Martin asked for exactly this shape: the red "main" tag is the switch. Clicking it goes to testnet, clicking the green "test" tag comes back, and the choice is kept in a file (~/.dogecoinarcade/tokens-chain) so an update does not put someone back on mainnet unasked. A token's own page carries its chain's tag but does not switch — the token belongs to that chain. The earlier --ledger-network test option, which made testnet the ledger for the whole process, is gone; one way to do a thing.

Two consequences are worth writing down:

What is broadcast is what was shown

Every token action is two submissions: the first builds, funds and signs a transaction and shows it decoded — sender, recipient, each output labelled, fee, total, txid; the second broadcasts. The second sends the same bytes. The prepared transaction is kept in the process under its txid and the confirm form hands that txid back; if it is no longer there (the server restarted), the action is rebuilt and shown again rather than sent. The wallet's plain coin send rebuilds on confirmation, which is harmless for a coin send with the same destination and amount but would let a token transaction go out with a txid and fee the user never saw.

Sender and recipient are checked by the engine's own rules

A token transaction the wallet funded is read back through determine_sender and determine_reference — the functions the indexer will run on it — and refused if they do not resolve to the intended addresses. Not a restatement of the rules in the builder: the same code. A send from the wrong address is a send of somebody else's tokens, invalid and paid for, and this is the check that stops it before the fee is spent.

Where it stops

An unknown or out-of-scope message type stops the index at that block (M2 hard rule 2, unchanged) and the tokens page says so, with the block and the type. Balances shown from then on are as of the block before. The index retries only when a new block arrives, since the block it stopped on will not read differently five seconds later.

D-017 — A bot RPC in Omni Core's vocabulary (2026-09-12)

Decision. arcade-web serves JSON-RPC at /rpc/test and /rpc/main with Omni Core's method names (omni_getbalance, omni_getallbalancesforid, omni_send, …), authenticated by a cookie file exactly as bitcoind is. Writing methods prepare and return the transaction; omni_broadcast sends it. Full reference: bot-rpc.md.

Why Omni's names

The engine is Omni's rules on Pepecoin; the people most likely to write a bot against it are the people who have written one against Omni Core. Giving them the names and field shapes they know (propertyid, balance/reserved/ frozen, amounts as strings) makes the port a URL change. Where the index holds no such thing — reserved, frozen — the field is present and zero rather than absent, so a client that reads it does not break. Where the arcade has something Omni does not, it is added (omni_getinfo reports the index's own height, what it is behind, and whether it stopped) rather than squeezed into an Omni field with a different meaning.

The standing rule is cookie or rpcauth for the node, never a plaintext password in a script; the same reasoning holds one layer up. The server writes ~/.dogecoinarcade/rpc.cookie (mode 0600, fresh each start) and a script sends it as HTTP basic auth. There is nothing to configure and nothing to paste. The forms' CSRF token is the wrong key for this: it is embedded in every page, so any script that can read a page has it, and a browser can be made to send it. The cookie lives only in a file the user owns, which a web page cannot read. Loopback binding stays what it was.

Why sending is two calls

Omni's omni_send broadcasts. Here it prepares, and omni_broadcast sends the bytes that were prepared — the same rule as the confirm screen (D-016). The alternative, one call that spends, was rejected because a bot that cannot see the fee before paying it cannot budget an airdrop, and because the transaction the server broadcasts should always be one somebody (or something) was shown. The cost is one extra call per send, which the example bot pays in one line.

One URL per chain

Omni Core serves the chain its node is on. The arcade indexes both, and the Tokens page has a switch. A bot must not follow that switch — a script that starts sending on mainnet because someone clicked a tag in a browser is a disaster — so the chain is in the URL and nowhere else. /rpc without a chain is a 404 that says so. The shell tool arcade-rpc defaults to testnet and needs -main said out loud.

What it does not do

No send-to-owners (Omni type 3). The engine does not implement it, and a transaction of that type stops the index (hard rule 2). Airdrops are one send per holder, which the example bot does with a done-file so a stopped run resumes without double paying. No rate limiting and no per-method permissions: the cookie is the operator's own authority, the same as the shell that can run pepecoin-cli sendtoaddress.

D-018 — A door you can see, with a key and a deadline (2026-09-12)

Reaching the wallet from a phone meant an SSH port-forward, which is fine for people who already know how and a wall for everyone else. The Remote page opens a Cloudflare quick tunnel and shows a QR code.

The temptation is to call the random hostname the security. It is not: a URL is read over a shoulder, lives in a phone's history, and passes through Cloudflare's edge. So the tunnel is a route, and three separate things are the protection — a key that only the QR code carries, a deadline the user cannot forget to enforce, and a flat refusal to serve the bot RPC through it.

Told apart by headers, not by address: cloudflared runs on the machine and connects to 127.0.0.1, so a phone in another country arrives from localhost. Verified against a live tunnel rather than assumed, and the guard deliberately does not treat an unfamiliar Host as remote, so someone reaching their own wallet over their own LAN is not locked out by a feature they never enabled.

Registered as middleware, not per route. A door guarded route by route is a door that is open the first time somebody adds a route and forgets.

cloudflared is pinned by version and by hash. Cloudflare publishes no checksum file beside the binaries, so the hashes come from GitHub's asset digests, read once and written into the installer: it checks against something it carried with it, not against a number fetched from the same place as the file.

D-019 — Collections are read from the chain, and a run is a file on disk (2026-09-14)

A HashLips build is a few thousand pictures with a JSON file each. Inscribing one by hand is a few thousand trips through the single-file form, so there is a wizard: point it at the build folder, see every item priced, confirm once.

Two decisions underneath it.

The collection is what the JSON says, not what the wizard was told. The wizard could have written "these belong together" somewhere — a manifest inscription, a field of its own. It does not. The index files an inscription into a set from its inscribed JSON alone: collection if there is one, otherwise a name ending in #<number>, which is the shape HashLips already writes. Every node running the indexer files the same sets from the same bytes, nothing has to be inscribed in addition to the items, and a set cannot be renamed or hijacked afterwards because there is nothing to edit. A set is keyed by its creator as well as its name, so two people using the same name are two sets. The ledger schema is version-locked, so the filing lives in a new table of its own, filled in for an existing index the first time it is opened.

A run is a job on disk, written one transaction at a time. Every txid the node accepts is recorded before the next piece is built. Pause is a flag read between pieces; resume starts at the next piece with no txid; a crash is a running job found at startup, which restarts itself. The one gap — the node took a transaction and the machine died before the record was written — is closed by asking the chain index which pieces of that inscription id it has already seen, so the worst case of a power cut is nothing, not a duplicate paid for twice. The wallet is split in batches sized by the largest item coming up, so a long run neither waits a block per piece nor makes ten thousand outputs at once, and the send lock is held per item so messages still go out during a run.

D-020 — A caller may ask; only the person may send (2026-09-14)

An inscribed page and a bot on the RPC both have reasons to want this wallet to move something — a marketplace, a game, a prize. Neither can be allowed to do it. A page is code a stranger inscribed, running in a frame of a wallet that can spend; a bot with the cookie can already spend, but a bot that a stranger's code can drive should not be handed that.

So there is one more verb between "read" and "send": ask. POST /r/send and da_request* file a request — kind, recipient, amount, and the caller's own words about itself and why. The request is a row on disk, not a transaction: nothing is built until the owner looks at it, and what is built then is shown in full and broadcast only if the yes names the txid that was shown, the same rule as every other send (D-016). It is one queue for coins, tokens and inscriptions because the person deciding should see one screen whatever is moving, and the caller should learn one shape.

Two limits keep asking from being a nuisance rather than a risk. A request nobody answers within an hour expires, so a page somebody looked at last week is not still asking. No more than twenty may wait, so a page that files in a loop gets an error, not an audience. Filing needs no key at all — the frame has no origin to present one from — which is fine precisely because filing is a question. The omni_send* calls keep their two-step for a program that holds the wallet; the difference between them and da_request* is whose finger is on the button.

D-021 — The wallet remembers for a page; the page never gets an origin (2026-09-14)

"Can inscriptions use localStorage? They should be able to." They should, and they cannot: a sandboxed frame without allow-same-origin has an opaque origin, and an opaque origin has no storage — the browser throws on the property itself. The flag that would fix it is the flag that would hand a stranger's code this wallet's origin, cookies, CSRF token and every page in it (D-018). A second origin for content would work on this machine and not over the tunnel, where there is one hostname, and it would still put every inscription in one shared bucket.

So the wallet remembers instead. The page loads /r/storage.js and gets arcade.storage, the shape of localStorage with a ready promise in front of it; the shim talks to the viewer over postMessage; the viewer — the wallet's own page, outside the sandbox — keeps the data in ~/.dogecoinarcade/pagedata.sqlite under the id of the inscription in the frame the message came from. The page never says who it is, so it cannot lie about it. Limits (256-character keys, 64 KiB values, 1,000 keys and 1 MiB per page) are there so a page in a loop fills its own room and nothing else.

Two things fall out that localStorage would not have given. The data is the same on the phone and the desk, because it is the wallet's and not the browser's. And the same channel is why the approval pop-up is safe: it is a frame of the wallet's own page held by the viewer, frame-ancestors 'self' on every page the wallet draws, and the inscription can neither see it nor frame one of its own.

D-022 — Over the tunnel, inscribed pages get a hostname of their own (2026-09-14)

Testing everything on testnet over the real tunnel found the inscribed shop page saying "…" on the phone. Every fetch('/r/…') from inside the frame came back 403: the sandbox gives the page an opaque origin, and a document with an opaque origin sends no cookie — not the remote key, nothing. Its own <script src> did not load either. On this machine nobody had noticed, because on this machine there is no door.

The ways out that were rejected: allow-same-origin (D-021 already says why); asking page authors to write credentials: 'include' and answering CORS with Access-Control-Allow-Origin: null (every page would have to know, and null is every sandbox on the web); serving the page API without any key over the tunnel (the random hostname sits in a phone's history and in Cloudflare's logs, and /r/wallet says what the wallet holds).

What is done instead: the Remote page opens two quick tunnels. The first is the wallet, as before — key in the QR, cookie, everything. The second is the pages' door: its hostname is handed out only inside the viewer's own page, cookie-guarded, as the src of the sandbox frame, and it serves nothing but /content/* and /r/* — exactly what a page may reach anyway, none of it able to spend or draw a wallet page — everything else answers 404 whatever cookie it carries. That hostname is as secret as the key: never shown, never in a URL a person types or a history keeps (frame addresses are not history), and gone with the tunnel. A tunnel is "open" only while both cloudflared processes live; one dying shuts the whole thing rather than leaving pages that hang. On this machine nothing changes: the frame is /content/<id> on the same host, and is_remote is what it was. Stood in for in the browser test by wallet.localhost and pages.localhost, two names for loopback that the guard tells apart by Host exactly as it does the real ones.

D-023 — An inscribed page talks to other nodes through the wallet, and nobody is asked (2026-09-14)

Asked for: inscriptions that send node-to-node messages to another DogecoinArcade node and get replies, without approval, on testnet.

The channel already existed for bots (da_send, da_inbox); what a page lacked was any way to reach it. It cannot fetch off the machine (the sandbox CSP, arcade/web/content.py), it has no cookie for the bot RPC and must never have one, and it has no key. So it goes the way storage goes (D-021): the page posts to the viewer around it, the viewer — the wallet's own page, with the CSRF token — calls /node/<id>, and the wallet seals with its own identity and sends from its funded address. The page's identity is the frame it was put in, never a claim in the message, so a page can read only the replies to what it sent itself.

Why no approval, when coins and tokens and inscriptions all queue: a message moves no value, and node-to-node messages live on the messaging chain, which is testnet only by D-010 — there is no mainnet path to gate. The thing a page could do without asking is spend testnet coins in a loop; the answer to that is a cap (thirty an hour per page, nodetalk.py), not a person pressing Approve thirty times. The route refuses outright if the messaging chain were ever mainnet, in case D-010 is one day reversed.

What a page sees of the inbox: messages from nodes it has written to, with an id above the inbox's newest at the moment it first wrote. Not the rest — every other page's replies and every bot's traffic are in the same table. The other node answers the wallet, not the page, because the page has no key; two pages writing to one node both see its answers, and the documentation says to put something of your own in the message. A per-page key would fix that and was not done: it would make every page a second identity to fund, back up and announce, for a problem a nonce in the body solves.

D-024 — A shop is an inscription, and a swap is one transaction both sides signed (2026-09-14)

Asked for: an inscription that sells — a buy button that sends a token, an NFT or coins in exchange for any of the same, without the seller pressing anything, including a random pick from a collection so a minting event is just a shop; and the two transfers landing in the same block.

The same block was the whole problem. Two sends, however close together, are two transactions, and there is always a block in which one has landed and the other has not — and a mempool in which the second can be dropped. So there is no second transaction. A swap is ONE Class C payload naming both legs (inscriptions.Swap, two Legs: an inscription, a token amount, or coins paid inside the transaction itself), in a transaction whose inputs are signed by both the seller and the buyer. The engine (state.Engine._swap) moves both legs when the block lands or refuses the whole thing, on every node. The buyer is whoever signed after the seller; the seller is paid net of what the seller put in, so a buyer who routes the seller's own coins back has paid nothing. Every node reaches the same verdict from the bytes, which is why neither party has to trust the other to go second.

Where the terms live: in the shop inscription's own JSON, which travels with the content and is covered by its hash. Not in a message, not in the page. A buyer's wallet reads the listings from its own ledger before it asks the seller for anything, and the seller's wallet reads the same bytes before it offers. No page is trusted about a price. Who may sell from a shop is the wallet that created the inscription AND still holds it — creating it makes the listings the owner's own words, holding it means the shop closes by sending the inscription away and cannot be copied by re-inscribing the JSON.

Why the seller is not asked, when D-020 says only the person may send: the person already said yes, in writing, when they inscribed the shop. The shopkeeper (shopkeeper.py, on the block watcher's thread) adds nothing to that: it offers exactly what the listing says, locks one of its outputs for the offer so nothing else in the wallet spends it, and signs only a transaction that matches the offer it made — its outpoint first, exactly the legs, paid what it asked (swap.countersign). The buyer IS asked, once, in the approvals pop-up, with the transaction as built by their own wallet; Approve signs the buyer's half only. Between the two, every step is a node-to-node message (D-023) — offer asked, offer made, half-signed transaction handed over, signature sent — so it is testnet only by D-010 and a minute or several per step, and the page shim (/r/swap.js) says so rather than spinning.

Extended by the same reasoning: a page this wallet created and still holds may send from it without asking (/r/owner.js, /owner/<txid>), because those are the person's own words too; the send is written in the approvals list as one from a page of your own, so it is visible after the fact. Refused outright in any other wallet and on mainnet.

Swaps are read from a starting height per chain (config.py, swaps_from; testnet 1,488,450) so that a Class C payload of this shape in an older block — there are none, but the check costs nothing — is recorded as unread rather than as an old transaction meaning something new.

D-025 — Mining is a button, a thread, and a notice; never one long RPC (2026-09-14)

Asked for: mine a testnet block from the wallet page any time, not only when the balance is zero; and a notice that a block is being mined, so the button is not pressed again and again.

A block on this testnet takes minutes (difficulty 0.0005–0.0006, so about 2.2 million hashes at the node's 1,622 per second on the mini PC) and generatetoaddress hashes inside the RPC thread; one call for the whole search sat past the RPC client's 120-second timeout and the page said the node was not responding when it was busy. So miner.mine_one asks for a first batch of 20,000 tries, measures the rate, and then asks for about thirty seconds' worth at a time until a block comes, the person presses Stop, or the ceiling is hit. Each answer is a chance to report progress and to stop; the RPC timeout is never near. The search runs in a thread the web app owns, with one state.mining record — started, tries, rate, expected hashes at today's difficulty — that the wallet page polls every three seconds and shows as a sentence: so many hashes so far, about so long on average, sooner or later by luck. Stop stops after the batch in hand, and says so. Mainnet is refused because mainnet has miners.

D-026 — Fees are what the block assembler counts, and a refusal pauses the run (2026-09-14)

Found: on testnet, a collection run put one inscription chunk per block, with a hundred waiting, on a network mining blocks a minute apart.

The node's block assembler prices a transaction by fee per virtual byte (miner.cpp:494, txmempool.cpp:71-73, policy/policy.cpp:215-218): max(size, sigops × 20), and a bare CHECKMULTISIG output counts twenty legacy sigops (script.cpp:156-173). A Class B chunk of 98 data outputs is 39,240 virtual bytes against about 3,900 real ones; at the assembler's minimum of 0.01 PEP per kB it needs 0.39 PEP where we paid 0.11. The fee-rate pass took nothing of ours; the priority pass took one transaction and stopped (miner.cpp:613). One chunk per block, exactly.

The fix is in one place: every fundrawtransaction in the application goes through fees.fund, which decodes what the node built, counts its sigops the way the node does, and re-funds at a rate that clears the minimum on virtual size — a plain send is unchanged, a chunk costs about 3.5 times what it did (0.004 PEP per data output), and a block now takes up to nine chunks, which is the block's own sigop limit. Estimates shown before sending (inscribe.plan, sender.plan) count the same way, so the number the person confirms is the number they pay.

Two things the runner (collections.py) did wrong in that afternoon are changed with it. A node that refuses a chunk — chain too long, wallet short, node down — is not the item's fault and is the next item's problem too, so the job pauses with the reason instead of failing that item and the thirty-nine after it; Resume puts failed items back to pending and carries on. The chain-too-long refusal in particular means "not until a block", so that one is not even a pause: the run waits for the chain to move — up to the same half hour — and tries the same item again, and nobody has to press Resume. And a job whose last split has not confirmed waits a generous half hour for a block (one confirmation is what the node's ancestor limit is about, validation.h:74-80), then pauses saying so, rather than chaining a third unconfirmed transaction the node would refuse anyway. The first chunk of a send prefers confirmed outputs for the same reason.

D-027 — A shopkeeper answers questions, never answers (2026-09-14)

Two shops on testnet, each on its own node, each with a shopkeeper reading the node-to-node inbox. One node ordered from the other, the other answered, and the two of them then talked to each other for eleven minutes — one message a block out of each wallet, thirty-two messages and about 0.74 tPEP between them, until a person stopped a node.

The reason was one line. shopkeeper._swap_message took any message whose swap field said offer or sign to be an order, and an answer carries the same swap value as the question it answers. So a refusal — "no such inscription on this node" — was read by the other shopkeeper as a fresh order, refused in turn, and that refusal read as an order again. Nothing was wrong with either shop, both sales completed correctly, and neither node did anything it was not asked to: they were asking each other.

An answer is now recognised by what only an answer carries — re, the txid it answers, and ok — and a message carrying either is not an order. The shape of the protocol made this possible and a named kind (offer-reply) would make it impossible; that is a bigger change to a format two nodes on the network already speak, and it can wait for a version of the protocol that changes other things too. What cannot wait is that an automatic answer must never be able to provoke another one.

The general rule this is a case of: anything that replies without being asked needs a reason to stop. A shopkeeper that refuses an order is being useful; a shopkeeper that refuses a refusal is a loop with a fee attached. Every automatic answer added from here — approvals, swaps, whatever comes after — gets a test that feeds it its own output and expects silence.

The same sale showed a smaller thing: swap.countersign unlocked the seller's outpoint when an offer expired but not when it sold, so a sold output stayed in the node's lockunspent list until the node restarted. Harmless while the output is spent, wrong as bookkeeping, and it would refuse a later legitimate offer that wanted a recycled outpoint. It unlocks on both paths now.

D-028 — What a wallet holds is asked of the index, not of the page on show (2026-09-14)

"Yours" on the inscriptions page said "Nothing yet on Testnet" to a wallet that held seven inscriptions, one of them bought ten minutes earlier. The list was built by filtering the page of inscriptions already on screen — [row for row in data["inscriptions"] if row["owner"] in owned] — and that page is the newest twenty-four. It was right for as long as everything ever inscribed fitted on one page. The afternoon a hundred-piece collection landed, every piece this wallet owned was older than page one, and the wallet's own section went empty.

The index is asked directly now: inscriptions(owners=[...]) and inscription_count(owners=[...]), one query over the addresses the wallet holds, newest first, capped at a page with "newest 24 of 107" when there are more. A wallet is many addresses, so the parameter is a list and an empty list means no addresses, which is not the same as "no filter" — that distinction is the whole reason owners=[] returns nothing rather than everything.

The general shape of the mistake is worth naming, because it is easy to repeat: a derived view computed from a paginated list is only correct while the list is short enough not to be paginated. Anything that answers "what is mine" asks the store, not the screen.

D-029 — A shop's own sales are written in the approvals book (2026-09-14)

Approvals is where a person looks to see what this wallet did for something that is not a person: a page asked to send coins, a program on the bot RPC asked for tokens, the buyer's half of a swap. A shop was the one thing missing. The shopkeeper sells what the inscription lists and signs it without asking (D-024), so the sale left no mark on the page a person reads — the wallet handed over a hundred tokens and the only record was in swaps.sqlite.

A sale is now written to the same queue the moment it is broadcast, with origin shop and status sent. It is never pending, because there is nothing to decide: it has happened. That is why file() takes a status — something already done does not count against the cap on how many requests may wait, and it is decided as it is written. The line reads from the shop's side, "sold 100 Arcade Test for 1.00000000 coins", rather than the buyer's.

Two orderings matter. The record is written after the broadcast, not before: a sale that happened and was not written down is a bookkeeping problem, and a sale that was written down and did not happen is a lie. And a book that cannot be written must not undo a sale — if the queue throws, the shopkeeper logs it and the sale stands, because the chain is the ledger and this is a convenience.

D-030 — The wallet answers "what do I have", the sections answer "what exists" (2026-09-14)

Two different questions had been sharing a page. /tokens listed every token on the chain AND the balances on your own addresses with a send form beside each; /inscriptions listed everything ever inscribed AND your own pieces with a Send button. Both pages were a public directory with a private wallet folded into the middle of it, and on a phone the private half was below a screenful of other people's things.

They are split along the question they answer. The wallet has three tabs — Wallet, Tokens, NFTs — and each shows what this wallet holds and what can be done with it: coins, token balances with the send form, inscriptions with Send. /tokens keeps what exists on the chain and how to make one; /nfts keeps everything inscribed and how to inscribe. Each page points at the other, so neither is a dead end.

Two renames go with it. Inscriptions is called NFTs in the interface, because that is the word people arrive with; it is still an inscription in the code, on the wire and in the API, and the page says so. /inscriptions redirects to /nfts rather than 404ing: links to it are on the chain, in other people's notes and in a shop page's own address bar, and a rename that breaks them is not a rename.

And the Keys page is gone. It listed announced public keys and offered to publish yours — a page of plumbing, and the fingerprints on it were the thing the owner asked never to show. The one control worth keeping, "publish your key on the chain", now sits on the Address book beside the name it publishes, which is the page a person is on when they think about how others find them.

D-031 — A wallet holds one balance of a token, not one per address (2026-09-14)

The wallet's Tokens tab listed a row per (address, token), so a wallet with Arcade Test on two addresses showed "Arcade Test" twice with two different numbers and two send forms, and no total anywhere. The owner asked the obvious question: why is it listed twice? Because that is how the node keeps it — which is plumbing, not an answer.

One row per token now, with the total. The addresses are still there, behind "where it sits", because they are real and occasionally matter; they are not the first thing shown. The API already took this view: /r/wallet has summed per token from the start, with a comment saying a wallet with coins on fifteen addresses holds one balance, not fifteen. The page had simply never been brought into line with it.

The send follows. A token send comes out of exactly one address, so the wallet picks: among the addresses holding enough, one that also has coins for the fee. When no single address holds enough, the refusal names the largest piece rather than the total — "this wallet holds 1,000, but a send comes out of one address and the largest holds 600" — because the total is not what a send can move, and a message that quotes it would be describing money the transaction cannot reach.

D-032 — An announcement carries both addresses and a name that can be checked (2026-09-14)

A key announcement said: here is my key, here is the address it belongs to, and here is a name I am telling you. The name was the weak part. Anyone can publish any name, so the interface had to say so everywhere it was shown, and a reader's address book filled itself in with a stranger's assertion.

It carries an @tag instead. A tag is on the chain: the tag index says which address holds which name, first claim wins, and a reader can check the announcement against it rather than believing it. Where they disagree, the announcement is wrong and the address book says so instead of repeating the claim. The name a person types stays where it always worked — travelling with a first message, and as the label you give somebody in your own book — and is no longer published.

It also carries the same wallet's address on the other chain. One announcement then answers both questions a reader has: where do I message this person, and where do I pay them. The 20 bytes say nothing about which chain they are for, so the reader supplies that from its own parameters (other_pubkeyhash_version) rather than guessing — Dogecoin and Pepecoin testnets share a version byte, and an address inferred from bytes alone could be either.

The format grew by sections rather than by a new field order: 0x02 for the other chain's hash160, 0x03 for the tag, after the existing 0x01 identity tail. A reader stops at the first section byte it does not know, so an older reader gets exactly what it got before, and a newer sender loses nothing by saying more. NUL padding — what Class B adds — ends the walk like any unknown section, which is why the section bytes start at 1.

And claiming a tag is now something a person can do. The chain rules had been implemented and tested since @tags went in; there was no button anywhere, so no tag had ever been claimed. It is on the address book, two steps like a send, on the chain the messages are on — tags are per-chain, and the same name on two chains is two names.

D-033 — A sale reserves what it sold until its block (2026-09-14)

The shopkeeper excluded from a random pick everything held in an open offer. A sale closes its offer as "sent" at broadcast, and the ledger goes on naming the seller as the owner until the block is indexed. For that window — about a block — a sold Goofball was in neither set: not reserved, and still apparently the seller's. A second buyer's order could be offered the same piece.

Nothing could be stolen: a swap is one transaction and the engine refuses it when the seller no longer holds what it promises, so the second sale simply fails. But the second buyer pays a message fee and waits a block to be told no, and a mint page watching for their piece waits for something that will never arrive. A rare race on a quiet chain, and the ordinary case for a popular mint, which is exactly what this was built for.

A sold offer now reserves its item and its outpoint until index.transaction can see its transaction. An index that cannot answer counts as not having it: holding a piece back for one more block costs a buyer nothing, and offering it twice costs them a fee.

Found by the GX10 reading the code rather than by a failure, which is the cheaper way to find it.

D-034 — One name for yourself, and it is the @tag (2026-09-14)

The address book had a box called "How you introduce yourself": a name you typed, sent with a first message, posted beside anything you put on the public board, and published with your key. The @tag does the same job and can be checked. Two names for one person is one too many, and the typed one was the weaker of the two — nobody could verify it, and publishing it put an unverifiable claim on the chain permanently.

So it is gone: the box, the route that saved it, and the careful machinery around publishing it — a byte counter, a warning about multisig outputs, a refusal to trim. profile_name now reads the tag the chain gives this wallet's identity address, so everywhere that introduced you by name introduces you by @tag, and a wallet with no tag introduces itself by its addresses alone, as it always could.

What is lost is the ability to be called "Big Chief Energy". What is gained is that nobody can be called "Big Chief Energy" without the chain agreeing — including someone claiming to be you.

D-035 — A post may name an inscription; the board shows a card, never the page (2026-09-14)

Somebody posts a link to an inscription on the public board. The useful thing is to see what it is without leaving the conversation. The dangerous thing is that an inscription can be a page of scripts, and a post is written by a stranger.

So the board renders a card from what this node's own index says — number, name, type, size, collection — and a button that opens /inscriptions/<txid>/view. It never renders the inscription's content, not even escaped. The viewer is the one place with a sandbox (allow-scripts and no allow-same-origin, its own content CSP) and the one place with a wallet on the other side of the frame to answer a shop page's questions. A board that framed pages would be a message list where every post can run code, and a mint page polling its collection and flipping two hundred tiles a second would run in every scrollback.

The card is drawn only for an inscription this node has indexed: a txid it does not know is left as text, because a card claiming to describe something unknown is worse than no card. At most four to a post.

Recognised by the 64 hex characters, with or without a /content/, /inscriptions/ or /nfts/ prefix, so a link and a bare txid both work and an ordinary sentence cannot become a card by accident.

D-036 — A collection can sell itself, from the run that inscribes it (2026-09-14)

Inscribing a hundred pieces and then selling them were two unrelated jobs. The first was a wizard; the second meant writing a page, writing the shop JSON beside it, and inscribing that by hand — which is what was done for the Goofballs, carefully, twice.

The wizard asks instead. A checkbox, on by default, a price, and whether the price is in coins or in a token. The runner inscribes the pad when the last item is on its way, and only then: a pad offering a random item of a collection half of which was never inscribed would be selling things that do not exist. It is inscribed last for the same reason, and a run that had a failed item does not get one at all.

If the pad cannot be inscribed the run stays done. Every item is still up, which is what the run was for; the refusal is written on the job and the pad can be made by hand. A collection reported as failed because its shop window did not go up would be the tail wagging the dog.

The page is the one the Goofball mintpad used, with the names taken out and two sentences removed — the ones explaining that both wallets sign one transaction. They were true and they were the page lecturing about its own plumbing; what a buyer needs to know is the price and that nothing moves unless everything does, which the wallet's own approval screen says.

The price is settled before anything is paid for rather than after, which is the point of asking at step 2 rather than offering a button at the end.

D-037 — The exchange reads the chain, not a list (2026-09-14)

Everything for sale is already on the chain: a shop is an inscription whose JSON names its listings, and it closes by being sent away. So the Exchange does not keep a directory. It asks the index for every inscription whose JSON names a shop and whose creator still holds it, reads the listings the way a storefront page does, and shows what is left.

That is what makes "it should be removed from the listing automatically" true rather than a job somebody has to do. A mintpad that has minted out has no items left to pick and drops off the Mintpads tab; an NFT that has moved makes its listing unavailable and drops off the Marketplace. Nothing is told; there is nothing to tell.

Four tabs because there are four questions: what has somebody offered me, which mintpads still have pieces, what tokens are being sold and for what, and which single NFTs are for sale. The Tokens tab is shops, not a book of bids and asks -- a real order book is consensus-level matching (M3), and calling a list of shop prices an order book would be a lie about what it does.

D-038 — An offer is made on an NFT, not on a listing (2026-09-14)

A shop offers what its owner decided to sell. An offer is the other direction: anyone may offer for any NFT, whether or not it is for sale, and the wallet that holds it decides.

It is not a PSBT, whatever it is called. The buyer cannot build the transaction: the engine requires the seller's own output to be the first input, that is what makes them the seller, and the buyer has no way to know which output that will be or to spend it. So an offer is a message -- this item, this price -- and accepting builds the seller's half exactly as a shop would, with the same checks: still theirs, buyer holds what they promised, an output locked to carry it.

What makes it one press rather than two is where the buyer's consent lives. The person named the price when they made the offer, so when the holder's answer comes back their wallet signs its half without asking again -- and only after checking that the answer names the same item and the same price as the offer it is answering. A yes cannot be turned into a different trade by the party answering it. That check is the whole reason this is safe to do unattended; without it, accepting could hand back any terms at all.

An offer on something of yours is never answered automatically. Nobody but the holder can say yes to parting with their own NFT, so the shopkeeper writes it down and leaves it on the Exchange for a person.

D-039 — The chart is what traded, and the gaps are part of it (2026-09-14)

There is no order book to draw (D-037), so the chart is drawn from the only prices that exist: the swaps the chain has read. A swap is two legs; a price is one divided by the other; a candle is the open, high, low and close of the prices inside a day.

The gaps are drawn as gaps. A day with no trade is a dot on the axis, never a line ruled to the next price, because a line between two trades a week apart is a picture of prices nobody paid. Two of thirty days traded is what a quiet chain looks like, and the chart says so under it.

One currency to a chart. Coins and tokens on one axis is adding pounds to metres, so an NFT chart is "NFTs paid for in Arcade Test" or "in coins", and the tab draws one for each currency anybody has actually paid in -- which is also why the first NFT chart this ever drew was in tokens, not coins: every Goofball so far sold for ten Arcade Test.

Read straight out of arcade_tx rather than kept in a table of its own. A swap's payload begins with a fixed ten bytes -- the AnyData type, INSC, version 1, kind 5 -- so one indexed LIKE finds every trade and nothing else, and a chart of a chain's whole history costs one query and a few hundred parses. A table would have had to be filled from somewhere, and the somewhere is this query.

Drawn as SVG in the page. A wallet should not fetch a charting library from anywhere to say what two trades went for, and the page's own CSP would stop it if it tried.

D-040 — A collection is a market, and you can only offer what you have (2026-09-14)

Two things the marketplace was getting wrong.

The chart was per currency: "NFTs paid for in Arcade Test", every collection on one axis. What a Goofball goes for says nothing about what a Doge Punk goes for, and a chart of both is a chart of neither. It is one chart per (collection, currency) now, and pieces that belong to no collection are a market of their own -- they are single things, and that is what they have in common.

And the offer form listed every token on the chain. Offering a token you do not hold is a fee spent to be told no, and told no by the other side, a block later, for something your own wallet could see immediately. The list is what this wallet holds, with the balance beside each; the token picker is not drawn at all when it holds none; and the door checks again when the form comes back, because a form is a convenience and not a guarantee. The coins option shows the balance for the same reason.

The check the door already had -- _buyer_for, shared with the shop machinery -- is the one that matters, and it now runs before anything is sent rather than after the message is paid for.

D-041 — A collection shows a different one of itself each time (2026-09-14)

A shop card for a collection showed no picture, or would have shown the cover — edition #1, every time. A collection is a set of different things, and one picture that happens to be first is a poor advertisement for that. The card draws a random piece, picked per request, from the pieces this node actually holds the bytes for and that a browser will draw. A thumbnail that 404s is worse than no thumbnail.

D-042 — An offer is said on the chain, because a holder never asked to be reachable (2026-09-14)

The first version of Make offer sent the offer as a node-to-node message to the wallet holding the NFT. That worked only when the holder had published a key AND was holding the piece on the very address they published — which is to say, almost never. The owner put it plainly: it only works if that is their main wallet and it has been published.

The asymmetry is the answer. Somebody holding an NFT never asked to be contacted; somebody making an offer is asking for a reply. So the offer goes on the chain as a payload every node reads (KIND_OFFER), where the holder's own wallet finds it by watching its own things — any address, no key, no announcement. The answer travels back as a message to the buyer, and the wallet refuses to MAKE an offer until this wallet's key is published, which is the one requirement the party who wants an answer can satisfy and be told about.

It fits one OP_RETURN — the inscription's 32 bytes and a price leg, 55 bytes wrapped — so an offer is a flat fee and no dust. Nothing is locked by one: it is an offer, and the engine refuses only what nobody could act on (an inscription this chain has never seen, your own piece, a price of nothing). Whether the buyer can pay is answered when the holder accepts and the swap is built, because a balance at this block says nothing about a balance three blocks later.

Offers now reach a wallet the way everything else here does: by being on the chain and indexed, rather than by being delivered.

D-043 — A token send comes out of several piles when it has to (2026-09-14)

A token send comes out of exactly one address, so a wallet holding a token in several piles could not send more than its largest pile held. It was refused with the largest pile's number, which was true and unhelpful: the wallet had the tokens.

It sends in several transactions now, shown together and broadcast on one yes. Which piles, in order:

Piles that have coins of their own go first, because every transaction pays its fee from the address it comes out of and a pile with no coins cannot send at all. When one is needed anyway, the wallet's existing message names the address and the amount it needs, which is better than a general refusal.

The address being sent TO is never used as a pile: a send from an address to itself moves nothing.

Every transaction is built and shown before any is broadcast, and what goes out is what was shown, which is the rule for a single send (D-016) applied to a set. They do not depend on each other, so one failing does not invalidate the rest -- and what did go is named, because a partly-sent amount the person is not told about is the worst outcome there is.

D-044 — A half-built swap holds the coins it spends (2026-09-14)

A buyer reported "input 1 unknown transaction failed" buying from the Arcade Test counter. The seller's node was refusing the buyer's half because one of its inputs was not in the seller's UTXO set.

The buyer's wallet had spent it. A swap takes several blocks and several messages -- the order, the offer, the signed half -- and every message is a transaction paying a fee out of the same wallet. Nothing stopped the wallet from funding one of those messages with the very output the half-signed swap was spending. By the time the seller looked, a block later, it was gone.

swap.build locks the inputs it chooses, as make_offer already locked the seller's. And the refusal says what happened rather than "spent or unknown": either that wallet spent it since, or this node has not seen its block, and either way another offer will be built from what is there now.

D-045 — A refusal names the thing that is actually wrong (2026-09-14)

Two refusals a real buyer hit in one evening, both true and neither usable.

"input 1 is spent or unknown" was the seller's node failing to find an input of the buyer's half. What had happened was that the buyer's own wallet spent it (D-044); what the buyer read was a sentence about a transaction they had never seen. It now says which of the two things happened and what to do.

"nZxSHUHvvh… has no output worth 0.00000000 to swap from" was an address holding five thousand tokens and no coins. The floor came out negative and printed as zero, so the message asked for nothing and refused anyway. There are two different failures there and they now read differently: an address with no spendable coins at all is told that every side of a swap puts one of its own outputs in, and that a fraction of a coin is enough; an address whose outputs are all too small is told the size it needs and the size of its largest.

The rule this is a case of: a refusal is a sentence a person acts on. If it quotes a number, the number has to be one they can do something about.

D-046 — One address a chain, and only the wallet this application made (2026-09-14)

A Core wallet spreads itself out: a fresh address for every payment received, another for every lot of change. Every awkward thing this evening came from it. A token stranded on a receiving address with no coins to move it. An NFT on one address and the published key on another, so nobody could work out who to write to. A balance in three piles needing three transactions to spend (D-043). A swap and the message carrying it picking the same single output (D-044).

So: one address a chain, holding the coins, the tokens and the NFTs. On the messaging chain it is the address the identity is derived from; on the other it is a named account address the node keeps for the life of the wallet. "Show a receiving address" shows that one rather than minting another, and change already came back to the sender everywhere the arcade builds a transaction. Address reuse links a wallet's activity together for anyone reading the chain; that is the price, and this ledger is address-keyed and public anyway.

The gather runs under the application's one-send-at-a-time lock, like everything else that spends, and does nothing at all when something else holds it. Housekeeping must never build a transaction from the outputs a collection run is part way through spending: a run's chunks are funded ahead and spent one after another, not locked, so nothing else would have stopped the two colliding. An open offer's output is already safe by a different route -- it is locked at the node, and listunspent leaves locked outputs out, so the gather cannot see it.

Anything that lands elsewhere walks home by itself, a few things every couple of blocks, coins first -- a token or a piece cannot move off an address that cannot pay its own fee, so the wallet sends it the fee and fetches it on the next pass. Automatically on testnet, where a transaction costs nothing real. Never on mainnet: sweeping somebody's coins without asking is spending their money for them.

Which addresses count depends on the chain, and the address says which chain it is for. On a test chain the node exists to run this: every address in its wallet was made by the arcade, so all of them are its and everything is gathered. On a real chain the node is usually somebody's own wallet as well, and those coins are not ours to show, to spend or to gather -- there, only what the arcade filed under its own accounts counts. Reading it off the version byte means no caller has to remember to pass a flag, which is the kind of thing that gets forgotten exactly once, on mainnet.

D-047 — One guide, shipped with the application (2026-09-14)

There were two guides: docs/features.md, which the site publishes, and a shorter one written by hand in guide.py for the application. A test kept their section titles in step, which caught a heading that moved and never caught a sentence that went stale. Two descriptions of one program is one too many, and the short one was always a little behind.

The application ships the document itself now -- arcade/web/templates/guide.md, the same bytes the site publishes -- and renders it. The test compares the two files rather than their headings: same file or the suite fails.

Markdown is rendered in about forty lines here rather than by a library. The subset the document uses is small, and a wallet should not grow a dependency to show its own help. Escaping comes first and markup second, because a document is text before it is markup.

And a bug now has somewhere to go from inside the application: the guide links to the site's bug page. Somebody who has just found a fault is standing in the wallet, not on the website.

D-048 — An order book on the chain, settled by swap (2026-09-15)

An Omni-style book for tokens against the chain's own coin. The payloads were already there and unimplemented — MetaDExTrade (25) and the cancels (26, 27) have been parsed since the payload module was written, and the balance table has carried metadex_reserve since the schema was.

Property 0 means the coin. Omni has no such id, because Omni's MetaDEx pairs two tokens and leaves the native coin to its older DEx; here the coin is one side of every pair, so it needs a name.

Asks hold their tokens; bids hold nothing. An ask moves what it offers into metadex_reserve, so the book cannot show tokens the seller has since spent. Nothing can hold coins the same way — there is no covenant on this chain that would reserve them and still let the wallet live — so a bid is an intent, and whether the coins are there is settled when somebody fills it and the transaction either stands up or does not. The page says so rather than drawing two sides that look equally solid.

Nothing is matched by the engine. Omni's MetaDEx can match without either party present because both sides are escrowed tokens; with a coin on one side, somebody has to sign. So a fill is a swap — one transaction carrying both legs, which has worked since D-024 and was proved between two machines tonight. The book is what is on offer; the swap is how it settles.

Martin asked whether the resting orders could be partially-signed transactions that a taker finishes. They cannot, and the reason is worth writing down. SIGHASH_SINGLE|ANYONECANPAY lets a maker sign one input and commit to one output — but a maker needs to commit to two things: the payload naming the legs, and their own payment output. Commit to the payload and a taker can redirect the payment; commit to the payment and a taker can rewrite the legs and take more than was offered. There is no sighash flag that covers exactly those two outputs and leaves the rest open. A maker's node signing at fill time is the honest version of the same idea, and it is what a shop already does.

Price is two integers and stays that way: amount_desired / amount_for_sale, compared and sorted as a Fraction. A cancel at a price matches on the ratio, so 100-for-1 and 200-for-2 are one price and both go. A book sorted on floats is a book whose order nobody can reproduce.

D-049 — The chain is what an answer is checked against (2026-09-15)

An offer for an NFT is said on the chain (D-042) and answered by a message. The answering wallet checks that the answer names the same item and the same price it offered — and it was checking against a note it kept locally.

It never wrote the note. The route that makes an offer broadcast the payload and recorded nothing, so every answer to every offer was found to be about an offer this wallet had never made, ignored in silence, and every offer timed out. The unit test passed because it inserted the row by hand; the path that was supposed to write it had never run. That is the second time in two days that a green test covered a fixture rather than the code.

So the check reads the chain. index.offer(txid) returns what was offered, by whom, for what, and the answer is measured against that. A wallet that was reinstalled since it made the offer, or whose local note was lost, still knows exactly what it asked for, because it is written where everyone can see it. The local note is still written — it is what shows an offer before its block lands — but nothing depends on it any more.

Two smaller things from the same report, both about telling a person what is happening:

An offer already accepted is waiting for the buyer's wallet to sign, not waiting for another Accept. The page drew the button again, somebody pressed it, and the refusal told them their own offer belonged to somebody else. The Exchange now says "accepted — waiting for @them to sign, held until 15:49", or "sold" with the transaction, and the refusal distinguishes the two cases rather than assuming the worst one.

Reported by a person on Windows who read the code, found the missing call, and said which line it belonged on. Twice now.

D-050 — The mempool is read, so a message arrives in seconds (2026-09-15)

Every message here is a transaction, and the scanner read blocks. So a message cost a block to arrive, and a swap cost several: the order, the offer, the signed half, each waiting for a miner. On a chain with minute blocks that is a trade measured in minutes, most of it spent waiting for carriage rather than for anything to be decided.

The scanner reads the mempool too, every pass. Rows land at height 0, which already meant "not in a block yet" for a post this machine had just made, and now means the same for anything seen on its way. The block promotes the row in place rather than writing a second one, and it keeps its id -- so a program that has already acted on a message does not see it twice.

What this does NOT touch is the ledger. Balances, the order book, inscriptions and the consensus hash are read from blocks and only from blocks, because they are what two nodes have to agree about and the mempool is not a thing anyone agrees about. This is carriage: messages, a shop's answer, half a swap. The transaction that settles a trade is the swap itself, which still has to be mined, and nothing was made to depend on an unconfirmed transaction being real.

A message that never confirms leaves a row saying it never confirmed, and the messenger says "in the pool, not in a block yet" rather than dating it. A shopkeeper acting on an unconfirmed order can at worst lock one of its own outputs for fifteen minutes and offer something nobody asked for -- the same exposure as an order that arrives and is then abandoned, which was always possible.

The pool is looked at every few seconds and mostly does not change, so the transactions already read are remembered and not fetched again.

D-051 — Told before it is paid for (2026-09-15)

A buyer's side of a swap is two transactions: the half they sign, and the message that carries it. Each needs an output of its own, and the swap refuses at the moment of signing when there is only one -- which is three transactions and two waits after the person pressed Buy, having already paid for an order and held a seller's goods for nothing.

The wallet now looks before the order goes out. If the address that would pay has fewer than two confirmed outputs, the offer is refused there and then, with what to do about it: split the address, one transaction and one block, and every purchase afterwards runs at the speed of the pool rather than a block a step.

A page can ask too -- the shop answer carries can_buy -- but the page that matters most cannot be changed: a mintpad is an inscription, and the one selling Goofballs was written before any of this. So the wallet says it itself, in its own chrome around the frame, where it can be said about any inscribed page whatever that page knows.

The number is two, not one: the message and the half are separate transactions and neither can spend what the other is spending (D-044).

D-052 — No confirmation for a message on a chain where coins are free (2026-09-15)

Sending a message and posting to the board each showed the transaction first: what it costs, how many transactions, how long it will take. That step exists so nobody spends real coins by accident, and on mainnet it stays.

On testnet it is a step between a person and the thing they have just typed. The coins are free, the board is a test board, and the thing being confirmed is a sentence. A single testnet message already skipped it; files and chunked sends did not, on the grounds that dust and minutes are worth knowing in advance -- but they are reported as they happen, by the progress bubble that was built for exactly that, so the warning was arriving twice and blocking once.

Both go straight out on testnet now. Mainnet is unchanged, and a mainnet board post still shows its bill and says "Post to mainnet" on a red button.

D-053 — A board open on another screen refreshes itself (2026-09-15)

The live-refresh script keys off an element with id="live", and the public board did not have one. So a post made anywhere -- another tab, the phone over the tunnel, this machine while somebody watched from the sofa -- appeared only when the page was reloaded by hand. The messenger has had the element since it was written; the board simply never got one.

With it, the board also needed something to notice: only the block watcher bumped the counter, so a post made in this process showed up a poll later at best. Recording a post bumps it now, which is the moment there is something new to see.

D-054 — The commands repair the environment they live in (2026-09-15)

A Windows install ran dogecoinarcade-update and got:

Error while finding module specification for 'arcade.update'
(ModuleNotFoundError: No module named 'arcade')

The virtual environment was there. The application was not. pip install --upgrade removes the old version before unpacking the new one, so a build that fails in between -- or an install that stops halfway, or a file pip cannot replace because the interface is running -- leaves an environment with nothing in it. From there every command the installer wrote is a traceback, and the updater is no help at all: it lives inside the package it would have reinstalled.

So the way back out is now outside the environment. The installer writes ~/.dogecoinarcade/repair.py as soon as the venv exists, before anything that can fail, and it imports nothing from the application: it reinstalls from ~/.dogecoinarcade/src with the venv's own Python and says what to do if that is not there either. Each shim runs it first -- the updater when import arcade.update fails, the other two when their executable is missing -- and then does its own job. The installer and the updater both check the import after pip reports success, because pip has reported success and left nothing importable.

Shims also carry a version now. Presence was the only test the updater made, so a corrected shim could never reach a machine that already had a file by that name -- which is every machine that has ever updated. The installer answers shims_current; a mismatch rewrites the set.

The repair script goes beside the environment, except when the environment lives inside a checkout: the first live run of this left an untracked repair.py in the repository, on its way to being committed and published. A checkout's environment gets its script in ~/.dogecoinarcade/ and reinstalls from the checkout itself, which is the source it was installed from.

An installation already in this state cannot repair itself -- there is nothing on it that knows how. Running the installer again fixes it, and keeps the wallet, messages and chain data:

curl.exe -O https://dogecoinarcade.com/install.py
py install.py --skip-core

D-055 — The Windows installer puts its commands on the PATH (2026-09-15)

On Linux and macOS the commands go in ~/.local/bin, which every shell already looks in. Windows has no equivalent, so they went into %LOCALAPPDATA%\DogecoinArcade\bin -- a directory nothing looks in -- and the installer's answer was a note at the end saying so. Every command it had just written was "not recognized" until the user fixed their own PATH.

It now adds the directory to the user's PATH itself, through the registry rather than setx: setx truncates a PATH longer than 1024 characters, and has been quietly eating people's environments for years. The user Path is read, the directory appended if it is not already there, and the value written back. The terminal that ran the installer inherited its PATH when it opened and cannot see the change, so the closing note says to open a new one.

D-056 — One way to launch a browser in the tests (2026-09-15)

Three files opened Firefox and only one of them honoured the overrides. tests/test_browser.py reads ARCADE_GECKODRIVER and ARCADE_FIREFOX_BINARY, because a snap-packaged Firefox is a symlink to /usr/bin/snap and geckodriver rejects it with "binary is not a Firefox executable" before it launches anything. tests/test_showcase.py and tests/test_inscription_sandbox.py each built their own with webdriver.Firefox(options=options) -- no service, no binary location -- so on that machine they skipped whatever the environment said.

The GX10 found it while running the suite as a second opinion: with the variables set it ran the approvals tests and skipped 20 others with the same message, which is the signature of two code paths rather than one machine. Twenty tests quietly not running on the machine whose whole job was to run them.

tests/browsers.py is the only place that launches one now, and the skip it raises names the variable that gets past the usual cause.

D-057 — A wait that throws is not a wait (2026-09-15)

tests/test_browser_approvals.py failed three times across two machines, two installs and two builds, always in a full run and never on its own. The assertion it failed on was the wrong place to look:

WebDriverWait(browser, 10).until(lambda b: b.execute_script(
    "return document.title").startswith(("visits", "error")))

document.title is None while a document is mid-navigation. .startswith on None raises AttributeError inside the lambda, and an exception that is not in ignored_exceptions ends the wait immediately rather than retrying: the wait returned after one poll, having waited for nothing, and the assertion after it read a stale title. Which is why a busy machine failed and an idle one passed -- the second load only has to be slower than the first poll.

Every title wait in the file goes through one helper now. A value that is not a string is "not ready yet", and document.readyState is asked first so the usual case is a wait rather than a lucky read. The dialog waits had the same shape with a different throw -- document.getElementById('ask').open on a null element -- and use ?. now.

The GX10 found it by reproducing the failure and then reading what the lambda actually did, rather than re-running until it passed. Two clean runs had already been offered as evidence that it was a race worth ignoring.

D-058 — An offer is readable from the mempool (2026-09-15)

Danny offered for Goofball #100 and both ends showed nothing: his Exchange said he had made no offers, the holder's said nothing had been offered. It was in the pool the whole time and confirmed at block 1,490,734, four minutes later, at which point both showed it. Everyone involved concluded it had never been posted.

Offers are read from the pool now, the same way messages already are (D-050) and for the same reason: an offer is a message to whoever holds a piece, and a block is a minute or ten. pending_offers() reads the pool fresh on every call and writes nothing down -- the ledger is built from blocks and only from blocks, so an offer that never confirms leaves nothing behind and two nodes still agree about the chain when their pools differ. A pool offer is checked exactly as the indexer checks an indexed one: the inscription must exist, it must not already be the offerer's, and an offer of nothing is not an offer.

offer() falls back to the pool as well, because it is what an answer is checked against (D-049): finding no offer for one a holder can plainly see would be worse than the wait. And the watcher compares the pool's offer ids each pass, so the page holding it refreshes itself -- the scanner bumps the generation for a message, and an offer is not a message.

D-059 — A red circle for the things that are waiting (2026-09-15)

Unopened messages, posts on the board since this wallet last looked, and offers on its pieces that it has not answered. Three different questions, one shape beside the nav item that answers it.

They are counted differently because they are different. A private message is addressed to you and carries its own read mark. A public post is addressed to nobody, so "unread" can only mean "arrived since you last looked" -- one mark per chain, set when the board is opened, no per-post state. An offer is neither: it is waiting for a decision, and it stops waiting when one is made, so accepting clears it without anything being marked.

The offer count needs the node -- the pool, and which addresses are ours -- and every page would ask. It is worked out at most once every ten seconds and kept; the Exchange page itself does the work properly. Nothing is drawn at zero: a badge that is always there is not a badge.

D-060 — The decision is where the question is (2026-09-16)

The approval pop-up frames the wallet's own approval page and puts a Close button under it. For a swap the page is longer than the frame: two legs, five rows, every output. Approve and Refuse were below the fold, so the person being asked had to scroll inside a modal to find them -- with Close, the one button that does nothing, in plain view the whole time.

They are pinned to the bottom edge of the frame now, level with Close. fixed, not sticky: sticky was tried first and is wrong here, because a sticky element may only move within its containing block, so on a page whose panel is short the bar stays exactly where it was and the fix works only for the long pages somebody happened to test. In a framed document fixed pins to the frame's own viewport, which is the box this is about. It leaves the flow, so a spacer holds the room it would have taken and the last output is not underneath it.

The test squeezes the window until the page scrolls and asks where the button was painted, rather than what its computed position says -- a computed sticky was true of the version that did not work.

D-061 — The book reads the pool (2026-09-16)

The same wait offers had (D-058), and worse, because an order is a public statement of a price. Until its block landed nobody could see it: not the person who placed it, whose own list said they had no orders, and not the person who would have taken it. A book a block behind the prices people are actually offering is a book nobody can trade on.

pending_orders() reads type 25 out of the pool and sorts it into the book by price, marked pending, alongside what is mined. Cancels are read too -- types 26, 27 and 28 -- and applied to both books, because a price somebody has withdrawn is not a price whether or not a miner has noticed. A pair whose only order is unmined appears in the pair list, or the order is invisible for want of something to click on.

Nothing is written down. The tokens an ask sells are reserved when its block lands and not before, so a pool order is a claim rather than a settled one -- the same line the messenger draws: the pool is read for carriage, the ledger is built from blocks. Two nodes with different pools still agree about the chain.

Validated exactly as the indexer validates it, minus what only a block can answer: two different sides, one of them the coin, amounts in range, the property exists. Whether the seller still holds what it is selling is settled by the block, and by the swap that fills it.

D-062 — A swap may fill a standing order (2026-09-16)

A resting ask holds its tokens in metadex_reserve, where a swap cannot reach them: _check_leg reads the free balance. So the only way to fill an order was to cancel it, wait a block, and trade with somebody who could still see a price that had just been withdrawn. The book was a noticeboard.

From fills_from a swap may take from that reserve and reduce the order by what it took. Martin chose partial fills: what is left stays on the book at its price, which is what an order book normally means.

Which orders. The swap does not name them. Every node works it out from indexed state -- the seller's orders on that pair, cheapest first, then oldest, then by txid -- so two nodes reading the same block pick the same ones. Naming them would have cost a payload change and an inscription kind, and bought nothing: what a swap names is what moves.

What protects the maker. The price. An order is a public promise to sell at a price, and its reserve may be spent at that price or better, never worse: take.amount * sale_amount >= want_amount * give.amount, integers, no division. What is left of a partly filled order rounds the maker's way, so a remainder is never cheaper than what was promised. The maker also signs the transaction -- an input is a signature -- so consent is explicit; the price guard is the rule that still holds when a wallet signs something it did not read closely.

Why a height. It makes valid what used to be invalid, so a node on the old code calls the same block's transaction insufficient-balance while a new one applies it, and the two ledgers part company with nothing to say they have. fills_from is set hours ahead of the tip it was written at, both known nodes update before it, and nothing fills an order below it.

Only a token sold for coins is a fill. An NFT swap, a token-for-token swap and everything else are swaps and touch no book.

D-063 — What a taker actually needs from a maker (2026-09-16)

Martin asked whether an order should publish the UTXO information needed to build the transaction, or whether a node should call the other node for it when a pair is opened. Reading swap.build (arcade/swap.py:893) answers it, and the answer is neither.

To fill a resting ask the taker needs exactly one outpoint of the maker's, of any size: its value comes straight back to the maker plus the coins owed, and the token leg moves by the meta-layer rule -- the seller is the first input -- not by coin value. The order transaction itself creates one: it is sent from the maker's address and pays its change back there, an output every node that read the order has already seen. So an ask is buildable from public chain data with no round trip and no protocol change. Publishing an outpoint in the order would cost bytes, pin the maker to an output that may be spent by the time anyone takes it, and buy nothing.

To fill a resting bid the maker must fund the coin leg, which one change output rarely covers. Martin chose: ask the maker's node, which picks its own inputs and signs its half, exactly as a shop answers today. It costs no wall-clock over publishing outpoints, because either way the maker must be online to sign -- and if it must be online anyway, asking it is strictly better than trusting a list of outpoints from an hour ago.

Neither publishing nor serving outpoints is a security risk: an outpoint is public, and a transaction spending it is worthless without the signature. The invariant that makes any of this safe is the one that was already there (D-038): a node signs only what it has re-derived from its own order and checked in full. Never because it was asked.

D-064 — The book is in the consensus hash (2026-09-16)

metadex_records read a table called metadex_trade that was never created: the book lives in book_order. So the section was always empty and the order book was not covered by the hash at all. Three orders stood on the live testnet book with 1,500 tokens reserved behind them, and that section was the digest of the empty string.

The GX10 found it by reading the sections rather than the total, which is what a second node is for: from one machine the hash matched itself perfectly.

It matters more from D-062 on. A fill mutates those rows -- what is left of an order, what it still wants, what it still holds -- so two nodes could have disagreed about every price on the book and reported the same consensus hash at the same height. The reserve was covered, through the balance rows; what it was reserved for was not.

The record is this node's row rather than Omni's MetaDEx layout, because the book is not Omni's: the coin is one side of every pair here and has a property id. Adding a section that was empty changes the hash for everyone, which is not a fork -- the hash is a check value, nothing consumes it automatically, and no ledger reads differently because of it. What must not change is anything else: on this node, across the change, balances stayed 171137da… and properties 65149d52…, both matching the GX10's reading at 1,492,351, while the total moved 0ec434ec… -> 9f10d752… and metadex_trades went from the empty digest to 1964f338….

D-065 — Releases are signed, and install themselves (2026-09-16)

Martin asked for automatic updates and, in the next breath, that they be secure. The second half is the whole of the work.

Until now the installer checked the archive against a SHA-256 published beside it, and its own comment said what that was worth: "it is not a signature, and it cannot be -- whoever could replace the archive could replace the sum next to it". It catches a truncated download. Both halves come from the same website, so it catches nothing else.

That was defensible while a person typed dogecoinarcade-update and could look at what they were installing. It stops being defensible the moment the machine does it on its own: an automatic updater that trusts a website is a website with a shell on every machine that ever installed this, with nobody watching. Automatic updates could not be added to what was there; they needed something else underneath.

So a release carries an Ed25519 signature over its manifest -- revision, archive hash, publication time -- made with a key that lives on the publishing machine and is on no server. Every installation carries the public half in its own code, which is what makes it a pin: an attacker who can write to the website cannot hand out a new public key, because every installed copy already has this one. Changing the key takes a release signed with the old one. PyNaCl, which was already a dependency, and Ed25519, which is the construction for signing a small message; nothing is invented here.

The refusals are total and deliberate. No manifest, a manifest signed by another key, a body that does not match, or a release older than the one installed -- and nothing is installed. The last is not paranoia: a signed release stays signed for ever, so somebody who can serve files but not sign them can still serve last month's, the one whose bug they know. published is inside the signature, so it cannot be edited to look new.

What this does NOT protect is a first install. install.py runs before the application exists, on whatever Python is lying around, with no PyNaCl and nothing pinned; it has TLS and the published sum, and that is the best it can do. The chicken-and-egg is real and worth stating plainly rather than papering over: the first copy is trusted, every copy after it is checked.

The automatic part is then small: the watcher looks every six hours, never while a send is in flight, and runs the updater in a subprocess so the new code finishes the job. A checkbox on the Overview turns it off, because this is a machine fetching code from a website and running it, and some people want to look first. On by default, because a node that is behind does not merely lack features: a consensus rule starts at a height (D-062), and old code reads the same block differently from everybody else. The people most likely to be behind are the ones least likely to be watching for a release.

D-066 — Every fetch says who it is (2026-09-16)

The signed manifest was published and the updater could not read it: HTTP 403, Cloudflare error 1010, "banned based on your browser's signature". curl got the same file with no trouble. The difference is the User-Agent -- Cloudflare's browser integrity check refuses Python-urllib/3.x, which is what urllib.request.urlopen sends when nobody sets one.

Every fetch this project makes now names itself. That is correct regardless of Cloudflare: a program that downloads software should say what it is.

What it means for machines already installed is worse than a bug in a new feature. Their updater sends the old header, so it cannot read the revision file and cannot download the archive -- an automatic update would have failed silently on every one of them, and a manual one fails with a 403 nobody could act on. Their way back is the installer: curl fetches install.py (unaffected, curl is not blocked), and a freshly downloaded install.py carries this fix.

Also worth Martin turning the zone setting off, so already-installed copies can update themselves rather than being reinstalled by hand.

And the likely answer to a question from this morning. Daniel's Windows install "did not work", and left a virtual environment with no application in it (D-054). install.py downloads the archive with urllib. If the zone was refusing that header then too, his install downloaded nothing, which is exactly the shape of what he was left with.

D-067 — Taking a price off the book (2026-09-16)

The book could be read and written and not traded on. D-062 made the engine accept a fill; this is the two wallets agreeing on one.

The taker presses Take and its node asks the maker's node for the one thing it cannot work out alone: which of the maker's outputs will carry the swap (D-063). The maker's node answers with an offer priced from its own order -- never from the question -- and locks that output. The taker's node checks the answer against two things that cannot both be forged: the note it wrote before it asked, and the order as its own index reads it off the chain. Then it signs its half and hands it back, and the maker's node countersigns and broadcasts, through the same countersign that has been carrying shop sales since D-038.

Three things are new and each is a refusal:

A bid is not filled this way. The coin side has to be funded by the wallet that holds the coins, so filling a bid is the token holder asking the bidder, which is the same conversation in the other direction and is not built yet.

D-068 — A release announces itself on the board (2026-09-16)

Automatic updates poll, and a poll is a compromise: every six hours is often enough to matter and rare enough not to hammer the site. A consensus rule that starts at a height does not care about that compromise. The machines that have not looked yet are exactly the ones that will read the block wrong, and today's near-miss was that shape -- the GX10 sat two releases behind the one that moved fills_from, believing activation was 1,496,000 while this node had moved it to 1,492,600.

So a release is announced on the public board, in a channel of its own, as one line of plain text: arcade-release <revision>. A node that sees one checks immediately instead of at its next poll.

The notice carries no authority. It names a revision and nothing else. What gets installed is decided by the signature on the manifest (D-065), so the worst a forged notice can do is make a node fetch a manifest it would have fetched anyway. That is the property that makes this safe, and it is worth stating because the obvious design -- "a message that tells nodes to update" -- would not be.

It is still checked: the sender must be the address that published @bigchiefenergy, read from the chain rather than pinned, so it moves when its holder moves it. That stops nuisance nudges, not attacks.

Announcing is self-selecting rather than configured. The node that holds the tag a notice must come from is the node that publishes releases, and it announces only a revision it is itself running -- telling other people to run something nobody has run is how a bad release reaches everybody at once.

D-069 — A guard that is right because of its caller is not a guard (2026-09-16)

Three findings from the GX10, reviewing the fill path before it was traded on.

build(..., from_order=...) checked one of four things. A fill skips the seller's free-balance check, because a resting ask's tokens are in its reserve. The skip tested leg.amount > from_order["reserved"] and nothing else -- not that the order sells that property, not that it belongs to the seller, not that it is the order this offer is for. It read as "some order holds enough units of something". Nothing could reach it: _fill is the only caller and fetches the order from its own note. But the discipline lived in the caller, and a guard that is correct only because of who calls it breaks the day somebody else calls it. All four are checked now, and the order a fill is for travels inside the offer so the check can be made against the offer rather than against the call site.

Eight watcher tests could not run. FakeState had no setting, which the watcher now asks before updating itself. The watcher was fine; its stand-in was not -- but the release went out with no passing watcher tests behind it, and the watcher is what notices the block a fill lands in.

A sandbox test failed in a way it could not distinguish from a breach. test_it_cannot_read_any_other_page_of_the_wallet asserts that a fetch of /guide from inside the frame is blocked. It got None: the collector waited for "seven of the possible eight" probes, wallet was the missing one, and the assertion read an absent key. So a probe that had not answered yet was indistinguishable from a sandbox that let the read through -- and the failure mode of an isolation test must never be ambiguous. Every asynchronous probe now says pending before it starts, and the collector waits for all eight with none pending.

The pattern in all three: each was found by someone reading the code and the test rather than the result. The suite was green on this machine for the first two, and the third had been green for weeks.

D-070 — A tag is a name for an address, everywhere (2026-09-16)

Two places took an @tag: sending an inscription, and addressing a message. Everything else wanted 34 characters of base58 -- coins, tokens, grants, revokes, and the address book itself, which is the one place whose whole job is remembering addresses so people do not have to.

One resolver now, used by every send. It turns @name into the address the chain says holds it, leaves anything else alone, and refuses by name -- "nobody holds @stranger on testnet" -- rather than by a base58 complaint about a string that was never meant to be one.

Resolved at the moment of sending and never remembered. A tag can move, and a wallet that pays yesterday's answer pays the wrong person.

The address book is the exception that proves the rule: it takes a tag in either address field and stores the address the tag named, not the tag. A book that kept the name would quietly follow it if it moved, and paying whoever holds a name today is not what somebody meant when they wrote it down last year. Every claimed name is offered as a completion while typing, which is what makes the field usable without making the book depend on the chain.

The token list shows an issuer's tag where there is one, with the address underneath in small type: an address is how the chain names an issuer, a tag is how a person does, and the page can show both.

D-071 — Where a piece has been (2026-09-16)

Martin asked whether an inscribed page can find out when an NFT first left the wallet that made it -- the mint date in the sense that matters for provenance. It could not, and the GX10 traced why rather than assuming: the inscription row holds the CURRENT owner and is overwritten on every transfer, so indexed state kept no history to serve. /r/tx/{txid} answers how deep a transaction is and nothing about who sent it, and none of the other twenty page endpoints exposes a transfer.

Every hand a piece changes in is written down now, as the move is applied: which piece, from whom, to whom, at what height, and whether it moved as a transfer or inside a swap. A swap counts -- provenance that recorded only transfers would miss every piece that was ever bought, which is most of the interesting ones. Keyed by the transaction, so replaying a block cannot double a row, and journalled, so a reorg unwinds it with everything else.

/r/inscription/{key}/history serves it, with left_creator answered directly rather than left for the page to work out: the first move whose sender is the creator is the question people are actually asking, and an API that makes every caller re-derive the same answer invites them to derive it differently.

Not in the consensus hash, and the GX10 called this right: these rows are derived from transactions the hash already covers, and adding a section changes every node's total for no gain in what is actually checked.

From here on, not backwards. The 106 inscriptions already on this chain have no history and will not grow one without a rescan. What Martin asked for is a fact about pieces as they trade, and a rescan to backfill six weeks of development traffic would cost more than it tells anybody.

D-072 — An address book that follows its people (2026-09-16)

A contact's @tag is read from the chain every time the page is drawn, never stored beside the name. So somebody who claims a new tag appears under it in everyone's address book, without telling anybody and without a single row in anybody's book changing.

That is the same rule as D-070 seen from the other end, and the pair is the point: the name follows, the address does not. What is stored is the address, so what a contact gets paid cannot change under them; what is shown is whatever the chain says today, so nobody is left calling somebody by a name they gave up. A book that stored the tag would have got this exactly backwards -- displaying a stale name and paying whoever holds it now.

Both of a contact's addresses are looked up now. It was the testnet one only, so anybody saved by their mainnet address alone went nameless.

D-073 — The history was recoverable after all (2026-09-16)

D-071 said "from here on, not backwards", on the grounds that a rescan to backfill six weeks of development traffic would cost more than it tells anybody. Two things made that wrong.

The GX10 corrected itself first: it had said the nodes run without -txindex, reading a comment in _tx_status rather than the machine. They do run with it -- the installer writes txindex=1 on both chains -- so reading an old transaction is a lookup, not a rescan. (The comment is corrected too. A comment that describes a configuration we do not write is a comment that will mislead somebody.)

The second is simpler and neither of us said it at the time: the rescan was never necessary. arcade_tx already keeps every protocol transaction this node has read and judged -- payload, sender, reference, height, position. The history can be replayed from rows that are already here. Ownership at each step falls out of the replay: a piece starts with its creator and each accepted move hands it on, which is the one fact no single row records.

Only a swap needs the node, for the buyer -- the first input that is not the seller's -- and that is where txindex earns its place.

It runs once, when the table is empty, from the watcher rather than the schema install, because it needs a node and a node that is not up yet must not delay the interface.

The first live run wrote four moves and reported success. There were fourteen. The rpc factory in the script that ran it raised AttributeError, a bare except Exception caught it, and every one of the ten swaps was skipped in silence -- so the run said "wrote 4" and nothing said "and could not read ten". The code was right; the invocation was broken and the silence was the bug. The GX10 caught it by replaying the same transactions its own way and getting thirteen, then saying so rather than assuming it had made a mistake.

For provenance a silent gap is not a missing answer, it is a wrong one: a piece that was bought reads as a piece that never moved. Every skip is logged now, with what raised and which transaction.

Run properly it rebuilt fourteen moves from 460 stored transactions -- four transfers and ten swaps -- and the replay reproduces the ledger's current owner for 109 of 109 pieces. That is the check worth having: the history is not merely plausible, it is the only trail that ends where the chain says everything is.

D-074 — Both names, and pages get them too (2026-09-16)

A piece's page showed the owner's @tag and the creator's bare address. The lists had been tag-aware for a while -- inscriptions, collections, the exchange -- so the detail page was the one place that had fallen out of the pattern, which is the usual way: the page written first is the page nobody revisits.

Both are looked up in one call, which also covers the case that is most of a collection most of the time: a piece still held by the wallet that made it, where the two addresses are the same one.

The same gap existed a layer down and matters more there. /r/inscription handed pages bare addresses, and a page cannot do anything about that -- its entire chain access is the block height, the block time and how deep a transaction is. An API that returns an address and no name has decided the page cannot show one. creatortag and ownertag come with every describe() now, null when nobody holds one, including in listings where one lookup serves the whole page.

With a caution in the field itself, because the GX10 spotted that the send page already carries it: a tag is read at this node's tip, and tags move. It is what an address is called today, not a name that belongs to a piece. Show it; do not store it against the piece. A page that caches one will be wrong eventually, and will be wrong in the direction of naming the wrong person.

D-075 — No fee warnings for coins that are free (2026-09-16)

The address book warned twice that something "costs a small fee": claiming a @tag, and publishing a key. The Overview said publishing "costs a few test coins". Both transactions are messaging transactions, and messaging is testnet only and always will be (D-010) -- a tag is claimed on the chain the messages are on (D-032), so it cannot be anywhere else either.

Warning somebody about the cost of something that costs test coins, which arrive free from a faucet, is noise dressed as caution. Noise is how a real warning stops being read: this application has warnings that matter -- a mainnet send, a permanent claim, an inscription that cannot be taken back -- and they are diminished by sitting beside three that do not.

The fee rows are gone from both previews as well. A number about nothing is still a number somebody has to read.

What is worth saying is still said, because it is about consequence rather than price: a key on the chain is public and permanent, and the first claim on a tag wins.

D-076 — One card, one button, one statement (2026-09-16)

Your @tag and your key on the chain were two cards with two buttons, and the split was the bug. A name is only half claimed until the key announcement says so too: the tag table binds the name to an address, the announcement tells every wallet reading announcements what that address calls itself. Somebody who changed their tag and stopped there stayed findable under a name they no longer held, and nothing told them.

One card, one button. Type a name, press it, and both go out: the claim, and the key saying it is yours. Unchanged name, and it is just the key -- which is what somebody publishing for the first time wants anyway.

Two things follow that were not obvious until they were built:

The announcement carries the name just claimed, not the one read back off the chain. The claim is not in a block yet when the announcement is built, so reading the chain would state the old name, and the two would disagree until somebody published a third time. The announcement is this wallet's statement; the claim is what makes it true.

And re-announcing is allowed when the name changed. Publishing the same key twice was refused, for good reason -- it is a permanent record and a second one says nothing new. A second one carrying a different name says the only thing that matters.

No preview step. Both transactions are testnet always, so there is no bill to show, and what is worth knowing about a claim -- permanent, first claim wins, and what letting go of it costs -- the card says before the button rather than after.

D-077 — An address book you can fill (2026-09-16)

Adding somebody meant pasting base58, which is an address book most people never fill. Every name claimed on the chain is searchable now: type part of one, press Add, and the entry is made.

Prefixes rank above the middle of a word and an exact match wins outright, because somebody typing "mar" means @martin far more often than @postmaster.

What is saved is the ADDRESS the name points at, as everywhere else (D-070, D-072). The card then shows whatever name that address holds today, so somebody who claims a different one appears under it without anybody's book changing and without what they get paid changing either.

D-078 — Automatic, but not slow (2026-09-16)

The update check ran every six hours, which was chosen against a picture of releases arriving weekly. On the day it shipped, nine went out in two hours, and every machine sat on whichever one it happened to have when it last looked -- including a node holding a consensus rule that had since moved, whose owner was twice told to update by hand. He asked the obvious question: I thought the updates were supposed to be automatic.

They were working exactly as built. The cadence was wrong, which is a different and more embarrassing failure than a bug.

Fifteen minutes now. It costs one small signed file per machine per quarter hour, and the board notice (D-068) remains the fast path: seconds, when there is something to hear. The poll is the floor, not the mechanism.

Two things worth keeping in view. A source checkout never auto-updates -- check() says it is not fetchable, which is right: it IS the source. And the notice path is currently inert here for a reason that is not the code's fault: it only acts on posts from whoever holds @bigchiefenergy, and the publishing node holds @notbigchiefenergy.

D-079 — The book holds people the chain can name (2026-09-16)

Martin's call: the address book takes only addresses that hold a published @tag, and anybody who wants to be in somebody's book claims one first.

The reasoning is the same one the book exists for. A name you typed beside a row of base58 is checkable by nobody, including you -- it is a note about who you think an address belongs to. A tag is a claim on the chain: it can be looked up, it can be disputed, and the wallet shows it beside the address every time. Requiring one turns the book from a private memo into a set of statements anybody can verify.

The refusal says what to do about it, because the person being added can fix it in one press on their own machine (D-076). Editing an entry saved before the rule still works: the check runs only when an address is being set, and locking somebody out of their own notes to enforce a rule about names would be a bad trade.

A mainnet address never holds a tag directly -- tags are claimed on the messaging chain -- so it is named through the announcement that binds both of a wallet's addresses to one key.

D-080 — A move is keyed by the pair, not the transaction (2026-09-16)

inscription_move was keyed on txid alone. One swap can hand over two pieces -- a Goofball for a Goofball is the trade the whole swap feature was built to make possible -- and _move_leg is then called twice for one transaction. The second insert collided.

Worse than a collision: it raised StateError, not InvalidTransaction, so it threw out of the handler instead of marking the transaction invalid. A single two-inscription swap on the chain would have stopped indexing at that height on every node that met one.

Keyed on (txid, inscription) now, and the journal registration widened to match -- the GX10 caught that second half, and without it a reorg would have unwound one leg of a two-piece swap and left the other. An installation with the old key drops the table rather than migrating it: every row is derived from transactions already read, so the backfill rebuilds the lot, and a rebuild from the source beats a rewrite of a table that was wrong.

D-081 — A schema that catches up (2026-09-16)

CREATE TABLE IF NOT EXISTS does nothing to a table it finds. So a column added to a SCHEMA reaches new installations and silently misses every existing one, and no test suite can see it: tests build their database from nothing every time. The newest install works and the oldest breaks, which is the worst way round.

It cost the first live fill. offer gained an order column; both machines had been running since before it; the taker paid a message fee to ask, and the maker's node answered "table offer has no column named order". SQLite did not even fail loudly on the way in -- a double-quoted name with no matching column is read as a STRING LITERAL, so four live rows sat there with the word "order" in them and nothing complained.

A sweep of every live database against every declared schema then found the mintpad's three columns missing from job in the same way.

So: add_missing_columns(conn, SCHEMA), which reads the declarations and adds what a present table lacks, run beside every executescript(SCHEMA) in the project. By reading the schema rather than by remembering to write a migration -- the remembering is the part that failed. Columns carrying PRIMARY KEY, UNIQUE or REFERENCES are skipped: SQLite cannot ALTER those in, and a table needing one needs rebuilding (D-080).

A test asserts every store that installs a schema runs the catch-up beside it, so the next column added is not the next outage.

D-082 — A swap says which order it fills (2026-09-16)

D-062 said a swap need not name the order it fills, because which orders are consumed is a pure function of indexed state. The first live fill between two machines proved that wrong, and it is worth writing down exactly how.

The trade worked. 333.33333333 Arcade Test for 4.00000000 coins, negotiated over messages, both wallets signing, one transaction at block 1,492,625, engine verdict valid, tokens where they should be. And the order it was taken from still advertised 500 at 0.012, reserve untouched.

Because _fills_for drew on the book only for what the free balance could not cover, and the maker held 980,801 loose. The engine could not tell a fill from an ordinary swap, so it took the easy tokens and left the advertisement standing. The book is decoration whenever a maker has stock, which is most of the time, and the same 500 could be taken again and again.

It is not derivable, and that is the finding. "Sold 333 at 0.012 while advertising 500 at 0.012" is produced byte-identically by a fill of the advertisement and by a private sale beside it. What separates them is what the taker asked for, and that never reaches the chain unless it is put there. Any derivation rule is a fixed guess at intent, and whichever way it guesses, the other intent is silently mis-executed.

The GX10 killed the obvious alternative. Drawing from the reserve first would have fixed this trade -- but the price guard admits every swap priced at or above the ask, so a maker selling 100 tokens at 0.05 in a private deal would have that swap eat their 0.012 public ask. Premium sale destroys cheap advertised liquidity the maker never meant to touch. That is the mirror of the bug, and worse, because it is unrecoverable rather than merely misleading. Narrowing to exact price equality does not work either: integer partials rarely land exactly, and a maker who wants the ask to survive shifts one satoshi.

So the swap carries the order's txid: 32 bytes after its two legs, and only a token-for-coins swap can carry them -- two inscription legs are already 66 of the 76 an OP_RETURN holds.

A preference, never a condition, which was the GX10's correction and is the most important line here. A swap naming an order that has gone, moved past its price, or emptied since the taker asked is still a valid swap. The coin leg settles on the Dogecoin layer whether this layer likes it or not, so making a stale name invalid would mean a taker paying real coins and receiving nothing. What it names is what it prefers; the trade stands either way.

The seller checks the name before signing, like every other term: a buyer naming a different order, or none, is asking for a private sale beside the advertisement rather than a fill of it. Same tokens, different thing.

Its own height, because it makes a payload legal that was invalid before. A swap naming nothing is encoded and read exactly as it was, so no history is reinterpreted -- including the swap that found this.

D-083 — Price, then time (2026-09-16)

Martin: several people selling at the same price should settle in the order they queued. That is what every order book means by fair, and it is the rule a maker relies on when they place an order behind somebody else's.

Two places had it wrong.

The book sorted the pool first. An unmined order has block_height 0, so a sort on (price, height, position) put the newest thing on the book ahead of orders that had been standing for hours -- time priority exactly backwards. Pending rows sort last within a price now, which is what they are: the newest of all, by definition, because they have not been mined.

A fill went to whichever row was pressed. Somebody pressing Take on the third row at a price is asking to buy at that price, not choosing which of three identical offers gets the trade. The fill now goes to the earliest order at that price or better with enough left.

One exception, and it is deliberate: an order whose maker this node cannot reach is skipped rather than queued behind. A fill is a negotiation -- the maker's node has to answer -- so an unreachable wallet cannot be traded with, and stopping the queue on it would let one silent maker block a price for everybody (D-042). Their order stands; it is simply not the one taken.

D-084 — A thing that runs on its own has to say when it ran (2026-09-16)

Martin asked twice why updates were not automatic. Both times they were running exactly as written, and both times neither of us could answer it from outside the process, because the only evidence was a debug log nobody reads. That is the actual defect: not the cadence, not the code, but that a background job had no way to account for itself.

Two changes.

It runs first, in its own hands. _auto_update was ninth in the tick, after the repairs, the scans, the ledger sync, the pool reads, the backfill and the release notices. Any of those raising took the update with it, and the outer catch logs at debug -- so a machine could go a week without checking and say nothing at all. An updater downstream of every other subsystem's health is not an updater. It now runs before any of them, wrapped on its own, and a failure there is a warning rather than a shrug.

It writes down what it decided, every pass, and the Overview shows it: when it last looked, what it concluded, what is installed and what is published, and the error if it could not reach the site. So "why is this not updating" has an answer on the screen instead of an argument.

Two truths that page now tells rather than hides: a source checkout never updates itself, because the checkout IS the source; and a machine that has just started has not checked yet, which looks identical to one that is broken until something says which it is.

D-085 — The notice path was inert for two reasons, both silent (2026-09-16)

A release announces itself on the board so other nodes check at once instead of at their next poll (D-068). It had never once fired, and neither reason announced itself.

my_tag() asked the wrong chain. It looked the address up on the ledger chain; tags are claimed on the chain the messages are on (D-032). So a wallet holding @notbigchiefenergy was told it held nothing -- and the gate on announcing is "do I hold the release tag", so the publishing node never announced.

The pinned tag named somebody who does not exist. RELEASE_TAG read "bigchiefenergy" because that is what Martin called it when he asked for the feature. The publishing node holds "notbigchiefenergy". A pin at a name nobody has is not a pin, it is an off switch nobody can see. It now names the tag that is actually claimed.

Both are the same failure in different clothes, and it is the one this whole evening has been about: a check that never passes looks exactly like a condition that never occurs. Neither logged anything, because neither was wrong in a way the code could notice -- "I hold no tag" and "that post is not from the tag holder" are both ordinary answers.

The Overview also still said updates were checked "every few hours" after the cadence became fifteen minutes (D-078). Copy that describes an old behaviour is a lie with a good conscience, and it is what Martin read when he asked why this was not faster.

D-086 — One authority for who holds a tag (2026-09-16)

The release notice still did nothing, and the reason is the sharpest of the evening: the two sides of the feature asked different tables who holds a tag.

The publisher asks the tag table -- the claim on the chain -- and announces. Every receiver asked its key_announcement rows, which carry the tag an announcement stated when it was made. Those disagree the moment somebody claims a different name: an announcement is a statement by the key holder, and a tag claim does not write that table. On the GX10 the announcement said bigchiefenergy and the tag table said notbigchiefenergy, both for the same address, and the receive side returned "nobody holds that" before it looked at a single post.

It could not be seen from the publishing machine. The half that works is the half the publisher runs. From here the notice goes out, the board shows it, and nothing ever says that no one acted. The GX10 found it by reporting that its node did nothing -- which is the whole argument for a second machine that talks back.

Both sides read the tag table now. An announcement's tag stays what it is -- a claim the wallet makes about itself, shown beside a name with a dispute marker when the chain disagrees (D-032) -- and it is not consulted for authority again.

One test-design note from the GX10 worth keeping: the notice named the revision the receiver already ran, so "acted" and "did nothing" both ended at up to date, and only the timestamp could tell them apart. A test where both outcomes look the same is not a test. The next one announces a release the receiver does not have.

D-087 — An acceleration mechanism cannot deliver its own repair (2026-09-16)

The GX10's finding, and it generalises past tonight.

c6f0f3e teaches the RECEIVER to resolve the release tag from the tag table. A node running the release before it cannot act on the notice that carries that fix, because acting on notices is the thing it cannot do. It waits for the ordinary poll like any other machine.

So: a repair to the receive side of an acceleration mechanism can never be accelerated by that mechanism. Its first delivery is always the slow path.

The corollary bites harder than the rule. Notice-based acceleration only ever reaches nodes already at or above the revision that made notices work, and the machines furthest behind are the least likely to carry a working receive path. The population that most needs accelerating is the population that can least hear you -- which inverts the reason the mechanism exists (D-062: the machines that have not looked yet are exactly the ones that will read the block wrong). The poll is not a fallback for the notice. It is the floor everything else stands on, and it is what must stay short.

A second corollary for test design, following the GX10's earlier one: "did the notice work" is answerable one release AFTER the fix, never on the release that contains it.

Pool rows count, which the GX10 asked about rather than assumed. A notice is acted on at height 0 -- broadcast, not mined -- which is a block sooner and is the point when a consensus height is close. Safe because the notice carries no authority: the sender is checked, and what installs is decided by the signature on the manifest, so a notice that never confirms costs one fetch of a file the node would have fetched anyway.

D-088 — The success case destroyed its own evidence (2026-09-16)

The GX10's node updated itself at 23:21:41 -- poll, not notice, nobody touching it, arcade-web restarting on its own. The first automatic update this project has ever done. And the journal said:

automatic update failed: Checking the commands are on your PATH
  already there
Checking cloudflared
      cloudflared 2026.9.1 is already installed

That "error" is the updater's progress output. The mechanism: _auto_update runs python -m arcade.update as a subprocess; that subprocess restarts arcade-web; systemd stops the parent and everything in its cgroup; the subprocess is killed mid-flight; the return code is non-zero; the parent logs a failure, writes "the update failed" -- and dies. The restart then wipes the in-memory status, and the new process's own first check writes "up to date" over it.

So the update worked, the log said it failed, and the page showed neither. D-084 exists to make a background job able to account for itself, and the one outcome a person most wants to see is the one that could not survive to be shown: the success case ends the process that would have recorded it.

Two changes, and both are the same idea.

The outcome is read from the installation, not the exit status. What is on disk after the subprocess is what happened. A process killed by the restart it caused is not a failure; a process that exits cleanly having installed nothing is.

The status is written to disk before the subprocess runs and read back on start. A record held only in memory cannot survive the event it exists to describe.

The general form, which is the fourth version of tonight's lesson: any job whose success restarts its own process must record intent before acting and judge by the world afterwards. Return codes describe the child; the installation describes the outcome.

D-089 — Every watcher phase in its own hands (2026-09-16)

Release announcements stopped. Three went out, then nothing for bb1fe5b -- and the announce code was unchanged, the tag was right, installed == published was true when checked by hand.

_tick ran its thirteen phases in a bare sequence. The first to raise took every phase after it, and the outer catch logs at debug -- so a subsystem switched itself off and nothing said so, on a machine that two agents were watching closely.

D-084 moved the update to the front of that sequence and wrapped it, which fixed the update and left the same trap for everything downstream of whatever broke. The right answer is that no phase can take the others down. Each runs in its own try now, and a failure is a warning naming the phase rather than a debug line naming nothing.

The general form, and it is the fifth version of tonight's lesson: a sequence of independent jobs sharing one exception boundary is a sequence where the first failure is the last thing that runs. The cost of finding that out is proportional to how quietly it happens.

D-090 — The line that had never run (2026-09-17)

_check_release_notices threw AttributeError: 'sqlite3.Row' object has no attribute 'get' on its first line, every tick, 36 times in three minutes. group_posts returns sqlite3.Row, which indexes but has no .get.

The tag fix did not break it. It unmasked it. For the whole life of that function the tag lookup answered "nobody holds that" and it returned before the loop, so the line had never once executed. D-086 made the lookup resolve; the loop ran for the first time and threw immediately. Three notices went out under a runtime where who was still empty and the loop was unreachable -- that is why announcements looked like they worked and then stopped.

D-089 contained the blast radius and called the cause "something upstream began raising". This is it, and the containment is what made it findable rather than fatal: the phase now fails on its own instead of taking the shopkeeper and the gather with it.

1,247 tests said nothing. Every test of this feature covered the lookup -- revision_in, address_for_tag, my_tag, "both sides ask the same table" -- with fakes. Nothing called the function with posts from a real store, so the code the fix unlocked was executed by no test at all. The GX10 named the missing one exactly: write a post through the real store, run the function, assert the poll was reset. It is written, and it fails with the old line.

The lesson, which is the sixth of these and the sharpest: a fix that makes an unreachable path reachable is a new code path shipping without tests. The lookup got a test because it was what changed. The loop did not, because it had not changed in years -- and it had never run.

D-091 — Routes an inscription declares (2026-09-17)

Martin wants two NFTs to interact: two creatures held by different people, battling in a third inscription that is an arena. It needs their power, their life, their stats, and whether each player is ready.

Declared, never executed. An inscription's JSON may carry an api block naming routes and where each answer lives, and the node is a fixed interpreter over that. The alternative -- an inscription shipping code that a stranger's node runs beside their wallet -- is remote code execution on somebody else's machine, and no sandbox written in an evening makes it safe. Every verb here is a lookup: a path into the piece's own JSON, a key the page saved through arcade.storage, its owner, its number, its collection, a constant.

The declaration is inscribed, which settles three things at once. What a piece will answer is public, so a caller can read the spec before spending a message on it. It is permanent, so a holder cannot quietly widen it. And it was written by whoever made the piece, not whoever holds it today -- which is what makes an answer worth anything to the other side of a battle.

A node answers on the inscription's behalf, with nobody at the screen. That is the whole point: two pieces can interact while one of their owners is asleep. Held here, the answer is immediate and free; held elsewhere, the question goes as a sealed node-to-node message and the answer comes back through the transport shops already use.

No writes. There is no verb that moves anything, and there will not be: a page that wants to send a token or mint a piece files an approval and a person says yes. Answers carry information, the chain carries consequences -- the same line D-082 drew for fills.

Which is also the design advice for the battle, and it is not a limitation of this feature but of trust: base stats belong in the inscription, where both sides can read them off the chain without asking anybody. arcade.storage is private to one machine, so a power level kept there can be set to 9999 by its owner. Storage is for what is only yours -- a toggle, a preference, whose turn it is. Earned stats that both sides must believe belong on the chain.

D-092 — A page may write an inscription, not only move one (2026-09-17)

The page API had four kinds and inscription among them meant transfer: it validated that a piece already existed and that this wallet held it. A page could hand a Goofball over and could not make anything. For the arena Martin described that is the reward half -- a trophy to the winner, a creature from breeding -- so it is now a fifth kind, mint.

It is unlike the other four in three ways, and each shows in the design.

The content is the thing being approved. Everything else names something already on the chain; a mint carries bytes. So the request row holds them from the moment it is filed, and what the approver sees is size, content type, transaction count and cost -- "inscribe this" tells a person nothing they can weigh, and the bill depends entirely on the content.

It can half-happen. A send lands or does not. A chunked inscription that stops partway has spent outputs that cannot be recovered and leaves bytes on the chain nobody can read. So approving one does not broadcast a transaction: it starts the wallet's own inscriber, with the progress and resume the Wallet page already has.

A page can file it, so it needs a ceiling. 100 KB, which is not a protocol limit -- the wallet's inscriber handles far more -- but a request body arriving over HTTP from a sandboxed frame, and a page that can file a five-megabyte question can fill a queue with them. Bigger belongs in the Wallet, where a person chose the file.

D-093 — One revision remembered against many posts (2026-09-17)

_release_seen held a single revision while a board holds several, so the newest-first walk alternated: take the newest, stop; next pass skip it, take the second, stop; next pass the first looks unseen again. Every tick found something "new", every tick reset the poll, and _auto_update is the first phase of the next tick -- about twelve fetches of source.rev a minute from one node, and it would have been every node with more than one notice.

"Often enough to matter, rare enough not to hammer the site" (D-078) was neither.

A high-water mark on the post id replaces it: only posts above the mark are considered, and the mark only moves forward. It cannot alternate, cannot grow, and cannot be confused by a re-read.

It also invalidated the test we had been waiting for. The discriminator was "the check timestamp moves within seconds of the board post rather than at the quarter hour" -- and with this loop it moved within seconds of everything, so a notice-triggered check and the bug were indistinguishable. A positive result would have proved nothing. That is the third time tonight that a measurement was worth less than it looked, and the GX10 caught all three.

Fourth time, too, that removing a suppressor exposed code that had never run: the AttributeError of D-090 aborted the loop before a second iteration could ever matter.

And the notice line is a warning now rather than info. It fires once per release, and info from this service does not reach the journal at all on the GX10 -- so the one event worth diagnosing from outside was the one leaving no trace.

D-094 — Silence becomes a refusal (2026-09-17)

The GX10 pointed at the state this node was actually in: five orders resting on the book, 1,500 Arcade Test reserved behind them, and a wallet that had not emitted anything on chain for an hour because its service was running dead code. A taker asking for one of those orders would have got silence -- not a refusal, not an error, nothing. The note would sit at "waiting for their node" for ever, and the book would go on advertising what could not be traded.

An order stands whether or not the wallet behind it is running. That is correct -- it is a chain fact, and a node being down does not withdraw a price -- but it means the taker's side has to be the one that notices.

A fill nobody answers is closed as unanswered after the offer's own lifetime, with what to do about it: the order may still be there, so try again or take another at the same price. Silence is the one answer a person cannot act on.

Paged before it filtered, in the first version. fills() is newest-first with a limit of fifty, for showing somebody what they asked for, and expiring through it meant an old note fell off the end of the page and was never examined again -- permanently stuck in the state this exists to abolish. It survives only in a wallet with more than fifty notes: the long-lived, heavily-traded one, which is the wallet least able to afford it and the one no test with a handful of rows can reach. The offers path never had this because open_offers filters by status before paging. The fill path now asks for candidates rather than for recent history.

This compounds with D-082 in a way worth naming: an order that does not shrink when filled, resting on a node that may not be able to countersign, and neither visible to anybody reading the book. Nothing was lost, because nobody tried. It is the shape not to meet during real trading.

D-095 — The publishing node runs the oldest code (2026-09-17)

Four releases went out tonight while the machine that published them ran code from before the first of them. Its service had not restarted since 23:22, so every fix shipped that night was running on the GX10 and not here -- and the chain shows it exactly: the last transaction of any kind from this wallet is the 23:20:36 announcement, then sixty-four minutes of silence.

The reason is structural rather than an oversight. A source checkout never auto-updates, and that is right: the checkout IS the source, so there is nothing to fetch. It collects its own fixes only when a human restarts it, while every other node has them within fifteen minutes.

Which inverts the usual assumption. "It works on the machine I published from" is the weakest evidence available, not the strongest. The publisher is the node most likely to be running something nobody else is.

The operational form, which the GX10 wrote into its own notes: when a feature works on the receiving node and not the publishing one, check the publisher's ActiveEnterTimestamp before debugging the feature at all.

And it is the fourth time tonight the same law bit: the notice path could not deliver its own fix (D-087), the updater could not report its own honesty (D-088), removing a crash exposed a loop that had never iterated (D-090, D-093), and the publisher cannot run what it publishes. Every one of them looked like success from at least one machine.