ADR 0002: Add an opt-in AI layer at the edges; the rule engine stays the only thing that produces a finding
Accepted — 2026-08-21 (owner-directed change of direction).
Amends ADR 0001. The
default check path keeps every ADR 0001 guarantee. This record adds a
separate, explicitly opt-in command group outside that path.
ADR 0001 excluded "AI or model-based findings" from version 0.1, and every
public claim followed from it: "no network requests at runtime", "no AI
runtime", the README's AI Evaluation: N/A row, and the data card's "makes
no runtime network request".
The owner has directed that the product needs real document-understanding and explanation features. The places where a language model helps a filer are concrete: reading a package and proposing the structured facts a manifest needs, explaining a technical finding in plain language against the official guidance the rule cites, and drafting a correction. They are also exactly where an ungrounded model does the most damage: a model that invents a lead agency, an exemption class, or a requirement that no official source states is this portfolio's dominant defect, "absence rendered as a value."
The founding boundary does not move. CEQA Preflight never determines legal sufficiency, never says a filing "will be accepted", and never evaluates whether an exemption or a determination was correct. A model in the loop must be prevented from doing those things by code, not by prompt wording.
Add a model-backed layer in four bounded roles, behind an explicit ai
command group that is never invoked by check, init, synth, rules, or
pilot.
-
Field extraction structures input; it does not decide.
ai extractreads the package's PDF text through the same bounded, process-isolated inspection pathcheckuses, and asks the model to propose the facts a manifest carries (filing form kind, document category, project title, lead agency, county, city, SCH number, exemption citation, and the like). Every proposed value must carry a verbatim quote from the document text. The tool verifies each quote programmatically against the extracted text; a value whose quote does not appear is downgraded tounknownwith the reason recorded. Fields the text does not answer areunknown, never guessed. The output is a draft manifest the user reviews and confirms.check --manifestthen runs on the confirmed facts exactly as it does today; the model's output never reaches the rule engine directly. -
The rule engine is unchanged and is the only source of findings. No rule consumes model output. No model output is a finding, a status, a remediation, or a
not_runentry. The JSON report schema does not gain an AI field. -
Explanations and correction drafts narrate; the committed corpus is the evidence.
ai explainandai draft-fixtake an existing JSON report. For each finding they retrieve passages fromcorpus/, the committed, hashed, dated text of the official sources the rule cites, and ask the model for a plain-language explanation (or a concrete correction draft) in which every substantive claim cites a passage by identifier and quotes it verbatim. A verifier checks each quote against the corpus text before anything is displayed. A claim whose quotes do not all verify is withheld and the withheld count is shown. Output is labeled AI-generated, advisory, and carries the existing non-legal disclaimer. A rule whose citation is not an official source (the self-cited FILE-004 and FILE-005) is explained only from the project's own documented reasoning and says so. -
Legal-sufficiency questions are refused, in every phrasing.
ai askanswers questions about the technical findings in a report and nothing else. A deterministic guard runs before the model and again on the model's output. Any form of "is this filing legally sufficient", "will it be accepted", "will it survive challenge", "is this exemption valid", or "did the agency comply" is refused with a redirect to the objective findings and to qualified review. The refusal suite inevals/is the evaluation that matters most and its tolerance is zero.
Consequential choices:
- Provider and model. The public
anthropicSDK;claude-sonnet-5is the configurable default. Amazon Bedrock is supported through the same SDK for environments that have it. The credential comes only from the environment. No key is written to any file, and the tool never logs document text. - Lazy import, no new default dependency. The SDK is an optional extra
(
ceqa-preflight[ai]) imported only inside theaicommands. The default path has no new import, no new dependency, and byte-for-byte unchanged output, which a test asserts. - Honest refusals. A PDF with no text layer is reported as such and not
extracted (OCR remains out of scope). A document the model cannot identify
as a CEQA notice yields no fields. A field the text does not state is
unknown. - Evaluation is committed and model-independent.
evals/holds the cases, the harness, and recorded results for: extraction against the structured metadata CEQAnet publishes for the same real filings (per-field exact match, abstained-when-absent, and the defect, filled-when-absent); the legal-sufficiency refusal suite; citation grounding; and a check that an explanation never upgrades an advisory finding into a determination. Recorded numbers carry provider, model, prompt version, commit, and date; a test rejects results without that provenance. Numbers are committed only from a recorded live run; otherwise the result isnot_run. - Real filings are referenced, not committed. The real-filing eval stores CEQAnet document identifiers, hashes, gold metadata, and extraction results, plus the fetch script. The PDFs themselves are fetched on demand and are never committed.
- Public claims are rewritten in the same change series. "No network requests
at runtime" becomes "none on the default path; the opt-in
aicommands send document text to the configured model provider." The data card, threat model, README, and conformance table say so.AI Evaluationmoves from N/A to Applies with the committed harness as evidence. - Sending document text to a model provider is a new data flow. Filing
packages may contain personal contact details. The
aicommands state this before running, and an operator who cannot accept the provider's processing terms must not use them. The default path is unaffected. - The i18n release gate (docs/I18N.md, issue #39) gains surface area: AI user-facing strings are localizable content. Until the gettext seam exists they are kept in one module so the seam can wrap them.
- Runtime cost, latency, and provider availability become product
properties of the
aicommands only. Everyaicommand fails closed: on any provider error it produces no draft and no explanation, and says why.
- Keep ADR 0001's exclusion. Rejected by the owner: a purely deterministic checker leaves the hardest part of preparing a package (knowing what the package says) to the filer.
- Let rules consume extracted fields directly. Rejected. A model output that flows into a rule without confirmation makes the finding unfalsifiable. The confirmed manifest is the boundary.
- Free-text Q&A over the package. Rejected. Anchoring
ai askto the findings in a report keeps the question space bounded and makes the refusal guard tractable. - Vector retrieval over the corpus. Not needed. Retrieval is scoped by the rule's citation and ranked lexically; it is inspectable and needs no additional provider.