Skip to content

feat(merchant): wallet.backend selection wired through the OpenWallet seam - #1155

Open
Amperstrand wants to merge 20 commits into
mainfrom
feat/wallet-backend-wiring
Open

Amperstrand wants to merge 20 commits into
mainfrom
feat/wallet-backend-wiring

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

The module half of the CDK-migration campaign's P4: the merchant service routes its wallet construction through tollwallet.OpenWallet (the merged selection seam) instead of calling the in-process gonuts constructor directly — wallet.backend=cdk becomes reachable from a running module.

What changes

  • Config schema: a wallet section — backend (enum: default | cdk) and socket (AF_UNIX path of the cdk-walletd sidecar; default /var/run/cdk-walletd.sock, matching the packaged daemon's UCI default). Schema-consistency lint passes (113 entries).
  • Config struct: WalletBackendConfig. A legacy config without the section parses as Backend "" — which selects the in-process wallet, byte-identical to the pre-selection behavior (asserted by test).
  • merchant.go: newFullMerchant calls openServiceWallet() — wallet.backend=cdk opens the SidecarWallet client (the info handshake runs at construction), selection is logged, and backend=cdk with no socket fails loudly rather than silently falling back (asserted by test).

Proven where

  • Unit: four-case selection suite green (absent → GonutsWallet, default → GonutsWallet, cdk → SidecarWallet against a minimal fake daemon speaking the info handshake, cdk-without-socket refuses). config_manager module tests green; js-schema-lint green; gofmt clean on touched files.
  • NOT yet claimed: end-to-end module behavior on the cdk backend (receive → session grant through the full service) — that is the campaign's P4 e2e leg in tests/cloud-lab, built on the parity rig, and follows as its own change once this seam lands.
  • No new dependencies; tollwallet itself is untouched (the seam was already merged). The cdk-walletd sidecar daemon is packaged separately (Amperstrand/cdk-walletd) and is a runtime prerequisite only when backend=cdk is configured.

Context

The wallet-layer gates already exist and are green: the crash-boundary parity suite (7/7, mutation-checked, plus the locked-token demotion-gate case) runs the money ops through the sidecar client, and the same WalletPort money-cycle assertions pass on gonuts in-process, cdk-walletd sidecar, and cdk-go in-process. This PR is the piece that lets the service itself pick.

… seam

Closes the module half of the campaign's P4: the merchant service now
routes its wallet construction through tollwallet.OpenWallet instead
of calling the gonuts constructor directly.

- config schema: wallet section (backend enum default|cdk, socket —
  the cdk-walletd sidecar's AF_UNIX path, default
  /var/run/cdk-walletd.sock matching the packaged daemon's UCI
  default). 113-entry schema consistency check passes.
- config struct: WalletBackendConfig; absent section (legacy configs)
  parses as Backend "" which selects the in-process wallet — the
  pre-selection behavior, byte-identical.
- merchant: openServiceWallet() (newFullMerchant now calls it);
  wallet.backend=cdk opens the SidecarWallet client; selection is
  logged; cdk without a socket fails loudly (never silently falls
  back).
- tests: four-case selection suite (absent/default -> GonutsWallet,
  cdk -> SidecarWallet against a minimal fake daemon, cdk-without-
  socket refuses).

With this, wallet.backend=cdk is reachable from a RUNNING module —
the prerequisite for the P4 e2e leg (receive -> session grant through
the full service on the cdk backend). The in-process cdk_static lane
and the sidecar parity suite remain the wallet-layer gates.
…ll legs

The e2e the wiring PR needed: the merchant service end-to-end on
wallet.backend=cdk against the packaged cdk-walletd sidecar, in the
cloud-lab docker topology.

Composition (docker-compose.cdk-backend.yml, layered over the base
lab): cdk-walletd (bind-mounted musl binary, socket on a shared
volume, load-or-create mnemonic) + upstream-cdk (the same
Dockerfile.tollgate build, config selects the sidecar) + the client
dependency retarget via depends_on: !override.

Lanes:
- test_cdk_backend_e2e.py (in-container pytest): advertisement, full
  payment -> kind 1022 session event, identical-token replay refused
  (the sidecar CheckTokenSpent path).
- run-cdk-backend-e2e.sh (host driver): the two daemon-kill boundary
  legs that need docker. LEG A: payment during a sidecar outage is
  REFUSED (400/21023 notice), the module survives, and recovers when
  the sidecar returns. LEG B: sidecar killed mid-payment; the verdict
  is value-based — allotment (the session's cumulative total) may
  rise by at most one payment's worth above the pre-kill baseline,
  and both attempts may never both grant.

Two harness traps this lane pinned, both now encoded in the driver:
- 'compose run client' STARTS the client's dependencies — it
  resurrected the deliberately-stopped sidecar before every outage
  payment, turning each outage leg into a happy-path run (measured:
  allotment kept growing 'during' the outage). The kill legs run
  with --no-deps.
- build-time container egress is firewall-blocked on the bench host
  (runtime bridge traffic is fine): builds run with network: host,
  and the client is pre-built explicitly because implicit dependency
  builds skip build.network on this compose version.

Verified: 3 consecutive full runs green (3/3 pytest + 4 driver
checks each); sidecar money-op log lines confirmed.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

P4 e2e landed on the branch (7778f22): the full module on wallet.backend=cdk against the packaged cdk-walletd sidecar, in the cloud-lab topology.

Verified (3 consecutive full runs green):

  • Happy path 3/3 pytest: advertisement served; payment → kind 1022 session event through Receive on the sidecar; identical-token replay refused (the CheckTokenSpent/NUT-07 path over AF_UNIX).
  • LEG A (outage): payment during a stopped sidecar → HTTP 400 notice (21023), no session granted without a confirmed Receive; module survives; full recovery on sidecar return.
  • LEG B (kill mid-payment): value-based verdict — the killed attempt's outcome is genuinely ambiguous, so the assert is allotment may rise by at most one payment's worth above the pre-kill baseline, and both attempts may never both grant. Measured: exactly one grant.

Two harness traps the lane pinned (encoded in the driver, worth knowing for any cloud-lab kill leg):

  • docker compose run STARTS the run's dependencies — it resurrected the deliberately-stopped sidecar before every outage payment, silently converting outage legs into happy-path runs. The kill legs use --no-deps.
  • The replay-after-ambiguous-outcome is a legitimate grant when the swap never reached the mint — a 'replay must never grant' assert is wrong; the double-grant invariant is about value, not response codes.

… legs C+D prove it module-level

THE GAP (RED first: TestSidecarReceiveAmbiguity_SurfacesAsOutcomeUnknown
failed on the pre-fix tree): the sidecar client returned raw
ErrSidecarAmbiguous from a written-but-unanswered money call, but the
merchant's do-not-resubmit classification keys on ErrOutcomeUnknown —
the gonuts path maps its AmbiguousOutcomeError, the sidecar path did
not. On wallet.backend=cdk an ambiguous receive fell into the generic
'payment-processing-failed' notice: no outcome-unknown guidance, the
customer resubmits, and if the daemon did execute the swap the resubmit
is refused as spent — value taken, no session, the #258/#640 class on
the new backend.

Fix: SidecarWallet.call wraps ambiguity for every non-read-only method
as ErrOutcomeUnknown (ErrSidecarAmbiguous stays wrapped inside for
diagnostics).

MODULE-LEVEL PROOF — two new e2e legs (run-cdk-backend-e2e.sh, 3x full
runs green, 7 checks each):
- LEG C (quote persistence): a Lightning quote created through the
  module survives a cdk-walletd restart — post-restart poll shows the
  quote ISSUED with access granted (allotment 6M): the paid invoice was
  recognized AFTER the restart.
- LEG D (deterministic ambiguity): a unix-socket proxy between module
  and daemon forwards the receive, then DROPS the daemon's answer
  (executed-but-unanswered, no kill race). The module answers kind 21023
  code payment-outcome-unknown (the fix, live), the token is
  deterministically spent, its replay is refused, and the module
  recovers on the direct socket afterwards.

Harness notes encoded: the swallow must live in the RESPONSE pump (two
readers race on the upstream socket; a request-side swallow loses the
race and the answer gets forwarded — measured); the socket dir is a
bind mount so the host-side proxy is reachable from both containers;
pkill -f self-matches the wrapping shell (split the pattern).

tollwallet suite: no new failures (the two v2-keyset failures are the
pre-existing tbmg#1156 fixture bug on main).
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Two additions on the branch (7f28a82):

  1. A real seam fix, RED-first: the sidecar client surfaced raw ErrSidecarAmbiguous from a written-but-unanswered money call, but the merchant's do-not-resubmit classification keys on ErrOutcomeUnknown (the gonuts path maps its AmbiguousOutcomeError; the sidecar path did not). On the cdk backend an ambiguous receive hit the generic 'payment failed' notice — the customer resubmits, and if the daemon executed the swap, the resubmit is refused as spent: value taken, no session. Now SidecarWallet.call wraps ambiguity as ErrOutcomeUnknown for every non-read-only method (unit test pins it, failed on the pre-fix tree).

  2. Two new e2e legs, 3× full runs green (7 checks each):

  • LEG C quote persistence: a Lightning quote created through the module survives a cdk-walletd restart; the post-restart poll shows the paid quote ISSUED with access granted.
  • LEG D deterministic ambiguity: a unix-socket proxy forwards the module's receive to the daemon and drops the answer — executed-but-unanswered with no kill-timing race. The module answers kind 21023 code payment-outcome-unknown (the fix, live through a real socket), the deterministically-spent token's replay is refused, and the module recovers on the direct socket after the leg.

…n verdict

Found by the skill-rubric audit pass over the money paths (2026-11-01),
measured live before fixing: on wallet.backend=cdk the pre-flight
DecodeToken is an RPC to cdk-walletd, so a daemon outage surfaced as
payment-error-invalid-token — telling a paying customer their e-cash
note is fake (message even leaking dial internals). The decode-before-
Receive ordering (#403/#409) is correct; its assumption that decode is
local and infallible was true for the in-process backend only.

ErrSidecarNotConnected at decode now returns the new
payment-processing-unavailable class: 'your note was not spent —
nothing was taken — try again in a moment.' Safe-retry guidance is
correct here (the connect failed before any money op), unlike the
post-Receive ambiguity classes which must say do-not-resubmit.

RED-first: TestPurchaseSessionWalletBackendOutageIsNotAnInvalidTokenNotice
failed on the pre-fix tree. Live-verified in the rig: outage payment
now answers kind 21023 code payment-processing-unavailable; LEG A of
the e2e driver asserts the code. Full five-leg suite green with the
fix (run14).
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Audit fix (d4ff8e6), found by the state-machine review pass and measured live before fixing: on wallet.backend=cdk the pre-flight DecodeToken is an RPC to cdk-walletd, so a daemon outage surfaced as payment-error-invalid-token — telling a paying customer their e-cash note is fake. ErrSidecarNotConnected at decode now returns a new payment-processing-unavailable class ('your note was not spent — nothing was taken — try again in a moment'; safe-retry guidance is correct pre-Receive, unlike the post-Receive ambiguity classes). RED-first unit test; LEG A of the e2e driver now asserts the code (full five-leg suite green with the fix).

Also filed #1157 (the degraded-mode wallet factory bypasses wallet.backend — narrow today precisely because this decode fix stops sidecar outages from reaching the mint-health downgrade chain; the remaining window is mint-returns-while-degraded).

…river

run-module-e2e.sh parameterizes the P4 driver on BACKEND (cdk|gonuts);
run-cdk-backend-e2e.sh becomes the BACKEND=cdk shim. One declaration of
the leg logic (the drift doctrine), the same VALUE-BASED verdicts on
both backends. The gonuts lane runs the base upstream service (config
without a wallet section = the default in-process backend) with
mint-outage analogs of the kill legs; LEG D is cdk-only (no proxyable
socket in-process — its ambiguity class is covered by the wallet-layer
parity lane and the unit tests; stated in the matrix doc, not silently
dropped).

Honesty pins in the driver: LEG A on gonuts is module-survival-only
(the pay helper funds from the mint, so a mint outage kills the helper
before any POST — the non-vacuous mint-outage refusal through the
module is LEG B's killed-mid-flight first attempt, now required to be
non-empty output); the gonuts override carries the same build.network:
host rules and remaps the debug port (a sibling session holds 2121 on
this bench).

Measured (MODULE-E2E-MATRIX.md): both columns green 3 consecutive full
runs — happy 3/3, quote survives the dependency restart (ISSUED +
granted, both), single-grant value verdict on kill-mid-payment (both),
cdk: outage = payment-processing-unavailable, LEG D =
payment-outcome-unknown with spent replay refused. With this, module-
level conformance is pinned alongside the wallet-layer parity lane;
every software gate the gonuts-demotion decision needs now exists.
…tags

Slipped out of 7f28a82: LEG A/D assert on the notice's machine-readable
code tag, which --verbose must include.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

The module-level conformance matrix is in (7a46baf + c7f151b): run-module-e2e.sh parameterizes the driver on BACKEND (cdk|gonuts) — one declaration of the leg logic, the same value-based verdicts. The gonuts lane runs the base upstream (no wallet section = the default in-process backend) with mint-outage analogs; LEG D is cdk-only (no proxyable socket in-process; its ambiguity class is covered by the wallet-layer parity lane and the unit tests — stated in the matrix doc, not silently dropped).

Measured (tests/cloud-lab/MODULE-E2E-MATRIX.md), both columns green across consecutive full runs:

  • Happy 3/3 pytest on BOTH backends; replay refused on both.
  • Quote survives the money-dependency restart on both (cdk: sidecar restart; gonuts: mint restart — ISSUED + access granted post-restart in both).
  • Kill-mid-payment: single-grant value verdict on both; gonuts' first attempt shows the non-vacuous mint-outage refusal (21023) through the module.
  • cdk: outage = payment-processing-unavailable; LEG D = payment-outcome-unknown with the deterministically-spent replay refused.

With this, every software gate the gonuts-demotion decision needs exists at both layers: wallet-level (three-backend parity, mutation-checked) and module-level (this matrix). The demotion itself remains an owner call.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Local CI gate — PR #1155: feat(merchant): wallet.backend selection wired through the OpenWallet seam

Run of scripts/pr-verify.sh (the local CI gate: GitHub Actions is
org-blocked and the ngit lane fires only on maintainer mirror pushes,
so a PR ref gets no CI runs — this comment is the offline verification
evidence, produced on the pinned head SHA).

  • Head: feat/wallet-backend-wiring @ c7f151b213
  • Base: main @ 6aabea2d36
  • Gate: pr-verify 294317251383 on ai-legion, started 2026-10-31 12:07 UTC, wall 1706s
  • Logs: /tmp/tollgate-pr-verify/1155-20261031T113911Z (on the verifying host)
Leg Result Detail
hygiene PASS no conflict markers in the tracked tree (0s)
contract-fences FAIL red: changelog fragments (assemble check) doc-facts contract dependency sync — see contract-fences.log; 13 checks green (13s)
packaging-sequential FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T113911Z/packaging-sequential (441s)
shell-suites FAIL red (2/23): uci-defaults-gatewayname-banner_test.sh uci-defaults-lan-private-wired_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T113911Z/shell-suites (1244s)
packaging-concurrent FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T113911Z/packaging-concurrent (8s)
go-battery FAIL 19 modules — see go-battery.log (0s)

Verdict: 5 leg(s) RED — 1 green.

What this gate does not run (no vacuous green)
  • spec-quote drift check (greatspectations tool + pinned nuts fetch)
  • reproducibility / conformance lanes (make release-check territory)
  • router-visible behaviour (PRTA / bench — unit green ≠ router works)
failing-leg log tails
contract-fences — tail

🔴 google.golang.org/genproto/googleapis/rpc — 2 versions:
   root: v0.0.0-20260928230214-8a89bd6388cc
   tollwallet: v0.0.0-20260720211330-0afa2a65878a

🔴 google.golang.org/grpc — 2 versions:
   root: v1.83.2
   tollwallet: v1.82.1

Fix: update all go.mod files to the same version.
     Run 'go mod tidy' in each sub-module after updating.
== gonuts pin fence (offline identity)
ok   an aligned tree (block + single-line carriers) passes
ok   a block-form carrier on a different version
ok   a single-line carrier left behind (the token-recovery shape)
ok   a go.sum that never recorded the truth version (half-update)
ok   a self-consistent pseudo-version integration pin passes OFFLINE (the --latest leg is the release gate for it)
ok   a manifest without the .gonuts key
ok   a tree whose manifest names a pin no carrier has

7 cases, 0 failed
== gonuts pin carriers
  manifest pin (packaging/build-inputs.json .gonuts.version): v0.14.1
  carriers discovered by glob: 5 module(s)
✅ one pin, everywhere: github.com/OpenTollGate/gonuts-tollgate v0.14.1 across 5 carrier(s).
== mirror/relay sync
ok    BLOSSOM_SERVERS: 13 copies identical (https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech)
ok    relay list: 13 copies identical (wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech)
== stale import paths
✅ All 359 Go source files use correct import paths.
packaging-sequential — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed
shell-suites — tail
--- uci-defaults-gatewayname-banner_test.sh.log
ok   the FULL path's banner IS the canonical one from the shared table
ok   reinstall (same version) run exits 0
ok   the same-version reinstall took the VERIFY branch
ok   the repair path COMMITS the banner '!TollGate-OQ3Q Portal'
ok   the repair path's banner is the SAME value the full path writes
ok   no gatewayname op is left pending in the /tmp/.uci-style delta
FAIL the repair path's captive SSID converged on the same code (got '!tollgate-0GLK', want '!TollGate-OQ3Q')
FAIL the banner is exactly '<captive SSID> Portal' (got '!TollGate-OQ3Q', want '!tollgate-0GLK')
ok   the operator's PSK is untouched
ok   drifting reinstall run exits 0
ok   the drifting repair path commits the SAME banner
ok     ...and the drifted allow-list rule was repaired (the commit did fire)
ok     ...with no gatewayname op left pending

27 passed, 2 failed
--- uci-defaults-lan-private-wired_test.sh.log
ok   silent no-op: the log names the missing port
== empty source and empty target: reported as an error, nothing written
ok   empty source+target: writer returns non-zero (1)
ok   empty source+target: private_bridge still has no ports
ok   empty source+target: log reports the broken state
== bridge device section beyond index 23 is still found and moved
ok   late bridge: writer returns 0
ok   network.private_bridge lists eth1 exactly once
ok   late bridge: the source section was cleared
== negative control: a script with a no-op writer fails the assertions
ok   negative control: the writer is neutralised in the copied script
ok   negative control: without the writer the port stays on the captive bridge
ok   negative control: the captive bridge keeps its port (the move assertion would fail)

lan-private-wired: 8 check(s) FAILED, 35 passed
packaging-concurrent — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed
go-battery — tail
=== module: src
gofmt: unformatted files (fix with: gofmt -w .):
merchant/unmeterable_session_cadence_test.go
make: *** [Makefile:13: go-battery] Error 1

…1157)

The audit's split-brain finding: all three degrade paths (the two
boot-time branches and the runtime downgrade via
NewMerchantDegradedFromFull) hardcoded DefaultWalletFactory — the
legacy in-process gonuts constructor — so under wallet.backend=cdk a
degrade window could silently construct a SECOND wallet implementation
and split operator value away from the chosen sidecar.

walletFactoryForBackend(configManager) routes the degraded wallet
construction through the same selection seam: backend=cdk constructs
via openServiceWallet (a dead sidecar yields a wallet-LESS degraded
merchant — fail closed, never a silent fallback); the default backend
keeps DefaultWalletFactory byte-identical.

RED-first: TestDegradedWalletFactoryRespectsBackendSelection failed on
the pre-fix tree (walletFactoryForBackend did not exist); now pins (a)
cdk + dead socket → factory error, no wallet, no legacy artifacts,
(b) default → legacy factory unchanged, (c) the runtime downgrade path
constructs wallet-less on a dead sidecar.

Gates: merchant suite green except the two StorageMmap tests —
pre-existing on pristine origin/main, filed as #1158 (fixture writes
no config; verified in a throwaway worktree before claiming it).
Module e2e cdk lane: full five-leg suite green with the change.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Closes #1157 (dd2ddd7): the degraded-mode wallet factory now routes through the backend selection at all three degrade paths — backend=cdk constructs via openServiceWallet and a dead sidecar yields a wallet-LESS degraded merchant (fail closed, never a silent second wallet implementation); the default backend keeps DefaultWalletFactory byte-identical. RED-first test pins all three (factory error + no legacy artifacts under cdk; legacy path unchanged; runtime downgrade wallet-less on dead socket). Module e2e cdk lane: full five-leg suite green with the change.

One pre-existing note: the merchant suite's two StorageMmap tests fail on PRISTINE origin/main too (verified in a throwaway worktree) — filed as #1158, a fixture regression (the setup writes no config, so the injected storage error is never recorded and ops blame the mints). Not from this branch.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Local CI gate — PR #1155: feat(merchant): wallet.backend selection wired through the OpenWallet seam

Run of scripts/pr-verify.sh (the local CI gate: GitHub Actions is
org-blocked and the ngit lane fires only on maintainer mirror pushes,
so a PR ref gets no CI runs — this comment is the offline verification
evidence, produced on the pinned head SHA).

  • Head: feat/wallet-backend-wiring @ dd2ddd7189
  • Base: main @ 6aabea2d36
  • Gate: pr-verify 294317251383 on ai-legion, started 2026-10-31 14:04 UTC, wall 141s
  • Logs: /tmp/tollgate-pr-verify/1155-20261031T140148Z (on the verifying host)
Leg Result Detail
hygiene PASS no conflict markers in the tracked tree (0s)
contract-fences FAIL red: changelog fragments (assemble check) doc-facts contract dependency sync — see contract-fences.log; 13 checks green (10s)
packaging-sequential FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T140148Z/packaging-sequential (21s)
shell-suites FAIL red (2/23): uci-defaults-gatewayname-banner_test.sh uci-defaults-lan-private-wired_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T140148Z/shell-suites (99s)
packaging-concurrent FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T140148Z/packaging-concurrent (10s)
go-battery FAIL 19 modules — see go-battery.log (1s)

Verdict: 5 leg(s) RED — 1 green.

What this gate does not run (no vacuous green)
  • spec-quote drift check (greatspectations tool + pinned nuts fetch)
  • reproducibility / conformance lanes (make release-check territory)
  • router-visible behaviour (PRTA / bench — unit green ≠ router works)
failing-leg log tails
contract-fences — tail

🔴 google.golang.org/genproto/googleapis/rpc — 2 versions:
   root: v0.0.0-20260928230214-8a89bd6388cc
   tollwallet: v0.0.0-20260720211330-0afa2a65878a

🔴 google.golang.org/grpc — 2 versions:
   root: v1.83.2
   tollwallet: v1.82.1

Fix: update all go.mod files to the same version.
     Run 'go mod tidy' in each sub-module after updating.
== gonuts pin fence (offline identity)
ok   an aligned tree (block + single-line carriers) passes
ok   a block-form carrier on a different version
ok   a single-line carrier left behind (the token-recovery shape)
ok   a go.sum that never recorded the truth version (half-update)
ok   a self-consistent pseudo-version integration pin passes OFFLINE (the --latest leg is the release gate for it)
ok   a manifest without the .gonuts key
ok   a tree whose manifest names a pin no carrier has

7 cases, 0 failed
== gonuts pin carriers
  manifest pin (packaging/build-inputs.json .gonuts.version): v0.14.1
  carriers discovered by glob: 5 module(s)
✅ one pin, everywhere: github.com/OpenTollGate/gonuts-tollgate v0.14.1 across 5 carrier(s).
== mirror/relay sync
ok    BLOSSOM_SERVERS: 13 copies identical (https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech)
ok    relay list: 13 copies identical (wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech)
== stale import paths
✅ All 360 Go source files use correct import paths.
packaging-sequential — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed
shell-suites — tail
--- uci-defaults-gatewayname-banner_test.sh.log
ok   the FULL path's banner IS the canonical one from the shared table
ok   reinstall (same version) run exits 0
ok   the same-version reinstall took the VERIFY branch
ok   the repair path COMMITS the banner '!TollGate-OQ3Q Portal'
ok   the repair path's banner is the SAME value the full path writes
ok   no gatewayname op is left pending in the /tmp/.uci-style delta
FAIL the repair path's captive SSID converged on the same code (got '!tollgate-0GLK', want '!TollGate-OQ3Q')
FAIL the banner is exactly '<captive SSID> Portal' (got '!TollGate-OQ3Q', want '!tollgate-0GLK')
ok   the operator's PSK is untouched
ok   drifting reinstall run exits 0
ok   the drifting repair path commits the SAME banner
ok     ...and the drifted allow-list rule was repaired (the commit did fire)
ok     ...with no gatewayname op left pending

27 passed, 2 failed
--- uci-defaults-lan-private-wired_test.sh.log
ok   silent no-op: the log names the missing port
== empty source and empty target: reported as an error, nothing written
ok   empty source+target: writer returns non-zero (1)
ok   empty source+target: private_bridge still has no ports
ok   empty source+target: log reports the broken state
== bridge device section beyond index 23 is still found and moved
ok   late bridge: writer returns 0
ok   network.private_bridge lists eth1 exactly once
ok   late bridge: the source section was cleared
== negative control: a script with a no-op writer fails the assertions
ok   negative control: the writer is neutralised in the copied script
ok   negative control: without the writer the port stays on the captive bridge
ok   negative control: the captive bridge keeps its port (the move assertion would fail)

lan-private-wired: 8 check(s) FAILED, 35 passed
packaging-concurrent — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed
go-battery — tail
=== module: src
gofmt: unformatted files (fix with: gofmt -w .):
merchant/unmeterable_session_cadence_test.go
make: *** [Makefile:13: go-battery] Error 1

One page: the question (maintained fallback vs unmaintained legacy vs
removal), the evidence table (every gate now green at both layers, with
pointers), the honest not-done list (on-device proof, CI billing, the
removal-only migration path), the risks of demoting, and the
recommendation — (b) unmaintained legacy fallback now, revisit removal
after on-device proof and the token-transfer rehearsal.

No code change rides on the answer before it; the policy default is
already cdk. Demotion changes engineering attention, not shipped
behavior.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Added docs/architecture/gonuts-demotion-readiness.md (this branch): the one-page decision brief for gonuts's status — every gate now green at both layers (parity mutation-checked, module conformance matrix, ambiguity semantics, degrade safety, packaging, rollback path), the honest not-done list, risks, and a recommendation (unmaintained legacy fallback now; removal only after on-device proof + migration rehearsal). It's a read, not an investigation.

…le-proven on BOTH backends

The last 'covered elsewhere' asterisk in the conformance matrix closed:
the gonuts lane now routes its mint traffic through the in-network
killproxy (promoted from the parity lane, PR #1137, bind widened for
in-network use) and LEG D arms it to swallow the mint's swap RESPONSE
after the mint processed the request — the same deterministic
executed-but-unanswered injection as the cdk lane's sock proxy, at the
in-process backend's own dependency.

Measured: gonuts LEG D → payment-outcome-unknown (do-not-resubmit),
deterministically-spent replay refused, passthrough recovery — 3 of 4
runs; the 4th granted through the armed proxy (a rig-timing race,
candidate: the helper's funding send-swap racing the arm, or the
client retry landing between swallow cycles) — documented in the
matrix and filed for follow-up. Two different underlying mechanisms
(ErrSidecarAmbiguous vs gonuts AmbiguousOutcomeError) now provably
produce the SAME customer-facing class at the module level: that is
the conformance claim.

Also: LEG B's kill is now STAGING-triggered on both lanes (the event
file appearing means the POST is in flight — the fixed offset kept
landing during the gonuts lane's funding phase); the staging window is
30s (cold funding through the proxy exceeds 5s); the quote helper
honors MINT_URL. cdk lane green with the changes; both lanes' full
suites green.
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

The gonuts LEG D landed (3f4660b): the ambiguity class is now module-proven on BOTH backends — the gonuts lane routes mint traffic through the in-network killproxy and LEG D swallows the mint's swap response after the mint processed it, producing the same payment-outcome-unknown class from a different underlying mechanism (gonuts AmbiguousOutcomeError vs ErrSidecarAmbiguous). 3 of 4 runs green; the swallow-race flake is documented in the matrix and filed (see the new issue). LEG B's kill is now staging-triggered on both lanes. cdk lane green with the changes.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Local CI gate — PR #1155: feat(merchant): wallet.backend selection wired through the OpenWallet seam

Run of scripts/pr-verify.sh (the local CI gate: GitHub Actions is
org-blocked and the ngit lane fires only on maintainer mirror pushes,
so a PR ref gets no CI runs — this comment is the offline verification
evidence, produced on the pinned head SHA).

  • Head: feat/wallet-backend-wiring @ 3f4660bc42
  • Base: main @ 6aabea2d36
  • Gate: pr-verify 294317251383 on ai-legion, started 2026-10-31 20:26 UTC, wall 81s
  • Logs: /tmp/tollgate-pr-verify/1155-20261031T202525Z (on the verifying host)
Leg Result Detail
hygiene PASS no conflict markers in the tracked tree (0s)
contract-fences FAIL red: changelog fragments (assemble check) doc-facts contract dependency sync — see contract-fences.log; 13 checks green (5s)
packaging-sequential FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T202525Z/packaging-sequential (13s)
shell-suites FAIL red (2/23): uci-defaults-gatewayname-banner_test.sh uci-defaults-lan-private-wired_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T202525Z/shell-suites (58s)
packaging-concurrent FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261031T202525Z/packaging-concurrent (5s)
go-battery FAIL 19 modules — see go-battery.log (0s)

Verdict: 5 leg(s) RED — 1 green.

What this gate does not run (no vacuous green)
  • spec-quote drift check (greatspectations tool + pinned nuts fetch)
  • reproducibility / conformance lanes (make release-check territory)
  • router-visible behaviour (PRTA / bench — unit green ≠ router works)
failing-leg log tails
contract-fences — tail

🔴 google.golang.org/genproto/googleapis/rpc — 2 versions:
   root: v0.0.0-20260928230214-8a89bd6388cc
   tollwallet: v0.0.0-20260720211330-0afa2a65878a

🔴 google.golang.org/grpc — 2 versions:
   root: v1.83.2
   tollwallet: v1.82.1

Fix: update all go.mod files to the same version.
     Run 'go mod tidy' in each sub-module after updating.
== gonuts pin fence (offline identity)
ok   an aligned tree (block + single-line carriers) passes
ok   a block-form carrier on a different version
ok   a single-line carrier left behind (the token-recovery shape)
ok   a go.sum that never recorded the truth version (half-update)
ok   a self-consistent pseudo-version integration pin passes OFFLINE (the --latest leg is the release gate for it)
ok   a manifest without the .gonuts key
ok   a tree whose manifest names a pin no carrier has

7 cases, 0 failed
== gonuts pin carriers
  manifest pin (packaging/build-inputs.json .gonuts.version): v0.14.1
  carriers discovered by glob: 5 module(s)
✅ one pin, everywhere: github.com/OpenTollGate/gonuts-tollgate v0.14.1 across 5 carrier(s).
== mirror/relay sync
ok    BLOSSOM_SERVERS: 13 copies identical (https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech)
ok    relay list: 13 copies identical (wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech)
== stale import paths
✅ All 360 Go source files use correct import paths.
packaging-sequential — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed
shell-suites — tail
--- uci-defaults-gatewayname-banner_test.sh.log
ok   the FULL path's banner IS the canonical one from the shared table
ok   reinstall (same version) run exits 0
ok   the same-version reinstall took the VERIFY branch
ok   the repair path COMMITS the banner '!TollGate-OQ3Q Portal'
ok   the repair path's banner is the SAME value the full path writes
ok   no gatewayname op is left pending in the /tmp/.uci-style delta
FAIL the repair path's captive SSID converged on the same code (got '!tollgate-0GLK', want '!TollGate-OQ3Q')
FAIL the banner is exactly '<captive SSID> Portal' (got '!TollGate-OQ3Q', want '!tollgate-0GLK')
ok   the operator's PSK is untouched
ok   drifting reinstall run exits 0
ok   the drifting repair path commits the SAME banner
ok     ...and the drifted allow-list rule was repaired (the commit did fire)
ok     ...with no gatewayname op left pending

27 passed, 2 failed
--- uci-defaults-lan-private-wired_test.sh.log
ok   silent no-op: the log names the missing port
== empty source and empty target: reported as an error, nothing written
ok   empty source+target: writer returns non-zero (1)
ok   empty source+target: private_bridge still has no ports
ok   empty source+target: log reports the broken state
== bridge device section beyond index 23 is still found and moved
ok   late bridge: writer returns 0
ok   network.private_bridge lists eth1 exactly once
ok   late bridge: the source section was cleared
== negative control: a script with a no-op writer fails the assertions
ok   negative control: the writer is neutralised in the copied script
ok   negative control: without the writer the port stays on the captive bridge
ok   negative control: the captive bridge keeps its port (the move assertion would fail)

lan-private-wired: 8 check(s) FAILED, 35 passed
packaging-concurrent — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed
go-battery — tail
=== module: src
gofmt: unformatted files (fix with: gofmt -w .):
merchant/unmeterable_session_cadence_test.go
make: *** [Makefile:13: go-battery] Error 1

…ic capture, probe mode

Rig improvements from the gonuts LEG D swallow-race investigation
(3/5 green with these; the race itself remains open):

- The pay helper can ARM an injector file between staging and its own
  POST (--arm-file; the client mounts the proxy's state dir) — removes
  both the driver-poll arm latency and the funding-swap ordering race.
- The pay helper gained --no-post (fund + stage an event without
  posting) for out-of-band protocol probes.
- dump_logs captures the killproxy container log on the gonuts lane;
  the mint runs with RUST_LOG for the same reason.
- LEG D-gonuts asserts unchanged (correct when the leg engages).

Tonight's forensics (in the #1163 comment): in failing runs the
module's gonuts Receive completes (amount=100, err=nil) with ZERO
requests reaching the armed proxy after ARMED — despite gonuts'
Receive always swapping (wallet.go:660: trusted and untrusted paths
both swapWithRetry). Two independent docker-DNS errno -3 traces
(getaddrinfo 'Try again', in the proxy's forward() and the client
container) point at bench DNS flakiness under container churn as a
contributing factor; the exact mechanism is unpinned.
The grant-despite-arm signature, solved. The DECIDE instrumentation
(logged the swallow-check tuple per watched POST) caught it red-handed:

    POST /v1/swap  ARMED  DECIDE code=200 armed=True RELEASED=True

A stale kp.marker.release from the PREVIOUS run's successful LEG D
latched released=True in the fresh killproxy process before the
driver's state-dir cleanup ran (the dir is bind-mounted and outlives
containers), and the reset branch could never clear it (it requires
'triggered', which only a successful swallow sets — and swallows were
being skipped). The module's swap WAS arriving and forwarding all
along; only the swallow was disarmed. Every earlier 'no wire request'
reading was a log-ordering artifact of reading without the tuple.

Fix, both layers:
- driver: the kp-state cleanup runs BEFORE any container starts;
- killproxy: released is state-dir-derived — a clean dir (no marker,
  no release) self-heals the latch, independent of 'triggered'.

The proxy also gained DECIDE tuples (path/code/armed/released per
watched POST) and connection-level open/close logging — this class is
now diagnosable from the log alone. The driver gained KEEP_STACK=1
(catch a failing leg with the live stack) and the pay helper gained
--no-post (fund+stage without posting, for protocol probes).
investigate-1163.sh is kept as the reproduction harness that caught it.

Stability: 6/6 consecutive full gonuts-lane runs green (previously ~3/5
with this signature; the earlier 1/4 batch was multi-causal bench
flakiness, separately documented on the issue).
The test sets an empty TOLLGATE_TEST_CONFIG_DIR and lets the ConfigManager
write factory defaults — whose accepted mints are seven REAL internet
mints, so this UNIT test probed the live network on every run and failed
on any DNS-restricted runner (measured: mint.lnserver.com unresolvable
on the bench -> go-battery's first module red for an environmental
reason).

The test now seeds the incident's SEVEN-mint shape pointed at closed
local ports (instant refusal, zero network) before the boot; the
construction-blocking stub and every assertion are untouched. Verified:
passes standalone (-tags testenv, package .).

Known separate flake (PRE-EXISTING on pristine main, verified by stash):
under 'go test ./...' the CLI-socket-in-flight assertion races
(startup_gate_test.go:116 on main; :146 with this change) and fails in
~0.01s — systematic on this bench, unrelated to this change. Filed for
follow-up.
The socket assertion was a single os.Stat on a side effect produced by
a concurrent goroutine — unsynchronized by construction. Poll with a
bounded wait (the construction is still BLOCKED at that point, so the
wait cannot outlive the in-flight window the assertion describes).

Polling also made the REAL ./... failure legible: it is not a timing
race. Under 'go test ./...' the test binary shows TWO CLI-server
starts — one at the default testenv socket path (an earlier test in
the package), then the boot's own attempt failing with 'cannot
determine ownership of existing socket ... connect: invalid argument'
(EINVAL — debris at the socket path that is not a socket, which
claimSocketLock's staleness classification does not cover). Full log
line in PR #1166; the ownership/staleness flow (src/cli/server.go
Start) needs its own follow-up.
…startup-gate uses a short config dir

THE STARTUP-GATE FLAKE, SOLVED: the CLI socket lives at
<CONFIG_DIR>/tollgate.sock and AF_UNIX caps sun_path at 108 bytes.
t.TempDir() under a long TMPDIR (28 bytes here) plus this test's
52-char name lands the path at 107-109 bytes BY RANDOM-SUFFIX LENGTH —
over the limit, bind AND dial fail with a bare EINVAL that the
ownership probe misreported as 'cannot determine ownership of existing
socket' for a path that may not even exist. Hence: deterministic-ish
flake, invisible on short-TMPDIR runners (CI), and the misleading
'double start' framing in the earlier investigation (the default
testenv path is 69 bytes and always fine).

- Start() now rejects >=108-byte paths with a readable error naming
  the limit (pinned by TestCLIServerStartRejectsOverLimitSocketPath).
- The startup-gate test uses a short /tmp config dir for its socket.

3/3 green under 'go test ./...' (the shape that failed 6/6 before).
…sists its mints (#1158)

Two halves of the #1158 breakage, both verified red on pristine
origin/main before fixing:

1. Fixture: newDegradedSetup mutated the GetConfig() COPY with its test
   mints but never persisted them — the manager kept the factory-default
   (mint-less) config, so the degraded constructor returned at the
   no-configured-mints branch and the injected wallet error was never
   recorded. Fixed the way the sibling fixtures already do it
   (SaveConfig) plus a manager reload so reads THROUGH the manager see
   the mints.

2. Product (the real bug the broken fixture was masking): the
   storage-aware walletUnavailableReason landed on main but every op
   still routed through notInitializedError -> DegradeReason, so a
   jffs2/mmap wallet reported 'wallet not initialized: no reachable
   mints' — the exact #583 misdirection (blaming fine mints for a
   filesystem the mint recovery can never fix). notInitializedError now
   surfaces the storage class first; every other reason keeps the
   existing DegradeReason text, byte-compatible.

All four StorageMmap tests green (red on main). Full merchant suite:
one clean run; one run with a single unnamed failure that did not
reproduce on rerun — noted for CI to arbitrate.
The fixture hardcoded an arbitrary keyset id over hand-picked keys —
exactly the shape the #705 forged-keyset-id guard exists to refuse, so
both TestV2Keyset tests failed on main from the day the guard shipped
(the guard is correct; the fixture was dishonest).

Adopt the shared honest-fixture helper (honest_keyset_test.go's
deriveHonestKeysetID — added for this class after #705 landed): the
advertised id is now DERIVED from the published keys, and the keys
live in one map so the id and the JSON cannot drift apart.

Both tests red on main, green here; full tollwallet suite green
(with PR #1160's merchant fix, both main battery breakages now have
fixes in review).
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Review obligation satisfied: the full 'make go-battery' is GREEN on this branch — 19 modules (gofmt, vet, build, race tests). Getting there required the main-repair chain this session produced, now cherry-picked onto the branch so it is self-sufficient and verifiably green today: the gofmt fix (#1165), the startup-gate offline seed + polled assertion + sun_path-length guard (#1166 — the flake was a 108-byte AF_UNIX socket path from long-TMPDIR temp dirs, not a race), the degraded StorageMmap fix (#1160), and the v2-keyset fixture fix (#1162). If those smaller PRs merge first, the duplicated commits here rebase away cleanly — they are byte-identical cherry-picks.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

MERGE-ORDER MANIFEST for the nine open PRs from this campaign (dependencies only — every one is independent of the others EXCEPT this one):

  1. style: gofmt the unmeterable-session cadence test #1165 (gofmt) — trivial, unblocks every battery run. No dependencies.
  2. fix(merchant): degraded op errors name the storage class; fixture persists its mints #1160 (Degraded-mode StorageMmap tests fail on main (setup writes no config; errors blame mints instead of storage) #1158 fix), test(tollwallet): make the v2-keyset spellings fixture honest #1162 (v2-keyset spellings tests fail on main: fake-mint fixture trips the #705 forged-keyset-id guard #1156 fix), test: startup-gate test seeds an offline seven-mint config #1166 (startup-gate offline + sun_path) — each red-on-main → green, independent of each other.
  3. fix(cli): reclaim non-socket debris at the CLI socket path #1168 (CLI server socket reclaim does not classify EINVAL (dial 'invalid argument' on non-socket debris refuses startup instead of reclaiming) #1167 EINVAL debris), fix(packaging): commit pending uci sessions on the install path #1164 (postinst/Makefile run uci-defaults with no trailing uci commit: an interrupted postinst can drop a pending delta #1087 uci commit), fix(packaging): the guest portal docroot respects the brand token #1170 (setup_uhttpd_portal hardcodes the GUEST portal docroot to the TollGate path — the brand token cannot select a re-brand guest portal #1122 docroot), test(packaging): entry-ui harness renderer stubs + LOGFILE via the #948 env seam (cures #1171 fully) #1172 (entry-ui fence: scenario 1 red on main — harness never stubs the absolute-path renderers added by #784 #1171 harness) — independent fixes/fences.
  4. feat(merchant): wallet.backend selection wired through the OpenWallet seam #1155 (this PR) LAST: it contains byte-identical cherry-picks of fix(merchant): degraded op errors name the storage class; fixture persists its mints #1160, test(tollwallet): make the v2-keyset spellings fixture honest #1162 and test: startup-gate test seeds an offline seven-mint config #1166's commits (taken to make the branch self-sufficient and battery-green while main was red). Merging the smaller PRs first makes those duplicates rebase away cleanly; merging feat(merchant): wallet.backend selection wired through the OpenWallet seam #1155 first makes the smaller PRs no-ops. Either order works, but 1→3 then this one is the least-conflict path.

After merges: close #1139, #1156, #1158, #1163, #1167, #1171 (all closed-loop by these PRs; each has the evidence comment). The gonuts-demotion brief (docs/architecture/gonuts-demotion-readiness.md, in this PR) is the one remaining owner decision the campaign gates on.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Four-agent PR-REVIEW.md pass (worktree-isolated): APPROVE — all 13 criteria, zero blocking. Verified in a fresh worktree: the three selection/outage test groups pass, TestSidecar passes, js-schema-lint PASS (113 schema entries), and the sidecar diff confirms ErrSidecarAmbiguous is wrapped in ErrOutcomeUnknown (not replaced) — the reconcile-never-retry class the fund-safety rules require. Non-blocking note: the compose rig was not re-executed by the reviewers (docker out of scope for the pass) — the PR's recorded five-leg × two-backend runs remain the rig evidence. Merge-order manifest posted earlier in the thread.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator

LGTM in substance — approve, with one thing to confirm before merge.

Verified on the branch, all four touched modules green:

src/cli               ok
src/config_manager    ok
src/merchant          ok
src/tollwallet        ok

(with -race, -count=1, one run per module the PR touches.) Sweep on the same head: gonuts pin fence green, go build ./... / go vet ./... rc=0, gofmt clean.

Wiring wallet.backend through the existing OpenWallet seam is the correct choice over a build-tag-only switch — it keeps the seam in one place (src/tollwallet/port.go) instead of growing a second selection mechanism, and it means the backend choice is inspectable config rather than a compile-time property. The 32-file diff is mostly the seam plumbing plus tests.

One thing I could not verify, and it matters for this PR: the cdk_wallet build-tagged half. Fetching github.com/Amperstrand/cdk-go fails here (invalid pseudo-version: … RPC failed; curl 28 Operation too slow), so go test -tags cdk_wallet cannot be set up in my environment. The default (gonuts) path is fully green above; the CDK path is untested by me.

That is worth pairing with the note I left on #1136: CI's go-test matrix runs ./... without -tags cdk_wallet, so the CDK parity suite is dark in CI as well. If this PR makes wallet.backend: cdk a supported operator setting, then the code path that setting selects needs a lane that actually executes it — otherwise the first time it runs is on a router.

Non-blocking, but I would want it answered before this ships: what happens at runtime when wallet.backend: cdk is set on a build compiled without the tag? If that is a clean, named error ("this binary was built without the cdk backend"), good — say so in the PR body. If it silently falls back to gonuts, the operator believes they selected a backend they did not get, which is the same class of misleading-state bug as the degraded health command fixed in #1143.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator

Review — PR #1155, head feat/merchant/wallet-backend-selection

Verdict: land-with-followups, after a rebase. The design is right, the two tests that matter both pass, and the one substantive money-safety concern I had is explicitly covered. It is CONFLICTING and that needs resolving first.

Verified

$ cd src && go build ./...                 # rc=0
$ cd src/merchant && go build ./...        # rc=0
$ cd src/cli && go build ./...             # rc=0

$ cd src/merchant && go test -count=1 -run "Backend" -v .
--- PASS: TestDegradedWalletFactoryRespectsBackendSelection (16.58s)
--- PASS: TestPurchaseSessionWalletBackendOutageIsNotAnInvalidTokenNotice (0.01s)
ok  	…/src/merchant	16.610s

$ cd src/config_manager && go test -count=1 .
ok  	…/src/config_manager	…

The money-safety concern — addressed, and this is the part worth crediting

The diff adds to PurchaseSession:

paymentCashuToken, err := m.tollwallet.DecodeToken(cashuToken)
if err != nil {
    // A wallet-backend outage is not a verdict about the note. On the …

That comment is the whole reason this PR is safe. Routing the wallet through a sidecar introduces a failure mode that did not exist in-process: the daemon can be down, and DecodeToken then fails for a reason that has nothing to do with the customer's token. Without this distinction the customer is told their money is invalid and re-mints — and the retry fails identically. That is the same misdirection shape as #583's "no reachable mints" and the portal's "the mint has rotated its keys": a wrong diagnosis that makes the customer act, and act wrongly.

TestPurchaseSessionWalletBackendOutageIsNotAnInvalidTokenNotice pins it. Good — that test is the difference between a refactor and a fund-safety change, and it is named for the failure mode rather than for the function.

TestDegradedWalletFactoryRespectsBackendSelection covers the other half correctly:

  • backend=cdk with a dead socket → the factory fails and returns no wallet, so the merchant degrades wallet-less;
  • and it asserts the in-process constructor's side effect does not happen — "legacy wallet artifacts created under backend=cdk" — which is the subtle one. A wallet directory created by the legacy path while running on the sidecar would be a second writer to wallet.db, and AGENTS.md's single-writer rule (Priority: P2 — multi-process wallet access assumptions: the daemon and the CLI can open the same bbolt wallet concurrently. #504) says exactly one process may hold it. Catching that in a test is better than catching it as a corrupted DB.

Design

walletFactoryForBackend(configManager) threaded through both NewMerchantDegradedWithWallet call sites, plus openServiceWallet(...) replacing the direct tollwallet.NewWalletPort(...), is the right seam: one place decides which backend, and the degraded path and the full path cannot disagree about it.

The schema addition is honest about defaults — backend: "default" with Enum: ["default","cdk"], socket defaulting to /var/run/cdk-walletd.sock, and the comment in merchant.go states the compatibility requirement: "An absent wallet section (Backend "") selects the default in-process wallet — the pre-selection behavior, byte-identical." That is the claim to hold the PR to, and the default-backend test asserts it.

Blockers / followups

  1. CONFLICTING against main. Rebase. There are three textual conflicts, one in src/merchant/merchant.go and one in src/config_manager/config_schema.go, plus src/tollwallet/v2_keyset_spellings_test.go — which is the fixture four other PRs are also editing (see below).
  2. src/tollwallet/v2_keyset_spellings_test.go conflict. Whatever version this branch carries, converge on test(tollwallet): local-fidelity jffs2 bring-up — shipped ext4-loop policy + REAL wallet init on real jffs2 (#1134 leg) #1154's: it corrects the advertised keyset id and adds TestKeysetIdIsHonestDerivation, which recomputes it from the keys. The 01e73e75… constant is the version main's Priority: P2 — NUT-13 residue-collision guard is dead code in gonuts v0.13.0 (Conduition disclosure): CheckCollidingKeysets never invoked; wallet-level fix belongs in gonuts-tollgate, tracked here #705 guard refuses, and test(tollwallet): tolerate an unreachable stored mint at startup #1142 is red on exactly that today.
  3. 2,038 lines across 32 files is a lot for "wallet.backend selection". The tests/cloud-lab/ additions (docker-compose for two backends, killproxy.py, run-module-e2e.sh, cdk_e2e_sockproxy.py) are a substantial new harness. If any of that can land separately, splitting would let the backend seam land now — it is the part with a fund-safety argument behind it.
  4. Missing test: does backend=cdk with a working socket actually route through the sidecar? The two tests prove the failure path and the default path. The success path is the one a router will run, and I could not exercise it (needs cdk-walletd).

Not verified

  • No cdk-walletd sidecar. I did not run the success path, so "wallet.backend=cdk works end to end" is not something I confirmed — only that it fails safely and that the default is unchanged.
  • No router. The sidecar socket path /var/run/cdk-walletd.sock presumes the daemon is installed; nothing here proves the package ships it or that the socket exists at boot.
  • The new tests/cloud-lab/ lanes did not run (need docker + a mint).

Context

main is red in src/merchant for TestMerchantDegraded_StorageMmap_* (#1160's fix). That is why a full-package run reads red; the backend-specific selection above is green.


Reviewed against PR-REVIEW.md. Evidence: go build in src, src/merchant, src/cli; targeted go test -run Backend in src/merchant (2 pass); go test in src/config_manager; diff read against the #504 single-writer rule.

@felixfelix-bot

Copy link
Copy Markdown
Collaborator

Review — PR #1155, head feat/merchant/wallet-backend-selection

Verdict: land-with-followups, after a rebase. The design is right, the two tests that matter both pass, and the one substantive money-safety concern I had is explicitly covered. It is CONFLICTING and that needs resolving first.

Verified

$ cd src && go build ./...                 # rc=0
$ cd src/merchant && go build ./...        # rc=0
$ cd src/cli && go build ./...             # rc=0

$ cd src/merchant && go test -count=1 -run "Backend" -v .
--- PASS: TestDegradedWalletFactoryRespectsBackendSelection (16.58s)
--- PASS: TestPurchaseSessionWalletBackendOutageIsNotAnInvalidTokenNotice (0.01s)
ok  	…/src/merchant	16.610s

$ cd src/config_manager && go test -count=1 .
ok  	…/src/config_manager	…

The money-safety concern — addressed, and this is the part worth crediting

The diff adds to PurchaseSession:

paymentCashuToken, err := m.tollwallet.DecodeToken(cashuToken)
if err != nil {
    // A wallet-backend outage is not a verdict about the note. On the …

That comment is the whole reason this PR is safe. Routing the wallet through a sidecar introduces a failure mode that did not exist in-process: the daemon can be down, and DecodeToken then fails for a reason that has nothing to do with the customer's token. Without this distinction the customer is told their money is invalid and re-mints — and the retry fails identically. That is the same misdirection shape as #583's "no reachable mints" and the portal's "the mint has rotated its keys": a wrong diagnosis that makes the customer act, and act wrongly.

TestPurchaseSessionWalletBackendOutageIsNotAnInvalidTokenNotice pins it. Good — that test is the difference between a refactor and a fund-safety change, and it is named for the failure mode rather than for the function.

TestDegradedWalletFactoryRespectsBackendSelection covers the other half correctly:

  • backend=cdk with a dead socket → the factory fails and returns no wallet, so the merchant degrades wallet-less;
  • and it asserts the in-process constructor's side effect does not happen — "legacy wallet artifacts created under backend=cdk" — which is the subtle one. A wallet directory created by the legacy path while running on the sidecar would be a second writer to wallet.db, and AGENTS.md's single-writer rule (Priority: P2 — multi-process wallet access assumptions: the daemon and the CLI can open the same bbolt wallet concurrently. #504) says exactly one process may hold it. Catching that in a test is better than catching it as a corrupted DB.

Design

walletFactoryForBackend(configManager) threaded through both NewMerchantDegradedWithWallet call sites, plus openServiceWallet(...) replacing the direct tollwallet.NewWalletPort(...), is the right seam: one place decides which backend, and the degraded path and the full path cannot disagree about it.

The schema addition is honest about defaults — backend: "default" with Enum: ["default","cdk"], socket defaulting to /var/run/cdk-walletd.sock, and the comment in merchant.go states the compatibility requirement: "An absent wallet section (Backend "") selects the default in-process wallet — the pre-selection behavior, byte-identical." That is the claim to hold the PR to, and the default-backend test asserts it.

Blockers / followups

  1. CONFLICTING against main. Rebase. There are three textual conflicts, one in src/merchant/merchant.go and one in src/config_manager/config_schema.go, plus src/tollwallet/v2_keyset_spellings_test.go — which is the fixture four other PRs are also editing (see below).
  2. src/tollwallet/v2_keyset_spellings_test.go conflict. Whatever version this branch carries, converge on test(tollwallet): local-fidelity jffs2 bring-up — shipped ext4-loop policy + REAL wallet init on real jffs2 (#1134 leg) #1154's: it corrects the advertised keyset id and adds TestKeysetIdIsHonestDerivation, which recomputes it from the keys. The 01e73e75… constant is the version main's Priority: P2 — NUT-13 residue-collision guard is dead code in gonuts v0.13.0 (Conduition disclosure): CheckCollidingKeysets never invoked; wallet-level fix belongs in gonuts-tollgate, tracked here #705 guard refuses, and test(tollwallet): tolerate an unreachable stored mint at startup #1142 is red on exactly that today.
  3. 2,038 lines across 32 files is a lot for "wallet.backend selection". The tests/cloud-lab/ additions (docker-compose for two backends, killproxy.py, run-module-e2e.sh, cdk_e2e_sockproxy.py) are a substantial new harness. If any of that can land separately, splitting would let the backend seam land now — it is the part with a fund-safety argument behind it.
  4. Missing test: does backend=cdk with a working socket actually route through the sidecar? The two tests prove the failure path and the default path. The success path is the one a router will run, and I could not exercise it (needs cdk-walletd).

Not verified

  • No cdk-walletd sidecar. I did not run the success path, so "wallet.backend=cdk works end to end" is not something I confirmed — only that it fails safely and that the default is unchanged.
  • No router. The sidecar socket path /var/run/cdk-walletd.sock presumes the daemon is installed; nothing here proves the package ships it or that the socket exists at boot.
  • The new tests/cloud-lab/ lanes did not run (need docker + a mint).

Context

main is red in src/merchant for TestMerchantDegraded_StorageMmap_* (#1160's fix). That is why a full-package run reads red; the backend-specific selection above is green.


Reviewed against PR-REVIEW.md. Evidence: go build in src, src/merchant, src/cli; targeted go test -run Backend in src/merchant (2 pass); go test in src/config_manager; diff read against the #504 single-writer rule.


Settled: full src/merchant package green on this branch

My earlier evidence was two targeted tests. The full package, run on this branch's tip with nothing else touching the worktree:

$ cd src/merchant && go test -count=1 .
ok  	github.com/OpenTollGate/tollgate-module-basic-go/src/merchant	398.044s

398 seconds, whole package, zero failures — including the payment-timing tests AGENTS.md names as the known flake class.

One false alarm to record, because it will bite the next reviewer: an intermediate run reported --- FAIL: TestWalletDirSingleSeam. That test does not exercise code — it os.ReadFiles merchant.go and merchant_degraded.go relative to the working directory and greps for a hazard literal. Run while the checkout is mid-switch, it reads a mixed tree and fails with a message that looks like a real regression (derives a wallet dir directly). On this branch's stable tip the literal does not appear at all — merchant.go:531 and :572 both read walletDirPath := walletDirFor(configManager) — and the test passes repeatedly.

Worth knowing generally: a source-grep test cannot distinguish "the code regressed" from "you ran it in a dirty or half-checked-out tree".

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Local CI gate — PR #1155: feat(merchant): wallet.backend selection wired through the OpenWallet seam

Run of scripts/pr-verify.sh (the local CI gate: GitHub Actions is
org-blocked and the ngit lane fires only on maintainer mirror pushes,
so a PR ref gets no CI runs — this comment is the offline verification
evidence, produced on the pinned head SHA).

  • Head: feat/wallet-backend-wiring @ c40422f4d4
  • Base: main @ 6aabea2d36
  • Gate: pr-verify 6f304ed0e72d on ai-legion, started 2026-11-05 03:31 UTC, wall 706s
  • Logs: /tmp/tollgate-pr-verify/1155-20261105T031938Z (on the verifying host)
Leg Result Detail
hygiene PASS no conflict markers in the tracked tree (0s)
contract-fences FAIL red: changelog fragments (assemble check) doc-facts contract dependency sync — see contract-fences.log; 13 checks green (11s)
packaging-sequential FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261105T031938Z/packaging-sequential (20s)
shell-suites FAIL red (2/23): uci-defaults-gatewayname-banner_test.sh uci-defaults-lan-private-wired_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261105T031938Z/shell-suites (77s)
packaging-concurrent FAIL red (3/17): backend-api-owner-network_test.sh entry-ui-install-sequence_test.sh rebrand-literal-gutter_test.sh — logs in /tmp/tollgate-pr-verify/1155-20261105T031938Z/packaging-concurrent (5s)
go-battery PASS 19 modules, gofmt/vet/build/-race (593s)

Verdict: 4 leg(s) RED — 2 green.

What this gate does not run (no vacuous green)
  • spec-quote drift check (greatspectations tool + pinned nuts fetch)
  • reproducibility / conformance lanes (make release-check territory)
  • router-visible behaviour (PRTA / bench — unit green ≠ router works)
failing-leg log tails
contract-fences — tail

🔴 google.golang.org/genproto/googleapis/rpc — 2 versions:
   root: v0.0.0-20260928230214-8a89bd6388cc
   tollwallet: v0.0.0-20260720211330-0afa2a65878a

🔴 google.golang.org/grpc — 2 versions:
   root: v1.83.2
   tollwallet: v1.82.1

Fix: update all go.mod files to the same version.
     Run 'go mod tidy' in each sub-module after updating.
== gonuts pin fence (offline identity)
ok   an aligned tree (block + single-line carriers) passes
ok   a block-form carrier on a different version
ok   a single-line carrier left behind (the token-recovery shape)
ok   a go.sum that never recorded the truth version (half-update)
ok   a self-consistent pseudo-version integration pin passes OFFLINE (the --latest leg is the release gate for it)
ok   a manifest without the .gonuts key
ok   a tree whose manifest names a pin no carrier has

7 cases, 0 failed
== gonuts pin carriers
  manifest pin (packaging/build-inputs.json .gonuts.version): v0.14.1
  carriers discovered by glob: 5 module(s)
✅ one pin, everywhere: github.com/OpenTollGate/gonuts-tollgate v0.14.1 across 5 carrier(s).
== mirror/relay sync
ok    BLOSSOM_SERVERS: 13 copies identical (https://blossom.primal.net https://blossom.psbt.me https://blossom2.orangesync.tech)
ok    relay list: 13 copies identical (wss://relay.damus.io wss://nos.lol wss://nostr.mom wss://relay2.orangesync.tech)
== stale import paths
✅ All 360 Go source files use correct import paths.
packaging-sequential — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed
shell-suites — tail
--- uci-defaults-gatewayname-banner_test.sh.log
ok   the FULL path's banner IS the canonical one from the shared table
ok   reinstall (same version) run exits 0
ok   the same-version reinstall took the VERIFY branch
ok   the repair path COMMITS the banner '!TollGate-OQ3Q Portal'
ok   the repair path's banner is the SAME value the full path writes
ok   no gatewayname op is left pending in the /tmp/.uci-style delta
FAIL the repair path's captive SSID converged on the same code (got '!tollgate-0GLK', want '!TollGate-OQ3Q')
FAIL the banner is exactly '<captive SSID> Portal' (got '!TollGate-OQ3Q', want '!tollgate-0GLK')
ok   the operator's PSK is untouched
ok   drifting reinstall run exits 0
ok   the drifting repair path commits the SAME banner
ok     ...and the drifted allow-list rule was repaired (the commit did fire)
ok     ...with no gatewayname op left pending

27 passed, 2 failed
--- uci-defaults-lan-private-wired_test.sh.log
ok   silent no-op: the log names the missing port
== empty source and empty target: reported as an error, nothing written
ok   empty source+target: writer returns non-zero (1)
ok   empty source+target: private_bridge still has no ports
ok   empty source+target: log reports the broken state
== bridge device section beyond index 23 is still found and moved
ok   late bridge: writer returns 0
ok   network.private_bridge lists eth1 exactly once
ok   late bridge: the source section was cleared
== negative control: a script with a no-op writer fails the assertions
ok   negative control: the writer is neutralised in the copied script
ok   negative control: without the writer the port stays on the captive bridge
ok   negative control: the captive bridge keeps its port (the move assertion would fail)

lan-private-wired: 8 check(s) FAILED, 35 passed
packaging-concurrent — tail
--- backend-api-owner-network_test.sh.log
ok   the fragment guards exactly :2121
ok   packaging/files/etc/nftables.d/30-backend-firewall.nft is an include fragment (no own table declaration)
ok   the chain hooks input at priority -1 (the drop is authoritative)
ok   chain 'backend_input_firewall' is declared once
== the admin-board and LuCI guards are still captive-bridge-scoped
ok   31-admin-board-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
ok   32-luci-not-guest-reachable.nft is scoped by $tg_portal_if (the captive bridge's define)
== the recipe installs the rule
ok   local-build-ipk.sh installs 30-backend-firewall.nft
ok   packaging/Makefile installs the 30-backend-firewall.nft rule via its *.nft glob
ok   packaging/Makefile FILES_ lists /etc/nftables.d/30-backend-firewall.nft
== the fragment parses
FAIL nft -c rejects the fragment — 'nft -f' would fail the whole fw4 reload

passed=17 failed=1
--- entry-ui-install-sequence_test.sh.log

== scenario 4b: converged box, root hash emptied — 999 re-refuses the board
ok   scenario 4b: :8080 is unbound — the refused board's own pair
ok   scenario 4b: :443 is unbound — the refused board's own pair
ok   scenario 4b: exactly one section binds :8090 (main)
ok   scenario 4b: exactly one section binds :8443 (main)
ok   scenario 4b: uhttpd.main keeps at least one listener
ok   scenario 4b: uhttpd.portal keeps at least one listener
ok   scenario 4b: uhttpd.trusted keeps at least one listener
ok   scenario 4b: the admin board is deliberately refused (fail-closed gate), not torn
ok   scenario 4b: LuCI (uhttpd.main) answers :8090 on both families
ok   scenario 4b: LuCI (uhttpd.main) answers :8443 on both families
ok   scenario 4b: the board is refused — its pair is intentionally unbound

tests: 72 passed, 1 failed
--- rebrand-literal-gutter_test.sh.log

== negative control: the scan pattern sees a planted occurrence
ok   the pattern matches a planted occurrence (the gutter is not inert)
ok   the pattern does not match a near-miss brand name (it is anchored to the banned literal)
ok   scan_tree ignores the control file (it is outside the tracked tree)

== the shipped uci-defaults reference only known uhttpd sections
ok   every uhttpd.<name> in the uci-defaults' active code is a known section (or a cert/key path)

== control: the section tokenizer ignores comments, not code
ok   comment-only section names (full-line and trailing) are not flagged
ok   known sections in active code are still extracted
ok   a foreign section in active code is still extracted (the check keeps its teeth)

tests: 8 passed, 1 failed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants