Skip to content

RFC 089: Identifiers API - #156

Merged
kenoir merged 23 commits into
mainfrom
rk/identifiers-api-rfc
Jun 29, 2026
Merged

RFC 089: Identifiers API#156
kenoir merged 23 commits into
mainfrom
rk/identifiers-api-rfc

Conversation

@kenoir

@kenoir kenoir commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Preview

View the rendered RFC on this branch:


What does this change?

The proposal is a small, read-only Identifiers API. Wellcome Collection gives every catalogue thing (a work, an image, an item) a stable public "canonical" id, and keeps a registry recording which underlying source ids that canonical id was built from. This API does one job: given a canonical id it returns the source id(s) behind it, and given a source id it returns the canonical id (optionally with its siblings). It only ever reads that registry; it never creates or changes ids.

It exists because of the Sierra/CALM to FOLIO/Axiell migration. As records move between systems a single canonical id accumulates several source ids over time (an original plus inherited "predecessor" aliases), and a couple of internal services sit right at the edges where that translation has to happen: the IIIF viewer needs to turn old b-numbers and CALM refs into the canonical id it presents under, and requesting needs to turn a canonical item id into the FOLIO UUID a hold is placed on, and back again. The guiding principle is that everything public speaks canonical and source ids only appear at those two edges (ingest and the FOLIO boundary). Rather than have each consumer re-derive the mapping or query the catalogue by source id, this API is the single shared place that translation lives. Because the main running cost is database queries, it is also the natural place to cache aggressively (at the edge, to keep requests off the database) and to attribute that database cost to the consumers driving it.

