Skip to content

PROD-3078: keep target-only edits to templates, URL redirections and list order - #261

Merged
5PK merged 8 commits into
mainfrom
PROD-3078-fingerprint-sync-state
Nov 4, 2026
Merged

5PK merged 8 commits into
mainfrom
PROD-3078-fingerprint-sync-state

Conversation

@5PK

@5PK 5PK commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

PROD-3078. Follow-up to #258: page templates, URL redirections and list order now follow the same rule as content, pages, models, containers, assets and galleries, so conflict detection and --overwrite behave the same for every type.

The rule

Since the last sync Result
Only the source changed The target is updated
Only the target changed The target's version is kept, with a warning ("Target changes kept", preflight, targetChangeKept in --jsonSummary)
Both changed Conflict
--overwrite The source wins in the last two cases

Before this, these three types couldn't tell which side changed, so any difference was settled by the source: a target-only edit was overwritten without --overwrite and without a warning.

How

These types have no version or modified date, so the mapping now records the last synced state.

  • Templates and redirections: sourceHash / targetHash / fingerprintVersion on the existing mapping record. Each is a sha1 of a normalised shape: the fields the "differs" checks already compare, without per-instance IDs, in a stable order. A template's target hash comes from what the server saved.
  • List order: a new listorder mapping per locale, with each list's source order and the target order the run left. A reorder is a change in the relative order of the items the record and the list both have, so adding or deleting an item isn't one.
  • Reverse sync: the fields are source* / target* pairs, so they transpose with the IDs.
  • Upgrading: a mapping from an older CLI has no record. The first sync lets the source win, as before (preflight labels it "no sync record yet"), and records it. Anything already in sync is recorded without a write.
  • Fetch API lag: redirections and list order are pulled from the Fetch API, which shows a change a few minutes after it's made. A sync run in that window used to read the previous sync's own change as an edit: a false "kept" warning, or in a reverse sync the old state synced back. The record now also keeps what the sync replaced (targetReplacedHash, targetReplacedContentIDs). A side still showing it is skipped until the Fetch API catches up, and that pull clears it.

Behaviour changes beyond the ticket

  • Kept or conflicting templates: the pages that use one are held back only when it has no section for one of their zones. Those zones would otherwise be placed by position or folded into the main zone.
  • Redirection deleted on the target: a mapped one is now a conflict, like a deleted template. --overwrite recreates it. Before, it was recreated silently.

Also in this PR: preflight plans a new model's lists and items

Found testing 1.3.0-beta.1 on a clone pair. A preflight writes nothing, so a model it would create gets no mapping: the container pusher skipped the model's lists ("target model mapping not found"), and hasValidMappings dropped their new items without a preflight entry. The plan showed only the model; the real sync created all of them. Now, in preflight only, a container whose model is planned is planned (and recorded as planned), and an item whose container and model are planned is planned too. Real runs are unchanged.

On the clone pair, for a new model with a list and 3 items: beta.1 planned Models: create 1 and skipped the list; this branch plans Models: create 1, Containers: create 1, Content: create 3. Unit tests in container-pusher.test.ts and has-valid-mappings.test.ts.

CI

The live permutation test now drifts each of the three on the receiving side every round: a list reorder, a template section display-name rename, and a redirection edit (a redirection is added to A before the first sync). drift:listOrder, drift:template and drift:urlRedirection pass only when the edit is kept with a warning, or reported as a conflict.

Getting this reliable needed two harness fixes:

  • The runner waits until the Fetch API shows a reordered list's new itemOrder. The order shows up later than the items' new versions.
  • content.reorder uses the list item-order endpoint, the drag-and-drop path. Saving items with swapped itemOrder values can leave the list unchanged.

Testing

  • Unit tests (2790 pass) cover, for each type:
    • source only, target only, target only with --overwrite;
    • both sides, both sides to the same value;
    • no record, and records from another fingerprint version;
    • a reverse sync reading transposed records;
    • stale pulls on either side.
  • Quick tier permutation run 41685279004 passed 24/24 before the rebase onto main. Run 41693418332 is running on this branch as rebased.

🤖 Generated with Claude Code

5PK and others added 7 commits November 3, 2026 13:23
… order

Page templates, URL redirections and list order now follow the rule the other types use: a change
on the source only updates the target, a change on the target only is kept with a warning, a change
on both sides is a conflict, and --overwrite lets the source win. Before, any difference was settled
by the source, so a target-only edit was overwritten without --overwrite and without a warning.

