Skip to content

D11 — Does a corpus subscribe to sources, or does the engine name corpora?

Status: PENDING OWNER DECISION. Nothing below has been executed. The binding runs engine-to-corpus today and stays that way until somebody with standing signs this off. §6 is the recommendation, §7 is what would falsify it, §8 is the part of the scoping this document disagrees with.

The question. config/sources.yaml is engine-owned, and each source entry declares discovery.regimes — the corpora it captures for. So the engine names the corpora. Should the direction invert: a corpus declares which sources it subscribes to, and each source’s regime is derived from where the declaration was found?

Who is affected. Every corpus repository that will ever run the poll path — regime-eu, regime-uk and regime-us-cfr exist and none of them can — and anyone adding a source or a corpus after this is settled.


The failure originally reported was two defects wearing one error, and one of them has since been fixed. Separating them is what leaves this decision its actual subject.

Fixed, and not this decision’s: resolve_sources used to bind every configured source before anything narrowed them, and to raise rather than record. So a corpus asking for any source at all died on eur-lex, the first entry — including fixreg-local, which declares no discovery block and therefore no regimes, and which never got bound at all. It now binds only the sources a run will poll, and records a source it cannot bind as a skip with the reason. Do not let that fix be counted as this proposal’s benefit; equally, do not read the original report as evidence that the binding reaches a source which opted out. It did not. The sweep died before it got there.

Not fixed, and this decision’s: the engine’s source list names a corpus. eur-lex, uk, us and gazette each declare discovery.regimes: [fixreg], and a corpus that holds no fixreg is asked to produce its SCOPE.yaml. Reproduced against the current tree, from a scratch corpus holding one regime that is not fixreg:

asked forresult
nothing (all)bound fixreg-local, gazette, us; unbound eur-lex, uk — “cannot bound discovery by regime ‘fixreg’: no scope declaration at /…/regime-eu/regimes/fixreg/SCOPE.yaml”
fixreg-localbound
eur-lexbound nothing; the same skip

So the blast radius shrank from “no corpus can run the poll path at all” to “every corpus silently skips the two sources it most needs”, and the skip is now legible rather than fatal. The defect is the same one and it is smaller and better-reported. That changes the urgency, not the answer.

docs/runbooks/new-regime.md already records it, and already names the answer — “Which regimes a source serves is a fact about a corpus, living in an engine-owned file” and “the binding is what has to move.”

2. Is this a two-roots instance? No — and that matters

Section titled “2. Is this a two-roots instance? No — and that matters”

The ownership table (tooling/tests/test_two_roots.py) names the hazard exactly: “a parameter spelled root, which makes no claim about which of the two it is.” The family stands at ten instances, the most recent three arrived through a root: Path | None parameter rather than a repo_root() call — which is why renaming the function was rejected, correctly — and the table has already caught an unrelated change silently. The structural guard works. That is the setting for the question, and it sharpens it: does inverting the source binding remove cases the table would otherwise have to keep catching, or relocate them?

Neither. It removes zero, because this is not one of them. The class the table guards is path resolution — which of two directories a shared file is found under. Applied here, both resolvers are already right: config/sources.yaml is the first row of the engine column and resolves to the engine, as it should; SCOPE.yaml is found under regimes/, a corpus row, and resolves to the run root, as it should. No root parameter is at fault. The failure is an engine-owned file carrying corpus-owned data.

Two consequences, and they point in opposite directions.

Against reading this as a two-roots win: no row of the table could ever have caught this, and none would catch it after the inversion. The table’s workload is unchanged either way, and the inversion buys the table nothing. The next instance of this confusion would go undetected exactly as this one did — which is why §6’s third amendment matters more than the move itself.

For doing it anyway: the class this does remove is real, structural, and described in §3. So the claim “this removes a class rather than an instance” is true, and it is about a different class than the one it was offered against. That distinction is the whole reason to check rather than repeat it: an inversion sold as a two-roots fix would be judged against a guard that has no opinion on it.

That class has three known members, and two are already settled — by a different mechanism, which is itself informative:

engine-shipped fileownershiphow it was resolved
config/trust.yamlcorpus-ownedfails closed from a corpus, naming the file to write
config/eval.yaml + fixtures/eval/questions.jsonlcorpus-ownedfails closed, naming the file it wanted
config/sources.yamlengine-owned, with corpus data inside itunresolved — this decision

