Review knowledge
How Git review history becomes reusable team knowledge — and why the last step is always a human's.
The domain model is built, and since slice B5 one producer fills it.
ReviewThread, PromotionGate and KnowledgeCandidate live in
domain/review.py,
and the promotion invariants below are held by three different mechanisms
(ADR-0013, INV-7):
- Absence.
KnowledgeCandidatehas noapprove,promoteorpublishmember, andCandidateStatushas noAUTO_APPROVED. Pinned bytest_candidate_has_no_self_approval_method. - The signature.
trust_levelisfield(init=False), so a candidate cannot be constructed claiming review-level trust at all — refused by the constructor rather than by a check, which makes one keyword load-bearing. Pinned bytest_a_candidate_cannot_be_constructed_with_a_trust_level. - Construction.
__post_init__rejects a candidate with no evidence, an empty body, or an unmet promotion gate. Pinned bytest_candidate_without_evidence_is_rejected_at_generationand its two siblings.
What fills that model has begun to arrive, and it is worth being exact about how
much. infrastructure/github/ holds the adapter now
(ADR-0030 slice 1): it
fetches pull requests, review threads, inline comments and resolution state by
spawning the operator's gh, over public repositories the project has
allowlisted. Slice 2 added the landing half: theurian review ingest screens
each fetched record and writes what the gate clears under .theurian/review/.
Slice 3 added the serving half: theurian review build projects the files under
.theurian/review/ into a SQLite store — whatever put them there, since that
directory is source rather than derived state and a clone may carry one
(threat model T-24) — and the review.search MCP
tool reads that store back under the untrusted-content safety triple. So system.capabilities reports
reviewIngestion: true beside reviewIngestionScope: "public-allowlisted",
both pinned by test_capabilities_report_what_is_and_is_not_built — a statement
about the callable surface and nothing wider, since no MCP tool spawns gh and
a fetch stays an operator's act. Slice B5 added the producer
(ADR-0033):
application/candidate_generation.py recomputes the promotion gate from the
stored review record, verifies the caller's fixCommit against the local
repository, and hands the candidate to the draft-only proposal facade — so the
only construction site is the candidate generator, and what it produces is a
proposal a human reviews. The same slice registered the
review.generateKnowledgeCandidate MCP tool, so a wire call reaches it: it joins
knowledge.proposeChange and knowledge.generateMigrationDraft behind the same
writeTools: true, which answers whether a write-intent tool exists and not how
many. What is still missing is the rest of collection —
theurian ingest reads local files only. So the sections below that describe
collection — the landing stages, classification, candidate generation,
provider access and privacy handling — describe a design, not
what runs today. Four parts of it are the exception and are named as such where
they appear: the fetch half of the first stage, the landing half beside it, the
ingestion-time privacy control the landing gate applies, and candidate
generation. The fourth carries a bound: Failure isolation below describes a
design in which generation may call a model, and the shipped generator calls
none — ADR-0033 decision 1 puts the generalization in the calling agent's hands
instead. Collection is
#479's, designed in
ADR-0030 and sliced there;
#368 is the other arm of FR-V
and builds no fetch path at all — it reads Review-Finding: trailers out of
local git history (ADR-0029).
Candidate generation (FR-V2, FR-V3) was out of ADR-0030's scope and is
ADR-0033's; nothing else below is scheduled by this sentence alone.
Evidence is not knowledge
A review comment says:
This will deadlock under retry. We hit this in the payments service last year. Take the lock after the read, not before.
That is evidence: what someone said, on which line, in which pull request, and whether it was acted on.
The reusable rule is something else:
Acquire locks after reads, never before, in retry-eligible paths. Evidence: PR #431 thread PRRT-123; incident 2025-11-payments.
The step between them is generalization, and generalization is a judgement. Theurian collects the first automatically and never performs the second automatically (ADR-0013).
Stages
flowchart LR
RE["Review Event<br/>(pull request)"] --> RT["Review Thread"]
RT --> RC["Review Comment"]
RT --> RR["Review Resolution"]
RT --> KC["Knowledge Candidate"]
KC -->|"human review + merged PR"| AK["Approved Knowledge"]
style AK fill:#1f6f4a,color:#fff
style KC fill:#8a6f2f,color:#fff
| Stage | What it is | Automatic? |
|---|---|---|
| Review Event | a pull request and its outcome | yes |
| Review Thread | a conversation on a file and line range | yes |
| Review Comment | one message, optionally classified | yes |
| Review Resolution | how and when the thread closed | yes |
| Knowledge Candidate | a proposed generalization | yes, gated |
| Approved Knowledge | a reusable rule | no — human only |
Ingested as structure, never as prose
ReviewThread(
external_id="PRRT-123",
file_path="src/payments/lock.py",
line_start=42, line_end=48,
commit_sha="a1b2c3...",
comments=(...),
state=ReviewThreadState.RESOLVED,
resolution=ReviewResolution(fix_commit="d4e5f6...", ...),
)
Rendering this to Markdown and calling the Markdown canonical would leave the
resolution state, the fix commit, and the thread structure as prose an LLM has to
re-parse — differently each time. A Markdown view is generated for reading, as
a derived artifact under .theurian/generated/.
Classification
Comments are classified into the eleven categories from §21 of the brief:
specification-gap, architecture-rule, security-rule, performance-rule,
reliability-rule, coding-convention, testing-rule, domain-rule,
rejected-approach, known-exception, incident-prevention.
Classification is a caller-supplied hint recorded on the candidate, not a router: it chooses neither the knowledge kind nor the namespace — those are the caller's own wire fields — and today it is not carried into the drafted proposal at all (#754 tracks that). It is not a truth claim, and a misclassification costs a reviewer one correction — not a wrong rule in the knowledge base.
The promotion gate
Seven observed facts decide whether a thread deserves a human's attention:
flowchart TD
T["Resolved review thread"] --> G1{"PR merged?"}
G1 -->|no| N["No candidate"]
G1 -->|yes| G2{"Thread resolved?"}
G2 -->|no| N
G2 -->|yes| G3{"Fix commit present?"}
G3 -->|no| N
G3 -->|yes| G4{"Not dismissed or outdated?"}
G4 -->|no| N
G4 -->|yes| G5{"CI green?"}
G5 -->|no| N
G5 -->|yes| G6{"Generalizable?"}
G6 -->|no| N
G6 -->|yes| G7{"Evidence present?"}
G7 -->|no| N
G7 -->|yes| C["Knowledge Candidate<br/>trustLevel: inferred"]
C --> H["Human review"]
H -->|accept| A["Migration → merged PR → approved"]
H -->|reject| R["Optionally record as a rejected approach"]
style A fill:#1f6f4a,color:#fff
style C fill:#8a6f2f,color:#fff
Every signal is an observed fact, not a model's opinion, so the decision is
auditable. Unmet signals are named individually — ("ci_successful",
"generalizable") — so "why was no candidate generated?" has an answer.
Crucially, the gate answers "should someone look at this?" It never answers "is this true?".
A candidate cannot promote itself
KnowledgeCandidate(
trust_level=TrustLevel.INFERRED, # not settable; always inferred
status=CandidateStatus.GENERATED, # no AUTO_APPROVED member exists
evidence=(...), # empty evidence raises at construction
gate=PromotionGate(...), # an unmet gate raises at construction
)
There is no approve() method, and CandidateStatus has no auto-approved
member. A test asserts both. trust_level is init=False and fixed to
INFERRED: a candidate cannot claim the trust that a human reviewer would be
granting it.
Failure isolation
This section is design context, and one of its premises did not survive implementation. It was written for a generation stage that may need a model; the shipped generator calls none, because ADR-0033 decision 1 put the generalization in the calling agent's hands. The isolation argument below stands as the reason the two stages are separate, and what the branch isolates today is the caller's availability rather than a model's.
Raw ingestion must not need a model either way (FR-V5), and both halves are now
structural rather than argued:
tests/integration/test_review_ingest_is_model_free.py walks the built ingest
pipeline and
tests/integration/test_candidate_generation_is_model_free.py walks the
candidate one, through the shared instrument tests/model_free_walk.py, each
with planted-model controls and each carrying the same recorded bound — a
provider reached through getattr, a factory looked up in a table, or an import
one frame deeper than the walk descends is invisible to it.
flowchart LR
A["Fetch PRs and threads"] --> B["Store as evidence"]
B --> C{"Candidate generation<br/>available?"}
C -->|yes| D["Generate candidates"]
C -->|"no, or it fails"| E["Skip. Report it.<br/>Evidence is already stored."]
style B fill:#1f6f4a,color:#fff
Evidence collection is reliable and cheap; interpretation is fragile and optional. Keeping them separate means a model outage costs you candidates, not your review history.
The rebuild that sits between them is wholesale, so its memory is linear in the
corpus. theurian review build — and the same rebuild theurian review ingest
runs after it lands — reads every record under .theurian/review/ and holds all
of them at once, so the only bound on one build's footprint is how much evidence
a project has. The measurement, its scope and what dominates it are recorded
where the build makes that trade, in ReviewSearchBuilder.build's docstring
(application/review_search_builder.py). An incremental rebuild is the change
that would bound it, and it is not designed.
Privacy
Review data contains author identity and opinions.
- Identity is the provider's stable ID plus a display name, so redacting the name does not break the identity graph.
- Redaction at ingestion is configurable, and this half is shipped rather than
designed (R-12, ADR-0030 decision 3).
providers.review.redactParticipantNamesis a boolean, defaultfalse, read bysecurity/project_config.py::read_review_participant_redactionand applied byapplication/review_landing_gate.pybefore a record becomes a file: every participant'sdisplay_namebecomes the one fixedREDACTED_DISPLAY_NAMEplaceholder, and theirexternal_idis kept where it is the provider's node id and replaced by a stable pseudonym where it is the author's own login.tests/unit/test_review_landing_gate.py::test_no_participant_reachable_from_a_landed_record_keeps_its_namewalks every position a record can hold a participant in, so the claim is over the record rather than over the fields someone remembered. The login case is the adapter's fallback —external_idis node id or login, so an answer carrying noidputs author-chosen text there — and leaving it alone made enabling this setting publish the name it promised to remove (PR #596 round 1)....::test_a_login_fallback_id_is_pseudonymised_before_it_can_landis what fails if the raw login can land again. - Ingested review evidence is the source, not a cache: upstream comments are
editable and deletable, so a discarded local copy of a deleted comment is data
loss and no refetch recovers it. ADR-0030
decision 3 lands it as durable files under
.theurian/review/and makes the SQLite serving store the derived, deletable half. Slice 2 gave that path a fact-side constant:ProjectPaths.reviewcomposes it, so it joins the containment sweep intests/unit/test_project_paths_containment.py, and it is deliberately not a member ofDERIVED_SUBDIRECTORIES— the property this bullet asserts is the reason it must not be. - The approved knowledge that results is a rule, not a quotation — attributed to evidence rather than to a person's opinion.
See SECURITY.md.
Provider abstraction
GitHub first, behind ReviewProvider. GitLab and others are new adapters, no
domain change. The port returns evidence only: it never classifies, generalizes,
or calls a model, so a provider adapter stays a thin, testable mapping.
Repositories must be allowlisted in .theurian/config.yaml before one is
contacted (SEC-10). That is shipped behaviour on the gh path now, not a design
obligation on the adapter. security/project_config.py is the one module in
src/ that opens that file, and it reads three keys out of it and nothing
else: security.secretScan, providers.review.repositories and
providers.review.redactParticipantNames (ADR-0027 decision 3, ADR-0030
decisions 2 and 3). security/review_allowlist.py is what the second key
reaches, and it refuses a repository the list does not name before any process
is spawned — an unallowlisted repository produces no spawn at all rather than a
filtered result, which
tests/integration/test_gh_review_provider.py::test_an_unallowlisted_repository_starts_no_process
asserts by requiring the spawn recorder to be empty.
#129 was closed on the
wording rather than on the control, so ADR-0030 decision 2 is what actually
built it.
That reader population is a measurement, not a sentence in this file. Its key is
$ git grep -n "read_secret_scan_policy\|read_review_repositories\|read_review_participant_redaction" -- packages/theurian-core/src
and what recomputes it are tests/unit/test_config_key_call_sites.py's
CONFIG_KEY_READER_SITES and WATCHED_SPELLINGS, plus
tools/audit/config_object_claims.py's KEYS_WITH_A_READER. A fourth key, or a
second module opening the file, reddens those rather than leaving this paragraph
quietly wrong.
What is still owed on the fetch side is the raw-URL controls — a scheme allowlist
and private-network rejection — against the OpenAPI $ref fetcher, which is a
different code path from gh and is not covered by anything above
(#429 owns it).