Skip to content

1.9.79: a PUT of one rule keeps the ids of the redirects it keeps - #307

Merged
tracking202 merged 22 commits into
1.9.79from
1.9.79-rule-put-keeps-redirect-ids
Oct 28, 2026
Merged

tracking202 merged 22 commits into
1.9.79from
1.9.79-rule-put-keeps-redirect-ids

Conversation

@tracking202

Copy link
Copy Markdown
Owner

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 against 1.9.79 includes theirs. This PR's own change is the top commit, 4e4754d.

What was wrong

PUT /rotators/{id}/rules/{ruleId} with redirects deleted 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 into 202_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 took writeKeptEntries() out of writeRuleSet()'s kept-rule branch without changing what it does. It calls the set's own writeCriteria() / writeRedirects() when the entries changed, and both writes now go through it. There is no second matcher.
  • How a redirect counts as "kept": I followed the set and match by the id the client sent:
    • An entry sent with its id (as GET returns it) is that entry, updated in place.
    • An entry the list leaves out is removed.
    • An entry sent without an id is added.
    • Entries are not matched by position or by content. A client that sends the same redirects without ids still gets new rows, exactly as with the set.
  • [Update] Fix CPC warnings and input length #27 (refusing another rule's id): a redirect id that is not one of this rule's own entries (another rule's, or another account's) was already refused by nestedKeyErrors() with a 422 on redirects.N.id. It still is. writeRedirects()'s UPDATE … WHERE id = ? AND rule_id = ? ties the write to the rule as well.
  • Docs:
    • documentation/api/12-rotators.md and docs/openapi.yaml both said entries were "written afresh, so they get new ids". They now describe the new behaviour.
    • docs/cli.md and the --redirects-json help of p202 rotator rule-update now say that a redirect sent with its id keeps that id.
  • Changelogs: a "Fixed:" line in both changelogs.txt and 202-config/changelogs.json.

The other writers of rule redirects

Writer Finding
Setup › Redirectors (tracking202/ajax/rotator.php) Already keeps ids. A posted redirect with an id is UPDATEd by id, after a check that it belongs to the saved rule. Redirects not posted are deleted with id NOT IN (…).
tracking202/setup/rotator.php Only deletes a whole redirector.
MCP tools No single-rule tool exists. rotator_set_rules goes through the set, which already kept ids.
p202 rotator rule-update (Go) A pass-through to this endpoint, so it is fixed by this change. Only its help text changed.
PHP CLI Has no rule-update command. rule-create and rule-delete are unaffected.
Sync engine (api/v3/Support/SyncEngine.php, resyncRotatorRules()) Same defect, separate writer, not fixed here. On re-sync it DELETEs every rule on the target over HTTP and POSTs them again, so on the target every rule and redirect gets a new id, and the target's clicks lose their rule and redirect. Reported as a finding below.

Tests

  • tests/Report/RulePutKeepsRedirectIdsIntegrationTest.php (integration, new):
    • It records a click on redirect A with the real click writer (MysqlClickRepository::recordClick), rolls it up with ClickRollupSql, and reads Group Overview's own query at the Redirect level (account-scope-runner.php, which now also prints each row's id).
    • It then sends a PUT that changes A's URL. The row still reads {id: A, title: 'A', clicks: 1}, and the ids match before and after.
    • It also covers: a redirect left out is removed; one sent without an id is added; a GET body sent back writes nothing; another rule's or another account's redirect id is refused with nothing written.
    • Plant: with the old delete-and-reinsert put back into 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 with cmp afterwards.
  • tests/Rotator/RedirectRowsKeepTheirIdsTest.php (structural, the machine check for this shape):
    • Every DELETE in the served tree whose FROM names 202_rotator_rules or 202_rotator_rules_redirects must be listed by file and function, with why it removes nothing a write keeps.
    • DELETEs whose table the scan cannot read are listed by their text, with the tables they reach.
    • It reads strings through AssembledStrings, as ClickRowsWrittenOnlyByTheWriterTest does. Planted shapes cover: a closure inside a method, lower case with backticks and a schema prefix, interpolation over several lines, a joined table, a TableRegistry constant, and a table read at runtime.
    • Plant: the old updateRule() delete fails it as updateRule(): DELETE FROM 202_rotator_rules_redirects WHERE rule_id = ?.
  • tests/Api/V3/RotatorDestinationsIntegrationTest pinned 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 on redirects.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 no 202-config.php in the tree, and the schema tier pointed at the scratch database p202_0kge_test:

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 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.sh runs them:

