Repository navigation
1.9.79: a redirector rule written on its own needs a criterion, as the page and the set ask - #290
Conversation
…or tools
PUT /rotators/{id}/rules replaces a redirector's rule set the way Setup ›
Redirector saves it, in one transaction: every rule the redirector is to
have, in the order the redirects try them. A rule or entry sent with its id
keeps it (its clicks and report rows stay its own), one without is added,
one left out is removed (remove_rotator_rule, as the page asks). What the
page refuses is refused (a rule with no criterion or destination, two
destinations without a split test, a split test without weights), every
problem named by its place in one 422 with nothing written; another
account's campaign or another redirector's rule is refused by name.
?dry_run=1 answers the set as it would be stored and each rule's change;
the route is stageable; PATCH is a 405. A GET body sent back changes
nothing.
The rules had no order but their ids, so a rule could only be added after
the others: 202_rotator_rules gains position (the 1.9.79 rung), read by
rtr.php and offrtr.php through Prosper202\Rotator\RuleOrder (in id order on
a database the upgrade has not reached, rather than failing the click), the
Setup page, the API and both rotator reports; the page's save and POST
.../rules write it. The API's rule writers lock the redirector's row.
A criterion's values are stored as the page stores them (split, trimmed,
`all` as ALL), and a device in capitals, which never matches, is refused.
p202 rotator set-rules <id> (--rules or --file, --dry-run) sends a set; the
MCP connector gains rotator_create, rotator_update, rotator_delete and
rotator_set_rules, whose rules argument is the set as JSON with a schema
(a tool argument can now be a JSON document). Hosted AI keys may write
redirectors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… tools Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
With the rotator tools the list is 84,564 characters; with #260's text-ad tools beside them, about 87,900. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… them The merged tree's unit tier read three of the new statements as hidden from it: the criteria and destinations delete named its table through a variable, the click path's rule read was built in a class the cached-read scan does not follow, and two rule reads put a method call after their WHERE. The delete names each table literally, RuleOrder makes the click path's cached read itself, and the ORDER BY goes through a variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… back
PUT /rotators/{id}/rules?dry_run=1 built each rule's criteria and
redirects in the order the body listed them, while the write updates kept
entries in place and GET reads them back by id, so a body that reordered a
rule's entries (keeping their ids) previewed one order and wrote another.
The nested order decides nothing on the click path: every criterion has to
match, and a destination is drawn by weight. So the plan now holds each
rule's entries as the write will read them back (the kept ones by id, the
new ones after them as sent), and the preview and the write answer the
same data. The rules keep the order sent. The API reference and the
OpenAPI description say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…rule
PUT /rotators/{id}/rules/{ruleId} read and checked the rule, then took the
redirector's lock, so a rule set that removed the rule could commit in
between: the update then matched no row and its criteria and redirects
were inserted for a rule that no longer existed (these tables have no
foreign keys), answered as a success. DELETE /rotators/{id}/rules/{ruleId}
checked the rule the same way and answered a removed rule's delete as done.
Both now take the redirector's lock, then the rule's own (a locking read,
which also holds off Setup › Redirectors' save, which takes neither), before
the first read of the transaction, and read the rule under them. A rule gone
by then is a 404, with nothing written; an update that matches no row is
checked by a current read and refused the same way. The redirector delete
no longer reads the redirector before its lock.
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
…a rule at once
A rule write read its answer back after its transaction committed, when
the redirector's lock was already let go: a writer waiting on that lock
could replace the set in between, and PUT /rotators/{id}/rules answered the
second writer's rules beside its own changes (POST and PUT of one rule
answered the second writer's set too). The read-back now runs inside the
transaction, under the locks, and is what the write answers.
A rule set's 422 promised every problem at once, and gave one per rule:
the name, the flags and each criterion and redirect threw at the first
problem. Every check of a rule now runs and each problem is answered by
its place (rules.0.criteria.1.value), several in one entry included; the
single-rule writes report every entry's problems the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
The catalogue keeps #280's traffic source, account and category updates and deletes beside the rotator writes, and rotator_list takes #279's hosted omission of --all. Both changelogs keep the version branch's lines and add this branch's. The tool list is 89,469 characters with both; its budget is 90,000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…or_get answers #281 holds a hosted call's arguments to 1,000 JSON values, because each item of a list argument becomes a flag of its own. rotator_set_rules takes its rules as one JSON document, written to its command as one flag, and has no per-rule tool to fall back on: at 1,000 values a hosted call could send back about 30 rules of one criterion and two destinations, where rotator_get answers about 100 under the result bound. A tool that takes a JSON document is held to 5,000 values instead; at that bound the call allocated 5.5 MB, the list arguments at theirs 5.1. Past it the refusal says what to leave out and which command sends a larger set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
"set the api url" ranked p202 system integrations level with config set-url once the rotator writes and #280's tasks were both in the index: the two had scored within half a percent on the version branch, and the new commands' words moved the weights enough to tie them. config set-url now has a search task with the words the docs use for what it sets, the install's base URL or instance URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…B limit --file read the whole file with os.ReadFile before checking it against the API's 1 MB body limit, so a path naming something huge, or endless like /dev/zero, was read into memory before the size refusal could run. A path is now read through the same rotatorMaxRuleSetBytes+1 limit stdin already was, and anything past it is refused by the size check. TestRotatorSetRulesReadsAFileOnlyUpToTheLimit sends --file /dev/zero and expects the 1 MB refusal with no request; with os.ReadFile put back it dies with "fatal error: out of memory" under a 3 GB address-space limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…or-rule-needs-criterion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…the set ask
POST /rotators/{id}/rules and PUT /rotators/{id}/rules/{ruleId} checked
only the entries they were sent, so a rule with no criterion answered 200:
rule-create without --criteria-json sent none, and {"criteria": []}
cleared them. rtr.php and offrtr.php read a rule with no criterion as
every visitor, so one call sent all of a redirector's traffic to that
rule's destination (measured on a live instance, both scripts), while
Setup > Redirectors and PUT /rotators/{id}/rules refused such a rule.
Both writes now go through planRule(), the rule set's check, so the three
API writes refuse the same rules in the same words: a criterion, a
destination, one destination unless it is a split test, and a weight on
each of a split test's with one above 0. A PUT of one rule keeps what it
leaves out, as stored and unchecked, and the rule it would leave is
checked whole.
Rules already stored with no criterion are left as they are, and the
redirects go on reading them as every visitor. GET answers them with
"criteria": [], Setup > Redirectors lists them as "If any visitor (no
criteria)", and a write that would leave one with none says so.
The Go CLI's rule-create refuses a rule with no criterion or destination,
and both commands an empty list, before any request; so does the PHP
CLI's rotator:rule:create. The page's save refuses a rule posted with no
criteria list before it reads the list (it answered with PHP warnings
ahead of its refusal).
RotatorRuleWritesAreCheckedTest holds every file that writes a rule's rows
to a list, and every public controller method that writes one to the
check, in its transaction, above the first write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8623213d6e
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| $step_ok = _upgrade_measurement_tables([ | ||
| \Prosper202\Database\Tables\ClickTables::clickLatestVisits(), | ||
| \Prosper202\Database\Tables\ClickTables::reClickBackfill(), | ||
| \Prosper202\Database\Tables\RotatorTables::rotatorRules(), | ||
| ]); |
There was a problem hiding this comment.
Run the position migration for existing 1.9.79 installs
Installations that already ran the parent revision record version 1.9.79, so the surrounding if ($prosper202_version == '1.9.78') skips this newly added reconciliation and leaves 202_rotator_rules.position absent. After deploying this commit, API reads, reports, and the Setup page query/order or write that column and therefore fail, while only the click-path helper has a missing-column fallback. Move this schema addition to a new versioned upgrade step or add an idempotent reconciliation that also runs for databases already marked 1.9.79.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This line isn't part of this PR's own change. It comes from #265, which this PR is stacked on: git blame puts line 4437 in 11260af, #265's commit. The same finding is open on #265's thread, where I set out why I'm not adding a second migration and left the decision to the owner.
In short:
- 1.9.79 isn't tagged.
git ls-remote --tags origin 'v1.9.79*'prints nothing, so no released install has1.9.79recorded. - Every real upgrade runs this rung. An install coming from 1.9.78 takes the 1.9.78 → 1.9.79 step,
positionincluded. - Only a development or staging install can miss it. That means one that ran the 1.9.79 branch before 1.9.79: redirector create, update, delete and whole-rule-set writes in the MCP connector #265 merged. CLAUDE.md Fix PHP 8+ compatibility issues throughout Prosper202 #33 freezes only a tagged version's rung, and 1.9.79's is still open.
If the owner wants that case covered too, the change belongs in #265 and this PR would pick it up by merge. Leaving this thread open for the owner's call on #265.
Generated by Claude Code
Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, 202-config/functions-upgrade.php, 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
Brings in mcp-rotator-crud (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
|
The adversarial review marks bf0f0a8 BLOCKING (9 findings), so its check is red. The report is encrypted for maintainers, so I can't read which finding blocks. For context, two neighbouring PRs that cover the same code came back NON-BLOCKING:
So this may be reviewer variance, but I'm not assuming that. A self-review of this PR's own change (8623213) is under way. Each real defect it finds will be fixed here, with a test that fails without the fix, and merged into #307. I'll report the result on this thread. If a maintainer can share the blocking finding's title, I can target it directly. Generated by Claude Code |
…t refuses strips nothing
A server-side sync (sync jobs, re-sync) wrote a redirector's rules by
removing every rule of the target's redirector and then posting the
source's one at a time. POST /rotators/{id}/rules now refuses a rule with
no criterion, which a rule stored before 1.9.79 can be, so a sync of such a
redirector stopped at that rule with the target's own rules already gone
and only the source rules before it written (measured: a target holding
"kept" and "kept too" was left with "france" alone; before this branch the
same sync copied the rule and succeeded).
SyncEngine now sends the source's rules as one set (PUT
/rotators/{id}/rules), which the target takes whole or refuses with
nothing written; the recorded refusal names the source rule each field
error is about. A target without that route (before 1.9.79, which takes
any rule) answers 404 and is written to one rule at a time as before, and
so is a source redirector with no rules, which a set cannot be.
SyncRotatorRulesIntegrationTest runs the real engine against two accounts
served by the real RotatorsController (RotatorControllerRemoteApiClient
replaces only the HTTP exchange): a refused rule leaves the target's rules
as they were, a redirector the sync creates gets every rule or none, the
target takes the source's rules in order, an install without the route is
written one rule at a time, and an empty source set leaves none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The self-review found one real defect in this PR's own change, and 6d31aa3 fixes it. The review report is still encrypted, so I can't confirm it's the finding the adversarial review blocked on; the re-run on this push will show.
The description is updated, including two decisions for the reviewer and two new items under "Found outside". Generated by Claude Code |
Backlog: JF3K
What changed and why
POST /rotators/{id}/rulesandPUT /rotators/{id}/rules/{ruleId}checked only the entries they were sent. A rule with no criterion therefore answered 200:p202 rotator rule-createwithout--criteria-jsonsent nocriteriakey;{"criteria": []}on a PUT cleared a rule's criteria.rtr.phpandoffrtr.phpread a rule with no criterion as every visitor ($count == count($rotate)is0 == 0), so one call sent all of a redirector's traffic to that rule. Setup › Redirectors and #265'sPUT /rotators/{id}/rulesrefused such a rule.Measured before the fix (this branch at the #265 merge, live instance):
{"rule_name", "redirects"}answered 200 and stored 0 criteria;rtr.php(Firefox, Chrome, an unknown agent) and two throughoffrtr.phpall went to the rule's URL, not the default;PUT …/rules/{id}with{"criteria": []}answered 200, and a Chrome click then went to a rule that had been Firefox-only.The fix: one check of a rule.
createRule()andupdateRule()now go throughplanRule(), the check the set already used, inside their transactions after the redirector lock. The three API writes refuse the same rules, with the same field errors in the same words (a test compares them field by field):rule_name;ALLmatches every visitor, said in so many words);weighton each destination of a split test, at least one above 0.A PUT of one rule keeps what it leaves out, as stored and unchecked, as
destination()already keeps a held destination: a rename does not re-validate an old criterion. The whole-rule checks, though, are asked of the rule the write would leave, kept parts included, so{"splittest": 0}on a rule with two destinations is a 422.Rows already stored with no criterion (#25). Decided:
"criteria": []is the mark, and the docs, openapi and the refusal now say what it means. A new read-only key would have to be accepted back by the set, the single PUT, the MCP schema and every client that builds a body from a GET, for a legacy state only.CLI paths.
rotator rule-createrefuses, before any request, a rule with neither--criteria-jsonnor--country, or neither--redirects-jsonnor--redirect-campaign.rule-createandrule-updatealso refuse an empty list. All are validation errors (exit 1) with a hint naming theALLcriterion. This also stops an old server, which would store such a rule, from being sent one.rotator:rule:createrefuses the same before sending. Its help example"value":"US"(a bare code the server refuses) now readsUnited States(US).MCP. It has no single-rule tools;
rotator_set_rulesalready goes through the set.Staged writes. A staged POST is applied through the same route, so the apply answers the 422 (live, below).
Sync: all of a redirector's rules or none (6d31aa3). A self-review after the adversarial review marked this PR BLOCKING found that this PR turned a source rule the target refuses into a destructive sync:
SyncEngine::resyncRotatorRules()deleted every rule on the target, then POSTed the source's rules one at a time.Now
SyncEnginesends a redirector's rules as one set (PUT /rotators/{id}/rules). The target takes them all or refuses them with nothing written, and each field error names the source rule:tests/Api/V3/SyncRotatorRulesIntegrationTest(5 cases) runs the realSyncEngineagainst two accounts, each served by the realRotatorsController, with only the HTTP exchange replaced.POST /sync/jobsand run against two accounts left the target's rules as they were. The error named the rule (rules.1.criteria: the source rule "everyone": A rule needs at least one criterion…). Once that rule had anALLcriterion, the next sync copied both rules.The page. Its save (
tracking202/ajax/rotator.php) refused a rule posted with nocriterialist only after aforeachover the missing key. The answer was two PHP warnings and thenERROR, so the editor showed its generic message by accident. It now checks both lists first and answersERRORalone. The page keeps its own checks, which the API's do not replace (see "For the reviewer").A fourth write path.
tests/Api/V3/RotatorRuleWritesAreCheckedTest(structural, unit suite) checks two things:202_rotator_rules,_criteriaor_redirectsis listed with what makes it safe. It reads strings as PHP assembles them, joined across..RotatorsControllermethod that reaches such a write, through any number of the class's calls, is listed. A "checked" one must have the write inside itstransaction()closure, below a statement directly in that closure (not in an if, a loop or a try) whose call reachesplanRule().It plants each shape it claims (8 controller plants, 4 string spellings). It does not see a table name built at runtime (none of the tree's unread-table writes reaches these tables), a write in another class, or whether the listed page's checks are right.
Docs.
documentation/api/12-rotators.md: a new "One rule" section, and the rule fields table.docs/openapi.yaml:createRotatorRulenow requirescriteriaandredirects, withminItems: 1;201with a rule; it is200with the redirector, which is what the route has always answered;RotatorRule.criteriasays what empty means.docs/cli.md,docs/cli-agent.mdandgo-cli/cmd/COMMAND_CONTRACTS.md.Tests
RotatorRuleSetIntegrationTest, 16 new cases, on a scratch MariaDB:testEachRuleWriteRefusesWhatTheSetRefusesInItsWords: every case of the set's refusal provider, sent as a new rule (POST) and as the whole of an existing rule (PUT). Each is compared field by field, message by message, with the set's answer for the same rule; nothing is written.testAPutOfOneRuleIsCheckedAsTheRuleItWouldLeave: a missingcriteriakey,null,[],{"splittest": 0}with two kept destinations, and a split destination with no weight are refused. A switch sent alone keeps the stored lists.testARuleStoredWithNoCriterionStaysAndAWriteOfItSaysWhatItDoes: a legacy row is still tried byRuleOrder::activeRules; a rename, and the set sent back, are refused with the message; a criterion is taken.testAPutOfOneRuleDoesNotCheckWhatItLeavesOutAgain.SyncRotatorRulesIntegrationTest(6d31aa3), 5 cases:RotatorDestinationsIntegrationTest;RotatorRuleSetIntegrationTest::testARuleAddedOnItsOwnGoesLast, which had worked around the gap;RtrClickInstanceTest's spare rule, which bumps rule ids;tests/live/rotator-breakdown.shrule A, nowsplittest: 1; clicks split by weight either way;tests/live/offer-rotator-routing.sh's all-zero-weights rule, now created with weights and set to 0 in SQL, as an older write stored it;NestedBodyValuesAreCheckedTest's registry:refuseNestedKeys()folded intoplanRule().tests/live/setup-pages.shcases (the page refuses a rule with no criterion and one whose criterion is empty, withERRORalone; it lists a stored one as "any visitor");tests/Cli/Commands/RotatorRuleCreateCommandTest; GoTestRotatorRuleWithNoCriterionIsRefusedWithTheWayToSayEveryVisitorand six new cases inTestRotatorInputErrorsAreValidationErrorsBeforeAnyRequest(exit code and hint asserted).Plants: 17, each caught by a named test. Backups were
mktempcopies checked withcmp, the tree was restored, and the run was repeated on the final tree.createRule()withoutplanRule().updateRule()withoutplanRule().setRules()planning before its transaction. Caught by the structural test, and by the set's race tests.setup-pages.sh: "a rule with no criterion is refused").rule-createwithout the presence check.ruleListError()disabled.Live pass
Instance. A local MariaDB 10.11 / PHP 8.3.6 instance on this branch, port 8291. It was installed by running
install.php's own steps (schema, account, API key) without the licence form; the licence stub is down.The licence check was made unreachable. For the signed-in pages, the server ran with its HTTP proxy pointed at a closed port.
AUTH::is_valid_api_key()treats that as a network failure and lets the session through, which is what the app does on a real outage.What could not run here. The Go CLI answers 402 on this instance, so
p202 rotator rule-createandrule-updatewere not run live; Go unit tests cover them.Results.
rtr.php(a tracker link) andoffrtr.php(record_adv.php, then?rpi=).criteriakey → 422 namingcriteria, and POST withcriteria: []→ 422. No rule stored; both scripts send to the default.allis taken. POST withvalue: "all"→ 200, storedALL; both scripts send every agent to the rule.criteria: []→ 422, and the criterion stays. PUT with a Firefox criterion → Firefox goes to the rule on both scripts, Chrome to the default.splittest: 0with two destinations kept → 422; the rule stays a split test.criteria: []. A rename → 422 with the "This rule has no criterion, which the redirects read as every visitor" message. Giving it a Chrome criterion → Chrome goes to it, and Firefox goes past it to the Firefox rule, on both scripts.bin/p202, live):rotator:rule:createwithout--criteria-json→ exit 1, nothing sent; with one → stored.tests/live/setup-pages.sh: 332 passed, 0 failed (1.9.79: redirector create, update, delete and whole-rule-set writes in the MCP connector #265 reported 328; the 4 new cases are the page refusals and the "any visitor" listing).tests/live/offer-rotator-routing.sh: 30/0.tests/live/rotator-breakdown.sh: 10/0.RtrClickInstanceTest: 3 tests, 36 assertions. The spare-rule branch ran: a rule with two redirects bumped the ids.RotatorRuleSetInstanceTest: 2/29.SetupPermissionsInstanceTest: 5/297.RotatorRuleSetIntegrationTest: 53 tests;RotatorDestinationsIntegrationTest: 34 tests;SyncRotatorRulesIntegrationTest5,ClickRotatorFields2,RotatorBreakdown3.Verification
.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, run on the final tree before the first commit (everything staged), PHP 8.3.6 with the fullvendor/, schema tier on a scratch MariaDB 10.11 database. The unit tier ran 6124 tests, 4 skipped (the documented "User class requires DB singleton" ones). The live instance's202-config.phpwas moved out of the checkout for the run.For 6d31aa3,
verify.sh --changedpassed patterns, phpcs, phpstan, schema, syntax and unit (6250 tests, 4 skipped).CI status: not yet observed on 6d31aa3.
What could not run here
rule-createandrule-updatewere exercised by Go unit tests against a fake server only.For the reviewer to check
planRule().ajax/rotator.phpwas not routed throughplanRule(). Its posted shape differs, and its checks differ in both directions: it refuses any 0 weight, and the API refuses only all-zero. The structural test lists it with that reason. Unifying them is a larger change (see below).messageis now the set'sInvalid <fields>form, where it was e.g.Unknown criterion type. The field errors and their words are unchanged.country is ALL, which the redirects read the same way. Both comparators (SyncEngineand the Go diff) would then need to treat an empty list as equal toALL.Found outside this item (one line each)
PUT /rotators/{id}/rules/{ruleId}writes a rule's criteria and redirects afresh with new ids (the set keeps them), so a click report row that names the redirect it was sent to loses it after a single-rule PUT ofredirects.array_filterdrops'0'), while the API and the set accept 0 and refuse only all-zero.tests/live/setup-pages.shwrites its artifacts to/tmpvia a baremktemp -d, not a directory the caller chooses.SyncEnginesends'status' => $rule['status'] ?? 1, so a rule stored with a NULL (inactive) status becomes active on the target.rtr.phpandoffrtr.phpnever readsplittestand split by weight whenever a rule has more than one usable destination.Generated by Claude Code