I filed a bug against a data model I hadn't read
Visible state, persisted state and derived state are three different things. I spent four days treating them as one.
An agent I was supervising filled in a web form, clicked save, and got no error back. The record it created was missing the single most important field on it: the author.
That part is an ordinary bug, and I fixed it in eleven hours. The interesting part came afterwards. Newly paranoid, I looked at a second record, decided it had the same problem, and opened a defect that lived in my project plan for four days — with an ID, an owner, a future article depending on it, and a hypothesis growing on top.
That second defect did not exist. The data model was fine. My model of their data model wasn't.
Everything below can be checked without asking me for anything. The system is Open Library: the records are public, every revision is public, and the source is on GitHub. Where I'm reporting an observation, I say what I observed; where I'm reasoning on top of it, I say that too.
The context in one line: I write and self-publish a book that now exists in ten languages, and I was creating its catalogue records — one abstract Work, ten Editions hanging off it — through /books/add, the ordinary human web form.
The symptom
The agent typed the author's name. The name was visibly in the box. It submitted. The server accepted the submission and created both objects without signaling an error.
The Work it created had no author. Not "an author with a typo" — no author:
$ curl -s "https://openlibrary.org/works/OL45838913W.json?v=1"
→ "authors": []
last_modified: 2026-07-29T23:10:53.693773
That revision is public, permanent, and not under my control. It is still there as you read this.
The first conclusion, which was right
The form carries two fields per entity. One is the free-text input a human sees and types into. The other is hidden, holds the key of the entity — work--authors--0--author--key — and is the only one the server consumes.
The visible field is decorative. It exists so that a person can type "Hernán Cap…" and pick from an autocomplete dropdown; the dropdown's JavaScript is what writes /authors/OL16541369A into the hidden field. Setting the visible input's value programmatically doesn't fire that JavaScript. So the agent produced a form that looked — to a screenshot, and to me — exactly like a correctly filled one, and that carried an empty key where it counted.
This was not a one-off. The same trap fired three times in that single form:
- Author — visible text vs. hidden key.
- Language — same shape,
edition--languages--0--key. - Links — typed into a template row that submission ignores; the real fields,
work--links--1--titleand--url, don't exist in the DOM until you click "add".
Three manifestations of one pattern in one form is what makes it a class of bug rather than an anecdote — and that pattern is everywhere. Autocomplete widgets, tag pickers, entity selectors: a screenshot is not evidence of what was submitted.
There was also a mirror-image case that I find more instructive than the bug itself: Open Library parsed the Title: Subtitle string I gave it and populated a subtitle on the Work — a field that form doesn't offer at all. So the form dropped a value I supplied and derived one I hadn't typed. In neither direction was the screen a reliable picture of the object.
None of this is a defect in Open Library. It's a form built for humans, and I drove it with a robot. The fix was to stop doing that: real focus, real keystrokes, arrow-down and Enter on the suggestion, then form.requestSubmit(saveButton) with the button passed as the submitter so its name travels in the POST. Zero retries. Then verify against the API instead of the page:
$ curl -s "https://openlibrary.org/works/OL45838913W.json?v=2"
→ "authors": [{"author": {"key": "/authors/OL16541369A"}, …}]
last_modified: 2026-07-30T10:55:21.506833
Eleven hours and forty-four minutes of a public record with no author on it.
The lesson I took away: don't verify that an action succeeded, verify that it persisted. Check the API, not the screen.
That lesson is correct. It is also exactly what set me up for the second mistake.
The second conclusion, which was wrong
Now properly paranoid, I went checking the API everywhere. And I found this:
$ curl -s "https://openlibrary.org/books/OL62392829M.json"
→ no "authors" key at all
Ten editions of that Work. Not one of them carries an authors array.
I concluded: same class of failure, still live. The Work got repaired, the Editions didn't. Ten public records with no author on them. I wrote it into a plan as an open defect, gave it an ID, assigned an owner, made a future article depend on it, and — the part that should have caught my attention — built a hypothesis on top of it: maybe search engines can't resolve our bibliographic records because our records are broken.
Every piece of evidence I had pointed that way. The pattern matched a bug I had personally fixed the day before. The field was absent, and absent looked like missing.
Where the schema contradicted me
Before writing any of this in public I went to read the source. Open Library's is on GitHub, so that took minutes instead of a support ticket.
openlibrary/schemata/edition.schema.json:
required: ["key", "title", "type", "works", "revision", "last_modified"]
authors is in the schema — as an optional property. works is required; authors is not. An Edition without authors is valid. An Edition without works would not be.
That single line inverted the diagnosis. I had been reading an optional field's absence as a missing value. Those are not the same thing, and no amount of API-checking would ever have told me the difference. I was verifying persistence with great discipline while being wrong about what was supposed to persist.
Where authorship actually lives
Three independent parts of the codebase say the same thing.
The indexer. solr/updater/edition.py builds an Edition's author fields like this:
if self._solr_work and isinstance(self._solr_work, WorkSolrBuilder):
author_fields = {
"author_name": self._solr_work.author_name,
"author_key": self._solr_work.author_key,
…
}
…
# Duplicate the author data from the work
**author_fields,
The comment is theirs, verbatim. The Edition's own authors array is never read by the indexer. Populating it would not change one field in the search index.
The renderer. plugins/upstream/models.py:
def get_authors(self):
"""Added to provide same interface for work and edition"""
work_authors = self.works[0].get_authors() if self.works else []
authors = [follow_redirect(a) for a in self.authors]
…
return work_authors + authors
Work authors plus edition authors, concatenated, with no deduplication. Writing the same author into the Edition wouldn't have fixed the page. It would have printed the author's name twice.
The importer. catalog/add_book/__init__.py derives the Work from the Edition when a record arrives by import — MARC, Internet Archive, and so on. Those source records describe a physical book with an author on it, so the Edition is created with authors and the Work is derived from that.
Which is, I think, the whole explanation. Records created by import flow Edition → Work and carry authors on the Edition as a residue of that direction. Records created through the web form flow Work → Edition and don't. Nothing was missing; the two paths just leave different marks.
That last step is inference, not documentation, so I checked it against the corpus: I sampled 358 editions through the API. Translations — precisely my case — carry authors 39% of the time, against 87% across the whole sample. Of the records with no source_records at all, the hand-made ones like mine, 0 of 8 had authors. Eight is a small cell and I won't pretend otherwise; it's the direction that the rest of the sample supports.
My records weren't the exception. They were the ordinary result of the path I used.
And the public surfaces, all correct and verifiable today:
$ curl -s "https://openlibrary.org/search.json?q=…&fields=author_name,author_key,edition_count"
→ {"author_key": ["OL16541369A"], "author_name": ["Hernán Capucci"], "edition_count": 10}
The HTML of that same authorless edition emits itemprop="author" linked to the author record, and its <meta name="description"> reads "… by Hernán Capucci". Search resolves it. Schema.org consumers get it. Aggregators get it. There was nothing to fix.
Visible, persisted, derived
Here is the distinction I was missing, and the reason the first lesson wasn't enough.
- Visible state — what the interface shows. In the first bug it lied by omission: the screen showed an author that no field would carry.
- Persisted state — what the authoritative object actually stores. For authorship, that's the Work.
- Derived state — what the system computes from persisted state and then exposes: the search index, the rendered page, the structured markup.
The first bug was visible ≠ persisted. I learned it the hard way and generalized it into a rule. The second was persisted-here ≠ persisted-anywhere, plus derived ≠ missing. Authorship was persisted, one layer up, and derived correctly into every surface that mattered. I was querying an object that was never the authority for that fact, and reading its silence as damage.
"Check the API, not the screen" doesn't protect you from that. It's still the right rule; it just answers a different question. It tells you whether a write landed. It cannot tell you where the write was supposed to land.
The framing that keeps me honest now: the object I'm holding is not the system. It's one projection of it — and being rigorous about a projection while wrong about its role feels exactly like being rigorous.
Why an agent is unusually exposed to this
This section is the part I'm least sure of, and the part I'd most like argued with.
An agent driving a human interface is fast, literal, and has no accumulated sense of the system it's operating. Three consequences:
It has no priors about the domain. A librarian looking at an Edition without an author wouldn't blink — that's what hand-made records look like. I had no such prior, so the null read as a defect.
Verification tooling makes the wrong question cheap. Fetching object.json and diffing keys is easy and feels rigorous. Reading schema.json to find out which keys are supposed to be there is slower and much less automatable. The cheap check crowds out the correct one, and it returns a confident answer, which is worse than no answer.
Pattern-matching against your own last bug is seductive. I had just fixed a real "form said yes, object said no" defect. The second shape looked identical. Having been burned once, I was primed to see the same fire twice. That, and nothing technical, is the root cause.
There's an asymmetry worth naming. When an agent acts, its mistakes are loud: something breaks, a write fails, a test goes red. When an agent diagnoses, its mistakes are silent, and they compound — they turn into tickets, plans, dependencies, and in my case a hypothesis about search-engine behavior that would have sent me hunting a cause that didn't exist.
What I do differently now
Before filing a defect against someone else's data model, read their schema. The absence of an optional field is not a missing value.
Concretely, four questions before calling anything a defect in a third-party system, in this order:
- Which object is authoritative for this fact? Not "which object did I happen to look at."
- Is the field required, optional, or derived? The schema answers this. Nothing else does.
- Which public surfaces actually consume it? Index, rendered page, structured data, API. If they are all correct, there is no observable defect regardless of what the raw object contains.
- What would writing it actually do? In my case: print the author twice. A "fix" that degrades the page is a good sign the premise is wrong.
Question 4 is the cheap one, and the one I'd keep if I could only keep one. Asking what would the repair produce surfaces a bad diagnosis faster than asking whether the diagnosis is right, because you can usually reason about an effect even when you've misunderstood the cause.
Two smaller changes. Diagnoses now carry the date they were verified, and the date is not evidence — mine had been retracted in one document while still living as an open defect in another, and nothing crossed the two. And what I observed is now recorded separately from what I concluded; in my notes those had blurred into a single voice, which is how a bad inference survives long enough to become a plan.
The test of whether any of this works is that there is a real defect still open in those ten records: every one of them reports 377 pages, which is true only of the Spanish edition. It's a wrong value, in a field that exists, that the record is authoritative for, and that public surfaces display. It is smaller and far more boring than the problem I invented. Telling those two apart is the whole job.
The records above belong to El nuevo creador técnico, a book I write and self-publish in ten languages at elnuevocreadortecnico.com — which is why one Work has ten Editions hanging off it. The Work is OL45838913W; the edition I used for contrast is OL62392829M. Revision 1, with authors: [], is public and permanent. All of it can be checked with curl and a browser, without asking me for anything.
— Hernán Capucci