Skip to content

swapwallet: harden credit activity projection - #954

Merged
bhandras merged 2 commits into
mainfrom
agent/credit-activity-review-fixes
Jul 15, 2026
Merged

swapwallet: harden credit activity projection#954
bhandras merged 2 commits into
mainfrom
agent/credit-activity-review-fixes

Conversation

@bhandras

Copy link
Copy Markdown
Member

Summary

This follow-up addresses every correctness finding from sputn1ck’s post-merge review of #950.

The activity store now treats terminal state as monotonic, emits the same enriched snapshot it persisted, and recognizes request-only enrichment. Credit activity suppression now requires explicit wallet-local ownership instead of relying on the server settlement rail alone, while durable polling retries terminal projection after transient store failures.

Commit structure

activity: Preserve monotonic rich projections

  • rejects stale PENDING projections once an activity row is terminal;
  • makes the state guard part of the SQL upsert so concurrent writers cannot race around an in-memory check;
  • loads and merges immutable note, request, creation, and correlation context before persistence;
  • appends and emits the effective merged snapshot rather than a sparse terminal input;
  • compares request JSON semantically so request-only enrichment is durable;
  • returns the affected-row count from the SQL upsert and suppresses events for rejected backward transitions; and
  • documents every exported ActivityProjection field and the effective-entry return contract.

swapwallet: Track credit activity ownership

  • records credit-only swaps admitted through this wallet’s durable credit registry;
  • reconstructs ownership from durable credit operations after restart;
  • suppresses the generic swap summary only when both CREDIT settlement and local ownership are present;
  • preserves public StartPay CREDIT activity when no wallet-local credit operation owns it; and
  • advances the projector memo and clears pending tracking only after a successful durable projection, so transient store errors are retried.

Races covered

A fast internal settlement may project COMPLETE before the router writes its initial PENDING row. The SQL transition guard now leaves COMPLETE authoritative and does not append a stale event.

A sparse terminal credit projection may race a rich pending receive or pay projection. The projector now merges immutable context before both the current-row upsert and event append, keeping live and replayed events identical.

A terminal store write may fail transiently. Polling now retains the prior memo and pending marker until the durable write succeeds, allowing the unchanged terminal operation to be retried on the next poll.

Validation

  • make fmt-changed-check base=origin/main
  • make sqlc-check
  • make commitmsg-lint range=origin/main..HEAD
  • go test ./db -count=1
  • go test -tags="swapruntime wavewalletrpc" ./swapwallet -count=1
  • make lint-local

All checks pass locally, including all 71 configured linters.

Follow-up to #950.

@bhandras

Copy link
Copy Markdown
Member Author

@claude review

Please review this two-commit follow-up to #950 with sputn1ck’s five post-merge findings as the acceptance criteria.

Commit one makes activity projection monotonic and rich: terminal state cannot regress to PENDING even under concurrent writers; sparse lifecycle updates merge immutable context before both persistence and emission; request-only enrichment is compared semantically; and live plus resumable events carry the effective stored snapshot.

Commit two makes credit ownership explicit and durable: a CREDIT settlement is suppressed only when a wallet-local credit operation owns its payment hash, ownership is reconstructed after restart, and terminal polling advances its memo only after a successful durable write.

Please focus on concurrent state ordering, whether public StartPay CREDIT activity remains visible, effective event snapshot parity, and retry behavior after a transient ActivityStore failure. Formatting, SQLC generation, commit-message lint, focused db/swapwallet tests, and all 71 configured linters pass locally.

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @bhandras's task in 4m 9s —— View job


Review — PR #954: swapwallet: harden credit activity projection

  • Review commit 1 — monotonic & rich activity projection
  • Review commit 2 — credit ownership tracking & durable polling
  • Verify the four focus areas
  • Post consolidated review

I reviewed both commits against sputn1ck's five findings, with the four focus areas as acceptance criteria. Overall this is a solid, well-tested follow-up — the monotonic guard, the effective-entry merge, and the durable-retry gating all land cleanly, and the new tests exercise exactly the races described. Below are the per-area verdicts plus a couple of notes worth confirming before merge.


1. Concurrent state ordering (monotonic terminal) ✅

