Skip to content

fix(ri-exchange): run unattended exchanges as manual while 4-eyes is on - #1108

Merged
IAmJSD merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-679
Oct 25, 2026
Merged

IAmJSD merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-679

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes the gaps from the #673 review that are still open on main: unattended auto-mode exchanges under 4-eyes (A) and the test gaps (C). Part B (creator-less rows) was already handled by #989; details below.

Root cause (A)

executeRIExchangeReshape (internal/server/handler_ri_exchange.go) passed cfg.RIExchangeMode straight to exchange.RunAutoExchange. Any non-manual mode executes exchanges on the spot, and nothing on that path reads RequireDifferentApprover. With 4-eyes on, the scheduled ri_exchange_reshape task could therefore move money with no approver at all. Since #673 the direct execute endpoints refuse under 4-eyes, which left this path as the only way around the policy. The ladder exchange runner (exchangeRunnerAdapter in internal/server/ladder_write.go) has the same shape, and it always asks for auto mode.

Fix

  • New fourEyesExchangeMode (internal/server/ri_exchange_four_eyes.go): with 4-eyes on, every mode except manual runs as manual. That covers auto, "" and typos, because pkg/exchange treats every non-manual value as auto. A nil global config fails closed and is treated as 4-eyes on.
  • Scheduler: executeRIExchangeReshape uses the effective mode. A downgraded run raises pending records with approval tokens and sends the pending-approval email. Each exchange then goes through /api/ri-exchange/approve, which applies the existing 4-eyes check (fix(ri-exchange): require an identified approver for creator-less exchanges under 4-eyes #989: an identified approver is required, and token-only requests are refused).
  • Ladder runner: exchangeRunnerAdapter.RunAutoExchange builds its AWS clients and hands them to a new run, which reads the global config and applies the same downgrade. If the config can't be read, run returns an error before any AWS call (fails closed). Client construction is unchanged apart from moving into riExchangeClients, which makes run testable with mocks.
  • The stored ri_exchange_mode is not modified. Turning 4-eyes off restores auto behavior at the next run.
  • Doc-only: the RequireDifferentApprover comment in internal/config/types.go and the PUT /api/ri-exchange/config description in openapi.yaml.

Owner decision needed (issue question 1)

I implemented the conservative option: under 4-eyes, auto mode does not bypass approval and runs as manual. I chose a runtime downgrade over refusing the config write for two reasons. It also covers turning 4-eyes on while auto is already armed. And it fails closed whatever order the two settings are written in.

Not made configurable. An "auto is exempt from 4-eyes" opt-out would need a new global_config column, a migration, store read/write and API plumbing. It would also add another 4-eyes bypass knob. That isn't cheap, and it isn't something I should add without your call. If you want auto to stay exempt, say so and it can be a follow-up (or this PR can be closed).

Part B (issue question 2): already handled, no change here

#989 (4e23d2c) changed requireDifferentRIExchangeApprover. Under 4-eyes, a creator-less (system) exchange now needs a resolved session, so an anonymous email-token approval is refused, and a config-load failure fails closed. An identified approver can still approve it. This matches your 2026-10-17 decision for system-created purchases on #233 (Option B, shipped in #1071): the system is the first eye and an identified human the second. This PR leaves that untouched. If you want creator-less exchanges denied outright under 4-eyes, note that every real exchange row is creator-less, so all exchanges would become unapprovable while the mode is on.

Part C: tests

  • TestExecuteExchange_FourEyesRefusesDirectExecute and TestExecuteExchange_FourEyesPolicyLookupErrorFailsClosed now install a spy executeExchangeFn (spyOnAWSExchange) that would report success, and assert zero calls. Before, they relied on the real executor failing on missing AWS credentials.
  • New TestExecuteAzureExchange_FourEyesPolicyLookupErrorFailsClosed: a GetGlobalConfig error must give a non-client (500) error containing 4-eyes policy check, with no CalculateExchange or ExecuteExchange call on the strict ops-client mock.

Verification

All Go commands ran on macOS with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid. The evidence is unit/mock-based: no real AWS or Azure calls and no real Postgres. This PR has no DB or schema change. The 4-eyes flag is read once per run from the global config, so there's no new race to test against Postgres.

New server tests (internal/server/ri_exchange_four_eyes_test.go):

  • TestExecuteRIExchangeReshape_FourEyesDowngradesAutoToManual (modes auto, "", Auto): zero executes, result mode manual, one pending record with an approval token, approval email sent, no completed email.
  • TestExecuteRIExchangeReshape_FourEyesOffAutoStillExecutes (control).
  • TestExchangeRunnerAdapter_FourEyesDowngradesLadderReshape (pending record keeps its LadderRunID) and TestExchangeRunnerAdapter_FourEyesOffLadderStillExecutes (control).
  • TestExchangeRunnerAdapter_PolicyLookupErrorFailsClosed: error returned, no RI listing, no execute, no record.
  • TestFourEyesExchangeMode (table, including nil config).

Fail-before / mutation probes. Each probe was applied to the fixed tree and then reverted. Command: go test ./internal/server/ -run 'FourEyes|ExchangeRunnerAdapter|ExecuteRIExchangeReshape' -count=1.

  1. Fix removed (scheduler passes the stored mode, ladder has no gate): FourEyesDowngradesAutoToManual, FourEyesDowngradesLadderReshape and PolicyLookupErrorFailsClosed fail.
  2. Helper never downgrades: both downgrade tests and TestFourEyesExchangeMode fail.
  3. Ladder swallows the config error: PolicyLookupErrorFailsClosed fails.
  4. Nil config treated as off: TestFourEyesExchangeMode fails.

API tests with requireNoDirectExchangeUnderFourEyes made a no-op, each run alone with go test ./internal/api/ -run '^<name>$':

  • TestExecuteExchange_FourEyesRefusesDirectExecute: FAIL ("no exchange may be submitted while 4-eyes is on").
  • TestExecuteExchange_FourEyesPolicyLookupErrorFailsClosed: FAIL ("an unreadable 4-eyes policy must not let the exchange run").
  • TestExecuteAzureExchange_FourEyesPolicyLookupErrorFailsClosed: FAIL (the strict mock rejects the unexpected CalculateExchange).

All pass with the fix.

Checks run

  • go build ./...: OK.
  • go vet ./internal/server/ ./internal/api/ ./internal/config/, plus -tags integration for server and api: OK.
  • go test -count=1 for ./internal/server/, ./internal/api/, ./internal/mocks/ and ./internal/config/: all ok. The targeted tests also pass under -race.
  • golangci-lint v2.10.1 run ./internal/server/... ./internal/api/... ./internal/config/...: 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" on the changed files: silent.
  • scripts/gosec-hook.sh on the changed packages: clean.
  • git-secrets scan of the changed files, in an isolated temp repo set up by scripts/setup-git-secrets.sh: clean. scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.
  • openapi.yaml parses. git diff --check: clean.

Not run locally: the full repo-wide race suite (disk constraints; CI covers it), and anything against a real AWS account.

Out of scope / notes

  • UI: the settings page still shows the stored auto mode while 4-eyes makes it run as manual. An "effective mode" field on GET /api/ri-exchange/config, plus the frontend change, could be a follow-up. Frontend work is off-limits for me right now.
  • Ladder: a downgraded ladder reshape creates pending rows but sends no email, because the ladder runner has no notification wiring. Today nothing in production calls ReshapeBuffer, so this is defense in depth for when it gets wired.
  • Timing: a scheduled run uses the config it read at start. Turning 4-eyes on mid-run takes effect at the next run.
  • API-key-created purchases with a null creator are fix(purchase): API-key-created pending purchases have a null creator and can never be approved under four-eyes #677, a separate issue and PR.

Maintainer: please mirror the issue's triage labels on this PR: priority/p2, severity/medium, urgency/this-quarter, impact/few, effort/m, type/security, triaged.

Closes #679

🤖 Generated with Claude Code

With RIExchangeEnabled and a non-manual RIExchangeMode, the scheduled
ri_exchange_reshape task called exchange.RunAutoExchange in auto mode and
executed exchanges with no approver, even with RequireDifferentApprover
on. The direct execute endpoints already refuse under 4-eyes (LeanerCloud#673), so
auto mode was the remaining path that moved money with no second person.

With 4-eyes on, every non-manual mode ("auto", "" and typos, which
pkg/exchange all treats as auto) now runs as "manual": the run only
raises pending records with approval tokens, sends the pending-approval
email, and each exchange then goes through the approve endpoint and its
4-eyes check. The stored mode is unchanged, so turning 4-eyes off brings
auto back. The ladder exchange runner gets the same downgrade; it reads
the global config itself and fails closed (no AWS call) when it cannot.

Also tightens the LeanerCloud#673 tests: the AWS direct-execute 4-eyes tests now
spy on the exchange executor and assert it is never called, and the
Azure global-config lookup failure has a fail-closed test.

Closes LeanerCloud#679

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/security Security finding labels Oct 19, 2026
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 8 billable files and costs up to $2.00.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 26004a6 and 05399f0.

Files selected for processing: 8
  • internal/api/handler_ri_exchange_azure_test.go
  • internal/api/handler_ri_exchange_test.go
  • internal/api/openapi.yaml
  • internal/config/types.go
  • internal/server/handler_ri_exchange.go
  • internal/server/ladder_write.go
  • internal/server/ri_exchange_four_eyes.go
  • internal/server/ri_exchange_four_eyes_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE 360a0dc

Reviewed head 360a0dc (1 commit, 379 changed lines, under the 400 cap) against origin/main b6a0649. The base is 40 commits stale; git merge-tree against origin/main is clean, and all tests below ran on the merged tree.

Findings (none blocking):

  • process: issue sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests #679 asks the owner to choose (question 1) between enforcing 4-eyes on auto mode and keeping auto exempt. The PR implements the conservative option and says so. The code is correct for that option. Merging it still needs the owner's explicit answer on sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests #679.
  • nit internal/server/ri_exchange_four_eyes_test.go:236-237 - the table covers "", "Auto" and "auto", but not "Manual". A mutant that passes the mode through case-insensitively at ri_exchange_four_eyes.go:25 (strings.EqualFold(mode, manual)) survives every test. pkg/exchange@541e05e5 auto.go:274 routes only the exact "manual" to the pending path, so that mutant would auto-execute a stored "Manual" under 4-eyes. Add a {on, "Manual", "manual"} row.
  • nit (documented in the PR) internal/server/ladder_write.go:62-70 - a downgraded ladder reshape creates pending rows with no email, and the next ladder run cancels them (CancelPendingExchangesByOrigin). That is harmless today because nothing in production calls ReshapeBuffer.

Attacked:

  • Every unattended execution path on the merged tree. The only callers of exchange.RunAutoExchange are handler_ri_exchange.go:111 (scheduled ri_exchange_reshape, now fourEyesExchangeMode(cfg, cfg.RIExchangeMode)) and ladder_write.go:86 (gated in run before any AWS call). The direct execute endpoints (AWS, Azure) already refuse under 4-eyes (requireNoDirectExchangeUnderFourEyes). No other scheduler task executes exchanges.
  • Pinned go lib pkg@v0.0.0-20261018003623-541e05e5fd29: the exact "manual" goes to processManualExchange, and every other value auto-executes. The downgrade therefore covers "", "Auto" and typos. go#578 (fail closed on unknown modes) is not in this pin. Once the pin is bumped past it, the PR is still correct: the downgrade only ever emits "manual".
  • fix(config): validate RIExchangeMode on PUT /api/config #1196 (PUT /api/config does not validate RIExchangeMode): with 4-eyes on, any stored bad value runs as manual. With 4-eyes off, behavior is unchanged. fix(config): validate RIExchangeMode on PUT /api/config #1196 stays open on its own.
  • Human approval is enforced, not just logged. Downgraded rows have a nil creator. requireDifferentRIExchangeApprover (handler_ri_exchange.go:2611) refuses a token-only approval under 4-eyes, and the approve path sends a pending-approval email when result.Mode == "manual".
  • Fail-closed: a nil config is treated as 4-eyes on. A ladder GetGlobalConfig error returns before the RI listing.
  • Mutation probes on a scratch copy of the merged tree (each reverted):
    1. scheduler passes the stored mode: FourEyesDowngradesAutoToManual fails (3 subtests).
    2. nil cfg treated as off: TestFourEyesExchangeMode/nil_config_fails_closed fails.
    3. ladder config error swallowed and run as auto: PolicyLookupErrorFailsClosed fails.
    4. ladder gate removed: FourEyesDowngradesLadderReshape fails.
    5. requireNoDirectExchangeUnderFourEyes made a no-op: all 3 API 4-eyes tests fail when run one at a time.
    6. case-insensitive "manual": survives (nit above).

Local results (merged tree 301be9e4, macOS, AWS creds disabled): go build ./... OK; go vet with and without -tags integration on server/api/config OK; go test -run 'FourEyes|ExchangeRunnerAdapter|ExecuteRIExchangeReshape|RIExchange' ./internal/server/ 30 passed; go test -run 'FourEyes|RIExchange|Exchange' ./internal/api/ 373 passed. CI on the PR head is all green, but it ran on the stale base.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE 360a0dc

Findings (none blocking):

  • [non-blocking] internal/server/ri_exchange_four_eyes.go:28 vs internal/api/handler_ri_exchange.go:2572 - The new helper treats a nil GlobalConfig as 4-eyes ON. The direct-execute gate (cfg != nil && cfg.RequireDifferentApprover) treats it as OFF.
    • Inconsistent, but nil without an error does not happen in practice, because the Postgres store returns defaults when no row exists.
  • [non-blocking] internal/api/openapi.yaml:1357 says only "the scheduler" runs auto as manual. The ladder reshape is downgraded too, as the types.go comment says. This is a doc gap only.
  • [non-blocking] The ladder dry-run now fails when the global config cannot be read (ladder_write.go:68-71). That is fail-closed and acceptable.
  • Part B is deferred to the owner, as the PR body states. Nothing in this PR depends on it.

Root cause:

  • pkg/exchange auto.go:274 routes only the literal "manual" to processManualExchange; every other value executes.
  • The helper downgrades every value except "manual", so "", "Auto" and typos are covered.
  • Both RunAutoExchange call sites, on head and on origin/main (server/handler_ri_exchange.go:110, server/ladder_write.go:86), are gated. There is no other caller in internal/ or cmd/.

Attacked:

Verified locally, on git archive exports with no cloud credentials, under the heavy lock:

  • Head:
    • go build ./...: OK.
    • go vet on server, api and config, and with -tags integration on server and api: OK.
    • gofmt -l on the 7 changed files: clean.
    • go test -count=1 on server, api and config: ok.
    • go test -race -run 'FourEyes|ExchangeRunnerAdapter|ExecuteRIExchangeReshape' ./internal/server/: ok.
  • Merged tree: build, vet and go test -count=1 on server, api and config: ok.
  • No migration, frontend or terraform changes.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES 360a0dc7c203605a56748dc29b82c2df3da55dbb

Blocking:

  • internal/server/ri_exchange_four_eyes_test.go:83, :174, :189, :205: the fixture uses account ID "acct-four-eyes". Main gained a 12-digit AWS account-ID check in the exchange store adapter today (fix(db): let ri_exchange_history.account_id hold Azure and GCP identifiers #1170, f56cecb, internal/server/handler_ri_exchange.go:361 on main: "refusing to record RI exchange: account ID %q is not a 12-digit AWS account ID"). The PR's CI ran on 2026-10-19, before that landed. After merging current origin/main into this head locally (merges cleanly), five of the new tests fail: TestExecuteRIExchangeReshape_FourEyesDowngradesAutoToManual (all 3 subtests), ..._FourEyesOffAutoStillExecutes, TestExchangeRunnerAdapter_FourEyesDowngradesLadderReshape, ..._FourEyesOffLadderStillExecutes. The record save is refused, so Pending/Completed are empty. Merging as-is turns main's Unit Tests red. Fix: use a 12-digit ID such as "123456789012", merge origin/main, and re-run CI.

Non-blocking:

  • The production change itself looks right. fourEyesExchangeMode is applied in both unattended paths, and they are the only two exchange.RunAutoExchange callers on main. It fails closed on a nil config and on a config-read error (ladder). pkg/exchange compares only == "manual", so downgrading every non-manual value is the correct gate.
  • The scheduler path reuses the *GlobalConfig already loaded by handleRIExchangeReshape. If 4-eyes is switched on mid-run, it takes effect at the next run, as the body says.
  • Ladder downgrade: pending rows get no email, and the next ladder run cancels them (CancelPendingExchangesByOrigin). That's fine while ReshapeBuffer is unwired, but it should be revisited when the ladder is wired.

Attacked:

  • Read issue sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests #679 against the diff. A is fixed with the conservative option. B is correctly left to fix(ri-exchange): require an identified approver for creator-less exchanges under 4-eyes #989: requireDifferentRIExchangeApprover refuses a nil session under 4-eyes. C's tests are present.
  • Grepped main and the head for every RunAutoExchange and direct-execute path to look for a third unattended executor. There isn't one.
  • Fail-before 1: reverted the two production call sites (scheduler passes cfg.RIExchangeMode; ladder skips the downgrade). FourEyesDowngradesAutoToManual and FourEyesDowngradesLadderReshape fail.
  • Fail-before 2: made requireNoDirectExchangeUnderFourEyes a no-op. All three API tests fail; the Azure one panics on the strict mock. Restored afterwards.
  • Ran on the head: go build ./...; go vet with and without -tags integration on server/api/config; go test -race -short -count=1 on server/api/config (ok); golangci-lint 0 issues; gocyclo silent. On head merged with origin/main: build ok, api ok, server FAIL as above.

Body-stale:

Reviewer: rA1. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

IAmJSD and others added 3 commits October 25, 2026 18:30
Bring the branch up to date with main (7cde88e), which now validates
RI exchange account IDs as 12-digit AWS account IDs (LeanerCloud#1170).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main now refuses to record an RI exchange whose account ID is not a
12-digit AWS account ID (LeanerCloud#1170), so the "acct-four-eyes" fixture made the
4-eyes downgrade tests fail before any assertion they make. Use the
123456789012 documentation placeholder instead; every assertion is kept.
The one-line adapter literals are split so the account field sits on its
own line, the only shape the .gitallowed placeholder rule exempts from
the AWS account-ID detector.

Refs LeanerCloud#679

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pick up LeanerCloud#1111 and LeanerCloud#1418 so CI runs against the fixed main (LeanerCloud#1418 fixes
main's red purchase integration test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Updated the branch. New head: 05399f08 (05399f08f94d2e475cfbb4a9a32551858592b6ee).

Checks on the merged tree:

  • go build, go vet (plain, integration, e2e): clean.
  • golangci-lint v2.10.1 on internal/{server,api,config}, with and without --build-tags integration: 0 issues. gocyclo: silent.
  • go test -race -short on ./internal/server/, ./internal/api/ and ./internal/config/: ok.
  • go test -race -tags integration on the same three packages: ok on the first merge (7cde88e). On a heavily loaded machine, the first server and config runs timed out building the shared Postgres template. The same timeout happens on unmodified main, so it is environmental. Rerun sequentially, server and config passed. I did not rerun integration after the second merge, which only brought in purchase/api changes from fix(purchase): guard the remaining post-CAS full-row saves on status #1111 and test(purchase): expect #1245's failure alert in the post-CAS safeFail race test (fixes red main) #1418.
  • Secrets scan: clean, and the planted key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 05399f08f94d2e475cfbb4a9a32551858592b6ee

Blocking:

  • none

Non-blocking:

  • Owner decision (issue question 1) is still open and is implemented in the conservative direction: under 4-eyes, auto runs as manual, and the stored mode is kept. I agree it's the right default for a money path, because it fails closed whatever order the two settings are written in. The acting owner should confirm it explicitly on sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests #679 so Closes #679 doesn't silently settle the question.
  • UX gap (disclosed): the settings page still shows auto while the scheduler runs manual, and the only signal is a server log line. Admins who enable 4-eyes will see pending-approval emails instead of completed exchanges with no in-product explanation. The follow-up (an effective-mode field on GET /api/ri-exchange/config) is worth filing.
  • internal/server/ladder_write.go:68: the ladder path now reads GetGlobalConfig once more per call. That's harmless. Like the scheduler, a 4-eyes toggle mid-run only takes effect on the next run (disclosed).

Attacked:

  • Bypass hunt: I listed every exchange.RunAutoExchange caller (scheduler executeRIExchangeReshape and ladder exchangeRunnerAdapter.run); both now go through fourEyesExchangeMode. The direct execute endpoints already refuse under 4-eyes (sec(ri-exchange): direct execute paths run an irreversible exchange with no approval and no four-eyes check #517/fix(api): refuse direct RI exchange execution while 4-eyes mode is on #673). The approve path keeps fix(ri-exchange): require an identified approver for creator-less exchanges under 4-eyes #989's identified-approver rule for creator-less rows. Every non-manual string ("", Auto, typos) is downgraded, which matches pkg/exchange's "anything not manual is auto" routing and the existing riExchangeArmState.armed predicate. A nil config or a config read error fails closed: nil counts as 4-eyes on, and the ladder errors before any AWS call. The downgraded result has Mode == "manual", so sendExchangeNotification sends the pending-approval email to the configured recipient, not a broadcast.
  • Fail-before: I reverted internal/server/handler_ri_exchange.go to origin/main and removed the ladder's cfg.Mode = fourEyesExchangeMode(...) line. TestExecuteRIExchangeReshape_FourEyesDowngradesAutoToManual and TestExchangeRunnerAdapter_FourEyesDowngradesLadderReshape fail. Restoring the files makes them pass.
  • Ran go build ./..., go vet (plain and -tags integration) on server/api/config, go test -race -short -count=1 ./internal/server/ ./internal/api/ ./internal/config/ and go test -race -tags integration -count=1 ./internal/server/ ./internal/api/ (testcontainers): all ok. The head merges cleanly with current main (10 commits behind).

Body-stale: none

Reviewer: rB9. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 05399f08f94d2e475cfbb4a9a32551858592b6ee

Blocking:

  • none

Non-blocking:

  • internal/server/ladder_write.go:67 (run): a downgraded ladder reshape mints approval tokens that are never delivered (no notification wiring), so those rows can only be approved from a signed-in session, and the next run cancels them. The PR body says so, and nothing in production calls ReshapeBuffer today. Worth a follow-up issue before the ladder is wired.
  • The settings UI and GET /api/ri-exchange/config still report the stored auto while the effective mode is manual. The PR body flags this as a follow-up. An admin could reasonably expect auto exchanges that never happen, so please open the follow-up issue.
  • fourEyesExchangeMode logs on every downgraded run. That's fine at a 6h cadence, just noting it.
  • Owner decision: the PR takes the conservative answer to issue question 1 (auto runs as manual under 4-eyes, with no opt-out). That matches the direct-execute refusal from sec(ri-exchange): direct execute paths run an irreversible exchange with no approval and no four-eyes check #517, and I agree with it. It still needs the acting owner's explicit sign-off before Closes #679 is correct.

Attacked:

  • Unattended paths: grepped every exchange.RunAutoExchange caller (only the scheduler executeRIExchangeReshape and the ladder exchangeRunnerAdapter), and both are gated. Checked pkg/exchange@541e05e5 processRecommendation. It routes only the literal "manual" to the pending path, so downgrading every non-"manual" value ("", Auto, typos) matches the consumer, and a nil config fails closed. The ladder reads the config before any AWS call, so an error stops the run. The approve path (requireDifferentRIExchangeApprover) refuses a token-only approval of a creator-less row under 4-eyes. The stored mode is untouched, so turning 4-eyes off restores auto.
  • Ran go build ./... and go vet (with and without -tags integration) on internal/{server,api,config}, plus go test -race -short -count=1 on those packages: all ok. golangci-lint on server and api: 0 issues. gocyclo -over 10 on the changed files: silent.
  • Fail-before: I reverted the two production call sites (scheduler back to cfg.RIExchangeMode, ladder downgrade removed). TestExecuteRIExchangeReshape_FourEyesDowngradesAutoToManual and TestExchangeRunnerAdapter_FourEyesDowngradesLadderReshape failed. Then I made requireNoDirectExchangeUnderFourEyes a no-op: both AWS spy tests failed on their "no exchange" assertions, and the new Azure fail-closed test failed (strict mock panic). Restored afterwards.

Body-stale:

  • none

Reviewer: rB11. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 05399f08f94d2e475cfbb4a9a32551858592b6ee merged onto main (51957bcf), together with #1100, #1108, #1147, #1149, #1276, #1292 in that order (combined tree bc575de3, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 25, 2026 20:53
@IAmJSD
IAmJSD merged commit bdfe866 into LeanerCloud:main Oct 25, 2026
36 checks passed
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on 05399f08 (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly 05399f08f94d2e475cfbb4a9a32551858592b6ee.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Conflicts in both monthlyCostFromConvertibleRI copies: main (LeanerCloud#1260)
replaced them with one shared ricost.MonthlyCostFromConvertibleRI, and
this branch added the no-positive-price exclusion (LeanerCloud#1188) to each copy.
Kept main's single implementation and moved this branch's check into it,
with the sentinels exported as ricost.ErrNonPositiveDuration and
ricost.ErrNoPricing so the API's excludedRIReason still picks the
per-RI reason. Both wrappers now just delegate, so the "keep in sync"
notes are gone.

ricost's table pinned "zero prices -> 0, no error" as today's behavior
"rather than endorsing it"; that row now expects the no-pricing error,
and a negative-sum row is added. The 4-eyes fixture added on main
(LeanerCloud#1108) used an unpriced RI, which is now excluded, so it gets the same
FixedPrice the other fixtures on this branch use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(ri-exchange): auto mode and nil-creator rows bypass 4-eyes; tighten #673 tests

2 participants