Every release of AutoBrain — new features, fixes and improvements. Also tracked in the repo's CHANGELOG.md.
Tell me what you'd like AutoBrain to do — I read every message.
/v1/cost and /v1/cost/reset for AI gateway (AUT-6646)pytest job in ci-tests.yml had no PostgreSQL service container, so the full backend test suite died with ConnectionRefusedError: [Errno 111] Connect call failed (127.0.0.1, 5432) (e.g. tests/test_logbook_club_reg.py::test_club_reg_logbook_is_disabled). Adds a pgvector/pgvector:pg17 service (same image as the alembic-migrations job) to the pytest job, exposing localhost:5432 with autobrain/autobrain/autobrain credentials matching the suite's DATABASE_URL.devices.vehicle_type rollback data loss: aut5092_dev_veh_type is a no-op on the hosted create_all database it exists to repair (the column is already there), yet its downgrade() dropped the column anyway, destroying real data on rollback. upgrade() now stamps the column it adds with a provenance marker (COMMENT ON COLUMN) and downgrade() drops only a column carrying that marker — a pre-existing column is left alone, while a genuine rollback of a column this revision created still drops it. Covered by tests/test_alembic_heads.py (offline) and two new cases in tests/test_alembic_migration_only.py (real Postgres, alembic-migrations CI job).BACKUP_OFFSITE_INGEST_KEY's docstring still said /api/backup/ingest, an endpoint that 404s; the value is the X-Ingest-Key for /ingest.fw-keeper) from 122.199.30.128 to 103.232.252.9 in docker-compose.hosted.yml comments and docs/Security/security.md. The fw-keeper container on the Oracle VM (152.69.188.133) must be updated via Portainer to allow the new IP for Portainer agent (:9001) and 9Router (:20128) access.POST /api/v1/ci/webhook CI triage receiver. The GitHub Actions ci-triage-webhook.yml fire job has never posted to the AutoBrain backend — it fires secrets.CI_TRIAGE_WEBHOOK_URL, the Paperclip routine trigger on the CI Triage routine, and that routine trigger is the only thing that wakes the CI Triage Agent. The backend route had no callers: AutoBrain-Hosted answered 503 CI triage webhook not configured to every request since at least 2026-09-03 because docker-compose.hosted.yml wired CI_TRIAGE_WEBHOOK_SECRET/CI_TRIAGE_PARENT_ISSUE_ID/CI_TRIAGE_GOAL_ID to empty defaults the Hosted env file never set, and the Hosted access log contains no POST from any Actions runner to that path. Deletes backend/app/api/v1/ci.py, its router registration, the CI_TRIAGE_* + server-side PAPERCLIP_* settings in backend/app/core/config.py, the same block in docker-compose.hosted.yml, the CI_TRIAGE_* block in .env.example, and the /ci/webhook row in docs/Engineering/api-spec.md; ci-triage-webhook.yml and the deployment guide now state that the Paperclip routine trigger is the canonical receiver so the route is not re-added or repointed. The Hosted stack no longer needs PAPERCLIP_API_KEY/CI_TRIAGE_WEBHOOK_SECRET to boot. backend/tests/test_ci_receiver_removed_aut5718.py asserts the route is gone (404) and the settings no longer exist, so a re-added receiver fails CI.fuel_price_snapshots, devices, market_listing_cache, and vehicles tables with HNSW indexes. New Alembic migration g7h8i9j0k1l3 adds embedding vector(1536) columns and cosine-similarity indexes. Search service _ENTITY_MAP expanded with fuel_price, device, market_listing, vehicle entity types (including scope_field override for vehicle-scoped search). Vector embedding service _to_text updated for new types. API /api/v1/search entity_types description updated.tests/test_advisor_value.py could not import BAND_LOW_RATIO / BAND_HIGH_RATIO from app.services.advisor, so the entire backend suite failed at collection — masked by ci-tests.yml running the suite as pytest … || true. The app.services.advisor package __init__.py already re-exports both constants from advisor/value.py; the stale backend/app/services/advisor.py module that shadowed the package is deleted (no imports referenced it). Removed || true from the full-suite step in .github/workflows/ci-tests.yml so future collection errors fail the build. All 16 test_advisor_value.py tests pass; full suite collects without import errors.ci-triage-webhook.yml fire job was incorrectly pinned to the self-hosted vm2 runner (runs-on: [self-hosted, linux, x64, vm2]) despite comments explicitly stating it must run on ubuntu-latest to avoid consuming scarce self-hosted runners for 15-minute sleep+curl bursts. The misconfiguration caused the step to hang and be killed by the 20-minute timeout (AUT-5668), silently losing ~half of CI triage wakes. Changed runs-on to ubuntu-latest (timeout was already increased to 30 minutes).Sync mobile app workflow failed on main with lib/core/models/fuel_history.dart is not referenced by a part directive in lib/core/models.dart. The monorepo frontend/lib/core/models.dart was missing the part 'models/fuel_history.dart'; directive that autobrain-mobile PR #60 added to its models.dart. Added the missing part directive and the corresponding fuel_history.dart model file to the monorepo source-of-truth so the sync guard (AUT-5205) passes and the mobile tree stays in parity. Verified by re-running the Sync mobile app workflow.backend/tests/test_deps_transitive_cves.py into the required Backend — pytest smoke CI job so transitive dependency CVE pins (starlette, PyJWT, python-jose/ecdsa) are enforced on every PR and push. The guard runs offline (reads requirements.txt only), needs no database, and blocks merges when pins fall below the fixed floors (starlette ≥1.3.1, PyJWT ≥2.15.0, no python-jose/ecdsa).favourite_stations_screen.dart referenced FuelPriceWatchlist without importing core/models.dart, causing a compile error (Type 'FuelPriceWatchlist' not found) that failed the visual-regression CI on branches fix/aut-5920-pr-based-pin-bumps and AUT-4556-servo-saver-map-improvements. Added the missing import.passlib[bcrypt]==1.7.4 from backend/requirements.txt and migrated backend/app/core/security.py to use bcrypt directly (bcrypt.hashpw / bcrypt.checkpw). passlib 1.7.4 (2020) is the last release and its bcrypt backend has known correctness issues (truncation bugs, version detection). The direct bcrypt API is already pinned (bcrypt==4.0.1) and is the OWASP-recommended algorithm. Regression test tests/test_api.py::test_password_hashing verifies hash/verify round-trip and wrong-password rejection; full pytest tests/test_api.py passes (17/17)./docs, /openapi.json, and /redoc returned the Flutter SPA shell (200 text/html) instead of the FastAPI documentation on hosted. The frontend nginx catch-all location / served index.html for these paths because explicit proxy blocks were missing. Adds location /docs, location = /openapi.json, and location /redoc blocks in docker/frontend/nginx.conf that proxy to backend:8000. Also enables docs_url="/docs" and redoc_url="/redoc" in backend/app/main.py for all environments (was gated off in production). Verified post-deploy: hosted.autobrainservice.app/docs, /openapi.json, and /redoc now serve Swagger UI, OpenAPI JSON, and ReDoc respectively.autobrain-mobile PR #60, which red-lined Sync mobile app on main (run 37212706670) with lib/core/models/fuel_history.dart is not referenced by a part directive in lib/core/models.dart. #60 added a **mobile-only** 30-day Servo Spy station price chart (model file lib/core/models/fuel_history.dart + an inline chart in lib/screens/servo_spy/servo_spy_screen.dart). sync-mobile.sh copies frontend/lib/ verbatim over the mobile tree, so every sync overwrote the mobile models.dart and the chart source, orphaning the model file on disk and tripping the AUT-5205 part-directive guard. The same chart already ships in the shared lineage: frontend/lib/screens/servo_spy/servo_spy_station_history_screen.dart (AUT-2376) renders it from FuelPricesApi.stationHistory and converts cents to dollars on both the axis and the tooltip, so the duplicate only cost the cents-vs-dollars bug tracked as AUT-4772. Store app keeps the chart through the synced ServoSpyStationHistoryScreen.origin/main into feat/AUT-4113-market-data-celery (PR #777), resolving the docker/backend/Dockerfile conflict — keeps AUT-4718's python:3.13.16-slim-trixie@sha256:6906dca8… base repin (clears the image-scan HIGH/CRITICAL findings from the stale 3.13.15 digest) together with AUT-3843's Playwright Chromium layer (ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright after the backend pip install, chown -R autobrain:autobrain /ms-playwright, and the chrome-sandbox re-SUID gate).visual_regression.yml (was ubuntu-latest) now runs on [self-hosted, linux, x64, vm2]; its subosito/flutter-action@v2 step downloads the Flutter SDK itself, so no pre-installed toolchain is required on the runner. dockerhub-publish.yml's dedupe-main-queue, ci-queue-guard.yml's cancel-orphaned-runs, and ci-triage-webhook.yml's fire also move to [self-hosted, linux, x64, vm2]: all three are API-only gates that call gh api to cancel superseded/orphaned runs and to fire the triage webhook, and none of them checks out or executes repository code, so they are safe on a persistent runner even under the pull_request trigger. build-hosted.yml's matrix already resolved to vm2/ARM64 and is unchanged. After this, no workflow in this repo requests a GitHub-hosted runner.gh-runner-autobrain-arm64, Oracle VM, Portainer EP5 stack 123) against the GitHub Actions broker WebSocket drops logged as System.Net.Sockets.SocketException (125): Operation canceled, and gave its stack a git source of truth for the first time. Measured 2026-09-27..2026-10-05 from the EP5 container logs: 86 drop events (~9/day), 76 of them while idle, every one self-healing via a ~12s broker backoff + session restart — 62 jobs started, 62 succeeded, 0 cancelled, 0 failed, RestartCount=0. The drop itself is upstream broker behaviour (the runner client cancels its own long-poll), so the hardening is container-level: stop_grace_period: 30m (Docker's 10s default SIGKILLs the runner mid-build on any stop/redeploy, and a drop overlapping a redeploy could previously compound into a lost job — now the update waits for the build to drain), the image digest-pinned to the arm64 manifest EP5 runs (v2.337.0) instead of floating :latest, and DISABLE_AUTO_UPDATE=true (an in-place self-update silently drifted the running broker build off whatever digest was pinned — a pin nothing enforced). Adds docker-compose.runner.hosted.yml as the only definition of the stack (it previously existed only inside Portainer, so every change was an untracked hand-edit) and scripts/check-runner-compose.py as a structural guard wired into compose-checks.yml — it fails on the previous live definition (floating tag, auto-update on, no grace period) and passes on the hardened one. Applied to EP5 stack 123; the runner stays online with a fresh listener session and the keepalive canary (arm64-runner-keepalive.yml, AUT-5463) continues to pass.08a5fb29 was **inert on hosted** — it trusted only Cloudflare's published edge ranges, but the frontend container's immediate TCP peer is the local reverse proxy (the nginx-proxy-manager container on the same docker bridge; measured live in PR #901: redis-cli --scan --pattern 'login:fail:*' returned only login:fail:ip:172.18.0.3), never a Cloudflare edge. real_ip_header only rewrites $remote_addr for peers in set_real_ip_from, so no request was ever rewritten, every visitor on the internet kept sharing one login:fail:ip:172.18.0.3 key (LOGIN_MAX_ATTEMPTS=5, LOGIN_WINDOW_SECONDS=3h), and five failed logins by anyone still locked out every hosted customer for three hours. docker/frontend/nginx.conf now also trusts the docker bridge pool the proxy hops over (172.16.0.0/12, covering hosted npm 172.18.0.3 and the EP2 host nginx 172.26.0.1) alongside Cloudflare's full published edge set — which adds the one missing range 198.41.128.0/17 (Cloudflare publishes 15 IPv4 ranges; the list carried 14). A public client cannot reach the container with a 172.16/12 source address (the compose files bind the frontend to 127.0.0.1 on hosted/dev and DNAT preserves an external source IP), so the AUT-303 spoof bypass stays closed; the residual widening is that a co-tenant container on the same bridge could set X-Real-IP. docker/frontend/nginx-proxy.conf is **deleted** — no Dockerfile, compose file or script referenced it (only the changelog and its own test did), so it was a second copy of the trust boundary that future edits would change nothing about. /api/ now sends X-Forwarded-For $remote_addr instead of $proxy_add_x_forwarded_for, so a client-supplied chain is never re-attached (nothing reads XFF today — client_ip() ignores it — so this is defence in depth). Deliberately **no** backend change: client_ip() still trusts only the proxy-set X-Real-IP and the socket peer. backend/tests/test_aut5460_cf_real_ip.py now pins the trust boundary **in both directions**: a stale list fails (this is how 198.41.128.0/17 shipped), an over-broad edit such as set_real_ip_from 0.0.0.0/0; or a bare RFC1918 range fails, and a missing 172.16.0.0/12 fails — the last is the regression that made 08a5fb29 inert. Verified locally: 10 passed; the four new/updated pins all **fail** against the merged 08a5fb29 conf, and injecting set_real_ip_from 0.0.0.0/0; into the fixed conf fails only the allowlist pin. nginx -t was **not** run by the authoring box (no docker/nginx there) — run it on the dev box before deploy. After deploy, the acceptance check is the same probe that found the bug: redis-cli --scan --pattern 'login:fail:*' must show per-visitor public addresses, never 172.1x.x.x.GET /api/v1/advisor/replace 500ed for every caller, and the next build of the backend could not boot at all. AUT-3916 (389213ca, Sep 26) deleted the legacy backend/app/services/advisor.py — which held compute_replace — and rewired app/api/v1/advisor.py to import it from the new app.services.advisor package, but the function was never moved into the package, so the package never exported it. The 500 stayed invisible for ~9 days because ci-tests.yml runs the suite as pytest … || true and tests/test_advisor_replace.py skips its route tests when app.main fails to import (they had never actually run: the module's fake vehicle had no id and get_accessible_vehicle was patched with a sync lambda, both now fixed). AUT-5639's PR #915 added the missing import line, which turned the latent breakage into a hard ImportError at boot for every process that imports app.main — i.e. main went red and any image built from it would crash-loop. This restores compute_replace in app/services/advisor/replace.py (verbatim from the deleted legacy module — deterministic, no AI/no network: used cost = current private-sale mid, new cost = mid × documented new_used_premium(age), gap = replacement − current − trade-in mid, surplus on a non-positive gap) and re-exports it from app/services/advisor/__init__.py. tests/test_advisor_replace.py now runs instead of skipping (15 passed, 0 skipped; 834 tests collect clean) and pins the export with a new test_compute_replace_is_exported, so a future dropped export fails collection at CI time instead of in production.GitConfig: null) with every image digest-pinned, so pullImage: true re-resolves an immutable digest and can never move a pin; and the compose-pin sync step in build-hosted.yml was gated on a *digest* having changed, so a compose-only edit (the #870 alembic pre-step) never reached the stack even though the diff looked correct. scripts/sync-compose-to-portainer.py now asserts, after every sync and on a read-only --verify-only pass, that each running container's RepoDigests (/images/{id}/json) contains the digest this compose pins and that its command/entrypoint matches what the compose declares — interpolated against the stack env first, because compose interpolates before the value reaches the container. A HTTP 200 from Portainer is no longer treated as evidence that the change shipped; drift exits 7. The sync step's changed gate is replaced by always() && steps.bump.outcome == 'success'. Covered by 28 tests in scripts/test_sync_compose_to_portainer.py and documented in docs/Deployment-and-Infrastructure/deployment-guide.md ("Stack 122 (autobrain-hosted) is an INLINE, digest-pinned stack").docker-compose.demo.yml:2 (added by AUT-5582, after the AUT-5063 sweep was scoped) still carried the published [email protected] / demo pair in its header comment, so the file that defines the Demo tier re-leaked the exact secret AUT-5063 removed from the other nine sites. The comment now points at the Paperclip secret demo/demo-account-password instead. Also wires DEMO_PASSWORD into the Demo backend's environment as a **required** var (${DEMO_PASSWORD:?…}, matching the existing MINIO_ACCESS_KEY/MINIO_SECRET_KEY pattern in the same file): DEMO_MODE: "true" was already set but no password was passed, so after AUT-5063's fail-closed seeding landed, reviving the Demo stack would have booted healthy and silently refused to create the demo account (and check_demo_compose.py — which asserts every ${VAR} resolves — would have rejected the file anyway). Now the stack fails closed at deploy time rather than at seed time. No redeploy: AUT-2409 keeps deploys hosted-only and the Demo stack is down (AUT-5057).app.db.bootstrap failed open on a failed alembic upgrade — it caught *every* migration error and degraded to Base.metadata.create_all, which never alters an existing table. So a migration that only adds a column (or any bad DDL, permission error, truncation, stale revision id, or forked head) silently did nothing while boot proceeded and the deploy reported success — schema drift surfacing hours later as an application error far from the deploy. This is the class behind the hosted devices.vehicle_type miss (AUT-5122, a StringDataRightTruncationError stamping a 34-char id into varchar(32) meant the repair never applied) and the AUT-4925 head fork in AUT-5114; both were swallowed by the one except. The fallback is now scoped to a genuinely fresh database (information_schema reports zero tables in public, i.e. an un-stamped schema with nothing to drift from); every other failure aborts startup. Before aborting, bootstrap emits a structured migration_failed_startup_aborted error log line and POSTs the same payload to a new optional BOOT_ALERT_WEBHOOK_URL ops webhook (best-effort, 5s timeout, never raises, so alerting can never mask the abort). A database whose state cannot be inspected at all also fails closed rather than guessing. Because the container command is sh -c '… && python -m app.db.bootstrap && …', the raise now exits the container instead of handing a healthy-looking boot to uvicorn — this is a deliberate behaviour change: a broken migration will now crash-loop the backend instead of serving traffic against a drifted schema, which is the point. Regression tests in backend/tests/test_aut5137_bootstrap_fail_closed.py drive the alembic CLI seam and the engine seam (no live postgres needed) and **fail on the pre-fix code** with DID NOT RAISE; they cover abort-on-non-empty-db (plus the log line, the alert, and both head/heads attempts), the still-working fresh-database create_all fallback, fail-closed on an uninspectable database, and that a clean upgrade never probes the schema at all.GET /api/v1/advisor/replace returned HTTP 500 for every caller. The route body calls compute_replace(db, vehicle, odometer_km=..., horizon_months=...), but app/api/v1/advisor.py never imported that name — the module import block has listed compute_advisor_recommendation, compute_dream, compute_finance_plan, compute_market_value, compute_upgrade, find_comparables, trade_in_band since the module landed in AUT-2446 (c858155, #489), so the Replace module has been unreachable since it shipped. ruff check --select F821 reports exactly this one undefined name in the module and nothing else. Adds compute_replace to the from app.services.advisor import (...) block. tests/test_advisor_replace.py was already asserting the route envelope and had been failing for the same reason, masked by ci-tests.yml running the suite as pytest … || true — it is green now (12 passed, 2 skipped). Surfaced while verifying the AUT-5137 bootstrap PR, where the newly strict test collection made this pre-existing failure visible.ai on a floating :latest-amd64, backend tag-pinned to 0.3.308 but with zero network aliases and no compose labels, frontend on a floating :default), so nothing stack-driven could redeploy it — scripts/upgrade-instances.sh resolved the tier by a Portainer stack named autobrain, which did not exist, and the Default step of the promotion chain failed silently, while Watchtower (nightly 04:00 AEST, all containers) recreated them outside any redeploy path. Adds docker-compose.default.yml as the tier's only definition (all three images digest-pinned to the digests EP2 was already running, restart: unless-stopped, autobrain_default joined as an external network with explicit ai/backend/frontend aliases) and adds scripts/check-default-compose.py as a structural guard wired into compose-checks.yml. Deployed as Portainer stack autobrain-default (id 137) with zero image drift; default.autobrainservice.app/health returns 200.docker-compose.demo.yml as the tier's only definition (project name pinned to autobrain-demo so the existing volumes and the autobrain-demo_default network the plate-api-scraper stack joins are reused; all six images digest-pinned to the ImageIDs EP2 actually runs, so the recreate changed nothing), sync-compose-to-portainer.py --create --env-file (Portainer 2.39 has no JSON stack-create route, so creation posts the multipart form the UI posts to /stacks/create/standalone/file), and check_demo_compose.py as a deployability gate (every ${VAR} resolves, digest pins, project name, volume names, no literal secrets) since no docker CLI can reach EP2 to run compose config. Deployed as Portainer stack autobrain-demo (id 136), all six services healthy with aliases restored.ci-tests.yml runs the suite as pytest … || true, so a collection error could not fail the build. tests/test_advisor_value.py imported BAND_LOW_RATIO / BAND_HIGH_RATIO / TRADE_IN_*_RATIO / _CONDITION_MULTIPLIER from app.services.advisor, but the AUT-4812 package split moved them into advisor/value.py without re-exporting them (also AUT-4812); the package __init__ now re-exports all six, matching its own "all public names are re-exported" contract. tests/test_aut2381_arbitration.py tested an arbitration design that no longer exists (SourceTrust / PriceCandidate / _consistency_bonus / select_best_price) — AUT-2386 moved the rule to app/services/fuel_source_arbitration.py with authority-dominant scoring and a median spread penalty, so the suite is rewritten against the surviving behaviour (authority ordering, freshness window with naive-timestamp and clock-skew handling, spread penalty, arbitrate() determinism, empty input raising). tests/test_car_check.py tested parse_listing_url / _verdict / _band, all removed by AUT-2651, and is rewritten against the current scoring helpers, flag builders and the car_check_fallback / validate_car_check_response contract. test_advisor_value.py's route test monkeypatched the awaited get_accessible_vehicle with a sync lambda (surfaced only now that the module collects) and built a vehicle stub without id. pytest --collect-only -q in backend/ now exits 0 (825 collected), and ci-tests.yml gains a dedicated collect-only step so a future import error fails the build instead of being swallowed by the full suite's || true. No production behaviour change outside the added re-exports.aiosqlite==0.21.0 added to backend/requirements.txt (test/dev block, next to pytest / pytest-asyncio / ruff). Four test modules — tests/test_backup_completeness.py, tests/test_demo_fuel_seed.py, tests/test_seed_reset_demo.py, tests_social/test_social.py — build a sqlite+aiosqlite async engine at import time, and the AUT-5356 collect-only gate turned their ModuleNotFoundError: No module named 'aiosqlite' into a hard CI failure on every push and PR (main went red on d005e8ce). With the driver present, pytest --collect-only -q collects 829 tests with zero errors. No runtime behaviour change; the test/dev block already ships in the backend image. Verified locally: collection clean, the AUT-5137 fail-closed tests pass (4/4), test_alembic_heads.py passes (4/4), and the two fail-closed regression tests fail on pre-fix main as intended. OSV reports no advisories for aiosqlite==0.21.0.429 Too many failed login attempts instead of 401 — behind Cloudflare, nginx's $remote_addr is the Cloudflare **edge** IP, so the X-Real-IP header the frontend proxy set was the edge IP and every visitor on that edge node shared one login:fail:ip:<edge> bucket (LOGIN_MAX_ATTEMPTS=5, LOGIN_WINDOW_SECONDS=3h). Five failed logins by *anyone* locked out *everyone* behind that edge for three hours, which is why the report looked intermittent (the shared bucket refills). docker/frontend/nginx.conf and docker/frontend/nginx-proxy.conf now enable nginx's realip module (real_ip_header CF-Connecting-IP + set_real_ip_from for Cloudflare's published edge ranges), so the X-Real-IP they already send carries the real visitor. Deliberately **no** backend change: client_ip() keeps trusting only the proxy-set X-Real-IP and the socket peer, never a client-supplied CF-Connecting-IP / X-Forwarded-For, so the AUT-303 spoof bypass stays closed — set_real_ip_from is the trust boundary, and a request arriving from outside a Cloudflare range is never rewritten. Adds backend/tests/test_aut5460_cf_real_ip.py, which pins every edge range in both configs, the /api proxy header that forwards the rewritten address, and the AUT-303 behaviour of client_ip().withOpacity call sites in login_screen.dart — the two background-gradient stops (scheme.primary.withOpacity(0.75), scheme.secondary.withOpacity(0.6)) and the form-card BoxShadow (Colors.black.withOpacity(0.18)). PR #905 fixed only the logo-shadow BoxShadow and deliberately deferred these, leaving dart analyze lib/screens/auth/login_screen.dart at 3 deprecated_member_use infos; the file is now clean. Cosmetic only, no behaviour change: withValues(alpha:) keeps alpha as a double where withOpacity rounded to 8-bit, so 0.75 and 0.6 render identically and the card shadow shifts by 1/255 of alpha (0.18 → 0.1804). The remaining 45 occurrences across 17 files under frontend/lib (incl. signup_screen.dart) are out of scope.Colors.black.withOpacity(0.30), which emits a deprecated_member_use analyzer info ('withOpacity' is deprecated … Use .withValues() to avoid precision loss) on every flutter analyze run. Swapped to Colors.black.withValues(alpha: 0.30) — same rendered shadow (the two differ only in 8-bit rounding of the alpha channel), deprecation cleared. Cosmetic only, no behaviour change; raised during QA re-review of PR #787 (AUT-5524).market-data's carsguide._filter_year fell back to the **whole unfiltered listing set** whenever the requested year matched fewer than 3 results (which is exactly what a rare-year car hits), and the backend then aggregated that mixed set into the median. Year filtering now widens in tiers (0 → 1 → 3 → 5 years, first tier with 3 listings wins) and returns nothing past ±5, so a thin same-year sample surfaces as an honest "no market data" instead of a confidently wrong number. market_data._build applies the same filter server-side so a provider that ignores the requested year can no longer poison the median. advisor.value.find_comparables now year-checks each *listing* (a cache row for one model year can hold listings from another — the row-level filter was not enough) and sorts by nearest model year instead of newest-year-first, which is what put a 2019 Crown at the top of a 2009 car's comparison set. Adds backend/tests/test_aut5541_comparables_year.py and extends the market-data self-check.[email protected] / demo credential literal (CWE-798). DEMO_PASSWORD defaulted to "demo" in backend/app/core/config.py, so any DEMO_MODE=true run without an explicit DEMO_PASSWORD re-seeded the old demo password; the literal also shipped in README.md, .env.example, docs/ and the Flutter login screen. seed_demo() / reset_demo() now fail closed — empty DEMO_PASSWORD skips and logs demo_seed_skipped_no_password / demo_reset_skipped_no_password instead of creating an account with a blank/default password, and reset_demo() skips before deleting so a reset can never wipe the demo environment for good. Adds the fail-closed cases to backend/tests/test_seed_reset_demo.py. No redeploy: AUT-2409 keeps deploys hosted-only and the demo stack is down (AUT-5057).DEMO_PASSWORD before the fail-closed check in seed_demo() / reset_demo() (CWE-798 / CWE-521). A whitespace-only value (DEMO_PASSWORD=" ", e.g. a secret pasted with stray whitespace) is truthy, so the empty-string guard let it through and seeded a demo account whose password was a single space — guessable for anyone holding the public demo email. Both guards now strip first, and seed_demo() hashes the stripped value so a padded secret still matches what the operator types (as seed_admin() already did). Whitespace-only regression cases added to backend/tests/test_seed_reset_demo.py.app.workers.tasks.backup_offsite_hourly crashed on **every** run with TypeError: Logger._log() got an unexpected keyword argument 'reason' — app/services/backup_offsite.py built a stdlib logging.getLogger but every call site passes structlog-style kwargs (reason=, filename=, status=, error=, pushed= …), so the first logger.info of the run raised before any push was attempted. Affecting EP2 Default (backend 0.3.305) and EP5 Hosted (0.3.307), i.e. off-site backups had not been pushing at all. The module now uses the project's get_logger structlog logger like every other service. The existing test could not catch it: pytest's logging plugin attaches a root handler, which makes setup_logging()'s logging.basicConfig() a no-op so the level stayed WARNING and logger.info() short-circuited before _log() ran — the suite now forces INFO and asserts the structlog-kwarg call path._ensure_next_service lost its function-local list_completed_services import in #888, so the AUT-5318 auto-suggest raised NameError and returned 500 on every odometer-triggered suggestion — i.e. adding a fuel or logbook entry to a vehicle with auto-suggest on and no scheduled service still created no service item. Restores the import._ensure_next_service (AUT-1275) kept only completed services whose service_type was one of ten canonical types, returned early when the vehicle had no service history at all, and silently gave up when the AI gateway was unreachable — so adding a fuel or logbook entry created no scheduled service for a car with only "repair"/"tyres"/"custom" records, a car with no logged services, or during any AI outage. All completed services now count as history, history is no longer required, and a deterministic manufacturer interval (measured gap between past services, else 20,000 km / 12 months — the same baseline as the ai/ service-prediction fallback) creates the suggestion whenever the gateway does not answer. Adds deterministic_next_due() + backend/tests/test_aut5318_deterministic_next_due.py (no DB) and an end-to-end case in backend/tests/test_odometer_priority.py (gateway down, repair-only history, zero history).f7e8d9c0b1a2 called PGInspector.get_constraints, which does not exist in SQLAlchemy, so every alembic upgrade head raised AttributeError at that revision, fell back to create_all and left alembic_version stuck at two rows (EP2 Default). It now reflects the UNIQUE constraint via get_unique_constraints and no-ops when the constraint or the passkey_credentials table is absent (idempotent + offline-safe); adds backend/tests/test_f7e8d9c0b1a2_passkey_unique.py covering create / already-exists / downgrade / missing-table / offline paths.test_aut2203_station_annotations.py constructed FuelStats with avg_litres_per_fill instead of the declared avg_fill_litres field, so 2 of its 7 tests raised a pydantic ValidationError and the cost_per_km / avg_fill_cost coverage the issue asked for never actually ran on maincannonfodder151/autobrain-frontend:demo) in dockerhub-publish.yml (AUT-5261). The Demo stack had no frontend build job: API_BASE_URL is compiled into the Flutter bundle, so Demo needed its own image and the only pre-existing :demo artifact was built 2026-09-28 with Hosted's API base.BACKUP_OFFSITE_URL (backend/app/core/config.py). The backend now validates the configured off-site backup URL at startup and **fails closed**: only schemes http/https, only hosts that are loopback-free, link-local-free, and named in the new BACKUP_OFFSITE_HOST_ALLOWLIST exact-match list are accepted. Cloud-metadata literals (169.254.169.254), their decimal/hex/IPv4-mapped encodings, RFC1918 literals, and loopback aliases are rejected even when allowlisted. Anchoring on anything but the whole label set would let an attacker-registered domain such as autobrain-backup.attacker.net satisfy the rule, so the match is an exact, case- and trailing-dot-normalised host comparison.docker-compose.hosted.yml sets BACKUP_OFFSITE_HOST_ALLOWLIST: ["backup"] so the shipped default BACKUP_OFFSITE_URL stays authorised. Overriding the URL without adding the new host now makes the backend refuse to boot, by design..env.example documents BACKUP_OFFSITE_HOST_ALLOWLIST next to the other BACKUP_OFFSITE_* entries (the AUT-2390 compose diff gate requires every compose env key to be declared there).backend/tests/test_aut3977_backup_offsite_ssrff.py (36 cases).backup_offsite_hourly wraps run_backup_offsite() in the persistent-loop _run() wrapper. Before the fix the async function was passed bare, so the coroutine was created and dropped — the task "succeeded" in ~0.001s and the hourly off-site push never fired. Guarded by backend/tests/test_workers.py and the real-task regression in backend/tests/test_backup_offsite.py..trivyignore reachability rationale for CVE-2026-103111 (pcre2 OOB write). The old condition-2 paragraph claimed the nginx base image "ships no JIT-enabled pcre2 build for our config" — false: the pinned nginxinc/nginx-unprivileged:stable-alpine binary (nginx 1.30.5-r1, pcre2 10.48-r0) links libpcre2-8.so.0 and imports pcre2_jit_compile_8 (plus pcre2_compile_8, pcre2_match_8, pcre2_pattern_info_8) from its .dynsym, which is the proof that PCRE2 JIT is compiled in. The suppression itself is unchanged and stays approved: condition 1 (attacker-controlled regex) fails independently — nginx only matches the static location ~ ^/(autobrain-assets|autobrainservice-assets)/ literal — and CPython 3.13 links no pcre2 at all. No entry added or dropped; the 2026-11-30 re-check date stands. Comment-only change, no runtime effect.Security — base image CVE scan (trivy) gate on main, and dropped the .trivyignore entries the bumps made redundant. The gate had been red on main since 2026-09-26, so every open PR looked like it had a regression.nginxinc/nginx-unprivileged:stable-alpine → @sha256:ed04ec1f… (Alpine 3.24.2, libexpat 2.8.5-r0, libuuid 2.42.3-r1). Clears CVE-2026-93990, CVE-2026-66046 and CVE-2026-76641 (libexpat) plus all seven util-linux/libuuid and the CVE-2026-80256 placeholder findings.python:3.13.15-slim-trixie → python:3.13.16-slim-trixie@sha256:6906dca8… (Debian 13.7). Clears CVE-2026-75804 / CVE-2026-84782 (OpenSSL QUIC/DTLS), CVE-2026-41992 (gzip), CVE-2026-11822 / CVE-2026-11824 (libsqlite3-0) and seven perl-base findings, and carries openssl 3.5.7-1~deb13u3 on both the amd64 and arm64 manifests.docker/frontend/Dockerfile built 44275388…, trivy-image-scan.yml scanned the amd64-only manifest ee1643ae…, and libexpat-version-check.yml watched 45ce1e2e…). All three now pin the same multi-arch index, so the scan again covers the arm64 frontend we actually build..trivyignore entries, each with its reachability argument, for CVEs whose fixes are published upstream but not yet in any published image: CVE-2026-103111 (pcre2 OOB write — needs an attacker-controlled regex *and* JIT; CPython links no pcre2 and our nginx PCRE patterns are static), and CVE-2026-97687 / CVE-2026-97689 (urllib3 2.7.0 as pip's vendored copy — build-time only; the runtime HTTP stack is httpx). Re-check 2026-11-30.trivy-image-scan.yml passed scanner: vuln to aquasecurity/[email protected], which does not accept that input (it is scanners). GitHub logged Unexpected input(s) 'scanner' on all three scan steps and dropped the value.libexpat-version-check.yml compared a per-arch manifest digest (regctl image digest --platform linux/amd64) against PINNED_DIGEST, which holds the multi-arch index digest, so unchanged was never true and the daily job re-filed a duplicate Paperclip issue on every run. It now resolves the index digest, and its threshold is raised to 2.8.5-r0..trivyignore: all three pinned base images return 0 findings at HIGH/CRITICAL with --ignore-unfixed (exit 0 each).scripts/check-compose-config.py crashed with KeyError: 'ai' on main after the AUT-3153 merge removed the standalone ai service, so the hosted compose structural guard had been dead. Optional services are now filtered by presence (SECRET_SERVICES + present()), BACKUP_OFFSITE_GUI_KEY_FILE / BACKUP_OFFSITE_INGEST_KEY_FILE (and gh-runner's github_pat) are known secret files, and the corresponding plain-env keys are forbidden.scripts/check-compose-consolidation.py asserted the standalone ai service existed; it now asserts the merged gateway indirection (AI_GATEWAY_API_KEY_FILE / AI_ROUTER_API_KEY_FILE) lives on backend.scripts/seed-secrets.sh aborted immediately: a comment inside a sed backslash continuation (# -e 's/^FUEL_VIC_API_KEY$/…' \) terminated the pipeline, so set -eu killed the script and **no** secret file was ever seeded. Comment moved above the pipeline; BACKUP_OFFSITE_GUI_KEY / BACKUP_OFFSITE_INGEST_KEY are now mapped to secret files..github/workflows/compose-checks.yml runs every scripts/check-*.py plus scripts/test_check_compose_config.py on compose/script changes, so the guards can no longer rot unnoticed.BoxFit.contain, so a non-square logo asset cannot silently stretch again. The layout fix itself already landed; this guards it.ClipOval subtree. find.byType(Container).first resolved to the gradient Scaffold.body Container, so the test failed for the wrong reason. Also add the test to visual_regression.yml, which previously never executed it.backend/tests/test_workers.py that reproduced on a clean origin/main checkout (not env-dependent, and not caused by the AUT-3827/AUT-3977 branch diff — root cause was the test harness, not the code under test):test_scheduled_backup_skips_on_missing_minio_credentials asserted on caplog (stdlib logging), but the worker logs through structlog, so the records never reached caplog. It now uses structlog.testing.capture_logs() and asserts on the reason="minio_credentials_missing" event field, matching the pattern in backend/tests/test_aut324_rego_log_redaction.py.test_ingest_fuel_prices_no_typeerror_when_source_in_result and test_run_due_checks_runs_inner_coro_via_run raised NameError at the patch.object(...) / asyncio.new_event_loop() call sites because unittest.mock.patch and asyncio were never imported. Both are now imported at module top.python3 -m pytest backend/tests/test_workers.py is green (7 passed) with only DATABASE_URL + SECRET_KEY exported.pypdf 6.16.1 -> 6.19.0 in backend/requirements.txt and ai/requirements.txt. 6.16.1 carried 8 known vulnerabilities (PYSEC-2026-4153..4160), which kept the pip-audit-gate job of Publish images to Docker Hub red on main and blocked every PR merge. pip-audit --disable-pip --no-deps over the deduplicated backend+ai pin list is now clean. The receipt worker's _pdf_text() and the reportlab PDF export paths are unchanged (pypdf is only ever a reader there); guarded by backend/tests/test_deps_pypdf_pin.py (floor raised to 6.19.0), backend/tests/test_pdf_dos_regression.py and the test_api.py PDF export tests.monthly tier when listing off-site snapshots (backend/app/services/backup_offsite.py). Retention manages four tiers but the off-site listing flattened only hourly/daily/weekly, so monthly backups were invisible to _apply_tiered_retention — never deduped per month slot and never pruned past the 6-month window. Tier list is now a single _OFFSITE_TIERS constant. Guarded by backend/tests/test_backup_offsite.py (new).FUEL_VIC_ENABLED: "false" in docker-compose.hosted.yml, matching docker-compose.prod.yml. The VIC Servo Saver endpoint api.servosaver.com.au is NXDOMAIN (AUT-4143), so the hosted nightly beat (ingest-fuel-prices) raised FuelFeedError for VIC on every run. NSW, QLD and SA feeds are unaffected. The VIC secret files stay mounted so the feed can be re-enabled when a paid VIC aggregator is available. Guarded by backend/tests/test_fuel_feed_flags.py.docker/backend/Dockerfile that broke every backend image build. The Playwright Chromium download sat above the backend pip install, so python -m playwright install --with-deps chromium ran against the bare python:3.13.15-slim-trixie base and aborted with No module named playwright — including the hosted ARM64 build that AUT-4153 / AUT-4160 depend on. The install block (with ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright) now runs immediately after pip install -r backend/requirements.txt; the root/SUID chrome_sandbox re-own stays the final root layer. Guarded by ai/tests/test_dockerfile_layer_order.py.gh-runner service from docker-compose.hosted.yml. It carried an inline build: block, and Portainer cannot build service images for a remote endpoint with no uploaded build context, so every compose-pin sync of this file failed with HTTP 500 on PUT /stacks/122?endpointId=5 (body: failed to deploy a stack: compose build operation failed: listing workers for Build) — reproduced on EP5 and EP6. The ARM64 runner already runs as its own Portainer stack (gh-runner-autobrain-arm64, stack 123 on EP5) on the external autobrain-hosted_default network, and its image autobrain-gh-runner:arm64-latest is unpublished, so the inlined service could never have started on EP5 anyway.scripts/sync-compose-to-portainer.py now prints the Portainer response body on failure and exits 4 instead of logging a bare HTTP Error 500, so the next such error names itself in the CI log.CHANGELOG.md-only commit. The AUT-4911 merge itself shipped without one, so the post-merge changelog-gate job failed on main and took the Publish images to Docker Hub run down with it, leaving the release queue stuck. A changelog-only diff does not re-trip the gate.sync-mobile.yml commits with git add -A, so a stray CHANGELOG.md.bak left in the autobrain-mobile working tree was swept into commit b102347 and stayed tracked (140KB) in every clone. Two fixes: autobrain-mobile now gitignores *.bak and *~, and scripts/sync-mobile.sh runs a pre-flight that aborts the sync if a backup artifact is present on either side of the copy — before any file is written, so there is no partial sync. Covered by scripts/test_sync_mobile_backup_guard.sh.alembic_version read aut3447_passkey_credentials, but passkey_credentials, engineers and engineer_reviews did not exist, so head migration f7e8d9c0b1a2 raised UndefinedTableError: relation "passkey_credentials" does not exist. bootstrap() then fell back to create_all, which could not repair the gap because app/models/__init__.py never imported those three models — so the version never advanced and the loop repeated indefinitely. Passkey sign-in and the engineer marketplace were both non-functional on hosted as a result. Three changes: f7e8d9c0b1a2 is now guarded and no-ops when the table is absent; a new guarded migration aut4925_missing_tables creates the three tables column-for-column identical to the ORM models (including engineer_reviews.updated_at, which a3661engineers omits); and the three models are imported so the create_all fallback covers them. A new test_every_model_table_is_exported_for_create_all guard fails if any model class declaring __tablename__ is not exported from app.models.test_deps_transitive_cves.py — pyjwt >= 2.15.0, so a future downgrade cannot silently re-expose GHSA-42vr-xj54-vc7v (unauthenticated RecursionError DoS via PyJWKClient.get_signing_key_from_jwt with verify_signature=False), which 2.14.0 does **not** fix. The pin guard itself is also hardened: _pins() now strips [extras] (PyJWT[crypto] -> pyjwt) and tolerates PEP 440 suffixes (2.9.0.post0), which previously raised ValueError and took down the whole module. The 2.15.1 pin itself already landed via AUT-4743 (#828).scripts/bump-version.sh printed docker build instructions that interpolated "$CARTO_API_KEY" even though the commands are only echoed, never run. Under set -u — how CI invokes it via auto-bump.sh — an unset CARTO_API_KEY killed the script at line 64 with unbound variable, after the CHANGELOG had been promoted but before the version bump could be committed. That broke the auto-bump job in both Publish images to Docker Hub and Build hosted images (multi-arch), blocking every release off main. This was a regression introduced by AUT-4824 (PR #822). The three lines now print a literal $CARTO_API_KEY placeholder for the operator to substitute; the set -u guard is untouched. New scripts/test-bump-version.sh runs the real script in a sandbox with the key unset and set, asserting exit 0 and that the bump lands.CARTO_API_KEY hard-fail introduced by AUT-4690 (PR #822) left three documented/scripted frontend build paths passing no --build-arg CARTO_API_KEY, so all of them failed if copied: the manual docker build -f docker/frontend/Dockerfile snippet in docs/Deployment-and-Infrastructure/deployment-guide.md, the three hosted/default/demo build commands printed by scripts/bump-version.sh, and the stale "Empty -> key-less public basemap" comments in .env.example, docker-compose.yml and docker-compose.prod.yml (the empty default is intentional — it fails the build loud rather than shipping a watermapped map). No behaviour change. New scripts/check-carto-build-arg-propagation.py statically asserts that every documented/scripted frontend build passes the key; it now runs as the carto build-arg propagation CI job so the paths cannot silently regress. scripts/publish-images.sh also sources the key from .env and hard-fails early instead of letting docker build reject it, and dockerhub-publish.yml fails with a readable message when the CARTO_API_KEY secret is unset rather than surfacing an opaque error deep inside the build.ai_app in the shared backend image. docker/backend/Dockerfile copies ai/app to ai_app and runs it as a co-process on :8001 inside the backend container, but every gateway module used an absolute from app.… import. In that image app resolves to the **backend** package, which has no logging, modules, router_client or fallbacks, so the gateway died at startup with ModuleNotFoundError: No module named 'app.logging' and every /ai/ route 502'd. 48 import statements across 20 files converted; the standalone ai/ suite is unchanged (109 passed, same 3 pre-existing failures).ai/tests/test_merged_image_layout.py simulates the image layout (backend app + gateway ai_app side by side) and asserts the gateway both imports and serves /health + auth on :8001, plus an AST check that no absolute app.* import reappears. The standalone AI suite imports app.main and could never catch this class of breakage; the new file fails 3/4 on the pre-fix tree and passes 4/4 after.autobrain-backup service is renamed to backup and is now the **single** backup container. backup-agent stays removed (AUT-3827 — its hourly snapshot push is the offsite-backup-hourly Celery beat task in backend), so the hosted stack runs one backup container instead of a GUI container plus a poller sidecar. GUI endpoint is unchanged (127.0.0.1:8080 on the host, /backups bind mount preserved). BACKUP_OFFSITE_URL now defaults to http://backup:8080; **any EP5 stack env override of the old http://autobrain-backup:8080 must be updated or hourly pushes stop on DNS failure.**scripts/check-compose-consolidation.py and scripts/check-compose-config.py no longer crash or pass vacuously on the consolidated stack — both still asserted the ai service that AUT-3824 removed (KeyError: 'ai'), and neither allowed-listed the backup_offsite_* secret files added by AUT-3827. Both now assert the exact 10-service set and the backup DNS name.PyJWT[crypto] 2.13.0 -> 2.15.1. 2.13.0 carries 12 known CVEs (CVE-2026-101917/101918/102265-102274), which made both pip-audit-gate and the resolved-tree scan (AUT-1189) fail on every PR and on main — it was blocking the hosted deploy pipeline, not just this PR. The API used by app/core/security.py and app/services/iap.py (encode/decode/ get_unverified_header/PyJWTError/InvalidTokenError) is unchanged.docker/frontend/Dockerfile now hard-fails when CARTO_API_KEY is unset/expired, and asserts the key value is actually present in the built main.dart.js. Previously an empty secret produced a *green* build and the Servo Spy map silently fell back to the watermapped public basemap, only caught weeks later by a human QA curl (AUT-4533, AUT-4649). The empty-key check runs before flutter build web so it fails fast.urlTemplate branches interpolate _kCartoKeyParam. cartoKeyParam was correct but unreferenced by the tile URL, so a build with --dart-define=CARTO_API_KEY still rendered watermapped tiles and flutter analyze only emitted an unused_element warning. Mirrored into autobrain-mobile by hand because sync-mobile.yml is red on the pre-existing analyze backlog (AUT-4708).10.0.3.17:20128) is a managed Portainer stack (9router, id 128) from docker-compose.9router.yml instead of a loose docker run container — it was invisible to Portainer's stack view, so it had no consistent update path and no health signaldecolua/9router:0.5.91@sha256:efc6e88c…) — the same image the floating :latest tag was already resolving to, so no version change/api/health healthcheck; the loose container had noneautobrain-hosted, EP6 has none and uses the EP2 one) and the two ways to break the shared :20128 routeAuthenticatorAttestationResponse; it previously passed response=None, so every registration attempt failed/auth/passkey/authenticate/complete now returns access_token + refresh_token; it minted both tokens and then discarded them, so a successful passkey assertion could not sign the user inAPP_BASE_URL instead of the client-supplied Origin header, which made the origin check a no-op (an attacker could echo any origin)f7e8d9c0b1a2 adds a unique constraint on passkey_credentials (user_id, credential_id) so one authenticator cannot be registered twice for an accountEngineerSortBy no longer inherits from list, which raised TypeError: multiple bases have instance lay-out conflict on Python 3.13 and broke app import/api/v1/engineers imported EngineerSearchResult and EngineerSearchResponse from the service module, which does not export them; both now import from app.schemas.engineer (also fixes EngineerResponse being undefined)api.servosaver.com.au returns NXDOMAIN and would raise FuelFeedError instead of returning 0 stations; set FUEL_VIC_ENABLED="false" in docker-compose.prod.yml and commented out unused secret seeds in scripts/seed-secrets.sh until a paid VIC aggregator is availableautobrain-assets on dev/hosted, autobrainservice-assets on demo/default) in nginx so community-hub photos load instead of serving the SPA shell; the regex location forwards the original URI (with its bucket prefix) to MinIObackend/app/services/ai_fallbacks.py with 435 lines of deterministic rule-based implementations.BoxFit.cover → BoxFit.contain) and sits in a black circle instead of a white one, on all three auth screens (AUD-427 user report)ApiClient.getCachedDecoded no longer throws when the local cache backend is unavailable (web/sqflite); the vehicle manage screen, timeline, and every other cache-first screen fall through to the network path instead of failing to loadPetrolPriceMapScreen itself is unchanged and still reachable from frontend/lib/screens/fuel/petrol_price_map_screen.darta3661engineers (engineer marketplace) and aut3447_passkey_credentials (WebAuthn) via new merge revision m3rge07; restores single-head guarantee so alembic upgrade head works and pytest-smoke gate passesGetFuelTypes 404 in QLD DirectAPI v1.5 — country 21 (Australia) returns 404 for GetFuelTypes; the call is now wrapped in try/except so the rest of the QLD ingest path continues. _parse_qld_direct_prices uses a P<id> fallback label when the fuel type name is absent, ensuring prices are still recorded.test_parse_qld_direct_prices_empty_fuel_map_uses_fallback_label and test_ingest_qld_fuel_prices_works_when_getfueltypes_404s — unit tests asserting QLD ingest returns stations/prices even when GetFuelTypes is absent or 404.vector_search.py; repeated searches return cached vector without 9Router callEMBEDDING_DIMENSION on read; a poisoned/wrong-dimension cache entry is rejected and the router path re-derives the vector instead of binding it to SQL (22P02)app/services/market_scraper/ package: CarsGuide, BikesGuide, SCA parts-guide scrapers (deterministic HTTP + Playwright subprocess for gated portals)refresh_market_data (daily 03:00 AEST) refreshes market cache for every vehicle in fleetshm_size: "256m" added to backend service for Chromium shared memorycarsguide/sca imports to module top so script-mode invocation (python browser.py ...) resolves without ImportErrorPath(__file__).resolve() in test_market_scraper.py instead of hardcoding /home/node/autobrain/..., which only resolved on the dev box and broke collection on CI runners and the hosted ARM64 VM (QA finding on PR #777)?key= param now passes the build-time --dart-define=CARTO_API_KEY value so CARTO basemaps render without watermarkvolumes: block left in docker-compose.hosted.yml by the AUT-3827 backup-agent removal, so the file has a single valid top-level volumes: key (duplicate keys are rejected by the Docker Compose strict YAML parser)./data/autobrain-backup/agent-data bind mount. Merging it into autobrain-backup would have duplicated the /backups container path already served by /data/autobrain-backup/data, and the backup-agent service that owned that directory was removed.beta and release lanes in frontend/ios/fastlane/Fastfile with match for cert/profile sync via S3, API key authentication, build number increment, and changelog integration.withValues(alpha:) with withOpacity(alpha:) in servo_spy_screen.dart for dart2js arm64 compatibility (PR #608)/api/v1/engineers/search — geospatial search by postcode/radius, specialty multi-select, minimum rating, price range, availability window filters/api/v1/engineers/{id} — engineer profile detail with reviewsEngineer + EngineerReview models, pgvector embedding support for semantic searcha3661engineers_add_engineer_marketplace.py>=) to exact (==) in market-data/requirements.txt to match backend/requirements.txt. idna==3.18 (PYSEC-2026-215), pycryptodome==3.23 (PYSEC-2026-1811), pygments==2.20 (PYSEC-2023-117 + PYSEC-2026-2987). Floor pins allowed pip to resolve to vulnerable versions if upstream constraints drifted.DiagnosticOut (backend/app/schemas/diagnostic.py) now exposes status: str and resolved_at: datetime | None, matching the Diagnostic model fields. GET /vehicles/{id}/diagnostics returns open + resolved entries with their state, so AC6 ("Diagnostics: open + resolved entries present") is verifiable and the frontend can render resolved state.odometer.run() fallback results through _clamp(), enforcing 0..9,999,999 bounds, numeric coercion, and the rule-based-fallback model. Added regression coverage for bounds, coercion, and empty OCR output.odometer.run() fallback results through _clamp(), enforcing 0..9,999,999 bounds, numeric coercion, and the rule-based-fallback model. Added regression coverage for bounds, coercion, and empty OCR output.autobrain-worker image build (AUT-3153 follow-up). Removed the worker leg from every for svc in backend worker ai frontend loop in .github/workflows/build-hosted.yml (build, per-arch verify, manifest assembly, digest capture) and .github/workflows/dockerhub-publish.yml (amd64 build + manifest assembly), dropped the WORKER_DIGEST env + worker=... arg from the compose-pin step, and removed the worker pin from scripts/update-compose-pins.py. CI no longer publishes the unused multi-arch worker image. The docker/worker/Dockerfile stays on disk as a reference for the security-scan workflows; infra/k8s/worker.yaml already runs the Celery worker+beat from autobrain-backend:latest and is unchanged.FUEL_SA_API_KEY and FUEL_SA_ENABLED in .env.example so hosted operators can provision the SA SAFPIS feed referenced by docker-compose.hosted.yml.getCachedDecoded calls in try-catch in HomeScreen._load() and _AdvisorDeepLink._load(). On web, OfflineCache backed by sqflite can throw (no web DB driver registered), which crashed _load() before _loading = false could run. Also make ConnectivityService.init() non-blocking: race the first probe against a 3-second timeout so a hung connectivity_plus plugin never blocks main() past runApp().timeout-minutes: 15 to the ocr-review job in .github/workflows/code-review.yml so a 9Router stall or runner hang can never hold the x64 runner beyond 15 min (previously unbounded at job level). The step-level 10 min timeout remains as the inner guard.poll_vic_fuel_prices Celery task and poll-vic-fuel-prices beat schedule entry — daily VIC Servo Saver polling consumer with FUEL_VIC_ENABLED/FUEL_VIC_API_KEY gating (AUT-1932 follow-up).GET /api/fuel/vic endpoint returning VIC-sourced stations + latest prices from the cached fuel_stations/fuel_prices tables.test_aut2132_vic_servo_saver.py) covering enabled+key present, enabled+key absent, and disabled skip paths.[skip ci] from auto-bump.sh commit message — the dockerhub-publish.yml version_only step already skips image build on version-cut pushes, so [skip ci] was redundant and suppressed sync-mobile.yml and github-release on auto-bump pushes. Document rationale in script header.GetFuelTypes 404 in QLD DirectAPI v1.5 — country 21 (Australia) returns 404 for GetFuelTypes; the call is now wrapped in try/except so the rest of the QLD ingest path continues. _parse_qld_direct_prices uses a P<id> fallback label when the fuel type name is absent, ensuring prices are still recorded.test_parse_qld_direct_prices_empty_fuel_map_uses_fallback_label and test_ingest_qld_fuel_prices_works_when_getfueltypes_404s — unit tests asserting QLD ingest returns stations/prices even when GetFuelTypes is absent or 404.scripts/upgrade-instances.sh redeploys Portainer stacks tier-by-tier (Demo → Default → Hosted) with explicit image pulls and health gates. docker-compose.hosted.yml drops dead dongle-server + duplicate hub, uses :- defaults for POSTGRES_USER/POSTGRES_DB and DONGLE_SERVER_URL so Portainer redeploys survive empty-stack env. PR #375.pumpAndSettle timeout in desktop layout visual regression tests by mocking getCachedDecoded in _FakeApi, setting _loading = false in VehicleListScreen._load(), and correcting test assertions. The visual regression workflow (desktop 1280/1440/1920) now passes.allow_methods to explicit set (GET/POST/PATCH/DELETE) and allow_headers to narrow list (Authorization, Content-Type, Accept, X-Requested-With). Add startup validator rejecting CORS_ALLOWED_ORIGINS=["*"] with allow_credentials=True.