# Deployment

## Build

Run from repository root:

```bash
cd vehicle-data-platform
pnpm --dir apps/web install
pnpm run web:build
cd apps/api
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-api ./cmd/platform-api
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-evaluator ./cmd/alert-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-stream-evaluator ./cmd/alert-stream-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-migrate ./cmd/platform-migrate
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync
# Optional release/performance gate; this binary is run on demand, not installed as a service.
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-benchmark ./cmd/alert-benchmark
```

## ECS Paths

```text
/opt/lingniu-vehicle-platform/current
/opt/lingniu-vehicle-platform/releases
/opt/lingniu-vehicle-platform/env/platform.env
```

## Browser-safe release switch

The web application splits every major route into a hashed lazy-loaded asset. An already-open browser tab can therefore request the previous release's route asset after `current` has switched. Keep exactly one previous generation of original build assets in the new release before the atomic symlink switch:

```bash
CURRENT_WEB=/opt/lingniu-vehicle-platform/current/web
NEW_WEB=/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/web

# Archives created on macOS must disable AppleDouble metadata. As a server-side
# guard, remove any metadata files before recording the release manifest.
COPYFILE_DISABLE=1 tar -C apps/web/dist -czf "/tmp/$PLATFORM_RELEASE-web.tar.gz" .
find "$NEW_WEB" -type f -name '._*' -delete

# Record only this release's own build assets before adding compatibility files.
find "$NEW_WEB/assets" -type f -printf '%P\n' | sort > "$NEW_WEB/.release-assets"

if [ -f "$CURRENT_WEB/.release-assets" ]; then
  while IFS= read -r asset; do
    test -n "$asset" || continue
    mkdir -p "$NEW_WEB/assets/$(dirname "$asset")"
    cp -n "$CURRENT_WEB/assets/$asset" "$NEW_WEB/assets/$asset"
  done < "$CURRENT_WEB/.release-assets"
else
  # One-time compatibility for releases created before the manifest existed.
  cp -an "$CURRENT_WEB/assets/." "$NEW_WEB/assets/"
fi
```

Never overwrite a new hashed file. The `.release-assets` manifest prevents compatibility files from accumulating recursively: the next release copies only the immediately previous build's original assets. The React route boundary also detects a failed dynamic import, reloads once with a short cooldown and keeps the application shell usable if recovery still fails.

After the switch, verify the root document, runtime configuration, every current asset and every compatibility asset from the immediately previous release. The smoke downloads each asset and compares it byte-for-byte with the release file, so a SPA fallback returning `index.html` with HTTP 200 cannot hide a missing chunk:

```bash
PREVIOUS_WEB=/opt/lingniu-vehicle-platform/releases/$PREVIOUS_RELEASE/web
/opt/lingniu-vehicle-platform/current/deploy/verify-web-release.sh \
  /opt/lingniu-vehicle-platform/current/web \
  http://127.0.0.1:20300 \
  "$PREVIOUS_WEB/.release-assets"
```

`deploy/install-web-release.sh` also accepts an optional third argument containing a newly built `platform-api`. When provided, it atomically publishes the new API and Web together. The installer inherits every runtime binary that exists in the previous release (`alert-evaluator`, `alert-stream-evaluator`, `platform-migrate`, `oneos-scope-sync` and the optional benchmark) before switching the symlink, so a later systemd restart cannot fail because a Web-oriented release omitted an unchanged binary:

```bash
deploy/install-web-release.sh \
  "$PLATFORM_RELEASE" \
  "/tmp/$PLATFORM_RELEASE-web.tar.gz" \
  "/tmp/$PLATFORM_RELEASE-platform-api"
```

## Environment