How it relates to the other RFCs:

  • It reads the ID Registry defined in RFC 083 (stable identifiers). RFC 083 owns the data and all the writes; this API is just a read-only window onto it.
  • It serves the IIIF/DDS lookup that RFC 085 (IIIF identities, open PR RFC 085: IIIF identities #143) describes, where the canonical Work id becomes the IIIF URI and older identifier forms redirect to it.
  • It is the concrete "service" answer to the open question in RFC 088 (the Sierra to FOLIO identity/requesting migration, open PR RFC 088: Migrating identity, requesting and items APIs from Sierra to FOLIO #153) about how requesting should translate item ids. RFC 088 left that access mechanism open (a direct database read, a sync, or a service); this RFC proposes the service and is for RFC 088 to ratify.

The RFC is written to stand on its own and carries the API contract alongside it, so it can be reviewed without access to the closed discovery/prototype repository where the working prototype lives.

Files added:

  • rfcs/089-identifiers-api/README.md: the RFC document.
  • rfcs/README.md: refreshed RFC listing table (RFC 089 row added).
  • rfcs/089-identifiers-api/openapi.yaml: the OpenAPI 3.0 spec for the two lookup operations (the source of truth).
  • rfcs/089-identifiers-api/openapi.md: generated human-readable rendering of the spec, browsable on GitHub without a Swagger/Redoc renderer.
  • render_docs.py, pyproject.toml, .python-version, .gitignore, uv.lock: a self-contained uv project that validates openapi.yaml and regenerates openapi.md.

How to test

  • Read rfcs/089-identifiers-api/README.md and review the contract, architecture, caching strategy and open questions.
  • Confirm the RFC passes repo validation: .scripts/validate_rfc.py.
  • Confirm the listing table is in sync: .scripts/create_table_summary.py --check-readme.
  • (Optional) Regenerate the rendered contract and confirm no diff: from the RFC directory, uv run python render_docs.py: this validates openapi.yaml against the OpenAPI spec validator and rewrites openapi.md.

How can we measure success?

No measurable runtime success criteria; this is a documentation RFC. Success is the RFC being reviewed and providing a clear, self-contained contract and architecture that the team can align on, and a decision on whether this service is the access mechanism for identifier translation in RFC 088.

Have we considered potential risks?

  • Documentation only; no production code or infrastructure is changed by this PR, so there is no runtime or deployment risk.
  • The design risks themselves (the caching strategy and the database cost it controls, the unmet FOLIO-item ingestion dependency, item canonical-id stability) are enumerated in the RFC's Open questions section and are intended to be the subject of review.
  • The OpenAPI spec is validated and the rendered Markdown is generated from it, reducing the chance of the contract and its human-readable rendering drifting apart.

kenoir and others added 5 commits June 18, 2026 13:00
Read-only canonical <-> source identifier translation over the RFC 083 ID
Registry, for the IIIF/DDS (RFC 085) and requesting (RFC 088) consumers.

Carries the OpenAPI contract alongside the RFC (openapi.yaml + a rendered
openapi.md via a small uv project, following the RFC 088 pattern) so the
proposal stands alone without the private prototype repository. Covers the
contract, AWS architecture, API-key auth + usage-plan metering, the caching
topology, and the live-data findings (folio-instance aliases present;
folio-item-id absent, so the requesting translation has no data yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The concern is the cost of database (Aurora) queries, not policing a per-consumer
billing quota. That inverts the caching strategy: an edge (CloudFront) cache that
serves hits without touching the database is now preferred, rather than rejected
for breaking metering. Recasts the per-consumer story as API keys for identity /
cost attribution plus a throttle as a database safety valve, and reorients the
caching open question toward hit-ratio, throttle sizing and cost attribution.

Also removes the detailed real-data-findings list (kept in the prototype docs),
leaving a one-line pointer. Contract unchanged (openapi.yaml/openapi.md untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces em dashes with plain punctuation throughout and tones down a few
flourishes ("single translation membrane", "exactly the win", "evaporates").
Directional notation (the migration and lookup arrows) is kept. No change to the
contract, the decisions, or the meaning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the decision-log section and its table of contents entry, and renames the
contract-summary heading to "API Contract" (the separate "API contract (OpenAPI)"
section is unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avoids two similarly-named sections after "The contract" became "API Contract".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread rfcs/089-identifiers-api/openapi.md Outdated
Comment thread rfcs/089-identifiers-api/README.md Outdated
Comment thread rfcs/089-identifiers-api/README.md Outdated
Comment thread rfcs/089-identifiers-api/README.md
Comment thread rfcs/089-identifiers-api/README.md Outdated
Comment thread rfcs/089-identifiers-api/README.md Outdated
Comment thread rfcs/089-identifiers-api/README.md Outdated
Comment thread rfcs/089-identifiers-api/README.md
Comment thread rfcs/089-identifiers-api/README.md
Comment thread rfcs/089-identifiers-api/README.md
kenoir and others added 16 commits June 22, 2026 15:32
The reverse-lookup 200 response is oneOf [CanonicalIdRef, IdentifierSet]
but render_docs.py only handled single $ref bodies, so the generated
table showed 'n/a'. Render oneOf/anyOf as the alternatives joined by an
escaped pipe (allOf as an intersection) and regenerate openapi.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reviewer asked whether '400 ... unsupported enum value' contradicts the
rule that an unknown open-set sourceSystem yields 404. It does not: type is
the only enum-constrained parameter. Name it explicitly to remove the
ambiguity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… UUID)

The Sierra item number is the predecessor that lets the new FOLIO item UUID
inherit the existing canonical id, matching the work-level pattern. The text
had the direction reversed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…and)

Record the decision rather than leaving it open: SourceIdentifier.type is
scoped to the three catalogue-entity types the API needs (Work/Image/Item)
and the enum is extended on demand, rather than modelling the full registry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…question

Records that digitisation metadata ingestion fetches mostly unique ids while
the Items API is more likely to repeat requests, grounding the hit-ratio
sub-question in the concrete clients paul-butcher raised in review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…en question

Records paul-butcher's specific-sibling include idea (?include=sierra-system-number)
as a related projection to settle alongside the bare-value reverse lookup: more
cacheable, but only in the immutable new-to-old direction, returns a filtered set
given the one-to-many registry, and must return canonical with a 200 when the
requested sibling is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the Lambda arch row from the decisions table and the two inline ARM64
references; we don't pin Lambda architecture elsewhere in the estate, so it
doesn't belong as a decision here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records agnesgaroux's review point that a bare value is not only expensive to
index but can be genuinely ambiguous: the same SourceId can appear under
different source systems and resolve to different canonical ids, so a bare-value
query may have to return multiple candidates or force disambiguation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records agnesgaroux's review point: FOLIO records carry both a UUID and an HRID,
so confirm which the OAI-PMH feed delivers. The registry can hold both forms per
item, so the Minter could record both and this API would serve HRID <-> UUID
translation, but okapi resolves the two natively so storing both is an
optimisation, not a requirement. Decision sits with the catalogue-pipeline
workstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lift the two parallel Context concepts (canonical-first principle, the two
consumers) to subheadings, and rephrase the remaining standalone bold
sentence-starters (service boundary, schema finding, edge caching, freshness)
into formal prose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decide the isAlias vs obsolete question: the API exposes the full sibling set
with isAlias and does not model an obsolete flag. Records that the two are
different axes (in the Sierra->FOLIO migration the isAlias=false original is the
retired id and the isAlias=true alias is the live one), so this is a decision not
to model retired-ness rather than a claim that isAlias encodes it. Drops the now
settled item from the RFC 085 contract-edges next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…demand)

Reword Q6 from an open question into a recorded decision, matching Q5. Soften
the Open-questions intro now that two items are settled, and drop the type enum
from the RFC 085 contract-edges next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Settle Q7: do not hoist a convenience top-level type. A single top-level value
would have to pick one row's type for a mixed-type canonical id and could
contradict the others, so the per-row representation is kept and the mixed-type
ambiguity is left to consumers rather than resolved here. Drop Q7 from the RFC
085 next step, leaving only the bare-value lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Narrow Q4 against RFC 085's actual requirement (WorkID-level identity lookup,
sourceSystem as an optional qualifier, full sibling-set response, no new shape)
and decide not to add the unqualified bare form now: sourceSystem stays a
required key component, added only if a consumer explicitly requires it, the cost
(secondary SourceId index plus cross-system ambiguity) being the reason. The
related specific-sibling include is deferred on the same basis. Drop the now
resolved bare-value item from the next steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverse open question 7: add a convenience top-level `type` to IdentifierSet,
populated from the original (the single isAlias=false) row, so consumers can read
a canonical id's type without scanning the set. Exactly one row is isAlias=false,
so the source is unambiguous; with cross-type predecessors the top-level value
reflects the original and may differ from a later alias. Update the spec (add the
property, required, enum), regenerate openapi.md, and add the field to the README
example and field docs.

Also a formatting pass on the open questions: prefix the resolved items (4-7)
with Decided and reword the intro, so it is clear at a glance which questions
carry a decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kenoir
kenoir marked this pull request as ready for review June 24, 2026 13:18
@kenoir
kenoir requested review from a team as code owners June 24, 2026 13:18
kenoir and others added 2 commits June 24, 2026 14:19
@kenoir
kenoir merged commit df1402c into main Jun 29, 2026
7 checks passed
@kenoir
kenoir deleted the rk/identifiers-api-rfc branch June 29, 2026 09:27
changes are described at bib/work level; item-level predecessor emission needs confirming with the
pipeline workstream.

4. **Decided: no bare-value reverse lookup for now (RFC 085).** RFC 085's identity service is WorkID-level:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see the secondary index argument against supporting this, but for a consumer, it's not as clean as it could be. Consider an incoming IIIF manifest request to the DDS (iiif-builder):

iiif.wellcomelibrary.org/presentation/{identifier}

What does the DDS do with this? We have no other context at this point, we don't know what source system {identifier} corresponds to. This is the first point of contact.

The DDS current behaviour has lots of knowledge of what identifiers look like and how they work. First, we see if it's a b number, or a permitted variation on a b number. If the identifier is a permitted b number variant form, we normalise it to the standard form:

// 7-digit form:
https://iiif.wellcomecollection.org/presentation/b1803597
// calculate missing checksum (8) and redirect to https://iiif.wellcomecollection.org/presentation/b18035978

This was needed pre wc.org, when there were generated links from the Sierra catalogue to wellcomelibrary.org viewer pages using the pure numerical id 1803597. Now the Sierra catalogue is kept hidden from the public, so 7-digit links are unlikely, but the code still supports them.

We then see if we have the manifest stored under this key in S3. If so, great, we return it.

If it's not in S3 at the normalised b number form, or if it wasn't even a b number, then the DDS queries its Manifestations table, populated when the item was processed: information about its identities that DDS hangs on to but is not authoritative for. This allows us to handle things like:

https://iiif.wellcomecollection.org/presentation/zjytxny8
// redirect to https://iiif.wellcomecollection.org/presentation/b18035978

https://iiif.wellcomecollection.org/presentation/dpv7wsth
// redirect to https://iiif.wellcomecollection.org/presentation/b19995271

https://iiif.wellcomecollection.org/presentation/SB/1/1/298
// redirect to https://iiif.wellcomecollection.org/presentation/b19995271
// Note these last two are the same redirect, both work ID and Calm Ref redirect to the b number it was digitised under

In the future, the workID form becomes the canonical one, and we will redirect all other forms to that.

If we have a bare-value lookup - "tell us what you know about 'b19995271'" - then the DDS can defer to an authority for the identity. If not, we'll still have to do some tricks locally.

When we see b19995271 or b1999527 for {identity}, we know it's a b number and not a work id, because we can run it through the code linked above. But DDS has to retain that b-number-aware code to do so. And then, it can specifically ask for

/v1/identifiers/by-source/sierra-system-number/b19995271?type=Work

or if we see SB/1/1/298 for {identity}, the presence of / characters may trigger us to ask for:

/v1/identifiers/by-source/calm-ref-number/SB/1/1/298?type=Work
// identity service needs to handle the unbounded trailing {**path} elements!

... and if the worst came to the worst, we could ask for each source system in turn to see if we have a match. The more identifier format knowledge we retain in the DDS, the more likely we ask for the right source system on the first attempt.

Consider the string b3288596

Is this a work ID or is it a b number? It's actually valid under both schemes. It matches the 8-character work ID reduced alphanumeric set, AND expands to a valid b+8-digits b number:

iiif.wellcomecollection.org/presentation/b3288596

We don't know if we need to redirect or or not, until we've made a call to the identity service to resolve it.

How many work IDs are there that are a b followed by 7 digits? Probably not many. And it's even more unlikely that any of them are real Sierra b numbers. And if there are any of those, it's astronomically unlikely to be the same work!

The question is, how much knowledge of identifier forms do we want DDS to retain? It could defer everything to the identity service (with a short term memory cache), always resolving {identity} using the service and never deducing anything about it. If on receipt of the query results, {identity} turns out not to have been the canonical form, then redirect to the canonical form. This reduces the knowledge the DDS has, AND works for any future identifier scheme, but at the expense of that secondary index!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is, how much knowledge of identifier forms do we want DDS to retain?

Some, and I think the floor is set by the data rather than by this API. The "defer everything
to this service" option doesn't quite work at this level, even if we paid for the secondary
index, for two reasons.

First, normalisation stays with the DDS either way. The registry only holds the forms the
minter minted from: b18035978 is a row, 1803597 and b1803597 are not. So the
checksum/variant handling in WellcomeLibraryIdentifiers.cs has to run before any lookup,
bare or qualified. A bare-value endpoint would remove the need to guess the source system,
not the need to normalise.

Second, a bare-value lookup can enumerate the ambiguity but it can't resolve it, and the
ambiguous case is routine rather than rare. The registry is pre-merge: as @agnesgaroux pointed out
above, every digitised b-number sits under both sierra-system-number and mets, resolving
to two different canonical ids (the public work and the invisible METS work), and both rows
are type=Work so the type filter doesn't split them. Which of those canonical ids is the
public one is decided by the merger, and the registry doesn't know that, so this API has no
basis to rank the candidates. "Tell me what you know about b19995271" comes back as two
labelled matches and the DDS still holds a precedence rule (prefer sierra-system-number
over mets). We'd have swapped format-parsing for a selection policy, plus the index. And
the b3288596 collision straddles the canonical and source id spaces, so a bare source
lookup doesn't settle it either; the same policy decides.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants