Skip to content

1.9.79: report losers and winners fail on a payout they cannot read, and every rows command returns its encoding error - #321

Merged
tracking202 merged 5 commits into
1.9.79from
1.9.79-report-errors-returned
Oct 29, 2026
Merged

tracking202 merged 5 commits into
1.9.79from
1.9.79-report-errors-returned

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Stacked on #310 (1.9.79-optimize-offers-error, which is itself stacked on #297). This PR must merge after #310. Until then its diff also shows #310's commits (and #297's). This PR's own change is the single commit eda5a87.

Backlog: WHC2 (a Go-CLI-only follow-up to 6WFB / #310)

What was wrong

  • The payout read's error was dropped. report losers and report winners with --aff-campaign-id read the campaign's payout with payout, _ = fetchCampaignPayout(...). The MCP tools report_losers and report_winners do the same. If the read hit a 5xx, a key or role the server refuses, a campaign that does not exist, or an unreachable server, every click was priced at a payout of 0. Losers then listed only the rows that never converted, winners listed rows whose CPC is past break-even, and both exited 0 (CLAUDE.md Replace all <? as <?php. #1).
  • A missing payout read as 0. fetchCampaignPayout read an answer with no aff_campaign_payout as 0 (toFloat(nil)). This affected every caller: report breakeven and campaign optimize too.
  • The JSON parse error had no category. It exited 1 with only the generic --help hint.
  • rowsToJSON printed its own error. It printed its json.Marshal error and returned nil. Each of its 10 call sites (render(rowsToJSON(rows))) then printed nothing on stdout and exited 0. That breaks the CLI rule "never print errors yourself".

What changed (Go CLI only)

fetchCampaignPayout

  • A failed read is wrapped with %w, so it keeps the API error's category, exit code and class-wide hint: 5xx gives exit 4 and p202 system health; 403 gives exit 2 and the role or scope hint; a network failure gives exit 3. A 404 gets its own hint naming p202 campaign list.
  • These answers are now a server error (exit 4) naming p202 campaign get <id> --json:
    • no data object
    • no aff_campaign_payout, or a null one (the column is DECIMAL(8,2) NOT NULL)
    • a value that is not a finite number
    • a body that is not JSON
  • A payout of 0 is still 0.
  • The decode reads both a JSON number (1.9.77 on; checked in the v1.9.77 tag, and php -r gives {"a":160,"b":0} with the API's flags) and a numeric string (older servers).

losers / winners

  • They return the payout error before anything is printed, and the breakdown is never read.

rowsToJSON

  • It now returns ([]byte, error), where the error is a server CLIError (exit 4) with a hint.
  • A new renderRows helper encodes and renders.
  • All 10 call sites pass the error up:
    • report_optimize.go ×2
    • verify.go ×5 (rotator test/check/trace, tracker test/check)
    • conversion_verify.go (conversion simulate)
    • countries.go (rotator criteria-values)
    • system_cron.go (table mode)
  • breakeven, losers and winners attach a hint naming p202 report breakdown --json.
  • losers and winners now encode before printing their Note: lines, so on a failure stderr holds the error alone.

Tests

  • report_payout_test.go, against a fake that answers GET /campaigns/7 with the full record shape the API sends:
    • Read failures: every failure × breakeven, losers and winners. Asserts exitCodeForError, hintFor and the message; that stdout and stderr are empty; that the breakdown is not read; the envelope under runAsMain; and the MCP tool's exit code.
    • Payout values: the payout read as 160, "160.00", 0 and "0.00" (0 still lists only zero-conversion spend as CUT).
    • rowsToJSON: a unit test, plus command-level overflow cases for winners and breakeven that assert nothing is printed, notes included.
  • Existing fakes that sent the payout as the old string ("0", "160"), or not at all (masked_test.go), now answer it as the API does: a number (CLAUDE.md Update release notes in README #9, [Update] Prevent warnings in subid deletion #28).
  • requestErrorDrops: the fetchCampaignPayout entry is removed. cmd/sync.go: orchestrator.Post stays; it is VZ10's.

New structural check: TestEncodingErrorsReachTheCaller

  • golangci-lint does not catch a blank-assigned encoding error. Measured: with out, _ := rowsToJSON(listed) planted, it reported 0 issues, because errcheck's check-blank is off.
  • The test walks the CLI's source and fails on two shapes:
    • an encoding call's error assigned to _
    • a function that makes an encoding call and returns no error, which is rowsToJSON's old shape
  • An encoding call is any of:
    • json.Marshal, json.MarshalIndent, or a one-argument Encode
    • a tree function or method that makes one and returns an error, to the fixed point
  • The 18 existing functions that make an encoding call and return no error are listed in encodingErrorSinks, each with what it does on a failure. Most re-encode values decoded from JSON, which cannot fail. The list only shrinks.

Docs

  • docs/cli.md: new "Break-even, losers and winners" subsection.
  • documentation/cli/10-go-cli.md: a row for the three commands.
  • --help Long text of breakeven and losers.
  • changelogs.txt and 202-config/changelogs.json under 1.9.79.

Verification

.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79-optimize-offers-error --changed:

Scope report
------------
  go         PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  golangci   PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  patterns   SKIP  no new or modified .php files still exist, so no lines were examined (compared against origin/1.9.79-optimize-offers-error)
  syntax     PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

The go tier covers gofmt, go vet ./..., go test ./... and the empty-HOME go test ./cmd/.... I also ran go vet, go test ./... and golangci-lint run ./... (0 issues) by hand under the local toolchain.

Planted defects. Each was planted, backed up to a mktemp path, and restored with a cmp check. Each failed a named test (an assertion failure, not a build error):

  • Dropped payout error (payout, _ = fetchCampaignPayout(...) back in triage): fails TestPayoutCommandsFailWhenThePayoutCannotBeRead (all 18 losers/winners subtests) and TestRequestErrorsAreNotDiscarded.
  • Missing payout read as 0: fails TestPayoutCommandsFailWhenThePayoutCannotBeRead (the "record without its payout" and "null payout" subtests for all three commands).
  • Dropped rowsToJSON error (out, _ := rowsToJSON(listed)): fails TestRankingsFailOnRowsTheyCannotEncode and TestEncodingErrorsReachTheCaller. golangci-lint reported 0 issues with this plant in place.
  • rowsToJSON printing its error and returning nil: fails TestRowsToJSONReturnsItsError and TestRankingsFailOnRowsTheyCannotEncode.
  • Structural-test shapes: 10 shapes were planted in scratch files and each was reported at its line, then the files were removed:
    • a package-level var b, _ = json.MarshalIndent
    • b, _ := json.Marshal
    • _ = enc.Encode
    • _ = renderRows
    • out, _ := rowsToJSON
    • a method encoder
    • a transitive wrapper
    • an aliased encoding/json import
    • the old rowsToJSON sink
    • a func-literal sink

What could not run

  • Nothing ran live. The licence stub is down, and the Go CLI gets 402 from an unlicensed instance, so no instance was stood up. The paths were proven against fakes that answer as the real API does: the full GET /campaigns/{id} record, with the payout as a number as numericDecimals() sends it (pinned server-side by ControllerTest::testADecimalFieldIsANumberAndTheVersionIsOfWhatIsServed), and error bodies in Bootstrap::errorResponse()'s shape.
  • No agent-eval case was added. The paths are server failures, which the seeded eval instance cannot inject.
  • CI: not yet observed.

For the reviewer

  • Category of the encoding error: server / exit 4, as 1.9.79: campaign optimize fails on a ranking it cannot read, and names the salvage campaign's id #310 chose for campaign optimize's own json.Marshal failure. The only reachable cause is a figure the server sent that is too large to compute with.
  • The 404 hint reads "This account has no campaign N (or it is deleted)". It assumes the 404 came from the API. A URL pointing at something other than an install would get the same hint.
  • Two entries in encodingErrorSinks are not swallows: runBatch (func literal) and eval runCase. The first hands the error on through a captured variable; the second records it as the case's failure. The test reads by name and cannot tell either apart, so they are listed with that reason.

Generated by Claude Code

…and every rows command returns its encoding error

p202 report losers and report winners (and the MCP tools report_losers and
report_winners) read the payout of --aff-campaign-id with
`payout, _ = fetchCampaignPayout(...)`. A 5xx, a key or role the server
refused, a campaign that does not exist or an unreachable server priced
every click at a payout of 0: losers listed only the rows that never
converted, winners listed rows whose CPC is past their break-even, and
both exited 0 (CLAUDE.md #1). The read's error is the command's now,
wrapped with %w so it keeps the API error's category, exit code and
class-wide hint, and a 404 names `p202 campaign list`. Nothing is printed
and the breakdown is not read.

fetchCampaignPayout also read an answer with no aff_campaign_payout as 0,
for every caller (report breakeven and campaign optimize too), and
returned its JSON parse error uncategorized (exit 1, the generic --help
hint). Both are a server error now (exit 4) naming `p202 campaign get <id>
--json`. The column is DECIMAL NOT NULL: a payout of 0 is still 0, and a
number (1.9.77 on) and a numeric string (before) read the same.

rowsToJSON printed its json.Marshal error and returned nil, so a row JSON
cannot carry left stdout empty and the command exited 0. It returns the
error (server, exit 4, with a hint), and each of its ten call sites passes
it up, through renderRows where the rows are the last thing printed;
losers and winners encode before printing their notes, so a failure
leaves stderr the error alone.

The test fakes answered the payout as the old string ("0", "160") or not
at all (masked_test); they answer it as the API does now, a number.
TestEncodingErrorsReachTheCaller is the floor for the encoding shape:
errcheck does not report a blank assignment (golangci-lint ran clean with
`out, _ := rowsToJSON(...)` planted), so the test walks the CLI's source
for an encoding call's error assigned to _ and for a function that makes
one and returns no error, listing the 18 that remain with what each does
on a failure. The fetchCampaignPayout entry leaves requestErrorDrops.

Stacked on #310.

Backlog: WHC2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Copilot AI balanced review requested due to automatic review settings October 28, 2026 13:23
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T13:23:26.127270Z
We couldn’t complete this review. Comment @codex review to try again.
eda5a87 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@github-actions

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For eda5a87: 5 finding(s). The report is encrypted for the maintainers in the review-findings artifact of this run.

Rewritten on every push; the check fails on BLOCKING.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For eda5a87: 0 issue(s). The review is encrypted for the maintainers in the review-findings artifact of this run.

Brings in 1.9.79's new commits. 1.9.79 added pageJSON, which encodes a page of
rows with truncated, limit and offset beside them, for report breakeven, losers
and winners. Like rowsToJSON before this branch, it printed an encoding failure
and answered nil. It now returns that failure as the command's error, as
rowsToJSON does on this branch, and rowsToJSON is pageJSON with nothing beside
the rows. losers and winners keep 1.9.79's payout_net sort and truncated note,
which now come after the encoding, so a failure leaves stderr holding only the
error. The help texts carry both sides' sentences. The changelogs and the CLI
table keep 1.9.79's lines, with this branch's after them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202

Copy link
Copy Markdown
Owner Author

1.9.79 moved to c6d5167: every other open 1.9.79 PR is now merged into it. This PR is not, because it conflicts with the paged/truncated report work that landed (go-cli/cmd/report_optimize.go: fetchBreakdownRows now pages and returns truncated, decodeBreakdown is the one strict reader of a breakdown answer, report losers/winners carry payout_net and truncated). Please update this branch from 1.9.79 and re-resolve there. Merged PRs are finished, so any new fix for them needs its own PR against 1.9.79.

1.9.79's PHPStan job has been red since the merges at 15:46Z. #308 extended
ForbidNumericCastOfRequestParamRule to the pages and baselined the casts
already there. Three PRs merged beside it each added a cast that its baseline
does not count, and each PR passed on its own:

- Personal settings (#283) cast the same-IP fallback after checking it is a
  key of its choices. It now stores 1 for '1' and 0 for '0', the two keys the
  check admits.
- Setup > Offline Conversions (#293) cast a feed id after a regex test. It now
  reads it with PostedId::read().
- Setup > Traffic Sources (#271) cast the edited account's id to read its
  stored postbacks. The page's own ownership check compares the raw value in
  SQL, where MySQL reads '12abc' as 12. So a save from
  ?edit_ppc_account_id=12abc changed account 12 (one of the account's own, not
  the one named) and held that account's postbacks. Measured on a live
  instance: the old page saved the tampered edit onto account 1. The id is now
  read exactly, and anything else is refused in the page's words before
  anything is read or written.

The baseline counts are unchanged: each file now holds only the casts they
counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…rs-returned

1.9.79's PHPStan job is red on its own tip: three request casts that its
merges left. #328 fixes them, and it is merged here so this branch's
PHPStan check runs green. When #328 merges into 1.9.79, the commit is
already there and this merge adds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

Copy link
Copy Markdown
Owner Author

Updated from 1.9.79, as you asked. 52af15c merges c6d5167 and resolves the clash in go-cli/cmd/report_optimize.go:

  • 1.9.79's pageJSON (truncated, limit, offset beside the rows) now returns its encoding error, as this branch's rowsToJSON does. rowsToJSON is pageJSON with nothing beside the rows.
  • report losers/winners keep 1.9.79's payout_net sort and truncated note. Both now run after the rows are encoded, so a failure leaves only the error on stderr.

go vet, go test ./cmd/... (also with an empty HOME) and golangci-lint pass.

What is still red, and why it is not this PR's:


Generated by Claude Code

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.

3 participants