The first two were fixed by making the corpus bring its own file. That option is available here and is the wrong one: a corpus should not carry a copy of the engine’s adapter list, endpoints and fetch policy. sources.schema.json pins the offline_fixtures paths to ^fixtures/… precisely because they are engine-relative, and new-regime.md says outright: “do not resolve it by giving a corpus a fixtures/ directory or a copy of the engine’s config.” So the third member needs the third mechanism — split the file along the ownership line — and that is what the inversion is.

This is the argument that does the work, and it is structural rather than rhetorical. discovery.regimes is a list, so the mapping from source to regime is one-to-many. Every consumer needs one-to-one. So every consumer carries its own clause for the case the schema permits and the code cannot serve:

in tooling/openregs/cli/main.pywhat it needs one regime forwhat it does otherwise
_poll_lock_pathwhich SOURCES.lock a poll appends torefuses, asking for --lock
_poll_precedencethe regime’s publisher_precedencereturns (); the canon writer then refuses any disagreement
_poll_amendmentswhich documents are amending actsreturns frozenset()
_replay_scopethe same amendment exemption, for replayreturns None
_replay_canonwhich canon a replay rewritesrefuses, asking for --canon

Five clauses, three of which degrade silently. _poll_amendments is the one to look at: its own docstring says the scope crawl and the replay take the amendment exemption from the same declaration, and “a poll that did not would quarantine a document the other two admit.” A source serving two regimes therefore quarantines amending acts, quietly, while the two other paths admit them.

Stand in the corpus instead and the regime is known before any of those five questions is asked. All five clauses become direct lookups and the divergence cannot arise. That is the class removed — not a two-roots class, a one-to-many-where-one-is-required class, and it is removed by construction rather than by five agreeing fixes.

4. The subscription already exists, three times over

Section titled “4. The subscription already exists, three times over”

SCOPE.yaml names sources today, in three places:

  • seed_instruments[].source — required by scope.schema.json, described as “Which configured source in config/sources.yaml serves this instrument”;
  • amendments[].source — the same;
  • publisher_precedence — “each entry is a source_id in config/sources.yaml”, and regimes/fixreg/SCOPE.yaml lists all five.

So the binding is not one-directional today. It is a cycle: the engine’s source names a regime, and _poll_precedence then reads that regime’s SCOPE.yaml to get back a list of source ids. main.py:2971 is that round trip in one line.

This is the strongest reason to invert rather than to patch: one of the two directions is redundant, and the redundant one is the engine’s.

Two places, and the first is load-bearing.

directory_codes re-creates the same failure one field over. The scoping keeps the publisher address spaces — cfr_parts, series, directory_codes — engine-side. For the first two the shipped file argues it and the argument holds: a CFR part and a gazette series are the publisher’s own way of addressing its catalogue. For directory_codes the shipped file argues the opposite, in the eur-lex entry’s own comment:

a real deployment lists whichever branches its own regimes live in … The branch follows the regime, not the project.

(The elision is two example subject areas, and it is deliberate: this repository names no industry, and quoting a comment is not an exemption from that.)

And it bites, mechanically. DiscoveryScope.admits tests the directory before it tests the declared instruments, and _in_directory filters unless the list is empty. The shipped value is 10.40.10.30, the fixture’s synthetic branch. So a real corpus that subscribes to eur-lex and inherits it has every real act rejected — directory 32.xx is outside 10.40.10.30 — for exactly the reason discovery.regimes rejects it today: an engine-owned field carrying the fixture corpus’s data. Leaving it behind moves the failure without removing it.

A corpus can now name a source that does not exist. The proposal’s check-regime row answers this, but only against the engine the corpus has pinned; a source removed from the engine later goes unnoticed until the pin moves. That is a cross-repo version-skew problem rather than a two-roots one, and it is strictly the better failure: today a correct corpus fails for naming nothing wrong, whereas after the inversion only a corpus that named something fails.

