docs: record each explanation once, in the venue that owns it - #396
docs: record each explanation once, in the venue that owns it#396thodson-usgs wants to merge 9 commits into
Conversation
9c0cd44 to
c95d6e9
Compare
Rationale accumulated in docstrings alongside the ADRs that already
owned it, because adding a paragraph where the reader is standing is
easier than citing a record they would have to open. An audit of
dataretrieval/ found ~500 lines restating decisions ADRs 0003-0011
already state: configuration.py re-derived 0009/0010/0011 in 65 lines
while citing them three times, and the no-progress budget was argued
from first principles in five places across transport.
That is a correctness problem rather than a tidiness one. Every copy is
a place the rule can be updated without the others, and two had already
gone stale -- an overview paragraph describing concurrency caps a later
ADR removed, and an ADR clause describing a credential rejection the
code deliberately no longer performs.
ADR 0000 records where each kind of explanation belongs: contract in the
docstring, local constraint in a short comment, history and measurements
in the commit message, cross-cutting decisions in an ADR cited by
number, vocabulary in CONTEXT.md. Two rules follow -- cite rather than
restate, and never edit an accepted record to reverse its meaning.
Public getter docstrings are explicitly out of scope; their numpydoc
tables are the deliverable and account for 55% of the package's
docstring lines at 2.5 lines of prose per line of code.
Applying it:
- 26 decisions the code was carrying land as clauses in the records that
own their subject (0004, 0006, 0007, 0008, 0009, 0011). New ADR 0012
records the deprecation policy, which ADR 0005 covered only for legacy
NWIS while four hand-rolled shims accumulated.
- 36 sites across 29 files drop the argument and cite the record, -189
lines. Each keeps the sentence saying what the code does; no numpydoc
section was touched and no code changed.
- Three ADR statements no longer matching the code are marked: ADR
0004's resume clause, superseded by 0008 without a backlink; the
overview's separate concurrency caps, removed by 0008; and ADR 0009's
claim that the **queryables path refuses `session`, which
credentials.py deliberately does not.
- CONTEXT.md gains Dialect, Single-shot adapter, Stall timeout and
Fitness function -- terms the ADRs used as if defined -- and loses two
self-contradictions. The largest fix is facade vs adapter: the
glossary reserves "facade" for a re-export with no logic of its own,
while the overview titled the public surface "facades" and then
described nwdc as one that builds CSV requests.
test_adr_references_resolve_to_a_record enforces the mechanical half in
every venue ADR 0000 names, so a renumbered record fails the suite
rather than leaving a dead pointer. Whether a paragraph should have been
a citation stays a review judgement: a prose-volume metric would push
contributors to delete parameter documentation to move a number.
Amended records note in Notes what changed after acceptance, including
where Decision text was corrected rather than added to. No decision is
reversed and no behavior changes.
History and measurements removed from source, preserved here:
_deprecation.py: four spellings of "tell the caller something is going
away" had grown up independently -- a dated decorator in nwis, an
undated kwarg shim in waterdata.utils, an undated module-level notice in
wqp, and one bare warnings.warn with no category. Only one carried a
date, so horizons could not be audited in one place.
ogc/engine.py base_url: it was once optional, falling back to ambient
scope -- which defaults to the empty string, so omitting it built a
relative /collections/{id}/items that planning accepted and only httpx
rejected at send time, surfacing as a NetworkError about an unknown
service.
ogc/chunking.py: ChunkedCall was this module's executor before it moved
down to transport as the API-neutral FanOut.
_configuration_core.py benchmarks: rebuilding the frozenset per read
measured a fifth of an adapter-scoped resolution; importing tomllib
eagerly cost ~4 ms of regex compilation for a file most callers do not
have; Path.home() dominated config_path resolution; stat(".") identifies
the directory ~17x cheaper than getcwd(). configuration.py: reading the
file twice cost a second stat on every adapter-scoped resolution.
The prose was then copy edited for clarity: 178 edits, of which 43 replace
evaluative wording with the property it was standing in for -- "the obvious
fix" says why it is reached for first, "worse than raising" names what it
costs, "a perfectly good table" becomes "a populated table". Descriptive uses
stay: "the value is silently wrong" reports an incorrect result, "both
spellings are correct" reports validity, and "best-effort" is a term of art.
Terms defined in CONTEXT.md are used rather than paraphrased, so "hiccup"
becomes "transient upstream failure" and "provenance" becomes "source".
Two files outside the PR's original scope are touched by that pass:
_querying.py, which was the third statement of the transient-5xx rule and the
only one left on the old wording, and ADR 0010, for one evaluative comparative.
A second sweep extends that to the rest of the package and the suite: 93
further replacements in comments and docstrings, where wording asserting
a quality is replaced by the property it stood in for. "A schema nothing
passes is worse than no schema" becomes what it costs the caller, "a
perfectly good [nldi] table" becomes "a valid" one, "the worst outcome
for a module whose job is to be trustworthy" names the outcome, and
"would type-check clean" becomes "would pass mypy". Imprecise but
descriptive wording is sharpened rather than dropped: "a bad value" is
an invalid one, "confidently wrong" is confidently incorrect.
Terms of art are unchanged and were counted before and after to confirm
it: naive datetime, tz-naive, CRS-naive, worst-case, best-effort, and
the HTTP reason phrase "400: bad request". So are identifiers -- the
_worst_case_args symbol and the parametrize ids spelled "bad" keep their
names.
The changes are prose only. Every touched module was parsed before and
after and compared as an AST with docstrings stripped: identical, so no
statement, identifier, decorator, parametrize id, or test name moved.
The one string literal that did change is an assert failure message
("expected a multi-chunk plan"); its assertion is untouched.
A final audit against CONTEXT.md corrects twelve places where the edited
prose blurred a distinction the glossary draws. A fan-out executes the
chunks a query was split into rather than being the query; a request
that fits gets a single-chunk plan, not a single-step one; concurrency
caps chunks in flight, not "subrequests"; a skip applies to a fan-out
over independent items, not a "batched retrieval"; cql is one of the
collection families, not a "family set"; and an overnight bulk
configuration profile is named in full where the Configuration class
could be read instead. ADR 0008's concurrency paragraph now says adapter
default throughout, matching the glossary and the overview.
Two undefined coinages in the prose this PR rewrote are replaced by what
they stood for: "bail-floor" is the all-singleton worst case, which the
surrounding parentheticals already spelled out, and ADR 0007's
package-wide clause says monitoring-location identifiers with NWIS's own
site_no named beside it, rather than "site numbers".
c95d6e9 to
87a87b0
Compare
ehinman
left a comment
There was a problem hiding this comment.
Mostly questions about what the text says and whether it makes sense to add an ADR0000 after ADRs 0001-11 have already been written. Does it make sense to edit the text of an old ADR, or add to it as an addendum? Seemed silly to read through editorial changes to past "Decisions".
| states the same order as seven rungs, splitting three of these sources into the | ||
| scopes inside them. An adapter default is not one of the branches here: it | ||
| arrives as the ``default`` a read site like :func:`concurrency` passes for its | ||
| own adapter. |
There was a problem hiding this comment.
No idea what any of this means.
There was a problem hiding this comment.
Fair — the paragraph reconciled the four sources here against ADR 0011's seven rungs, which is unreadable without 0011 open. It now says the two list the same order, that 0011 is the finer grain, and that an adapter default arrives as the default argument rather than being a fifth source.
| lines restating decisions already recorded in ADRs 0003 through 0011. Three | ||
| examples: | ||
|
|
||
| - ``dataretrieval/configuration.py``'s module docstring spends 65 lines |
There was a problem hiding this comment.
Are these examples needed?
There was a problem hiding this comment.
Trimmed. The three bullets fold into one sentence in the paragraph above — their file-by-file detail belonged to the audit, not to the decision.
| (``__init__.py``) exposes the service-adapter seam: ``OgcDialect``, | ||
| ``prepare_request_args``, and ``get_ogc_data`` (whose ``cql_body`` | ||
| parameter covers verbatim-CQL2 queries). | ||
| Protocol subsystem for Water Data and NGWMN. A facade (``__init__.py``) |
There was a problem hiding this comment.
Can we not use "facade"? That's a very AI-y word.
There was a problem hiding this comment.
Leaving this one, with the reasoning: "facade" is the Gang of Four pattern name rather than an AI coinage, CONTEXT.md defines it as a re-export with no logic of its own, ADR 0007 is titled with it, and three architecture tests are named for it. Renaming it is a vocabulary change across the ADR set, the glossary, AGENTS.md, and the tests — worth its own PR if you want it. Say the word and I will open one.
There was a problem hiding this comment.
Fair enough. And I see it defined in the CONTEXT.md In another PR, can we link to the CONTEXT.md in a few more places? Like in the README? This is really helpful for people digging a little deeper into the architecture who are not familiar with the jargon.
There was a problem hiding this comment.
I recommend something like README -> CONTRIBUTING -> CONTEXT, etc. for humans, and AGENTS.md -> CONTEXT, etc. for bots
|
@ehinman, thanks for the review. The idea of AD000 was that it will become a general ADR for documentation that we can reuse across projects. That's the longerterm goal at least. When to create a new ADR versus amend an old one is also TBD. The ADR practice seems to be useful, but we need to adapt it for agents that are less human and more like the guy in Memento. |
Review of DOI-USGS#396 flagged prose that assumed vocabulary or context the reader does not have, and asked whether amendments to accepted ADRs are visible as amendments. No code changed. Wording: - "Sugar for" and "the dunders" named the mechanism in jargon rather than naming what runs; `__len__` and `__iter__` are now spelled out. - `credentials.py`'s note on why `session` is absent was in past tense, as though describing a bug that had been fixed rather than the reason the list reads as it does. - `configuration.py` reconciled its four resolution sources against ADR 0011's seven rungs, a mapping that meant nothing without 0011 open. It now says the orders are the same and that 0011 is the finer grain. - ADR 0000's "The problem is elsewhere" read as a claim about time rather than about place; a reviewer asked whether it was still true after this PR. It now names the internal modules. The three worked examples fold into that paragraph, since their detail was the audit's, not the decision's. - ADR 0012 said "the category" with no antecedent, and inventoried the four deprecation spellings module by module -- history the commit that removed them owns, per this PR's own policy. Amendments: Each amended record already logged its added clauses in `Notes`, at the bottom. A reader met the amended Decision text first and the fact of amendment last. `Status` on 0004, 0006, 0007, 0008, 0009, and 0011 now says the record was amended and where the log is, and ADR 0000 states that placement as the rule. `facade` was raised as jargon and is left as is: it is the GoF pattern name, CONTEXT.md defines it, ADR 0007 is titled with it, and three architecture tests are named for it. Changing it is a vocabulary decision for its own PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
The venue rules answer where an explanation goes. Nothing in them asked who it
reads for, and the review of this PR found the gap: eleven comments, all the
same defect. Undefined jargon ("sugar", "dunders"), a pronoun with no
antecedent ("the category"), a referent that read as temporal rather than
spatial ("the problem is elsewhere"), a mapping between two numbering schemes
that needed a second document open, and an amendment a reader met after the
text it amended.
One of the eleven broke this record's own rule: ADR 0012 inventoried the four
deprecation spellings module by module, which is history the commit that
removed them owns. The author applied the single review question this record
supplied and still missed it, which is the argument for a second one.
So: a third rule, and a second question. The rule says a citation replaces an
argument only if the sentence left behind stands on its own. The question asks
whether a reader who has not opened the cited record can follow it. Both are
guarded against the obvious misreading -- the repair is to restore the reader's
footing, not to restate the argument the citation replaced, which would undo
the record.
This record now amends itself, so it follows the rule it just stated: `Status`
says it was amended and `Notes` logs the clause. Getting that wrong here would
have repeated the ADR 0012 defect in the act of fixing it.
`Notes` also marks which sections are this package's and which hold anywhere,
so the record can be adopted elsewhere without carrying dataretrieval's audit.
Its `Decision` keeps this package's vocabulary -- getters, service adapters --
because neutering those to make it portable would make it a worse record of
this project's decision. The generic wording belongs in a template, not here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
Re-reading the package against ADR 0000, now that it also asks who prose reads for, found the review's own findings still in place elsewhere. Two were the same defect in a second location, missed because the first fix matched on the instance rather than the pattern: - ADR 0012 said "ADR 0004 covers the category assertions" in `Compliance`. Review flagged that exact phrasing 26 lines earlier, where "the category" had no antecedent; `Compliance` kept it. It now names the warning category and what is asserted about it. - "sugar" survived in `waterdata/utils.py` and in the user guide, where it is user-facing. The user guide now says "shorthand for"; `_with_state` says the parameter adds to rather than replaces the native ones. Two more are the reader's-position rule applied to prose that predates it: - `_configuration_core.py` deferred outright: "the file refuses the same key in the same words, for the reason ADR 0011 gives". A reader without 0011 open learns nothing. It now gives the reason -- a base URL from outside the code redirects the library where the script's reader cannot see it -- and keeps the citation. - `configuration.py` explained source-major precedence by contrast with "scope-major", a term appearing exactly once in the repository, defined in neither the glossary nor an ADR, and used only in its own negation. The alternative is now described rather than named. ADR 0010's edit is reverted. This PR had changed one word of its accepted `Consequences` text with no `Status` line and no `Notes` entry, breaking the amendment rule this PR itself adds. The rule wants the edit recorded or not made; recording a copy-edit is ceremony over nothing, and the review asked not to read editorial changes to past decisions. So 0010 leaves this PR untouched, and the six records carrying amendment notices are exactly the six that were amended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
CONTEXT.md defines **Source** as where a setting's value came from. The code called that a `tier` and used `source` for something else -- the display string naming the concrete origin, `API_USGS_RETRIES` or a file path. So `_resolve` returned `(raw, source, tier)` where the glossary's Source was the third element and the second contradicted it. ADR 0010 calls the same thing a tier and ADR 0011 a rung: four names, two of them for one concept. ADR 0000 settles which way this resolves -- where a term and the code disagree, the term wins -- so the code moves. `tier` becomes `source` and the display string becomes `label`, in the seven read functions and `_resolve` where both are in scope at once. The parse and coerce helpers in `_configuration_core` keep their `source` parameter. No `tier` exists anywhere near them, so nothing there is ambiguous, and renaming ~100 call sites to settle a collision that does not occur in that layer would be churn in a module this branch is otherwise not rewriting. ADRs 0010 and 0011 are not edited. They are accepted records, and their wording was current when they were written; CONTEXT.md now says a tier and a rung are this term, so a reader meets the mapping in the venue that owns vocabulary rather than by inferring it. The glossary also separates the two senses the code had merged: the source is the category, the label the instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
ADR 0000 gives commit messages the history: what the code used to do, what was tried, what a change replaced. Three sites in source were still dating themselves to a change instead of describing what is there now. - `utils.py` called itself "the historical home of the legacy query path" and said the query path "used to sit alongside it". A reader wants to know the module re-exports names whose documented path is `dataretrieval.utils`; the move that produced that arrangement is in the log. - `transport/pagination.py` said the driver owns "the composition three adapters used to copy". The composition is the contract; how many adapters once duplicated it is not. - `credentials.py` said `configure()` "now" takes `Configuration(api_key=...)` and that an exact-match list "missed" `x_api_key`. Both are true statements about a moment. Present tense says the same thing without going stale: the substring match is there *because* an exact-match list misses that spelling. What that prose carried, for anyone looking for it: The query path moved out of `utils` into `_querying`, and the WQX3 / legacy-WQP column conventions into `_wqx`, when `utils` was split so a service-neutral shaping helper did not import a service's conventions (ADR 0001). The re-exports stayed because `dataretrieval.utils.<name>` is a documented public path. `transport/pagination.py` was extracted after NWIS, WQP, and the OGC engine had each grown a copy of the same page walk, differing in whether they reused the executor's client and which URL the aggregate reported. `credentials.py` began as an exact-name list. It missed `x_api_key` -- the spelling this library's own documentation makes most tempting, after the `X-Api-Key` header -- which is why the check matches substrings of the separator-stripped name. The fourth history site, the shared-setting-group block in `_configuration_core.py`, is rewritten in the commit that follows: its history and its restatement of ADR 0011 are the same paragraphs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
Three comment blocks had outgrown the one-or-two-line rule because they were not explaining the lines beneath them. Each re-argued a decision an ADR already owns, and the record said it better, having room to. - `_configuration_core.py`'s shared-setting-groups banner ran 31 lines re-deriving ADR 0011's "an adapter composes shared setting groups; it does not respell their fields" -- down to the same `retries: int | None = _UNSET` example the record uses. Now 11: what is true of the classes beneath (plain mixins, frozen, reverse-MRO field order), and a citation for the design. - The scope-stack comment argued for 15 lines why frames beat a merged mapping, most of it restating the nesting rule ADR 0011 states and tests. The reason *frames* is local and stays, in one sentence. - `credentials.py` explained across six lines why the predicate lives in the leaf. ADR 0006 says it in as many words -- "Refusing credential-shaped keywords is the credentials leaf's job... one definition, so ten getters cannot drift into ten spellings of the same check." Now one sentence and a citation. Also trims "rather than six times" from the profiles banner, which was the last echo of the history the previous commit moved out of the first block. That banner is otherwise left alone: it is the same shape but not the same defect. It states its claims and attaches a record to each rather than re-deriving them, which is what citing looks like. The prose removed here is not lost. ADR 0011 carries the setting-group argument and the nesting rule; ADR 0006 carries the credentials-leaf placement. The one thing neither holds -- that the annotations in an adapter module would be decorative, so a drift to `retries: str | None` would pass `mypy --strict` and fail only when a value reached the chain -- is ADR 0011's too, in the clause's final sentence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
This reverts commit 0efe23e.
One word, "source", names two things. The ordered category a value came from -- block, environment, file, built-in default -- and the exact thing within it, `$API_USGS_RETRIES` or a path to the config file. Both readings are natural, and both are already published: the user guide says "sources are merged" for the category and "names the exact source" for the instance, four lines apart in places. The glossary defined only the first, so a reader had no way to tell which was meant. It now defines both, and says which is which. The code is left alone. It spells the pair the other way around -- `_resolve` returns the origin label under the name `source` and the source under the name `tier` -- which reads backwards against the glossary, and the glossary now says so rather than leaving a reader to work it out. An earlier version of this branch renamed the code instead, and that was wrong twice over. ADR 0000's glossary rule binds *documents*: "Documents use those terms rather than redefining them, and where a term and the code disagree, the term wins" decides which word prose writes, and only needs stating because the identifier is not going to move. Reading it as licence to rename identifiers also put executable changes in a branch whose whole claim is that it changes none. That commit is reverted; this is the docs-only fix the rule asks for. Renaming the pair remains defensible on its own merits -- one word, one meaning -- but it is a refactor touching ~123 sites, a public docstring, and the user guide. It belongs in its own PR, judged as a refactor, not carried by a documentation policy that does not reach identifiers. ADRs 0010 and 0011 keep "tier" and "rung"; the glossary maps them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAEQqs7XzQHGQQi2KakuXD
| or a config-file path — which names the concrete thing a value came from, for | ||
| error messages and `show_configuration()`. The source is the category; the | ||
| label is the instance. | ||
| setting does not displace another setting's value from a lower source. |
There was a problem hiding this comment.
This is confusing because you got rid of source in a lot of places and replaced it with tier and don't define tier anywhere.
Why
Rationale accumulated in docstrings alongside the ADRs that already owned it, because adding a paragraph where the reader is standing is easier than citing a record they would have to open.
An audit of
dataretrieval/found ~500 lines restating decisions ADRs 0003–0011 already state:configuration.py's module docstring re-derived ADRs 0009/0010/0011 in 65 lines — while citing them three times.transport/retry.py,transport/liveness.py, andtransport/fanout.py.This is a correctness problem rather than a tidiness one. Every copy is a place the rule can be updated without the others, and two had already gone stale — an overview paragraph describing concurrency caps a later ADR removed, and an ADR clause describing a credential rejection the code deliberately no longer performs. A reader has no way to tell which copy is current.
What ADR 0000 decides
Each explanation is recorded once, in the venue that owns that kind of knowledge:
Parameters,Returns,Raises,ExamplesCONTEXT.mdThree rules follow: cite rather than restate, write from the reader's position, and never edit an accepted record to reverse its meaning.
The middle rule was added after review. The venue rules answer where an explanation goes; nothing in them asked who it reads for, and review of this PR found eleven places where correctly-placed prose was still unreadable from outside the author's head. One of them broke this record's own history rule — ADR 0012 inventoried the four deprecation spellings module by module — which means the single review question this record shipped with was not enough.
Compliancenow asks a second: could a reader who has not opened the cited record follow this sentence?Public getter docstrings are explicitly out of scope. They hold 55% of the package's docstring lines at 2.5 lines of prose per line of code, and that is the deliverable — the policy says so in as many words, so this cannot be read as licence to trim them.
What changed
MAX_CONCURRENT_REQUESTSis gone), and ADR 0009's claim that the**queryablespath refusessession, whichcredentials.py:114deliberately does not.CONTEXT.mdgainsDialect,Single-shot adapter,Stall timeout,Fitness function— terms the ADRs used as if defined — and loses two self-contradictions. The largest fix is facade vs adapter: the glossary reserves "facade" for a re-export with no logic of its own, while the overview titled the public surface "facades" and then describednwdcas one that "builds CSV requests".Enforcement
test_adr_references_resolve_to_a_recordasserts everyADR NNNNcitation resolves to a record, across every venue ADR 0000 names — package,docs/,CONTEXT.md,CONTRIBUTING.md,AGENTS.md. A renumbered record fails the suite instead of leaving a dead pointer. It caught one bad citation in this PR's own diff.Whether a given paragraph should have been a citation stays a review judgement. ADR 0000 argues against a proxy metric explicitly: a prose-volume ratio would push contributors to delete parameter documentation to move a number.
Review notes
Status. Review asked whether these edits should be addenda. They were —Notesheld the log — but at the bottom, so a reader met the amended Decision text first and the fact of amendment last.Statuson each amended record now says it was amended and points atNotes.Noteswhat was added after acceptance, and where Decision text was corrected rather than added to — ADR 0006's retry scope was widened from "gateway 5xx", which was never true of a fanned-out call. No decision is reversed.base_urlwas-once-optional story, and the_configuration_core.pybenchmarks.Conflict with #388
Both branches amend ADR 0006 and both add a
Notessection at the end, so they will conflict. It is a straightforward both-added conflict; land #388 first, since it is further along.Verification
1130 passed,mypy --strictclean over 60 files, ruff / xenon / complexipy / import-linter all green, every ADR parses clean as RST.🤖 Generated with Claude Code