Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,8 +637,10 @@ scheme, number, title, status, version, date, path, the whole frontmatter
as JSON, and the body), a row per field value with list fields exploded
into positioned rows, the typed edges (`superseded_by:`, `influenced_by:`,
every declared reference field), every citation the lint's scanner counts
— where it sits, whether it resolves, whether a directive excuses it — and
every journal entry with its tags. Rebuilt from scratch on each run, so a
— where it sits, whether it resolves, its `standing` (`in-force`,
`acknowledged`, `sanctioned`, `unacknowledged` or `dangling`, as the lint
reads it), whether a directive or a `lint.inactive_ok` rule excuses it and
`excused_by` naming which — and every journal entry with its tags. Rebuilt from scratch on each run, so a
row never outlives its source; refuses to overwrite a file it did not
write. It is a generated view for asking the record questions with
`sqlite3`, Datasette or pandas — never a source, and not committed
Expand Down
22 changes: 15 additions & 7 deletions luria/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@
edges the typed graph `luria/edges.py` derives — a reference
field, `superseded_by:`, `influenced_by:`
citations every code the lint's scanner counts as a citation, with
the file and line it sits on, whether it resolves, and
whether a directive excuses it — a suppression is
counted, never hidden (DP-1)
the file and line it sits on, whether it resolves, its
`standing` — `in-force`, `acknowledged`, `sanctioned`,
`unacknowledged` or `dangling`, read off the same scan
the lint reads — and what clears it, a directive or a
`lint.inactive_ok` rule: a suppression is counted,
never hidden (DP-1)
journal_entries one row per entry, keyed on the timestamp that is its
identity; `journal_tags` holds their tags
meta which version of luria wrote it, when, and from where
Expand Down Expand Up @@ -89,7 +92,11 @@
line INTEGER NOT NULL,
code TEXT NOT NULL,
resolves INTEGER NOT NULL, -- 0 when the code names no document here
excused INTEGER NOT NULL -- 1 when a directive acknowledges it
standing TEXT NOT NULL, -- in-force | acknowledged | sanctioned
-- | unacknowledged | dangling
excused INTEGER NOT NULL, -- 1 when a directive or a rule clears it
excused_by TEXT -- `inactive-ok @ path:line`, or
-- `lint.inactive_ok[N]`; NULL if nothing
);
CREATE INDEX citations_by_code ON citations(code);
CREATE TABLE journal_entries (
Expand Down Expand Up @@ -137,8 +144,9 @@ def _citation_rows(cfg) -> list[tuple]:
rows: list[tuple] = []
for pool, resolves in ((scan.cited, 1), (scan.dangling, 0)):
for code, sites in pool.items():
rows += [(cfg.rel(c.path), c.line, code, resolves,
int(c.excused_by is not None)) for c in sites]
rows += [(cfg.rel(c.path), c.line, code, resolves, c.standing,
int(c.cleared_by is not None), c.cleared_by)
for c in sites]
return sorted(rows)


Expand Down Expand Up @@ -191,7 +199,7 @@ def write(out: Path, cfg=None) -> Path:
conn.executemany("INSERT INTO edges VALUES (?,?,?,?)",
[(e.source, e.relation, e.target, e.because)
for e in graph.edges])
conn.executemany("INSERT INTO citations VALUES (?,?,?,?,?)",
conn.executemany("INSERT INTO citations VALUES (?,?,?,?,?,?,?)",
_citation_rows(cfg))
conn.executemany("INSERT INTO journal_entries VALUES (?,?,?,?,?)",
entries)
Expand Down
50 changes: 49 additions & 1 deletion luria/ref_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,17 @@ def annotations(path: Path, text: str, known: set[str],
# ── Scanning ─────────────────────────────────────────────────────────────


# What a site needs, in one word each — the lint's reading of a citation,
# named once so the export's `standing` column and any report that asks
# read it from here rather than re-deriving it (DP-004).
IN_FORCE = "in-force" # the cited document is in force
ACKNOWLEDGED = "acknowledged" # a directive answers for the finding
SANCTIONED = "sanctioned" # a `lint.inactive_ok` rule does
UNACKNOWLEDGED = "unacknowledged" # retired, and nothing answers: loud
DANGLING = "dangling" # names no document, and nothing answers
STANDINGS = (IN_FORCE, ACKNOWLEDGED, SANCTIONED, UNACKNOWLEDGED, DANGLING)


@dataclass(frozen=True)
class Citation:
path: Path
Expand All @@ -252,6 +263,10 @@ class Citation:
# The `lint.inactive_ok` rule this citation matches, as its key: nothing
# to acknowledge, and so nothing for an `inactive-ok` to answer for.
sanctioned_by: str = field(default="", compare=False)
# Whether the code names a document here, and whether it is in force:
# what the site would need with nothing answering for it.
resolves: bool = field(default=True, compare=False)
in_force: bool = field(default=False, compare=False)

def __str__(self) -> str:
return f"{_rel(self.path)}:{self.line}"
Expand All @@ -266,6 +281,38 @@ def loud(self) -> bool:
acknowledges it and no `lint.inactive_ok` rule sanctions it."""
return self.excused_by is None and not self.sanctioned

@property
def standing(self) -> str:
"""What this site needs, as one of `STANDINGS`.

A document in force needs nothing, whatever answers for it — a
`mention-ok` over it is only keeping itself alive (ADR-105). Any
other site stands on what clears it: a rule before a directive,
since a `mention-ok` is the only directive that can sit over a
sanctioned site and it claims nothing. A `mention-ok` over a retired
or dangling target answers its finding, so the site is
`acknowledged`."""
if self.resolves and self.in_force:
return IN_FORCE
if self.sanctioned:
return SANCTIONED
if self.excused_by is not None:
return ACKNOWLEDGED
return UNACKNOWLEDGED if self.resolves else DANGLING

@property
def cleared_by(self) -> str | None:
"""What answers for this site, or None: the rule's key
(`lint.inactive_ok[0]`), else the directive's kind and where it
sits (`inactive-ok @ docs/x.md:12`)."""
if self.sanctioned:
return self.sanctioned_by
if self.excused_by is not None:
ann = self.excused_by
suffix = "-file" if ann.scope == directives.FILE else ""
return f"{ann.kind}{suffix} @ {ann}"
return None


@dataclass
class Scan:
Expand Down Expand Up @@ -540,7 +587,8 @@ def _acknowledge(result: Scan, sites: list[tuple[Path, int, str]],
rule = (f"lint.inactive_ok[{sanctioned[i]}]" if i in sanctioned
else "")
pool.setdefault(code, []).append(
Citation(path, line, code, ann, rule))
Citation(path, line, code, ann, rule, resolves=code in docs,
in_force=code in docs and docs[code].active))
result.sanctioning = frozenset(sanctioned.values())
result.unused = frozenset(a for (a,) in derived.get("unused", ()))
for a, code, _ in derived.get("lost", ()):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
### Added

- `luria export`'s `citations` table has a `standing` column, which is one
of `in-force`, `acknowledged`, `sanctioned`, `unacknowledged` or
`dangling`. It also has an `excused_by` column, which names the directive
(`inactive-ok @ path:line`) or the `lint.inactive_ok[N]` rule that clears
the site ([ADR-tmptj3j3](record/decisions.d/ADR-tmptj3j3.md)).

### Changed

- The export's `excused` column is 1 when a directive or a
`lint.inactive_ok` rule clears the site. Before, it counted directives
only, so a sanctioned citation exported 0 and looked like a warning.
105 changes: 105 additions & 0 deletions record/decisions.d/ADR-tmptj3j3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
status: Active
title: 'An exported citation states its standing and what clears it'
version: 1
tags:
- mechanism
date: '2026-10-21'
summary: >-
The export's `citations` table gains `standing`, which is one of
`in-force`, `acknowledged`, `sanctioned`, `unacknowledged` or `dangling`,
and `excused_by`, which names the directive (`inactive-ok @ path:line`) or
the rule (`lint.inactive_ok[N]`) that clears a site. `excused` widens to 1
when a directive or a rule clears the site. The standing is
`Citation.standing` in `ref_status`, the reading the lint makes, so the
export does not derive it a second time. Rejected: keeping `excused` as
it was and adding only `standing`, and dropping `excused`.
influenced_by:
- ADR-116
- ADR-105
- ADR-tmpfu1s6
- DP-004
---

# ADR-tmptj3j3: An exported citation states its standing and what clears it

## Context

`luria export` writes every citation the lint's scanner counts. Each row
carried `resolves` and `excused`, "1 when a directive acknowledges it"
([ADR-116](ADR-116.md)). Five different situations ended up in those two columns:

- the cited document is in force;
- a directive acknowledges the site;
- a `lint.inactive_ok` rule sanctions it ([ADR-tmpfu1s6](ADR-tmpfu1s6.md));
- the cited document is retired and nothing answers for it, which is the
case the lint warns about;
- the code names no document here.

An in-force site and an unacknowledged one both exported `excused = 0`.
To tell them apart, a query had to join `documents` and know the scheme's
`active` word. That reconstructs the lint's reading by hand, and it went
wrong once a rule could clear a site without any directive in the text. A
sanctioned site exported `excused = 0` and looked like a warning.

## Decision

**Each citation row states its standing**, as one of these words:

| standing | the site |
|---|---|
| `in-force` | cites a document in force, and needs nothing |
| `acknowledged` | is a finding that a directive answers for |
| `sanctioned` | cites a retired document where a `lint.inactive_ok` rule allows it |
| `unacknowledged` | cites a retired document and nothing answers for it, so the lint warns |
| `dangling` | names no document and nothing answers for it, so the lint warns |

A dangling site under `unresolved-ok` is `acknowledged`, because the
directive answers its finding.

**A `mention-ok` site stands where its target puts it.** The directive
claims nothing about state ([ADR-105](ADR-105.md)). Over a document in force it is
`in-force`: the mention only keeps itself alive. Over a retired or dangling
target it is `acknowledged`, because the mention answered the finding the
site would otherwise be. In both cases `excused_by` names the mention.

**`excused_by` names what clears the site.** For a directive this is its
kind and where it sits, as the stale-directive report already prints it:
`inactive-ok @ docs/x.md:12`, or `inactive-ok-file @ …` for a file-scoped
directive. For a rule it is the rule's key, `lint.inactive_ok[0]`. It is
NULL when nothing clears the site. A rule takes precedence over a
directive. The only directive that can sit over a sanctioned site is a
`mention-ok`, and it claims nothing.

**`excused` is 1 when a directive or a rule clears the site.** This changes
the column's meaning: before, a sanctioned site exported 0.

**There is one reading.** `Citation.standing` and `Citation.cleared_by` in
`luria/ref_status.py` derive both values from the scan the lint reads, and
the export only copies them out ([DP-004](../principles.d/DP-004.md)). The words are constants
(`ref_status.STANDINGS`), so a report that wants them reads the same
values.

## Alternatives considered

- **Keep `excused` as it was and add only `standing`.** Nothing breaks, but
`excused` would mean "a directive, not a rule". A query that already
filters on `excused = 0` to find what needs attention would keep finding
every sanctioned site. A rule is an acknowledgement written once, and
the column should count it.
- **Drop `excused`.** `standing IN ('acknowledged', 'sanctioned')` says the
same thing, but removing a column breaks every saved query to tidy a
redundancy. The flag costs one integer per row.
- **Derive the standing in `export.py`.** That would be a second
implementation of the lint's reading of a site. It is the drift [DP-004](../principles.d/DP-004.md)
names, and it is what produced the wrong `excused = 0`.

## Consequences

- A query asks `WHERE standing = 'unacknowledged'` for what the lint warns
about, with no join.
- A saved query reading `excused = 0` as "not acknowledged" now also
excludes sanctioned sites, which is what it meant.
- `tests/test_export.py` pins each of the five standings, `excused_by` for
a directive and for a rule, and the `mention-ok` cases.
`tests/test_ref_status.py` pins `Citation.standing` directly.
32 changes: 32 additions & 0 deletions record/devlog.d/2026/10/21/194454.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
title: 'Export standing: one reading of a citation'
created: '2026-10-21T19:44:54'
tags: []
---

**The export had been reading a site with half the facts.** Its
`excused` column was `excused_by is not None`, which is true only for a
directive. Once `lint.inactive_ok` could clear a site with no directive in
the text, a sanctioned citation exported `excused = 0`, exactly like a loud
one. An in-force citation did too, so no query could find "what the lint
warns about" without joining `documents` and knowing each scheme's
`active` word.

**The fix puts the reading on `Citation`, not in `export.py`.** A
`Citation` did not know whether its target resolved or was in force:
`_acknowledge` used both facts to choose a pool and then dropped them. It
now carries both, as `resolves` and `in_force`. With them,
`Citation.standing` and `Citation.cleared_by` are properties, and the export
copies them out ([ADR-tmptj3j3](../../record/decisions.d/ADR-tmptj3j3.md)).

**One thing that looks like a bug and is not.** A `mention-ok` can sit
over an in-force site and over a sanctioned one, because it answers any
site nothing else claimed. So an in-force row can carry `excused = 1`, with
`excused_by` naming the mention. The standing stays `in-force`, because a
mention claims nothing about state ([ADR-105](../../record/decisions.d/ADR-105.md)). Over a sanctioned site the
rule wins, both in the standing and in `excused_by`.

**A sanctioned citation in frontmatter is a real case.** In the export
test, the citer's own `summary:` names the decision it replaced, and the
row lands on the summary's line, not in the body. That is the lint's
reading, since `summary:` is prose and a code in it is a citation.
77 changes: 76 additions & 1 deletion tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from _scheme import decision

# unresolved-ok-file: ADR-918, ADR-919 — fixture codes, deliberately not
# unresolved-ok-file: ADR-917, ADR-918, ADR-919 — fixture codes, deliberately not
# real: the citations test checks that they land in the table as unresolved.


Expand Down Expand Up @@ -94,6 +94,81 @@ def test_citations_come_from_the_record_s_own_scanner(project):
("docs/notes.md", 3, "ADR-919", 0, 0)]


def _with_rule(project: Path) -> None:
"""Add a `lint.inactive_ok` rule letting an Active decision cite a
Superseded one."""
import yaml
path = project / "luria.yaml"
raw = yaml.safe_load(path.read_text())
raw.setdefault("lint", {})["inactive_ok"] = [{
"match": "(:ADR {status: 'Active'})-[:cites]->"
"(:ADR {status: 'Superseded'})",
"reason": "a successor names what it replaced"}]
path.write_text(yaml.safe_dump(raw, sort_keys=False))
config.reset()


def test_every_citation_carries_its_standing(project):
"""`standing` says what a site needs, in the lint's own reading: nothing
(in force), nothing because a directive or a rule answers for it, a
reader's attention (unacknowledged), or a target (dangling). `excused`
is 1 whenever a directive OR a rule clears the site, and `excused_by`
names which."""
_with_rule(project)
decision(project, 1, "Active")
decision(project, 2, "Superseded", superseded_by="ADR-001")
decision(project, 3, "Active", "The successor",
summary="Replaces ADR-002.")
(project / "docs" / "notes.md").write_text(
"# Notes\n"
"\n"
"In force: ADR-001.\n"
"<!-- inactive-ok: ADR-002 — the history -->\n"
"Acknowledged: ADR-002.\n"
"\n"
"\n"
"Loud: ADR-002 again.\n"
"Dangling: ADR-919.\n"
"<!-- unresolved-ok: ADR-918 — kept on purpose -->\n"
"Acknowledged dangling: ADR-918.\n")
out = export.write(project / "record.sqlite")
got = rows(out, "SELECT path, line, code, standing, excused, excused_by "
"FROM citations WHERE path IN ('docs/notes.md', "
"'record/decisions.d/ADR-003.md') ORDER BY path, line")
assert got == [
("docs/notes.md", 3, "ADR-001", "in-force", 0, None),
("docs/notes.md", 5, "ADR-002", "acknowledged", 1,
"inactive-ok @ docs/notes.md:4"),
("docs/notes.md", 8, "ADR-002", "unacknowledged", 0, None),
("docs/notes.md", 9, "ADR-919", "dangling", 0, None),
("docs/notes.md", 11, "ADR-918", "acknowledged", 1,
"unresolved-ok @ docs/notes.md:10"),
("record/decisions.d/ADR-003.md", 7, "ADR-002", "sanctioned", 1,
"lint.inactive_ok[0]"),
]


def test_a_mention_counts_by_what_the_site_would_need(project):
"""`mention-ok` claims nothing about state, so a site under one stands
where its target puts it: in force stays `in-force`, and a retired or
dangling target is `acknowledged` — the mention answered the finding.
Either way `excused_by` names it."""
decision(project, 1, "Active")
decision(project, 2, "Superseded", superseded_by="ADR-001")
(project / "docs" / "notes.md").write_text(
"# Notes\n"
"\n"
"<!-- mention-ok: ADR-001 ADR-002 ADR-917 — named, not cited -->\n"
"ADR-001, ADR-002 and ADR-917 as spellings.\n")
out = export.write(project / "record.sqlite")
got = rows(out, "SELECT code, standing, excused, excused_by "
"FROM citations WHERE path = 'docs/notes.md' ORDER BY code")
by = "mention-ok @ docs/notes.md:3"
assert got == [("ADR-001", "in-force", 1, by),
("ADR-002", "acknowledged", 1, by),
("ADR-917", "acknowledged", 1, by)]


def test_journal_entries_are_exported(tmp_path, monkeypatch):
(tmp_path / "luria.yaml").write_text(
"issue_url: https://example.test/{n}\n"
Expand Down
Loading
Loading