Suite Result
RulePutKeepsRedirectIdsIntegrationTest OK, 3 tests
RotatorRuleSetIntegrationTest OK, 53
RotatorDestinationsIntegrationTest OK, 34
LegacyReportAccountScopeIntegrationTest (the runner's other user) OK, 22
RotatorBreakdownIntegrationTest OK, 3
ClickRotatorFieldsIntegrationTest OK, 2
tests/Schema/ OK, 13

RotatorRuleSetInstanceTest (@group instance) passed (OK, 2 tests) against the live instance from this branch.

Live, before and after. Both instances ran under php -S with 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 a PUT /rotators/1/rules/1 sent GET's redirects with A's URL changed. Group Overview was read at the Redirect level through tracking202/ajax/group_overview.php, signed in.

  • Base (1.9.79: a redirector rule written on its own needs a criterion, as the page and the set ask #290 head):
    • The redirect ids went from 1, 2 to 3, 4.
    • Group Overview went from live A 3 / live B 9 to a single row titled '' with 12 clicks.
    • Both stored rule_redirect_ids now name nothing.
  • This branch:
    • The ids stayed 1, 2.
    • Group Overview showed live A 4 / live B 8 both before and after.
    • The next clicks on A went to https://a2.example/.
    • A PUT naming another rule's redirect id was answered 422 on redirects.0.id, with nothing written.
    • A PUT keeping A by id, leaving B out and adding C left the rows as A (id 1, new URL) and C (a new id), with B removed.

What could not run

  • The licence stub is down. The web installer refuses the licence key (install.php fails closed when my.tracking202.com cannot be reached), so neither instance was installed through the form. Each was installed by running install.php's own steps after its licence check: INSTALL::install_databases(), InstallAccount::create(), the REST key insert and install_database_partitions(), run from a CLI script against that instance's 202-config.php. Everything after that went through HTTP: the API with curl, clicks through rtr.php, sign-in and Group Overview through the pages.
  • The Go CLI was not driven against an instance, because it gets 402 without the licence. Its change is one line of help text, and the go, golangci and empty-HOME tiers ran.
  • No agent-eval case was added or run. The evals need the Go CLI against a licensed instance, and this fixes a side effect of an existing write rather than adding a capability.
  • CI status: not yet observed.

For the reviewer to check

  • writeRuleSet() changed only by the extraction of writeKeptEntries(); 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.
  • Criteria now also keep their ids on a single-rule PUT. Nothing names a criterion by id, but it is a behaviour change.
  • account-scope-runner.php's group: reader now returns id on each row as well as title and clicks. LegacyReportAccountScopeIntegrationTest is unaffected; it passed.
  • Not added: a note under CLAUDE.md [Setup] Remove undefined variable from template calls #25 generalising this (a delete-and-reinsert is a REPLACE in two statements, and changes the one column other rows name). Editing CLAUDE.md was outside what I was asked to do, so the structural test covers the rotator tables only.

Findings outside this item

  • Sync engine re-sync (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.
  • Setup › Redirectors' save (tracking202/ajax/rotator.php) removes a rule left off the page (DELETE FROM 202_rotator_rules WHERE id NOT IN …) without deleting that rule's 202_rotator_rules_redirects rows. The orphaned rows stay behind.

Generated by Claude Code

claude added 17 commits October 23, 2026 19:54
…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
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
… 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
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:44.875912Z
We couldn’t complete this review. Comment @codex review to try again.
4e4754d 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.

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.

@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.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For 4e4754d: 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 8f5a314: 7 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, 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
tracking202 merged commit 0e74320 into 1.9.79 Oct 28, 2026
20 checks passed
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