Skip to content

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

Merged
tracking202 merged 4 commits into
1.9.79from
1.9.79-rule-set-split-check
Oct 29, 2026
Merged

tracking202 merged 4 commits into
1.9.79from
1.9.79-rule-set-split-check

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Follows up #265, which merged without this. Backlog: K2SP.

What was wrong

RotatorsController::planRule() checks every redirector rule the API writes: PUT /rotators/{id}/rules (and the MCP tool rotator_set_rules), and since #290 POST /rotators/{id}/rules and PUT /rotators/{id}/rules/{ruleId}. It refused a rule with two or more destinations and splittest 0: "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. With more than one destination they split the visitors by weight (getSplitTestValue()), and when every weight is 0 the first destination takes them all.

Measured on a live instance built from origin/1.9.79 (dee579c, whose rotator code is unchanged at c6d5167). Rules were stored by SQL, then 24 clicks per rule went through rtr.php (a tracker link) and 24 through offrtr.php (record_adv.php, then ?rpi=):

Rule rtr.php offrtr.php
splittest 0, 50/50 a=13 b=11 a=11 b=13
splittest 1, 50/50 a=15 b=9 a=11 b=13
splittest 0, 100/0 a=24 a=24
splittest 1, 100/0 a=24 a=24
splittest 0, 0/0 a=24 a=24

On the same instance, the API refused all of these:

  • a GET /rotators/{id}/rules body sent back to PUT /rotators/{id}/rules;
  • a rename alone (PUT /rotators/{id}/rules/{ruleId} with {"rule_name": …}) of the 50/50 splittest-0 rule;
  • POST of a rule with two destinations and splittest 0. The 1.9.78 API took that body; without weights it stored 100/100, which the redirects split 50/50.

