Skip to content

Narrow a VIN decode to one trim, and say how confident it is - #315

Open
zer0stars wants to merge 15 commits into
r2-device-definitionsfrom
resolved-decode
Open

Narrow a VIN decode to one trim, and say how confident it is#315
zer0stars wants to merge 15 commits into
r2-device-definitionsfrom
resolved-decode

Conversation

@zer0stars

Copy link
Copy Markdown
Member

Stacked on #314. Retarget to main once that merges.

Why

toyota_camry_2020 in production declares powertrain_type: ICE while carrying
the hybrid's fuel economy, tank size and OEM code — one flat record standing
for several real cars. A sibling pipeline now emits that model-year as ten
trims
: three HEV at 13.2 gal against seven ICE at 16.

This makes a VIN decode narrow a template to one trim and say how confident
it was.

What changes

  • The catalog reads t/<id>.json — the template shape — with no fallback to
    definitions/<id>.json. A missing template means the import hasn't run, and
    that must fail loudly rather than silently serving the pre-migration record.
  • A pure matcher narrows trims by manufacturerCode, styleName or a
    VIN pattern. Several matches is ambiguous, not "take the first" — it
    emits only the attributes every candidate agrees on and names the candidates.
    Nothing matching is model-only, which carries no trim's values at all.
  • DecodeVinResponse gains trim, template_version, match_quality,
    match_candidates, match_by, hardware_template_id — fields 12–17,
    additive only, nothing renumbered.
  • A quality/source-labelled counter, so how often decodes land on
    model-only is measurable rather than assumed.

Bugs caught in review

  • A catalog outage reported as "vehicle not found." One undifferentiated
    error covered a real 404 and a 500 alike. The plausible response to a spurious
    404 is to create the definition — so decode_vin wrote a duplicate
    definition on every decode during an outage
    , on the hot path. Now a typed
    sentinel, checked by identity.
  • Trim matching was structurally unreachable. The extraction keys trims on
    manufacturerCode; buildFromDrivly dropped that field during
    normalisation. No trim could ever match — every decode would have answered
    model-only while looking healthy. Each half was correct alone; the gap was
    only visible where they met.
  • The cached path answered differently from the fresh one. Handle returns
    early for any VIN already in vin_numbers — most production traffic — and
    that path never ran the matcher. The same VIN resolved Hybrid LE / HEV / exact once, then an unqualified guess forever after, with match_quality
    emitting empty.

Merge-ready, not deploy-ready

docs/superpowers/2026-08-28-dd-api-followups.md records the gate: Create()
still PUTs /definitions/<id> and manifest() still GETs /manifest.json,
and the new worker serves neither. Every catalog miss and every search sync
will fail — loudly, not corruptingly — until those migrate. Nothing here
worsens it.

🤖 Generated with Claude Code

https://claude.ai/code/session_016knorHPbUjvAv85qppPwx7

A VIN decode narrows a multi-trim template to one trim and reports how
confident the match is, instead of asserting one flat record for every
configuration. Adds template-shaped catalog reads, a pure trim matcher, and
additive proto fields for trim, template version and match quality.
GetDefinitionByID/GetDefinitionByIDFresh become GetTemplateByID and
GetTemplateByIDFresh, reading t/<id>.json instead of definitions/<id>.json.
No fallback to the old path: a 404 now fails loudly rather than being
served as the pre-migration flat record. Attribute values decode as
map[string]any so typed values (fuel_tank_capacity_gal: 15.8) stay typed
instead of getting stringified.

Updates every caller of the renamed methods to consume the new Template
shape directly, without shimming it back into the old model.
bulk-update-powertrain read templates (typed attributes) then wrote them
back through Update(), which stringified everything and hit the legacy
definitions/<id> route -- undoing the point of the template migration and
mixing the new no-fallback read path with the old flat write path in one
runnable subcommand. Powertrain is now a per-trim value derived during
extraction anyway, so a flat bulk override no longer matches the model.

