Research/Engineering note

A sandbox DOI looks like a real one

Publishing our dataset, a rehearsal returned a DOI under a test prefix. Nothing about it looked wrong: same shape, same length, and the archive built, the upload succeeded and the page rendered a citation. The only check that separates it from a real one is resolving it.

We were publishing the dataset behind our 800-domain study, and we rehearsed the deposit on Zenodo's sandbox first, which is exactly what a sandbox is for. It returned this identifier:

10.5072/zenodo.585409

Production Zenodo mints under 10.5281. 10.5072 is DataCite's test prefix. Nothing else about it differs: same shape, same length, same structure, same place in every string that consumes it.

Every step downstream succeeded

This is the part worth sitting with. A wrong identifier does not fail loudly, because nothing downstream of it is checking what it means. In our pipeline it:

  1. rendered a correct-looking "How to cite" block in the archive README,
  2. let the archive build without complaint,
  3. uploaded to object storage successfully,
  4. and made the study page render its data section, because that section is gated on the DOI field being merely truthy.

Every green light in the process stayed green. The failure surfaces for one person only: a reader, months later, clicking a citation. And by then it is at a URL nobody can edit. Our archive path is versioned and immutable by contract, and Zenodo locks a record's files at publish, so the cost of catching this late is not an edit, it is a new version with a new identifier and a broken link in between.

The one check that settles it is resolving the identifier. Put doi.org/10.5072/zenodo.585409 in a browser and it redirects to a DataCite page that says, in as many words, that this is a test prefix.

Assert on what the thing does, not on whether it looks right. An identifier is a promise about resolution; the only test of it is resolution.

The general form

Whenever a service offers a sandbox, its outputs will flow through your pipeline exactly like real ones. That is the sandbox working as designed. It also means the boundary between rehearsal and production lives entirely in whoever is at the keyboard, which is not where a boundary should live.

Two changes, and the second matters more than the first. We reject the test namespace explicitly at the boundary, so a sandbox identifier cannot be pasted into the registry at all. And we gave the rehearsal a real, safe path of its own: a --rehearse flag that publishes to a scratch prefix and structurally cannot address the versioned one.

That second part is the design principle. Making the safe option also the convenient option is worth more than any amount of remembering, because a rule that makes the correct action inconvenient gets satisfied by a workaround that nobody audits.

A second way the same rehearsal lied

While hardening this, we wrote a guard that refuses to publish when the destination bucket is not configured. To verify it, we did the obvious thing: unset the environment variable and ran the publish command, expecting a refusal.

It published. For real. The script calls process.loadEnvFile on startup, which put the variable straight back. The guard never fired, because the condition it guards against never existed.

The semantics are asymmetric, and that is the whole trap. Measured on Node 24: loadEnvFile does not override a variable the shell has set, so the shell wins. But it does refill one the shell has unset. And "remove the variable" is precisely the control shape a fail-closed guard demands. The single control style the guard required is the one the script silently defeats.

Two rules came out of it. Never test a guard by running the command the guard protects: if it fails to fire, your test performs the very act it was checking for, so the failing case is the one with the side effect. Call the guard directly, where the input is genuinely controllable and nothing reaches the network. And make the control prove it varied what you think it varied by printing the value the guard will actually read, not the value you meant to set.

The same reasoning rewrote a later probe. We wanted to prove a scoped storage token could not reach the customer bucket, and the first version attempted a write into it. If the token turns out not to be scoped, that probe succeeds and has just written into the customer bucket. A list operation tests the same scoping with no side effect in either outcome. It came back denied, alongside a positive control proving the token works at all, because a refusal from a broken token is not evidence about scoping.

What do these three have in common?

A test identifier, a guard that never ran, and a probe that would have caused the harm it was testing for. In all three the output was indistinguishable from success, and in all three the resolution was the same: stop asserting on appearance, and assert on behavior. Resolve the identifier. Call the guard. Read the value that will actually be used.

Sources

  • The resolution above is reproducible right now. As of 2026-09-01, doi.org/10.5072/zenodo.585409 redirects to datacite.org/testprefix, which is how a test prefix announces itself.
  • Zenodo: manage records, for the rule that makes catching this late expensive. Metadata stays editable after publication; files do not. help.zenodo.org
  • The dataset this pipeline publishes, and the DOI it eventually got right, are linked from the study.

These notes are how the audit engine works and where it has been wrong. The data studies it produces are at /research, published with the sample, the method and the limitations.