ADR-0039: Derive a valid-time window from date and supersession, and resolve a git ref at the CLI boundary
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-09-21 |
| Review by | 2027-09-21 |
| Schema version | 0.1.0 |
| Reversibility | two-way-door |
| Blast radius | component |
| Scope | component |
| Tags | cli, core, markers, temporal |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Review tier | async |
| Review reason | Adds a temporal contract and the first subprocess in @adrkit/cli. |
| Approvals | @mbeacom |
| Decided at | 2026-09-22T01:42:29Z |
| Relates to | 0009, 0021, 0022, 0031, 0033 |
| Affects | path:packages/core/src/temporal/**, path:packages/core/src/markers/resolve.ts, path:packages/cli/src/as-of.ts, path:packages/cli/src/index.ts, path:site/src/content/docs/commands.mdx |
| Source | docs/adr/0039-derive-a-valid-time-window-from-date-and-supersession-and-resolve-a-git-ref-at-th.md |
Status: accepted. Agent-drafted and explicitly ratified by
@mbeacomon 2026-09-21. Implements part B of #116, whose part A shipped in #187 under ADR-0022’s advisory rule. The decision is unchanged from the proposal; two defects in the mechanic that implements it were found in review and are recorded in Consequences.
Context
Section titled “Context”“Which decisions governed this file when this code was written?” is the question
every archaeology session asks, and no surface answers it. adr explain answers
the present tense only: decisionBucketFor sends superseded to history
unconditionally, which is exactly right for “what binds my change today” and
exactly wrong for “what bound this line in March”.
The corpus already contains the answer. Every record carries a date, and a
superseded record names the successor that replaced it, so a valid-time window
is derivable with no schema change: a window opens at a record’s own date
and closes at its immediate successor’s date. The bi-temporal shape is well
established, and #116 cites Zep/Graphiti (arXiv:2501.13956) and Hunch as prior
art over the same data.
Two things make this a decision rather than a feature.
First, the window is derivable only along the accepted → superseded
lineage, and that is not a simplification we chose — it is what
adr.schema.ts permits. The schema refuses supersededBy unless status is
superseded, so a deprecated record carries no close date anywhere in
frontmatter. There is nothing to read. A tool whose whole purpose is answering
archaeology questions, answering one confidently and wrongly, is worse than a
tool that does not answer it — which is the argument #116 itself opens with
about stale markers.
Second, --as-of a1b2c3d needs a git ref resolved to a date, and
@adrkit/core has no subprocess, no clock, and no filesystem traversal
(ADR-0009). This repository has settled that boundary twice already — marker I/O
stays outside checkChanges (ADR-0022), TTY detection stays outside the graph
renderers (ADR-0033) — and this is the same shape a third time.
Decision
Section titled “Decision”We will add adr explain <path> --as-of <date|ref>, built on three commitments.
-
A pure temporal kernel in
@adrkit/core.packages/core/src/temporal/derives windows and places records on a supplied UTC calendar date. No filesystem, no subprocess, and no clock: the date is always an argument, because a temporal answer that depended on when it was computed would not be reproducible, and reproducibility is the entire point of asking about a past date. -
A separate temporal bucketing, not a re-use of
bucketDecisions. The as-of view has its own five-way standing —governing,activeProposals,history,notYetRecorded,undetermined— with a half-open window[opensOn, closesOn)so the successor owns its own start day and exactly one record along a chain is in force on any date. Adeprecatedrecord that existed on the asked-for date is reported asundeterminedwith awarnfinding, never as governing and never as history. Arejectedrecord is history on every date, because it was in force on none. -
Git at the CLI boundary, with a stated precedence.
packages/cli/src/as-of.tsis the only subprocess in@adrkit/cli. The date grammar is tried first and a git ref only when the value is not a date, so a tag named2026-03-01reads as a date. The date half isresolveAsOffrom@adrkit/core, unwrapped, soadr queue --as-ofandadr explain --as-ofcannot phrase the same rejection two ways. A ref is peeled withrev-parse --verify --quiet <ref>^{commit}— annotated tags are tag objects, and this repository’s release tooling already turns on that distinction — and dated withgit show -s --format=%cI, the committer date.
Three scope boundaries are part of the decision, not omissions from it.
--as-ofre-dates the corpus, never the working tree.affectspatterns are read from today’s records and@adrmarkers from today’s file. Reading file contents at a past ref is a strictly larger contract — it needs a path that may have been renamed, a corpus that may not have existed, and a blob-level read — and is deliberately not attempted here. This is the boundary #116’s author named when deferring part B.- A marker is judged against the date being asked about. Under
--as-of,@adr 0007naming a record that was in force on that date is not stale. The present-tense diagnostic is unchanged; telling a reader to “update the marker” while the same output reports the record as governing on that date is two answers to one question. --as-ofisexplainonly, and has no default.adr checkand the CI Action stay present-tense. Absent the flag, stdout and--jsonare unchanged, and theasOfblock is additive — the present-tensegoverning,activeProposals, andhistorykeys keep their meaning underneath it.
Options considered
Section titled “Options considered”Option A: Derive the window from date + supersession, git at the boundary (chosen)
Section titled “Option A: Derive the window from date + supersession, git at the boundary (chosen)”| Dimension | Assessment |
|---|---|
| Schema change | None. date and supersededBy already exist and are already validated. |
| Purity | Kernel stays pure; the one subprocess is at the CLI edge, matching ADR-0022 and ADR-0033. |
| Honesty | deprecated is reported as undetermined rather than guessed; rejected never governs. |
| Blast radius | Additive. Every existing invocation and every existing JSON key is untouched. |
| Cost | A five-way standing to learn, and a documented gap for deprecated. |
Option B: Add an explicit validFrom / validUntil to the schema
Section titled “Option B: Add an explicit validFrom / validUntil to the schema”Pros: answers deprecated exactly; no derivation rules to explain; the
window becomes a fact rather than an inference.
Cons: a schema change on the one type every published surface and the hosted
JSON Schema depend on, for data that is already recoverable for the common case.
It creates a second source of truth that can disagree with supersededBy, and
every existing corpus would answer null until backfilled by hand — so the
feature would ship broken for every current user and become correct only as
people edited records. Rejected on the strength of “no schema change” being
available.
Option C: Resolve the ref inside @adrkit/core
Section titled “Option C: Resolve the ref inside @adrkit/core”Pros: one call site; the CLI stays thin.
Cons: puts a subprocess inside the package whose contract is that it has
none (ADR-0009), and makes buildDecisionWindows untestable without a git
fixture. Rejected for the same reason marker I/O is not inside checkChanges.
Option D: Do nothing
Section titled “Option D: Do nothing”#116 part B stays open. The question remains unanswerable by any ADR tool,
and the data to answer it stays in the corpus unused. The cost of doing nothing
is not neutral: agents and humans doing archaeology infer the past from
present-tense output, which reports a record that governed in March as history.
Trade-offs
Section titled “Trade-offs”deprecatedhas a hole, and the hole is visible. A user asking about a deprecated record gets “cannot be determined” rather than an answer. That is the honest report of what the corpus holds, and it will read as a gap.- Two temporal vocabularies now exist.
bucket(present) andstanding(as-of) sit side by side in--json, and a record can legitimately bebucket: "history"andstanding: "governing"at once. Both are true; a reader has to hold both. %cIis a choice, not a derivation. The committer date moves with a rebase or cherry-pick while the author date stays with the original keystroke.--as-of <ref>asks where a branch’s timeline stood, so the committer date is the better fit — but a user reasoning about when code was written will occasionally want%aIand will not get it.dateis not a file’s history. A record’sdateis the decision date, not when the file was committed. A record back-dated or edited later carries the date its author wrote down, and the window inherits that.- The open-window case is library-only, and #116’s version of it cannot arise
here. The issue imports from prior art that “legacy records with unknown
close dates stay
null= open”. In adrkit asupersededrecord is required by the schema to name a successor, and a successor the corpus lacks is adangling-supersededByerror — which gatesadr explainbefore any temporal code runs. Sotemporal-window-openis reachable throughresolveDecisionsAsOfas a library call and not through the CLI. The guard is kept because the function is a published export, but the claim should not be read as CLI-observable behavior. Verified by running the case, not by reasoning about it.
Consequences
Section titled “Consequences”- Easier: archaeology. “What governed this path in March” is one command, reproducible, with no network and no model call (ADR-0009). A stale-marker warning stops firing on markers that were accurate at the time being asked about, which is what made the combination of part A and part B confusing.
- Harder: the
explainsurface is larger — five standings, a window line, a provenance note, and anasOfJSON block. The CLI now has a subprocess, and inherited git config is a hazard on both sides of it. Two instances were found by executing the code rather than reasoning about it: a developer withtag.gpgSign = trueset globally hangs the test suite on a passphrase prompt (fixed withGIT_CONFIG_GLOBAL=/dev/nullin the harness), and a user withlog.showSignature = trueand a signed commit getsGood "git" signature for …prepended to stdout, which made--as-of HEADfail with the misleading “git could not resolve it to a commit” (fixed with--no-show-signature, and pinned by a test that signs a commit). Any future git invocation here inherits the same class of problem. - The human view states its own boundary. Because
via path:anddeclared by …lines are the one thing--as-ofdoes not re-date, the view says so in a note under its header — the same jobrenderMarkerScanNotedoes for an empty scan. Suppressing the evidence lines was the alternative and was rejected: hiding them trades a misreading for a silence, and this repository consistently prefers the report. - Two defects in the implementing mechanic, both found in review. Neither
changes the decision; both are recorded because the mechanic was wrong in ways
the decision’s own reasoning should have anticipated.
Bun.spawnin a Node-targeted artifact. The subprocess wrapper was copied fromcatalog-backstage, which runs only under Bun.@adrkit/clibuilds with--target=nodeandbun builddoes not shim theBunglobal, soadr explain --as-of <ref>was aReferenceErrorin every published install — and the wrapper’s broadcatchconverted it into the message “git is not installed or not on PATH”. A confident, wrong diagnosis is the exact failure this record’sdeprecatedreasoning exists to avoid, so the catch is now narrowed toENOENTand anything else surfaces. A Bun-run test suite cannot observe this class of defect at all; the guards are a shipped-source contract test and a--as-of <ref>case in the Node smoke.- An inverted window rendered as an interval.
standingAsOfrefuses to call an inverted window governing on any date, but the renderer still printedin force <opens> → <closes>for it — reporting a governing period the kernel had rejected. The predicate is nowisInvertedWindowin@adrkit/core, used by both the kernel and the renderer, so the two cannot drift.
- How we would know this was wrong:
- A user reports that
--as-ofnamed a record as governing that demonstrably was not, becausedatewas back-dated or edited after the fact. That would meandateis too weak a proxy and the window needs git history rather than frontmatter. undeterminedis the common answer rather than the rare one — i.e. real corpora deprecate more than they supersede. Measure: the share of records in theundeterminedbucket across the reference repository and the repository’s own corpus. Above roughly a quarter, option B’s schema change becomes the better trade.- Anyone still reads the as-of output as a claim about the file’s past contents despite the note under the header. That would mean the boundary is not sayable in one line and the evidence lines need to be suppressed or relabelled per group.
- A user reports that
- Revisit if: a second surface needs as-of (
check, the Action, or the MCP server), which would make the CLI-boundary git resolution a shared concern rather than one command’s; or@adrkit/sdkmoves past a types-only sketch, at which pointDecisionWindowneeds a place in that surface.
Action items
Section titled “Action items”- Pure
temporal/window.tsandtemporal/decisions.tsin@adrkit/core, exported and covered by the public-surface test. -
--as-ofonadr explain, registered in the command registry so shell completion and the unknown-option suggester agree with the parser. - Marker staleness judged against
asOfwhen supplied, unchanged otherwise. - Every load-bearing rule observed failing under mutation before counting
as coverage (ADR-0016): half-open boundary, present-tense bucketing,
deprecatedhandling, marker suppression, immediate-vs-terminal successor, the--no-show-signatureregression, the shipped-source Bun-global contract, and inverted-window rendering. - Reference-repository run against a corpus with a real supersession chain
(ADR-0014 rung 2). Closed by
mbeacom/adrkit-t018-dogfood#24:
a fixture corpus purpose-built to exercise the window closing (a
supersession pair, a three-record chain, a
deprecatedrecord, arejectedrecord, and a deliberately inverted-date pair), a self-verifying, fail-closed CI workflow, and a tracked evidence index (merged intomainat that commit). This record ships at rung 1–2: rung 1 (unit, contract, purity, and mutation coverage, a Node-runtime smoke) plus rung 2 (maintainer-owned isolated reference-repository validation). Not rung 3 — no external party has run this. - Decide whether
adr checkand the governing-decisions Action should accept--as-of, or stay present-tense permanently. - Revisit
%cIversus%aIif anyone asks for the author date.