Deleted the subcommand, its main.go registration, Update() (its only
caller), and DeviceDefinitionUpdateInput (Update()'s only consumer).
fetchDoc/fetchDocFresh/fetchDocFrom/catalogDoc and workerPutBody stay:
they're still live behind GetDeviceDefinitionByID/GetDefinition (add_vin),
Create (decode_vin, create_dd), and the manifest reader.
GetTemplateByID/GetTemplateByIDFresh returned one generic error for both a
genuine 404 and any other catalog failure, so get_ds_by_id.go and
upsert_vin_decode.go both collapsed the two into NotFoundError. A catalog
outage then looked exactly like a missing vehicle, and the plausible
response to that spurious 404 -- creating the definition -- would write a
duplicate for a vehicle that already exists, at the worst possible moment.

Add ErrTemplateNotFound, a typed sentinel returned only for a literal 404;
every other failure (bad status, transport error, decode failure) stays a
distinct error. Both callers now branch on errors.Is(err, ErrTemplateNotFound)
rather than treating any error as not-found, with a test on each side.

Also: make TestTemplateHasNoLegacyFields actually exercise the Template
type instead of asserting on its own fixture, and add a no-fallback test
for the Fresh/worker-backed read path, which previously only had the CDN
path under direct test.
…tage

decode_vin.go's main Handle() flow treated any GetTemplateByID error --
outage, timeout, 500, decode failure, not just a genuine 404 -- as "this
definition doesn't exist" and fell through to Create() with a nil
template. On the VIN-decode hot path, at decode volume, a catalog outage
meant every decode for an already-existing vehicle attempted to write a
duplicate definition.

Only errors.Is(err, gateways.ErrTemplateNotFound) (or a nil error with a
nil template) is now allowed to fall through to the create-if-missing
path; any other error aborts the decode and returns before any writes.
Checked the other two GetTemplateByID call sites in this file
(hydrateResponseFromVinNumber, vinInfoFromKnown) -- neither calls Create
or any other write on failure, so neither carries this risk.

Added a test proving Create() is never called when the catalog fails for
a non-not-found reason, and confirmed it fails against the pre-fix code
before restoring the fix.
Extraction emits only manufacturerCode selectors, and decode_vin could
only ever supply styleName, so no trim could match and match_quality
was permanently model-only. Drivly is the one provider that carries a
manufacturer code (DrivlyVINResponse.ManufacturerCode); it was being
dropped during normalization to VINDecodingInfoData. Carry it through
and forward it into MatchSignals so drivly-decoded VINs -- the
population the templates were built from -- can actually resolve.
The plan's riskiest assumption is that manufacturerCode reaches a decode as
a usable signal at all: trim selectors are keyed on it, only drivly supplies
it, and the plan says Task 3 "must report how often it happens on real
decodes". Nothing did. The share of decodes landing on model-only, and which
providers they come from, was inferable only by reading one decode response
at a time.
MatchTrim resolves both and nothing could read either. match.by ("which
selectors fired") is in the resolved contract and was computed and dropped.
hardwareTemplateId is the template-default-with-trim-override resolution the
contract requires be settled server-side "so callers never reimplement the
fallback" -- it has two rulings and three tests behind it, and its only
observable effect was inside those tests.

Also documents what an empty match_quality means. The three values all assert
the matcher ran; empty means it could not run, which is distinct from
model-only and was previously undocumented.

Additive only: fields 16 and 17, nothing renumbered or removed. Regenerated
with the Makefile's pinned protoc-gen-go v1.30.0.
Every VIN is answered from vin_numbers from its second decode onward, so
hydrateResponseFromVinNumber -- not the freshly-decoded path -- is where most
production traffic lands. It never ran the matcher. trim, template_version,
match_quality and match_candidates were all left unset, emitting an empty
match_quality that the response contract does not define, and powertrain was
read from template-level attributes alone.

That last part is the old bug wearing a new hat. A template only carries
powertrain_type at the top level when every trim agrees on it; on exactly the
multi-trim templates this migration exists for, it lives on the trims. The
lookup therefore missed and fell through to a make/model heuristic, so the
same VIN returned "Hybrid LE / HEV / exact" on its first decode and a
heuristic guess with no stated confidence on every decode after.

The signals are rebuilt from what was already persisted: drivly_data is the
marshalled DrivlyVINResponse the live path read manufacturerCode from, and
the style row holds the name processDeviceStyle wrote. No heuristic fallback,
matching the live path -- the two paths disagreeing is the defect being fixed,
not a behaviour to keep on one side of it.

Proven red by reverting: the exact-match test fails on the old code with
"Unexpected call to GetManufacturerNameByID", direct evidence that the cached
path reached for the make/model heuristic instead of the trim.

Also plumbs match_by and hardware_template_id on the freshly-decoded path,
and threads the request context into the cached path instead of
context.Background().
A trim declaring both manufacturerCode and styleName matched only when both
agreed, but that was established by reading the code -- and reading the code
is what has repeatedly missed defects in this migration. Were it ever to
degrade to an OR, a VIN with the right style name and the wrong OEM code
would silently resolve to a trim it is not.

Covers both halves matching, each half alone, and a declared selector whose
signal is absent entirely.
GET /device-definitions/{id} now returns a vehicle template -- typed
attributes and a trims array -- where it returned the flat tableland model
with metadata.device_attributes. Its swagger annotation still advertised the
old shape, so the one place a consumer would look to discover the break said
nothing had changed. docs/swagger.json is generated and still stale; that is
recorded as a follow-up.

GetTemplateByIDFresh's comment describes read-modify-write callers that no
longer exist: its only caller was the bulk powertrain tool, deleted with the
legacy Update() path earlier on this branch. Say plainly that it has no
production caller and name the migration expected to adopt it.
…ploy

Headline is the worker-route blocker: the new worker serves /t/:id and four
/admin routes and 404s everything else, while dd-api's Create, Delete and
manifest reads still speak /definitions/<id> and /manifest.json. Nothing here
breaks because nothing here deploys, but dd-api cannot run against the new
worker until those migrate.

Also records the pre-existing panic in bulk_validate_vin's dead code, the
attributes GetDeviceStyleByID now omits for multi-trim templates, the stale
generated swagger, and the deferred minors the ledger flagged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant