fix(transport): use /api/v1/config/{sdk_key} route for remote config fetch - #46
fix(transport): use /api/v1/config/{sdk_key} route for remote config fetch#46usmanabbas7 wants to merge 133 commits into
Conversation
…s (RED) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ementation (GREEN) Scaffold-only Python SDK foundation (Story 1.1). Frozen decisions honored: distribution convert-python-sdk, import package convert_sdk (src/ layout), hatchling backend, requires-python >=3.9, version single-sourced at src/convert_sdk/version.py = 0.1.0, public boundary Core/Context/__version__ (placeholders), empty runtime deps, py.typed shipped. Author Convert.com <[email protected]>; uv.lock gitignored (library). Removed .DS_Store and tests/__pycache__/*.pyc accidentally tracked in the RED commit. 9 tests pass; wheel convert_python_sdk-0.1.0-py3-none-any.whl + sdist build valid and ship convert_sdk + py.typed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-vs7n Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
SDKConfig/TransportConfig (dataclass, no pydantic), typed error hierarchy (ConvertSDKError/ConfigError/InvalidConfigError/ConfigLoadError/TransportError), NFR8 TLS-only enforced at config time, qs-08 inline URL-redaction shim, httpx>=0.28,<1.0 runtime dep (qs-09 F-060). Extended public surface preserves frozen Story 1.1 boundary. Beads: ai-driven-product-dev-vs7n Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-9i6g Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…n (GREEN) config_loader (validators/normalizer/loader) validates raw payloads, deep-copies boundary data, and builds a frozen ConfigSnapshot with precomputed experience/feature key+id indexes. Snapshots are immutable and never alias the caller's input dict. Beads: ai-driven-product-dev-9i6g Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-2rxo Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…GREEN)
Transport Protocol port + httpx adapter (long-lived client, trust_env=False,
TLS-only enforced upstream, optional bearer auth). JS-parity config route
/config/{sdkKey} with conditional environment= and _conv_low_cache=1 params.
Core initializes from direct config (no network) or sdkKey (fetch via
transport), exposes authoritative is_ready and immutable current_config.
Failures surface as typed ConfigLoadError/InvalidConfigError with redacted
endpoints.
Beads: ai-driven-product-dev-2rxo
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…8 NFR23)
The inline redaction shim now masks the /config/{sdkKey} path segment
(first4***last4, short keys fully ***) in addition to stripping the query
string, so full SDK keys never appear in error messages. Added redaction unit
tests; corrected the transport 5xx test to assert masking.
Beads: ai-driven-product-dev-scbi
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-lm55 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Pure-Python murmurhash3_32 (seed 9999, unsigned 32-bit) byte-exact with npm murmurhash v3 (charCodeAt&0xff UTF-16 semantics). 72 JS-derived parity vectors. Beads: ai-driven-product-dev-lm55 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-scbd Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
is_rule_matched (OR/AND/OR_WHEN tree + JS comparison operators) and qualifies() (matchRulesByField MVP policy). Added audience id/key indexes to ConfigSnapshot. Beads: ai-driven-product-dev-scbd Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-ranl Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ation (GREEN) ExperienceResult frozen dataclass (read-only variation payload) and select_experience (qualify -> build buckets -> deterministic select), mirroring JS _retrieveBucketing minus storage/tracking. Beads: ai-driven-product-dev-ranl Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-84o7 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…EEN) Minimal visitor-scoped Context (immutable stored attributes, ephemeral request-time overlay), Core.create_context, run_experience/run_experiences (local-only, no network/tracking). ExperienceResult exported additively. Beads: ai-driven-product-dev-84o7 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-kvc5 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…tion (GREEN) Beads: ai-driven-product-dev-kvc5 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…le config Beads: ai-driven-product-dev-chtr Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-0qgb Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…GREEN) Beads: ai-driven-product-dev-0qgb Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-vo1o Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… implementation (GREEN) Beads: ai-driven-product-dev-vo1o Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…(RED) Beads: ai-driven-product-dev-5zfa Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… implementation (GREEN) Beads: ai-driven-product-dev-5zfa Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-nd5o Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ate attr access) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-1bqe Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…/Event — implementation (GREEN) Beads: ai-driven-product-dev-1bqe Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…— implementation (GREEN) Beads: ai-driven-product-dev-577c Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…t — tests (RED) Beads: ai-driven-product-dev-usaq Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…king re-point — implementation (GREEN) Beads: ai-driven-product-dev-usaq Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-eugx Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…rier fragment Beads: ai-driven-product-dev-5r84 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…shed coverage) - stop() skips self-join when called from worker thread (deadlock guard) - remove pragma; test refresh.worker_crashed guard + re-entrant stop Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-1pug Agent: fullstack-sdk-dev Status: completed Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…-compat audit Beads: ai-driven-product-dev-gfy3 Agent: fullstack-sdk-dev Status: completed Forward-compat audit grounded in actual post-5.2 module tree; no Phase-3 code. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-ugkj Agent: fullstack-sdk-dev Status: completed Looking-ahead in index; async/framework forward-refs in migration + extending docs; changes/+story-5-3-async-framework-roadmap.internal.md fragment. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…py + flush.py rows) Beads: ai-driven-product-dev-gfy3 Review finding: audit table claimed to cover every MVP module but omitted tracking/tracker.py and tracking/flush.py. Both added with extend-with-sibling verdicts. No production code; gates green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… parity The rules engine implemented only 8 of the 10 comparison operators present in the JS reference and PHP SDK. isIn and regexMatches were missing, so any config using "value in list" or regex audience rules silently never matched (unknown match_type resolves to None -> rule item returns False). - Add _is_in and _regex_matches, registered under "isIn"/"regexMatches". Semantics mirror the JS source of truth (regex catches re.error -> False). - 20 unit tests (10 per operator) matching PHP ComparisonsTest granularity. - 17 cross-SDK parity vectors (9 isIn, 8 regexMatches). Full suite: 799 passed; ruff + mypy --strict clean; rule parity 44 vectors pass. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…fetch
HttpxTransport._build_route built /config/{sdk_key}, but the real
config-serving CDN only serves that resource under /api/v1, so every
sdk_key-mode initialization 404'd against the production and staging
endpoints. Confirmed against the php-sdk generated ProjectConfigApi
(server base https://cdn-4.convertexperiments.com/api/v1 + resource
/config/{sdk_key}) and a live staging fetch (404 without the prefix,
200 with it).
The /api/v1 prefix lives in _build_route so DEFAULT_CONFIG_BASE_URL and
any user-supplied base_url stay pure hosts. Query-param variants
(environment, _conv_low_cache) are preserved. Direct-config (data=) is
unaffected.
Adds 5 regression tests asserting the prefix across query variants and
updates the integration mock endpoint and docstrings/comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
There was a problem hiding this comment.
Code Review
This pull request updates the configuration route in HttpxTransport._build_route() to use the correct /api/v1/config/{sdkKey} prefix instead of /config/{sdkKey}, aligning it with the real Convert config-serving CDN. The changes include updated documentation, mock endpoints, and comprehensive regression tests. The reviewer recommended adding a defensive guard in _build_route to raise a ValueError if config.sdk_key is missing or None (such as during offline initialization), which prevents invalid network requests.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def _build_route(config: SDKConfig) -> str: | ||
| query = HttpxTransport._build_query(config) | ||
| route = f"/config/{config.sdk_key}" | ||
| route = f"/api/v1/config/{config.sdk_key}" | ||
| return f"{route}?{query}" if query else route |
There was a problem hiding this comment.
Since SDKConfig.sdk_key is defined as Optional[str] (and can be None when initializing the SDK offline with direct data), calling _build_route with an offline configuration will result in an invalid route path /api/v1/config/None. To enforce defensive programming and prevent invalid network requests, we should add a guard to ensure sdk_key is present before constructing the route.
| def _build_route(config: SDKConfig) -> str: | |
| query = HttpxTransport._build_query(config) | |
| route = f"/config/{config.sdk_key}" | |
| route = f"/api/v1/config/{config.sdk_key}" | |
| return f"{route}?{query}" if query else route | |
| def _build_route(config: SDKConfig) -> str: | |
| if not config.sdk_key: | |
| raise ValueError("SDK key is required to build the config route.") | |
| query = HttpxTransport._build_query(config) | |
| route = f"/api/v1/config/{config.sdk_key}" | |
| return f"{route}?{query}" if query else route |
abbaseya
left a comment
There was a problem hiding this comment.
Review — final implementation (this branch is the stack tip = the full SDK)
The /api/v1 route fix in this PR is correct and well-evidenced (pure-host base + /api/v1/config/{sdk_key} route, query params preserved, live 404→200 against staging, regression tests added). Two notes on the fix, then the implementation-side parity findings this branch carries (spec-side findings are on convertcom/ai-driven-product-dev#59). All claims are file:line-grounded; empirical ones were executed.
On the fix itself
- ✅ Correct and verified. Good call sourcing the prefix from the php-sdk generated client + a live staging fetch.
- 🟡 Minor structural divergence from JS parity: JS keeps
/api/v1in the base (DEFAULT_CONFIG_ENDPOINT = 'https://cdn-4.convertexperiments.com/api/v1', route/config/{sdkKey}); this fix puts/api/v1in the route with a pure-host base. Same full URL — cosmetic, but it means the Python "base" no longer matches the JS "configEndpoint" concept. Fine to keep; just flagging for cross-SDK mental-model consistency. - 🟠 Context: this is the third parity defect caught post-sprint (after the missing rule operators in
2605a59), all with green tests during the audited sprint. The pattern matters more than any single fix — see below.
🔴 CRITICAL — Bucketing hashes UTF-16 code units, not UTF-8 (non-ASCII visitors bucket differently than JS/PHP)
src/convert_sdk/evaluation/bucketing.py feeds _utf16_code_units(value) (lines 40-58) into MurmurHash3. JS uses the npm murmurhash package's TextEncoder().encode() → UTF-8 bytes; PHP uses UTF-8 (unpack('C*') / native murmur3a). Python is the sole outlier.
Verified empirically against the real npm package the JS SDK ships:
| input | Python (this branch) | npm murmurhash (JS) |
|---|---|---|
test_visitor (ASCII) |
2397688595 | 2397688595 ✓ |
用户123 |
531569922 | 3859151469 ✗ |
A non-ASCII visitorId (or composite experienceId+visitorId) computes a different hash → can select a different variation in Python vs JS/PHP. This breaks same-experiment cross-SDK consistency.
Compounding it:
qs-04-murmurhash3-wrapper.md:66mandated UTF-8; the implementation did UTF-16, and thebucketing.pydocstring (lines 14-17, 43-46) defends UTF-16 with a false "byte-exact against the JS reference" claim.- The parity fixtures cannot catch it:
scripts/js_reference/bucketing.jsis a hand port usingkey.charCodeAt(i) & 0xff(UTF-16, co-buggy) — not the real npm module — sotests/parity/fixtures/bucketing_vectors.jsonpins用户123to the wrong value and the suite is green against a wrong oracle.
Fix: hash value.encode("utf-8"), use the UTF-8 byte length for the length-mix, and regenerate fixtures from the real npm murmurhash package (or a TextEncoder UTF-8 source). The hash core (seed 9999, constants, finalizer, composite order f"{experience_id}{visitor_id}", bucket math) is otherwise correct — the defect is isolated to the input encoding.
🟠 Rule operators (now patched here) shipped incomplete during the audited sprint
2605a59 added isIn + regexMatches. The on-disk _COMPARATORS map (rules.py:168-182) now has all 13 JS/PHP names — good. AND/OR/OR_WHEN tree, empty-group/short-circuit, and key-not-found behavior all match JS. Two minor notes: isIn lowercases only testAgainst (matches JS; PHP lowercases both — PHP is the outlier, Python is correctly JS-aligned); isIn scalar-testAgainst wraps→can-match (follows PHP) vs JS []→never-match (low blast radius).
🟠 Segment latch claimed but not implemented
src/convert_sdk/evaluation/segments.py:13,74 docstring claims a JS-style segments_matched latch, but select_custom_segments (lines 86-105) evaluates each segment's rule independently (if not this_matched: continue) — no latch variable. JS (segments-manager.ts:~100) latches after the first match and adds subsequent rule-bearing segments. For ordered multi-segment lists where an early segment matches and a later one's rule does not, JS records both, Python records only the first. Verify against JS and fix the code or the docstring.
🟡 Public return-type drift (undocumented vs PRD)
Ships ExperienceResult/FeatureResult (domain/results.py:22,69); the PRD declares BucketedVariation/BucketedFeature (PHP-parity) with no reconciling footnote. ExperienceResult lacks the variables field the PRD's BucketedVariation declares. Core.current_config (core.py:108) vs PRD's Core.config_version. Sentinels collapse all misses to None (deliberate/documented; JS returns RuleError/BucketingError sentinels and a DISABLED feature object, never null — reasons in Python are available only via the opt-in diagnose_* surface).
🟡 DoD #5 — no staging demo; examples use invented keys
examples/_sample_config.py hard-codes account_id "100123", experience checkout-experiment, feature checkout-banner (self-labeled placeholders). The canonical staging keys test-experience-ab-fullstack-1 / test-feature-rollout-1 appear nowhere in the repo; there is no demo/ directory and no path that sends a real conversion. The examples meet the (offline-scoped) Story 1-6 spec but not the runbook's staging-verification DoD — which is also what would have caught this PR's 404 before it shipped.
What's correct (stated plainly)
- Config shape reads flat at the root end-to-end (
config_loader/{loader,normalizer,validators}.py) — no Ruby qs-05 envelope bug;config_available/ready requiresaccount_idANDproject.id(no empty-but-ready masquerade). - Tracking payload matches the JS wire contract (
tracking/payloads.py):{accountId, projectId, source, enrichData, visitors[].events[]}, conversion eventgoalId/goalData(array)/bucketingData(flat map), nogoalKey/timestamp(F-001 implemented), revenue→{key:"amount"},enrichData = data_store is None. Dedup keyed(visitor, goal), batch size 10, explicit/timeout/atexit flush — parity-correct.source="js-sdk"is a deliberate safe fallback; confirm the backend allowlist before GA. - Error model is the strongest area — clean typed hierarchy, never raises on normal misses.
- Feature variable casting is parity-correct for canonical config; diverges only on off-spec values (
"0"→bool, dirty int strings).
Blockers before merge to main: the UTF-8 bucketing fix (+ real-oracle fixtures) and the staging demo. Full write-up in the Asana review task; spec-side findings on ai-driven-product-dev#59.
f09ee36 to
c934e6b
Compare
…h in custom-segment evaluation — F-066 Propagates the F-066 latch to the fix/config-route-api-v1-prefix branch (PR #46), which is on a separate accumulation lineage from the rebased story stack and was not reached by the story-branch propagation. select_custom_segments now latches `segmentsMatched` (JS segments-manager.ts:100-121, PHP SegmentsManager.php:104-128): once an earlier segment's rule matches, subsequent segments are recorded without re-evaluating their own rules. segments.py is byte-identical to the remediated 3-3 (f2d8916); added the two ordered-multi-segment parity tests. Full suite 806 passed; ruff + mypy --strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
F-066 — segments_matched latch propagated hereThis branch ( Also re-pointed this PR's base
|
…t UA and report status
Tracking events were POSTed to the config CDN host (returning 403) instead of
the separate metrics endpoint. Adds TransportConfig.track_base_url (default
https://[project_id].metrics.convertexperiments.com/v1, JS/PHP parity),
substitutes [project_id] at request time, and POSTs to {track_base}/track/{key}.
Also sets User-Agent: ConvertAgent/1.0 so the metrics bot filter does not
silently drop events (200-but-discarded), and propagates the HTTP status code
on the success path so QueueReleasedPayload.status_code carries the real 2xx
(send_tracking now returns int).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Adds demo/run_demo.py — an OFFLINE (committed fixture, deterministic, no network) and LIVE (sdk_key against staging) manual-verification harness that runs the full FullStack arc: experience -> feature-rollout experience -> segments -> conversion + delivery report. Mirrors the JS and PHP demos: buckets the feature rollout via run_experience (key test-experience-ab-fullstack-4, per php-sdk config; the JS demo's test-feature-rollout-1 is stale), passes location=events and the mobile=true audience attribute, and sets the country=US default segment. Self-diagnoses any non-bucketing via diagnose_experience. Excludes demo/ from the sdist, gitignores demo/.env, and lints demo/ in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ollout-1 typed vars The committed demo harness (49de4e9) had unilaterally overridden the frozen qs-12 spec's feature-rollout decision: it used PHP's run_experience on test-experience-ab-fullstack-4 and printed raw string variables_data, instead of the spec's JS-authoritative run_feature('test-feature-rollout-1') resolving typed variables (bool/string/integer). This left two gaps: the OFFLINE output did not match the README contract, and the typed-variables acceptance criterion was unmet. Align the implementation to the spec: - config_fixture.json: re-key the feature to test-feature-rollout-1; declare the 4 typed vars the README shows; drop the redundant rollout-as-experience block; correct the _comment (JS-authoritative; LIVE override via env var). - run_demo.py: step [2] now calls run_feature(test-feature-rollout-1) printing typed vars + status + via experience/variation; move the audience attrs + country default-segments LIVE-only so OFFLINE output is byte-identical to the README; correct the misleading comments. - test_demo_harness.py: flip the feature-key assertions/comments back to test-feature-rollout-1; the /api/v1/config route guard is unchanged. OFFLINE output now byte-matches demo/README.md verbatim. Full suite 828 passed, ruff + mypy green, demo/ excluded from sdist + wheel, zero src/ changes. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
abbaseya
left a comment
There was a problem hiding this comment.
⚠️ Corrected round-3 review (supersedes the earlier version of this review)
My earlier review of this PR led with "bucketing UTF-8 fix never landed." That is true for this branch, but I wrongly presented it as the SDK's state overall. The fix did land on PR #45 (epic-5/story-3). The accurate framing: this PR is a divergent branch that never received the F-063 propagation.
What this branch (fix/config-route) uniquely gets right
- Config
/api/v1route fix — correct (resolves the sdk_key-mode 404). ✅ - Tracking delivery → metrics endpoint +
ConvertAgentUA (7dd639f) — fixes events POSTing to the config CDN (403, silently dropped). ✅ Surfaced by building the demo — exactly what the "verify against staging" leg exists to catch. - Demo harness with real staging keys (
test-experience-ab-fullstack-1,test-feature-rollout-1, project10035569/10034190). ✅ - Operators ✅, segment latch ✅.
What this branch is missing
- 🔴 F-063 bucketing UTF-8.
bucketing.pyhere still hashes UTF-16 code units (executed:用户123→531569922vs correct3859151469); fixture still pins the buggy value;js_reference/bucketing.jsis still the co-buggy hand port. PR #45 fixed all three. This branch was cut off the old, pre-patch-sprint stack and never rebased onto the F-063 work.
This PR is confusing — please clarify or close it
- Its base is
main(the empty placeholder), so the diff is the entire ~25k-line tree instead of an isolated fix. - It diverges from the rebased canonical story stack (PR #45) — neither is an ancestor of the other.
- It's missing the F-063 bucketing fix that PR #45 carries.
There's no clear reason to keep this as a parallel open PR. Recommendation: move this branch's three unique fixes (config /api/v1, tracking→metrics+UA, demo) onto the canonical PR #45 line, then close this PR — so there's a single authoritative branch to review and merge. The merged branch must carry bucketing UTF-8 and config /api/v1 and tracking→metrics+UA and the demo. Then re-run the demo LIVE against staging and confirm a conversion lands in reports.
|
Superseded by PR #45 (
The bucketing/parity changes on this branch were not carried over: this branch's MurmurHash3 used UTF-16 code units ( Reconciled state also pushed as |
Problem
HttpxTransport._build_route()built/config/{sdk_key}, but the real Convert config-serving CDN only serves that resource under/api/v1. Everysdk_key-mode initialization 404'd against the production and staging endpoints. The direct-config (data=) path was unaffected.Evidence
ProjectConfigApi.php): server basehttps://cdn-4.convertexperiments.com/api/v1+ resource path/config/{sdk_key}→ full path/api/v1/config/{sdk_key}.base_url=https://cdn-4-staging.convertexperiments.comreturned 404; the identical fetch with the/api/v1prefix returned 200 and a validConfigSnapshot(experiencetest-experience-ab-fullstack-4).Fix
_build_routenow emits/api/v1/config/{sdk_key}. The prefix lives in the route (notDEFAULT_CONFIG_BASE_URL) so the default and any user-suppliedbase_urlstay pure hosts.environment,_conv_low_cache=1) preserved.Tests
/api/v1prefix across query-param variants./api/v1/config/{key}.ruffclean;mypy --strictclean.base_url(404 → 200).Base is the cumulative story-tip branch (not
main, an empty placeholder in this repo's stacked-branch model) so the diff is isolated to this fix.🤖 Generated with Claude Code