Inspiration

A clerk at the New York City Department of Buildings typed this into a violation record:

000810 PAID INVOICE 90876458

A keyword list reads that as an open violation. Act on it and you have told a contractor to pay a fine they already paid.

That single sentence is the whole argument for putting a language model in this system, and for keeping it out of everything else. Here is the shape of it, measured against live NYC Open Data:

DOB violations since 2021-09-14 Count
Still categorised ACTIVE 165,001
Carrying a hand-typed disposition sentence 581
A keyword list reads correctly 574
A keyword list gets wrong 7

Source: NYC Open Data dataset 3h2n-5cm9, pulled 2026-09-14. Reproduce with python -m scripts.measure, which prints its SoQL beside every figure.

Seven sentences. Not a revolution. That is the model's entire job description in Lapse, and it is small enough to be checkable, which is the only kind of claim worth making about a regulated business.

What it does

Lapse watches a contractor's permits and violations, works out what lapses next, drafts the response, and interrupts them once per item.

The interesting part is not arithmetic on dates. It is that nothing in New York's open data answers the only question a contractor actually has, which is what do I owe this month. Three datasets hold the pieces and all three are keyed differently:

Dataset Answers Keyed by
DOB Permit Issuance ipu4-2q9a what expires and when permittee business name
DOB Job Application Filings ic3t-wcy2 whether the job is even open job number
DOB Violations 3h2n-5cm9 what is outstanding at that building BIN

Walking the permit list once turns a business name into a set of buildings, and that set turns into violations the city never attached to the contractor, because the city attaches violations to buildings.

Run it across the city and the scale appears. 605 permits carried an ISSUED status and an expiry date inside the last thirty days. The permit feed holds one row per issuance, so a permit renewed four times looks like four permits; collapse the renewal sequences and 542 remain. Join each to its job filing and ask whether the city ever signed that job off, and 463 had not been. Another 142 expire inside the next seven days.

Across one real contractor's public filings: 235 items read, 179 held without a word. The holds are the product working. 87 are the building owner's obligation rather than the contractor's, 80 are not due, 9 sit on a job already signed off, 3 are superseded by a later sequence.

How we built it

agent/engine/deadline.py decides what is due. No prompt reaches it. It returns FILE, DECIDE or HOLD, and the model is handed the result.

The engine decides. The agent reads the seven sentences.

Three guardrails, each a Strands primitive:

  1. judge_disposition accepts the model's read of a clerk's free text and returns the computed verdict rather than the model's opinion.
  2. FilingVeto, a BeforeToolCallEvent hook, cancels file_response unless the engine returned FILE for that exact case.
  3. approval_gate, a HumanInTheLoop intervention configured allowed_tools=["*", "!file_response"], so filing in a licensed contractor's name can never be trusted away.

The veto fired in production without anyone staging it:

{"event": "veto", "detail": "case f28dda4b5b2584dd was already filed; it will not be sent twice"}

Lambda runs the unattended pass, EventBridge Scheduler runs it daily, DynamoDB holds cases keyed by a deterministic hash, S3 holds the evidence, and SES sends from a DKIM-verified domain. Strands is model agnostic, so the model provider is one config line.

Challenges we ran into

Four of our own rulebook citations were wrong, and a test caught them. Lapse quotes the actual NYC rule behind each deadline. A provenance test fetches every cited rule PDF and asserts the quote appears in it contiguously. Four had been spliced or approximated; one joined two subdivisions twenty five lines apart. All twelve verifiable quotes now match the source verbatim, and four that cite JavaScript-rendered DOB pages are held to a separate standard rather than counted as passing.

The filing path was addressing itself. Sender, recipient and intended recipient had collapsed onto one address, which produces a real SES MessageId that reaches nobody. Delivery now records a distinct intended recipient and refuses to write a record that is a loop.

A scheduled pass destroyed a contractor's answer. Answering a question and then letting the pass run discarded the answer and re-asked four seconds later, because answer was missing from the carry-forward list. The timeline proved it: two answered events, then two fresh asked events, seconds apart.

Accomplishments that we're proud of

The engine refuses to invent a deadline. Most violation classes have no published response window, so instead of guessing one, Lapse hands the clock to the contractor and asks. A tool that invents due dates for a regulated business is worse than no tool at all.

189 tests pass from a clean clone with no credentials. Sixteen of them fetch the cited NYC rule and assert the quote is really there.

What we learned

The valuable half of this problem is not what is due. It is what is not yours: 87 of 179 held items belonged to the building owner, not the contractor holding the permit.

What's next for Lapse

SES production access so responses reach a real filing desk. More city portals, because the join pattern is not specific to New York. And the contractor count above one.

Built With

Share this project:

Updates

Submission history