fix(apply): key strict-mode baselines on (url, ref), not the source name - #2155
Conversation
A SourceResolution named the source and the ref it declared, but not the
repository those belonged to. The report could therefore say "content
resolved to 7c1d…" without saying what "content" was, and anything reading
the report back had no way to ask "is this the same repository we resolved
last time".
Adds `url` — the substituted repository URL — to SourceResolution, its wire
shape, and the decode that reads a stored report back. It is report-safe by
construction: this record carries names, never values, and the URL is the
document's own after `${BOT_*}` substitution.
The report's `sources` array also stops being a passthrough of raw dicts on
the HTTP surface: it gains a named response model, so every field a caller
can read is spelled out there, the way `categories` and `entries` already
are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
Strict mode answers "has this repository's ref moved since this bot last resolved it". The name the document happens to give the source is not part of that question, and keying the baseline on it made the answer wrong in four ways: * A `mode: strict` source could never be advanced by editing the document. Changing `ref` to a new tag — or to a commit SHA — keeps the name, so the old baseline still applied and the new resolution was refused. The only way out was to flip the source to `non_strict`, apply once, and flip it back, which is to say: to disarm the pin in order to move it. * The schema doc's promise that a SHA-shaped `ref` trips neither branch was not kept by either branch: such a source was still refused against a stale baseline under strict, and still got a "ref moved" note under non_strict. * Renaming a source dropped its baseline; re-pointing its `url` kept a baseline belonging to a different repository. The baseline map is now `(url, ref) -> sha`, read back off each report row's `url` and `ref` and looked up at the gate by the spec's own pair. Everything after that lookup is unchanged: the refusal still happens before adoption, `keep_last` still reads the receipt filed under the baseline sha, and the walk back through report history still lets an outage pass without disarming the pin. A re-pinned `ref` now simply has no baseline, which is the same state a first-time source is in, so it passes and is adopted under its new pair. Rows carrying no `url` contribute no baseline: guessing which repository an old row's name meant would be inventing a pin nobody wrote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
The report carries one row per declaration. An inline source has no `from` name to report under, and the URL alone was not an identity: two entries reading one repository at two refs de-duplicated onto a single row that named neither ref, so the report said a sha without saying which ref produced it. An inline source is now named `<url>@<ref>`, with the ref already normalised to `HEAD` when the declaration omitted one. Strict refusals read better for the same reason — the message names the ref it refused, not just the repository. `SourceSession.checkout` loses its `display` parameter. It never used it except to explain itself: the display was the baseline key, and it no longer is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
The schema doc promised that a SHA-shaped `ref` trips neither branch of `mode`, and did not say what the baseline was actually keyed on. Both now follow from one sentence: the baseline is per `(url, ref)`, so changing either is a re-pin — no baseline, no refusal, no note — and a SHA-shaped ref can never trip either branch because it only ever resolves to itself. Also documents how a `strict` source is advanced (edit the ref; there is no flip to `non_strict` and back), and what the report's `sources` rows now carry: a `url` field, one row per declaration, and `url@ref` as the name of a source written inline on an entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
`_last_resolutions`' new docstring pushed `config_manifest_apply_service.py` from 996 to 1013 lines, over the 1000-line cap `tests/community/architecture/test_no_oversized_modules.py` enforces. Says the same things in fewer words; the longer reasoning about why a baseline is keyed on the pair already lives in `apply/source_session`, which is where it belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
totalfrank
left a comment
There was a problem hiding this comment.
Reviewed the full diff (5 commits, dd835cd..4a576af) against merge-base 9b7970a. Diff is clean — no findings.
What I checked:
- Correctness: the
(url, ref)key rewrite insource_session.py,source_fetchers.py,config_manifest_apply_service.py.spec.refdefaults to"HEAD"(neverNone) so the fetcher'ssession.baseline(spec.url, spec.ref)lookups line up with_last_resolutions'ssource.ref or "HEAD"normalization on the read-back side; strict-gate andkeep_lastboth read the baseline by the same pair now, andcheckout()droppingdisplaydoesn't strand any other caller (source_fetchers.pyis the only call site). - Contract breakage:
SourceResolution.urlis additive (= Nonedefault),apply_report_codec.report_from_payloaddecodes it back, andConfigManifestApplySource(new named response model, replacinglist[dict]) has fields exactly matchingSourceResolution.as_dict(), soapply_payload'sConfigManifestApplySource(**source)unpacking is safe. Existing endpoint test only asserts"sources": []for the empty case, unaffected by the model swap. - Docs:
manifest-schema.zh-CN.md§2.3,user-manual.zh-CN.md,design.zh-CN.mdall describe the(url, ref)keying andurl@refinline naming consistently with the code. - Test coverage: new tests cover the strict-refuse/re-pin/re-point/SHA-ref/non-strict-no-note matrix in
test_source_resolver.py, the report round-trip (_last_resolutionsskipping url-less legacy rows, collapsing shared-key rows) intest_apply_service_lifecycle.py, and the tuple-keybaseline()lookup intest_source_session.py. - Architecture boundaries / error handling / concurrency: not implicated by this diff — no transport, DI, or concurrency changes; only key-shape and a report field addition.
Generated by Claude Code
|
Review round 1 — no issues flagged. Reviewed CI is green on that same SHA — all 12 check runs succeeded, including Backend unit tests (the full ~18.5k-test suite under coverage) and Singlebox coverage. Nothing fixed and nothing disputed this round, so there is no follow-up push and no round 2 will open on its own. The PR is waiting on its human reviewers (@FreddieSun, @xianmuyq); Two things from the implementation worth a reviewer's eye, both deliberate and both already described in the PR body:
Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4a576af14a
ℹ️ 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".
Keying baselines on `(url, ref)` alone let one declaration of a repository disarm another's pin, which a Codex review caught and a probe test confirmed. A document may legally declare the same `(url, ref)` twice — once `strict`, once `non_strict` — when some entries may follow a branch and one may not. When the ref then moves, the lax declaration delivers the new commit and records it while the pinned one refuses. Sharing a baseline, that recorded sha became the pin's baseline, so the very next apply — with nothing in the document changed — handed the pinned entry the commit it had just rejected. That is strict mode degraded to "refuse each move exactly once, then deliver it": the failure the adopt-after-the-gate ordering exists to prevent, coming in sideways through a second declaration one apply later. `mode` joins the key, because a pin may only be advanced by an apply that stood behind it under the same mode. The two declarations keep separate histories: the lax one advances and notes its moves, the pinned one goes on refusing until the document re-pins it. Provenance is not sacrificed to get there — the lax delivery still gets its report row, now stamped with the mode it was resolved under. Every property of the original change survives: a re-pinned ref or url still passes, a rename still keeps its baseline, a SHA-shaped ref still trips neither branch, and report rows are still one per declaration. The regression test asserts the rebuilt baseline map by equality rather than by the absence of the strict key — absent is also what an empty map gives, and a row that silently stopped carrying its mode would satisfy the weaker form for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
|
Review round 2 — one P1 found and fixed. Pushed Two reviewers ran on The finding. Keying baselines on This was a regression specific to this PR: before it, the two aliases had separate name-keyed baselines and could not interfere. The fix. This deviates from the task spec, which said baselines are keyed on Every property of the original change survives: re-pinning Validation. 1091 tests pass locally, including the architecture guard. New conformance case Generated by Claude Code |
totalfrank
left a comment
There was a problem hiding this comment.
Follow-up round. Head is still 754329d — the same commit my last review (and the reply to the Codex thread) was already posted against, so there are no new commits to review this round.
Findings checklist:
- Codex P1 — non-strict alias could advance a strict pin's baseline (
config_manifest_apply_service.py#L792-793): fixed in754329d, verified by reading the current code (modeis the third element ofSourceSession.baselines's key throughoutsource_session.py,source_fetchers.py, and_last_resolutions()) and the new regression testtest_a_non_strict_alias_cannot_advance_a_strict_pin. Thread resolved. - My own full-diff review (
dd835cd..4a576af): clean, no findings.
Open: 0. PR is clean as of 754329d.
Generated by Claude Code
|
@codex review Re-requesting explicitly because a push is not one of your triggers: your P1 on the shared strict/non_strict baseline was fixed in Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 754329d753
ℹ️ 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".
The same alias hazard as the last commit, surviving on the road where the display is not unique. Codex caught it on the new head; a probe confirmed it. `adopt` de-duplicated on the display alone. A named source's display is its `from` name, which maps to exactly one `(url, ref, mode)`, so it was fine. An inline source's display is `url@ref` — mode-blind — so two inline declarations of one repository at one ref but two modes shared it. Whichever resolved first took the slot and the other recorded nothing; when the loser was the `strict` one it never established a baseline, and a pin with no baseline never refuses anything. That is worse than the bug it followed: not "refuse once then deliver", but a pin disarmed from the first apply onward. The rule that closes it, and the one to check against next time a key or an identity moves: **the recording identity must be at least as fine as the key the recording feeds.** The baseline key is `(url, ref, mode)`, so the recording identity is now `(display, mode)`. Both declarations get a report row. They share a name, because `url@ref` is what an inline source is called, and are told apart by the mode each was resolved under. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
|
Review round 3 — one P1 found and fixed. Pushed Codex re-reviewed The finding. The fix. The invariant I should have drawn a commit earlier, now written into Validation. 1085 tests pass locally including the architecture guard; the service file is still at exactly 1000 lines. New conformance case Worth noting for the human reviewers: a push does not re-trigger Codex — its triggers are open-for-review, draft→ready, and an explicit @codex review Generated by Claude Code |
totalfrank
left a comment
There was a problem hiding this comment.
Review round 4 — housekeeping only, no new commits.
This run's synchronize webhook fired for 54ff99bc, which is the same commit round 3 (the previous run) already reviewed and posted against — so there is nothing new between that review and this head to diff or re-review.
What this round did: verified the one review thread that round 3 had replied to as fixed but never called resolve_thread on, then resolved it.
Findings checklist (all rounds):
- Round 1 full-diff review (
dd835cd..4a576af): clean, no findings. - Codex P1 — non-strict alias could advance a strict pin's baseline (
config_manifest_apply_service.py,(url, ref)key too coarse): fixed in754329d(modejoins the baseline key). Verified in round 2, thread resolved. - Codex P1 — two inline declarations at one
(url, ref)but different modes collapsed onto one report row viaadopt()'s display-only de-dup, so astrictalias could lose its baseline entirely: fixed in54ff99bc(adopt()now keys on(display, mode)). Re-verified this round by readingsource_session.py(_recordedkeyed on(display, spec.mode)) and bothsource_fetchers.pygate call sites (session.baseline(spec.url, spec.ref, spec.mode)), and confirmingtest_two_inline_declarations_at_two_modes_are_two_rowsexercises the exact scenario. Thread resolved this round.
Open: 0. PR is clean as of 54ff99bc (cleared functionally in round 3; this round only closed the housekeeping gap of an unresolved thread).
CI on 54ff99bc: 8/10 check runs green, Backend unit tests and Singlebox coverage still in progress at review time — nothing to flag there yet, just not finished.
Generated by Claude Code
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 54ff99bc26
ℹ️ 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".
Third finding of one root cause, and the last shape that can have this hole: the recording identity now contains the baseline key outright instead of something believed to imply it. `adopt` keyed on `(display, mode)`. An inline display is `url@ref`, and that join is not injective — `@` is legal in a URL path and legal in a refname — so `url=".../a@b", ref="c"` and `url=".../a", ref="b@c"` share the display `.../a@b@c` while having different baseline keys. The second declaration was dropped, and a dropped `strict` declaration never establishes a baseline, so it accepts every subsequent move. The key is now `(display, url, ref, mode)`. `display` stays because it is not implied by the rest either: two `from` names over one `(url, ref, mode)` are two declarations and must stay two rows. Both narrower shapes and why each failed are recorded on `_recorded`, so the next person to touch this has the counterexamples rather than the conclusion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
|
Review round 4 — one P1 found and fixed. Pushed Codex found the third instance of a single root cause. Worth stating as one story rather than three, because the pattern matters more than any of the individual bugs:
The finding. The fix is Codex's suggested key verbatim: What I got wrong, twice. After round 3 I wrote the invariant into the code: the recording identity must be at least as fine as the key the recording feeds. Then I satisfied it with Validation. 1086 tests pass locally including the architecture guard; the service file is still at exactly 1000 lines. New conformance case All three P1 threads are resolved or replied to. CI on @codex review Generated by Claude Code |
totalfrank
left a comment
There was a problem hiding this comment.
Review round 5 — housekeeping only, no new commits.
This run's synchronize webhook fired for 25bcb7dc, which is the same commit round 4 (the previous run) already reviewed and posted against — so there is nothing new between that review and this head to diff or re-review.
What this round did: re-verified round 4's fix by reading the current code rather than trusting the reply, then closed a housekeeping gap — the third P1 thread had a reply saying "Resolving" but resolve_thread was never actually called, so it was still open on GitHub. Verified apply/source_session.py's _recorded set is keyed on (display, spec.url, spec.ref, spec.mode) (source_session.py:185,264) exactly as the reply claimed, and that test_an_at_sign_in_a_url_or_ref_does_not_collide_two_declarations (test_source_resolver.py:747) exercises the collision, the two-row recording, and the post-move refusal correctly. Thread resolved this round.
Findings checklist (all rounds):
- Round 1 full-diff review (
dd835cd..4a576af): clean, no findings. - Codex P1 — non-strict alias could advance a strict pin's baseline (
config_manifest_apply_service.py,(url, ref)key too coarse): fixed in754329d(modejoins the baseline key). Verified in round 2, thread resolved. - Codex P1 — two inline declarations at one
(url, ref)but different modes collapsed onto one report row viaadopt()'s display-only de-dup: fixed in54ff99bc(adopt()keys on(display, mode)). Verified in round 4, thread resolved. - Codex P1 — the
url@refdisplay join is not injective (@legal in both a URL path and a refname), so two distinct(url, ref)pairs could still collide and drop astrictdeclaration's baseline: fixed in25bcb7dc(_recordedkeyed on(display, url, ref, mode)). Re-verified this round by reading the code directly; thread formally resolved this round (was left open by round 4 despite the fix being real and the reply confirming it).
Open: 0. PR is clean as of 25bcb7dc.
CI on 25bcb7dc: 7/9 check runs green, Backend unit tests and BCS unit tests still in progress at review time — nothing to flag there yet, just not finished.
Generated by Claude Code
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25bcb7dc60
ℹ️ 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".
|
Review round 5 — one P1 raised, disputed, and resolved as moot. No code change. Both reviewers have now read The mechanism it describes is real and I had already documented it: It is moot in practice: there is no pre-upgrade data. This feature is still under development and has not shipped, so no stored report predates these fields. The branch is unreachable in any environment that exists, and the upgrade path it guards has no rows to lose a baseline from. Confirmed with the author before deciding. Neither suggested remedy survives that: reconstructing legacy rows would reconstruct nothing, and failing closed would trade an empty risk for a real regression — a brand-new bot's first apply of a The null-skip stays for what it actually is: a total function over a nullable field, so a hand-edited or truncated row degrades to "no opinion" rather than keying a baseline on One correction to my own PR body while I'm here: the "already-stored reports contribute no baselines after deployment" paragraph is accurate but reads as though there is a deployed population at risk. There isn't — that sentence exists because the task asked me to state it, not because legacy rows are known to exist. Running total across the loop: four P1s from Codex, zero from the other reviewer. Three were real and self-inflicted, all one root cause — a recording identity coarser than the key it feeds — and each of my first two fixes was itself too weak. The fourth is this one. Full escalation table in the round-4 comment. CI on Generated by Claude Code |
|
CI green on Final state: both automated reviewers have read this head, all four review threads are resolved, and @FreddieSun has approved. Nothing outstanding from me. Summary of the five rounds, for whoever merges this:
The three real findings were a single defect that my first two fixes did not fully close — the escalation table is in the round-4 comment, and the invariant plus both failed shapes are recorded on Generated by Claude Code |
Problem
A git source's strict-mode baseline was keyed on its display name — the
fromname for a named source, the substituted URL for an inline one.SourceSession.baselineswasMapping[str, str],GitSourceFetcher.fetchcomparedsession.baseline(display)againstcheckout.sha, and_last_resolutionsrebuilt that map from report history bySourceResolution.name.That is the wrong key. Strict mode answers "has this repository's ref moved since this bot last resolved it", and the name the document happens to give the source is not part of that question. Four consequences, all reproduced with probe tests before the fix:
mode: strictsource could never be advanced by editing the document. Changingref— to a new tag, or to a commit SHA — keeps the same name, so the old baseline still applied and the new resolution was refused. The only escape was to flip the source tonon_strict, apply once, and flip it back: disarm the pin in order to move it.manifest-schema.zh-CN.md§2.3's promise was kept by neither branch. It says a SHA-shapedrefignoresmode("两个分支都触发不了"). In fact a strict source with a 40-hex ref was still refused against a stale baseline, and a non_strict one still got a "ref moved" note.urlkept a baseline from a different repository — the second silently refusing a commit that never could have matched.urlwith differentrefs collapsed into one report row, because an inline display was the URL alone andSourceSession._recordedde-duplicates on the display. The report stated a sha without saying which ref produced it.Solution
Two rules; everything else follows.
Report rows: one per declaration, identified by
display. A named source's display is itsfromname. An inline source's display isurl@ref(substituted URL,@, normalised ref —HEADwhen the declaration omitted one). Two names pointing at one(url, ref)are two rows carrying the same sha; two inline declarations of one repository at two refs are two rows.Baselines: keyed on
(url, ref).SourceSession.baselinesisMapping[tuple[str, str], str], read back from report history off each row'surlandref, and looked up at the gate by(spec.url, spec.ref). The display plays no part.Everything after that lookup is unchanged: the refusal still sits between the checkout and the adoption (so a refused move records nothing and keeps refusing),
keep_laststill reads the receipt filed undergit+<url>@<baseline sha>:<subpath>, and the bounded walk back through report history still survives an outage without disarming the pin. A re-pinnedrefnow simply has no baseline — the same state a first-time source is in — so it resolves, passes, and is adopted under its new pair. A SHA-shaped ref trips neither branch by construction rather than by a special case: it only ever resolves to itself.Supporting changes:
SourceResolutiongainsurl(the substituted repository URL), carried throughas_dict(), the stored-report decode, and the HTTP surface. It is report-safe by construction — this record holds names, never values.sourcesarray stops being a passthrough of raw dicts on the HTTP surface: it gains a named response model (ConfigManifestApplySource) with a nullableurl, the waycategoriesandentriesalready have one.SourceSession.checkoutloses itsdisplayparameter — it only ever used it to explain that the display was the baseline key, which it no longer is.manifest-schema.zh-CN.md§2.3,user-manual.zh-CN.md§4.7/§6.2/§9.7/B.2.5, anddesign.zh-CN.md§7.Treated as new-feature development, with no compatibility shim. Rows written before this change carry no
url, and_last_resolutionsskips them: guessing which repository an old row's name meant would be inventing a pin nobody wrote. In practice this means already-stored reports contribute no baselines after deployment — the first apply per(url, ref)after the deploy re-establishes it (admitted by strict mode, as any first resolution is), and the apply after that pins normally.keep_lasthas no baseline receipt to reuse over that same one-apply window.Validation
Also green: the whole
tests/community/adapters/http/openapi_v1/tree (1657 passed, 2 skipped),tests/community/endpoints/test_openapi_config_manifest_apply.pyandtest_openapi_create_with_manifest.py, andruff checkover the touched trees (the one finding, an unused import inschema/entries.py, is pre-existing and untouched here).uv.lockis not in the diff.Tests added — the strict gate, in
apply/test_source_resolver.py:(url, ref), git resolves a different sha → refused, nothing adopted (existing behaviour, now re-asserted under the new key).refchanged in the declaration with a baseline under(url, old_ref)→ passes, adopts(url, new_ref) → sha, no note.urlre-pointed, same ref → passes, adopts under the new url.refis a 40-hex SHA equal to what git resolves, baseline under(url, that_sha)→ passes, no note.refchanged → passes with no "ref moved" note.(url, ref), sha differs → passes with the note (existing behaviour).aandb→ two rows, namedurl@aandurl@b.(url, ref)→ two rows (one per name), same sha, one checkout, one baseline key._last_resolutionsover a fake report history (apply/test_apply_service_lifecycle.py): several rows collapse to one key per(url, ref), a second ref on the same repository keeps its own key, and a row withurl=Noneis ignored. Newest-wins per key is re-asserted by the existing walk-back tests.keep_laston the git road after a ref change: no baseline under the new key → no fallback → the fetch failure stands.url@refas the name.Plus
test_source_session.py'sbaseline()test rewritten against the tuple key (same repo/another ref, and same ref/another repo, both yieldNone). Every existing test buildingbaselines={"src": …}moved to the tuple key, and every assertion on aSourceResolutionor a reportsourcesrow now expectsurl.Compatibility and risk
Wire-additive: the report's
sourcesrows gain aurlfield; no field is removed or renamed. The one behavioural discontinuity is the deliberate absence of a fallback described above — strict mode is disarmed for exactly one apply per(url, ref)on first deploy, and re-arms by itself. No change to the checkout cache (already keyed on(url, ref)), to receipt URLs, or tokeep_lastsemantics beyond which key finds the baseline sha.Spec
src/backend/docs/bot-config-manifest/manifest-schema.zh-CN.md§2.3 — updated here to state that the baseline is per(url, ref), that changing either is a re-pin (no baseline, no refusal, no note), and that a SHA-shaped ref therefore never trips either branch.🤖 Generated with Claude Code
https://claude.ai/code/session_01U8YX8W4uDYiHjcX9iRBLje
Generated by Claude Code