Skip to content

Tell the routing dispatcher what the workflow dispatcher already hears - #12

Open
futurebuildai wants to merge 2 commits into
mainfrom
ai-lm/routing-depot-note
Open

futurebuildai wants to merge 2 commits into
mainfrom
ai-lm/routing-depot-note

Conversation

@futurebuildai

Copy link
Copy Markdown
Contributor

internal/depot produces an operator-facing sentence when a run's origin falls back — "branch
'Plano Yard' has no coordinates in GableLBM … rooted at the branch this run's orders ship from
('Dallas Yard') instead"
. workflow.Plan carried that to the client. routing.Plan logged it to
slog and dropped it, and had no field for it at all.

So the warning existed and never reached the API a dispatcher calls.

What changed

Two response-only fields on routing.Plan, json tags matching workflow.Plan exactly:

DepotSource string `json:"depot_source,omitempty"`
DepotNote   string `json:"depot_note,omitempty"`

Populated in Service.Plan from the same four return values the existing slog line consumes —
one call to resolveDepot, two consumers, so the log and the response cannot drift.

No migration. route_plans is column-backed, so these are populated on POST /routing/plan
and absent on GET /routing/plan/{id} and on POST /routing/plan/{id}/approve, both of which
read back from Postgres.

That asymmetry is deliberate and documented in three places, each naming what a migration would
buy. The load-bearing part: an empty note on the read path means "provenance was never stored",
not "no fallback happened"
— indistinguishable there, so nothing downstream may read it as an
all-clear. Re-resolving on read is explicitly prohibited: it would answer today's question about
yesterday's plan, since the branch may have been geocoded since.

Verification

Confirmed by execution, not by reading:

  • On the wire. Handler-level tests drive the real mux and read raw bytes off the recorder —
    depot_source and depot_note are present in the POST body.
  • GET is structurally empty, not stale or invented: Repository.Get scans a fresh Plan and
    its SELECT list has no depot columns.
  • Byte-identical to workflow for the same input — both driven with identical orders, branches
    and config, 6/6 matching on source and note.
  • Mutation-tested: commenting out the two struct-literal lines fails all four new tests.
  • A new columnBackedStore fake persists exactly Save's INSERT list and returns exactly Get's
    SELECT list — the existing fake returns the pointer it was handed and would have reported a note
    Postgres could never produce.

Gates: gofmt · go vet · go build · go test -race ./... -count=1 — green, including on a
trial merge with main @ c4b88a0 (the licensing seam).

One correction to the framing, from the review

The commit originally said the workflow dispatcher was told and the routing one was not. That is
true at the JSON payload level only. Grepping depot_note|depot_source across app/src/
returns zero hits — neither RoutePlan nor WorkflowPlan declares the fields, so no dispatcher
sees the sentence on either surface today.

This PR closes an API-payload asymmetry. The human-facing warning still reaches nobody, on
either surface, and that gap is pre-existing rather than routing-specific. Surfacing it in the UI is
a separate change and worth doing — the sentence is written for a person and currently read by none.

colton-vp-product and others added 2 commits August 31, 2026 15:39
internal/depot resolves where a run is rooted and, when the branch rung was
possible but declined, writes a sentence for an operator explaining why and
where the route went instead: "branch "Plano Yard" (...003) has no coordinates
in GableLBM — it has never been geocoded; rooted at the branch this run's
orders ship from ("Dallas Yard" (...001)) instead".

workflow.Plan carried that note to the client. routing.Plan did not — it had no
field for it at all; internal/routing/service.go logged it and dropped it. So a
dispatcher on the /plan surface was told their route had been rooted at a yard
their load does not leave from, and a dispatcher calling POST
/api/v1/routing/plan was not. The warning existed and never reached half the
people it was written for. That asymmetry was the defect.

route_plans is a column-backed table with no depot_source/depot_note column, and
adding one is a migration, so the fix is response-only: both values now ride the
routing.Plan struct out of Plan(), which puts them on the CREATE response at the
moment the dispatcher is looking at a fresh route and can still act on them.
Nothing is written to Postgres.

That leaves a real limitation — a later GET of the same plan carries neither —
and it is documented rather than left to be rediscovered. The load-bearing part
is that an empty note on the read path means "not stored", NOT "no fallback
happened", so nothing may read a read-path blank as an all-clear and nothing may
fill it in by re-resolving (which would answer today's question about an old
plan). Plan.DepotSource, Repository.Get and a new CLAUDE.md section all say so,
and each names what a migration would buy: the warning surviving a reload.

The slog line stays. It is now the durable copy of a response-only field, and a
test pins that it carries the response's sentence word for word.

Tests: the create response carries the note and the right source; a clean run
carries an empty note (a note is always a warning, never happy-path noise); and
a plan re-read through a store that persists only what has a column comes back
with neither field, asserted as the documented behaviour. The store fake models
route_plans' actual column list, because the existing fake hands back the very
pointer it was given and so could not show the loss. Existing routing and
workflow tests are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VS2YVQehtn78L2Cq49TSj3
None changes behaviour; each was a sentence that would mislead the next reader.

The byte-identity claim was too strong. Routing and workflow produce identical
notes for identical INPUT, both coming from internal/depot — but not for the
same 'run': routing accepts a request branch that workflow.IngestRequest has no
field for, so routing can emit a sentence workflow structurally cannot.

A third surface went undocumented. POST /routing/plan/{id}/approve also returns
a Plan and also carries neither field, for the same reason GET does not — it
reads back from Postgres. Naming only POST and GET implied approve was covered.

TestPlanResponseNoteMatchesTheLoggedNote did not pin the log. It compares
resolveDepot's output to the response and never captures slog, so changing the
log line to print a different variable would not fail it. Renamed to
...MatchesTheResolverOutput and the comment now says what actually keeps them
in step: shared locals in Service.Plan, not this test.

And a cross-reference pointed 'below' at a section above it.
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