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.
1. What the binding does today
Section titled “1. What the binding does today”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 for | result |
|---|---|
| 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-local | bound |
eur-lex | bound 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 file | ownership | how it was resolved |
|---|---|---|
config/trust.yaml | corpus-owned | fails closed from a corpus, naming the file to write |
config/eval.yaml + fixtures/eval/questions.jsonl | corpus-owned | fails closed, naming the file it wanted |
config/sources.yaml | engine-owned, with corpus data inside it | unresolved — 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.
3. What the inversion actually removes
Section titled “3. What the inversion actually removes”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.py | what it needs one regime for | what it does otherwise |
|---|---|---|
_poll_lock_path | which SOURCES.lock a poll appends to | refuses, asking for --lock |
_poll_precedence | the regime’s publisher_precedence | returns (); the canon writer then refuses any disagreement |
_poll_amendments | which documents are amending acts | returns frozenset() |
_replay_scope | the same amendment exemption, for replay | returns None |
_replay_canon | which canon a replay rewrites | refuses, 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 byscope.schema.json, described as “Which configured source inconfig/sources.yamlserves this instrument”;amendments[].source— the same;publisher_precedence— “each entry is asource_idinconfig/sources.yaml”, andregimes/fixreg/SCOPE.yamllists 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.
5. Where it relocates rather than removes
Section titled “5. Where it relocates rather than removes”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.
6. Recommendation
Section titled “6. Recommendation”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:
- No new
sources:key.publisher_precedenceis already the corpus’s list of source ids and already means which publishers this regime believes. Make it the subscription, and require everyinstrument.sourceto 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 modeopenregs bumprefuses 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 asources:key,check-regimewould have to assertpublisher_precedence ⊆ sourcesandinstrument.source ∈ sources— the reconciliation tax, paid forever, for a list that is derivable. - Move
directory_codeswith it, for §5. Keepcfr_partsandseriesengine-side, on the file’s own argument. - Add the check that makes the class unenterable, not just the instance
fixed: assert that
config/sources.yamlnames no regime at all. One assertion, offline, and it is what turns this from a fix into a rule — a contributor who reaches fordiscovery.regimesagain gets a red test rather than a review comment.
Consequences, stated plainly because they are the price:
- Absence of
publisher_precedencechanges 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. openregs init-regimemust scaffold it. The template writes nopublisher_precedencetoday. 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.- A source serving several corpora in one process becomes unrepresentable. See §7; it is unsupported in fact already.
- 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.
7. What would falsify this
Section titled “7. What would falsify this”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.
| member | status |
|---|---|
discovery.regimes: [fixreg] on four sources | open — this decision |
resolve_sources aborting the sweep on one bad entry | already fixed; see §1 |
the adapters’ offline_fixtures catalogue and transport resolved against the run root | already fixed — and that one was a two-roots instance, which is the contrast worth keeping in view |
fixtures/scope/*-instruments.catalogue.json being fixture catalogues | open, 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 deployment | open, 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.
9. Trigger to revisit
Section titled “9. Trigger to revisit”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