Shipmind Labs

A footer saying "generated by AI" is not a machine-readable mark

· 8 min read

Article 50 of the EU AI Act asks two different things about synthetic content, and only one of them is a wording problem. A sentence under an image telling the reader it was generated by AI discharges the duty to disclose. It embeds nothing in the file, and 50(2) wants a mark inside the content, detectable as artificially generated or manipulated. Compliance checklists tend to collapse both into one row ("AI content is labelled"), and that row is true and useless at once.

We hit this while building disclosurekit, a package that works out which parts of Article 50 apply to a system, says what a notice has to contain and where it has to appear, and keeps the record. It does not write the notice and it does not mark content. The second disclaimer is the one worth dwelling on, because a library that quietly let wording stand in for a watermark would be worse than no library at all. It would hand you a green assessment for a system that is not marking anything.

The duty with no wording#

The article is short. Five duties, one table: who owes it, when it bites, what has to happen, what can excuse it. The 50(2) row reads like this. The provider owes it, it bites when the system generates or manipulates synthetic audio, image, video or text, and what has to happen is that a machine-readable mark is embedded in the content. It is excused by assistive standard editing that does not substantially alter the input, or by authorised law enforcement. Nothing in that row mentions a sentence, a footer, a tooltip or a header. No wording can satisfy it, because the obligation is about the artefact and not about the reader.

The 50(4) deep-fake row sits immediately below it and works the other way round: the whole duty is to disclose, and a sentence is exactly how you do that. So one image pipeline can owe both duties at once, and exactly one of them closes by writing something.

That asymmetry is the thing to model. In disclosurekit it is a field on the requirement, derived from whether the measure has any wording elements at all:

typescript
function requirementsOf(assessment: Assessment): readonly Requirement[] {
  return measures(assessment).map((measure) => {
    const spec = REQUIRED[measure];
    return {
      measure,
      obligations: assessment.applies
        .filter((applied) => applied.obligation.measure === measure)
        .map((applied) => applied.obligation.id),
      elements: spec.elements.map((id) => ELEMENTS[id]),
      placement: spec.placement,
      satisfiedByText: spec.elements.length > 0,
    };
  });
}

REQUIRED["mark-machine-readable"] has an empty elements list, so satisfiedByText comes back false and there is no element for a caller to write wording against. Its placement does not point at a page or a response either, it says the mark goes inside the content. A pipeline that generates images of real places owes both rows, and the requirements come back in that order:

typescript
const profile: SystemProfile = { role: "both", generatesSyntheticContent: true, deepfake: true };

requirements(profile).map((r) => r.measure);
// ["mark-machine-readable", "disclose-artificial-content"]

const [marking] = requirements(profile);
marking.satisfiedByText; // false
marking.elements;        // []
marking.placement.where; // inside the content

Once the distinction is a boolean on a row rather than a paragraph in a policy document, the rest follows mechanically.

What the notice is, and what reading it does not accomplish#

disclose() takes the profile and the caller's wording and returns the disclosure. The statement is the caller's words. We take the view that a package cannot write a sentence in a language it has not read, and that the only failure a library can honestly catch is a blank. So element wording is trimmed, anything empty raises IncompleteDisclosure naming each Element.must, so the error tells you what the missing sentence was supposed to get across. What survives is deduped and joined in element order:

typescript
  const machineReadable: Record<string, string> = {};
  if (obligations.length > 0) {
    machineReadable[DISCLOSURE_HEADER] = obligations.join(", ");
  }

  return {
    statement: [...new Set(elements.map(wording))].join(" "),
    elements,
    obligations,
    unmetByStatement: required.filter((r) => !r.satisfiedByText).map((r) => r.measure),
    machineReadable,
    notes,
    locale: notice.locale,
  };

Two fields on that object carry the whole argument of this post. statement is what the person reads. unmetByStatement is the list of measures that reading it does not accomplish. For the image pipeline above the statement is one sentence and unmetByStatement is ["mark-machine-readable"]. The AI-Disclosure header names both obligations, art50-2, art50-4-deepfake, and it is only set when something actually applies, so a system that owes nothing does not advertise a header claiming it owes nothing.

The second field is not an error. It is not a warning either. It is part of the normal, successful return value of a function that just built a perfectly valid notice. The notice is correct, it is simply not the whole duty, and the type refuses to let you forget that by making the gap a value you have to look at.

A marker that admits it is not a marker#

The package ships a Marker interface and one implementation, UnmarkedContent:

typescript
const result = await new UnmarkedContent().mark({ content: pixels, mediaType: "image/png" });
result.marked; // false
result.method;  // "none"

It returns marked: false, method "none", and a detail saying Article 50(2) is not met. That is the honest default for a package that is not in the business of re-encoding files. Embedding a detectable mark is a format and provenance problem, so it belongs wherever the bytes are produced, not in a wrapper that only ever sees a finished Response and cannot touch the inside of a PNG without decoding and re-encoding it. What the wrapper can do is refuse to pretend. A default marker returning marked: true with method "footer" would probably be the most damaging line of code in the repository.

The gap goes into the record, not into a ticket#

The reason to compute unmetByStatement at all is that somebody will ask about it months later, by which time the footer will have been reworded twice and the pipeline replaced. So recordDecision writes it into the evidence log next to everything else that went into the decision:

typescript
  return log.append("assessment", {
    inputs: inputsOf(profile),
    applies: assessment.applies.map(({ obligation, note }) => ({ ... })),
    excluded: assessment.excluded.map(({ obligation, because }) => ({ ... })),
    needsReview: assessment.needsReview.map(({ obligation, question }) => ({ ... })),
    unmetByStatement: disclosure?.unmetByStatement,
    elements: disclosure?.elements,
    statement: disclosure?.statement,
    locale: disclosure?.locale,
  });

The entry is written to be legible on its own. It repeats each obligation's article and summary rather than only its id, keeps the reason for every exemption and the text of every unanswered question, and records an input nobody answered as "not stated" rather than as a false on the operator's behalf. Entries are hash-chained, verify() walks the chain, and toJSONL() exports one canonical record per line. Those are the same bytes that were hashed, so a line can be checked where it lies, and the file greps with tools that have never heard of the package.

The result is a record that says, in one place: these duties applied, this is what we told people, in these words, in this locale, and this duty was not covered by saying it. That is a much better artefact than a green checkmark, because it reads the same whether or not anyone got round to wiring up a marker.

Where the failure lands#

withDisclosure builds the notice once, at wrap time, and records the decision at the same moment:

typescript
export function withDisclosure(
  handler: Handler,
  profile: SystemProfile,
  options: MiddlewareOptions = {},
): Handler {
  const disclosure = disclose(profile, options);
  if (options.log) recordDecision(options.log, profile, disclosure);
  ...

So a profile with wording missing for a required element throws where the service starts up, not in front of a user mid-request. The same holds for an unanswered question. An input given as "unknown" sends the obligation to needsReview, and disclose() refuses to build a notice while a question is open. A service that cannot say what it owes does not boot.

What it costs to run#

Almost nothing per request. The assessment and the notice are computed once per wrapped handler. Per response the middleware copies headers, sets the disclosure header, optionally injects the statement into a JSON body under a key you name explicitly, and appends one log line recording the path, method, obligations, statement and locale. Bodies are otherwise untouched, since silently reshaping a service's response is the kind of surprise that gets a library deleted.

The real cost is the one the library makes visible. If your pipeline owes 50(2), you still have to mark the bytes, and no amount of good wording moves that line. What the model buys you is that the gap has a name, a place in the return value, and a line in the record, instead of living in the space between a footer and a checklist, where nobody is looking.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com