Repository navigation
1.9.79: a PUT of one rule keeps the ids of the redirects it keeps - #307
Merged
Merged
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
PUT /rotators/{id}/rules/{ruleId} with `redirects` deleted the rule's
redirects and inserted them again, so every redirect took a new id. A click
names the redirect that sent it by id (202_clicks_rotator.rule_redirect_id,
copied into 202_dataengine), so after any such PUT every click on the rule,
and its report row, named a redirect that was gone: Group Overview's
Redirect level listed them under no redirect. Criteria were rewritten the
same way.
The single-rule write now writes a rule's entries as the rule set (#265)
writes a rule it keeps, through the same function: writeKeptEntries(),
taken out of writeRuleSet(), calls writeCriteria()/writeRedirects() where
the entries changed. "Keeps" is the set's answer: an entry sent with its
id (as GET answers it) is that entry, updated in place; one the list leaves
out is removed; one sent without an id is added. An id that is not one of
this rule's own entries (another rule's, another account's) was already
refused by nestedKeyErrors(), and still is.
Tests:
- RulePutKeepsRedirectIdsIntegrationTest: a click written by the click
writer on a redirect, rolled up, then a PUT changing that redirect's URL;
Group Overview's own query (through account-scope-runner.php, which now
prints each row's id) names the redirect by the same id before and after.
With the old delete-and-reinsert planted, it lists the click under no
redirect and fails. Also: a redirect left out is removed and one without
an id added, a GET body sent back writes nothing, and another rule's or
another account's redirect id is refused with nothing written.
- RedirectRowsKeepTheirIdsTest (structural): every DELETE in the served
tree whose FROM names 202_rotator_rules or 202_rotator_rules_redirects is
listed by file and function with why it takes nothing a write keeps; the
planted old updateRule() delete fails it.
- RotatorDestinationsIntegrationTest pinned the old ids moving ("a GET body
read before the entries were rewritten"); it now asserts the ids are kept
and that a body naming a redirect a later write removed is refused.
Docs (12-rotators.md, openapi.yaml, cli.md) and rule-update's
--redirects-json help say an entry sent with its id keeps it.
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. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Contributor
Contributor
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
…t-ids Brings in rotator-rule-needs-criterion (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
…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
…t-ids Brings in the stacked base's sync fix: a sync writes a redirector's rules as one set, so a rule the target refuses no longer strips its rules. Only the changelogs conflicted; both sides' lines are kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202
added a commit
that referenced
this pull request
Oct 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backlog: 0KGE
Stacked on #290 (
1.9.79-rotator-rule-needs-criterion), which is stacked on #265 (1.9.79-mcp-rotator-crud). Merge after both. Until they merge, this PR's diff against1.9.79includes theirs. This PR's own change is the top commit, 4e4754d.What was wrong
PUT /rotators/{id}/rules/{ruleId}withredirectsdeleted all of the rule's redirects and inserted them again, so every redirect got a new id. A click names the redirect that sent it by id (202_clicks_rotator.rule_redirect_id, which the rollup copies into202_dataengine). After any such PUT, every click on the rule, and its report row, named a redirect that no longer existed, and Group Overview's Redirect level listed those clicks under no redirect. Criteria were deleted and reinserted the same way.What changed
RotatorsController::updateRule()now writes a rule's criteria and redirects the way the rule set (1.9.79: redirector create, update, delete and whole-rule-set writes in the MCP connector #265) writes a rule it keeps, using the same code. I tookwriteKeptEntries()out ofwriteRuleSet()'s kept-rule branch without changing what it does. It calls the set's ownwriteCriteria()/writeRedirects()when the entries changed, and both writes now go through it. There is no second matcher.id(as GET returns it) is that entry, updated in place.idis added.idthat is not one of this rule's own entries (another rule's, or another account's) was already refused bynestedKeyErrors()with a 422 onredirects.N.id. It still is.writeRedirects()'sUPDATE … WHERE id = ? AND rule_id = ?ties the write to the rule as well.documentation/api/12-rotators.mdanddocs/openapi.yamlboth said entries were "written afresh, so they get new ids". They now describe the new behaviour.docs/cli.mdand the--redirects-jsonhelp ofp202 rotator rule-updatenow say that a redirect sent with its id keeps that id.changelogs.txtand202-config/changelogs.json.The other writers of rule redirects
tracking202/ajax/rotator.php)UPDATEd by id, after a check that it belongs to the saved rule. Redirects not posted are deleted withid NOT IN (…).tracking202/setup/rotator.phprotator_set_rulesgoes through the set, which already kept ids.p202 rotator rule-update(Go)rule-createandrule-deleteare unaffected.api/v3/Support/SyncEngine.php,resyncRotatorRules())Tests
tests/Report/RulePutKeepsRedirectIdsIntegrationTest.php(integration, new):MysqlClickRepository::recordClick), rolls it up withClickRollupSql, and reads Group Overview's own query at the Redirect level (account-scope-runner.php, which now also prints each row's id).{id: A, title: 'A', clicks: 1}, and the ids match before and after.updateRule(), both id-keeping tests fail on the report row:id '',title '', i.e. the click is listed under no redirect. The file was restored and checked withcmpafterwards.tests/Rotator/RedirectRowsKeepTheirIdsTest.php(structural, the machine check for this shape):202_rotator_rulesor202_rotator_rules_redirectsmust be listed by file and function, with why it removes nothing a write keeps.AssembledStrings, asClickRowsWrittenOnlyByTheWriterTestdoes. Planted shapes cover: a closure inside a method, lower case with backticks and a schema prefix, interpolation over several lines, a joined table, aTableRegistryconstant, and a table read at runtime.updateRule()delete fails it asupdateRule(): DELETE FROM 202_rotator_rules_redirects WHERE rule_id = ?.tests/Api/V3/RotatorDestinationsIntegrationTestpinned the old behaviour ("a GET body read before the entries were rewritten (their ids moved)" was refused). It now asserts that a GET body sent back keeps every criterion and redirect id, and that a body naming a redirect a later write removed is refused onredirects.1.id. Each refusal case now asserts the exact field it trips.Verification
verify.sh --since origin/1.9.79 --changed. Run from the committed branch with no202-config.phpin the tree, and the schema tier pointed at the scratch databasep202_0kge_test:The unit tier ran 6137 tests with 4 skipped (the documented baseline).
Integration suites, run by hand on MariaDB 10.11, each on a freshly created database as
tests/run-integration-suites.shruns them:RulePutKeepsRedirectIdsIntegrationTestRotatorRuleSetIntegrationTestRotatorDestinationsIntegrationTestLegacyReportAccountScopeIntegrationTest(the runner's other user)RotatorBreakdownIntegrationTestClickRotatorFieldsIntegrationTesttests/Schema/RotatorRuleSetInstanceTest(@group instance) passed (OK, 2 tests) against the live instance from this branch.Live, before and after. Both instances ran under
php -Swith every proxy variable pointed at the closed port 127.0.0.1:9.Setup: a redirector with one rule (country ALL, split test) and two URL redirects, "live A" and "live B". 12 clicks went through
rtr.php. Then aPUT /rotators/1/rules/1sent GET's redirects with A's URL changed. Group Overview was read at the Redirect level throughtracking202/ajax/group_overview.php, signed in.live A 3 / live B 9to a single row titled''with 12 clicks.rule_redirect_ids now name nothing.live A 4 / live B 8both before and after.https://a2.example/.redirects.0.id, with nothing written.What could not run
install.phpfails closed when my.tracking202.com cannot be reached), so neither instance was installed through the form. Each was installed by runninginstall.php's own steps after its licence check:INSTALL::install_databases(),InstallAccount::create(), the REST key insert andinstall_database_partitions(), run from a CLI script against that instance's202-config.php. Everything after that went through HTTP: the API with curl, clicks throughrtr.php, sign-in and Group Overview through the pages.For the reviewer to check
writeRuleSet()changed only by the extraction ofwriteKeptEntries(); its behaviour is the same. I did not touch 1.9.79: a redirector rule written on its own needs a criterion, as the page and the set ask #290's criterion checks.account-scope-runner.php'sgroup:reader now returnsidon each row as well astitleandclicks.LegacyReportAccountScopeIntegrationTestis unaffected; it passed.Findings outside this item
SyncEngine::resyncRotatorRules()) deletes every target rule over the API and POSTs them again. Every rule and redirect on the target gets a new id, and the target's existing clicks lose their rule and redirect in reports.tracking202/ajax/rotator.php) removes a rule left off the page (DELETE FROM 202_rotator_rules WHERE id NOT IN …) without deleting that rule's202_rotator_rules_redirectsrows. The orphaned rows stay behind.Generated by Claude Code