The agent that is not allowed to see the money
The US tariff schedule shipped three revisions in three weeks. I built an agent to re-classify a catalog against them, and then took away its access to duty rates, because a model that can see which answer is cheaper can never prove why it chose.
Contents +
I built this for the All Things Agentic Hackathon, and this post is my entry for the content bonus.
The US tariff schedule shipped three revisions in three weeks this summer: Rev 14 on July 29, Rev 15 on August 3, Rev 16 on August 11. Every one of them retires some codes and splits others. Somewhere in a manufacturer's catalog, a line that cleared customs last month is now filed under a heading that does not exist.
A dead code usually has more than one legal successor. At eight digits those successors can carry different duty rates, and the difference on a single entry runs into thousands of dollars. Customs does not tell you which one you picked wrong. The penalty notice does, later, with interest.
So the obvious build is an agent that reads the catalog and re-classifies it. I built something narrower than that, and the narrowing is the whole point.
Most of the work does not need a model
The official correlation table already says what happened to each code. When it maps a code one-to-one, there is no judgment to make: the successor is the successor. Handing that line to a model would produce the same answer, slower, at a price, with a chance of being wrong.
On my evaluation set of 500 items, 212 are like that. They are settled by a program and never reach the agent. What is left, 288 items where the prior heading no longer exists, is the part where somebody has to read chapter notes and argue.
This is not a cost optimization. It is what makes the remaining number mean anything. If I ran all 500 through the agent and reported 92.8% accuracy, the number would be mostly measuring a table lookup that a model happened to perform. Split out, the picture is honest:
| n | correct at 8 digits | carrying the old code forward | |
|---|---|---|---|
| prior heading is dead | 288 | 88.9% | 21.2% |
| prior heading maps to several | 90 | 95.6% | 97.8% |
| prior heading maps one-to-one | 122 | 100% | 100% |
The right-hand column is the mechanical floor: what you would score by doing nothing but carrying the old code forward. On the dead-code stratum it is 21.2%, and that is the only place the agent is doing work you could not do with a join.
The agent cannot see duty rates
The classification agent gets four read-only corpora: chapter and section notes, tariff lines, ruling search, and ruling texts. It does not get duty rates. It does not get chapter 99, where the additional measures live. It has no tool that returns money.
That is a deliberate hole in its capabilities, and it exists because of a failure mode nobody can audit after the fact. If a model can see that one candidate code carries 8% and the other carries 32%, and it is asked to pick one, you can never prove afterwards which consideration produced the answer. Its own explanation is not evidence: post-hoc rationales are the one thing language models are unconditionally good at.
Removing the rates from its input makes the question unanswerable. The duty is computed afterwards by a program, from the code the agent chose, and shown to the person who signs.
Every quoted word is re-resolved
The agent returns citations: a chapter note, a tariff line, a past ruling. A deterministic checker then re-resolves every one of them against a frozen snapshot of the source text. Punctuation may differ. Case and line breaks may differ. Words may not.
Anything that does not resolve stops the line. On the 500-item run, seven answers cited something that did not hold up. Four ruled a candidate out on an authority with no row in the current schedule. Three quoted a note that exists, with words in it that do not. None of those seven shipped.
The interesting question about a checker is not whether it passes the things that are right. It is whether it catches the things that are wrong, and you cannot demonstrate that by waiting for a hallucination to happen on camera. So I took every answer that had already passed, inserted one word into one real quote, and sent it back through the same checker in production.
423 altered answers. 423 caught. Zero missed.
The inserted word is solely, which reads like a qualifier a paraphraser would add rather than obvious damage. Any inserted word fails, and the demo is better for the fault being subtle.
Three things that broke
A wrong filename returns HTTP 200 with an empty body. The government endpoints this reads from do not 404 on a bad file name; they return success and nothing. For the screening list, an empty response means every party passes screening, which is the most dangerous failure available in this domain. Status codes are never sufficient. The health gate checks byte and row floors and re-verifies the sha256 of what landed on disk.
The evaluation set leaked its own answers. CBP ruling texts open with sentences like "you request a ruling on the applicability of subheading 6201.93". The merchandise description I was feeding in as input carried the answer inside it. Cutting each text before its first tariff reference would have destroyed 23% of the items, some down to 8% of their original length, because the reference often comes before the product description. Redacting in place instead kept every product fact. A gate re-verifies after each rebuild that no description can read its own answer; it caught three regex mistakes, one of which had taken leakage from zero items to 58.
Deploying it killed it. The precedent index holds 218,606 rulings. At 1 GiB the Cloud Run container was terminated mid-batch at 1030 MiB, and every case the agent had open came back stranded. The memory limit is a load-bearing setting, and the deploy script now says so in a comment, next to --no-cpu-throttling (background threads keep working after the response is sent) and --max-instances=1 (in-process worker state).
What it refuses
When the description does not carry the one fact the decision turns on, the agent does not pick the likelier candidate. It refuses, names the missing property, and names the department that owns the answer. The case waits in Cloud SQL with its history intact, and when a person answers days later, the re-run cites the answered fact rather than the guess.
It does this on 6 of 500 items. A refusal rate of 1.2%, against a design cap of 10%. A system that escalates whenever it is unsure hands its whole caseload back to the person it was supposed to help; the cap is what keeps "refuses to guess" from becoming "refuses to work".
The shape
A table settles what a table can settle. One agent argues what needs arguing. A checker holds every word to its source. A person signs once.
The code is at github.com/cyh7789/tariff-reclassification-line. It runs on Cloud Run with Cloud SQL, and the agent is Gemini 3.7 Flash through the Google GenAI SDK on Vertex AI.