Invert it. resolve_sources should ask which sources this corpus subscribes to and derive each source’s regime from where the declaration was found. Three amendments to the proposal as scoped:

  1. No new sources: key. publisher_precedence is already the corpus’s list of source ids and already means which publishers this regime believes. Make it the subscription, and require every instrument.source to appear in it. A fourth list of source ids in the same file would be a second copy of one fact, which is the failure mode openregs bump refuses in as many words: “there is deliberately no second pin file … the failure mode of two copies is that they disagree while both look authoritative.” Under a sources: key, check-regime would have to assert publisher_precedence ⊆ sources and instrument.source ∈ sources — the reconciliation tax, paid forever, for a list that is derivable.
  2. Move directory_codes with it, for §5. Keep cfr_parts and series engine-side, on the file’s own argument.
  3. Add the check that makes the class unenterable, not just the instance fixed: assert that config/sources.yaml names no regime at all. One assertion, offline, and it is what turns this from a fix into a rule — a contributor who reaches for discovery.regimes again gets a red test rather than a review comment.

Consequences, stated plainly because they are the price:

  1. Absence of publisher_precedence changes meaning. Today it means unranked, and an unranked disagreement fails the canon write. It would come to mean subscribes to nothing, so a corpus with no list can poll no source. Both fail closed, but it is a semantic change to a shipped field and the schema’s description has to change with it.
  2. openregs init-regime must scaffold it. The template writes no publisher_precedence today. It already takes --seed-source, so the one-element list is free — and a one-element ranking is a no-op, which is the right shape for a corpus with one publisher.
  3. A source serving several corpora in one process becomes unrepresentable. See §7; it is unsupported in fact already.
  4. The five clauses in §3 must actually be deleted, not left beside the new path. Two of them would otherwise remain as the only readers of a field nothing writes.

A deployment that must poll one source for two corpora in one process. The inversion makes the source’s regime a property of where the subscription was found, so one poll serves one corpus. If the owner intends the multi-corpus deployment — one operator running regime-eu and regime-uk from one process, polling eur-lex once for both — then the engine has to name them and the current direction is right. What that would require is honest work rather than a slogan: the five clauses in §3 would have to be made to work for the many case instead of refusing it. No entry in the shipped file names more than one regime today, so the many case has never been exercised and its cost is not established.

Evidence that the source list is genuinely engine knowledge end to end. The recommendation assumes the endpoint, the adapter and the fetch policy are the engine’s while the bound is the corpus’s. If a real corpus turns out to need its own endpoint — a mirror, a contracted feed, a national portal the engine does not ship an adapter for — then the split is in the wrong place and the corpus needs a source definition, not a subscription. This is not established offline: no corpus in this organisation has captured a real act through the poll path, so no deployment has yet demanded anything of the source list.

8. What this does not fix, and must not be sold as fixing

Section titled “8. What this does not fix, and must not be sold as fixing”

The real class is larger than discovery.regimes: the engine’s source configuration is also the fixture deployment’s source configuration. Three members of that class are visible in one file, and two further defects sat in front of them, which is why the class was hard to see at all.

memberstatus
discovery.regimes: [fixreg] on four sourcesopen — this decision
resolve_sources aborting the sweep on one bad entryalready fixed; see §1
the adapters’ offline_fixtures catalogue and transport resolved against the run rootalready fixed — and that one was a two-roots instance, which is the contrast worth keeping in view
fixtures/scope/*-instruments.catalogue.json being fixture cataloguesopen, and untouched by the inversion: a real seed still resolves through the fixture’s catalogue and comes back unresolved. new-regime.md’s other blocker
the fixreg-local source itself, shipped to every deploymentopen, and untouched

Two of the five have been closed since the question was framed — one by a two-roots fix, one by narrowing what gets bound. Neither closed this one, and neither would have. So this is stage one of splitting config/sources.yaml along the ownership line: a shipped source catalogue that names no corpus, and a corpus-side subscription. It is the right stage one because it is the one still blocking three existing repositories, and because §6’s third amendment makes it a rule rather than a repair. It should be described that way and not as the end of the matter.

One item in the scoped cost is not this proposal’s to pay. check-regime’s _check_scope does not verify that seed_instruments[].source names a configured source — a corpus can declare a typo today and nothing catches it until a poll. That row is owed whether or not the direction inverts.

Not a date, and not the creation of another corpus repository — three exist and none changes anything above. Revisit D11 when one deployment must poll one source for two corpora, which is §7’s first falsifier and the only shape the current direction serves better; or when a corpus needs a source the engine does not define, which is its second and would move the split rather than reverse it.

Rendered from openregs/openregs@f3a2d10:docs/decisions/d11-source-subscription-direction.md