Setup › Redirectors was measured in a browser (Playwright, the page's own JS). A rule with splittest 0 and two destinations opened with the Split test switch off, which shows and posts only the first destination. Saving the redirector, with no edit, deleted the second destination. The list named both destinations without their weights.

What changed

  • The check. planRule() no longer reads splittest. A rule with more than one destination needs a weight on each and at least one weight above 0. The message says why: "The redirects split the visitors it matches by weight … whether or not splittest is 1". A rule with one destination is taken whatever its weight, since the redirects send it every visitor. splittest is stored as sent: it is the page's Split test switch. The set, POST and PUT of one rule, and the MCP tool all go through this check.
  • The page (tracking202/setup/rotator.php). A rule with more than one destination opens as a split test, each destination with its weight, and Save keeps them all (and stores splittest 1). The list marks it "split test" and shows the weights. Measured after the change: Save on the same rules kept both destinations, and the clicks split as before (13/11 through rtr.php, 11/13 through offrtr.php).
  • Docs and texts: documentation/api/12-rotators.md, docs/openapi.yaml (the set, POST, PUT and both rule schemas), docs/cli.md, the set-rules help, and the MCP catalog's splittest and weight.
  • Agent eval. rotator-rules-001 no longer requires splittest == 1. The clicks split either way; the check still holds the order, the equal weights and the kept id.
  • Changelog. 1.9.79: redirector create, update, delete and whole-rule-set writes in the MCP connector #265's and 1.9.79: a redirector rule written on its own needs a criterion, as the page and the set ask #290's 1.9.79 lines now say what is asked. A new Fixed line covers the page.

Where the page, the set and the single-rule writes still differ

  • A weight of 0. The API takes it (a 100/0 rule routes every click to the first destination, measured). The page refuses it: the weight input has min="1", and ajax/rotator.php's array_filter reads '0' as missing. So a redirector holding a destination weighted 0 cannot be saved on the page until that weight is raised or the destination removed. This predates the change. Before it, the page silently cut such a rule when splittest was 0. Now it shows the 0 and refuses to save. Left for a separate item.
  • A hand-built POST to ajax/rotator.php with split=false and several destinations stores them all. The page's JS never sends one.

Tests

  • tests/Api/V3/RotatorRuleSplitIntegrationTest.php (new). It holds the check to the click path:
    • Runs the real getSplitTestValue(), extracted from connect2.php as a closure, over 11 weight shapes.
    • Expects a rule to be taken exactly when the redirects send its visitors where its weights say.
    • Expects the same answer with splittest 0 and 1, through the set, POST and PUT of one rule.
    • Fails if rtr.php, offrtr.php, connect2.php or RuleOrder.php comes to read splittest, or chooses a destination another way.
  • tests/Setup/RedirectorPageShowsSplitsIntegrationTest.php (new) renders the page through the page runner for rules with one destination (splittest 0 and 1) and with two destinations at splittest 0.
  • RotatorRuleSetIntegrationTest:
    • The refusal provider asks for weights with splittest 0 and 1 alike.
    • The PUT-of-one-rule cases now refuse every weight 0 and a missing weight. {"splittest": 0} on two destinations is taken and keeps both destinations.
  • SyncRotatorRulesIntegrationTest: a source rule stored with splittest 0 and weights 60/40 syncs whole, both as a set and one rule at a time, with its weights. On 1.9.79 the target refused it, so the sync of its redirector failed.
  • RotatorRuleSetInstanceTest: a set sent with splittest 0 splits 24 rtr.php clicks between two even destinations, and a destination weighted 0 takes none of 8.

Plants. Each defect was planted with a mktemp backup, run on a fresh database, and restored with a cmp check. Every one failed a named test:

  • 1.9.79's own check put back: 8 failures in RotatorRuleSplit and 3 in RotatorRuleSet (the provider case "a destination with no weight on a rule that is not a split test" in both tests that use it, and testAPutOfOneRuleIsCheckedAsTheRuleItWouldLeave).
  • Only the splittest-0 refusal added back: 6 in RotatorRuleSplit and 1 in RotatorRuleSet.
  • The same plant fails both cases of SyncRotatorRules (testARuleSplitWithTheSwitchOffSyncsWithItsWeights).
  • Over HTTP on the live server: testTwoDestinationsSplitTheClicksByWeightWhateverSplittestSays answered 422.
  • Page $isSplit reverted, or the editor switch reverted: RedirectorPageShowsSplitsIntegrationTest failed each time.
  • rtr.php or offrtr.php choosing by splittest: testTheRedirectsChooseADestinationWithoutReadingSplittest failed each time.

Verification

verify.sh --since origin/1.9.79 --changed, on 01fa4f3 (origin/1.9.79 at c6d5167 plus this commit), PHP 8.3.6, MariaDB 10.11:

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    FAIL  phpstan.neon.dist failed, phpstan-strict.neon passed
  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
  • unit: 7,573 tests, 6 skipped.
  • phpstan FAIL: 5 errors, all in files this PR does not touch (202-account/account.php, tracking202/setup/offline_conversions.php, tracking202/setup/ppc_accounts.php), all prosper202.numericCastOfRequestParam and its ignore.count. The same 5 errors appear on origin/1.9.79 itself (c6d5167), measured with the same config on those three files.
  • phpstan, touched paths: phpstan.neon.dist over api/v3/Controllers/, tracking202/redirect/ and tracking202/setup/rotator.php reports no errors, and phpstan-strict.neon reports no errors.

Integration suites. Each ran alone on a fresh xk2sp_ database (MariaDB 10.11):

  • RotatorRuleSet: 56 tests
  • RotatorRuleSplit: 12 tests
  • RotatorDestinations: 34 tests
  • SyncRotatorRules: 7 tests
  • ClickRotatorFields: 2 tests
  • RotatorBreakdown: 3 tests
  • RulePutKeepsRedirectIds: 3 tests
  • RedirectorPageShowsSplits: 1 test
  • SetupPagesAccountScope: 17 tests

All OK.

Instance suites against the live server: RotatorRuleSetInstanceTest (3 tests) and RtrClickInstanceTest (3 tests), both OK.

Live pass before and after, on the same instance: the click table above, the API answers and the page save.

Could not run:

  • The Go CLI and p202 mcp against the instance. The licence stub is down, so the instance was installed with install.php's own steps without the licence check, and the CLI would get 402. The API was driven directly; the MCP tool's write is the set's controller call.

For the reviewer

  • Weights are now asked of a rule with more than one destination whatever splittest says. The 1.9.78 API defaulted a missing weight to 100 for splittest 0. Such a body, two destinations without weights, is now a 422 naming each weight. GET always answers weights, so a body read back is unaffected.
  • A rule with one destination and splittest 1 is now taken with weight 0 or no weight. 1.9.79 refused it. The redirects send that destination every visitor either way.
  • The page saves a multi-destination rule with splittest 1. So a page save changes the stored splittest of a rule the API stored with 0. Routing is unchanged, but p202 diff and sync will see the field change.

Generated by Claude Code

… 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
Copilot AI balanced review requested due to automatic review settings October 28, 2026 16:10
@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-28T16:10:38.492855Z
We couldn’t complete this review. Comment @codex review to try again.
01fa4f3 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.

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
…lit-check

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

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


Generated by Claude Code

@tracking202

Copy link
Copy Markdown
Owner Author

@copilot resolve the merge conflicts in this pull request

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.

4 participants