The guard is correctly implemented as a double barrier:

  • In-memory, changesRow short-circuits a PENDING projection over a non-PENDING row (db/activity_store.go:373-377).
  • Authoritatively, the SQL upsert re-evaluates the guard at write time so a concurrent writer that flipped the row terminal between the read and the upsert still wins:
    WHERE activity_entries.status = sqlc.arg(pending_status)
        OR EXCLUDED.status <> sqlc.arg(pending_status)
    Rejected only when stored ≠ pending AND incoming = pending — exactly "terminal cannot regress to PENDING." :execrows returns rows == 0 on rejection, and ProjectEntry short-circuits before AppendActivityEvent (db/activity_store.go:231-233), so no stale event is appended. The COMPLETE-before-PENDING race is handled correctly.

One design note (not a bug): the guard only blocks regression to PENDING; terminal→terminal transitions (e.g. COMPLETE→FAILED) still pass. That matches the stated finding scope, but if genuine terminal monotonicity is intended, it isn't enforced here.

2. Public StartPay CREDIT visibility ✅

Ownership is now explicit: creditProjectorOwnsSwapSummary requires both CREDIT settlement and a payment-hash entry in creditOwnedSwaps (credit_projector.go:38-47). A public StartPay with CREDIT settlement but no local credit op is no longer suppressed by the monitor/history reconciler — the exact regression the finding called out. Live admission is recorded synchronously in sendCreditInvoiceIntent (router.go:317-319) before the entry is emitted, and both the ownership key and the pay:<hex> op-key parse resolve to the same hex payment hash, so the projector lands on the same row. Good.

3. Effective snapshot parity ✅

project() clones the incoming entry, merges immutable context from the durable row via mergeActivityContext, and then both persists (entryToProjection(effectiveEntry)EntryJSON is protojson(effectiveEntry)) and emits effectiveEntry (projector.go:172-180). Live and replayed snapshots are therefore built from the same merged entry — TestProjectAndEmitPreservesImmutableContext confirms note / request / payment-hash parity across live and event-log replay. jsonValueChanges semantic comparison is safe: protojson encodes int64 as strings so there's no float64 precision hazard in reflect.DeepEqual, and the empty-stored / malformed cases fall back to exact comparison correctly.

4. Retry after transient ActivityStore failure ✅

pollCreditOps now gates all in-memory advancement on a successful durable write: on projectEmitLocked error it continues before updating projected[op.OpID] and before clearPending/trackPending (credit_projector.go:182-195). An unchanged terminal op therefore stays eligible for the next tick. TestCreditProjectorRetriesFailedTerminalProjection verifies the op is retried and only advances once the store accepts it. Correct.


Notes worth confirming

a) Restart ownership-reconstruction window (behavior change worth a second look).
Before this PR, suppression keyed on settlement type alone, so a credit-only swap summary was suppressed immediately on restart. Now suppression depends on creditOwnedSwaps, which after restart is only repopulated by recordCreditProjectorOwnership during a poll. The first poll runs in the projector goroutine (credit_projector.go:120) asynchronously relative to the monitor's fanOutSwapUpdate. So there is a brief restart window where a CREDIT summary for a wallet-owned credit-only pay is not suppressed and the monitor may project a zero-sat terminal row over the correct negative-amount row — precisely the overwrite monitor.go:184-188 warns about.

It is self-healing: the monotonic guard keeps a terminal row terminal, and the next credit poll corrects the amount (a mutable field changesRow detects). But it does briefly emit an incorrect snapshot on restart, and it appends an extra activity_events transition that a resumable subscriber will replay. If that transient is acceptable it's worth a comment on recordCreditProjectorOwnership noting the window; if not, reconstructing ownership synchronously before the monitor resumes would close it.

b) Request-only enrichment appends a same-status event.
TestProjectAndEmitEnrichesRequestWithoutMemo shows enrichment produces a second activity_events row at the same status/phase. This is intentional per the PR description, but it means resumable subscribers replay a transition where only immutable context changed. Confirm downstream consumers treat a repeated-status event as idempotent (no double-counting, no UI flicker).

c) Minor — router.go:327 tracks pending before projecting.
trackPendingEntryWithoutTimeout(entry) runs before projectAndEmit, so in the fast-settlement race the row is briefly tracked as pending even though the credit poll may have already projected COMPLETE. The next poll's clearPending reconciles it; harmless, just noting the ordering.


