Put the Model in the Narrator's Seat
How I rebuilt a CRLO maintenance feature so deterministic code owns facts, confidence, and reminder creation while the model explains bounded signals.
projects · architecture · ai · mobile · convex · reliability
A polished explanation can make a weak conclusion feel trustworthy. That is a bad failure mode for a vehicle app.
I ran into this while rebuilding the maintenance feature in CRLO, the shared vehicle logbook I am developing with Expo and Convex. An earlier version reduced the vehicle’s history to a numeric AI health score. The number looked precise, but a logbook cannot diagnose a vehicle. It only knows what people recorded.
The replacement uses a different boundary:
Deterministic code decides the facts, confidence, and possible actions. The model explains those results in plain language.
That sounds restrictive. It is. The restriction is what makes the model useful without letting fluent text quietly become product authority.
False precision was the first problem
A score such as 82 out of 100 implies a scale, a calibration method, and enough evidence to distinguish 82 from 76. CRLO’s saved records do not support that claim. They can establish narrower facts:
- an insurance date is approaching,
- an active reminder already covers an item,
- three consistent service records suggest a recurring mileage interval,
- there are too few reliable records to estimate mileage use.
Those facts are useful. Compressing them into one number makes them harder to inspect and easier to over-trust.
CRLO now returns categorical states such as onTrack, attentionSoon, and
actionDue. Confidence is separate. It can be high, medium, low, or
insufficientData.
That last state matters most. When the record history is sparse or contains a decreasing odometer sequence, the feature says that the data is insufficient. It also explains what is missing, such as two dated odometer readings over at least 14 days or a consistent service history. Refusing to calculate is part of the feature, not an error screen.
Calculate before calling the model
The backend builds the maintenance outlook before it makes an AI request. It reads saved mileage, service, reminder, and vehicle milestone data, then calculates a bounded set of signals.
Each signal already has the fields the product relies on:
| Field | Owner | Example purpose |
|---|---|---|
window | Domain code | Action now, next three months, or later |
dueWindow | Domain code | The date or mileage target shown in the UI |
confidence | Domain code | How much the saved history supports the signal |
evidence | Domain code | The records behind the suggestion |
dedupeKey | Domain code | Stable identity across regeneration |
explanation | Model | Optional plain-language narration |
The model receives those completed signals. It does not receive permission to
create dates, change severity, raise confidence, or add a maintenance item. Its
structured response contains a short summary and optional explanations keyed by
the supplied dedupeKey values.
CRLO then filters the returned explanations against the original allowlist. An unknown key is discarded. More importantly, the application persists the original computed date, evidence, confidence, and action fields. The model’s text cannot replace them.
Structured Outputs helps enforce the response shape. It does not prove the explanation is true. The useful control comes from combining a narrow schema with deterministic inputs, an allowlist, and server-side ownership of every consequential field.
Provenance belongs in the interface
An architectural boundary is weak if the interface hides it.
The CRLO card labels the feature as a maintenance outlook based on the vehicle’s logbook. It shows the outlook state, data confidence, and the number of records reviewed. Each suggested item includes its due window, reason, evidence, and confidence. The screen also states that the outlook is not a mechanical diagnosis.
This changes how a person can challenge the output. Instead of asking, “Why is my vehicle at 82?” they can see that an insurance date came from a saved vehicle field or that a mileage suggestion came from three consistent service records. If the source record is wrong, there is a specific thing to correct.
The generated explanation is secondary. CRLO falls back to the deterministic basis when the model omits an item explanation. That fallback is a good test of the design: an optional sentence cannot become the only account of why an item exists.
Keep mutations outside the narration path
The model does not create reminders. It cannot call a reminder mutation or smuggle a new candidate into its response.
The UI presents the server-generated suggestions as selectable items. A user chooses which ones to approve. Only then does a separate authenticated mutation attempt to create reminders.
Approval is still not a blind insert. The mutation reads the current active reminders again. If an equivalent reminder appeared after the outlook was generated, the operation returns that existing reminder instead of adding another one. It also reports stale suggestion identifiers and preserves resolved states such as dismissed or handled.
This is where the product boundary becomes concrete:
- Domain code proposes a bounded candidate.
- The model may explain it.
- The user selects it.
- The server rechecks current state.
- The server performs or skips the write.
The sequence costs more code than asking a model to return a ready-to-save plan. It also makes retries, stale screens, and regenerated outlooks much less surprising.
Regeneration needs memory, not amnesia
Vehicle records change often. A new service entry or odometer value should refresh the outlook, but every write does not need to start another immediate generation job.
CRLO increments a data version and schedules a refresh after a short delay. If another relevant change arrives while that refresh is already pending, it updates the version instead of scheduling another job. The result is a small debounce around source-data churn.
When a refreshed outlook is saved, CRLO reconciles each signal by its stable
key. A previously dismissed item stays dismissed. An item already represented
by an active reminder becomes alreadyScheduled. A stale approval request is
reported as stale rather than guessed back into existence.
This is less visible than the AI summary, but it is the harder product work. Trust disappears quickly when an assistant revives dismissed advice or creates the same reminder twice.
What the implementation does not prove
The source and tests support the boundary described here. They cover insufficient-data cases, recurring-service rules, equivalent reminders, regenerated suggestions, stale submissions, and repeat approval behavior.
They do not prove diagnostic accuracy because the feature is not a diagnostic system. They do not prove safety validation, production outcomes, or physical device behavior. A structured response also does not make generated prose factually reliable by itself.
Those limits are intentional. CRLO’s maintenance outlook is a way to explain saved logbook evidence and help a person create reminders. It should never pretend to replace an inspection or a mechanic.
Give the model the job it is good at
Language models are good at turning structured facts into readable language. They are a poor place to hide business rules, confidence thresholds, identity, and side effects.
The reusable pattern is simple. Compute first. Pass only bounded facts. Validate the returned shape and identifiers. Keep provenance visible. Require explicit approval for writes, then recheck current state before performing them.
Put the model in the narrator’s seat. Keep the steering wheel in code.