From 98c5b86bce5daf61db859ef510af274da7cd5028 Mon Sep 17 00:00:00 2001 From: James Reategui Date: Thu, 9 Jul 2026 17:19:50 -0400 Subject: [PATCH] feat(api): backfill vehicles.vin from the DIMO VIN VC (pull-once) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vehicles.vin was only ever filled from uploaded registration documents, so it stayed NULL for most of the fleet. Almost every vehicle carries a DIMO VIN verifiable credential, readable via telemetry-api's vinVCLatest { vin } with privilege 5 — which our vehicle-JWT exchange already requests. - TelemetryAPIService.VINFromVC: one vinVCLatest query per vehicle through the existing per-vehicle-JWT GQL path; null VC / blank vin = found=false. - LicensePlateSyncService: VC fallback after the registration-document pass whenever the VIN is still unknown, and a lean SyncVINOnly that skips the fetch-api pull entirely. Fill-if-missing — never overwrites; VIN is immutable, so the vin IS NULL gate makes every path pull-once (bounds DCX cost to a one-time backfill plus one read per new vehicle). - import-group-attestations -vin-only: one-shot backfill restricted to vehicles with no VIN, dry-run-able; the normal cron pass now converges new vehicles via the fallback. No migration, no config, no frontend changes (/vehicles already returns vin). Plan: docs/VIN_SYNC_PLAN.md. Live-verified on mainnet: dry-run resolves the VC VIN, live run caches it, re-run checks 0 vehicles. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UxoaxTXSLqUwkYfewdwGBM --- .../import_group_attestations.go | 45 +++++-- api/internal/app/app.go | 2 +- api/internal/service/license_plate_sync.go | 84 +++++++++++- api/internal/service/telemetry_api.go | 45 +++++++ api/internal/service/telemetry_api_test.go | 58 ++++++++ docs/VIN_SYNC_PLAN.md | 126 ++++++++++++++++++ 6 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 api/internal/service/telemetry_api_test.go create mode 100644 docs/VIN_SYNC_PLAN.md diff --git a/api/cmd/fleet-lite-app/import_group_attestations.go b/api/cmd/fleet-lite-app/import_group_attestations.go index 0b19789..424e798 100644 --- a/api/cmd/fleet-lite-app/import_group_attestations.go +++ b/api/cmd/fleet-lite-app/import_group_attestations.go @@ -10,6 +10,7 @@ import ( "github.com/DIMO-Network/fleet-lite-app/internal/gateway" "github.com/DIMO-Network/fleet-lite-app/internal/service" "github.com/DIMO-Network/shared/pkg/db" + "github.com/aarondl/sqlboiler/v4/queries/qm" "github.com/ethereum/go-ethereum/common" "github.com/google/subcommands" _ "github.com/lib/pq" @@ -36,6 +37,7 @@ type importGroupAttestationsCmd struct { dryRun bool warmOnly bool warmDays int + vinOnly bool } func (*importGroupAttestationsCmd) Name() string { return "import-group-attestations" } @@ -43,13 +45,15 @@ func (*importGroupAttestationsCmd) Synopsis() string { return "pull vehicle group attestations from fetch-api and merge them into the DB (additive, de-duplicated)" } func (*importGroupAttestationsCmd) Usage() string { - return `import-group-attestations [-tenant-id ID] [-token-id N] [-warm-only] [-warm-days N] [-dry-run]: + return `import-group-attestations [-tenant-id ID] [-token-id N] [-warm-only] [-warm-days N] [-vin-only] [-dry-run]: Pulls dimo.document.vehicle.groups attestations per vehicle (latest per producer) and adds any group membership not already present in vehicle_fleet_groups. Additive only — never removes; de-duplicated by primary key. Unknown groups are auto-created. -warm-only limits the run to tenants with - a member login within -warm-days days (default 7). -dry-run logs changes - without writing. + a member login within -warm-days days (default 7). -vin-only skips the group + and registration-document sync and only backfills vehicles.vin from the DIMO + VIN VC, restricted to vehicles with no VIN yet (see docs/VIN_SYNC_PLAN.md). + -dry-run logs changes without writing. ` } @@ -59,6 +63,7 @@ func (p *importGroupAttestationsCmd) SetFlags(f *flag.FlagSet) { f.BoolVar(&p.dryRun, "dry-run", false, "log what would change without writing") f.BoolVar(&p.warmOnly, "warm-only", false, "only tenants with a recent member login (see -warm-days)") f.IntVar(&p.warmDays, "warm-days", 7, "login-recency window (days) that makes a tenant warm") + f.BoolVar(&p.vinOnly, "vin-only", false, "only backfill vehicles.vin from the DIMO VIN VC (vehicles without a VIN)") } func (p *importGroupAttestationsCmd) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { @@ -70,7 +75,8 @@ func (p *importGroupAttestationsCmd) Execute(ctx context.Context, _ *flag.FlagSe authProvider := gateway.NewDimoAuthProvider(p.logger, &p.settings) fetchAPI := gateway.NewFetchAPI(p.logger, &p.settings, authProvider) groupSync := service.NewGroupSyncService(&p.logger, &p.pdb, fetchAPI, authProvider) - plateSync := service.NewLicensePlateSyncService(&p.logger, &p.pdb, fetchAPI, authProvider) + telemetryAPI := service.NewTelemetryAPIService(p.logger, &p.settings, authProvider) + plateSync := service.NewLicensePlateSyncService(&p.logger, &p.pdb, fetchAPI, authProvider, telemetryAPI) // Resolve tenants to process. var dbTenants dbmodels.TenantSlice @@ -111,16 +117,17 @@ func (p *importGroupAttestationsCmd) Execute(ctx context.Context, _ *flag.FlagSe continue } - // Vehicles for this tenant (optionally one token id). - var vehicles dbmodels.VehicleSlice + // Vehicles for this tenant (optionally one token id). The vin-only + // backfill restricts to vehicles with no VIN yet — pull-once: a filled + // vehicle never costs another telemetry query. + mods := []qm.QueryMod{dbmodels.VehicleWhere.TenantID.EQ(dt.ID)} if p.tokenID != 0 { - vehicles, err = dbmodels.Vehicles( - dbmodels.VehicleWhere.TenantID.EQ(dt.ID), - dbmodels.VehicleWhere.TokenID.EQ(p.tokenID), - ).All(ctx, p.pdb.DBS().Reader) - } else { - vehicles, err = dbmodels.Vehicles(dbmodels.VehicleWhere.TenantID.EQ(dt.ID)).All(ctx, p.pdb.DBS().Reader) + mods = append(mods, dbmodels.VehicleWhere.TokenID.EQ(p.tokenID)) } + if p.vinOnly { + mods = append(mods, qm.Where("(vin IS NULL OR vin = '')")) + } + vehicles, err := dbmodels.Vehicles(mods...).All(ctx, p.pdb.DBS().Reader) if err != nil { p.logger.Err(err).Str("tenant_id", dt.ID).Msg("list vehicles, skipping tenant") continue @@ -128,6 +135,20 @@ func (p *importGroupAttestationsCmd) Execute(ctx context.Context, _ *flag.FlagSe for _, v := range vehicles { checked++ + + // vin-only: just the VIN VC read, no group or registration-doc pull. + if p.vinOnly { + pres, perr := plateSync.SyncVINOnly(ctx, *tenant, v.TokenID, p.dryRun) + if perr != nil { + p.logger.Debug().Err(perr).Int64("token_id", v.TokenID).Msg("vin vc sync, skipping") + } else if pres.VINChanged { + vinsChanged++ + p.logger.Info().Str("tenant_id", dt.ID).Int64("token_id", v.TokenID). + Str("vin", pres.VIN).Bool("dry_run", p.dryRun).Msg("cached vin from vc") + } + continue + } + res, serr := groupSync.SyncVehicle(ctx, *tenant, v.TokenID, service.SyncOpts{DryRun: p.dryRun}) if serr != nil { p.logger.Debug().Err(serr).Int64("token_id", v.TokenID).Msg("sync vehicle, skipping") diff --git a/api/internal/app/app.go b/api/internal/app/app.go index ef72391..0a9b704 100644 --- a/api/internal/app/app.go +++ b/api/internal/app/app.go @@ -180,7 +180,7 @@ func App( tenantApp.Get("/telemetry/:tokenID/replay", telemetryCtrl.GetTripReplay) // Glovebox / documents - plateSvc := service.NewLicensePlateSyncService(logger, pdb, fetchAPI, authProvider) + plateSvc := service.NewLicensePlateSyncService(logger, pdb, fetchAPI, authProvider, telemetryAPI) documentsCtrl := controllers.NewDocumentsController( logger, settings, vehicleSvc, authProvider, extractAPI, attestSvc, fetchAPI, plateSvc, ) diff --git a/api/internal/service/license_plate_sync.go b/api/internal/service/license_plate_sync.go index a6809f4..318a888 100644 --- a/api/internal/service/license_plate_sync.go +++ b/api/internal/service/license_plate_sync.go @@ -51,10 +51,11 @@ type LicensePlateSyncService struct { pdb *db.Store fetchAPI *gateway.FetchAPI authProvider *gateway.DimoAuthProvider + telemetry TelemetryAPIService } -func NewLicensePlateSyncService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider) *LicensePlateSyncService { - return &LicensePlateSyncService{logger: logger, pdb: pdb, fetchAPI: fetchAPI, authProvider: authProvider} +func NewLicensePlateSyncService(logger *zerolog.Logger, pdb *db.Store, fetchAPI *gateway.FetchAPI, authProvider *gateway.DimoAuthProvider, telemetry TelemetryAPIService) *LicensePlateSyncService { + return &LicensePlateSyncService{logger: logger, pdb: pdb, fetchAPI: fetchAPI, authProvider: authProvider, telemetry: telemetry} } // PlateSyncResult reports what a SyncVehicle call did across the registration @@ -64,6 +65,7 @@ type PlateSyncResult struct { Plate string // the resolved plate (empty when none found) VINChanged bool // the cached vin was updated VIN string // the resolved vin (empty when none found) + VINSource string // where a newly-cached vin came from: "registration" | "vc" } // SyncVehicle pulls one vehicle's registration attestations and caches the latest @@ -109,6 +111,22 @@ func (s *LicensePlateSyncService) SyncVehicle(ctx context.Context, tenant models updates["vin"] = null.StringFrom(vin) res.VINChanged = true res.VIN = vin + res.VINSource = "registration" + } + + // VIN VC fallback: most vehicles never get a registration document + // uploaded, but almost all carry a DIMO VIN verifiable credential — read + // it when the VIN is still unknown after the document pass. Pull-once by + // construction: once vehicles.vin is set, res.VIN is non-empty here and + // the vehicle never costs another telemetry query. See + // docs/VIN_SYNC_PLAN.md. + if res.VIN == "" { + if vin, found := s.vinFromVC(tenant, tokenID); found { + updates["vin"] = null.StringFrom(vin) + res.VINChanged = true + res.VIN = vin + res.VINSource = "vc" + } } if len(updates) == 0 { @@ -131,6 +149,68 @@ func (s *LicensePlateSyncService) SyncVehicle(ctx context.Context, tenant models return res, nil } +// vinFromVC wraps TelemetryAPIService.VINFromVC with this service's skip- +// quietly posture: no SACD, no VC, or a transient error all come back as +// found=false and are retried on a future pass (only while vin IS NULL). +func (s *LicensePlateSyncService) vinFromVC(tenant models.Tenant, tokenID int64) (string, bool) { + if s.telemetry == nil { + return "", false + } + vin, found, err := s.telemetry.VINFromVC(tenant, uint64(tokenID)) + if err != nil { + s.logger.Debug().Err(err).Str("tenant_id", tenant.ID).Int64("token_id", tokenID). + Msg("vin vc read failed, skipping") + return "", false + } + return vin, found +} + +// SyncVINOnly fills vehicles.vin from the DIMO VIN VC for one vehicle, +// skipping the fetch-api registration pull — the lean path for the cron's +// -vin-only backfill. No-op when the vehicle already has a VIN (pull-once). +// Same fill-if-missing and dry-run semantics as SyncVehicle. +func (s *LicensePlateSyncService) SyncVINOnly(ctx context.Context, tenant models.Tenant, tokenID int64, dryRun bool) (PlateSyncResult, error) { + v, err := dbmodels.Vehicles( + dbmodels.VehicleWhere.TenantID.EQ(tenant.ID), + dbmodels.VehicleWhere.TokenID.EQ(tokenID), + ).One(ctx, s.pdb.DBS().Reader) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return PlateSyncResult{}, ErrVehicleNotFound + } + return PlateSyncResult{}, fmt.Errorf("load vehicle: %w", err) + } + + res := PlateSyncResult{Plate: v.LicensePlate.String, VIN: v.Vin.String} + if v.Vin.String != "" { + return res, nil + } + vin, found := s.vinFromVC(tenant, tokenID) + if !found { + return res, nil + } + res.VINChanged = true + res.VIN = vin + res.VINSource = "vc" + + if dryRun { + s.logger.Info().Str("tenant_id", tenant.ID).Int64("token_id", tokenID). + Str("vin", vin).Msg("would cache vin from vc") + return res, nil + } + + if _, err := dbmodels.Vehicles( + dbmodels.VehicleWhere.TenantID.EQ(tenant.ID), + dbmodels.VehicleWhere.TokenID.EQ(tokenID), + ).UpdateAll(ctx, s.pdb.DBS().Writer, dbmodels.M{ + "vin": null.StringFrom(vin), + "updated_at": time.Now(), + }); err != nil { + return PlateSyncResult{}, fmt.Errorf("update vin: %w", err) + } + return res, nil +} + // latestRegistrationField returns the value of the first matching name in // names from the most-recent (by CE time) registration document that carries // a non-empty one, and whether such a document was found. Used for both diff --git a/api/internal/service/telemetry_api.go b/api/internal/service/telemetry_api.go index a0a31dc..9fcfa27 100644 --- a/api/internal/service/telemetry_api.go +++ b/api/internal/service/telemetry_api.go @@ -132,6 +132,11 @@ type TelemetryAPIService interface { // JWT exchange fails are returned in NoPermissions. Results are cached // per tenant for fleetLocationsCacheTTL; force bypasses the cache. FleetLocations(ctx context.Context, tenant models.Tenant, tokenIDs []uint64, force bool) (FleetLocationsResult, error) + // VINFromVC reads the vehicle's VIN off its latest DIMO VIN verifiable + // credential (`vinVCLatest`). found=false with a nil error means the + // vehicle has no VC (or an empty vin) — not a failure. Requires privilege + // 5 (GetVINCredential), which the vehicle-JWT exchange already requests. + VINFromVC(tenant models.Tenant, tokenID uint64) (vin string, found bool, err error) } // SegmentPoint is one end of a detected trip: a GPS fix plus its timestamp. @@ -273,6 +278,46 @@ func (t *telemetryAPIService) LatestLocation(tenant models.Tenant, tokenID uint6 }, nil } +// VINFromVC queries `vinVCLatest(tokenId)` for the VIN carried on the +// vehicle's latest VIN verifiable credential. See the interface doc for the +// found/error contract; a "no VC" GraphQL error (should the API express +// absence that way) surfaces as err and is treated by callers as +// skip-and-retry-later, which is equally safe. Pull-once callers gate on +// vehicles.vin IS NULL so a filled vehicle never costs another query (DCX). +func (t *telemetryAPIService) VINFromVC(tenant models.Tenant, tokenID uint64) (string, bool, error) { + q := fmt.Sprintf("query { vinVCLatest(tokenId: %d) { vin } }", tokenID) + + raw, err := t.query(tenant, tokenID, q) + if err != nil { + return "", false, err + } + return parseVINVCResponse(raw) +} + +// parseVINVCResponse extracts the VIN from a `vinVCLatest { vin }` response +// body. A null vinVCLatest or blank vin is (found=false, nil) — no VC exists. +func parseVINVCResponse(raw []byte) (string, bool, error) { + var resp struct { + Data struct { + VinVCLatest *struct { + Vin string `json:"vin"` + } `json:"vinVCLatest"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + return "", false, fmt.Errorf("parse vinVCLatest: %w", err) + } + vc := resp.Data.VinVCLatest + if vc == nil { + return "", false, nil + } + vin := strings.TrimSpace(vc.Vin) + if vin == "" { + return "", false, nil + } + return vin, true, nil +} + // TimeSeries queries `signals(tokenId, from, to, interval)` for one signal // and returns its min/max/avg/last buckets. Interval is a duration string // the telemetry-api recognizes (e.g. "1d", "1h", "15m"). diff --git a/api/internal/service/telemetry_api_test.go b/api/internal/service/telemetry_api_test.go new file mode 100644 index 0000000..71b46ed --- /dev/null +++ b/api/internal/service/telemetry_api_test.go @@ -0,0 +1,58 @@ +package service + +import "testing" + +func TestParseVINVCResponse(t *testing.T) { + cases := []struct { + name string + raw string + wantVIN string + wantFound bool + wantErr bool + }{ + { + name: "vc present", + raw: `{"data":{"vinVCLatest":{"vin":"JTJGARBZ0M5023425"}}}`, + wantVIN: "JTJGARBZ0M5023425", + wantFound: true, + }, + { + name: "no vc (null)", + raw: `{"data":{"vinVCLatest":null}}`, + wantFound: false, + }, + { + name: "empty data object", + raw: `{"data":{}}`, + wantFound: false, + }, + { + name: "blank vin treated as absent", + raw: `{"data":{"vinVCLatest":{"vin":" "}}}`, + wantFound: false, + }, + { + name: "vin whitespace trimmed", + raw: `{"data":{"vinVCLatest":{"vin":" 1ABC123 "}}}`, + wantVIN: "1ABC123", + wantFound: true, + }, + { + name: "malformed body errors", + raw: `not json`, + wantErr: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + vin, found, err := parseVINVCResponse([]byte(c.raw)) + if (err != nil) != c.wantErr { + t.Fatalf("parseVINVCResponse err = %v, wantErr %v", err, c.wantErr) + } + if found != c.wantFound || vin != c.wantVIN { + t.Fatalf("parseVINVCResponse = (%q, %v), want (%q, %v)", vin, found, c.wantVIN, c.wantFound) + } + }) + } +} diff --git a/docs/VIN_SYNC_PLAN.md b/docs/VIN_SYNC_PLAN.md new file mode 100644 index 0000000..cb605dd --- /dev/null +++ b/docs/VIN_SYNC_PLAN.md @@ -0,0 +1,126 @@ +# VIN Sync via telemetry-api VIN VC — Plan (fleet-lite-app) + +Fill `vehicles.vin` for the whole fleet from the DIMO **VIN verifiable +credential**, read directly off telemetry-api — instead of hoping a +registration document gets uploaded. VIN is immutable, so this is a +**pull-once** design: the only gate is `vin IS NULL` — once set, a vehicle +never costs another query. + +Created: 2026-07-09 +Status: IMPLEMENTED (same day) — `VINFromVC` on the telemetry service, VC +fallback in `LicensePlateSyncService.SyncVehicle`, `-vin-only` backfill flag. +Live-verified on mainnet (token 191347): VC read → cached VIN, re-run skips +the filled vehicle (`checked: 0`). Open question resolved in practice: the +code handles both null-VC and GraphQL-error shapes (both → skip + retry). + +--- + +## Problem + +`vehicles.vin` is populated only by `LicensePlateSyncService` +(`api/internal/service/license_plate_sync.go`), which extracts a `vin` field +from `dimo.document.vehicle.registration` attestations — i.e. a VIN exists only +when someone uploaded a registration document to the glovebox for that vehicle. +Most vehicles have no such document, so the column stays NULL and the UI's VIN +row / list column / search-by-VIN mostly come up empty. Telemetry-api is never +consulted today. + +## The read path (confirmed 2026-07-09) + +Telemetry-api exposes the VIN directly on the VC type — no rawVC parsing: + +```graphql +vinVCLatest(tokenId: 190322) { + vin +} +``` + +- Auth: per-vehicle JWT with **privilege 5** (`privilege:GetVINCredential`). + Our `GetVehicleJWT` already exchanges for `[1, 3, 4, 5]` + (`gateway/dimo_auth_provider.go:101`) — **no permission changes needed**. +- **Almost all fleet vehicles already have a VIN VC** (confirmed by James), so + no generation step is needed. The rare no-VC vehicle simply stays NULL and is + re-checked on future passes (see Edge cases; attestation-api generation is a + deferred fallback we don't expect to need). +- Cost: telemetry queries bill DCX per query. The `vin IS NULL` gate bounds + total cost to a one-time backfill (~fleet size) plus one read per + newly-added vehicle. + +## What already exists (reuse — do not rebuild) + +| Capability | Location | Notes | +|---|---|---| +| Per-vehicle registration-fields sync | `service/license_plate_sync.go` `SyncVehicle` | **extend** — already runs per vehicle in the cron and after a glovebox attest (`controllers/documents.go:182`) | +| Vehicle JWT w/ priv 5 (cached, exp-aware) | `gateway/dimo_auth_provider.go` `GetVehicleJWT` | reuse as-is | +| Telemetry GraphQL client (per-vehicle JWT) | `service/telemetry_api.go` `doQuery` | add one small query method | +| Cron driver (daily warm / weekly full) | `cmd/fleet-lite-app/import_group_attestations.go` (plate sync at :146) | VIN step slots into the same per-vehicle loop | +| No-permission skip semantics | `FleetLocations` noPerm handling | JWT-exchange failure → skip quietly, never fatal | +| `vehicles.vin` column + `/vehicles` exposure + UI | migration `20260629120000`, `service/vehicle.go:138,167`, list/card/search | frontend needs **zero** changes | + +## Decisions + +1. **Read from telemetry-api only** — `vinVCLatest { vin }` through the + existing GQL gateway. No attestation-api client, no new config value. +2. **Fill-if-missing only.** Never overwrite a non-NULL `vin`, whichever source + set it (registration doc or VC). Avoids churn and any precedence question. +3. **Placement: extend `LicensePlateSyncService.SyncVehicle`** (it is already + the "registration fields" per-vehicle pass) — constructor gains the + telemetry service. +4. **Skip quietly** on JWT-exchange failure (no SACD) and transient errors; + log at debug/info. Same posture as locations. +5. **Backfill = same code path** via a CLI flag on the existing command (e.g. + `-vin-only`), dry-run-able, restricted to `vin IS NULL` vehicles, so the + current fleet converges in one run instead of over cron cycles. + +## Backend changes (`api/`) + +### B1. Telemetry — `service/telemetry_api.go` +- `VINFromVC(tenant, tokenID) (vin string, found bool, err error)`: + per-vehicle JWT via `GetVehicleJWT`, then + `query { vinVCLatest(tokenId: N) { vin } }` through `doQuery`. + Distinguish "no VC / empty vin" (found=false, nil err) from transport + errors. Add to the `TelemetryAPIService` interface + mock. + +### B2. Sync — `service/license_plate_sync.go` +After the registration-document pass, when the effective VIN is still empty: +`VINFromVC` → found → `updates["vin"]` via the existing write path (dry-run +respected). `PlateSyncResult` gains `VINSource` (`registration | vc | none`) +for logging. + +### B3. Cron/CLI — `cmd/fleet-lite-app/import_group_attestations.go` +- No structural change: the extended `SyncVehicle` runs where it runs today. +- Add `-vin-only` (Decision 5): per tenant, `SELECT ... WHERE vin IS NULL` → + run just the VIN step. Fast, cheap, one-shot. + +### B4. Nothing else +No migration (column exists), no config change, no frontend work +(`/vehicles` already returns `vin`; quick-view VIN row, list column, and +search already render it). + +## Rollout + +1. PR: B1–B3 + tests (mock telemetry client; testify). +2. Deploy, then one manual backfill run per env (`-dry-run` first, then live), + `kubectl create job --from=cronjob/...` per the README pattern. +3. Cron keeps new vehicles converging; no further action. + +## Edge cases / notes + +- **No-VC / no-integration vehicles**: `vinVCLatest` returns nothing or the + JWT exchange fails — skipped quietly, re-checked on future passes only while + `vin IS NULL`. Cost is one cheap query/exchange per pass for a handful of + vehicles; if that ever bothers us, add a `vin_checked_at` back-off column + (deferred — schema-free v1). Generating missing VCs via attestation-api + (`POST /v2/attestation/vin/:tokenId`, priv 5 — note that's + `attestation-api.dimo.zone`, a different service from `attest.dimo.zone`) + is a known fallback, deferred since almost all vehicles already have a VC. +- **Registration doc later disagrees with VC:** fill-if-missing means first + writer wins; a mismatch is surfaceable later if it ever matters (unlikely). +- **DCX budget:** one-time ~fleet-size reads + per-new-vehicle. No recurring + spend once converged. + +## Open questions / to verify + +1. `vinVCLatest` response when no VC exists — null vs GraphQL error — drives + the found=false detection in B1. (One curl against a known no-VC vehicle + settles it; low stakes since both shapes are easy to handle.)