```text
HTTP_ADDR=:20300
STATIC_DIR=/opt/lingniu-vehicle-platform/current/web
MYSQL_DSN=lingniu_vehicle:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/lingniu_vehicle_data?parseTime=true&loc=Local
REDIS_ADDR=r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379
REDIS_USERNAME=lingniu_vehicle
REDIS_PASSWORD=***
REDIS_DB=50
TDENGINE_DRIVER=taosWS
TDENGINE_DSN=root:***@ws(172.17.111.57:6041)/
TDENGINE_DATABASE=lingniu_vehicle_ts
CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check
DATA_MODE=production
EXPORT_DIR=/opt/lingniu-vehicle-platform/data/exports
AUTH_MODE=enforce
# JSON array with viewer/operator/admin principals. Keep this file mode 0600.
AUTH_TOKENS_JSON=[{"token":"<at-least-16-random-characters>","name":"ecs-admin","role":"admin"}]
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=<strong-first-admin-password>
AUTH_SESSION_TTL_HOURS=12
ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
ONEOS_SCOPE_MAX_REJECTED=100
ONEOS_SCOPE_MAX_REJECT_RATIO=0.10
REQUEST_TIMEOUT_MS=5000
AMAP_WEB_JS_KEY=***
AMAP_SECURITY_JS_CODE=***
AMAP_SECURITY_SERVICE_HOST=/_AMapService
# Optional reserved server-side key for AMap REST/track services.
AMAP_API_KEY=***
PLATFORM_RELEASE=platform-YYYYMMDDHHMMSS
ALERT_EVALUATION_INTERVAL_SEC=10
ALERT_STREAM_MODE=active
# Defaults to KAFKA_BROKERS from the shared ingest environment when omitted.
ALERT_STREAM_KAFKA_BROKERS=<kafka-host>:9092
ALERT_STREAM_KAFKA_TOPICS=vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1
ALERT_STREAM_KAFKA_GROUP=vehicle-alert-stream-shadow-v1
ALERT_STREAM_BATCH_SIZE=200
ALERT_STREAM_BATCH_WAIT_MS=100
ALERT_STREAM_LATENESS_SEC=120
```

`ALERT_STREAM_MODE=shadow` only validates envelopes and advances checkpoints; `active` additionally evaluates every non-`freshness_sec` rule in the same MySQL transaction. Keep the existing consumer group when switching so active resumes the shadow-proven offsets instead of replaying retained history. In active mode the snapshot evaluator retains exclusive ownership of `freshness_sec` and skips all dynamic telemetry rules. Roll back the mode to `shadow` and restart the API plus both evaluators if active batches fail; transaction rollback prevents a failed rule effect from advancing the authoritative checkpoint.

`AMAP_WEB_JS_KEY` is served to the browser through `/app-config.js` so the same static build can be reused across environments. For production, set both `AMAP_SECURITY_JS_CODE` and `AMAP_SECURITY_SERVICE_HOST=/_AMapService`; the API service keeps the security code on the server and proxies AMap requests with `jscode` appended. Only omit `AMAP_SECURITY_SERVICE_HOST` for controlled debugging where exposing `AMAP_SECURITY_JS_CODE` to the browser is acceptable. `AMAP_API_KEY` is reserved for backend-only AMap service APIs such as geocoding, route planning, geofence, or trajectory service integration.

`apps/web/public/app-config.js` is ignored and may contain developer-local values. `pnpm build` always replaces its copied output with `app-config.example.js` before packaging, then verifies an exact byte match. This prevents a local security code from entering a release archive even though production requests are dynamically intercepted by the API. The release smoke gate separately rejects any `/app-config.js` response containing `amapSecurityJsCode` and requires `amapSecurityServiceHost=/_AMapService`.

`PLATFORM_RELEASE` is surfaced by `/api/ops/health` at `data.runtime.platformRelease` so operators can confirm which ECS release is currently active after a deployment.