These types have no version or modified date, so the mapping now records the last synced state:
- Templates and redirections: a hash of each side's normalised shape (sourceHash, targetHash,
  fingerprintVersion), the same fields the "differs" checks compare, without per-instance IDs.
  A template's target hash comes from what the server saved.
- List order: a new `listorder` mapping per locale with each list's source order and the target
  order the run left. A reorder is a change in the relative order of the items both have, so an
  item added or deleted on either side isn't one.
The fields are source*/target* pairs, so a reverse sync reads them transposed.

A mapping from an older CLI has no record: the first sync lets the source win, as before (preflight
labels it "no sync record yet"), and records it; lists and templates already in sync are recorded
without a write.

A template kept on the target (or in conflict) holds back the pages that use it only when it has no
section for one of their zones, which would otherwise be placed by position or folded into the main
zone. A mapped redirection deleted on the target is now a conflict, like a deleted template;
--overwrite recreates it.

Kept changes are reported as before, with the new types template, urlRedirection and listOrder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every round now also reorders a list and renames a template section's display name on the side
being synced to. drift:listOrder and drift:template pass when the sync keeps the edit with a
target-changed warning, or reports a conflict when the round's own changes touched it too. A
template section rename is also in the change catalogue for the random tiers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before the first sync the run adds a redirection to A. Every round then points it somewhere new on
the side being synced to, and drift:urlRedirection passes when the sync keeps the edit with a
target-changed warning. The Management SDK can only delete redirections, so the run saves through
the Management API's batch endpoint and reads the Fetch API's redirection list, as the CLI does,
waiting for each edit to show there before syncing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al reorder

Round 1 of the first run graded drift:listOrder as failed although the CLI decided nothing wrong:
the sync's pull didn't show the reorder yet. An order change reaches the Fetch API's sync feed later
than the item's new version, and the runner only waited for versions. The runner now also waits
until the feed shows each reordered item at its new itemOrder.

Swapping two items by saving them can leave both with the same itemOrder, which the CLI rightly
reads as no defined order rather than a reorder. The list order drift now moves a list's last item
to the top through the list item-order endpoint, as drag and drop does, and checks the order it
left. The change is also in the catalogue for the random tiers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run 3 showed that saving two items with swapped itemOrder values can leave the list in its order,
so the round's own list swap silently did nothing. content.reorder now moves a list's last item to
the top through the list item-order endpoint, as the Manager's drag and drop does, and is the one
reorder in the catalogue. The list order drift avoids a list the round already reordered on the
other side: the same move on both sides leaves the two in the same order, with nothing to report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an edit

Redirections and list order are pulled from the Fetch API, which shows a save or a reorder a few
minutes after it's made. A sync run in that window pulled the state the last sync had replaced: on
the target it was reported as a kept target change (the live test's preflight right after a sync
listed the list that sync had just reordered), and in a reverse sync, where that side is the
source, it would have synced the old state back.

When a sync replaces a target redirection or list order, the record now also keeps what it
replaced (targetReplacedHash, targetReplacedContentIDs; a reverse sync reads them as source*). A
side that still shows it is waiting on the Fetch API, not edited: with no other change the entity
is skipped until a pull shows the new state, which clears it. A real change on the other side
still counts. Templates are read from the Management API and don't need it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's content drift passes `avoid` as a Set of content IDs; the list order drift took a list of
list names under the same key. It now uses `avoidLists`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@5PK
5PK deployed to cli-dev-live November 3, 2026 18:25 — with GitHub Actions Active
A preflight writes nothing, so a new model gets no mapping. The container pusher then skipped its
lists as "target model mapping not found", and hasValidMappings dropped the lists' new items without
a preflight entry, so the plan showed only the model while the real sync created all of them.

In preflight, a container whose model the model pusher recorded as planned is now planned as a
create and recorded as planned itself, and an item whose container and model are both planned
counts as having valid mappings. Real runs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@5PK
5PK deployed to cli-dev-live November 3, 2026 18:32 — with GitHub Actions Active
@5PK
5PK deployed to cli-dev-live November 3, 2026 18:33 — with GitHub Actions Active
@5PK
5PK deployed to cli-dev-live November 3, 2026 18:39 — with GitHub Actions Active
@5PK
5PK merged commit f7fc6d0 into main Nov 4, 2026
6 checks passed

This branch was successfully deployed

1 active deployment
cli-dev-live — 3d4cd71b Deployed Nov 3, 2026 by 5PK via Release leases #26
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.

2 participants