ADR-0053: A part claimed first is claimed over a contract, and an indexer source is pulled over its own
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted Date: 2026-10-25 Decided: 2026-10-25, by the maintainer, Wessel Verheij:
- a part that belongs to whoever claims it first is claimed over a new contract,
owner.claim@1, which the Usenet indexer aggregator and the listening server speak; - a curator that reads its indexers from an aggregator is told the aggregator over a new
contract,
indexer.pull@1, which the curator of books speaks; - both are facets the core asks of every service that speaks them, never chosen among fillers, spoken and never provided, and each is proved by its speakers’ conformance rather than by a substitute;
- an operation an upstream cannot perform answers
unsupported, conformance passes it, and the core treats it as not offered.
Amends ADR-0041: §10. A facet the core asks of every provider is proved by its speakers’ conformance, and has no substitute.
Context
ADR-0041 moves every
service module out of the core and into a plugin’s adapter, and the core speaks only
contracts. Three parts were still asked something no contract carried, measured against
the core at 2924e33d and this specification at 7ad6c1e:
- The Usenet indexer aggregator starts open. NZBHydra2 serves its configuration to
anybody until authentication is turned on. The core turns it on with an administrator it
names and a password it mints, restarts it, and checks that an anonymous read is then
refused. It fills
indexer.search, and noindexer.searchoperation says any of this. - The listening server has no account until one is made. Audiobookshelf answers
whether it holds an account, and makes its first one for whoever asks first. The core
makes it at first run. It fills
media.serve, which has nothing of the kind. - The curator of books reads its indexers from an aggregator. Bindery is told
Prowlarr’s address and key and pulls its indexers from there. The operations that say
so sit in
indexer.search@1’sAggregatorsgroup, the indexer’s contract, which Prowlarr answersunsupported.
A contract has no optional operations: an adapter answers every case of every operation of each contract it speaks, and a refusal the contract declares counts as an answer. Adding these operations to the contracts the three parts fill would hold every other speaker of those contracts to them.
Decision
owner.claim@1says who holds a part, and claims it.owner()answersOpen,Settling,OursorTheirs.claim()claims anOpenpart with the credential in the adapter’supstream.jsonand answers what it then is; it changes nothing that isOursorTheirs. The credential is the plugin’s minted secret and never crosses a call.- The core claims every speaker. It asks
claimat install’s first run and on every seed, and waits outSettlingwithin its patience. A partOpenafter a claim it recorded is drift; a partTheirsis refused, naming the plugin’s secret. One doctor check asksowner()of every speaker, and reset puts a claim back. indexer.pull@1tells a curator where to pull its indexers from.aggregators()lists the aggregators it reads from, andadd_aggregatoradds one by name, address, protocol and key. The protocol is the aggregator’s native API, so a curator is never handed an aggregator it cannot read.- The core pushes where it can and pulls where it must. Where the indexer’s app sync
serves a curator’s media, the indexer is told about the curator; otherwise a curator
speaking
indexer.pull@1is told about the indexer whose native API matches. Anunsupportedanswer leaves the two unpaired. - A facet is asked of every speaker.
owner.claimandindexer.pullare asked by the core of every service that speaks them, never chosen among fillers. A facet is spoken and never provided, so it has no entry in the capability vocabulary, whose capabilities are what a filler is chosen for. Each speaker’s conformance proves its facet, so neither has a substitute. - An operation an upstream cannot perform answers
unsupported. Conformance passes it, and the core treats the operation as not offered for that filler, by the answer’s type alone. An operation added to a contract holds every speaker to record again.
Consequences
- The core loses three more service modules. The NZBHydra2, Bindery and Audiobookshelf clients, and the guarding, claiming and aggregator steps written for each, give way to one claim step and one pull rule over every speaker.
- Three adapters are written. NZBHydra2’s speaks
owner.claim@1andindexer.search@1; Audiobookshelf’s speaksowner.claim@1andmedia.serve@1; Bindery’s speaksindexer.pull@1andlibrary.curate@1, its key minted. Each answersunsupportedwhere its upstream has no equivalent. indexer.search@1keeps itsAggregatorsgroup until its next major, which drops it.- The media server’s first run fits the same shape.
identity.source@1’sstartup_completedandcreate_adminare a claim, and can move toowner.claimat that contract’s next major.
Alternatives considered
| Alternative | Why not |
|---|---|
| A claim group inside each capability | The same pattern written into indexer.search and media.serve, and every other speaker of them held to it. |
| NZBHydra2 shipped with authentication already on | It cannot take over a running instance that is open, it rewrites the file it would ship, and the listening server would still need a contract. |
The Aggregators group moved into library.curate@1 |
Every curator of library.curate would answer it, for one curator that pulls. |
| Optional groups in a contract | A second axis through speaks, conformance, the recorder and the choice of filler, for what unsupported already says. |
The three kept in the core, reached through native |
The core keeps three service modules, against the core speaking only contracts. |
Revisit when
- a fourth part needs a claim that
owner()andclaim()cannot say; - adapters answer
unsupportedto most of a contract, which would make optional groups worth their cost.