The global monitor persists its navigation workspace in the `/monitor` query string and passes a validated `monitorReturn` only to vehicle detail, track, history and mileage routes. Reverse proxies must preserve query strings on SPA fallback responses. Do not rewrite or decode the nested return value separately: the client accepts only a relative same-application `/monitor` path, canonicalizes filters, pagination and viewport values, and rejects external origins or malformed bounds. Production smoke should request all affected SPA routes and verify that the current `MonitorReturnBar-*` and `monitorContext-*` assets are served from the active release.

All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development.

`EXPORT_DIR` must point outside the release symlink. The API writes mode-`0640` CSV files and an atomic `jobs.json` index there; completed tasks survive API restarts, while interrupted queued/running jobs are marked failed and can be recreated. Every job persists its owner account, role/user type, customer/tenant references, resolved VINs and per-vehicle time scope. Non-admin principals can list and download only their own jobs; customers additionally pass a live account-status and vehicle-grant check before execution, every 5,000-row batch and every download. Old ownerless jobs remain visible only to administrators. The browser downloads through an authenticated Blob request rather than a direct anchor, so the Bearer credential is always present.

Exports run one at a time, use 5,000-row forward-only TDengine cursors instead of `OFFSET`, stream into a `.part` file, and atomically publish the final CSV only after flush, `fsync`, and close succeed. A task is limited to five vehicles, 31 days, 32 metrics, 1,000,000 rows, and 30 minutes. Both the initial count and observed rows enforce the row cap so late-arriving data cannot bypass it. Keep the directory mode `0750`, preserve `jobs.json` and completed CSVs across releases, and include the directory in retention/backup policy. Export routes require an authenticated viewer-capable principal; ownership and current Scope are enforced inside the export service instead of relying only on role middleware.

Release verification for this path includes the opt-in synthetic million-row gate:

```bash
cd vehicle-data-platform/apps/api
EXPORT_MILLION_TEST=1 go test ./internal/platform -run '^TestHistoryExportMillionRows$' -count=1 -v
```

Production smoke must create an export for an active VIN, wait for `completed`, verify `rowCount == processedRows == totalRows`, and assert that owner, role, resolved VINs and time scope are present. Download with the same Bearer principal, confirm the CSV contains export audit, vehicle Scope, query-range, metric/unit and quality metadata, and verify a guessed task ID returns 404. Compare the downloaded SHA-256 and byte count before and after one API restart, and confirm that no `.part` file remains. The automated gate must separately cover two mutually isolated customer principals plus account/vehicle revocation during execution.

Before a customer demonstration, run `deploy/verify-customer-demo.py` with the password supplied only through `DEMO_PASSWORD`. The gate logs in as the actual customer, requires the exact four customer menus, compares the complete realtime VIN set with the approved cohort, verifies representative source/location states, confirms an out-of-Scope VIN cannot resolve, checks optional track and mileage evidence, loads the four customer SPA routes and logs out. The script never creates an account, changes grants or persists the password; account identity and VIN Scope must be approved separately. See `docs/customer-demo-acceptance.md`.

`AUTH_MODE=enforce` is mandatory on ECS. Tokens are stored as SHA-256 comparisons in memory and sent as Bearer credentials; the browser stores the entered token only in `sessionStorage`. Roles are cumulative:

| Role | Permissions |
| --- | --- |
| `viewer` | Read pages, query data and inspect evidence; may create/download only its own history exports when the menu is available |
| `operator` | Viewer permissions plus alert actions, notification reads and read-only single-vehicle source diagnosis; export access remains owner-bound |
| `admin` | Operator permissions plus rule, access-threshold and per-vehicle source-policy configuration |

After editing the environment file, run `chmod 600 /opt/lingniu-vehicle-platform/env/platform.env`. Never put a real token in Git, static JavaScript, shell history or deployment logs.

