Inspiration

A family member posted to r/AgingParents on 9 September 2026 about their mother: "She fell and couldn't get up or get to a phone last time I was there (it could have been days before anyone found her)."

The fall is usually not what does the damage. In a one-year follow-up of 110 people aged over 90, 82% of falls happened when the person was alone, 80% of those who fell could not get up, and 30% lay on the floor for an hour or more. The finding about the pendant is the one that decided this design: "Call alarms were widely available but were not used in most cases of falls that led to lying on the floor for a long time." The same door has a second problem behind it. Home care visits are billed by the visit, and the evidence a visit happened is nearly always a record the care worker writes. HHS's own investigators: "fraud is often proven by showing that the existing documentation of such activities by PCS attendants and providers is false."

What it does

Threshold lays a household's Ring door events beside its booked care schedule on one time-aligned page, and annotates each visit block with one of exactly two sentences: "Front-door activity recorded during this visit" or "No arrival was recorded at the front door during this visit". It watches for a morning that has stayed quiet longer than that household's own pattern. It never names anyone and never returns a verdict about a person.

How we built it

Python, standard library only: http.server, sqlite3, hmac, hashlib. Ring documents no simulator, so RingClient is written against the real endpoint shapes, base URL, bearer headers and JSON:API envelopes, and is never pointed at a live host outside a live_smoke marker that is skipped by default. The demo replays HMAC-signed fixture webhooks through the same verification path a real Ring webhook would hit.

The engineering that matters here is not the model call, it is the gate around it. Every judge-facing string in the product leaves through one choke point, phrasing.py, and there is no route that builds prose in an f-string and no string construction on the client. assert_safe checks the closed vocabulary. assert_model_output_safe is stricter: a forbidden-substring list, whole-word pronouns and role words, certainty and escalation language, and a numeric grounding check that rejects any number the rules did not compute, against digest.allowed_numbers(). That last check is what makes it structurally impossible for this product to assert something about a named carer, rather than merely unlikely. sub_type is stored as a display hint and gates nothing: Ring's own motion classifier has a documented track record of calling a person on a mobility scooter a delivery, and this product refuses to build a filter on top of a label that unreliable.

Amazon Bedrock appears in exactly one place, the weekly plain-language summary, and it sees only digest.facts_block(), never a raw event, so it has nothing to classify. Converse in us-east-1 over a chain of us.anthropic.claude-sonnet-4-6, then Sonnet 4.5, then Haiku 4.5, an 8 second timeout, and one bounded retry. A third attempt would be a model arguing with a rule, so the rules-written summary is used instead. That fallback is a complete answer rather than a degraded one, and it also covers a missing boto3, missing credentials or an unreachable service. With the network unplugged the family gets the log without the prose and nothing else changes.

Household notes are the other half of the record, the human half: a family writes in what the door could not see, rendered as an attributed quotation, never restated in the app's voice, never changing a visit's status and never reaching the model prompt. Every surface prints RECORDED, REPORTED or INFERRED as a word. The shell is a light, neutral page, one spine down the middle carrying the day's timeline, the care schedule on the left and the door log on the right, both at their real time of day. The "no arrival was recorded" state never uses red and never uses a cross, and renders at the same weight as its opposite, because both are answers to the same question; red is reserved for an actual system failure, like the read-failed banner, never for grading a visit. Type is a vendored Inter and JetBrains Mono rather than a system serif or sans, because a system font name resolves to a different face on every platform a judge might open this on. 483 tests, 18 of them skipped against a shared adversarial-sentence corpus, each skip naming the specific reason that row doesn't apply to this app rather than dropping it silently.

Challenges we ran into

Daylight saving is a false-accusation bug here, not a formatting one. The first implementation built a rule's window in UTC, so on the Monday after the spring change a carer arriving at 08:05 local sat an hour outside an 08:00 UTC window and the app reported no arrival for a visit that happened exactly on time, twice a year, with nothing on screen to say it came from arithmetic. Households now carry an IANA zone, windows are built in it, days are counted locally, and every stamp prints its offset. The test asserts the right answer and demonstrates the wrong one, so the regression cannot come back quietly.

