Shipmind Labs

An unanswered question is not a no

· 8 min read

A compliance checklist made of tick boxes has exactly two states, and an empty box reads as "no". That is a quiet way to ship a chatbot without its disclosure: nobody ever decided whether the system speaks directly to people, and the form decided it for them, in the direction that creates no work.

We hit this while building disclosurekit, a small TypeScript package that works out which of the transparency duties in Article 50 of the EU AI Act apply to a system, what a notice has to get across, and where it has to appear. The rules themselves are short, five rows in one table. The decision procedure over them is simple enough. The input is where it gets awkward: a description of your own system, put together by someone who does not know all of it.

The missing answer is the normal case#

When we started writing down the inputs an assessment needs (does this system interact directly with people, does it generate synthetic content, is any of it a deep fake of real people, is emotion recognition in the path, are we the provider or the deployer) we expected the uncertain answers to be rare. They are not rare. They are the normal condition of a system in the middle of its life.

The backend team knows whether a model output reaches an end user unmediated. Whether the vendor whose model sits behind the feature counts us as a provider or a deployer is a question for whoever read the contract. Whether the generated images in a product listing are a deep fake of a real place is a question nobody has been asked yet. Each of these has a correct answer. None of them has an answer at the moment the assessment runs.

A boolean field cannot hold that situation. It holds true, false, and the absence of the key, and the absence of the key is where every uncertain answer ends up, because the person filling in the form had nothing to put there. The library then applies the only reading a two-state input allows, decides the duty does not bite, and returns a clean result. That is the failure mode we care about: not a wrong answer that looks wrong, but a wrong answer that looks finished.

Three states in, three buckets out#

So the input type admits "unknown", and the output has somewhere to put it.

typescript
import { assess } from "disclosurekit";

const profile = {
  role: "provider",
  interactsWithPeople: "unknown",
} as const;

const result = assess(profile);
// Article 50(1) is not in the applied duties and not in the excused ones.
// It is in result.needsReview, where nothing is safe to ship.

The third bucket is the whole mechanism. An obligation in needsReview has not been decided either way; it is a question that has been raised and left open, and it stays visible until somebody closes it. Compare that with what a two-state input produces for the same system: a result where Article 50(1) simply is not there, indistinguishable from a system that genuinely does not interact with anyone.

The same discipline applies to a question that was never routed to the assessment at all. An input left out entirely decides the same way a false does, and we are not going to block a release because somebody did not enumerate every field in the schema, but it is written into the record as "not stated". false is a claim: someone looked and said no. "not stated" is the absence of a claim. They produce the same decision today and they are not the same thing in six months, when the question is why the duty was not applied and the honest answer is that nobody was asked.

This is also why we separated "unknown" from omission instead of collapsing them. "unknown" is an assertion of ignorance by someone who was asked, and it is strong enough to stop a release. Omission is silence, and silence does not get a veto, it gets a footnote in the record.

The gate belongs in CI, not in a meeting#

assess() is a pure function over a plain object. No network, no clock, no reads from disk. Which means the open-question check is just a test, and it runs on every commit rather than at a review meeting a week before launch.

typescript
import { assess } from "disclosurekit";
import { profile } from "../src/ai-profile";

test("no open transparency questions", () => {
  const { needsReview } = assess(profile);
  expect(needsReview).toEqual([]);
});

That test fails the day an engineer adds image generation to an endpoint and marks the new input "unknown" because they do not know whether the output counts as a deep fake. The failure is correct and it is cheap to clear: either someone answers the question, or the team knowingly accepts an open item and records that it is open. What the test will not let you do is pass by saying nothing.

Keeping the profile in the repository next to the code it describes matters as much as the check does. A compliance answer that lives in a document drifts from the system within one sprint. An answer that lives in a typed object, read by a test, breaks the build when it stops matching reality, assuming someone updates the object, which is exactly the habit you are trying to install.

What the library refuses to tell you#

The tri-state input has a sibling rule on the output side: the package reports what saying something does not cover.

typescript
import { requirements } from "disclosurekit";

requirements({ role: "deployer", emotionRecognition: true });
// [{ measure: "inform-exposed-persons",
//    obligations: ["art50-3"],
//    elements: [{ id: "ai-system", must: "…" },
//               { id: "biometric-operation", must: "…" }] }]

Some duties are discharged by wording and some are not. Article 50(2) asks for a machine-readable mark embedded in the content; telling a user in a sentence that an image was generated by AI is not that mark, and a run with no marker configured says so plainly rather than reporting success. A library that returned "compliant" there would be worse than no library, in the same way a checklist that reads blanks as noes is worse than no checklist: both hand you a result with the uncertainty removed.

The package also ships no sentences. It knows which elements a notice must get across and when and where it has to appear; it does not know your product's voice or your users' language, so the wording is yours, and a notice with an element left blank is refused.

typescript
import { withDisclosure, EvidenceLog } from "disclosurekit";

const log = new EvidenceLog();

export default withDisclosure(
  handler,
  { role: "provider", interactsWithPeople: true },
  {
    locale: "en-GB",
    text: { "ai-system": "You are chatting with our AI assistant." },
    log,
  },
);

The log is the other half of the three-state argument. An assessment and a disclosure that was actually shown both go into it as entries in a hash chain that verifies, and the log can be stamped with the ruleset version that was current when the decision was taken (new EvidenceLog({ rules: "eu-ai-act-article-50@0" })) so a decision made under an older reading is recorded as what it was rather than silently re-litigated.

What it costs to run#

Almost nothing in machine terms. The assessment is a table lookup over a small object; it runs in a unit test, in a CI job, or in a request handler without anyone noticing. The middleware is built on the Web Request and Response, adds a header to every response, and touches the body only if you name a key for it, because a response body belongs to the service.

The real cost is social, and that is where the value sits. A needsReview entry is a work item with an owner, and somebody has to go and find out whether the vendor arrangement makes us a provider or a deployer. A two-state checklist spares you that conversation by answering on your behalf. The three-state version makes the open question visible, holds the release until it is closed, and leaves a record of which answers were given and which were never asked for.

None of this is legal advice, and the package says so in its own README. It is a map of the structure of one article, written by engineers, and where the answer matters a lawyer decides it. What the code can do is make sure the question reaches that lawyer instead of being closed by an empty box.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com