`ONEOS_MYSQL_DSN` must use a dedicated account with direct table-level `SELECT` grants only. The sync binary runs `SHOW GRANTS FOR CURRENT_USER` before every read and accepts only global `USAGE` plus `SELECT` on the seven tables used by its query. It refuses database/global reads, `ALL PRIVILEGES`, DML, DDL, PROCESS, replication, roles, `SHOW VIEW`, or any other privilege. It then opens a repeatable-read, read-only transaction, applies a 10-second statement timeout, classifies invalid customer/contract relationships, and atomically publishes a content-addressed local snapshot. Never point it at the existing `ln-bi` account: production audit showed that account still has broad write and replication privileges.

Have an RDS administrator review and run `docs/oneos-scope-reader-provision.sql` separately. It restricts the login source to the verified ECS private address and grants `SELECT` on only the seven source tables used by the query. Do not include that DDL in application deployment or migration automation. Store `platform.env` as root-owned mode `0600`, and verify `SHOW GRANTS` before enabling the timer.

The active lifecycle comes from OneOS delivery and return task facts: the newest active delivery must have `delivery_status IN (2,3)`, and it must not have an active return task with `status IN (2,3,5)`. The sync intentionally does not use `vehicle_lease_order_record.last_return_time`, because OneOS currently writes that aggregate field while a return form is still a draft. The aggregate record remains a separate customer-ownership cross-check; missing, duplicate, or conflicting ownership fails closed.

The default two-minute timer reads one bounded current-scope result set (639 rows at the 2026-07-14 transactional-fact baseline), so it avoids per-request access to OneOS and adds negligible RDS load. A run is rejected before publication if it has no accepted rows, more than 100 rejected rows, or a rejected ratio above 10%. Current baseline is 613 accepted and 26 quarantined. An unchanged content hash records a successful run without duplicating the snapshot.

## Forward Migration

Before switching the API symlink, apply the idempotent access-threshold migration with a MySQL account allowed to create/alter platform-owned tables:

```bash
export MYSQL_DSN="$(sed -n 's/^MYSQL_DSN=//p' /opt/lingniu-go-native/env/base.env | tail -1)"
test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/platform-migrate \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/001_access_thresholds.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/002_alert_center.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/003_alert_rule_advanced.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/004_alert_repeat_index.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/005_metric_catalog.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/006_vehicle_profile.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/007_alert_master_data_scope.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/008_access_projection.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/009_alert_stream_checkpoint.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/010_alert_stream_metric_mapping.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql \
  /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/015_vehicle_source_policy_audit.sql
```

The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report.

Production release `source-diagnosis-stable-20260716173740` applied migration `015` before switching API traffic. The release gate verified 23 current assets and 42 compatibility assets. The authenticated diagnostic smoke used a real multi-source vehicle, confirmed that `source_key` was absent, and exercised the admin PUT route with values identical to the current policy; version, audit count and recommended source remained unchanged. Viewer/operator/admin access returned 403/200/200, median response time across 20 reads was approximately 70 ms (P95 79 ms), and the platform plus both alert evaluators remained active.

Install and start the evaluator as a separate unit only after API smoke checks and a small enabled-rule review:

```bash
sudo cp deploy/systemd/lingniu-vehicle-alert-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-alert-stream-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator lingniu-vehicle-oneos-scope-sync.timer
sudo systemctl status --no-pager lingniu-vehicle-alert-evaluator
sudo systemctl status --no-pager lingniu-vehicle-alert-stream-evaluator
sudo journalctl -u lingniu-vehicle-alert-evaluator -n 100 --no-pager
sudo journalctl -u lingniu-vehicle-alert-stream-evaluator -n 100 --no-pager
sudo systemctl status --no-pager lingniu-vehicle-oneos-scope-sync.timer
sudo journalctl -u lingniu-vehicle-oneos-scope-sync.service -n 100 --no-pager
```

## Health

