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.

Three guardrails, each a Strands primitive:
judge_dispositionaccepts the model's read of a clerk's free text and returns the computed verdict rather than the model's opinion.FilingVeto, aBeforeToolCallEventhook, cancelsfile_responseunless the engine returned FILE for that exact case.approval_gate, aHumanInTheLoopintervention configuredallowed_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
- amazon-dynamodb
- amazon-eventbridge
- amazon-ses
- amazon-web-services
- anthropic-claude
- aws-lambda
- next.js
- nyc-open-data
- python
- strands-agents
- typescript
- vercel
Log in or sign up for Devpost to join the conversation.