← back

Shipping a Legally Required AI Disclosure People Actually See

#ai-product-management#compliance#responsible-ai#ui-ux#product-decisions

Shipping a statutory AI-disclosure notice into a companion app's chat thread, and how reading the code changed the design record's mechanism without changing the promise.

Three state laws want the same thing from an AI companion app. Tell the user they are not talking to a human at the start of the conversation, and tell them again every three hours if the conversation keeps going. My app had one notice, in small print under the message box. It cleared the contrast rules and still failed the word that mattered, which was "beginning". Today I built the two surfaces that fix that, and the interesting part was not the notice. It was what the code said about the plan.

A footer is not a beginning

The old notice sat under the composer, eleven pixels tall at sixty percent opacity. On paper it was always visible. In practice, when a conversation opens at bedtime, the eye is on the thread, waiting for the first line. A footer is where you look when you are done, not when you begin. The design session before this one had already named that: placement is part of "conspicuous", and a notice nobody reads at the moment the law cares about is a notice in name only.

So the first surface is a line in the thread itself. It is the first thing in the conversation, above the AI's greeting, every time a conversation opens, whether it is a fresh one or a draft picked up from last night. It uses the same visual pattern as the row the app shows when the AI fails to answer, because that pattern was designed for the same reason: at bedtime, a toast is missed and the eye is on the thread. The small print stays underneath, made a little larger and a little brighter, because two surfaces that say the same thing in different registers are cheaper than one surface that has to do both jobs.

The three-hour case is rare and still has to exist

A nightly gratitude session with a fifty-message cap almost never runs three hours. But the composer stays open, and a phone tab can sit on that screen for a whole evening. The law does not ask whether the case is common. It asks whether the app handles it. So the second surface is a small sheet that appears after three hours, needs one tap to dismiss, and writes the same sentence into the thread as the record.

The design question was how to measure three hours. Nobody has published a rule for it, so the earlier session did the research and settled on the conservative reading: measure from the last time the user saw a notice, check before every message they send, and also check while the tab is visible in case someone sits reading without typing. A background tab's timers cannot be trusted, so the visible-tab check runs the moment the tab comes back. That decision was locked before I wrote a line, and it held.

The plan said "on the draft"; the code said otherwise

The design record had an implementation note: keep two timestamps and let them ride along with the saved draft, so a discarded tab could resume with the clock intact. It carried a caveat, verify at build whether that state lives locally or in the encrypted draft row. Good caveat. When I read the code, the draft's payload turned out to be a plain list of messages, and that list has the same shape as the one used for saved journal entries. Adding timestamps would mean changing a shape shared by two storage paths, in the layer that handles encrypted content. That is not a small edit in an app whose privacy story depends on that layer being boring.

Then the better question surfaced. Why persist the clock at all? Every time the screen mounts, it shows the opening line. A reload is a beginning. A discarded tab that comes back is a beginning. The user has just seen the notice, so the three hours legitimately start again from that moment. The clock lives in memory, and nothing about the promise to the user changed. If anything, the mechanism became more conservative, because it can never under-count. I flagged the reasoning for legal review with the statute's wording next to it, because a mechanism that differs from the record deserves a second reader even when the behaviour is identical.

The general lesson is one I keep relearning. A design record's implementation note is a hypothesis about code nobody has read yet. Carry the behaviour lock into the build. Re-derive the mechanism there, with the file open.

Forcing a three-hour case in five minutes

You cannot ask a founder to sit on a preview build for three hours to check a sheet. You also should not ship a debug switch into production for a case that runs once a night at most. The pattern that worked was a single commit on the branch that shortened the interval to two minutes, pushed for the smoke, and reverted before the merge, with the full test gate run again on the reverted tree. The tests themselves fake only the clock and leave real timers alone, so the chat thread's asynchronous rendering behaves the way it does for a user. The first version of one test failed because it sent a second message while the AI was still "typing" the first reply, which the app refuses. The test had to wait for the reply like a person would.

One more small thing from the close. After the deploy I asked for a load of the production site so the telemetry would prove which build was live. The database showed a single visibility event on the old build. A tab that was already open had come back to the foreground still running the bundle it fetched hours earlier. A foregrounded tab is not a load. The proof came after a reload, and the checklist now says reload.

Mental Models

A footer is not a beginning. If a rule says "at the start", put the notice where the eye is at the start. Visibility on paper is not the same as being seen at the moment that matters.

A record's mechanism note is a hypothesis until the code is read. Lock the behaviour in the design session. Re-derive the mechanism in the build, with the file open, and say out loud when it changed.

Persist only what a fresh start cannot recreate. If every mount reproduces the state you were about to save, you do not need to save it, and the layer you would have touched stays untouched.

Force the rare case with a throwaway commit, not a switch. Shorten the interval on the branch, smoke it, revert it, and gate the reverted tree. Production carries no debug path for a case it handles once a night.

A foregrounded tab is not a load. After a deploy, an open tab reports the old build. Ask for a reload before reading the proof.