```bash
read -rsp 'Platform admin token: ' PLATFORM_TOKEN; echo
AUTH_HEADER="Authorization: Bearer $PLATFORM_TOKEN"
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/session
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/ops/health
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/metrics
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/monitor/summary?limit=200'
curl -fsS -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
  -d '{"keyword":"LNXNEGRR7SR318212","status":"active","limit":20,"offset":0}' \
  http://127.0.0.1:20300/api/v2/alerts/events
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/vehicles/LNXNEGRR7SR318212/telemetry/latest
curl -fsS -H "$AUTH_HEADER" \
  'http://127.0.0.1:20300/api/v2/vehicles/LNXNEGRR7SR318212/source-evidence?date=2026-07-16'
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/vehicles?limit=5'
curl -fsS -H "$AUTH_HEADER" \
  'http://127.0.0.1:20300/api/v2/history/series?keyword=LNXNEGRR7SR318212&dateFrom=2026-07-14T00%3A00&dateTo=2026-07-14T06%3A00&targetPoints=240'
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":5,"offset":0}' \
  http://127.0.0.1:20300/api/v2/access/vehicles
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":20,"offset":0}' \
  http://127.0.0.1:20300/api/v2/access/unresolved-identities
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/access/thresholds
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
  -d '{"sourceSystem":"release-smoke","sourceVersion":"dry-run-1","conflictPolicy":"preserve","dryRun":true,"items":[{"vin":"RELEASE_SMOKE_MISSING","operationStatus":"unknown"}]}' \
  http://127.0.0.1:20300/api/v2/vehicle-profiles/sync
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"limit":5,"offset":0}' \
  http://127.0.0.1:20300/api/v2/alerts/events
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/alerts/rules
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/alerts/notifications?unreadOnly=true&limit=5'
curl -fsS http://127.0.0.1:20300/app-config.js
curl -fsS http://127.0.0.1:20300/ | grep -q '<div id="root"></div>'
MAIN_ASSET="$(sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' /opt/lingniu-vehicle-platform/current/web/index.html | head -1)"
curl -fsS "http://127.0.0.1:20300$MAIN_ASSET" >/dev/null
unset PLATFORM_TOKEN AUTH_HEADER
```

The release gate must include the root document, runtime configuration, every asset in the current `.release-assets` manifest and every asset in the immediately previous release manifest. API-only smoke checks are insufficient because an incomplete archive can leave the service healthy while the browser returns 404 or an HTML fallback for a missing chunk. Before upload, verify the archive contains `web/index.html`, `platform-api`, both evaluator binaries, `platform-migrate`, `oneos-scope-sync`, every numbered migration, the web-release verifier and all platform systemd units.

When `alertStream.lastInvalidCode` reports a recent `missing_vin_jt808`, query `/api/v2/access/unresolved-identities` as a viewer and hand the masked evidence to the identity owner. The response must not contain raw `phone` or unmasked identifier fields. Confirm the evidence against the GPS provider or vehicle owner, then maintain the authoritative `vehicle_identity_binding`; never derive VIN from the terminal number. The queue excludes a terminal after a valid binding exists. Retain the invalid counter as audit evidence and verify the recent warning clears after five minutes without another invalid frame.

The history-series gate must use an active production VIN and a current local-time window. Verify that `rawPointCount > 0`, every returned series remains within the requested point budget, `dateFrom`/`dateTo` and point timestamps describe the same absolute window, and the browser renders `Asia/Shanghai` labels. TDengine timestamp literals for this endpoint include an explicit RFC3339 offset; bare UTC-looking strings are unsafe because the server session may interpret them again in its local timezone.

The history-query gate must also request both `location` and `raw` through `/api/v2/history/query`. Every returned row must contain a non-empty `qualityReason`; RAW columns must include fields discovered from the bounded fetched scope rather than only the visible page. In the browser, verify field-name/key search, multi-select, the single visual time-range control, selected-field consistency across table/trend/export, and absence of a RAW-evidence placeholder when `evidenceId` is unavailable. Keep the existing maximum of five vehicles, 200 rows per page and 1,000 fetched rows for interactive field discovery.

