Inspiration
A shop owner in Montreal, writing about the cameras he already owns: "Cameras feel useless in the moment... they feel more like tools for after the fact. Reviewing footage to see what happened, maybe filing a police report that might not go anywhere for small stuff." Another put it shorter: "The cameras record everything, but I never have time to watch the footage."
That is the gap. The Home Office's Commercial Victimisation Survey 2023 found 26% of wholesale and retail premises suffered customer theft in the previous year, up from 20% in 2014, and among those that did, 48% experienced it at least weekly and 25% at least daily. The same department's crime outcomes report records a charge rate of 18.5% for shoplifting against 2.5% for theft generally, "which could be the result of more accessible evidence, for example, CCTV within the shop". Whether a shop can produce a usable record changes what happens next. Since 1 July 2024, California Labor Code 6401.9 also requires every employer to keep a violent incident log, nine specified fields, for five years.
What it does
Something happens. Whoever is on the floor taps one button on a screen with nothing else on it. Incident Book pulls the ninety seconds either side of the tap from the Ring event stream and opens a record with the date, time, location and event timeline already filled in. The nine legal questions wait on a different screen, for a different moment, because a person who has just been threatened cannot answer them yet.
How we built it
Python and Flask, server-rendered for a counter kiosk, with SQLite for storage. There is no official Ring Partner SDK, so ring_client.py is a hand-written HTTP client against the documented REST shapes: GET /v1/devices, GET /v1/history/devices/{id}/events and POST /v1/devices/{id}/media/video/download, in Ring's JSON:API envelope with a bearer header. It takes a transport object, which is the only seam: FixtureTransport returns the exact documented response shape, and HttpTransport would replace it with no change above that line. Webhooks are verified with real HMAC-SHA256 over the raw body, constant-time compared, and tools/replay_webhook.py signs a fixture the same way and posts it, so nothing in the app knows the event came from a replay.
Amazon Bedrock drafts statute fields (C), the detailed description, and (H), the consequences, and only those. The call goes to us.anthropic.claude-sonnet-5, falling back to claude-sonnet-4-6 and then claude-sonnet-4-5-20250929-v1:0 if an earlier model in the chain is not entitled on the account. Every classification field stays human-selected, because the model is never asked who did it. The result is written to its own drafts table and never to a statutory field: the completion form opens with the draft in the boxes, the person edits, and the save is what files it, writing a row to an append-only revisions table with the completer's name and job title. The document then prints, in the gutter beside each field, one of "Written by completer", "Drafted by software, edited by completer" or "Drafted by software, accepted unchanged", computed by comparing the filed text to the proposal character for character. Model output is guarded before it is ever shown, for accusation, overclaiming, and any personal identifier the person did not type, and a failed guard discards the whole draft. INCIDENT_BOOK_BEDROCK=off runs the entire app with no AWS account through a stub runner that ships in the package.
The statute forces two documents rather than one. 6401.9(d)(1)(B) requires the employer to omit personal identifying information from the log, and that duty does not apply to the pack that goes to a police officer, so both are built from one context and cannot disagree. The log masthead names the citation that redacted it; the pack masthead says it carries identifying information. PDFs render through WeasyPrint with fonts embedded and no network access, and the pack ships a manifest.sha256 in GNU coreutils format, so the sha256sum -c line printed on the document is the command that actually works.
Retention is the other statutory duty, subdivision (f)(3): five years, minimum. The first retain_until calculation used created_at + 5 * 365 days, which is short by the leap days inside the span — one day for most records, two for one created just after a February 29 — so every purge-eligibility date the app printed, on the retention screen, in the JSON log, in the CSV and on the statutory log document, fell inside the five years the statute sets as a floor. five_years_after now walks the calendar (born.replace(year=born.year + 5), with February 29 landing on March 1) instead of counting days, and Incident.earliest_deletion takes the later of the stored value and the recomputed one, so a book written before the fix is corrected rather than shortened twice. Nothing purges on a timer: retention.purge() refuses any record whose five years have not passed, with no override, and once they have, a named person has to ask for it, which writes a ledger row saying who, when and which files were removed. A purge empties the narrative and the evidence and keeps the stump — dates, premises, device, hashes, amendment trail — so the book still shows an incident happened on that date at that shop. 263 tests.
Challenges we ran into
Ring documents no simulator. A sandbox is named in the glossary and the FAQ, and the Test section of the same reference page says only "use your personal Ring account and devices". Real testing needs a US-located device on an active Protect plan, and clip retrieval needs the paid continuous-recording tier or it returns 416 TIMESTAMP_NOT_FOUND. So the build is fixtures replayed through the real client code, never above it, the placeholder clip is a labelled byte string rather than footage, and both the record and the exported pack carry "source": "fixture" verbatim. The webhook signing scheme is one documented sentence with no canonicalisation rule and no worked example, so our interpretation sits in one function with its assumption in the docstring.
Reading the statute instead of our own summary of it changed the build. Field (B) is "the workplace violence type or types" and (G) asks "whether it involved any of the following", so both are multi-select and the first build modelled both as single values. (D) lists eight classifications, not five. (I) requires the date completed, which we had not stored. A paraphrase of a legal list is a different list.
And a bug that 45 passing tests could not see. Flask's dev server runs each request on its own thread, and the app's single shared sqlite3.Connection was opened on the startup thread, which sqlite3 refuses to touch from another. Flask's test client spawns no threads, so the whole suite was blind to it and the live server returned a 500 on the first real tap.
Accomplishments that we're proud of
The app never names or accuses anyone. Ring's own classifier has been documented labelling a motorised wheelchair a package, so the record says an event was detected and the classification questions are answered by a human who was there. Ring's content policy bars the commercially tempting version outright, no cross-shop suspect database and no watchlists, and staying inside that line made the product better.
The redaction is proven on screen, not asserted. An early cut of the demo skipped the step where the completer edits and saves the description, so the field stayed empty and both exported documents would have shown nothing to redact — a passing test suite would not have caught this, because it is a demo-fidelity gap, not a code path the tests exercise. The fix was to actually type an edited paragraph containing a name and a phone number into the form and press save before exporting. The two documents that came out then show the real pipeline running: the statutory log reads "a person who said his name was [name removed] came over the counter... my manager's cell is [telephone number removed] if you need to reach her," and the evidence pack carries the same sentence with the name and number intact, because 6401.9(d)(1)(B) only requires redaction on the log a regulator can request, not on the pack a police officer needs to act on.
What we learned
"All tests pass" is not "the app runs". The one instruction to build the thing and run it caught a failure the entire suite was structurally unable to see.
What's next
The OAuth authorization-code handler that would point build_client() at a live device, a reminder rather than a timer when a record first becomes eligible for purge, and webhook replay protection once Ring documents a scheme for it.
Log in or sign up for Devpost to join the conversation.