Skip to content

1.9.79: Customer LTV's pages read the record a button names exactly - #308

Merged
tracking202 merged 14 commits into
1.9.79from
1.9.79-ltv-posted-ids
Oct 28, 2026
Merged

tracking202 merged 14 commits into
1.9.79from
1.9.79-ltv-posted-ids

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Backlog: EZJ3

Stacked on #296 (1.9.79-ltv-delete-webhook-id), which is stacked on #289. It uses #296's Prosper202\Http\PostedId and its extension of ForbidNumericCastOfRequestParamRule to the request superglobals. Merge it after both. The diff against 1.9.79 includes theirs until they land. This PR's own change is commit 89f4952 (one commit on top of #296's head ba572614).

What was wrong

Companies' Merge read company_id and source_company_id with (int), so an id sent as 12abc merged company 12. That is one of the account's own companies, and not the one named (CLAUDE.md #4). The other Customer LTV pages read their ids the same way:

  • Companies' Edit and Delete.
  • Products' Save and Delete.
  • The customer page's customer_id, so Erase, Merge, Link Identity, Unlink and Save Changes all acted on customer 12. A link with ?customer_id=12abc opened customer 12 too.
  • The customer page's source_customer_id and alias_id.
  • The merge search's exclude.
  • Every list's offset and edit.

Measured live against origin/1.9.79 with tests/live/ltv-pages-posted-ids.sh (7 passed, 33 failed):

  • source_company_id=2abc answered "Companies merged." and company 2 was gone.
  • source_customer_id=2abc merged customer 2.
  • customer_id=1abc with Erase erased customer 1.
  • product_id=1abc deleted product 1.
  • A Companies Delete posted with offset=50abc deleted the company.

What changed

Ids. Every id the LTV pages read from $_POST/$_GET now goes through PostedId::read(), via new readers in tracking202/ajax/ltv_helpers.php (p202_ltv_posted_id() and the others below).

  • Anything but exact digits is refused, with the page's own sentence naming what to pick. That covers 12abc, 0, -1, empty, absent, a leading zero, a sign, .0, and a list.
  • The refusal happens before anything acts on the id. The sentences, in 1.9.79: LTV Settings reads the row a button names exactly #296's shape:
    • Pick the company to keep / merge away / edit / delete from the list.
    • Pick the product to edit / delete from the list.
    • Pick the customer from the Customer LTV list.
    • Pick the customer to merge away from the list.
    • Pick the identity to unlink from the list.
  • The merge search answers a refusal with a 422 and {"results": [], "error": "Pick the record to merge into from the list."}. Before, a bad request read as "no matches". The dialog now shows the server's sentence instead of "Search failed — try again". This was checked in Chromium (Playwright) by rewriting the dialog's own POST in flight.

The pages' other values, read the way #296 read LTV Settings':

  • Offset, filters and actions.
    • A page number the pager did not write is refused with "Pick a page with Previous or Next." A request carrying one runs no action.
    • On the report and subscriptions lists, a grouping, segment or status the list does not offer is said, not silently read as the default.
    • An unknown customer-page action is said ("Unknown action.").
    • Each refused value shows the default, and leaves the others as they were sent.
  • Lists read as strings. A value sent as a list (name[]=) is refused. Before, (string) stored a company name or an identity as "Array". This applies to:
    • company name and domain;
    • product name, SKU and price;
    • alias type and value;
    • the report's search;
    • the company drill-down's company;
    • the customer page's mode;
    • every action.
  • Customer Save Changes. A save is refused when crm comes as one value, when one of its ten fields is left out, or when it carries an unknown key. The same goes for email left out and cf sent as one value. Before, the page read each of those as emptied and cleared the stored value. The form drawn again shows what was typed, and the stored value where nothing usable was.

Machine checks.

  • Baseline. The LTV entries leave phpstan-baseline.neon, so the rule now holds those pages:

    Page Sites
    ltv_companies 7
    ltv_products 5
    ltv_customer 3
    ltv_merge_search 1
    ltv_subscriptions 1
    sort_ltv 1
    Total 18

    The baseline goes from 28 pages / 66 sites to 22 / 48, exactly the 18 fixed. PHPStan reported each cast when it was planted back (14 of 14 (int) plants).

  • New: LtvPagesReadPostedValuesExactlyTest. It holds every LTV page, LTV Settings included, to no (string), (bool), empty(), strval() or boolval() of a request value. It finds 36 such sites on origin/1.9.79 and 0 on this branch. The rest of the tree is not held (see findings).

Tests

  • LtvPostedValueReadersTest (unit) covers the readers.

  • LtvPagesPostedIdsIntegrationTest (@group integration, in 1.9.79: LTV Settings reads the row a button names exactly #296's style) runs each page as the account through tests/Setup/fixtures/page-runner.php. For each id below it posts 12abc-style, 0, -1, empty, absent, N, N , N.0, 0N, +N and [N], and checks the sentence and that nothing changed. It then checks that the exact id still works.

    • company merge: both ids;
    • company update and delete;
    • customer merge: both ids;
    • customer_id on erase, add_alias, delete_alias and save;
    • alias_id;
    • product save and delete;
    • the merge search's exclude.

    It also covers:

    • offsets on four lists, plus a refused offset with a delete;
    • filters;
    • the customer form's shapes, and the form drawn again after a refusal.

    Plants used a fresh mktemp backup, checked with cmp before planting and after restoring. Each plant was counted only when a named assertion failed:

    • 17 of 17 plants of the old cast or read made it fail. Every cast above, plus (string) of a list, the is_array fold of crm, and a left-out field read as emptied.
    • The redrawn form's fallback, planted, fails it too.
  • tests/live/ltv-pages-posted-ids.sh is the live pass, listed in tests/live/README.md.

Verification

verify.sh --since origin/1.9.79 --changed, on the committed branch (this includes #289's and #296's files):

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)
  openapi    PASS
  patterns   PASS
  phpcs      PASS
  phpstan    PASS
  schema     PASS
  syntax     PASS
  unit       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 unit run had 6143 tests and 5 skipped:

  • the 4 documented "User class requires DB singleton" skips;
  • CronEntryPointsFailLoudlyTest, which skips when the checkout has a 202-config.php, as this one did for the integration and live runs.

Run by hand against MariaDB 10.11 on PHP 8.3.6:

Suite or pass Result
LtvPagesPostedIdsIntegrationTest OK, 10 tests / 2166 assertions
#296's LtvSettingsPostedValuesIntegrationTest, re-run because LTV Settings loads the helpers file OK, 9 / 624
Live pass, this branch 40 passed, 0 failed
Live pass, origin/1.9.79 (915a367e) 7 passed, 33 failed (the before)
Customer edit form serialized as the browser does (form-body.py), one field changed Saved
Same form with crm sent as one value Refused; the form was drawn again with the stored name
Merge dialog in Chromium Shows the 422's sentence. With the exact id it lists only the other company

What could not run, and how the live pass ran without the licence

  • Licence. The licence stub is down, so install-instance.sh cannot pass its licence step. Each instance was installed with install.php's own steps without the licence form: INSTALL::install_databases(), InstallAccount::create(), the 202_api_keys row and the partitions, as 1.9.79: LTV Settings reads the row a button names exactly #296's agent did. AutoCron and the daily email registration were left out. php -S (through the eval router) ran with every proxy variable pointed at the closed port 127.0.0.1:9, so AUTH::is_valid_api_key() failed open. Each server log shows 25 licence checks, every one Failed to connect to 127.0.0.1 port 9. Nothing reached the licence service.
  • Seeding. The pass signs in through 202-login.php's own form and seeds its companies, customers and product in the database directly. The REST API and Go CLI were not used.
  • Not run here: CI, the agent evals, and tests/live/upgrade-equals-install.sh (no schema change in this PR).

For the reviewer

  • Partial requests are now refused. The customer page refuses Save Changes when a crm[...] field or email is missing, or crm has an unknown key. The edit form always posts all ten fields and email, so only a hand-built request is refused. Before, any such request cleared the fields it left out.
  • Empty means none, for navigation only. For offset and edit, an empty value means none, as ltv.js drops 0 and '' from bookmarks and 1.9.79: LTV Settings reads the row a button names exactly #296's show_deliveries does. An action's id that is empty is refused.
  • An unrecognised view value never blocks the page. A refused offset, edit, filter or search shows the page's default with the sentence. It does not refuse to draw the list.
  • FieldErrorPathsAreDottedTest. ltv_customer.php is listed in ALLOWED for its two crm[<column>] sentences. These name the form's own field names (a form post, not an API body), as OverviewChart.php already is.
  • Wording. A well-formed id that is not the account's still gets the repository's API wording, e.g. "source_company_id 9 not found for this account". That is unchanged here; see findings.
  • Not touched: ltv_settings.php (1.9.79: LTV Settings reads the row a button names exactly #296), MysqlCompanyRepository (4J3V), and role permissions (E29Z).

Findings outside this item

  • (string) of a request superglobal is read at about 360 sites in 48 files outside the LTV pages. A tree-wide sibling of ForbidNumericCastOfRequestParamRule, with a by-count baseline, would hold them.
  • The LTV pages show the repository's API-worded message for a well-formed id that is not the account's, e.g. "source_company_id 9 not found for this account" or "Alias not found on this customer". 1.9.79: a dead LTV webhook can be revived, and is sent what it missed in the last 7 days #289/1.9.79: LTV Settings reads the row a button names exactly #296 mapped this to the page's words for Revive only.
  • ltv_settings.php keeps its own ltv_settings_posted()/ltv_settings_posted_id(). They could use the shared p202_ltv_posted_*() once both PRs merge.
  • tracking202/static/p13n_event.php stays in the baseline: (float) of an is_numeric() value, not an id.

Generated by Claude Code

claude added 10 commits October 23, 2026 21:56
LTV webhooks were queued by every /ltv write and sent by nothing a
default install runs: 202-cronjobs/ltv_webhooks.php was the only
dispatcher, and every default scheduler (the installer's cron line,
install.sh, both Docker cron services, AutoCron) fetches index.php
alone. EveryCronJobIsScheduledByDefaultTest listed it as NOT_SCHEDULED
("opt-in, predates the measurement rewrite": history, not a reason). The
Landing Page Optimizer's pairing webhook waited with the rest.

The dispatch moves into Prosper202\Ltv\WebhookDispatcher, and two entry
points run it: the minutely tier of index.php (last, with a 15-second
budget) and ltv_webhooks.php on its own (--budget=N, default 50), which
the Coolify worker service now runs too. Both take one MySQL named
lock, as the attribution worker and the Android intake do, and each
delivery is still claimed by compare-and-set before it is sent, so runs
the lock does not see cannot send it twice either.

- The deadline is asked before each delivery, at least one a run, so a
  run ends at most one delivery past it.
- A webhook whose delivery took 2 s or more to fail is not sent to again
  in that run; its other deliveries stay due, untried, and a cursor on
  (next_attempt_at, delivery_id) steps past them. A receiver that times
  out costs one timeout a run instead of the budget every webhook on the
  install shares. A failure that comes back at once is retried at its own
  pace, as before.
- Retries and backoff are unchanged (recordAttempt(): 2^n minutes, failed
  after 6, the webhook marked dead). A refusal, a failed lookup or a send
  that throws fails its own delivery and the run goes on (#24).
- A delivery still unsent 7 days after it was queued is failed with
  "expired: ..." as its last response, never sent, its webhook left
  active: an upgraded install sends the last week of its backlog, oldest
  first, rather than months of stale events. Expired 1,000 a statement;
  a run whose budget ends first sends nothing, since what is left to
  expire is the oldest due.
- The response body is stored as UTF-8 cut at a character boundary: a
  body that is not UTF-8 was refused under a strict sql_mode, the outcome
  went unrecorded and the delivery would have been sent again.
- post() builds every option through requestOptions(), the guard's set
  winning over the POST's, and refuses to send when curl refuses one.

Tests: WebhookDispatchIntegrationTest (a real server, a php -S receiver,
two child processes for the overlapping runs, under the lock and
without it), WebhookDispatcherTest, LtvWebhooksAreSentEveryMinuteTest
(the minutely tier, last, with its budget; the Coolify worker), and the
scheduling, guard-parity and LTV server-failure tests updated. Each
removal planted was caught by a named test. tests/live/ltv-webhooks.sh
is the pass end to end against an https receiver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
1.9.79 gained #262. The only conflict was 202-config/changelogs.json, where
both sides added 1.9.79 lines: 1.9.79's lines are kept and this branch's
follow them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in what 1.9.79 gained since this branch last merged it. The
conflicts were only in the changelogs, where both sides added 1.9.79
lines: 1.9.79's lines are kept as it has them and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… in the last 7 days

A webhook is marked dead when one delivery fails 6 times, about an hour
of retries now that #273 sends them, and nothing could make it active
again: LTV Settings said to register it again, which for the Landing Page
Optimizer, whose pairing keeps the webhook's id and secret, meant pairing
again. A dead webhook was also queued nothing, and the deliveries it had
pending spent their remaining attempts on "endpoint not active", so the
events of an outage were lost for good.

- MysqlWebhookRepository::revive() makes a webhook that is not active
  active again with its id, URL and secret, resets its pending
  deliveries' attempts to 0, and (requeue, the default) makes every
  delivery of it not delivered and queued within the dispatcher's 7 days
  pending again, due now: the one that killed it and the ones held while
  it was dead. A delivered one is never touched. It locks the webhook row
  first, and an active webhook is left as it is.
- While dead, enqueue() still queues its events, and the dispatcher holds
  each (holdDelivery(): failed, attempts as they were, "held: …"), never
  trying it. The hold asks the webhook's status again in its own UPDATE,
  so a delivery read while the webhook was dead and revived since is sent.
- PUT /ltv/webhooks/{id} {"status": "active", "requeue": bool}, LTV
  Settings' Revive button on a dead row (its confirm names how many it
  missed), p202 ltv webhooks revive <id> [--requeue=false] and bin/p202
  ltv:webhook:revive <id> [--no-requeue].

requeue is the default: the receiver was down, the events it missed are
what keeps a subscriber (the optimizer's conversions, a CRM's revenue)
consistent, the 7 days are the window the dispatcher already applies, and
every one goes with its own X-P202-Delivery, so a receiver that did take
one de-duplicates it. requeue false is there for a receiver that was
replaced and wants new events only.

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

This branch is stacked on #289: its Revive action reads the webhook id
strictly, and the fix here reads Delete's id the same way. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
LTV Settings' Delete read webhook_id with (int), so an id sent as 12abc
deleted webhook 12: one of the account's own, and not the one named
(CLAUDE.md #4). Measured live against the page before this change:
webhook_id=5abc answered "Webhook deleted." and webhook 5 was gone.

Delete now reads the id as #289's Revive does, through one helper both
use (Prosper202\Http\PostedId::read(): digits only, no sign, no leading
zero, round-tripped so a value past PHP_INT_MAX is no id), and refuses
anything else with "Pick the webhook to delete from the list.". The
page's other casts are swept the same way: the field's and the
integration's Delete, the delivery log's webhook id, the customer
c-param (1abc saved c1), the Required box (!empty() read "false" as
ticked), the webhook events (a string, or no box ticked, subscribed the
webhook to every event), and every text field, which (string) read as
"Array" when sent as a list (an integration's provider was stored as
"array").

ForbidNumericCastOfRequestParamRule now reads the request superglobals
outside api/ as well, naming PostedId::read() there; the 28 pages that
still cast (66 sites) are listed by count in phpstan-baseline.neon, so
the counts only shrink. CLAUDE.md #4 says so, and what the rule does not
see.

Tests: PostedIdTest; LtvSettingsPostedValuesIntegrationTest runs the page
as the account (page-runner) and posts each spelling; the rule's fixture
now plants superglobal casts outside api/. tests/live/
ltv-settings-posted-ids.sh is the live pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… and waits for a run that is sending

revive() with requeue false only reset the pending deliveries' attempts
and made the webhook active, so the next run sent every delivery queued
while it was dead that no run had held yet, and every one waiting out a
retry's backoff: "new events only" sent old ones (Codex, on #296).

Without requeue, every delivery of the webhook still pending is now held
in the revival's transaction, as a dead webhook's are: failed, its
attempts as they were, "held: its webhook was revived for new events
only, …", never sent. The answer says how many (held).

With requeue, the pending ones are sent: their attempts go back to 0
and each keeps its due time, so one waiting out a backoff waits at most
32 minutes. A claim (claimDelivery()) reads the same as a backoff, so a
due time is never shortened. The failed ones it sends again are due at
their queueing rather than at the revival, so they go oldest first with
the pending ones no run has held yet.

A delivery a run has claimed and not yet recorded must not be held or
made due under it: a hold would be overwritten by the run's record (a
held delivery sent anyway), and a requeued one could be claimed again
while the first send is in flight. revive() now takes the dispatcher's
lock (WebhookDispatcher::betweenRuns()), waiting up to 20 seconds for a
run in flight; a run still sending then is an LtvConflictException, a
409 through the API with nothing changed, and the Go CLI hints to run
it again in a minute rather than "update it instead".

Tests: WebhookReviveIntegrationTest gains a pending row at revival time
both ways and a revival from a second session while a run has claimed a
delivery; each failed against a78e59a. The live pass revives with a
delivery still pending, both ways.

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

Brings in #289's fix for a revival without requeue, which takes the
dispatcher's lock and can answer a conflict. The conflicts were only in
changelogs.txt and tests/live/README.md: #289's revised revive lines are
kept as it has them, and this branch's lines follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
#289's revive() now waits up to 20 seconds for a dispatcher run still
sending and then throws LtvConflictException with nothing changed. The
page showed the repository's API-worded sentence; it now says, in its own
words, that webhook #N was not revived because deliveries are being sent
right now, nothing was changed, and to revive it again in a minute.

LtvSettingsPostedValuesIntegrationTest holds the dispatcher's lock while
the page revives a dead webhook: the sentence, and the webhook still dead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Companies' Merge read company_id and source_company_id with (int), so an
id sent as 12abc merged company 12: one of the account's own, and not the
one named (CLAUDE.md #4). The same cast read Companies' Edit and Delete,
Products' Save and Delete, the customer page's customer_id (so Erase, Merge,
Link Identity, Unlink and Save Changes acted on customer 12, as did a link
with ?customer_id=12abc), its source_customer_id and alias_id, the merge
search's exclude, and every list's offset and Edit row. Measured live
against origin/1.9.79: source_company_id=2abc answered "Companies merged."
and company 2 was gone; customer_id=1abc erased customer 1; a Delete posted
with offset=50abc deleted the company.

Each id is read through PostedId (#296) by tracking202/ajax/ltv_helpers.php's
p202_ltv_posted_id(), and anything else (12abc, 0, -1, empty, absent) is
refused with the page's sentence naming what to pick ("Pick the company to
merge away from the list.", "Pick the customer from the Customer LTV
list.", "Pick the identity to unlink from the list.") before anything is
changed. The merge search answers a refusal as a 422 with the sentence,
which its dialog now shows instead of "Search failed".

The pages' other values are read the same way, as #296 did for LTV
Settings: a page the pager did not write ("Pick a page with Previous or
Next.", and a request carrying one runs no action), a grouping, segment or
status the list does not offer, and an unknown action are said, where they
were read as the first page, the default or nothing; a value sent as a list
is refused, where (string) stored a company name or an identity as "Array";
and a customer's Save Changes is refused when crm[] comes as one value, a
field is left out or one is unknown, where each field so sent was cleared.

The LTV pages' 18 sites leave phpstan-baseline.neon (28 pages and 66 sites
to 22 and 48), so ForbidNumericCastOfRequestParamRule holds them;
LtvPagesReadPostedValuesExactlyTest holds them (and LTV Settings) to no
(string), (bool), empty(), strval() or boolval() of a request value: 36
sites on origin/1.9.79, 0 now.

Tests: LtvPostedValueReadersTest (the readers);
LtvPagesPostedIdsIntegrationTest runs each page as the account
(page-runner) and posts every spelling on each merge, delete, edit and
alias action; each of 17 planted casts and reads fails it.
tests/live/ltv-pages-posted-ids.sh is the live pass.

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 10:17
@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-28T10:17:59.008825Z
We couldn’t complete this review. Comment @codex review to try again.
89f4952 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

Code review: CLEAN

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

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For 703c232: 4 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.

Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, tests/live/README.md, 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
Brings in ltv-webhooks-scheduled (this branch's stacked base), and through it 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, 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
Brings in ltv-webhook-revive (this branch's stacked base), and through it 1.9.79's new commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in ltv-delete-webhook-id (this branch's stacked base), and through it 1.9.79's new commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202
tracking202 merged commit 0604d1e into 1.9.79 Oct 28, 2026
26 checks passed
tracking202 added a commit that referenced this pull request Oct 29, 2026
…and every rows command returns its encoding error (#321)

* 1.9.79: report losers and winners fail on a payout they cannot read, 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

* 1.9.79: read the three request values 1.9.79's own merges left cast

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
tracking202 added a commit that referenced this pull request Oct 29, 2026
… split them, not by splittest (#327)

* 1.9.79: a rule's destinations are checked by weight, as the redirects split them, not by splittest

The rule check (RotatorsController::planRule(), used by PUT
/rotators/{id}/rules, the MCP tool rotator_set_rules, and POST and PUT of one
rule) refused a rule with two or more destinations and splittest 0, saying
"splittest is 0, so the rule sends every visitor it matches to one
destination". Neither redirect reads splittest: rtr.php and offrtr.php send
a rule's one destination every visitor it matches, and split them between
more by weight with getSplitTestValue(), the first taking them all when every
weight is 0. Measured on a live instance, 24 clicks each through rtr.php and
through offrtr.php: splittest 0 at 50/50 split 13/11 and 11/13, as splittest
1 did; 100/0 and 0/0 sent every click to the first destination under either
value. So the refusal named behaviour that does not happen, a GET body
holding such a rule could not be sent back to the set, such a rule could not
be renamed through PUT of one rule, and a POST the 1.9.78 API took was
refused.

The check now asks every rule with more than one destination for a weight on
each and at least one above 0, whatever splittest says, and says why in its
message; a rule with one destination is taken whatever its weight, since
the redirects send it every visitor. splittest is stored as sent: it is
Setup › Redirectors' Split test switch.

Setup › Redirectors showed a rule with more than one destination and
splittest 0 with the switch off, which shows and posts only the first
destination, so saving the redirector removed the others with nothing said
(measured in a browser, on 1.9.79 and before). Such a rule now opens as a
split test, each destination with its weight, and saves as one; the list
names it a split test and shows the weights.

RotatorRuleSplitIntegrationTest holds the check to the click path: it runs
the real getSplitTestValue() from connect2.php over each shape of weights,
expects a rule taken exactly when the redirects send its visitors where its
weights say, the same with splittest 0 and 1 and through all three writes,
and fails if rtr.php, offrtr.php, connect2.php or RuleOrder.php comes to read
splittest or to choose a destination another way.
RedirectorPageShowsSplitsIntegrationTest renders the page for such a rule.
SyncRotatorRulesIntegrationTest syncs one, as a set and one rule at a time;
the check made that sync fail. RotatorRuleSetInstanceTest clicks through
rtr.php for a rule sent with splittest 0. Follows up #265, which merged
without this.

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

* 1.9.79: read the three request values 1.9.79's own merges left cast

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tracking202 <306382+tracking202@users.noreply.github.com>
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