The history-export gate must verify owner-bound listing and authenticated Blob download. A customer A task must be absent from customer B's list and return 404 when B guesses its ID; an administrator may audit it. Persisted job JSON and CSV metadata must retain the owner and exact per-VIN time scope after an API restart. Disabling the customer or revoking any required vehicle grant must stop an incomplete task and deny download of a previously completed file.

Track smoke must also select the final playback event and verify synchronized SOC/direction/alarm availability plus a resolved current address. For high-frequency sources, confirm positive subsecond samples do not become alternating zero-second `数据间隔` segments; only non-increasing timestamps or intervals over ten minutes are gaps. Access smoke should exercise `model`/`provider` and one paired first/latest receive-time range, then confirm the filter survives a shareable URL and every returned row remains in scope.

Global-monitor smoke must verify `zoom=5` returns clusters, a city `bounds` at `zoom=13` returns at most 2,000 lightweight points, and invalid/reversed bounds return HTTP 400. In the browser, the rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting `driving` or `idle` must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is `go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem`.

Source-evidence smoke must use a vehicle with at least two protocols and a vehicle with more than one JT808 terminal. Verify that the response returns every actual location/mileage candidate, exactly one current recommendation, protocol-internal selection, availability/quality state and cross-source differences. Raw `source_key`, source IP, endpoint, phone and device identifiers must not appear; terminal labels must remain masked. The browser must not call this endpoint before the operator expands coordinates or mileage. Production baseline for `沪A35898F` on 2026-07-16 returned two JT808 terminals with one recommendation; 20 sequential requests had a 26.6ms median and 29.1ms P95.

Latest-telemetry smoke must use an actively reporting VIN and verify that populated categories exactly sum to `values.length`, catalog-mapped values retain both the unified `key` and exact `sourceField`, every value has frame/time/protocol evidence, and the quality reason agrees with freshness and parser status. `scannedFrames` must remain at or below 15: the implementation resolves identity once, queries the three supported protocol tables concurrently with five rows each, and never runs a pagination count. The browser must display source, compact device time and quality without deriving labels, units or categories from RAW keys. The bounded synthetic gate is `go test ./internal/platform -run 'TestBuildLatestTelemetryResponse|TestLatestTelemetryQuality|TestLatestTelemetryBoundsProtocolReads' -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkLatestTelemetryHundredFrames -benchmem`.

Alert evaluation is intentionally not exposed as an HTTP route because it mutates candidate/event state. Production evaluation is owned by the evaluator unit. Confirm its `rules/vehicles/candidates_advanced/duplicate_observations/late_observations/stale_evidence_skipped/opened/recovered` log line and verify the resulting event through the read APIs. A release gate with an active scoped rule must survive at least one persisted candidate reload and one persisted last-trigger reload; MySQL `DATETIME(3)` values are scanned directly as times so millisecond values are never forced through an integer `UNIX_TIMESTAMP` conversion.

The Kafka stream evaluator accepts only explicit `shadow` and `active` modes. A new group starts at the latest retained offset, then resumes committed offsets. Each batch validates the canonical `FIELDS` envelope, protocol/topic pairing, field namespace, identity and event/receive time. In active mode it locks enabled dynamic rules, reads only state belonging to the current batch's rules/VINs/protocols, applies candidate/event/action/notification changes, and updates the MySQL checkpoint in one transaction; Kafka is committed only afterward. Replayed offsets below the database checkpoint are skipped. Evidence beyond the configured receive/event lateness window can still drive `data_delay_sec`, but cannot open or recover speed/SOC/alarm rules. `/api/ops/health.alertStream` and the `Alert Kafka stream` link expose mode, partitions, checkpoint lag, processed/valid/invalid/late/replay counts, last invalid code/time and last update. A recent `missing_vin_<protocol>` warning means a real terminal is still reporting without an authoritative VIN; resolve it through access/identity operations instead of fabricating a binding. Historical invalid totals remain audit evidence but stop holding the link in warning five minutes after the last invalid message. Release requires lag below the configured gate, no unexplained invalid messages and an active-rule canary with exactly-once event evidence.

