Changelog
Short commit hashes cited in this repository refer to the pre-publication development history, which is not public. This public history starts at the import of 2026-10-01.
unreleased — 2026-10-01, the rehearsal, the site, and the first two products
- Incident rehearsal T14 ran (spend/soak/README incident #5, INCIDENT.md; timeline in the operator's
drill directory): detect → wallet-rpc down 1 min 41 s → last sweep to the vault 15 min 26 s; the vault
spendable 35 min after detect; a NEW keeper live on this tree 36 min after detect; its first payment
70 min after detect (the swept-back funds sat in one output, locked 20 min per spend). All 592 lines
of the burnt keeper's log verify. Fees 0.0158 XMR, 17 % of the wallet:
sweep_allat priority 4 and 3 refused 182 small outputs, priority 2 took them; 0.00003638 XMR of dust is unsweepable. The freeze failed as written:systemctl --user mask --runtimeis overridden by unit files in~/.config/systemd/user, soRestart=alwaysrevived the burnt keeper for 23 s (no request reached it; the log stayed byte-identical). Fixed byspend/soak/freeze.sh/unfreeze.sh(c38b9e3): aRestart=nodrop-in,daemon-reload,kill -9, then an assertion that the unit stays down andspend.locksurvives — proven on a scratch unit against the old way. Also from the drill (1b574ac): the vault's wallet-rpc needs--no-initial-syncbeforerefresh {start_height}can be sent;gen-keys.mjsno longer rewrites the tracked policy template and now mints roots thatsigelo-offline recoveraccepts (the old soak's randomS.hexwas not recoverable); steps 5–6 are a concrete per-identityrecoverprocedure. Open spec gap: the keeper's own DID cannot be recovered — its recovery key derives fromspend.key, which the thief holds (ROADMAP §5.6). The live soak now runs this tree (REVISION c2c9cdc at 13:56Z) and rode through a second host network outage the same afternoon: it paid on the first tick after the network returned. - The site (
site/,0a4381f,4bf622b,3c2d364): sigelo.io as a static, agent-first site generated from the tree — 17 pages each with a.mdtwin andrel=alternate,llms.txt,llms-full.txt,adopt.md,robots.txtnaming the AI crawlers,sitemap.xml,.well-known/security.txt,index.jsonwith the spec and vector sha256s, JSON-LD on every page, raw and content-addressed copies of the docs, the mock world served;integrations/mcp/server.json. Its own test: 255 checks, andsite/test/TASK-site.md— a Haiku agent given only the served site and the release files installed from the tarballs, checked SHA256SUMS, made a tier-2 identity, got attested by the served world and produced a bundle the release verifier accepts: 6/6 in 97 s (docs-test/RESULTS.md). Not deployed: hosting and the maintainer's name wait on D1. - Recovery ceremony kit (
kit/,ad970a5; product page3c2d364): the offline high-security tier as a product an operator runs alone —ceremony/run.shrefuses to run online, runsceremony --human(words to the terminal only, proven on a pty), writesbackup.ageand a secret-free record; the printed procedure (ceremony, drill, runbook bound to the operator's paths bybind.mjs, rendered byprint.sh);drill/schedule.mjsandgrade.mjs(the T14 timeline scores 100/100);CONTACT.md. The vendor never receives, holds or sees any key, seed, backup or share — boxed on every page (MiCA: software and facilitation, no custody). Packagesigelo-recovery-kit0.1.0, the fifth release artefact. 25 + 32 checks (the drill and runbook pages re-synced to the rehearsal's fixes and the licence reissue step,9f75a7d). - Keeper installer and tiers (
17e7787,eb9f428):sigelo-spend initsets up one keeper on the operator's own host and wallet-rpc (optionally writing the wallet-rpc unit, loopback,--rpc-login),doctorchecks the install,receipts exportandlicence show|install. Free tier: one keeper, one agent, that agent's whole policy. Paid verbs —/delegate,/fund,/approve, a pay aboveapproval_above, receipts export, a secondiniton the host — refuse403 licence_requiredwithout a licence;/revokeand/delegatesare never gated; an expired licence stops only the paid verbs. The licence is a sigelo attestation issued by the vendor DID to the keeper's DID (claims {tier, seats},exp), verified offline by the repo's own verifier; no network call anywhere; no wire change. No hosted mode exists or will. spend: 659 → 725 passed with the live wallet; pack-test 20 checks over five packages; SHA256SUMS identical across two builds. - A keeper's own DID is recoverable (
164b8c4,cc0a83c; closes the gap the rehearsal found).sigelo-spend initwrites the keeper genesis to a publicidentity.jsoncommitting to the Owner's root recovery key (k(S, "sigelo/v1/recovery/ed25519"), the key every agent already uses; from the ceremony'skeeper-<j>.jsonvia--keeper-package, or--recovery-commitment), and refuses a genesis whose recovery would derive fromspend.keyor be null. After a compromise:sigelo-offline recover --genesis identity.json <root> --new-keeper …offline, thensigelo-spend init --adopt recovered.jsonon the new host serves the samechain[0]DID that receipts, approvals and the licence name; the thief's later voluntary rotation loses (SPEC §7). Proven end to end in the spend suite with ts and Go agreeing. No wire change: every genesis already carries a recovery commitment; which key it names is key management. Keepers keyed before this (the live soak's) stay legacy and unrecoverable until their next rekey. ts 620 → 628, spend 725 → 745. - The soak is a paying customer of its own keeper (
5b4a488):gen-keys.mjsmints a soak-local TEST VENDOR and issueskeeper/licence.json(tier pro, 4 seats, 400 days) to the keeper's DID;setup.shsetsSIGELO_VENDOR_DIDin a unit drop-in;check.mjsreports UNHEALTHY under 30 days to expiry. Proven in a scratch keeper:/delegate200 with the licence, 403licence_requiredwithout. The soak now exercises the paid tier end to end; a real deployment gets its licence from the vendor at D1. - The installed MCP plugin (
integrations/mcp, as installed on the maintainer's host on 09-24) was exercised live from a Claude Code session:whoami→bundle→verify, and the releasesigelo-verifyat c2c9cdc accepts the plugin-made bundle identically. - Round 3b (docs-test/RESULTS.md). The Haiku lifecycle run repeated on the docs of
9516622, aftera795275gave QUICKSTART the exacted25519-testbind()call, a Timestamps paragraph and the recovery-beats-iatworked example: 10/10 (7/10 two days earlier on the same task). The R3 bar (≥ 9/10 at Haiku tier) is met with Claude models; non-Claude families still wait on API keys. wallet_slow(spend/,98ea994). Soak tick 5: atransferbuild took 152 s on the slow daemon link (decoy selection; the same delegate's next build took 9 s), the keeper's 60 s wait ran out and it answered the genericwallet; nothing was relayed — the build isdo_not_relay,relay_txis called only with metadata the keeper received and only after the fsyncedintentline, and a repeat is answered from the log — so fail-closed held, but the code was wrong and a tick was wasted. Builds (transfer,sweep_all) now wait 180 s, every other call 60 s, and a build that outlives its wait answersTRY LATER wallet_slow("the wallet is still working on that payment; nothing was sent"). Tests: the late build is never relayed, the repeat pays once, a third run is ALREADY PAID, arelay_txtimeout stays UNCERTAIN. spend 745 → 756.- Contact surface (
a52163e,17477fb)./contacton the site (one maintainer,contact@sigelo.io, SimpleX on public relays, security reports to/security; no form by design), the SimpleX address as one constant feeding the page,security.txt,index.jsonand the JSON-LDContactPoint, omitted everywhere until it is set so no placeholder ships; both mailboxes exist and are read. site 272 checks. - Public since 2026-10-01. First push:
https://github.com/csigelo/sigelo, mainbca5b16(the export of13e3b12).release/publish.shnow has an update mode (8a024b3): against the pushed clone it builds, gates and tests the export in a temp tree and commits onesync:commit on top ofmain; fresh mode is refused on a clone with a remote. The first GitHub run failed in three places, all fixed from a clone of the public repository (1bcb01f,0fbd592,1cde665,268111d): two crosscheck result files had no final newline, which the portability line-ending check read as "no line ending" on every OS (the check now fails only on CRLF or mixed endings, in index or checkout);pack-test.shhonoured a runner'sXDG_CONFIG_HOMEand so looked for the identity outside its test home; the actions were pinned to Node 20 versions (now v7, by sha). TheDEVICE_STRINGSidentity gate fails loudly when the secret is missing in this repository and reports hits as file and count only; it is its own job so it never hides the other results. Thespendstep of thetsjob failed on the runner and passes here under the same node, npm and shell; it now annotates its FAIL lines so the next run names the check. macOS and Windows are still unproven at runtime. - sigelo.io is live (
site/deploy/, deployed 2026-10-01 from the export): one Let's Encrypt certificate forsigelo.io,www,sigelo.netand itswww(the.netnames 301 to the apex), nginx with the security headers, no client IPs in logs, HSTS at five minutes for the first week; the server is key-only SSH with root login off, fail2ban, a firewall and unattended upgrades. - Deploy tooling (
site/deploy/,ca39b0e).server-setup.shonce as root (nginx + certbot, thesigelouser, TLS 1.2+, HSTS starting at 5 minutes, CSPdefault-src 'self',www→ apex, no client IPs in logs, 7-day rotation),deploy.sh user@host(builds, runs the 272 checks, uploads with rsync ortar | ssh, swaps atomically, keepsdist.prev,--rollback,--dry-run),check.mjsagainst the live origin (45 checks incl. headers, content types, sha256s and the build commit), and a sudo-limited helper so the deploy key never installs an arbitrary nginx config. Deploy from the public export, never from the private tree:index.jsonrecords the build commit. Tested locally end to end through a fake ssh; certbot andnginx -tneed the server. - Drill cadence. The kit's
schedule.mjsinstalled the quarterly reminder on the test host; the next drill is 2027-01-01. - D1, most of it decided (
8cde486,f85a42a,724a903): the domain is sigelo.io, the account csigelo (https://github.com/csigelo/sigelo, Go modulegithub.com/csigelo/sigelo/goat export, MCP registry nameio.github.csigelo/sigelo), the e-mail contact@sigelo.io (reports staysecurity@sigelo.io); the pseudonym is the handle. The site linked toblob/master/…while the export createsmain— every repository link would have 404'd after the first push; fixed. Still open: the age recipient and SimpleX address for disclosure, the vendor DID ceremony for licences, prices and hours on the kit and keeper pages, hosting and DNS.
spend 659 → 745 passed with the live wallet. Code lines: spend/service.ts 1 037, spend/cli.ts 126,
spend/init.ts 494, spend/licence.ts 84, ts/src/ceremony.ts 169, ts/src/offline.ts 167 (CLAUDE.md).
unreleased — 2026-09-29 (evening), round 3 on today's docs, the one-seed ceremony, Carrot, and a stranger's clone
- Round 3 of the comprehension harness (docs-test/RESULTS.md), docs frozen at
9dadab0(docs-test/snapshot/9dadab0, lifecycle digestff3639074728bd09…, verifier469da8a3c59fe898…). Lifecycle from the docs alone: Sonnet 10/10, Haiku 7/10 (its binding was cross-signed but datednow + 3600, so §9 step 6 discarded it; and its thief's rotation carried an earlieriatthan the recovery, the inverse of the task — it spaced everyiatlike a story) — the ≥ 9/10 Haiku bar (ROADMAP R3) is still not met. Both trace to facts the docs stated only as permissions in SPEC:a795275gives QUICKSTART the exacted25519-testbind({… addr_secret, iat: now})call, a Timestamps paragraph (a future-dated binding is discarded, never pass a laternow), and "recovery beatsiat" worked at T and T+3600; ts/README's API notes say the same. To be re-measured at the next round. A verifier from SPEC and vectors alone, in Python (1 033 lines, PyNaCl + pycryptodome, Edwards arithmetic by hand): 139/139 ongrade-verifier.mjsand 139/139 onsigelo-verify --conformance --impl, first iteration, no spec finding. Perl was the second candidate language and is not possible on the test host (no Ed25519 library, no compiler). Every run was a Claude model; the non-Claude runs still wait on API keys. Transcripts were audited for reads outside the allowed files: none. - M4, the human's seed (
278e87c,29a0402).ceremony --humanwrites the 25 words to/dev/ttyonly, afterbackup.ageexists, and refuses without a terminal;restore --words <file|->takes them from a file or stdin, never the command line, and reproduces--backupbyte for byte;ceremony --importof an existing seed is refused unless--i-know-this-seed-was-cold, with the liability printed. The backup field staysmnemonic(sigelo-root/2since feb7e96). "Vault only, never a hot wallet" is on every ceremony screen. - M6, FCMP++/Carrot (
085bf06, ROADMAP §2). The 25-word legacy root survives: Carrot keeps thea = H_s(b)hierarchy, subaddresses and address format for sending and receiving, the reference wallet still restores only legacy seeds, and no wallet-rpc method sigelo calls changed. Re-check when seraphis-migration#306 (the new-wallet seed format) closes, whencarrot_implmerges, or when a mainnet height is set. Follow-ups applied (ba825f3,b5eeadd,4daef77,9241e04,cad0357): the wallet-rpc canary pinned version 1.30 exactly and so already failed on the current release 1.31 — it accepts 1.30–1.33 now, with the ceiling to move after an oracle run; the unusedget_tx_key: trueis gone; Polyseed is in monero core since 2026-09-20 (PR #10765) and is still not used (it cannot carryS); the promise reads "any Monero wallet that restores a 25-word (legacy) seed";get_tx_proofis flagged not yet functional on the FCMP++ stressnet. - A stranger's clone of the public export (49c585a). Everything ran green from the
single-commit clone in a private network namespace: every suite,
build.shtwice with identical SHA256SUMS,pack-test.sh, the sim (all three digests),crosscheck/run.shwith--self-test, QUICKSTART tiers 1 and 2,--impl139/139. Fixed from its findings (5e96d29,77a7be4,78878b6,da03f18): every suite count in the READMEs and ROADMAP re-measured (conformance 242,go test498,--impl139,--break136/139, pack-test 16 + its own build, ts 620, spend 659); the soak policy template held two real stagenet subaddresses andts/test/monero-vectors.jsonheld oracle signatures from the same wallet — placeholders now, filled bygen-keys.mjs policy, and the vectors re-signed from a scratch wallet; QUICKSTART's walkthrough files andworld.lockare ignored, crosscheck runs write to an ignoredresults/latest/; device and desktop specifics in the docs replaced by "the test host".
ts 595 → 620, spend 652 → 659, go 498. Code lines: ts/src/ceremony.ts 150, ts/src/offline.ts 150,
spend/service.ts 974 (CLAUDE.md).
unreleased — 2026-09-29, the readiness bar: six gaps closed in one day
wire: an envelope has exactly its defined keys (bcee912, SPEC §3.1; vectors
attestation_good_and_envelope_extra_keys, binding_envelope_extra_key,
binding_envelope_sig_id_only_unproven, fatal_rotation_envelope_extra_key;
attestation_envelope_extra_key_int flips from accepted to discarded). The 30-day freeze count
runs from this change.
ROADMAP §1 was re-measured against the tree (d07f077) and every gap that needed no Owner decision
was closed the same day, each by its own agent, each proven before its commit. One wire change (the
envelope rule above); the spend keeper's formats are unchanged; the live soak still runs 6df3b67.
Tokens re-read per request (spend/,
37190c5). The simulation's live-policy scenario showedtoken newprinting "the old token no longer works" while the old token kept paying and the new one got 401 until a restart, so a leaked token outlived the command meant to kill it. The keeper now checks policy.json's mtime on every request and, when it changed, validates the whole file but takes only the token hashes of roots it already serves: the old token is refused and the new one pays with no restart. Everything else in the policy still needs a restart, including a new root — MONERO.md §6 now ends that procedure with one, and the CLI says exactly this. A malformed file, or one that gives two roots the same hash, keeps the last good tokens and logs a warning.Envelopes have exactly their defined keys (SPEC §3.1,
bcee912, wire). Both verifiers accepted unknown keys in an envelope — outside the signature, so unauthenticated — while unknown body fields were refused; a third implementation could have read §3.1 either way. Now an extra key discards an attestation or binding and is fatal in a rotation envelope, in ts and go alike; the schemas refuse it and the moadim adapter stores only body and sig. The 1f916 bare-served attestation the sim planted is now discarded by rule, not by a hash mismatch.Two behaviours named (
5cde1ac). A duplicated attestation counts twice (SPEC §9.1: the verifier reports what it was given, invariant 8, as it does for rotations); a retired world key keeps minting valid attestations under its old DID until they expire (SPEC §5, THREAT-MODEL §3.2).Keeper daemon fallback (spend/,
e9b4970).SIGELO_DAEMONSis an ordered list of daemon addresses. After three wallet-rpc builds in a row with "no connection to daemon", or 20 minutes of a stuck height while asked to pay, the keeper callsset_daemon(untrusted) on the next one — never on one failure, never more than once a minute, one warning line and no spend.log line. With fewer than two addresses nothing changes. 23 tests on the fake wallet.Soak alerts (
246fd66) and the clock at boot (4ec79b2).check.mjs --notifywritessoak-alerts.logand raises a critical desktop notification when UNHEALTHY;sigelo-soak-check.timerruns it hourly. The agent unit runssystemd-time-wait-sync(10 s) as anExecCondition, so a tick is skipped, not run, until NTP has set the clock —After=time-sync.targetorders nothing in a--userunit (the keeper started at "2026-01-11" on 09-26). The keeper unit sets four stagenet nodes asSIGELO_DAEMONS. Nothing was installed or enabled on the soak host.Third-party cross-check (
crosscheck/,0a17aa3, T12). JCS against the RFC 8785 reference (6/8 testdata byte-equal, the two float files refused as §3 requires; 20k documents, 100k integers, 100k doubles equal; 1 800 forbidden inputs rejected), base58/addresses/25-word seeds against monero-python (10k/20k, 5k wallets, 2.5k subaddresses, 20k address checks, 4k/8k seeds, ts and go), Ed25519 against libsodium, RFC 8032, Wycheproof 151/151 and speccheck 12/12: 0 divergences.run.sh --self-testcorrupts sigelo's answers and every section catches it. monero-python itself departs from Monero's C++ three times (2^64 base58 block, word triples ≥ 2^32, no curve check); sigelo sides with the C++. Not covered: SigV2 (no oracle here).Simulation gaps (
sim/,ecb7f17,4d14292,ddff500). Dishonest worlds: 400 bundles over 36 SPEC-cited cases (future and backdatediat, small-order issuer keys, reused nonces, unchallenged and rotated-away DIDs, forged issuers, replays, a collusion ring) — ts and go agree on all 400, 13/13 mutants killed, one (future iat accepted) invisible to the old swarm. Delegates of delegates to depth 4 with a revoked middle delegate (7/7 planted bugs caught). policy.json edited under a running keeper: 78/78 steps; the policy is read at start only, as documented, and two docs promised otherwise —token new's "the old token no longer works" and MONERO.md §6's new root without a restart (fix below, G7). The 1f916 adapter runs as a world; serving its genesis bare again makes every bundle fatal in both verifiers.npm run sim5 min;npm run plants10 min.Installable packages (
20fd24e,7cdf4ea, T4 code side). Nofile:dependency in a packed manifest (release/prepack.mjs), bins fromdist/, LICENSE in each tarball,SIGELO_PUBLISH=1guard.release/build.shbuilds from a clean clone of HEAD: 4 tarballs, staticsigelo-verifyfor five targets, a Go source archive, SHA256SUMS identical across two builds.release/pack-test.shinstalls them where no clone is: 17/17. Publishing waits on D1.Conformance for foreign implementations (
c4cef7d,4e1a5aa, T7).sigelo-verify --conformance --impl '<command>'runs a candidate over all 135 bundle cases withgrade-verifier.mjs's protocol and exits 1 on any FAIL: Go as its own candidate 135/135, ts 135/135, a broken wrapper 133/135. A weekly CI canary runsspend/canary.tsagainst the pinned and the latest monero-wallet-rpc (unrun on GitHub yet).Docs measured (
d07f077,81a6500,327e266,7cefd5d). ROADMAP §1/§6 re-stated with a commit per claim; the 1f916 proposal says upstream's repository answers 404 and1eedaddcame from public forks; THREAT-MODEL §3.7a and INCIDENT §1 cover a wrong clock and an offline host.
ts 591 → 595, go 494 → 498, spend 617 → 652 passed with the live wallet. Code lines: ts/src/sigelo.ts 364,
go/sigelo.go 383, spend/service.ts 975 (CLAUDE.md).
unreleased — 2026-09-29, soak incident #4: 32 h offline, and the clock
The test host lost its network for 32 h because its network manager stopped auto-connecting after a failed handshake and nothing could re-authorise it unattended. The keeper failed closed — nothing was sent, every line verifies — but the tooling around it did not see the outage, and the investigation found a latent wrong-clock risk. Keeper wire formats (receipts, spend-approvals, log lines) are unchanged; no error code was renamed.
- Clock guard (spend/). Every route that signs (
/pay,/fund,/delegate,/revoke,/approve,/bind) refuses503 clock_behind— a TRY LATER, nothing signed, logged or sent — when the keeper's clock is before its build floor (2026-09-29) or more than 300 s behind the newesttsit signed in spend.log. The test host's clock boots at 2026-01 (its build epoch) until NTP; without a network the keeper would have signed lines with Januarytsthat fall out of every cap window once the clock is right: spends that never counted, in a log that cannot be corrected. wallet_offline(spend/). A wallet-rpc "no connection to daemon" is its own TRY LATER code and line ("the wallet has no connection to the Monero network"), not the genericwallet: for 32 h the agent could not tell the host's network from a wallet fault. relay_tx failures stay UNCERTAIN whatever their text.- Wallet store (spend/). After every relay_tx, once the answer is sent and still in the lane,
the keeper calls wallet-rpc
store; a failure is a warning and never changes the verdict. The soak wallet file was 12 days stale (every stop a crash), so each boot rescanned for 18 minutes.close()waits for the lane. - Soak agent. Snapshot timeout 30 s → 90 s (it collided with wallet-rpc's 30 s refresh-retry).
A tick whose wallet height did not advance, or where the keeper said
wallet_offline, is an outage tick: an expected-success TRY LATER there isoutage, not MISMATCH (outage_ticks), and a carol rent given up after 3 h of nothing but outage TRY LATERs isabandoned_offline. The 6 mismatches of this outage were the keeper being right. - Soak check. UNHEALTHY with no payment in 2 h, a wallet height stuck across the last two
snapshots, or a log clock in the future or stepping back — it said HEALTHY for all 32 h.
--evidencechecks a burnt directory without asking the live host. - Soak README. Incident #4 and the wrong-clock note; the T14 rehearsal fixed where the step-0
run found it wrong (the wallet-rpc is a systemd unit, gen-keys before setup, the vault's creation
command and seed warning, sweep back to the allowance address after 10 confirmations, step 7
with
--evidence).
spend: 592 → 617 passed with the live wallet (559 → 584 + 2 skipped without it).
unreleased — 2026-09-24, hostile-JSON differential (ts vs go)
wire: D1 nesting depth 512 (425d2bb), S1 created format (bc7165a), S2 canonical curve points
(731483b), S3 commitment format (63cceed), S4 nonce format (c5faf69), S5 noncharacters (001ecfa);
vectors named in each bullet below. Recorded here 2026-09-29 to satisfy VERSIONING.md §1; the 30-day
freeze count (ROADMAP R6) runs from the last of these.
A differential of 1 805 hostile documents through both verifiers found three verdict divergences, four message-level ones, two slow paths and five gaps against SPEC. Closed here, each in both implementations with the same reason text, a SPEC MUST and vectors:
- D1 Nesting depth is bounded at 512 levels (SPEC §3). Go's recursive parser died of a
fatal, uncatchable stack overflow near 750 000 levels (a 1.6 MB document), where ts,
iterative, answered; deeper than 512 is now a parse error in both,
nesting deeper than 512 at offset N, and both canonicalizers refuse it. Vectorsraw_depth_512_in_claims,raw_fatal_depth_513_in_claims. - D2 ts picked the latest recovery
iatwithMath.max(...list), one call argument per valid recovery rotation; past ~125 000 of them at one node V8 threwRangeError(a crash naming no check) where Go answered VALID orrecovery tie. A loop now, and no argument spread over data is left in ts/, spend/, adapters/, integrations/ or sim/ (a ts test scans the built verifier for one). SPEC §9: exhausting a resource is not a verdict. - D3 Two shipped TypeScript readers,
sim/verify-one.mjs(the ts half of the sim's Go differential) andintegrations/skills-cli/sigelo/verify.mjs, read files as'utf8'strings: invalid bytes became U+FFFD, so a bad genesis nonce verified as a different, VALID identity wheresigelo-verifyrejected the document. Both read bytes throughparseBytesnow and sayparse: …assigelo-verifydoes; so dosigelo-spend pay's request file, the keeper's torn-tail repair and the ceremony restore. The swarm simulation presents BYTES (new tamper and fuzz kindinvalid-utf8), so it can see this class at all. - P1 Base58 decoding is quadratic and ran before the 34/64-byte checks: a 300 000-character
signature (per item) or genesis key (fatal) took 79 s in Go and over three minutes in ts.
SPEC §2 now bounds a multibase key at 64 characters and a signature at 100, checked after
the (linear) alphabet scan and before decoding, same reason text in both. Vectors
fatal_genesis_key_too_long,attestation_sig_too_long. - S1
createdwas checked only for being a string:23:59:60Z,+05:30,.123Z, a space forT,2026-13-45T25:61:61Zand"yesterday"all verified. SPEC §4 now fixes the form (exactlyYYYY-MM-DDTHH:MM:SSZ, a real date, no leap second) as a MUST, fatal in every genesis slot; the JSON Schema drops its leap second. Attestations and bindings carry no timestamp string (theiriat/expare integers), so there is no per-item case. Vectorsgenesis_created_leap_day,fatal_genesis_created_leap_second,fatal_genesis_created_feb_29_common_year,fatal_issuer_created_offset,fatal_next_genesis_created_fraction. - S2 Key slots were checked for the multicodec prefix and 34 bytes only: an all-zero,
identity or
y ≥ pkey made a VALID identity or issuer no signature could ever verify under. SPEC §2:key,recovery_keyand aned25519-testaddrMUST be a canonical point not of small order (Go: edwards25519SetBytes+ canonical re-encoding + cofactor; ts: noblePoint.fromBytes(…, false)+isSmallOrder), fatal in a genesis or rotation, per item in a binding. Issuer keys are genesis keys, so they are fatal (invariant 7), not per item. Vectorsfatal_genesis_key_all_zero,fatal_issuer_key_identity,fatal_rotation_recovery_key_y_ge_p,binding_ed25519_test_addr_all_zero_unproven. - S3 The recovery commitment was checked for its
sha256:prefix only, sosha256:,sha256:xyzand UPPERCASE hex verified — the last silently disabling recovery, since no key's commitment is ever spelled that way. SPEC §4: exactlysha256:+ 64 lowercase hex, fatal in every genesis slot. Vectorsfatal_genesis_recovery_uppercase_hex,fatal_genesis_recovery_prefix_only. - S4 Nonces were any string (
"\u0000", emoji, U+2028,/,z+ hex). SPEC §2: a genesis, binding or invoice nonce isz+ 1 to 63 base58btc digits, fatal in a genesis, per item in a binding; the §5.2 challenge nonce stays opaque. Three spend/ test invoices used nonces withI,Oorland were respelled. Vectorsfatal_genesis_nonce_z_hex,binding_nonce_not_multibase,binding_nonce_65_characters,binding_nonce_64_characters. - S5 SPEC §3.1 cites RFC 8785 and so I-JSON, which forbids noncharacters as well as lone
surrogates, but both parsers accepted U+FFFF as a key and a value. Now a parse error in both,
raw or escaped (an escaped pair counts), at the string's opening quote,
noncharacter U+FFFF in string at offset N; both canonicalizers refuse one built in memory, so no conforming library signs what no conforming parser reads. The schema'sstringrefuses them too. Vectorsraw_fatal_noncharacter_in_claims_value,raw_fatal_noncharacter_astral_in_claims_key. - M1 Parse-error offsets differed by one between the two parsers on
bad \u escape,expected ":",expected "," or "}"andexpected "," or "]"(ts incremented past the character before failing; Go peeked), though go/jcs.go claimed both said the same. Both now report AT the offending character, the convention the Gofailcomment spells out; a nine-message table runs in both suites.
unreleased — 2026-09-24, audit before the public export
wire: A1 two identical rotation entries are a fork (7c285ea, vector fatal_duplicate_rotation_is_fork).
Self-audit of the whole tree (AUDIT.md), three findings closed with tests and vectors:
- A2 A document holding invalid UTF-8 was rejected whole by the Go verifier but read
lossily by every TypeScript caller, which discarded one item and accepted the rest. New
parseBytes()decodes fatally and is used at every file, body and stdin edge (CLI, mock world, keeper, MCP server); SPEC §3 states the rule. Same bytes, same verdict. - A1 Two byte-identical copies of one voluntary rotation are two candidates and REJECT
the chain as a fork, in both verifiers. SPEC §7.3 now says so; vector
fatal_duplicate_rotation_is_fork. - A3 The 1f916 adapter bound a DID after checking only three genesis fields; it now checks the full §3.1 shape and names the bad field.
release/publish.sh: the public repository is a fresh single-commit export (pseudonym, UTC, identity-string gate, every suite run inside the export), not a rewritten history.- A4 The export's identity-string gate silently passed on busybox: GNU-only
grepflags made grep exit 2 and the script read the swallowed error as "no hits". It is nowgit grepover HEAD with exit 0/1/other handled explicitly; the CI check excludesrelease/publish.shas it excludes its own file; ROADMAP §5.1 no longer spells the strings the gate looks for. - spend/soak:
gen-keys.mjspasses nonces as bytes (the hex strings it used to pass were stored verbatim, a genesis the SPEC §2 nonce rule now rejects); acompat <dir>mode, run bysetup.shbefore any file is copied, refuses to redeploy over a live soak this code's verifier would not accept; an existingkeys/approver.jsonis never overwritten. Soak incident #3 (near miss). - A5 The gate's own files still spelled three "generic" patterns and excluded themselves
from the search, so the first export published exactly the strings it guarded. No tracked
file holds a pattern now: all come from the
DEVICE_STRINGSsecret / private pattern file, an empty list refuses to export, nothing is excluded. ROADMAP §5.1 no longer describes the maintainer's setup. - PORTABILITY.md: what is proven, documented and unrun across operating systems.
unreleased (still wire sigelo/0) — 2026-09-17
Spec gaps found on first independent review, all closed with vectors.
Bundles carry
issuers(bare genesis documents) so attestations from unknown worlds verify offline. Previously §9 asked for a genesis the bundle had no slot for.Chain walk rejects cycles (
nextalready in chain) and self-rotations. A naive walk looped forever on a rotation back to an earlier DID.New vector
rotation_hostile_carried: a fully valid voluntary rotation defeated only by recovery precedence. The old hostile vector also failed the commitment rule, so a verifier without precedence still passed.chain_precedence_onlyisolates it.JCS done properly (
py/jcs.py): UTF-16 key order, ES6 escape set, duplicate keys rejected, non-integer literals rejected at parse. Vectorattestation_unicodecarries the exact canonical string. The oldjson.dumps(sort_keys=True)mis-sorted non-BMP keys.§9 takes
nowas input and returns a specified result shape (§9.1). Vectorbundlefixes the expected result for a full bundle, including per-item rejections.Binding proof status is three-valued:
proven,unproven,unsupported. Bad proof is discarded, not downgraded.admissionrestricted to the six defined values.THREAT-MODEL §4 citation corrected against the Reuters story (2026-09-04): OpenAI agents, DseWiki, 15,000+ edits; the "propagates to later agents" clause was not in the source and was replaced with what the researchers actually observed.
§5.2 fixes the proof-of-control handshake (
typ: "challenge") so worlds interoperate on the one step they all need. Vectorchallenge.Round 2, after a from-spec-only implementation test: §3.1 normative field table with a no-unknown-keys rule; §7.4 separates "not a candidate" from "REJECT the chain" with a per-failure table; §7.3 rejects two recoveries tied on
iat; §2 states the signature and DID encodings that were only inferable from examples; the signing prefix is given as bytes; §9.1 key order is informative. The four "not a candidate" negatives ship complete envelopes and state the resulting chain. New:bundle_minimal, a second issuer inbundle,rotation_bad_sig,unknown_field_*.QUICKSTART.md and examples/world.mjs, after three fresh-agent adoption runs.
Monero: MONERO.md design; §6.2 rewritten with the real SigV2 construction and the view-mode binding; the "view key for a single subaddress" claim was wrong (Monero has one view key per wallet) and is replaced by proofs and per-relationship wallets.
method: "monero"bindings verify in ts/ and py/ against vectors from Monero core, cross-checked with monero-wallet-rpc 0.18.5.0.§6.3 invoices: a new signed object naming where to pay this time, under the identity key, never in a bundle.
{ v, typ, did, method, addr, iat, exp, nonce }plus optionalamount(a string of atomic units) andmemo. Astructure()slot in both implementations, not a sixth constructor. Vectorinvoice.Root-seed derivation (
ts/src/keys.ts): one 32-byteS, HKDF paths for identity, recovery and each Monero wallet (MONERO.md §2), carried by a 24-word BIP-39 mnemonic that is transport forSonly and is not a Monero seed.sigelo-offline new|derive(ts/src/offline.ts) is the air-gapped box's whole job and withholds the treasury spend key and the recovery secret unless--reveal-all. Interop is tested live: a derived wallet is imported into a stockmonero-wallet-rpcby private spend key and the stock wallet reproduces our address, view key, spend key and subaddresses.py/monero.py: the Python half of §6.2 — Keccak-256, edwards25519 group ops, Monero base58, addresses, subaddresses and SigV2 — sopy/reference.pyverifiesmethod: "monero"bindings and the two implementations agree on them.sigelo-spend(spend/): the MONERO.md §4 policy service. Loopback HTTP, bearer token, allowlist by literal address or by DID-with-attestation (bundle verified offline, address must be aprovenbinding or a §6.3 invoice), per-transaction cap, per-period budget, rate limit, buckets pinned to Monero accounts,unlock_timeforced to 0, and an append-only log of receipts signed by the service's own sigelo identity. In CI; the live-wallet section SKIPs there.adapters/moadim: Monero commandswallet-set,bind,receive,invoiceandverify-invoice, in their own file so the identity core stays at 77 lines. The agent holds(a, B)and can never spend;bindcomputes the view-modesig_addrlocally, with no wallet and no daemon.sigelo-spendspent real coins end to end on stagenet (2026-09-23): 5e8 atomic units from account 0 to the wallet's own subaddress (0,1), fee 30500000, txid2582d050b5ca46ae4901317d85ce60511c962aaebce16af60b2aa526367fe63d; over-cap request refused 403per_tx_max, receipt verifies under the service key,/budgetaccounted it.spend/test.ts§3 now pays from the funded account (largestunlocked_balance) instead of the fixture's empty account 1, still--dry-runonly.Vectors: 18 positive groups, 27 negative cases (
vectorsholds 19 entries — the extra one,expected_chain, is a shared expectation, not a vector).§3.1 field types, enforced by
structure()in both implementations:iat/expare integers in [0, 2^53−1] withexp>iat; every other field is a string exceptrecovery,claims,amountand the bundle's own fields. The two verifiers disagreed on identically signed bindings:iat: "5"ornullverifiedprovenin TS (comparison coercion) and raised TypeError in Python, killing the bundle; anaddrarray of characters verifiedprovenin Python only. Envelope junk (anullbinding entry crashed TS; non-dict entries crashed Python; a non-string rotationsigwas fatal in TS but ignored in Python) now has one outcome.JCS: a lone surrogate is rejected by both canonicalizers (RFC 8785 requires I-JSON; TS used to sign it as U+FFFD, so two bodies shared a signing input). The key
__proto__is rejected by both strict parsers and both canonicalizers:ts/src/jcs.tsassigned it, which swapped the parsed object's prototype instead of adding a key. The TS parser now defines properties.§6.2: a Monero address whose prefix is a non-minimal varint (
92 00for 18) is refused by bothdecodeAddressimplementations, as wallet2'sread_varintdoes (EVARINT_REPRESENT, src/common/varint.h). Monero base58 decoding accepts only strings.Vectors: 13 new negative cases (40 total), among them
parity, 48 malformed bundles that both verifiers must take to the same result or the same rejection. At b348b5d, 20 of the 60 §3.1 and parity checks diverged between the implementations and 17 crashed one of them.sigelo-spend(MONERO.md §4), after independent review: two-phase relay —transferwithdo_not_relay/get_tx_metadata, caps checked, every line signed, an fsyncedintentline, thenrelay_tx, thenrelayedorrelay_failed(still debited: the daemon may have taken it). Money could previously move with no log line: ato.didthat could not be signed threw after the wallet paid (5 requests → 5 transfers, 0 lines), as did a slow or non-JSON wallet reply. Caps count amount + fee (1-atomic payments at a 3e7 fee drained ~8.6e10/day against a 1000 cap).totakes only{addr, did, bundle, invoice}with a stringdid, own properties only;purpose≤ 200 characters. Dry runs sign nothing — no receipt,dry_run: true, rate-limited, no budget. Invoices go throughstructure(), must name a subaddress, are paid exactly theiramount, and once (nonce logged).statusis inside the signed entry; lines without one are read as legacyrelayed.moadim sidecar:
bind,wallet-set,rotate,add-issuerandadd-attestationtake the lockreceivetakes; abindinterleaved with areceivewrote the old counter back and the same subaddress was handed out twice.verifyInvoicerefuses a non-subaddressaddr.§9 step 2 / invariant 7: a non-integer number, lone surrogate or
"__proto__"key inside ONE attestation or binding (body or envelope) now discards that item and counts it; before, both verifiers canonicalized the whole bundle first and rejected it outright. The bundle-level check covers only what defines the identity (shape, genesis, rotations, issuers), where the same fault stays fatal.parity+6:binding_iat_floatflips to discarded; good+float binding, floatsig_addr, good+__proto__and good+lone-surrogate attestations (the latter asrawtext), and fatal float in a rotation body and in an issuer genesis.Vector generator ported to TypeScript (
ts/src/gen_vectors.ts,npm run gen), step 1 of replacing Python with Go as the second verifier. It reproducestest-vectors.jsonbyte-for-byte; CI now regenerates with both generators and diffs each against the committed file. What a further port must match: insertion key order ({...x, k: v}keepskin place),monero_spend_seedafternegative,JSON.stringify(…, null, 2)with no trailing newline, and lone-surrogaterawtext as compact ASCII-only JSON with lowercase\uXXXX. The generator refuses integer-like keys, which JavaScript would silently reorder.ts/src/test.ts: a slowmonero-wallet-rpc(fetchTimeoutError) mid-interop is a SKIP, not an uncaught exception.go/: step 2 of that move, the §9 verifier in Go (Verify,Structure, JCS parser and canonicalizer, §6.2 Monero, and a staticsigelo-verifyCLI). It has one dependency,filippo.io/edwards25519; Keccak-256 is ported rather than taken from x/crypto.go testruns every checkpy/reference.pyruns, with identical PASS names (185 lines, diffed), plus JCS, json/v2, Ed25519 edge-case and 1f916-fixture tests. CI jobgoruns it next to the Python job. A differential againstts/distgave byte-identical outcomes: §9.1 JSON or the exact rejection message, over 71 vector bundles × {strict text, parsed value} and 3,000 seeded random mutations. Two edge cases are deliberate. Ed25519 is verified with @noble'szip215: falsestrictness, notcrypto/ed25519's: canonical A/R, no small-order A, cofactored. Parse-error offsets count UTF-16 units, as ts/ does.encoding/json/v2is not used: it fails the whole document on a lone surrogate, where §9 step 2 discards only the item carrying it.Step 3, the move is done: the Python implementation is removed.
go/is the reference verifier (217 checks,ALL PASS),ts/src/gen_vectors.tsis the only vector generator and CI still diffs its output againsttest-vectors.jsonbyte-for-byte. The 1f916 adapter's cross-check (wasadapters/1f916/check.py) isTest1f916ingo/sigelo_test.go, over the samesample-bundle.json, now also asserting the claim names and integer counts check.py asserted. The moadim adapter's test re-verifies its bundle withgo/cmd/sigelo-verifyinstead of Python (SKIPs withoutgo; CI installs Go in that job and fails if it skipped). CI has two jobs,tsandgo; nothing needs a Python interpreter.Go-port review, three findings, fixed in ts/ and go/ with vectors. (1) Monero points are decoded as Monero decodes them:
ge_frombytes_vartime(src/crypto/crypto-ops.c) refuses y >= p and x = 0 with the sign bit set, andget_account_address_from_strrunscheck_keyon both address keys. Both decoders had been permissive (noblezip215: true, edwards25519SetBytes), and in spend mode the view key is only hashed, so an address Monero refuses could carry aprovenbinding. Now strict everywhere a point is decoded (address keys,check_signature's key, subaddress derivation); §6.2 says so. Vectorsbinding_monero_view_key_y_ge_p,binding_monero_spend_key_x0_signbit: genuine signatures, discarded. (2) Whole-envelope canonicalization is pinned: paritybinding_envelope_float_unsupported_method(sig_addr: 1.5on a method nothing verifies) andattestation_envelope_extra_key_floatare discarded;attestation_envelope_extra_key_intpins that an extra envelope key is otherwise ignored. A body-only canonicalizer fails exactly those two in both test suites. (3) The strict parser no longer rejects a whole text over one number: a non-integer or out-of-range literal parses to a value (tsRawNumber, goNumber) that canonicalization refuses, so it is per-item like the same value arriving as an object; duplicate keys and__proto__stay fatal to the text (§3).rawparity vectorsraw_binding_good_and_float,raw_attestation_good_and_int_out_of_range(per-item),raw_fatal_rotation_iat_float,raw_fatal_attestation_duplicate_key(fatal). ts 284 checks, go 236; differential 159/160 over vector bundles (the one difference is the harness'sJSON.parsepath on the duplicate-key text) and 6000/6000 over the 3,000 mutations.MONERO.md §8 G4, keeper roots (
ts/src/keys.ts):keeperRoot(S, j)=k(S, "sigelo/v1/keeper/<j>")andagentIdentitySeed(K, i, n)=k(K, "sigelo/v1/identity/<i>/ed25519/<n>"), same HKDF and path conventions as the existing branches; indices are safe non-negative integers, printed decimal.deriveRoottakeskeepers = 1and returnskeepers: [K_0 …]; every other output is unchanged (test-vectors.jsonregenerates identically, the stock-wallet interop still passes).sigelo-offline derivehandsK_0to the operator (the agents' keeper, §2 table), so a restore byderiverecovers it. Fixed vectors over the test root S1 forK_0,K_1, agent(0,0)and(1,0), cross-checked against an independent HKDF; ts 291 checks.sigelo-spend, MONERO.md §8 G1 — per-agent entries. The policy'sbucketsbecomeagents: each entry holds its owntoken_hash, account, caps andallow. A token maps to its agent by comparing it against every agent's hash with no early exit; unknown and revoked tokens get the same answer. The account always comes from the entry; a request'sbucket, still accepted, must name the token's own agent. New destination{label}resolves only against the agent's own allowlist; new rule{issuer, ctx?}admits any DID holding that attestation, under the same binding-or-invoice rule./budget,/logand/healthanswer for the caller's agent only (/healthgives its account balance, not the wallet's). Validation moved topolicy.tsparsePolicyand is now strict at every level (unknown fields; shared accounts or token hashes; literals not payable onnet; a non-nullapproval_above, refused until G6 enforces it). Pre-G1 files load as one agent named after their one bucket; a file with several buckets under one token is refused. Log lines now carryrequest.agent; olderrequest.bucketlines still replay.sigelo-spend token new <policy> <agent>.sigelo-spend, G2 —refidempotency (§4.1 step 4, §4.2). Every spend has aref: the one sent, orsha256(account ‖ JCS({to, amount, purpose}))withtotaken without its bundle.refand the request fingerprint are logged per agent. Withindedupe_seconds(default 600) a repeat gets the first outcome back from the log and builds nothing: the same receipt forrelayed(already_paid: true), the same UNCERTAIN forrelay_failedor for an intent whose outcome a crash never wrote, and202 approval_neededforpending(a status no G1/G2 code writes; it debits nothing). An explicitrefreused for a different request is409. Five existing checks sent identical bodies to test budgets, rate and invoice replay; they now varypurposeso each is still a separate payment.spend/checks: 148 → 227.go/keys.go: MONERO.md §2 key derivation in Go, mirroringts/src/keys.tsso a Go keeper derives the same keys without ts:K(HKDF-SHA256, empty salt, path asinfo, stdlibcrypto/hkdf),IdentitySeed,KeeperRoot,AgentIdentitySeed,RecoverySeed,RecoveryPublicKey,RecoveryCommitment,WalletFromRoot(sc_reduce32 branch) andDeriveIdentity(a reproducible genesis from explicitcreated/nonce). Indices areuint64capped at 2^53−1 with ts's error strings.go/keys_test.gopins ts's fixed keeper/agent vectors and the identity key, DID, recovery commitment and stagenet/mainnet wallet keys and addresses that ts derives from S1. No new dependency. Go checks: 236 → 262.MONERO.md §8 G7 — the root ceremony (§4.5).
sigelo-offline ceremony --net <net> --recipient <age1…> --out <dir> [--keepers N] [--treasury-keeper j]generatesS, derives withderiveRoot, and writesbackup.age(age to the Owner over the §4.5 JSON:v,mnemonic,created,keepers,public),fingerprint.txt(JCS(public),public= treasury and allowance addresses and the recovery commitment) and a 0600keeper-<j>.jsonper keeper (K_j, net, keeper identity public key, recovery commitment; keeper 0 adds the allowance wallet and the root identity seed;--treasury-keeperadds the treasury wallet).S, the mnemonic and the recovery secret appear nowhere but insidebackup.age. age is a shelled-outage/rage(no npm dependency), injectable insrc/ceremony.ts; missing binary or a bad recipient exits 2 and writes nothing.sigelo-offline restore --backup --identity --net [--reveal-all]decrypts, re-derives, refuses on a fingerprint mismatch, and prints the fingerprint and eachK_j.deriveoutput renamed to the keeper vocabulary:operator→agents_keeper(inner field names unchanged),air_gapped→owner_backup; newkeeperslist and--keepers N;agent.treasuryunchanged forsigelo-agent wallet-set. Keeper packages are plaintext 0600 on the ceremony host, not encrypted per keeper host as §4.5 step 5 has it. ts checks: 291 → 313 (four of them with the realagebinary, SKIP without it).MONERO.md §8 G3 — the agent surface (§4.2), in
spend/. Routes, all behind the agent's token and answering for its own account only:GET /balance(get_balanceon the account + what the policy still allows),POST /receive {purpose?}(create_addresson the account, labelled with the purpose, rate-limited in memory byrate_per_minute),GET /history?n=(the agent's log, one entry per payment, +get_transfersin/poolpinned to the account),POST /bind {body}(the keeper's view-modesignat (0,0) over a binding body that passesstructure(body, 'binding'), names the agent's registereddid— new optional, unique per-agent policy field — and the wallet's base address, with a window ≤ 30 days; the signature is verified as view-mode before it is returned). Every error now carriescode(the refusing check) and cap refusalsfacts(affordablereturns them). Newspend/wallet.ts, binsigelo-wallet:balance,receive [note],pay <to> <amount> [purpose] [--ref R] [--atomic],history [n],--json; XMR↔atomic by string arithmetic (≤ 12 decimals, no sign/exponent/blank/leading zero); one line per verb; the message table and exit codes 0–4 inspend/README.md, with the weak-agent prompt snippet matched to the CLI. Departures from §4.2:receivemints a subaddress but no invoice (no amount, noSIGELO_IDENTITY); the command issigelo-wallet, notwallet; the snippet says "within 10 minutes".spend/checks: 227 → 324 (5 of them against the live stagenet wallet, read-only and a view-modesign; nothing relayed, nocreate_address).MONERO.md §8 G5 — delegation (§4.3), in
spend/. Newspend/tree.ts(pure): the tree is replayed from signeddelegate/revokelines inspend.logon top ofpolicy.json's agents (the roots;policy.jsonstays the Owner's file); a delegate's caps are refused above its delegator's at creation and clamped to the minimum along its ancestors at every spend,period_secondsis the root's,allowkeeps only rules every ancestor still covers; revoke cascades to the subtree and is idempotent; account indices and names are never reused; a delegate whose root leftpolicy.jsonis orphaned (dead). Count rule:max_delegatesbounds the whole subtree — each live child reserves 1 + its own, a child's is at most its parent's − 1. Routes:POST /delegate {name, fund, caps?, allow?}(create_account, skipping held indices; identityagentIdentitySeed(K, i, 0)with the new optional policy fieldrecovery_commitment; a token returned once, its hash in the signed line;fund> 0 is a transfer to the delegate's (i, 0) through the ordinary two-phase/paypath, counted against the delegator),POST /fund {name, amount, ref?},POST /revoke {name}(signed line, subtree's tokens dead at once, thensweep_all {address, account_index, subaddr_indices_all: true, priority, unlock_time: 0, do_not_relay: true, get_tx_metadata: true}per account to the revoker's (r, 0), dust (amount ≤ fee) and locked balances skipped, intent/relayed lines per sweep tx),GET /delegates.spend.keyis now documented as the keeper rootK(the ceremony'skeeper_root_hex): the keeper signs withidentitySeed(K, 0)as before, delegates derive from the sameK; tree lines must verify under that key or the service does not start.sigelo-wallet delegate <name> <fund> [--per-tx X] [--per-day Y] [--allow label=addr ...] [--max-delegates N],fund,revoke,delegates— not in the weak-agent snippet (§9 decision 9);delegateprints the URL and token once under "Give this to the delegate, it is shown once:". Departures from §4.3: noapproval_abovein the ask (G6); the keeper does not cross-sign the delegate's binding at creation (the delegate callsPOST /bind, which now works for it); the credentials are the HTTP answer / CLI output, not a 0600 file; the Owner revokes through a root's token (no keeper-host admin command);/fundonly by the direct delegator.spend/checks: 324 → 413 (88 new), all passing with the stagenet wallet reachable.create_accountandsweep_allhave not been run against a real wallet (G8).MONERO.md §8 G6 — approvals (§4.1 step 8, "The spend-approval"), in
spend/. Newspend/approval.ts(pure): the closedspend-approvalbody{v, typ, keeper, net, agent, ref, to, amount, purpose, nonce, iat, exp}, signed by an approver over"sigelo\n" ‖ JCS(body), and its checks in §4.1's order — fields andtyp, this keeper's DID and net,iat ≤ now < expandexp − iat ≤ max_approval_ttl, the approver's bundle verified offline with its current DID inapprovers, the signature under that DID's current key, the body equal field for field to the keeper-signed pending request with that nonce, not the agent (by DID, by the approver's chain, or by the agent's key under another DID) and not the keeper, the nonce neither approved nor spent. A/payaboveapproval_above(amount alone, after caps and rate, before any wallet call) gets202 approval_neededwith the body and writes a signedpendingline (no debit, one rate tick);POST /approve {body, sig, bundle}(no token) writes a signedapprovedline; the agent's re-run of the same pay goes through every check again and the two-phase relay, and its intent line names the nonce inrequest.approval, which spends it — a further run is the ordinaryALREADY PAID. Pending and approved lines answer until the request'sexp, notdedupe_seconds; an expired request frees the ref for a fresh one with a new nonce; an approved line counts only if it verifies under the keeper key. Policy:approval_aboveis an atomic-unit string or null (the G1 "refused at load" placeholder is gone); set, it needs the agent'sdidand a new optionalgenesisfield that hashes to it, and a non-emptyapprovers; approvers must be DIDs, unique, and not an agent's own;max_approval_ttldefaults to 3600. Delegates:approval_abovein delegate lines may be a string,/delegatetakescaps.approval_above(refused above the delegator's), and the effective threshold is the lowest along the ancestors. The keeper's DID is now stable across restarts (genesis nonce from its key,createdpinned, as SPEC §3.1 allows) because approvals name it.sigelo-spend approve-request <policy> <ref>prints the body to sign; the approver signs with its own tooling and posts to/approve. Departures from §4.1:nonceadded to the body (single use checkable from the log, and an old signature cannot answer a later request for the same ref); the approver must sign the keeper's body exactly (it cannot choose its owniat/exp); root agents with a threshold must carrygenesis.spend/checks: 413 → 487, all passing with the stagenet wallet reachable. Not run against a real wallet (G8).SPEC §6.2 (Owner decision 2026-09-23): a
method: "monero"binding'saddrmay be a subaddress as well as a standard address; integrated stays refused (verified live: a base-address signature verifies for the integrated spelling, and 0.18.5 still makes them) and so does SigV1. A subaddress is checked against its own(D, C), the keys wallet2::sign's subaddress branch signs with (b + mspend,a·(b + m)view), and isprovenin either mode; for a subaddress, view mode does not imply a view-only signer. ts and goverifySigeloMoneroSigAddrrefuse onlykind == integratednow. Vectors: the negativebinding_monero_subaddress_addris retargeted into positivesbinding_monero_subaddress_spendandbinding_monero_subaddress_view(bothproven), plus the negativebinding_monero_subaddress_base_sig(subaddressaddr, signature by the base keys: discarded).ts/test/monero-vectors.jsongainswallet_rpc_oracle: a live stagenet monero-wallet-rpc's statelessverifyon those vectors (stagenet spellings) and its ownsignat (0,0)/(0,1) in both modes — all nine agree with ours. MONERO.md §3/§4.2:/bindstill signs the keeper's base address (no spend/ change); an account's(i, 0)is now bindable by spec. The moadimwallet-seterror no longer says the spec forbids subaddresses. ts checks: 313 → 329; Go checks: 262 → 278.spend/
POST /bindsigns at the caller's OWN account address(i, 0)(SPEC §6.2 accepts subaddress bindings):addrmust beget_address {account_index: i}— the keeper's base address is refused for any agent above account 0 — and the keeper callssignwithaccount_index: i, address_index: 0, spend mode fori > 0(a subaddress's view signature needs the spend key anyway, secret a·(b + m), so view mode would claim less without protecting anything), view mode at (0,0) for account 0 as before. The result is checked for that mode by that address before it leaves; the answer addsaccountandmode. A delegate binds the DID from itsdelegateline. Also:/delegate'sfundobject no longer lets the spend body'sstatusstring overwrite the HTTP status (a funding that waited for approval readstatus: "approval_needed", never 202). The mock wallet now signs as wallet2 does at any (major, minor). Tests: mock bind at (1,0) spend + (0,0) view, cross-account and base-address refusals, wrong-mode 502s, a delegate's bindproven; live: a spend-modesignat an existing account(i, 0)(SKIP until the wallet has one). spend/ 487 → 499 checks (496 + 1 SKIP while the wallet has no account above 0).G8 (MONERO.md §8): stagenet end to end through spend/dist and
sigelo-wallet— delegate, approval-gated funding, pay / ALREADY PAID, an above-threshold pay waiting for and using one approval, a delegate's(1, 0)binding, revoke with a realsweep_all. Script outside the suite; txids in MONERO.md §8.spend/ review 2 (MONERO.md §4.1, §4.2, §4.3). The lane could freeze:
/pay,/approve,/delegate,/fundand/revokequeued first and read their body only at the front of the queue, and the body read settled only onend/error— a client that went away while queued (a half-sent body, orsigelo-wallet's own timeout behind a slow wallet build) never fired either, and every later spend waited forever. Now the body is read whole before the request joins the lane (1 MB → 413, 10 s → 408, a gone client settles it too), and a queued request whose client has left is dropped at its turn with no wallet call and no log line. Names: a root the Owner removed kept its log lines but freed its name, so a delegate given that name read its/log,/historyand/budgetand answered its refs; every name spend.log names is now taken, and an agent inpolicy.jsonwhose name the log shows on another account refuses the start. One keeper per directory:spend.lock(O_EXCL, pid) next tospend.log; a second start is refused while the pid is alive, a dead pid's lock is taken over, a reused pid is removed by hand (README). Stale waits: a pending or approved line is judged by the current policy — threshold off or raised → it pays (an approval on file is still spent); approval by an approver since removed → a fresh 202 with a new nonce instead of a 403 untilexp. CLI: nothing from an invoice file orhistoryis printed raw (addresses and names in their own shapes, free text as printable ASCII, else a JSON string with C1/bidi escaped too;--jsonas well), so a crafted invoice cannot print a second line; apay/fundtimeout isUNCERTAIN … Run `sigelo-wallet history` before paying again.(exit 4), not TRY LATER; locked change still in the pool says "a payment is still confirming … about 20 minutes"; the locked-sweep line is one sentence. Also: a delegate's ownapproval_aboveis refused while the policy lists no approvers; self-approval is refused for any key in the approver's verified chain, not only its current one (the requester's own rotated key stays unknowable to the keeper — documented).SIGELO_WALLET_TIMEOUT_MSandServeOptions.bodyTimeoutMsfor tests. Three checks that planted an unsignedpendingline under a policy withoutapproval_abovenow expect the payment (G2) or use an agent that really needs approval (G3). spend/ checks: 499 → 529, all passing with the stagenet wallet reachable.The root is a Monero 25-word seed (Owner decision; MONERO.md §2, §4.5).
Sis carried as Monero's Electrum-style English mnemonic (src/mnemonics/electrum-words.cppat monerod02c7c57: 4 LE bytes → 3 of 1626 words, 25th word = CRC-32 of the 3-letter prefixes mod 24), replacing the 24-word BIP-39 transport (ts/src/bip39-english.tsremoved; wordlist nowts/src/monero-words.ts/go/monero_words.go, SHA-256 pinned). Its own wallet, the vault (b = sc_reduce32(S),a = H_s(b)), restores from the 25 words in any stock Monero wallet; it is the Owner's cold wallet and never loaded by a keeper — no keeper package carries a vault key (tested). Every derivation is unchanged:treasury,allowance, keeper roots, identities and recovery stay HKDF overS, sotest-vectors.jsonand every existing key vector are byte-identical.Smust be canonical (0 < S < l): pastla wallet shows backsc_reduce32(S)'s words, which sigelo would read as another root — sonewRootdraws from[1, l)and words, hex and the ceremony refuse anything else. ts:newRoot()returns 25 words;rootFromMnemonic,mnemonicFromRoot,encodeMoneroWords/decodeMoneroWords,vaultFromRoot;deriveRootgainsvault. Go:RootFromMnemonic,MnemonicFromRoot,EncodeMoneroWords/DecodeMoneroWords,VaultFromRoot.sigelo-offline new [--net]prints 25 words and the vault address;derive <25 words|hex>;derive/restoreprint the vault address, its keys only under--reveal-all(owner_backup.vault); the ceremony backup issigelo-root/2(mnemonic= 25 words; fingerprint unchanged) andrestorerefusessigelo-root/1by name. Three fixed vectors (V1 hashed, V2 = 2^252−1, V3 = l−1) and a 64-root sweep digest pinned in both implementations; oracle:monero-wallet-rpc0.18.5restore_deterministic_walletfrom our words returns our address,b,aand our words (stagenet in the suite; mainnet once by hand),create_wallet's words round-trip through ours, non-canonical words restore to a wallet showing other words, and wallet2 refuses the past-2³² triple and a wrong checksum word as we do. ts checks: 329 → 355; Go checks: 278 → 295.Keeper API made consistent (found writing
spend/openapi.yaml; code fixed, README and openapi now agree with it).POST /delegate's funding outcome isfund: {http, …body}: the HTTP status no longer overwrites the body'sstatus, so a funding aboveapproval_abovenow reads{http: 202, status: "approval_needed", code: "approval", …}(sigelo-wallet delegatenow readsfund.http; it says NOT FUNDED — WAITING FOR APPROVAL, exit 3).GET /healthchecks the wallet's reply — a missing height or a balance that is not atomic units is502code: wallet, never"undefined". A missing, unknown or revoked token is401code: tokenon every route,/payincluded (was403there). Every error body has acode:/health's 502 (wallet) and the catch-all 500 (internal) gained one. README: the route shapes (/history{agent, account, entries},/bind{addr, account, mode, sig_addr},/delegatecaps.approval_above,/budget,/health), the two 202 shapes (fresh:error; replay:repeat,pending) and/approve's 400/408/413/500 codes. spend checks: 561 → 564.Dependencies pinned per VERSIONING.md §6, with no version change: exact versions in
ts/,spend/andadapters/moadim/(@noble/ed255193.2.0,@noble/hashes2.4.0,typescript5.9.3,@types/node24.13.5), lockfiles regenerated (npm install --package-lock-only: only the declared ranges and stalebinmetadata changed, every resolved version and integrity is the same);go/go.modgainstoolchain go1.27.1; every GitHub Action inconformance.ymlis pinned to a full commit SHA with its tag in a comment (checkout and setup-node v4 → v4.4.0, setup-go v5 → v5.6.0, upload-artifact v4 → v4.6.2: what those major tags pointed at on 2026-09-23).Docs closed for the three gaps round 3's docs-only Haiku run exposed (docs-test/RESULTS.md, 5/10), no code or wire change. QUICKSTART: the mock world is a program run from the repo root, with its two commands, what each prints and its state files (
./world.local.json,./challenge.local.json); everysigelo-agentline has a# library:equivalent (the docs-only condition has no CLI); step 0 also writesrecovery.puband shows it as one bare line. SPEC §2's nonce row and ts/README:z+ base58btc of raw bytes, neverz+ hex (the §5.2 challenge nonce stays opaque); to choose one, passnonce: crypto.getRandomValues(new Uint8Array(16)).examples/world.mjs --helpprints that usage, and bad arguments print it before any state is written. The next Haiku run is scored against a new snapshot.Keeper
spend.locksurvives reboots and pid reuse (soak incident #1, 2026-09-24: after a reboot the old keeper's pid 1103 belonged to bluetoothd, and the keeper refused to start for 54 minutes underRestart=alwaysuntil the lock was removed by hand). The lock is now two lines, the pid alone (sokill -9 $(head -n1 spend.lock)still works) and{"pid","boot_id","start"}with this boot's id and the process starttime from/proc/<pid>/stat; a held lock is taken over with one warning naming what was stale when its boot_id is not this boot's, or its pid is dead, or its pid is alive with another starttime. Two live keepers are still refused; old one-line locks keep the pid-only rules; without /proc the keeper writes the pid line only. The stale-lock takeover race (two keepers starting at the same instant) is documented as the remaining limit. 7 tests (spend 571). soak/README: Incidents section; INCIDENT.md §2.2 kill line updated.
v0.1 draft (wire sigelo/0) — 2026-09
First public draft. Nothing is stable.
- Identity as a hashed genesis document, so the recovery commitment provably predates any compromise.
- Domain-separated signing input
"sigelo\n" || JCS(body);typbound inside signed bytes. - Non-integer numbers forbidden in signed objects (JCS interop).
- Recovery rotations supersede voluntary ones regardless of
iat. - Voluntary rotations must carry the recovery commitment forward unchanged; only a recovery rotation may change it.
- Forks under a single key are rejected, not resolved.
- Cross-signed payment bindings; unproven bindings explicitly labelled and never paid to.
- 11 positive vector groups, 11 negative cases, verified by
py/reference.py.