None of the notes are blocking. The core correctness claims all hold, generated sqlc matches the query change, and test coverage maps directly onto the four focus areas. Note (a) is the one I'd most want the author's confirmation on, since it's a genuine behavioral trade vs. #950.
• branch agent/credit-activity-review-fixes

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances the activity store and credit projector to prevent stale pending updates from overwriting terminal states and to preserve immutable request context across sparse projections. Key changes include updating the SQL upsert query to return affected rows and enforce state transitions, introducing semantic JSON comparison for request payloads, and tracking locally owned credit swaps within the runtime. Feedback on the changes suggests optimizing the semantic JSON comparison helper by adding a fast-path check for identical strings to avoid unnecessary unmarshaling and deep equality checks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread db/activity_store.go
Comment on lines +420 to +423
func jsonValueChanges(next, stored string) bool {
if next == "" {
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

An identical next and stored string is guaranteed to be semantically equal. Adding a fast-path check next == stored avoids the overhead of calling json.Unmarshal twice and performing a deep reflection comparison (reflect.DeepEqual) for unchanged JSON values, which is a very common case during re-projections.

Suggested change
func jsonValueChanges(next, stored string) bool {
if next == "" {
return false
}
func jsonValueChanges(next, stored string) bool {
if next == "" || next == stored {
return false
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folded into the first commit. jsonValueChanges now returns immediately when the incoming and stored strings are identical, avoiding both unmarshals and reflection for the common unchanged re-projection path. The semantic comparison remains for differently encoded but equivalent JSON.

@bhandras
bhandras force-pushed the agent/credit-activity-review-fixes branch from 8a448df to 92467d4 Compare July 15, 2026 16:05
@bhandras

Copy link
Copy Markdown
Member Author

@claude review

I folded both follow-ups into their logical commits and force-pushed the two-commit branch.

The restart-window note is now closed synchronously: resumeAll reconstructs credit-only ownership from the durable registry before activity backfill runs and before start launches the live monitor. TestResumeAllRestoresCreditOwnershipBeforeMonitor verifies a CREDIT summary is owned immediately after the wallet-ready resume phase, including when no swap backend is configured. The ordinary async poll still reconstructs and projects as before.

Gemini’s identical-JSON fast path is also folded into the persistence commit and answered inline.

Repeated-status request enrichment remains intentional: activity events are canonical-entry upserts, not counters, and immutable context becoming available is an observable snapshot change. Terminal-to-terminal updates also remain permitted deliberately because a wallet timeout overlay may later reconcile to the source operation’s authoritative completion.

Focused db and tagged swapwallet tests, formatting, commit-message lint, and diff checks pass on rewritten head 92467d4. Please re-review this head, especially startup ordering and the synchronous ownership restore.

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @bhandras's task in 3m 7s —— View job


Re-review — PR #954: swapwallet: harden credit activity projection

  • Review synchronous ownership restore in resumeAll (restart-window fix)
  • Verify Gemini fast-path in jsonValueChanges
  • Re-verify the four focus areas hold on rewritten head
  • Post consolidated re-review

Re-reviewed the two-commit head 92467d4. The restart-window note from the prior review is now properly closed, the fast-path is folded in correctly, and all four focus areas still hold. Details below.


Restart-window fix — closed synchronously ✅

The ordering is exactly right. restoreCreditProjectorOwnership is the first statement in resumeAll (runtime.go:171), before rehydrateWalletLocalPending and backfillActivity, and resumeAll itself completes before runtime.start() launches the monitor/reconciler loops (register.go:132-137, inside a startOnce.Do). So ownership is fully reconstructed from the durable registry before either consumer of creditProjectorOwnsSwapSummary can run:

  • Backfill / history reconcilercollectSwapEntries reads ownership (history.go:615), and it runs via backfillActivity after the restore in the same resumeAll pass.
  • Live monitorfanOutSwapUpdate (monitor.go:189) only starts under start(), strictly after resumeAll returns.

Placing the restore before the SwapBackend == nil early-return (runtime.go:182) is the right call — ownership depends only on the credit registry, so it's restored even on a wallet with no swap backend. TestResumeAllRestoresCreditOwnershipBeforeMonitor locks in exactly that (CreditRegistry set, no SwapBackend, ownership present after resume). The zero-sat overwrite + spurious activity_events transition the prior note described can no longer occur on the restart path. The async poll's recordCreditProjectorOwnership (credit_projector.go:198) remains as a self-healing backstop; both merge into the same monotonic map, so double-restore is idempotent.

Gemini fast-path ✅

jsonValueChanges now short-circuits next == "" and next == stored before unmarshaling (db/activity_store.go:423). Correct and safe: byte-identical JSON is trivially semantically equal, so the two json.Unmarshal + reflect.DeepEqual calls are skipped on the common re-projection path. The malformed-input fallback to exact comparison is preserved, so corruption still can't hide as a suppressed no-op.

Four focus areas — still hold ✅

  1. Concurrent state ordering — Double barrier intact: in-memory changesRow PendingStatus guard (activity_store.go:384-387) plus the authoritative SQL WHERE activity_entries.status = $22 OR EXCLUDED.status <> $22 (generated at db/sqlc/activity_log.sql.go:313-314). :execrows returns rows == 0 on a rejected regression and ProjectEntry short-circuits before AppendActivityEvent (activity_store.go:231-233) — no stale event. Terminal→terminal remains permitted by design (you confirmed: timeout overlay reconciling to the source op's completion), and the credit-poll amount self-heal relies on it.
  2. Public StartPay CREDIT visibility — Suppression requires CREDIT settlement and a wallet-local ownership entry (credit_projector.go:38-47). A public StartPay with no local credit op is not suppressed. Live admission is recorded in sendCreditInvoiceIntent (router.go:317-319).
  3. Effective snapshot parityproject() clones, merges immutable context from the durable row via mergeActivityContext, then persists entryToProjection(effectiveEntry) and emits the same effectiveEntry (projector.go:172-180). Change-suppressed no-ops (seq == 0, store wired) emit nothing, so a rejected transition never reaches subscribers.
  4. Retry after transient store failurepollCreditOps gates all in-memory advancement (projected[op.OpID], clearPending/trackPending) behind a successful projectEmitLocked, continue-ing on error before any of it (credit_projector.go:220-233). An unchanged terminal op stays eligible for the next tick.

Generated sqlc (activity_log.sql.go, querier.go) matches the :execrows + pending_status query change, and the UpsertActivityEntry interface signature change to (int64, error) is consistent across db/activity_store.go and the mock/store call sites.


Non-blocking notes

a) creditOwnedSwaps grows unbounded. Every credit-only pay's payment hash is added and never removed (markCreditProjectorOwned / recordCreditProjectorOwnership), mirroring the durable registry which also retains terminal ops. Correct for monotonic ownership, but on a long-lived, high-volume wallet the in-memory set grows with lifetime credit-pay count. Bounded by the durable op count, so likely fine — worth a one-line acknowledgement on creditOwnedSwaps that it intentionally tracks all durable credit-only pays for the process lifetime.

b) Repeated-status request enrichment — Confirmed intentional per your note (activity events are canonical-entry upserts, not counters; immutable context becoming available is an observable snapshot change). No action.

c) Minor doc wrap — The project doc comment splits a sentence awkwardly across projector.go:32-33 ("A caller stamps" then "// a live update"). Purely cosmetic; reflow if you touch the file again.


Nothing blocking. The startup ordering and synchronous ownership restore you asked me to focus on are correct, and the prior review's one substantive concern (the restart window) is now closed with a matching test. LGTM.
• branch agent/credit-activity-review-fixes

bhandras added 2 commits July 15, 2026 18:17
A fast credit child can project COMPLETE before the router writes its
initial PENDING row. The activity upsert previously accepted that stale
write and appended a backward event, leaving the wallet permanently
pending even though the source operation had completed.

Reject terminal-to-pending updates in the SQL upsert and suppress their
events. Compare request JSON semantically so an otherwise unchanged row
can still gain immutable invoice context.

Read and merge the current row before projecting a sparse lifecycle
update. Live and replayed events now contain the effective memo,
request, and correlation fields instead of the sparse terminal payload.
Settlement type identifies how a swap settled, but it does not prove
that the wallet credit registry owns its activity row. A caller can use
the public swap service directly and create a CREDIT summary without a
matching local credit operation.

Track payment hashes admitted through the local registry and rebuild the
set from durable credit operations. The monitor and history reconciler
only defer to the credit projector when both the rail and local owner
match.

Memoize projected credit state and clear pending tracking only after the
activity write succeeds. A transient store failure therefore retries on
the next poll instead of permanently hiding a terminal transition.
@bhandras
bhandras force-pushed the agent/credit-activity-review-fixes branch from 92467d4 to da03c39 Compare July 15, 2026 16:19
@bhandras

Copy link
Copy Markdown
Member Author

@claude review

Final folded head is da03c392. This rewrite addresses both remaining non-blocking notes from your LGTM re-review without changing behavior:

  • creditOwnedSwaps now documents that retaining every durable credit-only pay for the process lifetime is intentional, preserving monotonic ownership with the durable registry.
  • The project contract comment is reflowed so the caller-stamping sentence reads cleanly.

The branch remains two signed logical commits on merged #952. fmt-changed-check, commit-message lint, the SQLite database suites, the tagged swapwallet suite, and all 71 configured linters pass locally. sputn1ck remains requested for human review.

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Claude finished @bhandras's task in 52s —— View job


Re-review — PR #954: swapwallet: harden credit activity projection

  • Verify the two doc-only follow-ups on head da03c392
  • Confirm no behavior changed since the LGTM head
  • Post consolidated verdict

Re-reviewed head da03c39. Both non-blocking notes from the prior LGTM are now addressed, and the delta is documentation-only — no logic changed. Confirmed still LGTM.


Note (a) — creditOwnedSwaps unbounded growth ✅ documented

runtime.go:70-76 now spells out the intent directly on the field:

// creditOwnedSwaps contains payment hashes admitted through this
// wallet's credit registry. Settlement rail alone cannot establish
// activity ownership because callers may invoke the public swap RPC
// directly without creating a matching local credit operation. The set
// intentionally retains every durable credit-only pay for the process
// lifetime so ownership remains monotonic with the durable registry.
creditOwnedSwaps map[string]struct{}

That captures both halves of the prior note: why settlement type alone is insufficient (public-RPC pays with no local op) and why the set is never pruned (monotonic parity with the durable registry, which itself retains terminal ops). Growth stays bounded by the durable credit-only pay count. Good.

Note (c) — project doc reflow ✅

projector.go:25-30 now reads as clean sentences — the caller-stamping line is no longer split mid-clause:

// A caller stamps a live update with the returned seq and can gate a follow-up
// side effect on the row actually landing. When no store is wired, projection
// is a no-op and the legacy derive-on-read path continues unchanged.

The three documented return shapes (seq, effectiveEntry, nil) / (0, effectiveEntry, nil) / (0, nil, err) remain accurate against the body.

Behavior parity ✅

The diffstat is byte-for-byte identical to the LGTM head (748 / 96, same 17 files), and both edits touch only comments — no executable line moved. The four focus areas verified on 92467d4 therefore still hold:

  1. Concurrent state ordering — double barrier (in-memory changesRow PendingStatus guard + authoritative SQL WHERE status = $pending OR EXCLUDED.status <> $pending, :execrowsrows == 0 short-circuits before AppendActivityEvent).
  2. Public StartPay CREDIT visibility — suppression requires CREDIT settlement and a wallet-local ownership entry; ownership restored synchronously in resumeAll before the monitor starts.
  3. Effective snapshot parityproject() persists and emits the same merged effectiveEntry; change-suppressed no-ops emit nothing.
  4. Retry after transient store failurepollCreditOps gates all in-memory advancement behind a successful durable write.

Nothing blocking, nothing left open from my side. The two follow-ups land exactly as described with no behavioral change. LGTM — deferring to sputn1ck for the human review.
• branch agent/credit-activity-review-fixes

@bhandras

Copy link
Copy Markdown
Member Author

CI note: the first Postgres unit job failed only in unchanged internal/actortest.TestDurableAskConcurrentRequests, where one of five concurrent durable-ask responses was not observed before the 37.8-second timeout. The changed Postgres db package and all other completed suites passed.

I filed the unrelated flake with the exact runner, log excerpt, scope analysis, and diagnostic acceptance criteria as #955, then reran only the failed job. No branch change was made.

@bhandras
bhandras merged commit f870a1e into main Jul 15, 2026
36 of 37 checks passed
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.

1 participant