## Alert Candidate Write Benchmark

`alert-benchmark` measures the evaluator's seven-column candidate batch shape against the configured MySQL/RDS connection. It never writes `vehicle_alert_candidate` itself and only accepts two compiled-in benchmark table names.

- The default `temporary` mode creates a connection-scoped temporary table. It is the lowest-risk network, SQL and index-path check.
- `durable` mode requires `--confirm-durable-write`, takes a zero-wait MySQL advisory lock, creates a dedicated physical `vehicle_alert_candidate_benchmark_durable` table with `LIKE vehicle_alert_candidate`, commits one transaction, verifies the row count, drops the table and verifies its removal through `information_schema`. A stale table from an interrupted prior run is removed only after the lock is held.
- Both modes are bounded to 100,000 rows and 1,000 rows per insert. The process uses an independent cleanup timeout so normal error returns still clean up the benchmark table.

```bash
export MYSQL_DSN="$(sed -n 's/^MYSQL_DSN=//p' /opt/lingniu-go-native/env/base.env | tail -1)"
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode temporary --rows 10000 --batch-size 500
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode durable --confirm-durable-write --rows 10000 --batch-size 500
/opt/lingniu-vehicle-platform/current/alert-benchmark --mode durable --confirm-durable-write --rows 100000 --batch-size 500
unset MYSQL_DSN
```

ECS baseline on 2026-07-14 against production RDS MySQL 8.0.36. Every row count matched and every dedicated table cleanup was verified:

| Mode | Rows | Batch | Transactions | Duration | Throughput | Observed redo delta | Cleanup |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| temporary | 10,000 | 500 | 1 | 192ms | 51,883 rows/s | n/a | verified |
| temporary | 100,000 | 500 | 1 | 1,765ms | 56,646 rows/s | n/a | verified |
| durable | 10,000 | 500 | 1 | 195ms | 51,234 rows/s | 3,121,664 bytes | 3ms, verified |
| durable | 100,000 | 500 | 1 | 1,971ms | 50,713 rows/s | 31,037,440 bytes | 3ms, verified |

`globalStatusDelta` is sampled from MySQL instance-global counters, including `Innodb_os_log_written`, `Binlog_cache_use`, `Binlog_cache_disk_use` and `Com_commit`. These values prove redo/binlog machinery was active around the durable run, but they are shared with concurrent RDS traffic and are not an exact per-benchmark attribution. The durable result closes the committed physical-table throughput gap without polluting business data; it still does not replace long-running evaluator observation with active rules, lock-contention tests, RDS Performance Insights, or binlog/redo monitoring over a representative peak window.

## Rollback

Keep the previous release directory until post-release monitoring is complete. Database changes are forward-compatible and are not rolled back. If API or browser checks fail, atomically restore the old symlink and restart both units:

```bash
PREVIOUS_RELEASE=/opt/lingniu-vehicle-platform/releases/<previous-release>
sudo ln -sfn "$PREVIOUS_RELEASE" /opt/lingniu-vehicle-platform/current
sudo systemctl restart lingniu-vehicle-platform
sudo systemctl stop lingniu-vehicle-alert-evaluator
sudo systemctl status --no-pager lingniu-vehicle-platform
```

Do not roll back the symlink if the old binary cannot tolerate the forward schema; validate this before every migration. The current `001`–`011` migrations are forward-only additions of tables, columns and indexes; the old realtime writer ignores the nullable/defaulted `008` columns and the prior API does not select them. Migration `003` once stopped after its first two MySQL `ALTER` statements because `last_value` was a reserved identifier; the final immutable migration uses `observed_value`, and the journaled runner safely resumed only the duplicate schema objects before creating the missing state table. Do not edit an applied migration—add the next numbered migration for future schema changes.