Making the schedule editable carries the same harm in one line. Move Tuesday's visit from 08:00 to 10:00 and every past Tuesday is re-compared against a window that was never booked on it. No accusation was typed and no phrasing rule broke; the harm arrives as a data bug. active_from now defaults to today rather than to "always", an amendment closes the old rule the day before and opens a new one, and nothing is deleted once it has governed a day.

The model's first real draft was refused for one word. It wrote "the front door recorded activity within the expected window for 12 of them", and "them" is banned because it is how a sentence starts referring to people. The refusal was correct and the sentence was harmless, which is the interesting case: a gate strict enough to be worth having will refuse things a person would have allowed. One bounded retry hands back the refusal and the reason; loosening the gate was the tempting wrong move.

A third failure in the same family ran the other direction: the gate rejecting a sentence that was correct. The number pattern used to fold a run of digits, commas and spaces into one match without checking whether the digits on either side of a separator belonged to one number or to two, so a model handed several dates in one fact block and asked to summarise them could produce a comma-and-space list, and the pattern would swallow it into a single figure nobody had written and the numeric-grounding check had never seen — the module's own comment records "2026-09-21, 2026-09-22" folding into a phantom 212026. The whole sentence was refused for a number that existed only because the tokenizer built it, not because the model asserted it. The pattern now requires exactly three digits after a separator, with none following, which is the shape a thousands group actually has and two adjacent numbers never do: "1,000" still folds to one figure, and two dates next to each other stay two. Reproducing the exact week of facts that triggered it and calling Bedrock directly, twelve times, before the fix: zero of twelve accepted, all twelve on the identical phantom number. Same facts, same model, same prompt, after the fix: sixteen of sixteen accepted, first attempt, every time, with the guard's word rules and every other test unchanged — the fix narrowed what counts as a number, not what counts as allowed.

The gate itself had two holes, and 483 passing tests hid both, because the tests were written from the same mental model as the code they checked. Porting the guard into a scratch script and feeding it ordinary sentences found that "she's not recorded at the front door at 8" sailed straight through: the apostrophe sat inside the tokenizer's own character class, so "she's" tokenised as one unmatched word and the banned pronoun "she" was never seen. The same run found "front-door activity was recorded for 5 of the booked visits" also passed, because the numeric check treated every integer from 0 to 7 as grounded unconditionally, on the reasoning that a small number couldn't assert much. Every figure this product argues about (visits without a recorded arrival, quiet days, door count) lives inside that range. Both looked like one-line fixes and needed a real one: the tokenizer now folds a string two ways so a contraction can't hide a pronoun in either direction, picking up the same fix for lookalike Cyrillic letters and zero-width spaces on the way through, and a number is now grounded because the rules computed it, never because of its size.

Accomplishments that we're proud of

Not trusting our own passing tests. Feeding the gate real sentences instead of the sentences its own denylist was written from is what actually found the two holes above; 483 green tests had not. Both fixes tightened the rule rather than loosening it: the sentence forms that broke it now fail correctly, and every existing test still passes unchanged.

What we learned

In most products an hour of clock drift is cosmetic. In this one it is an unfounded suggestion that a named, low-paid person did not turn up, generated twice a year by arithmetic, with nothing on screen to say where it came from. The same lesson held for the gate meant to stop that exact sentence: a guard is only as good as the sentences someone actually tried against it, and our own test suite, however large, was not that someone. Which bugs are dangerous is decided by what the product claims, not by how the code fails.

What's next

A real notification channel for the silence state, a live view if a household is on a plan that supports one, and a first-day experience that does not depend on history Ring cannot backfill.

Built With

  • amazon-bedrock
  • boto3
  • pytest
  • python
Share this project:

Updates

Submission history