Skip to content

fix(sync): push stamps API-derived baselines; one canonical script[] shape; statement-boundary markers (#686) - #694

Merged
padak merged 6 commits into
mainfrom
claude/issue-686-pr-draft-867ec7
Aug 25, 2026
Merged

fix(sync): push stamps API-derived baselines; one canonical script[] shape; statement-boundary markers (#686)#694
padak merged 6 commits into
mainfrom
claude/issue-686-pr-draft-867ec7

Conversation

@padak

@padak padak commented Aug 25, 2026

Copy link
Copy Markdown
Member

Fixes #686.

What this fixes

Three defects sharing one root cause — sync push stamped the manifest baseline (pull_config_hash) from a hash computed from the files on disk, while sync pull and sync diff compute it from the API response, and the two sides held contradictory definitions of what script[] looks like:

  1. Phantom drift (the reported symptom): after a successful sync push of a multi-statement SQL transformation, every subsequent sync diff reported ~ REMOTE MODIFIED … codes changed forever, even though remote and working tree were byte-identical. Only a real sync pull cleared it — until the next deploy re-created it.
  2. Silent production rewrite (found during triage, worse than the report): when the API's script[] elements carry no trailing semicolons (["SELECT 1", "SELECT 2"]), transform.sql had no statement boundaries left to recover, so push collapsed N statements into ONE element — the MULTI_STATEMENT_COUNT=1 crash shape of sync push collapses transformation script[] array → runtime fails #119/fix: preserve multi-element script[] arrays in sync roundtrip #120/config update: re-split script[] elements that contain multiple statements (gap beyond #245) #274 — and diff then said in sync.
  3. is_disabled phantom (same class, non-script instance): a config disabled in the UI whose local YAML has no is_disabled key drifted permanently after any push, because the disk-derived baseline lacked the key the API-derived comparison emits.

How

Part 1 — push stamps the baseline from the API response (all six sites: config create/update, row create/update, Phase C/D binding backfills). The mutation response is used only when it carries a complete configuration; an id-only response triggers a get_config_detail() / get_config_row() read-back; if that fails, the baseline is left visibly stale (+ a warnings[] entry in the push envelope) — never silently stamped from disk. pull_hash / pull_extra_hashes stay disk-derived (they describe local files; that is correct).

Part 2 — one canonical script[] shape. Runtime semantics decide: one array element = one executable statement (the premise of #119/#120/#274). _normalize_scripts is now component-aware — SQL transformations split per-element via the new single producer canonical_sql_script() (never joined first), everything else keeps the join. This is also why existing push-stamped baselines start matching diff without any user action: they were already split-shaped.

Part 3 — transform.sql carries statement boundaries when semicolons can't. Extraction verifies the round-trip; only when split_statements() cannot reproduce the canonical array does it insert /* ===== STATEMENT ===== */ between statements — ;-terminated trees stay byte-identical. Markers are guaranteed boundaries on merge, and split_statements() still runs within each segment (a hand-typed ; SELECT … inside a marked segment splits correctly). A statement whose own text contains a marker-identical line suppresses markers with a warning (documented limitation). Auto-appending missing ; was rejected: it changes content and Oracle/ODBC rejects trailing semicolons (ORA-00911).

Migration — per-entry config_hash_version. Part 2 changes the computed hash for pull-stamped baselines of multi-statement SQL configs, so a naive release would hand every existing tree a third flavour of phantom drift. Instead:

  • versioned entries compare strictly; unversioned (pre-upgrade) entries match leniently against the new shape OR the legacy collapsed shape (recomputed from the raw API config via the old path — never by re-collapsing already-split data);
  • leniency applies everywhere a stored hash meets a fresh API hash: diff base, force-pull conflict detection, pull idempotency;
  • one clean sync pull migrates: re-extracts (writing markers where needed) and stamps config_hash_version: 2 — but only when _config.yml AND all companion files are unchanged; a locally-modified tree keeps its files, old hash and no version stamp;
  • the version key is never stamped next to a legacy-produced hash;
  • pushing a modified SQL config from an unversioned tree whose only difference from remote is statement boundaries aborts that change with the new SYNC_LEGACY_BOUNDARY error ("run sync pull first") instead of silently rewriting production; genuine edits proceed.

Verification

The issue's repro table now reads (was PHANTOM/CHANGED before):

['SELECT 1;', 'SELECT 2;']  -> sent ['SELECT 1;', 'SELECT 2;']  | content SAME    | diff in sync
['SELECT 1', 'SELECT 2']    -> sent ['SELECT 1', 'SELECT 2']    | content SAME    | diff in sync
['SELECT 1;\nSELECT 2;']    -> sent ['SELECT 1;', 'SELECT 2;']  | content CHANGED | diff in sync
['SELECT 1']                -> sent ['SELECT 1']                 | content SAME    | diff in sync

Row 3 is deliberate: one element packing two statements is not a canonical array (it is the #274 crash shape); push normalizes it. No file format can express "do not split here", and push has always sent 2 elements for it — only the phantom is gone.

  • tests/test_sync_baseline_stamping.py (17 tests): the reporter's regression (pull → edit → push → diff in sync), statement-count mirrors with/without semicolons, the is_disabled instance, partial-response read-back, failed-fetch leaves baseline unstamped + warning, versioned-vs-unversioned migration incl. real-drift discrimination, pull migration preserving an edited transform.sql, the SYNC_LEGACY_BOUNDARY abort vs genuine-edit passthrough, force-pull leniency (A/B-verified).
  • tests/test_sync_code_extraction.py: marker round-trip, marker collision fallback, within-segment re-split, broad-predicate SQL variants, the full repro table pinned as a parametrized test.
  • make check green: 6189 passed, 12 skipped; ruff/ty/loc-check/error-codes/sentinel-guards all clean (sync_service.py net −78 code lines).

Deliberately updated legacy tests

  • tests/test_sync_config_format.py::TestNormalizeScripts (7 tests) — _normalize_scripts now takes component_id; they pass a non-SQL component and keep asserting the join semantics for that branch.
  • tests/test_sync_reconcile.py::test_adopted_by_id_push_registers_manifest_entry — the bare mock now serves get_config_detail from the remote fixture (an id-only response no longer counts as a stampable baseline).
  • tests/test_sync_service.py::TestFreshCreateVariableBinding::test_push_resolves_bindings_end_to_end — create mocks gained get_config_detail/get_config_row read-backs.

Notes for the release PR

  • No version bump, no changelog.py entry (per the release process). Doc surfaces (gotchas.md, sync-workflow.md, CLAUDE.md, commands-reference.md, keboola-expert.md) are tagged (since vNEXT).
  • New error code: SYNC_LEGACY_BOUNDARY (type conflict).
  • Release note should say: run sync pull once per project after upgrading to migrate baselines to config_hash_version: 2; until then unversioned entries are matched leniently (no worse than the pre-upgrade blindness, and only for the one collapsed-shape difference).

Follow-up

A separate PR wires normalize_blocks_codes_script (the #274 runtime-safety guard) into the sync push path as a belt-and-braces backstop — after parts 2+3 it is a no-op on this path by construction.

padak added 5 commits August 25, 2026 13:51
Two functions held contradictory definitions of
parameters.blocks[].codes[].script: config_format._normalize_scripts
(pull + the remote side of diff) collapsed each code into exactly one
joined string, while code_extraction._lines_to_script (push + the local
side of diff) split SQL on statement boundaries. They can never agree
for a multi-statement SQL transformation, which is the permanent
"~ REMOTE MODIFIED ... codes changed" phantom drift of issue #686.

The runtime already picked the canonical shape -- one array element is
one executable statement (#119/#120/#274) -- so _normalize_scripts is
now component-aware and splits per element via the shared
canonical_sql_script() producer; non-SQL components keep the join.

transform.sql also carries an explicit /* ===== STATEMENT ===== */
marker when (and only when) semicolons alone cannot recover the
canonical array. Without it, API elements with no trailing semicolon
lost their boundaries and push silently rewrote production into the
MULTI_STATEMENT_COUNT=1 crash shape. Emission is conditional, so
;-terminated trees stay byte-identical; markers are guaranteed
boundaries but split_statements still runs within each segment.
`pull_config_hash` is the 3-way diff's base and is defined as the hash
of the config as the API returns it -- that is how `sync pull` and the
remote side of `sync diff` compute it. `sync push` recomputed it from
the files on disk instead, so any config whose local<->API round-trip
is not hash-stable was reported `~ REMOTE MODIFIED` by every later
diff, forever, with the tree byte-identical to the remote (issue #686).

All six stamp sites now derive the hash from the API's own view of what
was just written: config create + update, row create + update, and the
Phase C / Phase D link backfills. The mutation response is used when it
carries a `configuration`; otherwise the config/row is re-read. When
neither works, `pull_config_hash` is left UNTOUCHED and a warning is
surfaced in the push envelope -- a disk-derived fallback is exactly the
asymmetry this fixes, and a partial response missing `isDisabled` must
never be read as "enabled".

`pull_hash` / `pull_extra_hashes` stay disk-derived: they describe
local files, which is correct.

This also closes the non-script instance of the same class: a config
disabled in the UI whose local YAML has no `is_disabled` key (issue
#467 semantics) previously drifted permanently after every push.

Migration for manifests already in the wild, since the canonical
script[] shape changed the hash of multi-statement SQL configs:

* every stamp records `metadata.config_hash_version` next to the hash
  it computed -- never next to a preserved legacy one;
* an entry WITHOUT that key is compared leniently: a stored hash equal
  to the pre-change hash of the SAME remote config counts as in sync,
  which pins every other field, so the leniency cannot mask real drift.
  Applied to the diff base, the local override and the pull
  idempotency check;
* one `sync pull` re-runs extraction (writing boundary markers) and
  re-stamps, ending the leniency for that entry. The migration pull
  also honours edited companion files, which the ordinary
  overwrite-guard never checked;
* `sync push` refuses one specific legacy change: a pre-markers tree
  whose only difference from the remote is the lost statement
  boundaries would silently collapse statements, so it aborts that
  change with SYNC_LEGACY_BOUNDARY and tells the user to pull first.
  Genuine edits push normally.
Covers every silent-drift doc surface for the change: the gotchas log
(new vNEXT-tagged section), sync-workflow.md (push/diff behaviour plus a
"migrating a tree pulled before vNEXT" section), the CLAUDE.md sync
command block, commands-reference.md (sync push / sync diff), and the
keboola-expert tool-selection note that a `codes changed` diff on an
untouched config was phantom on <= 0.90.1.
Drives the real pull -> push -> diff producers over the four script
shapes from the issue and asserts both the sent array and hash parity;
the single-element-two-statements row is CHANGED by design (one element
= one statement is what the runtime wants, #274), no longer phantom.

Also pins the case R1 called out: a SQL backend matched only by the
fragment predicate (keboola.exasol-transformation) is not in the
extraction set, so its blocks round-trip through the YAML unchanged and
both sides of the hash still agree.
`sync pull --force` aborts with SYNC_CONFLICT when a config is both
locally modified and changed on the remote. It compared the stored
baseline strictly, so an entry written before the script-shape change
(issue #686) read as "remote changed" and a force-pull aborted on a
config nobody had touched remotely -- the third place a stored hash
meets a fresh API hash, after the diff base and the pull idempotency
check.

`_is_conflict` and `_detect_force_pull_conflicts` move to
`_sync_baseline` as free functions, joining the other two comparison
sites, and the config-level check now runs the stored hash through
`effective_stored_hash`. Rows stay strict: their hash producer never
changed. sync_service.py shrinks by ~78 code lines in the process.
@padak

padak commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

Live E2E verification (project 5946, e2e-snowflake)

Ran an A/B harness (~/kbagent/e2e/sync_baseline_stamp_e2e.py, follows the local e2e convention: token stays in-process via ConfigStore, sync operations shell out to the CLI under test). Each phase gets its own throwaway keboola.snowflake-transformation with two codes — ["SELECT 1;", "SELECT 2;"] and ["SELECT 3", "SELECT 4"] (no trailing semicolons) — then: pull → edit transform.sql → push → diff.

Control = installed v0.90.1 (validates the instrument — both defects must reproduce):

PASS: control: diff before push sees local edit
PASS: control: PHANTOM drift after push (bug reproduced)
PASS: control: silent rewrite reproduced (NoSemi collapsed to 1 element)

Fix = this branch:

PASS: fix: pulled transform.sql has marker for NoSemi
PASS: fix: marker count is exactly 1 (Semi needs none)
PASS: fix: manifest entry stamped config_hash_version=2
PASS: fix: diff before push sees local edit
PASS: fix: NO phantom drift after push (the #686 regression)
PASS: fix: edited statement landed remotely
PASS: fix: NO silent rewrite (NoSemi keeps 2 elements)
PASS: fix: unversioned entry still in sync (lenient match)

Result: 11 pass, 0 fail. The last check strips config_hash_version from the manifest entry and re-diffs — the pre-upgrade leniency path works against live data too.

@padak
padak marked this pull request as ready for review August 25, 2026 13:01

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

Resolves the #691 (ignoredComponents) overlap: _is_conflict and
_detect_force_pull_conflicts moved to _sync_baseline.py by this branch,
so main's ignored_components parameter and filter are carried into the
moved detect_force_pull_conflicts; _effective_ignored_components stays
on SyncService. Doc conflicts combine both sides.
@padak
padak merged commit 7a6ad74 into main Aug 25, 2026
3 checks passed
@padak
padak deleted the claude/issue-686-pr-draft-867ec7 branch August 25, 2026 13:18
padak added a commit that referenced this pull request Aug 25, 2026
Follow-up to #694 / issue #686. sync push (push_create, push_update, Phase C variables backfill) now runs normalize_blocks_codes_script — the #274 runtime-safety guard already wired into config update and transformation edit/create — before sending, surfacing normalization records via the push envelope's warnings[] in both human and --json mode. Belt-and-braces: after #694 the guard is a no-op on canonical trees; it protects hand-authored _config.yml bodies that bypass transform.sql.
@padak padak mentioned this pull request Aug 25, 2026
13 tasks
padak added a commit that referenced this pull request Aug 25, 2026
Bump pyproject to 0.91.0, add the changelog entry covering every PR merged since v0.90.1 (#627, #681, #691, #692, #693, #694, #695, #696, #697, #698), resolve all 54 vNEXT version-gate placeholders, and run version-sync + skill-gen.

No web/frontend changes in this batch, so no whatsnew.ts entry.
padak added a commit that referenced this pull request Aug 25, 2026
CONTRIBUTING.md release step 4 keeps version tags off headings: resolving the
placeholder changes the generated anchor slug and breaks inbound links. PR #697
did this pass, but #694 and #696 landed their headings around the same time and
fell outside its grep.

- gotchas.md: two headings, tag moved to the section's first body line
- sync-workflow.md: heading reworded to 'Migrating a legacy sync tree', with
  the version stated on a body line

No inbound links reference the affected anchors (verified by grep).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant