nexa / ARCHITECTURE.md
DBax127's picture
depends: the two bindings, measured again on the tree facts and not taken
a8f9476
|
Raw History Blame Contribute Delete
311 kB
# Nexa β€” Architecture and Decision Record
How Nexa works, every significant decision and the evidence behind it, and an honest
account of what it cannot do.
Decisions are recorded with the reasoning, including the ones that were **wrong first**.
A decision record that only lists the final answer hides the most useful information.
---
## 1. Overview
```
question
β”‚
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” embed on CPU, cosine over the corpus
β”‚ retrieve β”‚ + pinned rules that bypass retrieval
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Qwen2.5-Coder-7B, 100% GPU, ~37 tok/s
β”‚ draft β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” nine layers, cheapest first, short-circuited
β”‚ verify β”‚ each reports a verdict AND what it could not check
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚ nothing repairable ─────────────► answer
β”‚ repairable findings
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Qwen3-Coder-30B-A3B, ~11 tok/s
β”‚ escalate β”‚ max_invocations = 1, enforced by topology
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β–Ό
verify ────────────────────────────► answer + PASS / WARN / REJECT
```
### The verify stage
| # | Layer | Asks | Mechanism |
|---|---|---|---|
| 1 | syntactic | Does it parse at all? | `ast` + compile; delimiter balance for TS/JS |
| 2 | structural | Is the answer the shape the prompt demanded? | required labels, leaked template text |
| 3 | semantic | Does the code mean what it claims? | AST checkers, curated import check |
| 4 | knowledge | Are the cited rules real, offered, and sourced? | citation scan against the corpus |
| 5 | domain | Does the code obey the corpus? | `violation_pattern` over comment-free code |
| 6 | context | Is it right *here* β€” this module, this series? | manifest, model names, tsconfig |
| 7 | empirical | Does it hold up against this machine? | import resolution, bytecode, opt-in execution |
| 8 | adversarial | Would the verdict survive a harmless rewrite? | meaning-preserving mutation |
| 9 | provenance | Can the verdict be re-derived later? | the evidence bundle, checked for completeness |
| β€” | decision | PASS / WARN / REJECT, and what was never checked | one stated policy, four separate confidences |
**UNKNOWN is a result, not a failure.** A layer that cannot run says so, and the
decision carries it forward as INSUFFICIENT EVIDENCE rather than letting it read as a
pass. See Β§3.14.
Three entry points, each independently useful:
| Entry point | Gives you | Needs |
|---|---|---|
| `nexa ask` | grounded answer + all nine layers | Ollama + models |
| `nexa check <file>` | the same, with a real context layer | Ollama + models |
| `nexa run` | the above plus bounded escalation | + LLM Workflow Router |
| `nexa report` / `nexa evidence` | measurement, and the record behind one verdict | nothing extra |
---
## 2. Hardware constraints that shaped everything
Developed on a Lenovo ThinkPad P15 Gen 2: Xeon W-11855M, 32 GB DDR4-3200 in a **single
channel**, NVIDIA RTX A3000 Laptop with **6 GB VRAM**.
The 6 GB ceiling is the single most important fact about this design. It is why:
- the working model is a 7B at Q4, not a 14B
- the escalation model is a **Mixture-of-Experts** 30B (~3B active parameters) that
lives in system RAM, rather than a dense model that would run at 1–2 tok/s
- embeddings run on the CPU
- the 30B is capped at `num_ctx 6144`
### Measured, not estimated
| | |
|---|---|
| 7B, full GPU offload | **37–38 tok/s** |
| 7B, Ollama's automatic split | **11.7 tok/s** |
| 30B MoE, `num_ctx 6144` | **11.3 tok/s** |
| 30B MoE, `num_ctx 8192` | **fails** β€” CUDA out of memory |
---
## 3. Decisions
### 3.1 Two single-stack models, not one dual-stack model
**Decision.** Separate `ai-odoo`, `ai-ts` and `ai-js` rather than one validation model.
**Why.** The first attempt used one system prompt covering both Odoo and TypeScript.
Asked an Odoo question β€” *"I want to cap a sale order line quantity, I was going to use
an onchange"* β€” it answered entirely in TypeScript with a Zod schema. A 7B does not
reliably route between two stacks described in one prompt; the instructions bleed.
**Consequence.** Extra personas are effectively free: all three share one 4.7 GB weights
blob because Ollama deduplicates. Adding a fourth stack costs a Modelfile.
**Do not merge them back.**
### 3.2 The corpus is data, and prompts are generated from it
**Decision.** Rules live in `corpus/rules/*.json` against a JSON Schema. System prompts
and the retrieval index are **generated** by `nexa build`.
**Why.** Prompt tuning plateaued after two iterations. Hardening the Odoo
`false`-for-empty-field rule fixed the scalar fields and simultaneously broke the
Many2one tuple handling that had previously been correct. A 7B holds a bounded number
of constraints; past that point, more instruction text trades one error for another.
Moving the rules into data changed the failure mode. Instead of rewriting prose and
hoping, a rule is added once and flows to the prompt, the retrieval index, and the
compliance checker together.
**Consequence.** `modelfiles/gen-*.Modelfile` are build artifacts. Editing them by hand
is silently undone by the next build.
### 3.3 Rules carry a machine-checkable violation pattern
**Decision.** Each rule may carry a `violation_pattern` regex, optionally cleared by a
`satisfied_pattern`.
**Why.** Citation checking proves an id exists. It cannot catch the failure that matters
more: the model cites a real rule and then breaks it. Observed exactly that β€” the 7B
cited `odoo.onchange-is-not-validation` above an `@api.onchange`.
**Consequence.** 32 of 43 rules are checkable by pattern. Eight more are checkable by
an AST pass (3.12) and the remaining three return no verdict rather than a false
pass. Patterns match the code with comments stripped (3.13).
### 3.4 Patterns are tested against the rule's own samples
**Decision.** `nexa validate` fails the build on three pattern faults: one that does not
match its own `wrong` sample, one that fires on its own `correct` sample, and a
`satisfied_pattern` that clears its own `wrong` sample.
**Why.** Every rule already carried a wrong/correct pair, so the test data existed for
free. A pattern that catches nothing is useless; a pattern that fires on good code is
worse, because it trains you to ignore the checker.
### 3.5 Cross-checking measures the false-positive rate
**Decision.** `nexa crosscheck` fires every pattern at every *other* rule's correct
sample.
**Why.** Self-testing proves a pattern handles its own case. It says nothing about
whether it fires on unrelated good code.
**Evidence.** The first run found three real defects:
| Pattern | Defect |
|---|---|
| `float-comparison` | Fired on `qty > 0`, including inside SQL where `float_compare` does not apply. Narrowed to equality, the actual trap. |
| `odoo-read-returns-array` | Fired on any `Schema.parse(await ...)`. Narrowed to Odoo `read()`. |
| `ondelete-is-a-decision` | Correctly flagged a sample **in this corpus** that did not follow its own rule. Fixed the sample; excluded `related=` fields. |
Now 0 cross-hits.
### 3.6 Some rules deliberately have no pattern
**Decision.** Rules that no regex can decide carry no `violation_pattern`, and the
reason is recorded in `NO_PATTERN_REASON` and shipped with the rule itself as
`no_check_reason`.
| Rule | Why not |
|---|---|
| `odoo.schema-change-needs-migration` | The violation lives in a diff between module versions plus the presence of a `migrations/` directory. Nothing in one snippet reveals it. |
| `typescript.safeparse-when-failure-is-expected` | Depends on whether the caller can recover β€” context a snippet does not contain. |
| `typescript.brand-validated-primitives` | Absence of branding is not detectable; plain strings are correct in most code. |
Honest coverage with a named gap beats full coverage with patterns producing noise.
The reason now travels **with the rule** rather than living only in the build script,
so every layer that reports a gap can print why it exists. A coverage list without
reasons is a number, and a number is what gets rounded up to "checked".
Two rules left this table for Β§3.12.
### 3.7 Documentation is consulted when authoring, never at query time
**Decision.** Rules carry a `source` URL to upstream documentation. That documentation
is read while writing the rule, not while answering a question.
**Why.** Query-time lookup needs internet, adds latency, and reintroduces the
hallucination surface the corpus exists to remove. Authoring-time verification bakes
checked knowledge into offline data. Validate once at the boundary.
**Evidence.** Consulting the Odoo docs immediately surfaced a rule that had been missed:
`@api.constrains` **silently ignores dotted field names**, while `@api.depends` accepts
them. The decorators look interchangeable and are not, and the failure is silent β€” the
module installs and the constraint simply never fires. Odoo's own wording on the
load-bearing rule is blunter than the original draft: *"Never ever use an onchange to
add business logic to your model... they are only triggered in the form view."*
### 3.8 Pinned rules bypass retrieval
**Decision.** Rules flagged `"always": true` are injected into every answer for their
stack regardless of similarity score.
**Why.** Retrieval is driven by the question, but some traps only become relevant once
the model picks an approach. "A CHECK constraint cannot read another table" cannot be
surfaced by similarity against a question about stock levels β€” the question says nothing
about CHECK constraints. The rule became relevant only because the model reached for SQL.
This is a real limit of retrieval, not a tuning problem.
### 3.9 Escalation is bounded by topology, not a retry counter
**Decision.** `nexa run` routes its transitions through
[LLM Workflow Router](https://gitlab.com/dobybaxter127/llm-router). The `escalate`
container has `max_invocations=1`.
**Why.** The bound becomes declared configuration rather than control flow buried in a
function. Changing the policy means editing the topology, not the pipeline. The engine
is content-blind β€” it sees container, depth and requested transition, never the question
or the answer β€” so the decision stays deterministic and independently testable.
**Evidence it earned its place.** The router's config validator rejected the first
topology written here: a `verify`/`escalate` cycle containing `allow_reentry=False` is
dead by construction. Caught at load time, before any model ran.
**Locating it.** The checkout is found by search, not by one hardcoded path:
`LLM_ROUTER_PATH` if set, then `llm-router-main` or `llm-router` beside this
repo or next to it, then the home directory and Desktop. The original single
default pointed at `~/Desktop/llm-router-main`, which was where it sat on the
development machine and nowhere else -- a clone with the checkout beside it
failed, and the failure stayed invisible for a whole session because a stray
`LLM_ROUTER_PATH` in the environment was masking it. A failure now names every
path it tried rather than the one it was told about.
**API note.** `invocation_depth` carries entries made *before* the current one. The
engine refuses on `depth >= max_invocations`, so incrementing before evaluating makes a
`max_invocations=1` container refuse its own first call.
### 3.10 Telemetry is JSONL first, OpenTelemetry second
**Decision.** Events are written as JSONL under a `localai.*` namespace. The OTel bridge
is optional and no-ops when `opentelemetry-api` is absent. `nexa run` uses the router's
own `ObservableEngine`.
**Why.** The questions worth asking are aggregate β€” does escalation pay for itself,
which rules do the models keep breaking, is retrieval surfacing the right thing. Those
are queries over a table. A trace waterfall answers "where did this request spend time
across services", and this is one process on one laptop. Running a collector and backend
here would spend RAM competing with the model being measured.
The OTel seam stays because the cost is one line and it matters the moment this runs
inside a larger service.
**Conventions borrowed from the router**, because they were right: owned namespace, zero
hard dependency, and **a violation is not an error** β€” the same distinction the router
draws for REFUSE. A detected violation means the checker worked. Status stays OK and the
outcome is an attribute.
### 3.11 Content is not recorded by default
**Decision.** Question and answer text are omitted from telemetry unless
`LOCALAI_TELEMETRY_CONTENT=1`.
**Why.** This runs on a machine used for client work. A telemetry file is the last place
client code should accumulate. Recorded instead: rule ids, counts, model names, timings.
### 3.12 Two critical rules moved from regex to AST
**Decision.** `odoo.constrains-must-list-every-field-read` and
`odoo.stored-compute-needs-complete-depends` are decided by checkers in
`bin/semantics.py`, declared on the rule as `semantic_check`.
**Why.** Β§3.6 named both as the strongest case for an AST pass: both `critical`, both
silent failures, both needing the same thing β€” a set comparison between what a
decorator declares and what the method body actually reads. Python's `ast` module
does that without leaving the standard library, which is the only reason it was
affordable.
The walk resolves aliases to a fixpoint, so `for rec in self` followed by
`for line in rec.line_ids` makes a later `line.price` resolve to the dependency
`line_ids.price`. Reads inside a `raise` are excluded: a field read to phrase an
error message does not decide whether the invariant holds, so it cannot decide when
the constraint has to re-run either.
**Evidence it needed the same discipline as a pattern.** Cross-checking the new
checkers against every other rule's `correct` sample found two hits on the first run.
One was checker breadth β€” `rec.display_name` read only inside a `ValidationError`
message β€” and narrowed the checker. The other was real: the `correct` sample of
`odoo.check-constraint-cannot-cross-tables` read `company_id` without declaring it,
so a sample in this corpus did not follow another rule in this corpus. The sample was
fixed, exactly as in Β§3.5.
**Consequence.** Machine-checkable coverage is 40 of 43 (93%): 32 by pattern, 8 by
AST. `nexa coverage` reports the two mechanisms separately, because a rule that
compares meaning and a rule that compares text are not the same kind of checked, and
one percentage would hide which of the two a `critical` rule is resting on.
**Carrying both is a hard error.** A rule with a `violation_pattern` *and* a
`semantic_check` fails `nexa validate`: the domain layer defers to the semantic
layer, so the pattern would be silently ignored.
### 3.13 Patterns match comment-free code
**Decision.** `violation_pattern` and `satisfied_pattern` are matched against the
generated code with its comments stripped.
**Why.** A regex cannot tell code from prose, and a `satisfied_pattern` exists to
clear a violation when the real fix is present alongside it. If the fix can be
*mentioned* rather than applied, the checker reports clean while the bug ships.
**Evidence.** Not a hypothetical. The counterexample probe builds its attack out of
each rule's own `correct` sample β€” the real fix, quoted in a comment instead of
applied β€” and on its first run **all 12 satisfied patterns in the corpus were
defeated by a comment**, four of them on `critical` rules. Writing an `@api.onchange`
and adding `# _sql_constraints = [` was enough to clear
`odoo.onchange-is-not-validation`, the rule this project opens with.
It cuts the other way too. A commented-out `@api.onchange` was being reported as a
live violation, which is the false positive that teaches people to ignore a checker.
**Consequence.** 0 of 12 defeated after the change. The stripper is quote-aware, so a
`#` inside a string and a `//` inside a URL are left alone, and it preserves newlines
so multi-line patterns still see the line structure they were written against.
### 3.14 Validation is layered, and UNKNOWN is a result
**Decision.** Every answer goes through nine layers β€” syntactic, structural,
semantic, knowledge, domain, context, empirical, adversarial, provenance β€” and each
reports a verdict, **what it checked**, and **what it could not check and why**. A
tenth step combines them.
**Why.** Two checks were doing the work: do the cited ids exist, and does the code
break a pattern. Both survive here as the knowledge and domain layers, but neither
ever saw an answer whose Python does not compile, whose `@api.constrains` omits a
field it reads, or whose imports name a module that is not installed. Those were
passing.
The more important reason is the one a flat checklist gets wrong. A list of checks
invites you to build the cheap ones, tick every box, and read the result as
validated. The cheap layers are cheap because they prove least. So a layer that
cannot run reports `UNKNOWN`, and the decision carries that forward as INSUFFICIENT
EVIDENCE. It never degrades into PASS, because *I found nothing* and *there is
nothing to find* are different claims and only one of them is supported.
**Consequence.** Confidence is reported as four separate words β€” structural,
semantic, contextual, empirical β€” and never averaged. An average is high whenever the
cheap layers ran, which is exactly the false reassurance the separation exists to
prevent. A PASS prints with the sentence *PASS means the checks that ran found
nothing. It does not mean the answer is correct.*
**Ordering is a cost decision.** Layers run cheapest first and short-circuit: code
that does not compile is not worth pattern-matching, so the layers below it report
UNKNOWN with that as the reason rather than emitting artefacts of a broken parse.
**A repair is not an error.** An Odoo answer very often shows a method at the
indentation it had inside its class, and a bare `ast.parse` calls that *unexpected
indent*. `parse_fragment` dedents, drops labelled sections that leaked inside the
fence, and finally tries wrapping the block in a class β€” and **records every repair
it made** as a medium finding rather than hiding it. Reporting a correct answer as
uncompilable is the worst failure a syntax layer has available: it is wrong, it is
loud, and it masks every layer beneath it.
### 3.15 The checkers are attacked as part of the build
**Decision.** `nexa adversarial` runs three probes, and `nexa build` runs them.
| Probe | Question it asks |
|---|---|
| `evasion` | Reformat the `wrong` sample without changing its meaning. Does the checker still see it? |
| `counterexample` | Can a `satisfied_pattern` be cleared by a comment quoting the fix instead of applying it? |
| `contradict` | When one rule fires on another's `correct` sample, is that intended breadth, or do the two rules disagree? |
**Why.** `validate` compares a checker against code written alongside it, by the same
hand, in the same sitting. Of course it passes. `crosscheck` adds unrelated good
code. Neither asks what it takes to get bad code *past* a checker, which is the
question that decides whether the checker is worth trusting at all.
**Evidence.** The first run found the comment hole in all 12 satisfied patterns
(Β§3.13) and one pattern losing its own violation under a trailing-comment rewrite.
Current state: 64/64 rewrites still caught, 0 of 12 satisfied patterns defeated, 0
contradictions.
**The phantom finding is recorded too.** The noupdate rule looked evadable until it
turned out the mutation was appending `# noqa` to **view XML**, which is not a
meaning-preserving rewrite but corruption. The comment marker now follows the
language. A mutation that changes behaviour makes a disappearing violation ambiguous,
and the entire value of the probe is that it is not.
### 3.16 Escalation fires on repairable findings, not on every finding
**Decision.** `nexa run` escalates when the syntactic, semantic, knowledge or domain
layer reports a `critical` or `high` finding. Structural, context, provenance,
empirical and adversarial findings do not trigger the 30B.
**Why.** A missing `Not handled:` label is a real finding that no larger model fixes
better; it would just cost 11 tok/s to find that out. A tsconfig with `strict` off is
not something a model can rewrite its way out of at all. The number that decides
whether the 30B earns its load time is the rescue rate, and that number only means
something if escalation fires on cases a rescue was possible for. Escalating on
cosmetics inflates the denominator with cases that were never winnable.
Amended 2026-10-01: a `critical` or `high` finding that is not advisory. An advisory finding
states a preference, which leaves a larger model nothing to repair, and until Β§3.81 it
escalated anyway.
### 3.17 Each run writes one evidence bundle
**Decision.** A run writes `bin/evidence/<trace>.json`: the corpus hash, the rules
retrieved with their scores and which arrived by pin, every layer's verdict and
findings, the checker versions, the model and its options, and the timings.
**Why.** Telemetry answers aggregate questions and is deliberately not addressable β€”
there is no way to pick up one past answer and ask why it was marked VERIFIED. A
verdict nobody can reconstruct is a claim, not a result. Same argument as Β§4's
closing line: an unmeasured number decays into a claim.
**Consequence.** The provenance layer checks the bundle rather than the answer, and
fails when a field needed to re-derive the verdict is missing. Content follows the
telemetry policy: question and answer text are omitted unless
`LOCALAI_TELEMETRY_CONTENT=1`. Bundles are gitignored and pruned by count.
---
### 3.18 The checkers are measured against code nobody wrote for them
Β§3.4, Β§3.5 and Β§3.15 all compare a checker against the 32 samples in this corpus,
written by the same hand in the same sitting. Passing proves self-consistency.
`nexa fieldtest <folder>` fires every checker at a tree of somebody else's source and
reports the hit rate per thousand lines. On code that already works, a hit is a
question about the checker.
Run against OCA/server-tools and OCA/account-financial-tools β€” 606 files, 34,550
lines β€” it took the corpus from 397 hits (11.3/kloc) to 293 (8.5/kloc) by narrowing
three checkers that were wrong in ways the 32 samples could not expose:
| Checker | Before | After | What it was doing |
|---|---|---|---|
| `onchange-is-not-validation` | 16 | 0 | Firing on any `@api.onchange`. None of the 16 was validating anything β€” they set defaults and narrow domains, which is what onchange is *for*. It now requires the onchange to try to enforce something. |
| `depends_covers_compute_reads` | 121 | 95 | Demanding `@api.depends` entries the ORM refuses: paths ending `.id`, and fields the method computes itself. It was telling authors to write a decorator Odoo would reject. |
| `prefer_sql_constraint_applies` | 8 | 2 | Reading a guard as the whole rule. In `if record.storage == "url" and not record.url:` the first half is an `ast.Compare` and the second is not, so it concluded the function "does nothing else". |
Corrected 2026-09-27: the ORM refuses neither entry in the `depends_covers_compute_reads`
row. Odoo 18 drops a field from its own `@api.depends` without a word, and keeps a path
ending `.id` as the relation it ends on, with a trigger on `id` that fires when a related
record is deleted (`resolve_depends` in `odoo/fields.py`, 18.0 at 3c3e3b3).
`ondelete-is-a-decision` took two passes. Odoo defaults a *required* `Many2one` to
`ondelete='restrict'` β€” the behaviour the rule recommends β€” so 56 of its 198 hits were
fields already correct and were exempted. That left 142 optional `Many2one`s genuinely
defaulting to `set null`: true positives by the rule's letter, at 4.1/kloc, and still
loud enough to bury the other nine Odoo rules under one that is a policy rather than a
defect.
The second pass asked where the harm actually lands. `set null` nulls the reference,
and a later read *through* it yields an empty recordset that Odoo renders as `False`
rather than raising β€” "dangling references that later reads treat as empty", which is
the rule's own wording and needs a read to happen at all. A field declared and never
dereferenced cannot exhibit it. Deciding that is a tree walk rather than a regex, so
the rule moved to an AST pass:
| | Hits | Per 1000 lines |
|---|---|---|
| Regex over the declaration | 198 | 5.66 |
| …exempting `required=True` | 142 | 4.11 |
| AST: no `ondelete` **and** read through | 33 | 0.96 |
and the finding names the read that makes it matter:
```
task_id declares no ondelete, so the ORM defaults it to 'set null' -- and
task_id.filepath on line 37 reads through it, which yields False rather than
raising once the parent is deleted
```
What that gives up is real and deliberate: a generated snippet that declares a
`Many2one` and does nothing else with it is no longer flagged. The rule still asks for
an explicit `ondelete` on every one. The **checker** is narrower than the rule and
fires only where the code is actually exposed β€” the same gap `required=True` already
had, written down rather than discovered.
Total across the corpus: **8.5 β†’ 5.3 hits per 1000 lines**.
One of the hits was a real defect in OCA rather than in the corpus: `auditlog`'s
stored `display_name` reads `user_id.name` while declaring only `user_id`, so the
stored value goes stale when a user is renamed.
**A second corpus, six times the size.** OpenSPP 2 β€” 1,353 files, 229,600 lines
of Odoo 19 β€” gives **2.1 hits per 1000 lines**, against 3.9 on the OCA sample.
A larger, newer codebase and a lower rate, which is the direction a precision
number should move when the sample stops being small.
**And something the OCA sample could not show.** 22 of the 128
`sudo-bypasses-record-rules` hits land on lines carrying
`# nosemgrep: odoo-sudo-without-context` β€” a different static analyser looked for
the same construct on the same line, and somebody read the finding and decided to
keep the code. Two things follow, and they pull in opposite directions:
- The rule is not idiosyncratic. An independent tool, written by people who have
never seen this corpus, is looking for the same thing.
- Nexa had no way to say "decided, keep it", so those 22 were questions this
project had already answered and every run asked again.
`layers.scan_source` reads those markers now β€” `noqa`, `nosemgrep`,
`pylint: disable`, `type: ignore`, `eslint-disable`, `@ts-ignore` β€” and marks the
hit. `fieldtest` counts those separately under ALREADY DECIDED and leaves them out
of the per-kloc rate; `check` lists them under their own heading and does not fail
a build over them; and neither drops one. The rate moved 2.2 β†’ 2.1 because 22 hits
left it, and all 22 are still on screen with their rule and line.
**There is deliberately no `# nexa: ignore`.** Recognising another tool's marker
means somebody read a finding from an independent analyser and decided; a marker
of our own would mean somebody wanted this tool quiet, and it would be the first
mechanism here capable of hiding a real finding. The whole design is an argument
against having one.
Hits on suppressed lines also do not enter the grounding for `check`. They are
reported; grounding an answer in a settled question spends one of six slots on it.
### 3.19 Every rule carries a retrieval probe
`nexa report rules` used to print *"never retrieved (7 of 32) β€” dead weight, or worded
so the embedding never matches"*. It was reading telemetry: 15 recorded queries, twelve
of them Odoo and one TypeScript, against a `TOP_K` of 6. Ten TypeScript rules and one
TypeScript query means at least four TypeScript rules **could not** have been
retrieved however they were written. Five of the seven names were arithmetic about the
sample, presented as a finding about the corpus.
Telemetry can say what *was* retrieved. Whether a rule *can* be is a different
question, so each rule now carries a `probe`: one question phrased the way somebody
meets that problem, deliberately not a paraphrase of the rule (a probe built from the
rule's own wording would match whatever the rule said). `nexa eval --retrieval` asks
each one and reports the rank. The build fails if a rule has no probe.
All 32 are retrievable at `TOP_K` 6 and at top 3. Two were not and were reworded β€”
the same failure `prefer-sql-constraint` had, which is what the probes exist to catch
before somebody notices by accident.
The report states its own limits: pinned rules are prepended regardless of score so
ranking against them measures the pin, and a stack with fewer rules than `TOP_K` + 1
cannot fail the test at all.
### 3.20 One answer to where Ollama is, and one to every way it can fail
Three files each hardcoded `http://127.0.0.1:11434`. `bin/endpoint.py` resolves it
once from `OLLAMA_HOST`, per call rather than at import, so a test can point the whole
system at a socket that is closed, slow or serving nonsense after the module has
loaded.
That mattered because the commonest failure this tool has printed a stack trace:
```
urllib.error.URLError: <urlopen error [WinError 10061] No connection could be
made because the target machine actively refused it>
```
`endpoint.explain()` converts each failure once, at the single point every caller
reaches the server through, and each conversion carries the next step: which address
was tried and whether that came from the environment, *which* model a 404 is about,
Ollama's own words from a 500, that a cold 30B load can exceed the timeout. Every
entry point catches it and exits 1 β€” called correctly, could not finish, which is not
the same as called wrong.
### 3.21 Appending telemetry needs a lock, on Windows
`telemetry.jsonl` is opened in append mode by every process that emits an event. Four
processes, forty events each:
| Strategy | Records surviving |
|---|---|
| `io.open(path, "a")` + write | 154–159 of 160 |
| `os.open(O_APPEND)` + `os.write` | 151 of 160 |
| lock, seek, write, unlock | 160 of 160 |
No torn lines in any of them, and no process reported an error β€” the lost records were
silently overwritten. Windows implements `O_APPEND` as a seek to end followed by a
write: two operations, so two processes resolve the same offset and one clobbers the
other. POSIX `O_APPEND` is atomic for a write this small, so the lock costs a syscall
there and is the whole point here.
`emit()` takes an exclusive advisory lock on one byte before appending β€”
`msvcrt.locking` on Windows, `fcntl.flock` elsewhere, an unlocked append as the
fallback where neither exists, because a line that might be lost still beats no
telemetry. It still never raises.
This matters because `nexa report` counts those lines. A tool arguing that its numbers
can be trusted cannot quietly drop 4% of them whenever two terminals are open.
### 3.22 The corpus hash identifies the corpus, not the machine
`evidence.corpus_sha256()` digests the rule files byte for byte, and that hash is in
every bundle to say which corpus a verdict was reached against. `build_corpus.py`
wrote its JSON in text mode, so on Windows every newline became CRLF β€” and the same 32
rules hashed differently on Windows and Linux. No bundle from one machine could be
matched against a bundle from another.
Fixed at both ends: the generator writes LF explicitly, and the hash normalises CRLF
to LF before digesting, so a checkout that arrives with CRLF still identifies itself
correctly. Verified across both platforms:
```
Windows b4702925cd802d1abc9556faad464ce5987b7baa5d767e74cbd3b50ab269c861
Linux b4702925cd802d1abc9556faad464ce5987b7baa5d767e74cbd3b50ab269c861
```
### 3.23 A fourth stack, and what `nexa check` does with a .py file
`nexa check` used to map every `.py` to Odoo. Then, for a while, it refused a `.py`
with no `odoo` import β€” honest, because reviewing a plain script against fifteen ORM
rules produces fifteen findings about a framework it does not use, and not useful,
because refusing is not an answer.
The Python stack is the answer. Eight rules, the same subject as the rest of the
corpus: `assert` disappearing under `-O`, the mutable default argument, the bare
`except` that swallows `KeyboardInterrupt`, `eval`/`pickle` on untrusted input,
`shell=True`, `os.path.join` discarding its base on an absolute component, binary
floats for money, and naive-versus-aware datetimes. Every one is documented upstream
and carries a pattern.
`check` now decides from the file: an `odoo` import or a `__manifest__.py` above it
means the Odoo rules, anything else means plain Python. `--stack` overrides either
way.
Measured before shipping, on Nexa's own `bin/` β€” 5,000 lines of stdlib-only Python:
**0 hits**. On the corpus generator, 11, every one of them the rule text quoting
`shell=True` and `datetime.now()` inside a string literal, which is what the real
checker does too (Β§3.13 strips comments, not strings). All 8 are retrievable by
their own probe at top 3.
Two of the eight were narrowed by that field test before they ever ran on anybody
else's code: `os.path.join`'s untrusted-input tokens were unanchored, so `form`
matched inside `.format(`; and money-versus-float fired on any `float()` in a file
with no `Decimal`, which caught a Wilson score interval.
The fourth stack left `context.py` behind. That module answers the same question
β€” which stack is this file β€” from the extension alone, and its docstring claimed
to match `cmd_check`. It did not, in three ways. A plain `.py` was python to the
router and odoo here, so the only thing the context layer ever said about a
Python script was that its `__manifest__.py` was missing: a true sentence about a
question that does not apply. `.mts` and `.cts` were TypeScript here and fell
through to Python there, so a `.mts` file was reviewed against the Python corpus
while this layer went looking for its tsconfig; `.cjs` the same.
There is one extension table now, and the caller that read the file passes the
routed stack back through `from_file(stack=...)` β€” the extension can say which
family a file belongs to, and only the content can say whether a `.py` is an
addon. `nexa eval --check` is what surfaced it (3.26): routing is one of the four
numbers it prints, and two answers to the same question disagree visibly the
moment something prints them side by side.
### 3.24 `nexa build` could not register a model on exFAT
Ollama resolves an unqualified `FROM` against the Modelfile's own directory before
trying the registry. That candidate path contains a colon β€”
`...\modelfiles\qwen2.5-coder:7b` β€” and on exFAT Windows returns
`ERROR_INVALID_NAME` rather than "not found". Ollama treats that as fatal and never
reaches the registry:
```
Error: CreateFile D:\nexa\modelfiles\qwen2.5-coder:7b: The filename,
directory name, or volume label syntax is incorrect
```
So the last step of `nexa build` β€” the one that registers the models β€” could not work
at all when the repository lived on an exFAT drive, which an external SSD holding a
dev tree very often is. The generated Modelfiles now carry
`FROM registry.ollama.ai/library/...`, which is resolved before the path attempt.
Worth recording how this was nearly mis-diagnosed. The first comparison was an LF
Modelfile on `D:` against a CRLF copy on `C:`, which failed and succeeded
respectively, and the obvious conclusion was that Ollama wanted CRLF. It was wrong:
two variables had changed. Moving the same bytes around one at a time showed the
drive was the variable and line endings did not matter at all. A CRLF commit went in
on the strength of the two-variable test and came straight back out.
### 3.25 Recall, and the number that was nearly flattering
Β§3.18 measures how often a checker is wrong when it fires. Nothing measured how
often it fails to fire, and the distinction matters more here than the precision
work did: crosscheck, adversarial and fieldtest would all be perfect on an empty
corpus.
`nexa recall <repo>` reads git history. For every commit that looks like a fix,
it runs the checkers over each changed file as it stood before the commit and
again after, and counts a **catch** when a finding was present before, is absent
after, and sat on a line the commit actually changed.
Against OCA/server-tools and OCA/account-financial-tools, 385 fix commits, 56 of
them touching a construct the corpus covers:
| | |
|---|---|
| Caught | 1 |
| Missed | 55 |
| Rules that caught anything | `check-company-on-relations`, once |
**The third condition is the measurement.** Without "on a line the fix changed",
the first run of this tool reported 12 catches, eleven of them from
`ondelete-is-a-decision`. The commit subjects were *"fix image url in locales
with thousand separator"*, *"poor performance updating assets massively"*, *"fix
JS crash in analytic distribution"*. That checker fires on a `Many2one` with no
`ondelete` that is read through, so any refactor moving the read makes the
finding vanish. Eleven refactors, presented as the corpus catching bugs.
The tool still reports how many commits fall in that gap, precisely so the
looser number cannot quietly come back.
**What follows from it.** The corpus is precise and narrow: right about what it
describes, and describing very little of what goes wrong. Several of the 55
misses are inside its own stated territory -- a `sudo()` removed during import,
an x2many leaking across companies, a stored compute not recomputing -- which
means the gap is not only "rules that do not exist" but "rules that exist and
did not reach".
**What does not follow.** The denominator is generous: many of those 56 commits
touched a covered construct while fixing something unrelated, so 2% understates
recall against validation bugs specifically. Two repositories in one ecosystem
is a narrow sample. This is a floor, and the first one the project has had.
### 3.26 `nexa check` is measured the way `nexa ask` is
Everything above about verdicts, stability and escalation was measured on `nexa
ask`. `nexa check` β€” the command somebody actually points at their own file β€”
inherited none of it, and was assumed to inherit all of it because it ends in
the same call.
It does not. That call is the last step; the command is the four decisions in
front of it. Which corpus the file is routed to, what the tree around it says,
how much of the file fits in 8192 tokens, and what question gets built from the
rest. Those lived inside `cmd_check`, next to the printing, where only a person
running the command could reach them. They are now `bin/subject.py`, so
`nexa eval --check` measures the decisions the command makes instead of a second
copy of them that drifts away quietly.
**Five files, five runs each, 25 reviews.** `subjects/` is committed β€” one file
per route `check` can take, one of them too long to fit. This is the state
`check` was in when it was first measured; 3.28 is what the measurement led to
and carries the current numbers.
| | |
|---|---|
| PASS | 5 of 25, 95% CI 9–39% |
| WARN | 15 of 25, 95% CI 41–77% |
| REJECT | 5 of 25, 95% CI 9–39% |
| Same verdict every run | 5 of 5 files |
| Files whose answer named nothing they declare | 2 of 5 |
| Files where no layer found anything, five runs of five | 1 of 5 |
| Seconds per review, median | 4.4 |
**Every PASS is one file, and it is the worst one in the set.** `fsm_visit.py`:
7,929 characters, 24% never sent, five answers of 435–598 characters each naming
1 of the file's 19 symbols, and not one layer found anything on any run. A PASS
under the stated policy, and the plainest number here for how little the checks
see on a real file.
That file used to WARN, on the module targeting Odoo 17.0 against a corpus
checked against 18.0 β€” an environment fact that fired regardless of the answer,
since demoted to a gap (3.27). The earlier run's 0-of-25 read as strictness and
was an accident.
**Stability is now its own number.** 5 of 5 files reach the same verdict every
run with no constant holding them there, though the route varies: `fsm_job.py`
reaches WARN through the semantic layer on four runs and through structural and
syntactic on the fifth, where the model produced a 1,942-character answer that
did not hold together. Same verdict, different reason, and none of them about
the file.
**The checkers never look at the file.** They run over the answer. On the Python
and TypeScript subjects the model reproduced the file, so the file's own defects
came back as findings β€” `mutable-default-argument`,
`path-join-does-not-contain-a-path`, `unknown-not-any-at-boundaries`. On the two
Odoo subjects it wrote a short generic snippet instead, 296 to 605 characters,
naming 0 of the 7 and 1 of the 19 names those files declare; five runs of each
produced no finding about the code at all.
`nexa fieldtest` β€” the same corpus, the same checkers, no model anywhere β€” fires
nine times on those two files: a stored compute whose `@api.depends` is
incomplete (four times), an `@api.constrains` reading two fields it does not
declare, an `onchange` used as a guard, a float compared with `==`. `nexa check`
reported none of them. Two of the nine sit below the 6,000-character cut, so one
run in five never saw a quarter of the file it was reviewing.
**What follows.** On real code the deterministic half carries the weight and the
generative half is decoration. The limitation note below called that an
observation; this is the measurement. Feeding the first 6,000 characters of a
file to a 7B and checking what comes back is the wrong shape for a review β€” the
checkers should be pointed at the file, and the model asked about what they
found.
**What does not follow.** Five files, written for the measurement rather than
sampled from anybody's production tree. `nexa eval --check <path>` takes a real
one; `subjects/README.md` says what the committed set does and does not buy.
### 3.27 The context floor was a finding, and is now a gap
The context layer had two findings that did not depend on the answer:
this module targets Odoo 17.0; the corpus was checked against 18.0, so
version-specific behaviour in these rules is unverified here
tsconfig has strict off, so the compile-time half of any type-based
guarantee in this answer is not actually enforced
Both are true, both are worth knowing, and both were in the wrong slot. A
finding says something in THIS ANSWER is wrong or unproven. These say the corpus
was verified against a different series, and that this project has not finished
turning strict on -- facts about the corpus and the tree, which hold whatever
the answer says.
As findings they were a floor. Any Odoo module not on the newest series, and any
project mid-migration, drew a WARN on every run forever, so PASS was unreachable
for most real users and the WARN stopped carrying information. That is the same
argument that keeps `check` silent about a file that fits: a warning on every
run trains people to skip it.
The deciding detail at the time: **0 of the 15 Odoo rules carried an
`odoo_versions` field.** `_version_applies` -- the check that produces a real
finding, naming a retrieved rule that genuinely does not hold on this series --
had therefore never fired and could not. The blanket warning was standing in for
per-rule data that did not exist, and spending a verdict to do it.
All 15 carry one now (3.29), so the layer answers the version question per rule
instead of once per module, and a module on a supported series reaches a
contextual PASS on a check that actually ran.
Both are gaps now, printed under "Not checked", and the layer reports UNKNOWN
when they were all it had. Contextual confidence reads `none`, which is the same
claim the WARN was making without burning the verdict. The per-rule version
check keeps its finding.
**What it did to 3.26, measured rather than projected.** The eval was re-run on
the same five files after the demotion. Five of the twenty WARNs became PASS,
exactly as the arithmetic on the recorded rows had said, and all five are
`fsm_visit.py`: the file that lost 24% of itself to truncation and came back
with a 435-character answer naming 1 of its 19 symbols. Five runs, and after the
demotion no layer finds anything at all on any of them.
That file now reads PASS. It was never being caught for being unreviewed; it was
being caught for its manifest, and a right verdict for an unrelated reason is an
accident rather than a safety property. Removing the accident is what makes the
gap visible: **truncation is not evidence-bearing.** A review of the first 6,000
characters of a 7,929-character file should not reach PASS without the verdict
saying so, and today the only thing that says so is a line of prose above the
answer. `decide()` already has the mechanism -- an essential layer reporting
UNKNOWN carries INSUFFICIENT EVIDENCE -- and truncation is not wired to it.
Both of those are done now: 3.28 wires coverage into the verdict and 3.29 fills
in the version data. The floor is gone and so is what it was masking.
### 3.28 `nexa check` reads the file, instead of only asking about it
3.26 measured `check` and found the shape of it wrong in two ways that had
nothing to do with the model's quality.
**The checkers never saw the file.** Every layer ran over the model's *answer*.
So the corpus could find six things in `fsm_visit.py` β€” an incomplete
`@api.depends` on three stored computes, an `@api.constrains` reading two fields
it does not declare, a uniqueness test racing in Python β€” and the command
reported none of them, because the 7B had not happened to mention them. The same
corpus, through `nexa fieldtest`, found all six with no model installed.
`layers.scan_source` is now the single answer to *what do the checkers say about
this source file*, and both `check` and `fieldtest` route through it -- the
latter since 3.33, which is when that sentence became true rather than intended.
`check`
runs it before generation and prints the findings with their line numbers. They
are established without a model and they do not move between runs.
The model is then shown those findings and asked to explain and repair them, and
to look for what no pattern can decide. It is a better question than the one it
replaced: *review this file* invited a rewrite, and got one.
**The excerpt was the first 6,000 characters.** A character count is not a unit
anybody thinks in, and it cannot be chosen well β€” the old rule cut
`fsm_visit.py` 1,900 characters above `_check_no_overlap`, which carries three
of that file's six findings.
The file is now split into symbols β€” functions, classes, methods, each with its
decorators, because `@api.constrains('engineer_id')` is half of what the method
below it means. Units are selected whole, **the ones a checker fired on first**,
and what is left out is named in the report, marked in place in the excerpt, and
declared to the model. A unit larger than the whole budget has its head taken and
its name says how much of it that is, so the degenerate case is never worse than
the rule it replaced.
**Coverage is evidence, and its absence is the lack of it.** `decide()` takes the
context now, and a review that did not cover the file carries INSUFFICIENT
EVIDENCE:
WARN (INSUFFICIENT EVIDENCE: 9 of 20 symbols were not reviewed)
3.27 exposed the need for this: with the version floor gone, the one file nothing
could see returned a bare PASS. The prose above the answer said it was an
excerpt; the verdict is what gets read.
**Re-measured on the same five files, five runs each.** The `after` column is
the committed state, with the checkers' findings reaching the grounding as well
as the report (3.31).
| | before | after | re-run |
|---|---|---|---|
| Findings reported about the files | 0 | **23** | **23** |
| Files with a finding reported | 0 of 5 | **5 of 5** | **5 of 5** |
| The same files, through `fieldtest` with no model | 23 | 23 | 23 |
| PASS / WARN / REJECT | 5 / 15 / 5 | 0 / 18 / 7 | 0 / 12 / 13 |
| Same verdict every run | 5 of 5 | 4 of 5 | 3 of 5 |
| Files reaching a contextual verdict | 0 of 5 | 2 of 5 (3.29) | 2 of 5 |
| Symbols of their own the answers name | 34 | **90** | 82 |
| Rules the model cited beyond the checkers | β€” | **1**, in 25 reviews | **3**, in 25 reviews |
| The truncated file | PASS x5, nothing found | WARN/REJECT, INSUFFICIENT EVIDENCE | REJECT x5 |
The `re-run` column is the same command against the corpus at 43 rules, and the
first three rows are why it is worth having: every deterministic number is
identical and every model-dependent number moved. None of the three rules added
in 3.34 and 3.35 fires on any of these files, so nothing in the corpus explains
the difference.
`partners.ts` settles what does. It moved from WARN five times out of five to
WARN/REJECT, and it is TypeScript -- `ai-ts` was not rebuilt and its ten rules
did not change. There is nothing left for that to be except the model at
temperature 0.15, and five runs being a small sample of it.
The PASS column going to zero is the point rather than a regression. Every one
of the five files has findings in it, and the one that used to pass five times
out of five was the one nothing could see.
The 23 is the number to look at, and it is not a model improvement: it is the
corpus finally being pointed at the thing it was written to check. `fieldtest`
extracted exactly the same 23 from the same files before any of this, with no
model installed, which is what made the gap measurable in the first place.
`fsm_visit.py`'s last method is reviewed now rather than cut off, because three
of its six findings sit in it and the excerpt is ranked on that.
**What the generative half is worth, with a number that moves.** Handing the
model the findings did make its answers more about the file: the symbols they
name went from 34 to 90 across the set. What it *adds* is a different question,
and the answer is small and unstable -- one rule cited beyond the checkers in 25
reviews on the first run, three on the second, and not the same rules either
time.
That correction matters more than the figure. "One in 25" was quoted here as
though it were a property of the tool; it was one sample of a quantity that
tripled on the next run of the identical command. The direction holds -- the
deterministic half is what carries this tool on real code -- but the number
attached to it is an interval nobody has measured, on a sample far too small to
be worth measuring. The deterministic
half carries this tool on real code and the design says so now instead of hoping
otherwise. Where to spend the next hour follows from it: another rule in the
corpus is worth more than another prompt, and 3.32 is what happened to the
prompt that was tried.
**And what follows for the command.** A handful in 25 is worth having and is not
worth paying for every time. `nexa check --no-model` stops after the checkers:
0.26 seconds, no Ollama, and the same output every run -- against a path where
three of five files gave different verdicts across runs of the identical command.
The instability is the stronger half of the argument, and it only became visible
by running the eval twice. A gate wants a number that does not move; this one
moves, and the flag is how a gate stops depending on it.
It is a second mode rather than a new default, because the one citation is real.
What the flag changes is who decides: a person reading a file wants the part no
pattern can decide, and a pipeline gate wants the part that is the same every
time, and those had been the same command.
It prints no verdict. There is nothing to have a verdict about -- no answer was
generated, so nothing was checked except the file -- and a clean run says *no
pattern fired* in those words rather than anything that could be read as a pass.
That is the same refusal `decide` makes when a layer could not run, in the one
place where the missing layer is all of them.
One verdict moved between runs (`load_prices.py`, REJECT once in five), which is
the honest cost of a non-deterministic component anywhere in the pipeline and
the reason this eval exists.
**And one thing the numbers exposed.** `fsm_job.py` reaches PASS with three
checker findings printed above it -- because the verdict is about the answer and
the findings are about the file, which are different claims about different
things. Both are true and printing them together invited conflation, so `check`
says which is which, and its exit code follows the file: a clean answer about a
file with three critical findings in it still exits 1.
### 3.29 Every Odoo rule declares the series it holds for
`odoo_versions` existed, `_version_applies` parsed four kinds of range, and
`layer_context` was ready to report a retrieved rule that does not apply here.
None of it had ever run, because no rule carried the field.
The field's absence was not neutral. The layer read a missing `odoo_versions` as
*assumed to hold on every series*, and said so under "Not checked" β€” which is a
broader claim than any range anybody would actually write. Fifteen rules made
that claim by saying nothing.
All fifteen now declare `>=16.0`. Two things follow:
- A module on 17.0 gets a **contextual PASS on a check that ran** β€” six retrieved
rules, each declaring a range, each covering 17.0 β€” instead of a gap saying the
question was unanswerable. That is the proper replacement for the floor 3.27
removed: not silence, but an answer.
- A module on a series outside the range gets **one finding per rule, naming the
rule**, rather than one blanket sentence about the module. That is the finding
the mechanism was built for.
**What `>=16.0` is, and what checks it.** It was an assertion about API
stability rather than something anybody had verified, and there is no 16.0
checkout here to test against. The strongest evidence available is the
documentation: if the API a rule is about is described in the 16.0 and 17.0
references as well as 18.0, the rule's subject exists in those series.
`corpus/check_sources.py` does that, for every Odoo rule against every series it
claims. Authoring-time, like the rest of the provenance work (3.7) β€” it is the
one thing in this repository that touches the network, it never runs to answer a
question, and nothing at query time depends on it.
| | |
|---|---|
| Rule-series where the cited page exists and names the API | 42 |
| …where the citation 404s but the ORM reference has it | 2 |
| …where neither does | **1** |
| Rules citing something other than the Odoo docs | 3 (9 rule-series) |
The two `reference` results are the tutorials, which were reorganised between
16.0 and 17.0: the page moved, the API did not. The one genuine gap is
`float-comparison-needs-float-utils` β€” `odoo.tools.float_utils.float_compare` is
not described anywhere in the 16.0 documentation, on the ORM reference, the
coding guidelines or the changelog.
That rule carries `odoo_versions_basis`, a new optional field saying why its
range holds anyway. The checker exits non-zero for any rule that has neither
documentation support nor a stated basis, so no version claim in this corpus
rests on nothing.
A documentation page is not a test run, and the tool says so in its own output.
What it catches is the case that matters most: a rule about an API that was not
there yet, or was described differently.
**And the blind spot it has.** A function Odoo shipped and never documented reads
as missing, which is exactly the case above: the basis field was a hand-written
sentence covering a gap in the evidence rather than a gap in Odoo.
`check_sources.py --source` closes it by reading odoo/odoo instead. For each rule
it asks whether the API is DEFINED in that series' module β€” `def float_compare(`
in `odoo/tools/float_utils.py`, not the word anywhere on a page β€” fetching the
handful of files it needs rather than cloning three branches to answer a question
about eighteen symbols.
| | |
|---|---|
| Rule-series defined in source across 16.0, 17.0 and 18.0 | **54 of 54** |
No Odoo rule's range now rests on an assertion. `float_compare`'s basis states a
measurement instead of a belief: the signature is byte-identical in all three
series, `float_compare(value1, value2, precision_digits=None,
precision_rounding=None)`.
Two things only the source could settle. `_check_recursion` is in every series and
`_has_cycle` only in 18.0; the rule already said so and said the result is
inverted, and 18.0's own deprecation warning β€” "one must use not _has_cycle()
instead" β€” confirms both halves. And `SOURCE_SYMBOLS` is completeness-checked
exactly as `SYMBOLS` is, because a rule with no entry is a rule the check silently
skipped.
A definition is not a test run either. It says the API existed and was spelled the
same way, not that it behaved identically.
### 3.30 The missed list becomes a worklist
3.25 measured recall against OCA and found 1 catch in 56 in-scope fixes, and
said the missed list was the more useful output: every entry is a bug somebody
found, fixed and pushed, which makes it a candidate rule with an incident behind
it rather than another one written from an opinion.
A flat list of 142 subjects is not a worklist, though, and that is what it was.
`nexa recall --harvest` clusters the misses by the construct each diff touched,
ranks the constructs by how many misses they carry, and marks the ones where the
corpus already catches something. A construct with forty misses against it and no
catches is where a rule is missing; the same forty scattered across everything is
noise, and only the clustering tells them apart.
**Measured against OpenSPP 2 (Odoo 19), 1,391 commits of history.** A second
ecosystem, and one built on a newer series than the OCA repositories:
| | |
|---|---|
| Fix commits examined | 368 |
| …touching a construct the corpus covers | 147 |
| Caught | 6 |
| Recall against in-scope fixes | 3% |
| Rules that caught anything | `sudo-bypasses-record-rules` (5), `stored-compute-needs-complete-depends` (1) |
Two repositories, two ecosystems, 2–3% both times. The floor is a floor.
The clustering is where the work is:
65x .sudo( (5 caught)
37x ValidationError (1 caught)
25x Many2one (1 caught)
21x api.depends (nothing caught here)
19x noupdate (1 caught)
15x api.constrains (nothing caught here)
13x ondelete (nothing caught here)
Twenty-one fixes touched `@api.depends` and the corpus caught none of them, while
carrying a rule about exactly that construct. Fifteen touched `@api.constrains`
against three rules about it. Those are not gaps in coverage -- the rules exist --
they are gaps between what the rules describe and what actually goes wrong, which
is a more useful thing to know and a harder one to guess.
**The word signal, and why it is filtered.** Recurring words in the missed
subjects are the cheapest topic signal available and the first run of it returned
`spp_api_v`, `semgrep`, `comments`, `review` -- the vocabulary of working on that
particular repository. Conventional-commit scopes are read out of the subjects and
excluded, along with a list of process words, which leaves `resolve` (12),
`sudo` (8), `missing` (5), `harden` (4). Still crude, and labelled as crude.
Read the diffs, not the subjects. A subject says what somebody meant to fix; the
diff says what was wrong, and that is what a rule has to describe.
### 3.31 The embedder's cut, measured instead of assumed
`check` builds a question from a file and hands the whole thing to retrieval,
which embeds it with nomic-embed-text. That model has a context limit and Ollama
does not complain when a query exceeds it; it truncates and returns a vector.
The claim that followed from that -- "the back of every long file is invisible to
retrieval" -- was a deduction, and this document had it as one for about an hour.
It is testable in seconds: append a distinctive marker and see whether the vector
moves.
prompt (7,931 chars) vs prompt + marker -> identical vectors
bisecting for where it stops mattering:
text appended before ~7,311 chars -> the vector moves
text appended after ~7,435 chars -> it does not
So the cut is real and it lands around 7,400 characters, which on the longest
subject is the last **6%** of the prompt -- not the back half, and not the back of
the file. The deduction was right in direction and wrong in size, which is the
usual shape of a deduction.
**What follows for the compact query.** `subject.retrieval_query` describes a file
by what it declares, what it imports and what the checkers already found in it,
instead of by 6,000 characters of source. `nexa eval --compare-retrieval` scores
both against the rules the checkers proved apply -- ground truth that costs
nothing and involves no opinion:
| | |
|---|---|
| whole prompt | 14 of 19 fired rules in the top 6 |
| compact query | 15 of 19 |
One rule, on one file, out of nineteen. That is a coin toss with a table around
it, and the defect it was meant to fix costs 6% of one prompt. **It is not wired
in.** The function stays as the thing the comparison compares, and it gets
adopted when a number says to rather than because it sounds tidier.
**What is wired in, and did not need measuring.** `retrieve()` takes `require`,
and `check` passes the rules its own checkers fired on. A rule proved to apply at
line 75 of this file does not need a cosine to earn a place in the grounding.
That is deduction of a kind that cannot come out wrong: the evidence is already
in hand, and the alternative is asking similarity to guess at something already
established.
### 3.32 The rewrite is load-bearing, which nobody expected
3.28 changed how `check` builds a question and left the question itself alone,
noting that "review this file" invites a rewrite and reliably gets one -- the
model reproducing the file under a new name instead of reviewing it. That read
as an obvious defect and the obvious fix was to ask for less.
**The obvious fix, measured.** A file with findings in it was asked to "show the
corrected code for the findings above -- only the lines you would change" instead.
Same five files, five runs each, the only difference the question:
| | review this file | show only what changes |
|---|---|---|
| Names of the file's own symbols mentioned | 90 | **65** |
| Rules cited beyond what the checkers found | 1 | **0** |
| Median answer length | 2,166 chars | 1,516 |
| WARN / REJECT | 18 / 7 | **9 / 16** |
| Layers that found something | semantic, structural, domain | + **knowledge 5x, syntactic 3x** |
Worse on every one, and the last row says why. Isolated changed lines are not
parseable code, so the syntactic layer fired three times and was right to; the
shorter answers also reached for rule ids they had not been shown, which the
knowledge layer caught five times. The rephrasing fought the output contract --
a fenced block that parses -- rather than the model.
**What it exposes.** The rewrite is not a bug in the prompt, it is the mechanism.
The checkers run over the ANSWER (3.28 moved a second pass onto the file, and
kept this one). An answer that reproduces the file is an answer those checkers
can find the file's own defects in, which is why `load_prices.py` -- reproduced
in full, 5 of 5 symbols named -- yields `mutable-default-argument`,
`path-join-does-not-contain-a-path` and `decimal-for-money-not-float` every run.
Asking for a diff removes the reproduction, and with it the layer doing the work.
So the question stays as it is, with a comment on it saying why, and the
low symbol counts on the Odoo files are a fact about the 7B rather than a prompt
waiting to be fixed.
**Second time this session.** The compact retrieval query (3.31) also sounded
obviously better and measured as a coin toss. Both were an hour's work, both were
reverted by a number, and both would have shipped on how sensible they read. That
is the argument for `nexa eval --check` existing at all: not to prove the tool
works, but to stop plausible changes getting in on plausibility.
### 3.33 Somebody else already decided, and a claim that was not true
Two things, and the second is a correction.
**Already decided.** 3.18 records that 22 of the 128 `sudo-bypasses-record-rules`
hits on OpenSPP sit on lines carrying `# nosemgrep: odoo-sudo-without-context`.
An independent analyser looked for the same construct on the same line and a
human read that finding and kept the code. Re-raising it as news on every run is
how a report gets skimmed, and a skimmed report is a report that misses the one
thing that mattered.
`layers.scan_source` reads those markers now -- `noqa`, `nosemgrep`,
`pylint: disable`, `type: ignore`, `eslint-disable`, `@ts-ignore` -- and sets a
flag on the hit. What each consumer does with it:
| | |
|---|---|
| `fieldtest` | counts them under ALREADY DECIDED, out of the per-kloc rate |
| `check` | lists them under their own heading, and does not fail a build on them |
| `check`'s grounding | leaves them out -- a settled question should not spend one of six slots |
| all three | never drop one: rule and line stay on screen |
The OpenSPP rate moved 2.2 to 2.1 per 1000 lines, which is what 22 hits leaving
a 229,600-line denominator looks like.
**There is deliberately no `# nexa: ignore`.** Recognising another tool's marker
means an independent analyser raised something and a person decided. A marker of
our own would mean somebody wanted THIS tool quiet, and it would be the first
mechanism in this repository capable of hiding a real finding. Everything else
here -- UNKNOWN as a verdict, declared gaps, INSUFFICIENT EVIDENCE, "PASS means
the checks that ran found nothing" -- is an argument against having one.
**The correction.** 3.28 said `layers.scan_source` was "the single answer to what
the checkers say about this source file, and both `check` and `fieldtest` route
through it". The first half was true. The second was not: `fieldtest` kept its own
copy of the loop -- compiled patterns, its own `run_check` call, its own line
arithmetic -- and nothing noticed, because both copies agreed on the samples
anybody ran.
They are one call now, which is what makes the suppression flag appear in both at
once instead of in whichever was remembered. That is the same argument 3.2 makes
about the corpus and the Modelfiles, and it took writing a second feature to
notice the drift had already happened.
### 3.34 Somebody else's ruleset, which is the only outside opinion available
Every measurement above tests whether what we wrote behaves. `crosscheck` fires the
checkers at the corpus's own correct samples, `adversarial` rewrites those samples,
`fieldtest` fires them at a stranger's tree, `recall` at a stranger's history. All
four ask the same question from different angles.
None of them can ask whether the corpus is the right set of rules to have written,
and that question cannot be answered from inside it: a rule nobody thought of is
invisible to every check that reads the rules. Coverage of the rules is not coverage
of the subject, and nothing here could tell the difference.
OpenSPP runs semgrep against a hand-written Odoo ruleset of its own, 44 rules in
`.semgrep/odoo-security.yml`. Somebody else decided what was worth checking, on the
same platform, without reading ours. `nexa crosswalk` compares the two.
**Semgrep is never run.** It is not installed here and an offline tool will not grow
a package index to answer one question. Instead each of their patterns is turned into
the smallest snippet it would match -- `$CR.execute($QUERY % ...)` becomes
`_cr.execute(_query % _h1)` -- and OUR checkers are run over that. Every pairing then
rests on a checker firing rather than on two rule descriptions sounding alike, which
is the failure mode any hand-written mapping between two rulesets would have.
| | | first run | now |
|---|---|---|---|
| Their rules read | 44 | | |
| Their patterns turned into a probe | 146 of 146 | | |
| Both corpora fire on the same construct | | **6** | **10** |
| Theirs fires, nothing here does | | 38 | 34 |
| Ours no pattern of theirs could match | 7 of 23 comparable | | |
The first six were `eval`, `exec`, `pickle`, `os.popen`, `shell=True` and domain
injection through `eval` -- every one of them reached by the two Python rules about
executing input, and none by an Odoo rule. The other four are the rules this run
produced, which is the difference between a measurement and a worklist: the number
moved because the gap it named got closed, not because anything was re-scored.
On everything else the two corpora barely intersect, and the honest reading of that
is not that either is wrong: theirs is about security and this one is about ORM
correctness. A gap on a subject a corpus deliberately does not cover looks exactly
like one it forgot, and the report says so rather than scoring it.
**The evidence that is not about rule text.** OpenSPP's source carries 687
`# nosemgrep:` markers. Each one is a finding that fired AND that a person read and
decided to keep, which makes it the only record available here of what the other
analyser does on real code. It is partial in a known direction: it records what was
dismissed and says nothing about what was fixed.
| | |
|---|---|
| Lines their analyser fired on | 631 (56 more markers name no rule) |
| …where a checker here also fires | **20** |
Twenty of 631. `odoo-sudo-without-context` fires on every `.sudo()`;
`odoo.sudo-bypasses-record-rules` fires on `.sudo().search(` and clears on a
`company_id` filter. Both decisions are defensible and they are different rules about
the same word, which is what most of the disagreement turns out to be.
**Two rules came out of it.** `odoo-commit-in-loop` and the SQL-injection family were
subjects with a real correctness failure behind them and nothing here that fired.
Both are now rules, and both are AST passes rather than patterns for the same reason
from opposite directions: a commit is the same string wherever it sits, and a
parameterised query carries the same percent sign as an interpolated one. A regex
cannot see either difference.
The commit rule found exactly one hit in 229,600 lines of OpenSPP, inside a loop
their analyser had flagged too: `# nosemgrep: odoo-commit-in-loop β€” batch consent
expiry cron`. Two analysers, one construct, one file, and a person who had already
decided. That is the strongest corroboration anything in this repository has.
They anchor differently, which the fieldtest reports rather than smooths over: semgrep
matches the whole loop and its marker sits on the `for`, this checker reports the
`commit()` ten lines inside it, and `scan_source` reads a marker only on the finding's
own line. So the hit counts as live here while a person has plainly already decided
about it. Widening the window would fix the count and build the thing 3.33 refused --
a marker silencing findings it was never written about.
### 3.35 Reading the harvest, and a column it was missing
3.30 turned the missed list into a worklist clustered by construct, and the ranking
said `21x api.depends (nothing caught here)` against a rule about exactly that. The
obvious reading is a missing rule, and the obvious reading was wrong.
Reading the diffs -- which 3.30 says to do and which nobody had -- of the 23 fix
commits whose diff touched `@api.depends`, most merely ADD a compute. The decorator is
in the diff because a new one was written, not because a depends bug was fixed, and
the scope-token denominator counts them anyway. Of the rest, the interesting case was
`spp_area_hdx/models/hdx_cod_source.py`: the checker fired correctly on the file
before the fix, and the fix went to a different line. Recall scored that a miss, and
the worklist then said "nothing caught here" about a rule that was firing.
**The column.** `measure` now records `spoke` -- rules with a finding in the file that
is not a catch -- and `--harvest` splits every cluster on it:
| | missed | caught | spoke | silent |
|---|---|---|---|---|
| `.sudo(` | 70 | 6 | 47 | 23 |
| `ValidationError` | 39 | 1 | 29 | 10 |
| `Many2one` | 26 | 1 | 14 | 12 |
| `api.depends` | 23 | 0 | 16 | **7** |
| `api.constrains` | 16 | 0 | 12 | 4 |
| `ondelete` | 13 | 0 | 10 | 3 |
| **total in scope** | **154** | 6 | **92** | **62** |
Ninety-two of the 154 misses are not blind spots. The corpus was looking straight at
those files and pointing somewhere else, and that wants the opposite repair from a
rule that does not exist: a sharper rule, not a new one. A flat miss count hides which
of the two you have, and it had been hiding it since 3.25.
**One rule came out of the diffs.** Two commits removed an `@api.depends` entry the
body never read -- a leftover from an earlier body in both cases. The harm is mild and
stated as mild: a dependency the body cannot use does not make the value stale, it
makes it recompute on writes that could not have changed it. The reason it is worth a
rule is the second commit, where `@api.depends("topic_ids")` sat on a compute that
counted rows on another model. The decorator was the only statement anywhere of what
the value was made from, and it said the dependency was accounted for while nothing
was watching the value at all.
The checker was narrowed three times against OpenSPP, and each narrowing is a class it
now deliberately misses: a body that hands a record to a call, a body that reads a
field the decorator does not declare, and a body that counts rows on another model.
The last one costs the second incident -- it is exactly that shape and is no longer
reported -- because once a compute reads another model, a declared dependency is the
author's best available approximation rather than a leftover, and telling them to
delete it removes the only recompute trigger the value has.
| | |
|---|---|
| Findings on OpenSPP before narrowing | 42 |
| …after | **11** (0.05 per 1000 lines) |
| True positives among them, classified by hand | **10 of 11** |
The one false positive is a compute whose docstring reads "Dummy compute to trigger
dependent field recomputation" -- a deliberate canary, depending on fields it does not
read on purpose. The checker cannot tell that from the two dead legacy computes it
correctly found in the same run, and there is no reason it should: both are findings
and only one of them is wanted.
### 3.36 Where the findings actually land
3.35 split the missed fixes into silent and spoke, and 92 of 154 were spoke: a
checker fired in the file and the fix went elsewhere. That reads as "the rules
are nearly right", and it was worth finding out whether they are.
`nexa recall --aim` places every finding against the METHOD the fix edited
rather than the file. `subject.units()` already splits a file into the things a
person would name, because `check` needs to say which methods it did not
review; the same split answers a different question here.
**Measured against OpenSPP, 160 in-scope fixes, 235 findings placed:**
| | | |
|---|---|---|
| on-target | 20 | 9% |
| same-symbol | 32 | 14% |
| other-symbol | **183** | **78%** |
Seventy-eight per cent of what the checkers say about a file somebody was
fixing is about a method nobody touched. That is the answer to whether the rules
are nearly right: mostly they are not near at all.
| rule | same | on | other |
|---|---|---|---|
| `sudo-bypasses-record-rules` | 10 | 13 | 18 |
| `ondelete-is-a-decision` | 7 | 1 | 34 |
| `stored-compute-needs-complete-depends` | 5 | 2 | 34 |
| `prefer-sql-constraint` | 2 | 0 | **47** |
`sudo-bypasses-record-rules` is the corpus working: 13 of its 41 placements are
on a line somebody changed. `prefer-sql-constraint` is the opposite and is the
clearest single result here -- 49 placements, 47 of them in methods nobody
edited, and not one on a fixed line.
**What that says about the design.** These rules describe constructs that
deserve thought, not defects. A Many2one without `ondelete` is worth a look and
is usually fine; a Python constraint that could be a SQL one is worth a look and
is usually fine. `fieldtest` at 2.1 per 1000 lines and recall at 4% are the same
fact seen from two sides: the corpus fires often and is rarely about the bug.
That is a defensible thing for a review assistant to be, and it is not what a
recall number measures. Both belong in the documentation rather than one of
them.
### 3.37 Narrowing the two worst-aimed rules, and what it did not fix
3.36 ranked the corpus by where its findings land. The two at the bottom were
`ondelete-is-a-decision` (1 on-target against 34 other) and
`prefer-sql-constraint` (0 against 47). Both were narrowed by reading their
findings on OpenSPP rather than by argument.
**ondelete.** The rule's complaint is that a nulled reference "yields False
rather than raising". An author who wrote `if not self.model_id: return` has
already answered it: the reference may dangle and nothing reads through it when
it does. Guarded dereferences are now excluded, at function level, which is the
resolution the dominant idiom actually uses -- a guard at the top of a method
covering the body.
**prefer-sql-constraint.** Two classes where the advice cannot be followed at
all. A search on ANOTHER model tests uniqueness across tables and no UNIQUE on
this one expresses it (that is
`check-constraint-cannot-cross-tables`). And a class already declaring
`models.Constraint("UNIQUE(code)", ...)` or the `_sql_constraints` equivalent
for that column has taken the advice; telling an author to do the thing they did
is how a checker gets switched off.
| | before | after |
|---|---|---|
| `ondelete` findings on OpenSPP | 101 | **20** |
| `prefer-sql-constraint` findings | 85 | 71 |
| Whole-corpus rate per 1000 lines | 2.1 | **1.7** |
**And what it did not do.** Re-placed with `--aim`:
| | before | after |
|---|---|---|
| on-target | 20 (9%) | **20** (10%) |
| same-symbol | 32 (14%) | 26 (12%) |
| other-symbol | 183 (**78%**) | 164 (**78%**) |
Every catch survived, which was the thing to protect: the narrowings cost no
recall at all. The share of findings landing in a method nobody edited did not
move, because 19 fewer noise findings out of 183 is not a change of kind.
Re-placed again on 2026-09-16, after Β§3.43: **19 / 25 / 147 β€” 10%, 13%, 77%**,
against 162 in-scope fixes and 191 placements. The same answer a third time. The
narrowings took another 17 findings out of `other-symbol` and left `on-target`
where it was, and `sudo-bypasses-record-rules` is placed identically to the run
above it at 10 / 13 / 18, which is what a rule nobody touched should look like.
Three measurements, one conclusion: this corpus is well aimed at a tenth of the
fixes it sees and looking elsewhere for three quarters of them, and no amount of
narrowing moves that β€” only a rule that does not exist yet will.
`prefer-sql-constraint` is the one to read. It went 0/2/47 to 0/1/42 -- it
barely moved, and it still has not once fired on a line anybody fixed. Its
findings are mostly CORRECT: those Python uniqueness checks do race. They are
simply never what the commit was about. That rule cannot be narrowed into a
defect detector because it is not describing a defect.
### 3.38 Advisory, a rule property with teeth
So it is labelled instead. `advisory: true` means the rule states a design
preference rather than a defect: following it is better, and not following it is
not a bug. (3.39 makes the label derived rather than authored β€” it was the
one-bit version of an axis that is now written down in full β€” but everything
below still holds, and the gating sites still ask this question.)
The label is not decoration. An advisory finding is **reported everywhere and
gates nothing**:
| | |
|---|---|
| `check` | its own heading, and out of the exit code |
| `fieldtest` | counted apart from the per-kloc rate, like a suppressed hit |
| the semantic layer | the finding is recorded, the verdict does not follow it |
Reported and not dropped, which is the part worth being deliberate about. The
reader still wants to see it: a Python uniqueness check really does race. What
changes is that nothing fails because of one.
whole-corpus rate 2.1 -> 1.7 (narrowing) -> 1.4 per 1000 lines
advisory 71, counted separately
The precedent is `decided`. A hit on a line carrying another analyser's
suppression marker is printed, excluded from the rate, and never dropped,
because a report that re-raises settled questions gets skimmed. A report that
fails a build over a preference gets switched off, and the real findings go with
it.
**Two grounds, each with its own bar.** One mechanism serving two reasons is
fine; one word meaning two things is not, so the field names both and the rule's
own `why` has to say which it is.
| ground | what it means | the bar |
|---|---|---|
| preference | a better way to write correct code | `recall --aim`: never once fires on a line somebody fixed |
| unproven | a real defect nothing measured here has met | zero findings in `fieldtest`, no incident in `recall` |
`prefer-sql-constraint` is the first: 0 on-target against 47, then 42.
`except-order-follows-the-hierarchy` is the second, and it is worth reading for
how it got here rather than for what it does. A tally over the 62 fixes the
corpus was silent on said nine of them added the name `AccessError` or
`MissingError`, which looked like the largest missing-rule cluster in the list.
The checker was written from that count and catches none of those nine: reading
the diffs afterwards, the before-state is one handler rather than two in the
wrong order.
try:
service.submit(cr)
except UserError as e:
raise HTTPException(status_code=409, ...)
`AccessError` is not mishandled there, it is unhandled -- swallowed by the base
class and reported as 409. Nothing shadows anything, and "caught the base class
without distinguishing a subclass" is not statically decidable, because `except
UserError` is correct in most code. The tally was a bad proxy for the diffs,
which is the same mistake `SCOPE_TOKENS` makes when it counts a commit in scope
for mentioning `ValidationError`.
The construct the checker *does* catch is a genuine defect -- a base class above
a subclass makes the lower handler dead code, and nothing warns -- so the rule is
kept and labelled for exactly what it is: correct, and standing on nothing this
repository has measured. Two of 43 rules carry the label.
### 3.39 Severity was doing two jobs
`severity` said how bad a thing is if it happens. It was also read as how likely
a finding is the bug, and those are independent.
`prefer-sql-constraint` is where the conflation stopped being survivable. A
duplicate reaching production is genuinely severe. Across 160 real fixes the rule
fired 49 times and not once on a line anybody changed. Written as one field,
either the impact is understated or the noise is overstated, and 3.38's advisory
flag was the one-bit patch over the gap.
Two fields now, and the flag falls out of the second one.
| field | question | source |
|---|---|---|
| `severity` | how bad if it happens | judgement, unchanged |
| `likelihood` | how likely this is the bug | `recall --aim`, measured |
The grades are read straight off 3.36's table rather than argued:
| rule | same | on | other | grade |
|---|---|---|---|---|
| `sudo-bypasses-record-rules` | 10 | 13 | 18 | `defect` |
| `ondelete-is-a-decision` | 7 | 1 | 34 | `construct` |
| `stored-compute-needs-complete-depends` | 5 | 2 | 34 | `construct` |
| `prefer-sql-constraint` | 2 | 0 | 47 | `preference` |
`construct` is the grade 3.36 described and had nowhere to put: a rule that
recognises the construct and not the defect, right file and wrong line.
`stored-compute` is the pair that needs both fields β€” critical impact, construct
aim β€” and no single value can say that.
**Absent means unmeasured, out loud.** Four rules have been placed by `--aim`.
The other thirty-nine grade `unmeasured`, which is a different claim from
"middling", and the build prints the split so the gap cannot be read as
confidence:
likelihood: 5 graded from `recall --aim`, 39 unmeasured
**`advisory` is derived.** It was set by hand in `ENRICH` while the evidence for
it lived in a comment two hundred lines away. It is computed in one place from
the two bottom grades, and `validate` fails if a hand-edited JSON disagrees β€” in
both directions, because a `defect` rule opting out of the gate is the same bug
as a preference opting into it. The gating sites are untouched: they ask
`advisory`, which is the question they have, and it now answers from a
measurement.
### 3.40 Encoding the trigger where it is checkable
Two rules are conditional on a fact no single file contains.
`ondelete`'s harm is a read through a reference that was nulled when the PARENT
was deleted. If nothing deletes the parent β€” if it is master data β€” there is no
dangling read to have. `check-company`'s harm is a record in company A
referencing company B's. A codebase running one company cannot exhibit it.
Both are answerable from the tree, and answering them is what turns a rule
describing a CONSTRUCT into one describing a DEFECT β€” for the subset where the
answer is available, and only there.
| trigger | what it does |
|---|---|
| present | the finding is a defect; it is promoted and says why |
| absent | there is no mechanism here; no finding is made |
| unknown | nothing is claimed, and the rule keeps the grade it had |
The third row is the one that matters. An assumed default would be a second
`severity` β€” a field carrying a judgement nobody made.
**What the evidence is.** For deletion: an `unlink()` call resolved to a model, an
`unlink` override, or an `ir.model.access.csv` row granting `perm_unlink` β€” which
is the half of the question reading Python can never answer, because a user
deleting through the UI writes no Python at all. For multi-company: `company_ids`,
`allowed_company_ids`, `env.companies`, an existing `check_company`, or a company
record rule in XML. A plain `company_id` is deliberately not on that list, because
nearly every Odoo model declares one and it separates nothing.
**Absent has two conditions, both strict.** The model must be one the tree
*defines* β€” a relation to `res.partner` reads unknown however much was walked,
because core Odoo's delete is not in this tree β€” and the walk must have been
complete. `nexa check` walks one module, which is bounded enough to run on every
review, and is marked incomplete: it can promote a finding and can never drop
one. A model declared in one module and deleted in another would otherwise vanish
from the report on the strength of not looking.
**Measured on OpenSPP.** 2,265 files read for the facts. 492 models declared, 403
of them deleted somewhere, 89 never β€” so `absent` is reachable. (The delete set
has 405 members: two are models this tree deletes without declaring, which is why
that number is not 492 minus 89.) Multi-company is in use: 14 Python files and 9
XML files carry the markers, and 12 files already use `check_company`.
| | before | after |
|---|---|---|
| `ondelete` findings | 20 | 20 |
| `check-company` findings | 12 | 12 |
| whole-corpus rate per 1000 lines | 1.4 | 1.4 |
**The counts did not move, and that is the result.** Every one of the 20
`ondelete` findings was classified by hand. Ten point at a model OpenSPP declares
and deletes β€” promoted to `defect`. The other ten point at `res.users`,
`ir.model.fields`, `res.groups`, `res.company`, `mail.template`, `product.product`
and `ir.model`: core models, every one of them, so the trigger reads unknown and
the rule keeps `construct`. Those are exactly the master-data cases the trigger
was written for, and it cannot suppress them, because the evidence that would
justify it is in Odoo's tree and not in this one.
So the trigger re-graded half the findings and silenced none of them here. The
suppression path is real and tested; this tree just does not exercise it. Saying
that is worth more than a narrowing number, because the next tree may.
### 3.41 Ranking the report by the prior
The cheapest of the three and it needed no new measurement. Findings printed in
line order tell the reader nothing about which rule is worth reading; the grades
from 3.39 were already sitting there.
So the grade leads, and line order survives inside it. `sudo-bypasses-record-rules`
is read before `prefer-sql-constraint` because history says which is more likely
to be the bug. `check` prints both axes as columns, and `fieldtest` prints the
tally per grade.
impact likelihood
critical defect line 96 odoo.check-company-on-relations
critical unmeasured line 214 odoo.constrains-must-list-every-field-read
high construct line 72 odoo.ondelete-is-a-decision
An unknown grade sorts last rather than raising. Ordering a report is not the
place to fail a run, and `validate` has already refused an unknown grade against
the schema enum.
---
### 3.42 A review of the tool itself, and the silence it was keeping
Everything above measures the CORPUS. This is the first pass over the code that
runs it, and the worst thing it found was not a wrong answer but an absent one.
**A syntax error anywhere in a file erased every AST finding.** Each semantic
checker opens with `tree, _ = parse_python(code)` and returns `[]` when that
fails. The error goes to `_`. Nine checkers then agree there is nothing to say,
`scan_source` returns an empty list, and `nexa check` prints *no checker findings
in the file* and exits 0.
a file with a real stale-compute bug 1 finding, exit 1
the same file, plus a trailing `def oops(:` 0 findings, exit 0
That is a false clean, which is the single failure mode this repository exists to
not have, and it fires exactly when people run `check`: on code they are still
editing. It also contradicts the contract every layer is held to β€” report what
was checked AND what could not be, with a reason β€” because here nothing was
reported at all.
`scan_source` now parses once, up front, and hands back a gap instead of silence:
the file does not parse at line 13 (invalid syntax), so 9 semantic
checker(s) never ran -- nothing they cover has been checked here
`check` prints it, refuses the word clean, and exits 1. `fieldtest` counts those
files apart from the ones it read, so their lines cannot sit in a denominator
nothing examined. `scan_source` already collected the checkers that RAISE, on the
argument that a swallowed crash looks exactly like a clean file. The case it had
no channel for was the checker that quietly declines, which looks exactly the
same and is far more common.
**A dotted `@api.depends` did not cover its own prefix.** `@api.depends('a.b')`
registers the whole chain β€” the ORM recomputes when `a` changes as well as when
`a.b` does β€” so a body reading `rec.a` needs nothing further. The comparison was
an exact string match, so it demanded the prefix be written out again and called
correct code stale. Straight from OpenSPP:
```python
@api.depends("requested_date", "definition_id.sla_days")
def _compute_sla_deadline(self):
for record in self:
if record.definition_id and record.definition_id.sla_days: # flagged
```
| `stored-compute-needs-complete-depends` | Hits | |
|---|---|---|
| OpenSPP, before | 104 | |
| OpenSPP, after | 62 | 42 removed, **40% of the rule's output** |
| OCA, before | 67 | |
| OCA, after | 61 | |
Not one true positive went with them, and no other rule moved by a single hit.
The converse still fires, and has to: a declared `line_ids` does NOT cover a read
of `line_ids.price`, which is the value that goes stale and the whole reason the
rule exists.
**Why `validate` could not see it.** The rule's own `correct` sample declared
`('line_ids', 'line_ids.price', 'line_ids.qty')`. The bare `line_ids` is
redundant β€” the two dotted paths already cover it β€” and it was the only reason
the self-test passed. The sample was written around the bug. It is narrowed now,
so the self-test exercises the thing it claims to.
This is the second time that pattern has cost something, and it generalises: a
rule's self-test proves the checker catches its OWN sample and nothing else. The
42 came from classifying real findings by hand, which is the only method that has
ever found one of these.
**A slash after a keyword was scanned as division.** `_REGEX_OK_AFTER` held
punctuation only, so in `return /[(]/.test(s)` the slash read as an operator and
the regex BODY was scanned as code β€” its unpaired bracket reported as an
unbalanced delimiter, at `high`, in a file that is fine. In `nexa run` that buys
an escalation to the 30B over nothing. It only bites when the class is
unbalanced, which is why `/^[^@]+@[^@]+$/` survived it: those brackets happen to
pair. `_prev_significant` now returns the trailing word rather than one
character, and fourteen keywords join the set.
**A decorator nothing can read statically is a gap, not a finding.**
`_decorator_args` returned `[]` both for "no such decorator" and for
`@api.constrains(*FIELDS)`, which the caller could not tell apart β€” so every
field the body read became a missing declaration, in a sentence that read
"declares only nothing". Three answers now: absent, unreadable, or the list. The
middle one goes to the gap channel. It found a real case on its first run,
`endpoint_route_handler.py` in OpenSPP.
**And the denominator was wrong.** `fieldtest` counted `body.count(NL) + 1`,
adding one line per file that is not in the file. Only a file ending in a
newline was affected, which is 1,301 of OpenSPP's 1,353 and 602 of OCA's 606 β€”
0.57% of the denominator, and every per-kloc rate ever quoted here was divided by
it. Counted as `wc -l` counts now.
| Re-measured 2026-09-15 | Hits | Per 1000 lines |
|---|---|---|
| OpenSPP 2 (Odoo 19), 1,353 files | 323 β†’ **281** | 1.4 β†’ **1.2** |
| OCA 18.0, 606 files | 135 β†’ **116** | 3.9 β†’ **3.4** |
The OpenSPP pair is a clean before/after on one checkout. The OCA pair is not
quite: those repos moved between the two runs, and only six hits of that drop are
the prefix fix. What makes the comparison worth printing anyway is that the file
count is identical at 606 and the corrected line count lands within sixteen lines
of the old one once its phantom line per file is taken back out.
Neither number is where this ends: Β§3.43 classifies the OCA hits and narrows four
checkers against what that found, taking OCA to 100 and OpenSPP to 269.
**Seven smaller ones**, none of which changed a published number: `telemetry.emit`
could raise through a docstring promising it never does, and inside `span()` the
resulting `TypeError` REPLACED the exception the caller was already handling; a
malformed `NEXA_EVIDENCE_KEEP` raised out of `import evidence` and took every
command with it; `nexa check <directory>` reported `[Errno 13] Permission denied`
at exit 1 where a bad argument is 2; `nvidia-smi` ran without a timeout, in the
one command people reach for when the GPU is wedged, and its output was unpacked
as though there were only ever one card; `nexa index` skipped `.github/` because
the filter was a substring test; comma-separated version bounds were combined
with OR, so `>=16.0,<=18.0` matched 25.0; and `retrieve` indexed a severity table
with `[]` over rows that come from SQLite rather than from the validated JSON.
**The banner printed twice** on `doctor` and `build`. Under `python nexa.py` the
name `nexa` is unclaimed, so `nexa_cmds` doing `import nexa` loaded a second copy
of the module β€” its own `COLOR`, its own `UNICODE`, its own `_BANNER_SHOWN` β€” and
each copy believed it was the first to print. `tests/test_cli.py` is named for
that invariant and could not see it, because under pytest the module is imported
once under its own name. Fixed by aliasing the two names to one module, which is
what the rest of the file already assumes.
The suite went from 531 tests to 546. Twelve of the fifteen fail against the
commit this started from; the other three assert the converse behaviour that had
to keep working, and passed all along.
---
### 3.43 Taking the worklist the classification produced
Β§3.42 re-measured. Section 6 classified all 116 OCA hits by hand and found 24 false
positives in seven classes. Four of those classes were a single predicate on
something already in the file, which makes them a worklist rather than a complaint.
| | Predicate | Removed |
|---|---|---|
| `TransientModel` | is the enclosing class a `TransientModel` | 7 |
| `related=` mirror | does the field declare `related=` | 4 |
| No stored column | `compute=` without `store=True` | 3 |
| Message-only read | is the field read only to build a message | 1 |
| Independent cursor | was this cursor opened in this function | 1 |
The first three are about what the database actually holds. A wizard's row is
vacuumed, so a stored value cannot go stale in it and a nulled reference cannot be
read from it much later β€” the harms `stored-compute-needs-complete-depends` and
`ondelete-is-a-decision` are named for. A `related=` field mirrors a value that
belongs to another record, so the constraint belongs on the source, which the same
run already flags in its own right; reporting both was reporting one decision twice.
A `compute=` without `store=True` produces no column at all, and `ondelete` and
`check_company` are instructions to a column.
The fourth is a gap in an exemption that already existed. `_message_only_attrs`
dropped a field read that happens inside a `raise`, on the grounds that phrasing an
error does not decide whether the invariant holds. It only knew the inline spelling:
```python
msg = self.env._("... %(journal)s ...", journal=journal.display_name)
raise ValidationError(msg)
```
builds the message one line earlier, and `display_name` was reported as a field the
constraint depends on.
The fifth is the one worth arguing about. `commit-inside-a-loop` has exactly two
instances in everything measured here, and they are the two sides of its own rule:
```python
self.env.cr.commit() # OpenSPP, in a batch expiry cron -- the caller's
# transaction, split. A true positive, and its author
# marked it `# nosemgrep` and kept it.
new_env.cr.commit() # OCA batch_unlink, on a cursor opened three lines
# earlier from Registry(...).cursor(). Not the
# caller's transaction at all.
```
The rule says *let the caller own the transaction*, and the second already does. The
exemption is bound positively β€” a name is only independent when this function is
watching it be opened β€” rather than by treating anything not spelled `self.env.cr`
as somebody else's. Scoped per function, too, so a `new_cr` opened in one method
cannot excuse a commit in another that reuses the name.
**What it did, on both trees:**
| | Hits | Per 1000 lines |
|---|---|---|
| OCA, before | 116 | 3.4 |
| OCA, after | **100** | **2.9** |
| OpenSPP, before | 281 | 1.2 |
| OpenSPP, after | **269** | 1.2 |
and on the classification that drove it:
| | Before | After |
|---|---|---|
| True positives | 79 | **79** |
| False positives | 24 | **8** |
| Undecidable from one file | 13 | 13 |
**Not one true positive went with them.** That is the whole test of a narrowing, and
it is checkable only because the classification is a file with a verdict on every row
rather than three totals in a document. `validate` still passes, every checker still
catches its own wrong sample, `crosscheck` is still 0 cross-hits and `adversarial` is
still 101/101 under mutation β€” but none of those four would have noticed a lost true
positive on real code, which is exactly the blindness Β§3.42 was written about.
The eight that remain are in section 6 with the reason each was left. Two of them are
deliberate: narrowing `sql-needs-query-parameters` to ignore an interpolated
identifier means deciding which holes in an f-string are table names, and getting
that wrong hides an injection. That is the one checker here where narrowing could
conceal the thing it exists to find.
---
### 3.44 The other twenty-five rules had never been fired at real code
Every measurement in this repository was `--stack odoo`. Both field tests, the
classification, both eval artifacts. The TypeScript, JavaScript and Python rules β€”
twenty-five of forty-four β€” had their own self-tests, `crosscheck` and
`adversarial` behind them and nothing else. That is exactly the state the Odoo
rules were in at 11.3 hits per thousand lines, before Β§3.18 fired them at somebody
else's source and took them to 2.9.
Pointing the same method at them found four defects in the measurement path before
it found anything about the rules.
**`fieldtest --stack javascript` answered "0 files" on any Odoo tree.** `SKIP_DIRS`
contained `static`, and in Odoo every line of first-party JavaScript a module ships
lives under `static/src` β€” 65 of OpenSPP's 69. The list was stack-independent and
what counts as "never the code under review" is not: `static` is assets when you
are reviewing Python and the whole subject when you are reviewing JavaScript. It is
stack-aware now, with a bundle guard so the vendored libraries under `static/lib`
are not read as though somebody here wrote them. The guard is ordinary JavaScript
convention rather than anything Odoo-shaped β€” the name says `min` or `bundle`, or
one line is longer than any line a person types:
| | longest line |
|---|---|
| OpenSPP's five vendored libraries | 32,071 / 95,621 / 198,502 / 375,248 |
| its 59 first-party files under `static/src` | 87 |
**A test named by its file was reviewed as shipped code.** `_is_test_path` reads
directory components, which is where Python and Odoo put tests. The JavaScript
convention puts them beside the thing they test, and 45 of the first 355 TypeScript
hits were in files like `src/hono.test.ts`. Anchored rather than substring, because
`latest_version.py` contains `test_`.
**Two matches on one line were two findings.** A pattern rule appended one
`SourceHit` per regex match and the evidence *is* the line, so a signature
declaring two `any` parameters reported itself twice, word for word β€” 34 of those
355. The semantic checkers have deduplicated since they were written; the pattern
path does now.
**`assert-is-not-validation` named the line above the assert.** Its pattern began
by consuming the preceding newline, and `match.start()` is what `scan_source` turns
into a line number. A lookbehind fixes it without changing what matches. It was the
only pattern in the corpus that began with a newline, and it is a critical rule.
**Then the rules.**
| Stack | Files | Lines | Hits | Per 1000 |
|---|---|---|---|---|
| JavaScript, Odoo frontend | 62 | 9,304 | 45 | 4.8 |
| TypeScript, hono and trpc | 781 | 73,762 | 277 | 3.8 |
| Python, llm-router and OpenSPP's scripts | 42 | 10,225 | 3 | 0.3 |
JavaScript started at 83 hits and 8.6 per thousand. One narrowing took it to 45: a
default that is *itself* falsy destroys nothing. A value `|| 0` is zero when the
value is zero, and a value `|| ""` is blank when it is blank, so there is no
legitimate value being replaced β€” which is the entire rule. That was 31 of the 69
hits on the dominant rule. All 45 are classified in
`classification-js-2026-09.json`: **21 true positives, 24 false.**
Twenty-one of those twenty-four are one judgement. `x || "label"` where the left
side is a URL, a colour, a mode or an error message: blank is the absence of
configuration there, not a value somebody stored. But the rule's own `why` names
"an intentionally blank note", so the harm is real in principle and not
mechanically separable from these. Left and written down, for the same reason the
SQL identifiers were in Β§6. One codebase does not settle it.
**TypeScript is measured and not yet classified, and the distinction is the
method.** Narrowing before classifying is the mistake this project exists to not
make. What the samples already show is that two of the three loudest rules fire on
the construct without testing the context their own text names:
- `unknown-not-any-at-boundaries` fires on every `: any`. The rule says "the body
of an incoming webhook, a request payload, a handler parameter"; the samples are
conditional types, a logger's rest parameter, a promise's reject callback.
- `validate-env-at-startup` fires on every `process.env` read. The rule says never
read it *deep inside* and wants it parsed once at startup β€” so a playwright
config reading `process.env.CI` is the rule being followed. 109 of its 115 hits
are in `examples/` demo apps.
- `types-are-erased-at-runtime` fires on a double cast and on a parsed response
asserted rather than checked. Precise, and those look like true positives.
That is `onchange-is-not-validation` firing on all sixteen `@api.onchange`
declarations again, in a different language, found the same way and at the same
stage. Classifying the 277 is what decides whether either rule can be narrowed or
needs a context a regex cannot ask for β€” and there is no JavaScript parser in the
standard library, which is the limit Β§6 has always named.
---
### 3.45 The corpus was advising a constraint Odoo 19 does not create
`--aim` said `prefer-sql-constraint` had never once landed on a line anybody
fixed: 47 placements, then 42, then 43, and zero on-target every time. Β§6 read
that as a rule describing a preference rather than a defect. Reading the misses
instead of the number found something else.
OpenSPP commit `208d9758`:
> *fix(change_request_v2): enforce reason-document uniqueness with
> models.Constraint* β€” the `(cr_type_id, reason)` rule was declared via the
> legacy `_sql_constraints` attribute, **which Odoo 19 ignores** β€” the registry
> only logs a warning and the constraint is never created, so duplicate rules
> could be saved silently since 19.0.3.0.0.
It shipped with a pre-migration to delete the duplicates that had accumulated,
and with a lint rule of their own, because *"Nothing in CI caught the legacy
attribute."* Their tree is 84 `models.Constraint` declarations against zero live
`_sql_constraints`; the ten remaining mentions are migrations, tests, and
comments reading "Odoo 19: Use models.Constraint".
**This corpus was advising the dead spelling.** `prefer-sql-constraint` declares
`odoo_versions: ">=16.0"` and its `correct` sample was:
```python
_sql_constraints = [
('code_uniq', 'UNIQUE(code)', 'Code must be unique.'),
]
```
`onchange-is-not-validation` β€” the rule the README opens with β€” carried the same
sample, and worse, a `satisfied_pattern` of `@api\.constrains|_sql_constraints`.
On 19 that cleared an onchange whose only "enforcement" was a declaration the
registry throws away. A rule whose whole subject is *a check that looks like it
holds and does not* was accepting one as the guard.
Both now advise `models.Constraint`, which arrived in Odoo 17, with the legacy
spelling kept as a comment naming the version it still works on. So do the two
finding messages, which were still telling the reader to put the UNIQUE entry in
`_sql_constraints`.
The `satisfied_pattern` was narrowed to `models\.Constraint` too, and that part
was **wrong and is reverted**. A `satisfied_pattern` is a plain regex with no way
to read the module's series, and `_sql_constraints` IS a real constraint on 16
through 18. The next field test said so immediately: OCA's `account_loan`, branch
18.0, a genuine `_sql_constraints` block, an onchange properly backed by it, and
a critical rule newly firing on it. The pattern accepts all three spellings
again, and the 19 hole is closed where the series is legible instead.
**And the checker was reading the dead declaration as satisfaction.**
`_columns_already_constrained` treated a `_sql_constraints` block as the column
being spoken for, which on 19 means exempting a class on the strength of nothing.
It now ignores the legacy form when the module pins 19 or later, which required
carrying `odoo_series` in the tree facts β€” `tree_facts` reads it from every
manifest in the walk and reports it only when they agree, because a tree holding
a 16 module beside a 19 module has no single answer.
| `@api.constrains` uniqueness check, beside… | series | fires |
|---|---|---|
| `_sql_constraints` | 16.0 | no β€” the constraint is real |
| `_sql_constraints` | 19.0 | **yes** β€” the constraint does not exist |
| `models.Constraint` | any | no |
| either | unknown | no |
Unknown keeps the old reading deliberately. Firing on every 16, 17 and 18 module
that did exactly the right thing is a worse trade than missing the 19 case, and
`check` already reports an absent manifest as a gap.
**What this does not do, and the first draft of this section said it did.** It
does not catch `208d9758`. That file has no `@api.constrains` at all β€” the author
went straight to `_sql_constraints` and the rule about preferring one has nothing
to say. Catching that incident needs a rule that does not exist yet: *the legacy
attribute is ignored from 19*. The fix here closes the narrower case, a Python
check standing beside a constraint that will not be created, and it is a genuine
one; it is not the one the commit was about.
It also changes nothing on OpenSPP, whose 71 findings are Python checks with no
SQL constraint beside them at all. That is the right outcome rather than a null
result: the tree already migrated.
### The cull, and what the evidence would not support
The plan had been to split `prefer-sql-constraint` β€” 43 of its 71 findings are
`search_count` uniqueness checks, which genuinely race, and 28 are a bound
against a literal, which does not. Instead the corpus was searched for rules to
remove outright.
**No two rules duplicate each other.** Across all five field tests β€” 226,937
lines of OpenSPP, 33,960 of OCA, 73,762 of TypeScript, 9,304 of JavaScript,
10,225 of Python β€” **not one pair of rules ever fired on the same line.** File
level co-occurrence tops out at 43% and is volume rather than overlap: the
highest-firing rule appears in most pairs. Textual similarity between any two
rule statements on the same stack tops out at 0.40, and the pairs that reach it
share a name prefix (`constrains-*`) rather than a subject. `crosscheck` already
proved no pattern fires on another rule's correct sample; field evidence now says
the same thing about code nobody wrote for this corpus.
**One rule went.** `javascript.validate-at-the-boundary-once`, on three grounds
together:
- Its checker cannot express its rule. The rule is *parse untrusted input at the
entry point*; the pattern is
`if\s*\(![\w.]+\)\s*return[\s\S]{0,200}?if\s*\(![\w.]+\)\s*return` β€” two guard
clauses within two hundred characters. Nothing in it can see "untrusted", which
is the whole rule.
- Its only field evidence is two hits, **both false**: `if (!this._registryRestricted) return;`
twice in one class, an internal boolean flag and an ordinary early return.
- It has no upstream source β€” one of three rules resting on experience alone β€”
and its subject is covered by two that do have one and that scored 6 true out
of 6 each: `json-parse-returns-any` and, on the TypeScript side,
`types-are-erased-at-runtime`.
**Nothing else met that bar, and the near misses are worth naming.** Sixteen
rules fired zero times, and for almost all of them that says where the tool was
pointed rather than anything about the rule:
`python.eval-and-pickle-execute-their-input` and
`subprocess-shell-true-is-injection` found nothing because llm-router does
neither; the three `typescript.odoo-*` rules found nothing because hono and trpc
are not Odoo clients. Removing those would be deleting good rules for being
aimed at code nobody measured.
`prefer-sql-constraint` stays, against its own numbers. It is the clearest
"useless" in the corpus by recall β€” 73 findings, zero catches, zero on-target
three times running β€” but 43 of those findings are Python uniqueness checks that
race, and a race nobody has hit yet produces no fix commit for `recall` to score
against. The zero is evidence that `--aim` cannot see this class, not that the
class is not there. It is advisory, so it is reported everywhere and gates
nothing, which is the right place for a finding whose harm is real and latent.
| | before | after |
|---|---|---|
| Rules | 44 | **43** |
| Machine-checkable | 41 (93%) | 40 (93%) |
| JavaScript findings, classified | 21 true / 24 false | **21 true / 22 false** |
### 3.46 Narrowing to Odoo, and composing the Python rules into it
**Decided 2026-09-16.** The corpus had four stacks. It has one, and the eight
plain-Python rules are inside it rather than beside it.
**Why narrow.** Β§3.44 fired the other twenty-five rules at real code for the first
time, and the answer decided this. JavaScript classified out at **21 true against 22
false**. TypeScript was measured at 3.8 per thousand lines and never classified, so
its number said nothing. Against those, the Odoo half carries 87 true / 15 false on
its two flagship rules across two ecosystems. The edge is Odoo ORM semantics;
everywhere else this corpus competes with mature tools and loses.
Removing them rather than labelling them experimental is the part worth arguing. A
rule that costs nothing at query time is not free: it is a claim the README has to
carry, a number somebody will quote, and a file `nexa check` will accept and then say
nothing useful about. A `.ts` file now raises `subject.Unreviewable` and is refused by
name, because **an empty finding list reads as a clean bill of health**, and silence
about a file nobody checked is the one lie this design exists to prevent.
**Why the Python rules were composed in instead.** Rules were selected by strict
equality on `stack` (`corpus.py`), so an Odoo module got the 19 ORM rules and none of
the 8 Python ones. Odoo modules are Python. `fieldtest --stack python` over OpenSPP
found **32 hits `--stack odoo` never reported**.
"ruff covers it" was the first answer and it does not hold. OpenSPP's own `.ruff.toml`
selects `B, C90, E501, I, UP` plus defaults β€” `S` and `DTZ` are off β€” so six of the
eight are genuinely unreported in that deployment, and a narrow selection is what most
Odoo shops run.
**What it cost, measured before shipping.** The composition was held to the bar in
Β§3.43: 50 new findings across OpenSPP and OCA, every one read in its own file.
**26 true, 24 false**, in `artifacts/classification-python-2026-09.json`.
| | | |
|---|---|---|
| OpenSPP live findings | 162 | **194** (0.71 β†’ 0.9 per kloc) |
| OCA live findings | 102 | **120** (3.0 β†’ 3.5 per kloc) |
| Corpus precision, composed-in half | β€” | 26 true / 24 false |
Twenty of the twenty-six are one rule, `naive-and-aware-datetimes-do-not-compare`, and
one fact: an Odoo `Datetime` is **naive UTC by contract**, so a naive local datetime
reaching one is off by the server's offset. Latent on a UTC-clocked server, wrong on
any other, and OpenSPP's history carries two fixes of exactly it.
Three rules fired and were never right, and the most interesting is
`decimal-for-money-not-float`: Odoo's `Monetary` field **is** a float, and the
framework's answer to precision is `float_round`/`float_compare`, not `Decimal`. The
rule fires on the idiom and names a fix that cannot be taken. Three findings do not
settle a checker either way, so it is recorded rather than removed.
**Two defects the classification found that no test would have.** `scan_source` stripped
`#` comments before matching and not string literals, so `eval()` named in a docstring
read as code. And suppression read any marker within a simple statement without
asking which rule it named, so a `# nosemgrep: odoo-sudo-without-context` silenced a
datetime finding four lines above it β€” the mute button of Β§3.42 reached from the other
side. Both were named in the README first, and both are closed in Β§3.47.
**What the id prefix means now.** `python.decimal-for-money-not-float` carries
`stack: odoo`. The prefix is **provenance** β€” where the rule came from and which
documentation `check_sources` verifies it against β€” and the stack is **selection**. The
validator's old invariant was "id prefix equals stack"; it is now "id prefix is one of
`corpus.ORIGINS`". Keeping the ids is what keeps every committed classification,
`LIKELIHOOD` grade and evidence bundle pointing at the same rule: renaming the subject
of a measurement is the quiet way a number stops meaning what it says.
**One rule had to be reworded.** Retrieval is scoped to a stack, so merging them put
27 rules in competition where 19 had been. `prefer-sql-constraint` ranked first for
*"stop anyone entering a negative quantity on a sale order line"* against 19 and
stopped being retrieved at all against 27 β€” that probe is the generic validation
question every constraint rule here answers. Reworded to the one only it answers.
**27 of 27 retrievable, 25 in the top 3.**
### 3.47 Reading the value instead of the name, and the rest of the sudo sentence
**Decided 2026-09-21.** Six false-positive causes the README had named were closed in
one pass, and the pass was ordered by cause rather than by rule, because the six had
three causes between them.
**The shared cause.** `float-comparison-needs-float-utils` (2 true, 5 false),
`path-join-does-not-contain-a-path` (0 of 4) and `decimal-for-money-not-float` (0 of 3)
each matched a variable's *name* and treated that as evidence about its *value*. All
five float falses compared an integer to zero β€” `search_count`, a `read_group`
`<field>_count`, `cr.fetchone()[0]` of a `SELECT COUNT(*)`, a `fields.Integer` β€” and
three of the five plus both true positives were spelled `total == 0`. No name could ever
have separated them. The assignment can.
So `semantics.py` grew a local value trace: which names a scope binds, what each was
assigned, whether an expression is certainly a whole number, whether its root came from
outside the file. One file, one function, no imports followed, no types inferred, and
the limit is written into the limitations section rather than discovered later. Three
regexes became AST passes on top of it. Every answer the trace gives that *suppresses* a
finding must be certain β€” a name assigned twice is an integer only if both assignments
are β€” because a suppressing guess is the direction that hides.
**The window that crossed a function.** `onchange-is-not-validation` was 4 true, 3
false, and the README's reading of the three ("the write is to a Many2one") was wrong.
Each is a four-line onchange that assigns a field, followed by an action method that
raises. The regex read `[\s\S]{0,400}` past the decorator; `_onchange_variable_id` is
200 characters long and the `UserError` it matched belongs to `action_remap`. A regex
has no way not to do that. Scoped to the decorated function by an AST pass, the three
are silent, and three new findings surfaced and were read: a phone-number validation
living only in an onchange with no constraint anywhere in the file; a
`_check_date_collected` onchange raising `exceptions.ValidationError`, a dotted spelling
the regex's `\w*` never matched; and one that clears a field and returns the warning
dict. The finding anchors on the decorator, because that is where the rule is and where
a marker about it gets written β€” a `FunctionDef`'s own `lineno` is the `def`, one line
down, and reporting there lost a suppression a test already relied on.
**Two infrastructure defects, fixed once.** Docstrings are now emptied before any
pattern sees the file β€” docstrings only, since `_SQL_COLUMNS` reads triple-quoted SQL
on purpose β€” with quotes, newlines and length preserved so no reported line or column
moves. And a suppression marker that *names* a code with words in it
(`odoo-sudo-without-context`) now covers only rules sharing one of those words, vendor
and language words excepted; a code with no words (`B608`, `E501`) or prose after the
colon cannot be compared with anything and covers its line exactly as before. The
direction of error is stated in `_marker_is_about`: a code the function cannot relate
to a rule leaves the finding reported.
Fixing that surfaced a third defect nothing had reached before. Every statement span
borrows the comment line above it, so consecutive statements overlap by one line, and a
finding on the first line of one statement is also inside the borrowed line of the next.
Two spans of equal width tied, and the winner was whichever `ast.walk` reached first.
On `spp_api_v2/wizards/show_secret_wizard.py` the `return` on line 38 won the marker on
line 36 away from the write on 37. A span whose statement *covers* the line now beats
one that only borrows it.
**The rest of the sudo sentence.** The rule says three things β€” scope `sudo()` to the
narrowest operation, re-apply company filtering by hand, never pass a sudo recordset
onward β€” and the checker tested only `.sudo().search()`. Β§3.30's harvest put `.sudo(` at
70 missed against 6 caught, 47 of the 70 in files where a checker fired elsewhere; the
README's own named miss, *"remove sudo() when importing record"*, is a `create`. Counted
on the OpenSPP tree, the query clause has 116 call sites and the unchecked one 136.
`sudo_search_drops_a_real_rule` became `sudo_drops_record_rules` and checks all three:
queries, touches (`create`, `write`, `unlink`, `copy`, `read`), and a sudo recordset
bound to a name and then returned or passed as an argument β€” the bare name only, since
telling `return p.filtered(...)` from `return p.mapped('name')` means knowing which
recordset methods return recordsets, and a new clause on a critical rule should
under-report.
A query decides *which* records exist for you and a touch acts on ones you already hold.
That line is where the measurement fell, so it is where the code divides.
The record-rule trigger of Β§3.40 gates every clause, and it needed one more thing to do
so: `Plan.sudo().create(...)` names no model, so the trigger read UNKNOWN and could not
suppress. The receiver is now followed through its assignment β€” `Plan =
self.env["spp.case.plan"]` a few lines up β€” and 12 of the 15 unresolved receivers in the
file that produced them resolve. `check_compliance` also runs semantic checkers now; it
ran patterns only, so every rule that moved to an AST pass had silently left the
compliance check of generated answers, including the onchange rule that function's own
docstring gives as its reason for existing.
**The `company_id` exemption became the rule's own fields.** The old test for "this call
already re-applied the scoping" was the literal word `company_id`, from the rule's
correct sample, and Β§3.40 recorded the cost: 14 of 20 surviving findings were scoped by
a group, a partner or an area and read as unscoped. It called a narrowing a worklist
entry because "deciding in general that a domain is already scoped means evaluating it,
and a narrowing that guesses would cost the 6 true positives this keeps."
There is a version that does not guess. `context` already parses every `ir.rule` in the
tree to decide whether one filters; it now also records **which fields each domain is
written about**, and the checker compares the call against those instead of against one
hardcoded word. Not one OpenSPP case model has a company rule at all β€” they are governed
by `[('case_worker_id', '=', user.id)]` and `[('case_id.case_worker_id', '=', user.id)]`
β€” so a create naming `case_worker_id` or `case_id` lands inside the caller's own
visibility, and that is the rule's advice followed in the only spelling that model has.
`company_id` stays as the fallback where the rules could not be read, so nothing exempt
before stops being exempt now.
Two details cost a true positive each before they were right. The comparison reads the
call's **arguments**, not the unparsed call: the receiver carries the model name, and
`spp.group.membership` contains the word `group`, which is one of that model's own rule
fields β€” it exempted an unscoped sweep of every membership in the database, classified
true by hand. And an argument that is a bare name is followed to what it was assigned,
because `create(vals)` otherwise hides every field it sets behind four letters. All six
true positives survive, checked explicitly.
**What the re-run says**, against the artifacts of Β§3.43 and Β§3.46:
| | OpenSPP before | after | OCA before | after |
|---|---|---|---|---|
| `float-comparison` | 7 | **2** | 1 | **3** |
| `onchange-is-not-validation` | 7 | 7, all three falses replaced | β€” | β€” |
| `decimal-for-money` | 2 | **0** | 1 | **0** |
| `path-join` | 1 | **0** | 3 | **0** |
| `eval-and-pickle` (docstring) | 1 | **0** | β€” | β€” |
| `datetimes` wrongly suppressed | 1 | **0** | β€” | β€” |
| `sudo` deciding / advisory / decided | 2 / 0 / 62 | **2 / 11 / 117** | 7 / 0 / 0 | **6 / 10 / 0** |
| Rate per thousand lines | 0.9 | **0.8** | 3.5 | **3.4** |
The OCA float number went *up*, and that is the old `satisfied_pattern` clearing a
whole file on a `float_compare` anywhere in it. The three are `Monetary` and `Float`
fields compared with `!= 0` in files that call `float_compare` somewhere else, each read.
**What the classification says, and what was done about it.** All 23 live sudo findings
were read by hand against their own files, in `artifacts/classification-sudo-2026-09.json`.
**0 true, 23 false.** The touch and escape clauses have not met a real one in 260,897
lines. Every finding they made is code where the caller is an administrator by
construction β€” a demo-data seeder, an accounting wizard behind
`group_account_manager` β€” or a framework model with no tenant boundary: an
`ir.actions.act_window`, an `ir.sequence.date_range`, a postgres tuning row.
So the two new clauses emit `unproven` **per finding**. That grade already exists for
exactly this position β€” "a real defect nothing measured here has met yet, so it is
reported and not trusted to stop a build" β€” and `scan_source` now derives a hit's
`advisory` flag from the hit's own grade as well as its rule's, which is the same
derivation the corpus builder already does one level up. The query clause keeps the
rule's `defect` and its 6 true positives, because a grade travels with the finding.
Not deleted, and not by path. Skipping `*_demo` modules would be a heuristic invented to
make a number smaller; grading a clause on what it was measured at is the thing this
project does everywhere else. `touches-a-record-already-held` is 7 of the 23 and
`scoped-to-a-record-already-held` an eighth of the same shape β€” a recordset the caller
obtained under its own rules, then written under sudo. That is not nothing, because
`ir.rule` governs write permission as well as visibility, but it is a different claim
from the one the rule's `why` paragraph makes. Whether it is a second rule or a
narrowing wants a tree where it fires on production code, and neither of these two is
that tree.
### 3.48 A fourth sudo clause, taken from the missed list
**Decided 2026-09-21.** The harvest is supposed to be a worklist, so it was used as one.
`--harvest` over the OCA trees puts `.sudo(` at the top again, and one row in it is the
miss the README had been naming by hand since Β§3.30: server-tools `cbefaa511485`,
*"[FIX] excel_import_export: remove sudo() when importing record"*. The diff deletes one
line.
```python
self = self.sudo()
```
Nothing below it mentions sudo. Every clause in Β§3.47 looks for `.sudo().<method>()` or
a sudo recordset handed to something, and this is neither: it **replaces the receiver
the whole method is written against**, so each subsequent `self.env[...]` inherits the
elevated environment while naming nothing. The commit says what it cost β€” *"finding
multiple records across all companies during import when they have same record name.
Previously, importing with sudo could bypass multi-company record rules"* β€” which is the
rule's own `why` paragraph, written by somebody who had just been bitten by it.
Three sites in 260,000 lines, so the clause is narrow as well as real. Run against that
commit's own before and after images the finding sits on line 283, is gone afterwards,
and 283 is one of the two lines the fix touched: an on-target catch by recall's own
definition, which is why this clause carries no grade of its own and keeps the rule's
`defect` where the touch clause takes `unproven`.
The same commit **adds** `ModelData = self.sudo().env["ir.model.data"]`, a deliberate
and narrowly scoped elevation. That is the evidence for not also flagging `.sudo().env[`,
and it is worth stating: the author of the fix reached for the shape one clause would
have reported, in the commit that fixed the thing another clause did report.
### 3.49 What the re-measurement says, and the grades that came out of it
**Measured 2026-09-21**, over 795 fix commits across both ecosystems.
| | OpenSPP before | after | OCA before | after |
|---|---|---|---|---|
| Fix commits examined | 406 | 409 | 385 | 386 |
| …touching a covered construct | 160 | **173** | 56 | **64** |
| Caught | 6 | **11** | 1 | **4** |
| Recall against in-scope fixes | 4% | **6%** | 2% | **6%** |
Both ecosystems land on the same number from different directions, which is worth more
than either figure alone. The in-scope denominators rose too, so the improvement is not
an artefact of measuring less.
`.sudo(` remains the largest cluster on OpenSPP but a different shape: **67 missed, 9
caught, 52 spoke, 15 silent**, against 70/6/47/23 before. The silent column is what the
new clauses ate. `noupdate` β€” 22 missed, 0 caught, 9 spoke, 13 silent β€” is now the
cluster with the most silence against the least coverage, and is next.
**Four rules earned a grade.** `recall --aim` placed every finding a checker made in a
file a fix touched against the method the fix edited, and the placements decide:
| rule | on-target | same-symbol | other | caught | grade |
|---|---|---|---|---|---|
| `sql-needs-query-parameters` | 3 | 5 | 12 | 1 | **defect** |
| `naive-and-aware-datetimes-do-not-compare` | 2 | 5 | 16 | 2 | **defect** |
| `check-company-on-relations` | 1 | 3 | 10 | 1 | **construct** |
| `constrains-must-list-every-field-read` | 1 | 2 | 22 | 0 | **construct** |
The two `construct` grades are the same reading `ondelete` and `stored-compute` already
have: the rule recognises the declaration and is about the method rather than the line
anybody fixes.
### 3.50 A third ecosystem, and the two things it changed
**Measured 2026-09-21.** Every recall number this project had came from two trees, and
the README's own caveat was that two repositories in one ecosystem is a narrow sample.
So the pass was run again against **odoo core 18.0** β€” 559,663 lines, 190,217 commits,
4,368 `.sudo(` call sites β€” with the recall half capped at 400 fix commits.
| | OpenSPP | OCA | odoo core | all three |
|---|---|---|---|---|
| Fix commits examined | 409 | 386 | 369 | **1,164** |
| …touching a covered construct | 173 | 64 | 97 | **334** |
| Caught | 11 | 4 | 5 | **20** |
| Recall | 6% | 6% | 5% | **6%** |
| Live findings per 1000 lines | 0.8 | 3.5 | **5.0** | β€” |
Three ecosystems landing on the same recall figure from different directions is the
first evidence this project has that ~6% is a property of the corpus rather than of a
sample. The precision rate on core is the highest of the three and is **not classified**;
core is the reference implementation, so a rate there is a statement about the rules
rather than about somebody's house style, and reading those 2,784 findings is the
largest single piece of work this repository now has outstanding.
**It caught a checker defect that two trees could not.** `self = self.sudo(False)`
turns record rules back **on**. It is how a method that must run under the caller's own
rights says so, and odoo core does it 60 times β€” including mail's
`_check_attachments_access`, whose docstring reads *"This method relies on access
rules/rights and therefore it should not be called from a sudo env."* Every sudo clause
read `.sudo` and ignored its argument, so the checker reported the safeguard as the
defect. Neither OpenSPP nor OCA contains a single `sudo(False)`, so no amount of work on
those two trees would have found it. `_escalates()` now reads the argument, and a
non-literal argument still counts as an escalation because `sudo(flag)` can be either.
**It stopped a grading mistake.** Measured on OpenSPP and OCA alone, eleven rules had
fired nowhere at all, and `unproven` β€” "a real defect nothing measured here has met yet"
β€” is literally accurate for a rule like that. Four of the eleven fire on odoo core:
`bare-except-catches-the-exit` 28 times, `mutable-default-argument` 15,
`eval-and-pickle-execute-their-input` 4 and `subprocess-shell-true-is-injection` 4.
Grading off two trees would have quietly downgraded four working rules on the strength
of not having looked far enough.
The seven that fire nowhere across all three β€” 820,000 lines and 1,164 fix commits β€”
are graded `unproven` on that evidence, which is more than the one rule already carrying
the grade had. Two of the seven, `decimal-for-money-not-float` and `path-join-does-not-
contain-a-path`, are silent because Β§3.47 narrowed them rather than because nothing was
there; both readings are in the README and the grade records the second.
Sixteen rules are now graded and eleven are `unmeasured`, which remains the accurate
word for them rather than a placeholder.
**One thing found, measured, and then not acted on.** Odoo core documents intentional
elevation with its own comment convention β€” `# sudo: discuss.channel - guests don't
have access for creating mail.message` β€” and uses it 136 times. The first reading was
that this is the same artefact as a `# nosemgrep` and that recognising it would
materially improve the rate on core.
The second half of that is simply false, and counting says so: of the **538** sudo
findings on core, **6** carry a `# sudo:` note within three lines. One percent. The 136
notes sit overwhelmingly on elevations no clause here fires on β€” the record-rule trigger
has already dropped those, which is the trigger working. So the practical case for
reading the convention evaporates, and what is left is only the argument of principle.
That argument is worth writing down anyway, because it is the one that decides whether
any future marker gets added. A `# nosemgrep` records that *an analyser made a specific
claim and a human rejected that claim*. A `# sudo:` records that *the author, while
writing the line, noted why they wanted it*. Those are different kinds of evidence: the
first is a response to a challenge, the second is the intention of the person who chose
the code. This corpus exists for defects that **look correct in review** β€” the headline
measurement is that 17% of OpenSPP's fixes were silent until the data was visibly wrong
β€” so an author's stated intention is close to the thing the tool is meant to second-guess,
not evidence against the finding. Treating it as a suppression would also be the
nexa-specific mute button Β§3.42 refuses to build, reached through a third party's
comment style.
The convention stays unread, and the reason is the principle rather than the number.
There is a general rule in that, and it is worth stating because this file is otherwise
an argument for measuring everything. A question that turns on **interpretation** does
not become settled by being measured. The 1% above is a fact about two checkouts on one
day; the thing actually in dispute is what a comment is evidence *of*, and no rerun
answers that. Pinning a number to it would produce a reference that drifts on every tree
and every rerun while the disagreement underneath it stays exactly where it was. Measure
to check a claim already made β€” that is how the 1% corrected an overclaim in this very
section β€” but do not manufacture a measurement in place of a decision.
### 3.51 Reading odoo core, and the first classification that is a sample
**Measured 2026-09-21.** Β§3.50 left core's findings unclassified and called reading them
the largest outstanding piece of work. Reading all 2,784 is not a session's work, and
pretending otherwise would have produced a worse artifact than admitting it.
So this is the first classification here that is a **sample rather than a census**: 15
findings of the largest rule β€” `stored-compute-needs-complete-depends`, 1,051 of the
2,784 β€” drawn with a fixed seed and read by hand. The draw is reproducible from the
artifact's own description, without the script that made it. **11 true, 4 false**, which
is 73% with a 95% Wilson interval of roughly 48% to 89%. The interval is wide and it is
the honest number for n=15: it says the rule is not mostly noise on core and it does not
say much more.
**All four false verdicts have one cause.** `odoo/models.py` adds `display_name` to
every model as `fields.Char(automatic=True, compute='_compute_display_name',
search=...)` with **no store**. An override that omits a dependency therefore cannot
leave a stale row, because there is no row. The checker read "no declaration in this
file" as unknown storedness and kept the finding, which is the right reading for an
ordinary field declared in another module and the wrong one here: the framework's own
declaration is knowable and says not stored. 121 of the rule's 1,051 findings on core
were this shape.
The narrowing is free, and that was checked rather than assumed. Ten hand-verified true
positives compute `display_name` β€” every one of them in OpenSPP β€” and every one declares
`store=True` on it **in its own file**, so in-file storedness reads True and they are
untouched. Re-run: 244 of 244 hand-classified true positives across all five artifacts
still found, none lost.
**The bigger thing the sample found, and did not act on.** 837 of odoo core's 1,360
stored computes are `readonly=False`: the "compute a smart default, then let the user own
it" pattern, where a deliberately narrow `@api.depends` protects a user's edit from being
clobbered. That is a plausible reading of most of this rule's volume on core, and it is
not a safe narrowing. Three of the 47 resolvable hand-verified true positives on OCA and
OpenSPP are themselves `readonly=False`, and nine of the eleven true verdicts in this
very sample are too. The pattern is a consideration inside each verdict, not a rule, and
the number that would have justified treating it as one is the number that refutes it.
**What is still unread.** 1,612 live findings on core. The next three by volume are read
in Β§3.52.
### 3.52 The other three rules on core, and what precision turned out to depend on
**Measured 2026-09-21.** Same method as Β§3.51 and the same seed: 15 findings each of
`sudo-bypasses-record-rules`, `assert-is-not-validation` and `ondelete-is-a-decision`,
drawn and read by hand. With Β§3.51 that is 60 of core's findings across the four rules
making 70% of them, in `artifacts/classification-odoo-core-2026-09.json`.
| rule | core | previously | 95% Wilson |
|---|---|---|---|
| `ondelete-is-a-decision` | **13/15** | 12/12 OCA, 14/14 OpenSPP | 60–95% |
| `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA | 48–89% |
| `assert-is-not-validation` | **3/15** | 6/10 OCA | 7–45% |
| `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP | 0–20% |
Corrected 2026-09-27: the 49/57 in the `stored-compute-needs-complete-depends` row is
OpenSPP's reading, not OCA's (`artifacts/classification-openspp-2026-09.json`: 49 true, 8 false,
3 undecidable). OCA's was 41 true of 41 decided when this was written, and 42 of 42 since
Β§3.71.
**The finding is that precision depends on what kind of code is under review, and this
is the first tree here that made that legible.** Two rules hold up on core. Two collapse,
and both collapse for the same reason rather than because the checkers are broken.
`assert-is-not-validation`'s own true class, set when it was classified on OCA, is *the
asserted value came from outside the module*. A framework's asserts are overwhelmingly
invariants between its own components β€” the registry asserting its cache-name
convention, a field descriptor asserting its own configuration, the query builder
asserting an alias it generated itself. There is no outside. Twelve of the fifteen are
that shape.
`sudo-bypasses-record-rules` is 0 of 15 and every one is a deliberate framework
elevation: a public route resolving an access token before any identity exists, an
`@api.ondelete` guard that has to see records the caller cannot or the guard is
bypassable, a cross-company sync whose entire purpose is to cross companies. The rule
describes an application leaking between tenants. Core is the thing that implements
tenancy.
Neither is a reason to change a grade. A precision number is about a subject, and the
subject here is one this corpus was never aimed at β€” nobody reviews odoo core with it.
It is a reason to say so in the README, which is done, and it is the strongest argument
yet that a single precision figure for "the corpus" means less than the same figure per
kind of codebase.
**Three checker defects, all found by reading rather than by testing.**
`_auto = False` marks a model odoo builds no table for: a SQL view. There is no foreign
key, so `ondelete` is not a decision anybody can make about it. Two of the fifteen
ondelete findings were report models of exactly that kind, and the exemption joins
TransientModel in `_transient_spans`. None of the 26 hand-verified true positives for
this rule sits on such a model, so it is free.
The other two are both `sudo` de-escalation the checker read as escalation, the same
class as the `sudo(False)` defect in Β§3.50 and missed by the same reading. Odoo's
signature is `sudo(self, flag=True)`, so **`sudo(flag=False)`** is de-escalation by
keyword and `_escalates` was only reading positional arguments. And **`x.sudo(self.env.su)`**
sets su to whatever the environment already has, so it cannot raise privilege above
ambient; core writes it beside the comment *"Unsudo the invoice after creation if not
already sudoed"*. `sudo(self.env.su or field.compute_sudo)` is deliberately not exempt,
because the `or` can turn it on.
Rates after all three: **4.7 per 1000 lines** on core, 0.8 on OpenSPP, 3.3 on OCA, with
244 of 244 hand-classified true positives still found.
**One finding worth acting on outside this project.** Core's
`ir.model.access.group_names_with_access` interpolates its `access_mode` argument
straight into SQL as `perm_{access_mode}`, and the only thing constraining it to four
literals is an `assert`. The method is public and RPC-callable. Odoo does not run under
`python -O`, so this is latent rather than live, and the assert also turns a bad
argument into a 500 rather than a clean error β€” but it is exactly the harm the rule
describes, found in the reference implementation.
**Still unread on core:** roughly 1,590 findings, led by `naive-and-aware-datetimes`
(167), `constrains-must-list-every-field-read` (151) and
`depends-must-not-name-unread-fields` (134).
### 3.53 The documents are checked against the artifacts, not only the other way round
**Measured 2026-09-26.** Every number in the sections above has an artifact behind it,
and none of the sentences quoting them had anything checking that they still agreed. A
rate was re-measured into a new paragraph while an older sentence kept stating the old
one in the present tense. By this date the README gave OCA's rate as 3.0 in its
introduction and 3.3 in its summary table, put "those 120 hits" beside a rate taken over
112, and gave recall as 1 of 56 and 7 of 162 in the table while the section below it said
20 of 334. Β§6 still opened on 2.9. No measurement was wrong. The prose had drifted from
all of them, and in more than one direction at once.
`bin/figures.py` recomputes every present-tense figure from the committed artifacts, and
`--check` fails on any figure in README.md or ARCHITECTURE.md that disagrees. A figure is
marked where it stands, `<!--fig:oca.rate-->3.0<!--/fig-->`, which renders as nothing.
Dated sentences are not marked: "re-measured on 2026-09-16: 3.5" is a record of a past
run and stays true after the next one, and a check that demanded it change would be
demanding that history be rewritten. It runs in CI as its own job, needing no checkout
and no model.
Three things it needed that did not exist:
- **The fieldtest artifacts were ten days stale** and carried no commit. Both were
written before the checker changes of Β§3.47 to Β§3.52, and core had no artifact at all,
so the 4.7 in Β§3.52 was the one headline rate with nothing behind it. All three are
regenerated and now record the commit of each checkout and the time they were made.
- **Recall had no artifact.** `recall --json` wrote bare rows with no head and no
`--limit`, and nothing was committed. It now writes both beside the rows, and one file
per ecosystem is committed.
- **"244 of 244 still found" was asserted by hand** each time, by re-running against the
checkouts. It is now computed from the artifacts: every `true` verdict in every
classification is matched to a finding the committed fieldtest still reports. Across
six classifications that is **271 of 271**. The 244 was five artifacts and predated
core's sample.
**Four of the 271 moved, and the window that allows it is printed rather than
absorbed.** All four are `sudo` findings in OpenSPP read at the line of the `.sudo()`
call; the AST pass now anchors them on the first line of the chained statement, one line
up. A finding re-anchored within three lines is counted as found and listed as moved, so
a window hiding a real loss would be visible in the output rather than in nobody's
reading of it.
**It also checks each artifact against itself.** A classification's `totals` and
`by_rule` are a second statement of what its rows say, and one of them had drifted:
`artifacts/classification-python-2026-09.json` summarised 26 true over rows holding 27. The 27th is
the consent-receipt timestamp that a sudo marker was wrongly suppressing; its note said it
was counted apart for that reason. Β§3.47 scoped markers to the rules they name, so it is
reported now, and it is counted. The composed-in Python rules are 27 true of 51, 53%.
A classification pinned to a different commit from the fieldtest it is matched against is
a fault rather than something to match around. Line numbers from two commits say nothing
about each other.
### 3.54 The recursion rule, and six true verdicts that were not
**Measured 2026-09-26.** Reading `spp_area/models/area_core.py` behind a verdict marked
true turned up a model the rule should never have fired on. `spp.area` sets
`_parent_store = True`, and odoo's `_parent_store_update` raises *"Recursion Detected."*
on any write that would make a record its own ancestor. It has done so since at least
October 2019, so across every series the rule claims. The constraint the rule asks for
is one the ORM already enforces.
So every true verdict for `recursion-check-on-hierarchy` was re-read against the class
around it. **Six of the seven were `_parent_store` models**: `account.asset.group` on
OCA, and `spp.area`, `spp.area.type`, `spp.audit.rule`, `spp.consent.purpose` and
`spp.hazard.category` on OpenSPP. All six are reversed to false, class
`parent-store-refuses-the-cycle`, and each row keeps `was: true` so the reversal can be
read rather than inferred. The seventh stands. `spp.dms.directory` declares a
`parent_path` Char but never sets `_parent_store`, so nothing maintains it and nothing
refuses a loop, while its `complete_name` and `root_directory_id` computes recurse up
the chain. It is exactly the shape the rule is for.
The rule was a regex for `parent_id = fields.Many2one`, cleared by either guard's name
anywhere in the file. It could ask neither question the harm depends on, and both
answers are in the class, so it moved to an AST pass, `parent_can_form_a_cycle`:
- **Does the field point at its own model?** A `parent_id` to a different model is an
ordinary foreign key. That was the `wrong-construct` false positive on OCA that Β§6 left
waiting for this migration. Self-reference is read through a string, the class's own
`_name` variable (`Many2one(_name, ...)`), and a module constant both are assigned
from (`_name = _type_model`).
- **Does the ORM already refuse the cycle?** `_parent_store = True` on `parent_id`, the
default `_parent_name`, clears it. The same flag on some other chain does not.
**Core found three more defects in the new checker before it shipped**, which is the
reason core is measured at all. Its first run left 17 findings, and reading them showed
eight that should not be there. Odoo's own test models write `_name = _description =
'x'`, and a helper reading only single-target assignments left those classes nameless,
so a `parent_id` to a different model read as unresolvable and fired. Two are SQL views
(`_auto = False`), which nobody writes and which the ondelete rule already exempts for
the same reason. One is `res.partner`'s `parent_id = fields.Many2one(tracking=3)`, which
names no model at all: it re-declares an attribute of a field declared in `base`, where
the relation and its `_has_cycle` guard both live. All three are fixed and tested.
| | before | after |
|---|---|---|
| OCA | 2 | **0** |
| OpenSPP | 6 | **1** |
| odoo core | 37 | **9** |
The nine left on core are all self-referencing and unguarded: mail threads, report
lines, restaurant tables, website menus, and five test models. None is classified yet.
The rule stays graded `unmeasured`. `recall --aim` has never placed it, and eight read
findings are too few to grade it by hand; they say the old checker was mostly wrong, and
say little yet about the new one. The survival check now counts 265 hand-classified true
positives rather than 271, because six of them never were, and all 265 are still found.
### 3.55 XML, which the tools had never read
**Measured 2026-09-26.** `noupdate` was the harvest cluster with the most silence and
the least coverage. The reason turned out to be structural rather than a missing rule:
`noupdate-on-user-editable-data` checks XML data files, and `fieldtest` and `recall`
read `.py` only. The rule had never been fired at a line of real code. Its `unproven`
grade was a statement about where the tools looked.
Pointing `nexa check` at the incident behind it showed three more things wrong.
OpenSPP's `124a9d822f` (#411) fixed a registry lock that every upgrade re-applied,
because the setting's install default lived in `config_parameters.xml` with no
`noupdate`. Before that fix, and after it:
- **Every XML file was parsed as Python.** It failed at line 1, and the report said
"15 semantic checkers never ran" and exited "not fully checked", on a valid file. It
also said the file was "reviewed against the 8 plain-Python ones".
- **The rule fired once, on `<odoo>`**, for any record at all. Views, menus and actions
have to upgrade; advising `noupdate` on them is advice nobody should take.
- **One `noupdate` anywhere cleared the whole file.** After the fix the file still holds
a parameter outside the block, and the regex called it clean.
**XML is a file kind now.** A rule declares `applies_to: xml`, `scan_source` reads the
kind off the content and runs only the rules that apply to it, and a file expat cannot
read is a gap with its line rather than a Python syntax error. `fieldtest` reads XML and
counts it apart: every per-kloc rate published before this is a rate over Python lines,
and folding view definitions into that denominator would have made the next number
incomparable with all of them. Not one Python figure moved. `recall --xml` is a separate
measurement over XML fix commits, so the Python denominator it shares with
`surfaced.py` is untouched. Demo files are skipped by both, because Odoo loads them
with `noupdate` forced on. A data file named `test_config.xml` was being skipped as a
test by its name, which is right for Python and wrong for XML a manifest lists under
`data`.
**Which records, measured rather than chosen.** The checker is an expat pass that
reports each `<record>` no `noupdate="1"` reaches, at its own line, for the models an
administrator tunes. The list came from asking odoo core what it does with its own
records: `ir.config_parameter` 26 of 28 under `noupdate`, `ir.sequence` 16 of 17,
`decimal.precision` 7 of 8, stages 30 of 34 across seven stage models, against 0 to 2%
for views, actions and reports. `ir.rule` is left out although core protects 566 of 598,
because a frozen record rule never receives the security fix its module ships later,
and that trade is not a checker's to make.
**Crons came in on an incident and were narrowed on the reading.** Core protects only
39 of its 93, so the measurement did not support them; OpenSPP's #473 did, naming two
crons and two parameters whose tuned values every upgrade reset. Every XML finding on
the three trees was then read, 76 of them, into `artifacts/classification-xml-2026-09.json`:
| | true | false | undecidable |
|---|---|---|---|
| parameters, sequences, stages | **11** | 3 | 0 |
| crons | 3 | **57** | 2 |
A cron's schedule is usually the mechanism, not a setting: the midnight rate-limit
reset, the hourly expiry check, the 2 a.m. posting run. An upgrade restoring those
restores intended behaviour. The shape that held up **ships `active=False` for an
operator to switch on**, which is exactly the decision an upgrade then undoes, so crons
now count only in that shape. That gives up the two quarantine crons #473 names; their
rows say so in `given_up`, and `figures.py` counts them apart from the survival check
instead of letting the check fail on them or hiding them from it. Their retention
parameters, which carry the harm #473 describes, are still reported. On core the 59 XML
findings become 7.
Two findings stand out. `spp_programs`' `program_fund_ref_num` is a `no_gap` sequence
that sets `number_next` to 1 in its XML, and `ir.sequence.write` stores that directly for
`no_gap`: every upgrade of the module restarts fund reference numbers at 1. And core's
`base.template_portal_user_id` is a Settings field in `auth_signup`, reset by every
upgrade of `base`. The two core crons that ship disabled and are switched on at runtime,
payment post-processing and predictive lead scoring, are recorded undecidable: an
upgrade resets them, and whether anything re-enables them afterwards needs a running
database to say.
**Recall over XML.** 197 fix commits on OpenSPP, 45 on OCA, and core capped as before.
The OpenSPP catches are #411 itself, the incident this section started from, and one
coincidence: `45c82bb0b701` deleted a parameter holding a shipped secret, and the
finding went with the record rather than with a `noupdate`. It is reported as both
caught and coincidental, because the criterion counts it and a reader should not. The
fix that added `noupdate` to the #473 file, #479, landed after the commit these
measurements are pinned to, so the checker reports those records as live, which is
what it should do.
The rule stays advisory and `unproven`. One incident caught is the first evidence it has
had, and it is not a grade.
### 3.56 Odoo runs in UTC, and the rule that carried the Python half did not know
**Measured 2026-09-26.** `naive-and-aware-datetimes-do-not-compare` was the one
composed-in rule that justified the composition: 21 of its true positives, graded
`defect`, and the README said so under "One rule carries it". Every true verdict but three
rested on a single premise, written into the rule's own comment: an Odoo `Datetime` is
naive UTC, so a naive *local* `datetime.now()` reaching one is wrong by the server's
offset, latent on a UTC server and live on any other.
Sampling it on core, the same way as Β§3.51 (`random.Random('20260921:' + rule_id)`),
found the premise false inside Odoo. `import odoo` runs
`odoo/_monkeypatches.set_timezone_utc`, which sets `TZ=UTC` and calls `time.tzset()`;
OpenERP did the same in its `__init__` for the same reason. And
`fields.Datetime.now()` is `datetime.now().replace(microsecond=0)`. Inside an Odoo process
there is no server offset for `datetime.now()` to be wrong by. Core's sample is **0 of
15**: every finding stores or compares UTC against UTC, or takes an epoch timestamp,
which does not depend on the zone at all.
Re-read under a criterion that does not depend on the server's clock, the OpenSPP and
OCA verdicts are **3 true of 33**, from 21. Eighteen are reversed, each keeping
`was: true`:
| class | | |
|---|---|---|
| `utc-by-odoo` | 15 | a "local" now against or into ORM values, a `Z` on a time that is UTC, a JWT whose library reads naive as UTC |
| `default-never-reached` | 2 | a server-date default after the required-field check that rejects its absence |
| `created-a-day-ahead` | 1 | a fiscal-year cron a whole day early, further than any offset |
The three that stand are two defect shapes. **A naive UTC timestamp leaving the system**
with no offset: two consent API responses, and exactly what the two commits recall
credits this rule with fixed, by writing `datetime.now(UTC)`. And **the server's date used
as a company's calendar day**: OCA's `purchase_unreconciled` dates a write-off move with
it, so for a company ahead of UTC the early hours land on the day before, at month end in
the period before. OpenSPP's own birthdate fixes, #397 and #520, are that class too,
which is where the old comment's "two fixes of exactly this class" came from; they were
never about the server's offset.
**The rule's advice was wrong for Odoo, not only its grade.** "Never call
`datetime.now()` without a tzinfo" applied to an ORM value raises: `Datetime.to_datetime`
refuses an aware datetime with `ValueError`. The rule now says where aware belongs, at
the edges, and names `fields.Date.context_today` for a user's day. Its grade moves from
`defect` to `preference`, which gates nothing: inside the ORM the finding is the house
spelling of the same value. The checker is unchanged and still fires on every
`datetime.now()`; a narrowing to the two surviving shapes needs to see where the value
goes next, which the regex cannot, and it is on the worklist.
The composed-in Python rules go from 27 true of 51 to 9 of 51. Β§6 said the two halves of
the corpus were not the same quality, and the distance is larger than it said.
### 3.57 The other two core samples
**Measured 2026-09-26.** Same seed, same recipe, the two rules left from Β§3.52's list.
| rule | core | OpenSPP | OCA |
|---|---|---|---|
| `constrains-must-list-every-field-read` | **10/15** | 38/45 | 7/7 |
| `depends-must-not-name-unread-fields` | **5/15** | 10/11 | 3/3 |
`constrains` holds up on the framework as it does on applications, which is what a
rule about a mechanism rather than a use case should do. Its five false ones are three
fields `ir.model` refuses to change after creation, one read that only phrases the error,
and a rate that is only ever bad through a direct write, which fires the check. One true
positive is a defect in core rather than a gap: `ir.actions.server._check_sms_template_model`
declares `template_id` and reads `sms_template_id`, so the field it validates is the one
whose writes never trigger it.
`depends` does not, and its ten false ones are three shapes, each a predicate the checker
could learn from evidence already in the file:
- **The override calls `super()` (3).** Redefining `@api.depends` on an override replaces
the parent's dependencies, so an override has to name what the chain reads, and its own
body reading nothing is correct. This one is safe to exempt outright.
- **A base stub declared for its overrides (2).** The body assigns a constant; modules
that override it read the declared fields and inherit this decorator.
- **A value meant to reset when its dependency changes (5).** `scrap_qty` back to 1 when
the product changes, a trigger cleared when the automation's model changes. The
dependency is the behaviour. This is the hard one: it is not distinguishable from a
leftover without knowing the intent, and the two OpenSPP fixes the rule was harvested
from were leftovers.
The survival check counts 274 hand-classified true positives across seven
classifications, all still reported.
### 3.58 The last three named false-positive classes
**Measured 2026-09-26.** The worklist named three classes left over from Β§3.43. Reading
what was still live showed one was already closed and two had shapes nobody had written
down.
**`check-company-on-relations`: one left, a SQL view.** The `related=` mirrors and the
computed `Many2many` the worklist named had shipped in Β§3.43. The one live false positive
was OpenSPP's `fund_report`, `_auto = False`: no table, so no foreign key to guard. The
ondelete and stored-compute checkers already exempted views; this one now does too.
**`sudo-bypasses-record-rules` on registry models: measured, then named.** Three
`sudo().search()` calls on `ir.model` stayed live on OCA after the AST move, because the
rules that would say whether `ir.model` is filtered live in `base`, which the tree does
not contain, so the trigger read UNKNOWN and kept them. Asked directly, odoo core gives
record rules to five framework models only: `ir.default`, `ir.embedded.actions`,
`ir.filters`, `ir.ui.view` and `ir.ui.view.custom`. `ir.model`, `ir.model.fields`,
`ir.model.data` and `ir.module.module` have none, so `sudo()` on them drops nothing, and
they are exempt by name. `ir.attachment` has none either and is kept out of that list:
its own `_search` filters by access unless the environment is su, so `sudo()` drops a
real check under another name. That only holds where the tree cannot see `base`. On
core, the tree's own facts show `ir.attachment` has no record rule, and the existing
trigger drops the finding, because this rule is about record rules and that access check
is outside its scope. The gap is the rule's scope, and it is stated here rather than
closed by a special case.
The checker also learned to read `self` as the enclosing class's model, which is how one
of the three was spelled. On OCA that took two more unclassified findings, both searches
on `ir.model` and `ir.model.fields`. On core it took 26, and every one was read. Twenty-five
are models the tree shows unruled, or calls that re-apply the model's own rule: a
`sudo()` search over ids a plain search had already filtered, wishlists filtered by the
user's own partner, a company's own branches, sequence lookups inside one journal.
**One is not, and the reason predates this pass.** `website_sale`'s
`set_sequence_up` swaps website sequence with a neighbouring `product.template` under
`sudo()`, filtered by `website_published`. `product.template` carries two rules, a
company rule and a website visibility rule on `website_published`, and the re-scoping
test counts a call as re-applying the rule when its arguments name any one rule field.
Naming one of two rules is not re-applying both. Resolving `self` made this call
reachable by that weakness; before, the model was unknown and the finding stayed. It is
recorded on the worklist rather than fixed here: the fix is to require every rule's
fields, and that changes the predicate every sudo verdict so far was read against.
The fourth OCA `sudo` false positive is the rebind clause, `self = self.sudo()`, over a
method that then only reads registry models. Proving that means showing every call after
the rebind is safe, which is a different checker; it stays classified false.
**Read only to phrase a message: two spellings missing.** The exemption knew a raise and
a message assigned to a variable. OpenSPP's five live false positives of this class were
all a **log line**, `_logger.warning("... %s", rec.name)`: a value read to phrase
something decides nothing, whether it is raised or logged. Core's one was a message
**collected in a list** and raised joined. Both spellings are now exempt; a field read in
the condition as well is still reported, and a test says so.
On the trees: OCA 103 to 98 live findings, OpenSPP 154 to 148. Every finding that went is
listed in the commit and was read. All 274 hand-classified true positives are still
reported.
### 3.59 An override that calls super(), and a reason that was backwards
**Measured 2026-09-26.** Β§3.57 named three shapes behind `depends-must-not-name-unread-fields`'
false positives on core and called the first safe to exempt outright: a compute whose body
calls `super()`. It is now exempt.
The reason Β§3.57 gave was wrong, and reading the framework rather than the finding showed
it. It said redefining `@api.depends` on an override *replaces* the parent's dependencies.
It does not. `Field.get_depends` resolves the compute through the MRO and extends one list
with every override's `_depends` (`odoo/fields.py`, 18.0 at 3c3e3b3), so an override's
decorator **adds** triggers for the chain, and the fields it names are read by the parent's
body, which the override reaches through `super()`. Core says so in its own comments:
*"EXTENDS 'web' to add dependencies"*, *"We are just adding depends on this compute."* The
exemption stands on the right reason now, and the docstring and test say that one.
The second shape, a body that only assigns a constant, was tried and withdrawn in the same
pass. It lost three true positives on OpenSPP and OCA, leftovers read true by hand: a
"legacy method", and a target model that is always `res.partner`. On core the same shape was
a stub for overrides or a deliberate reset. The file cannot tell intent apart, so it stays a
finding, and a test pins that it does.
On the trees: OCA and OpenSPP unchanged, and core **2,420 to 2,380**. All 40 findings that went
were read, and every one is an override whose body delegates to `super()`, most with nothing
else in it: the partner and employee avatars, the `need_cancel_request` computes of four
country modules, the currency-rate computes that Czech and Polish invoices re-base on the
taxable supply date. Three of them were in core's read sample, so the sample is 79 now, 42
true and 37 false. All 274 hand-classified true positives are still reported.
### 3.60 Re-scoping has to re-apply every rule everybody gets
**Measured 2026-09-26.** Β§3.58 left one weakness recorded rather than fixed: a `sudo()` call
counted as re-scoped when its arguments named a field of *any one* of its model's record
rules. Odoo does not combine rules that way. Global rules, the ones with no `groups`, are
ANDed for every user; group rules are ORed per audience. Naming a group rule's field
re-applies that rule for that audience and nothing else. The tree facts now keep each rule
apart with its audience, and a call is re-scoped only when it names a field of **every global
rule** on its model. Where a model has no global rule the old test stands, any rule's field
and then `company_id`, so nothing about group-only models moved.
It is the first change to this checker that can only add findings, so the reading was of
what it added, not of what it removed. OCA: nothing. OpenSPP: 19, every one on a line
OpenSPP's own `nosemgrep: odoo-sudo-without-context` marker already acknowledges, so all 19
are decided and out of every rate, as decided findings always have been. Core: **45**, read
in full into `artifacts/classification-sudo-core-2026-09.json`, not sampled.
**One is true, and it is the one the change was made for.** `website_sale`'s `set_sequence_up`
finds its neighbour under `sudo()` among every company's published templates and writes that
template's `website_sequence`, so reordering one company's shop reorders another's. The file
argues the case itself: `set_sequence_down`, six lines below, is the same code with a plain
`search()`, which the company rule filters.
**The other 44 were exempt before for a reason that was not the reason.** Each named a
field some *group* rule follows, `employee_id`, `move_type`, `website_published`, and for
`res.partner` the portal rule's `id`, so `search([('id', '=', partner_id)])` passed as re-scoped
on the strength of a rule that only portal users get. Most of them are fine anyway, for
reasons the checker cannot see:
| class | | |
|---|---|---|
| `domain-scopes-it-already` | 18 | confined through a record the caller holds: one journal, `self.ids`, a leave's employee, the user's own partner |
| `public-website-read` | 14 | the `/partners` and `/customers` pages, a published combo, a forum redirect |
| `guard-must-see-everything` | 5 | a constraint or lock that has to see every company to be a guard at all |
| `posts-in-the-records-own-journal` | 3 | an entry created in the journal of the order or valuation it is for |
| `touches-a-record-already-held` | 2 | a write on records a plain search just found |
| `company-refiltered-outside-the-call`, `refiltered-by-the-callers-search` | 2 | the company check happens two lines later, or in the caller's own search |
So the change trades 44 false positives on core, 5 of them advisory, for a test that says
what it means and one true positive the old test hid. Core goes from 2,380 to **2,420** live
findings; OpenSPP and OCA are unchanged in every rate. That is not obviously a good trade on
precision alone, and it is recorded as a measured cost rather than presented as a win. What
would recover most of it is the largest class, and it is one predicate: a domain leaf whose
value is the ids of a recordset the caller obtained under its own rules. That exemption would
restore what the old test let through on a stated reason instead of an accidental one. It is
on the worklist rather than built here, because it changes OpenSPP and OCA findings as well,
and the six OpenSPP true positives have to be re-read against it first.
No existing verdict changed. A stricter test cannot remove a finding, so every `sudo` verdict
read against the old test still sits on a finding, and none of the 45 had been read before.
The survival check now counts 275 true positives across eight classifications, all reported.
### 3.61 The datetime rule sees where the value goes
**Measured 2026-09-26.** Β§3.56 left `naive-and-aware-datetimes-do-not-compare` graded
`preference` with a regex that fired on every `datetime.now()` and `utcnow()`, and named the
two shapes that survived reading. Both need to know where the value goes next, so the rule is
an AST pass now, `naive_time_leaves_or_dates_a_day`:
- **A naive timestamp leaving the system with no offset.** Its `isoformat()` anywhere, unless
a `Z` or `+00` is written after it by hand; or the value itself as a keyword of a class being
built, or in a returned dict, in a file that imports fastapi, pydantic, starlette or
`odoo.http`.
- **The server's UTC date used as a calendar day.** A `.date()` of it into a vals key or
domain field spelled as a date, a `Date` field's default, or an attribute named for a date;
and a naive `now()` into a key named exactly `date`, which Odoo truncates to UTC's day.
The sources did not change. `date.today()` and `fields.Date.today()` are the same UTC date and
were measured rather than added: 48 and 114 sites on OpenSPP, 31 and 217 on core. Adding them
would have turned a narrowing into a widening, and `fields.Date.today()` is the framework's own
spelling of the value.
| | before | after |
|---|---|---|
| OCA | 7 | **1** |
| OpenSPP | 25 | **5** |
| odoo core | 167 | **2** |
All three hand-classified true positives are still reported, and both commits recall credits
the rule with still fire on the files as they were before the fix. On OpenSPP and OCA every
finding that went had been read false. On core the 167 were too many to read one by one, so
they were triaged: the 84 in a file that speaks HTTP, or with `.date()`, `isoformat`,
`strftime` or a date key within three lines, were read, and the rest are the `utc-by-odoo`
shape the sample already named, a naive UTC time meeting naive UTC values inside the ORM.
**The reading of what went is what found the two on core, and they changed the checker.** The
first draft gated shape A on HTTP alone, and it removed `l10n_es_edi_facturae` and
`l10n_es_edi_tbai` writing `datetime.now().isoformat()` into `xades:SigningTime`, an
`xsd:dateTime` in a signed e-invoice. Inside Odoo the value is UTC; with no offset, a reader
takes it as local time, and in Spain that is one or two hours off. It is exactly the rule's
sentence, "a file for another system", so `isoformat()` became a clause of its own. It costs
three findings OpenSPP's classification already reads false, a generated-at line in two
report scripts and a `computed_at` in a result dict, and that trade is kept in the open rather
than tuned away. Both core findings are recorded in `artifacts/classification-datetime-core-2026-09.json`,
a census rather than a sample.
What it misses, and says so: `l10n_in`'s demo invoices write `'invoice_date': datetime.now()`
into a `Date` field. A key ending in `_date` can just as well be a `Datetime` (stock's
`scheduled_date`), so a naive time into one is not reported and its `.date()` is.
**The grade goes back to `defect`**, the promotion Β§3.56 said should follow once the checker
could see the shape. On the three trees it is 5 of 8 read true, and both recall catches are
on-target. Those are small numbers and are stated as small. The rule's `wrong` sample moved
with it: it was a comparison, the one shape that did not survive, and a checker has to fire on
its own wrong sample. The composed-in Python rules gain nothing and lose nothing in their
classification: the verdicts are unchanged, only which of them are still reported.
### 3.62 `_sql_constraints` is ignored from Odoo 19
**Measured 2026-09-26.** The rule an incident asked for, and the first new rule since the
corpus reached 27. OpenSPP's 208d9758 (#394) found `spp.cr.type.reason.document` declaring
its `(cr_type_id, reason)` uniqueness in `_sql_constraints` on a 19.0 module. Odoo 19 logs
one warning at load and creates nothing, so duplicate rules were saved from 19.0.3.0.0
until the fix, and the fix added a lint of its own because nothing in CI had caught it.
`odoo.sql-constraints-are-ignored-from-19` is an AST pass over the file plus the manifest's
series, `legacy_sql_constraints_on_19`.
**A version claim was wrong on the way in.** Β§3.45 and the `prefer-sql-constraint` rule said
`models.Constraint` arrived in Odoo 17. Odoo 18.0 core at 3c3e3b3 has no `models.Constraint`
anywhere and declares `_sql_constraints` in 174 files; the 18.0 ORM reference has no
"Constraints and indexes" section and the 19.0 one does, declaring `Constraint`, `Index` and
`UniqueIndex` and never naming `_sql_constraints`. So up to 18 the attribute is the only
spelling, and from 19 it does nothing. The rule text, both correct samples that said "Odoo 16
only", and the checker docstring that repeated it now say 16 to 18. Β§3.45 is left as it was
written; this is its correction.
**Which series decides everything, and there are three answers.** 19 or later: reported, at
the assignment. 16 to 18: silent, because a finding would be advice that cannot be taken.
Unknown: silent when a tree was walked and simply does not pin a series, which is odoo core,
whose manifests carry versions like "1.0" and would otherwise yield 174 findings, every one
wrong. Reported as advisory, `unproven` on the finding itself, when nothing was walked at
all, because "nobody looked" and "looked and it does not say" are different answers, and
silence on the first would hide the rule from a bare snippet and from `validate`'s own samples.
It is the first rule to declare `odoo_versions` other than `>=16.0`, and that exercised the
context layer's per-rule check for the first time: grounding a 17.0 module with it is now a
finding, which is what that check was written to say. A test pins both sides.
On the trees it fires nowhere, and that is the expected answer rather than a missing one.
OpenSPP at the pinned commit already contains the fix and has 72 files of `models.Constraint`;
OCA's 20 `_sql_constraints` are all in 18.0 modules; core is 18.0. So the evidence is
OpenSPP's history. Five blocks were written on 19. Three were fixed together by `ca44b5e3`,
*"migrate _sql_constraints to models.Constraint (Odoo 19)"*, one is #394, and one went with a
reverted feature rather than a fix. The checker fires on all four fixed ones as the files stood
before their fix and on none after, and `nexa check --no-model` on the #394 file inside its
19.0 module reports it as high impact, likelihood defect, at the line the fix removed.
It is graded **defect** on that, which is more than `noupdate` got for one incident in Β§3.55,
and the difference is the construct rather than the count. A heuristic can be wrong about
intent. This cannot: on 19 the attribute does nothing, so every finding is a constraint that
does not exist. The one way to be wrong is a series misread, and that is why the unknown
series is handled the way it is.
Its probe, *"after upgrading to Odoo 19 my unique constraint is not enforced and duplicates
are saved"*, brings it back inside the top 3. Re-measured 2026-09-27 with the index rebuilt:
all 28 rules retrievable at `TOP_K` 6, and 26 of 28 in the top 3, where it had been 25 of 27.
The two that miss at 3 are `commit-inside-a-loop` and `eval-and-pickle`, not this one.
### 3.63 A named bug above the fold
**Checked 2026-09-27.** A maintainer reads the first screen, and the first screen described
a class of defect without showing one. It now shows one: OpenSPP's `spp.area` stores
`area_level`, computes it from `parent_id.area_level`, and declares only `parent_id`, so moving
an area recomputes its own level and leaves every area beneath it on the old one. It was
classified true in `artifacts/classification-openspp-2026-09.json` and is re-checked rather than
remembered: still line 84 at cfa0dc8e, still reported by `nexa check --no-model`, and
`OpenSPPArea.write` recomputes nothing by hand. `spp_api_v2_gis` groups areas by the level,
which is where the stale value would show.
The second candidate the worklist named, `spp_approval`'s `_check_approval_config`, is live
too and stays out of the opening: it is a constraint that `use_multitier` alone skips, a
real gap but a longer story to tell in four lines. One example says what the tool is for;
two start to read like a list.
### 3.64 A domain on records the caller already holds
**Measured 2026-09-27.** Β§3.60 made `sudo` re-scoping honest and paid for it with 44 false
positives on core, and named the largest class as one predicate. It is built now:
`_tied_to_held_records`. A `sudo()` call counts as re-scoped when a domain leaf pins it to the
ids of records the caller obtained under its own rules, with `=`, `in` or `child_of`.
`('employee_id', 'in', self.ids)` returns the contracts of employees the caller can already
see; `('journal_id', '=', self.journal_id.id)` returns the moves of one journal, which belongs
to one company.
"Held" is narrow on purpose, because every yes suppresses a finding. `self`, reached through
attributes and calls that return a subset of what the caller could see (`filtered`, `mapped`,
`sorted`, `with_context`, `exists`, a plain `search`); the user's own records; a name every
assignment to which is one of those; a loop variable over one. Not held: an id the caller
passed in, anything under `sudo()`, `browse(ids)` and `env.ref(...)`, which apply no rule, and
the result of any other method on `self`. That last exclusion came from reading: the first
draft trusted a controller's `self._get_search_countries()` because its root was `self`, and
said nothing about whose rules produced the countries. Five findings came back when it was
tightened. `!=`, `<` and `not in` never count, which is why `set_sequence_up`, whose leaf is
`('website_sequence', '<', self.website_sequence)`, is still reported.
| | before | after |
|---|---|---|
| OCA | 99 | 99 |
| OpenSPP | 153 | 153 |
| odoo core | 2,422 | **2,350** |
Of the 45 that Β§3.60 added, 14 go: 11 of the 18 `domain-scopes-it-already` and 3 guards. The
other seven of that class stay, each for a reason the predicate gives: four take a task,
partner or commercial-partner id as a parameter, one builds its ids in a list appended to
in a loop, the recruitment check is scoped by website rather than by records, and a channel
list is assigned once from the user and once under `sudo()`. The one true positive stays.
It also reached 58 older findings nobody had read, and every one was read before accepting
it. None is a defect. 20 are guards that must see every company: an account with journal
items anywhere cannot be deleted, a product used in any stock move cannot change its unit. 21
are records belonging to held records: a leave's work entries, a record's own activities and
scheduled messages, a message's notifications. 9 are the user's own: their loyalty card,
their slide progress, their departments. 2 are deliberate syncs across companies of one
shared record: a global milestone setting applied to every company's lines, and a partner's
new address re-pricing that partner's draft carts everywhere.
The risk named before building it held up and is worth keeping in view. Partners and
products are shared across companies, so a search tied to `self.partner_id` can still cross
a company boundary. In what was read, every such call was a guard, a sync of the same person
or product, or a count, and none returned another company's records to someone who could not
see them. That is a statement about core, not a proof, and a tree where it is false would show
up as a lost true positive in the survival check. All 277 are still reported.
### 3.65 A public page reading what it publishes
**Measured 2026-09-27.** The second class Β§3.60 named, `public-website-read`, 14 of the 44,
was mostly one shape. `website_customer`'s `/customers` and `website_crm_partner_assign`'s
`/partners` list, group and count partners under `sudo()`, because the anonymous visitor's
own rules would hide most of them, and every domain they build starts from
`('website_published', '=', True)`. That is `_published_on_a_public_page`.
The reason is in the framework, not in the page. Publishing is the record owner's decision to
show it to everybody. And on a model with `website.published.multi.mixin`, which
`res.partner` and `product.template` both carry, `website_published` is a computed field whose
`_search_website_published` adds the current website's domain whenever the request has one
(`website/models/mixins.py`, 18.0 at 3c3e3b3). A `website=True` route always has one. So the
leaf re-applies the scoping a public visitor's page is meant to have, in a spelling the rule
field test cannot read.
Narrow on purpose, as Β§3.64 was, because every yes suppresses a finding. All of these must hold:
- a read (`search`, `search_count`, `search_read`, `read_group`), not a write;
- inside a function decorated `route(..., auth='public', website=True)`;
- the literal leaf `('website_published', '=', True)` ANDed at the top of the domain. The
domain is read as the prefix notation it is: a leaf under `|` or `!` does not hold for
every record returned, a list glued on with an operator short of terms is a fragment, and
an `insert` can put an operator in front of the leaf, so each of those keeps the finding.
A domain built in a name counts only when every plain assignment to it carries the leaf;
`+=` and `append` only AND terms on.
`is_published` does not count: it applies no website. Neither does a published flag compared
with anything but `True`, nor the same search in a model method, which is how
`set_sequence_up` stays reported.
| | before | after |
|---|---|---|
| OCA | 99 | 99 |
| OpenSPP | 153 | 153 |
| odoo core | 2,350 | **2,336** |
Of the 14, the 12 on `/customers` and `/partners` go. The other two stay, each for a reason the
predicate gives: the forum redirect looks a partner up by an id from the URL, and the combo
configurator's search runs in a helper, not in the route, with no published leaf of its own.
Two older findings also went, in `website_membership`'s `/members` page: a `read_group` of
published members by country and a search for published free members. Both were read; both
are the same shape as the twelve. The dotted `('partner.website_published', '=', True)` on the
membership lines three calls away is not the leaf this reads, and those stay.
The cost is named, as Β§3.64 named its own. A partner published with no `website_id` is
published on every website, so a company's `/partners` page can list another company's
published reseller. That is Odoo's design for a record published everywhere, and the owner
chose it; it is still a company boundary the page crosses, and a tree that means otherwise
would show up as a lost true positive in the survival check. All 277 are still reported.
### 3.66 A mapped() path is a read
**Measured 2026-09-27.** Building the sample findings report for the Odoo review, the depends
check was silent on this:
```python
@api.depends("invoice_ids")
def _compute_outstanding_total(self):
for account in self:
account.outstanding_total = sum(account.invoice_ids.mapped("amount_residual"))
```
and reported the same read written as `sum(inv.amount_residual for inv in account.invoice_ids)`.
`_field_reads` collects attribute accesses, and the only one in the first spelling is
`invoice_ids`, which is declared. The field that goes stale is a string argument.
`_mapped_reads` now reads `x.mapped("a.b")` with a literal string as a read of `x.a.b`. The
receiver resolves the way a loop's iterable does, through `filtered()`, `sorted()` and `sudo()`,
and everything after that is the checker as it was: a trailing `.id` names the relation, a
field the method computes is never asked for, a non-stored compute is outside the rule, and a
declared `a.b.c` covers a read of `a.b`. A `mapped()` inside a `raise` or a log line is phrasing
a message and is skipped, as an attribute read there is. `mapped(lambda ...)` and `mapped(name)`
stay unread: the fields a lambda touches are a guess from here, and every guess in this checker
is a finding somebody has to read.
| | before | after |
|---|---|---|
| OCA | 99 | 99 |
| OpenSPP | 153 | 153 |
| odoo core | 2,336 | **2,345** |
OCA and OpenSPP do not move, and that was checked rather than taken from a quiet run. OCA has 5
computes reading through a literal `mapped()`, 3 of those reads undeclared: a `display_name`
override, whose column the framework never stores, and two in a `TransientModel` wizard.
OpenSPP has 22 and 3, and all three compute fields declared without `store=True`. Each is
exempt for a reason the checker already had.
Core has 117 such computes, and 9 findings survive the exemptions. Nothing disappears. All 9
were read:
- `pos_sale`, three. `sale.order.amount_unpaid` is stored, reads `transaction_ids.invoice_ids`
and `order_line.pos_order_line_ids.price_subtotal_incl`, and declares the relations but
neither field. `sale.order.line.untaxed_amount_invoiced`, stored in `sale`, adds
`pos_order_line_ids.price_subtotal` and declares only `pos_order_line_ids`. An amount written
on a linked POS line leaves both totals where they were.
- `survey`, one. `scoring_percentage` and `scoring_total` are stored "for perf reasons" and read
`predefined_question_ids.suggested_answer_ids.answer_score`. The decorator names
`predefined_question_ids.answer_score`, the score of a simple question, and not the scores of
the answers. Editing a choice's score leaves every submitted attempt on the old percentage,
while editing a simple question's score recomputes them.
- `account_peppol_response` and `l10n_dk_nemhandel_response`, one each, the same shape: the
stored move state reads the responses' `response_code` and declares only their state. True
as the rule states it. In core `response_code` is required and written only when a response
is created, which the declared relation already covers, so nothing in this tree edits one in
place.
- `purchase_stock` and `sale_stock`, one each: `incoterm_location` read from the linked orders.
`purchase_stock` declares only `purchase_id`, a `store=False` autocomplete field;
`sale_stock` declares the orders and not their `incoterm_location`. Both true, and both on a
`readonly=False` compute, the smart-default pattern Β§3.51 kept as a consideration inside a
verdict rather than a rule: a bill's incoterm location following its order after the fact may
not be what anybody wants.
- `sale_mrp`, one, false. `_compute_qty_to_deliver` reads `move_ids.bom_line_id.bom_id`, but the
two fields it computes are declared in `sale_stock` without `store=True`. The file in front
of the checker cannot see that; it is the storedness-in-another-module class the OCA reading
left at 13 undecidable.
So 8 true as the rule states it, two of them with a field nothing rewrites and two on a smart
default, and 1 false. None of the nine goes into a classification file: core's is a fixed-seed
sample, and these were read as a census of what one change added. All 277 hand-classified true
positives are still reported.
What it still does not follow, named so nobody finds it later: a lambda or a variable as the
argument; `mapped()` on the result of another `mapped()`; and a loop over
`rec.line_ids.mapped("move_id")`, whose loop variable is not bound to a path, because a
`mapped()` result is not a recordset in general and `_resolve_iterable` still says so.
### 3.67 What backs an onchange, measured and mostly not taken
**Measured 2026-09-27.** `onchange-is-not-validation` stays quiet when the file names any
constraint at all. So a `UNIQUE(reference)` silences an onchange that raises on
`credit_limit`, and on an Odoo 19 module a `_sql_constraints` block counts as the backstop even
though Odoo 19 never creates it (Β§3.62). Two narrowings were proposed together: count a
constraint only when it covers a field the onchange watches, and never count
`_sql_constraints` when the tree pins 19 or later. The rule was that this ships only if what
it adds reads true. Both were measured, apart and together.
| | before | field-matched, and 19 | 19 only (shipped) |
|---|---|---|---|
| OCA | 99 | 100 | **99** |
| OpenSPP | 153 | 157 | **153** |
| odoo core | 2,345 | 2,376 | **2,345** |
**Field matching added 36 findings, and 11 read true.** On OpenSPP, 3 of 4. `spp_area` caps
an area at level 10 in `_onchange_parent_id` and nowhere else; its constraint is
`unique(code)`. `spp_change_request_v2` keeps required documents inside the available list
only in the form. `spp_registry`'s one-head-per-group rule is checked in the membership
onchange and in the group's own `write()`, and a change request's `add_member` creates
memberships directly, past both. The false one is `spp_import_match`: the parent model's
`@api.constrains("field_ids")` enforces the same no-duplicates rule, under a field name the
child's onchange does not watch.
On core, 8 of 31. Four are the partner "block" warnings in `account`, `sale`, `purchase` and
`stock`: each clears the partner in the form, and nothing refuses a blocked partner on import
or RPC. That is Odoo's design for the feature, and it is also exactly the rule's sentence. The
other four refuse a value nothing else checks: a loyalty card's expiration date, a negative
price rounding, an expense edited by someone who may not edit it, and a manufacturing order
whose component is its own product. Four of the false ones are checked on the server
elsewhere: a work order's dates by the inverse `_set_dates`, the reference unit by
`_check_category_reference_uniqueness`, an API key's duration when the wizard creates it, and
the Argentinian journal pairing when the invoice posts. The other 19 refuse nothing. They are
notices: "Note: the reference already exists", a packaging quantity suggestion, "the new rate
is quite far from the previous rate", a sequence format that changed. On OCA the single
addition is `account_loan`'s rate warning, and it is the same kind of notice.
That last finding corrects an old reading. Β§3.45 described `account_loan` as "an onchange
properly backed by" its `_sql_constraints`. The block is `unique(name, company_id)` and backs
nothing the onchange watches. The finding was false because the onchange only informs.
So the file-wide guard was hiding a second, wider problem: the checker counts any returned
warning dict as a refusal. Narrowing the guard first would ship that noise, 25 false for 11
true, so it was not taken. A variant that counts a warning dict only when the onchange also
resets a field it watches was run for information. It keeps all 11 true additions and 4 of the
false ones, but it also removes 17 existing core findings and 1 on OpenSPP that nobody has read.
Whether that is the right next change depended on reading those 18 first. They were read the
same day, and Β§3.68 is what came of it.
**The series half ships.** A `_sql_constraints` block no longer counts as backing on a tree
that pins 19 or later; on 16 to 18 and on an unknown series it still does, as Β§3.45 requires.
It changes nothing on the three trees: OpenSPP's modules are all on 19, but every file there
that has an onchange and a constraint uses `models.Constraint` or `@api.constrains`. That
includes the state before OpenSPP's own incident fix (208d9758), whose file also carried
`@api.constrains` methods. It is shipped for the same reason Β§3.62 was: a declaration the
registry throws away is not a backstop, and the checker should not say it is. All 277
hand-classified true positives are still reported.
### 3.68 A warning refuses only when it takes the value back
**Measured 2026-09-27.** Β§3.67 left the field-matched guard out because the file-wide one was
hiding a wider test: any returned warning dict counted as a refusal. Both changes ship now,
together, and the second is what makes the first read true.
`_refuses_in_branch` asks one thing of a warning: does the branch that returns it also take the
value back? The branch is the innermost `if`, `for` or `while` around the warning, or the whole
body when there is none. Taking the value back is writing a field the onchange watches (the
quantity clamped to zero, the removed document restored, the component taken out again), or
clearing any field on the record to `False`, `None` or empty (the blocked partner, the
registrant that no longer fits). A `raise` still counts on its own. A warning with no write
beside it is a notice, and the user saves past it in the form as easily as in an import, so
nothing is being enforced anywhere and the rule's harm is not there. A write outside the
warning's branch does not count either: `account.move` sets its `show_name_warning` flag in
an earlier `if`, and that flag is not a refusal.
The first draft only looked for writes to watched fields, and reading what it removed stopped
it. `spp_change_request_v2`'s `_onchange_request_type_id` watches the request type and clears
the registrant when a group lands on an individual-only type. Nothing else checks that pairing:
the registrant's domain is a form attribute, so a change request made through the API can pair
them. Clearing a field the onchange does not watch is a refusal too, and the test now says so.
Setting one to a suggested value is not: a scrap order's recommended location, moved in for
the user with a note, refuses nothing.
| | before | after |
|---|---|---|
| OCA | 99 | 99 |
| OpenSPP | 153 | **157** |
| odoo core | 2,345 | **2,339** |
Every change was read. Added: on OpenSPP the four from Β§3.67, 3 true and 1 false, now in
`artifacts/classification-openspp-rest-2026-09.json` under a new class, `backed-by-the-parent-constraint`.
On core, 11: the 8 true from Β§3.67 (the four partner blocks, loyalty expiry, negative price
rounding, expense edit rights, a component equal to its product) and 3 of the 4 that are checked
on the server elsewhere (the work order's dates, the reference unit, the Argentinian journal).
The API key's duration no longer appears: its onchange only warns. OCA's `account_loan` note
does not come back.
Removed: 17 on core, all read, and none refuses anything. Six are advice about a value that
saves anyway: an OCR number that fails its checksum, an invoice name too long for GST rule
46(b), an EGP rate with more than five decimals, a rounding finer than the decimal accuracy, a
package heavier than its type allows, a pricelist minimum quantity that event tickets will
ignore. The other eleven say what is about to happen: tokens archived with a payment method or
provider, a payment method attached to a provider, pricelists archived with the feature, a
carrier's rating error, a subcontracting purchase order, a decreased service quantity, tracked
products already in stock, a storage category with no location, an open cart keeping its
pricelist, and a scrap order's recommended location. None of them is in a classification file.
So the rule now reports 15 more onchanges than it did and 17 fewer, and of the 15, 11 are
true. OpenSPP's census gains 3 true and 1 false; core's 17 removals were notices nobody had
read, and they are not claimed as false positives fixed. All 280 hand-classified true
positives are still reported, the 3 new ones among them.
What it still does not see: a constraint whose fields sit under another name, as
`spp_import_match`'s parent `@api.constrains("field_ids")` does for its child's `field_id`, and a
check the server makes in an inverse, a wizard's `create()` or at posting, none of which this
checker reads as a constraint. The 3 false additions on core are that. Β§3.69 takes the first
two of those three places; posting stays.
### 3.69 The same check, made again where the server runs it
**Measured 2026-09-27.** Of Β§3.68's three false additions on core, two were checks the server
already makes in the same class under a name the constraint test does not read.
`mrp.workorder`'s `date_finished` onchange refuses to unplan one work order, and `_set_dates`,
the `inverse=` of both date fields, raises the same sentence. The API key wizard's onchange and
its `create()` both call `res.users.apikeys._check_expiration_date` on the expiration date.
`_server_repeats_the_check` treats an onchange as backed when a method of its own class that
the server runs whatever the client is (`create()`, `write()`, a field's `inverse=`, an
`@api.constrains` method) makes the same check. The same check means one of two things, and
both are strict because every yes suppresses a finding:
- the exact message the onchange raises, raised there too, at least 20 characters so a stock
word does not match. A paraphrase is not the same check;
- the same check-shaped helper (`check` or `validat` in its name) called there, where the
onchange hands it a field it watches. A bare `self._check_company()` in the onchange is
checking something else, and `write()` calling it too says nothing about this value.
Not counted: a method the client chooses to call, like an `action_` button, and posting, which
is why `l10n_ar`'s journal pairing stays reported. A draft can hold the value until somebody
posts, and that window is the gap the rule is about.
| | before | after |
|---|---|---|
| OCA | 99 | 99 |
| OpenSPP | 157 | 157 |
| odoo core | 2,339 | **2,338** |
One finding goes, the work order's, read as false in Β§3.67. The API key wizard was already out:
its onchange only warns, and Β§3.68 stopped counting a bare warning. Nothing else moves on the
three trees, which is the evidence that the two signals are narrow. They are there for code
this corpus has not met yet, where a check written once for the form and once for the server
is a common way to be careful. All 280 hand-classified true positives are still reported.
### 3.70 A dotted name is read, not matched
**Measured 2026-09-27.** `constrains-ignores-dotted-names` was a regex for a quoted name of two
segments, lowercase letters and underscores, anywhere in the decorator's arguments. A sweep of
the checkers found two shapes it could not see. `@api.constrains('partner_id.country_id.code')`
has three segments, so where the regex wanted the closing quote there was a dot. And the class
had no digits, so `@api.constrains('l10n_in_gst_treatment', 'partner_id.l10n_in_pan')` was
invisible, as was any dotted name through an `l10n_` field. A check that looks like it runs
and does not is what the rule is about, and its own checker was one.
`constrains_declares_a_dotted_name` reads the arguments the way the other constrains checker
already did, with `_decorator_args`, and reports each method whose declaration contains a dot,
on the decorator line. The message says what is left to trigger the check: nothing when every
name is dotted, and the plain names when some are. On 18.0 a dotted name is not an error.
`_constraint_methods` logs "is not a field name" the first time the model collects its
constraints, and `_validate_fields` never finds it among the written fields (`odoo/models.py`,
18.0 at 3c3e3b3). A computed list, `@api.constrains(*FIELDS)` or the callable form Odoo also
accepts, goes to the gap channel, as it does for `constrains-must-list-every-field-read`. Both
checkers meet the same unread method, so `_note_unreadable` now records a method once. Twice,
`check` would print it twice and fieldtest would count it as two files it could not fully read.
| | before | after |
|---|---|---|
| OCA | 99 | 99 |
| OpenSPP | 157 | 157 |
| odoo core | 2,338 | 2,338 |
Nothing moves, row for row, and that was measured rather than assumed. The same AST read over
every `@api.constrains` in the three trees, tests included, finds 815 decorators (497 on core,
43 on OCA, 275 on OpenSPP) and not one dotted name. 41 name a field with a digit in it, which
the regex would have missed had it been dotted. The 9 computed lists are all on core and were
gaps already. The rule stays in the NEVER FIRED list and keeps its `unproven` grade.
Its value is somewhere else: in client code under `nexa check`, and in generated answers, where
an `@api.depends` beside the constraint takes the same dotted name and means it. That is the
mistake the rule was written for (Β§3.7), and the three-segment spelling of it would have
passed there too. The corpus now decides 8 rules by a regex and 19 by an AST pass. All 280
hand-classified true positives are still reported.
### 3.71 What a string names, and what a subscript picks
**Measured 2026-09-27.** Β§3.66 taught the depends check that `x.mapped("a.b")` reads `x.a.b`.
The sweep that found Β§3.70's gap found the siblings of `mapped()` still silent, and one more
place where a path stopped:
- `x.filtered("is_paid")` reads `x.is_paid`, and `x.filtered("a.b")` reads `x.a.b`.
- `x.sorted("sequence")` reads `x.sequence`. Odoo 18 takes one field name there, as
`itemgetter(key)`. On 19 the argument is an order, `"date desc, id"`, whose terms each name a
field (`_sorted_order_to_function`, `odoo/orm/models.py` on 19.0).
- `x.filtered_domain([("state", "=", "done")])` reads `x.state` for every leaf written out, and
through an `any` leaf to the related records. `x.grouped("f")` keys on `x.f`.
- `rec.line_ids[0].amount` read nothing past `line_ids`, because `_attr_path` and
`_resolve_iterable` stopped at a subscript. `rec.partner_id.child_ids[:1].email` reported
`partner_id.child_ids` and never the email, which is the field whose change goes unnoticed.
`_named_reads` replaces `_mapped_reads` and reads all five methods, literals only. A lambda, a
name and a domain built at run time stay unread, and an order with any word after a field but
`asc`, `desc`, `nulls`, `first` or `last` is read not at all rather than in part. A subscript is
looked through when its index is a literal integer or a slice, since indexing or slicing a
recordset gives records of the same path. The other thing a subscript does is `record[name]`,
which reads the field called `name`, so a string or a variable as the index stops the path as it
did before. Every exemption the check has applies to every new read: a trailing `.id`, a field
the method computes, a non-stored compute, a wizard, `display_name`, a declared longer path, and
a read inside a `raise` or a log line.
`constrains-must-list-every-field-read` had only ever read attributes, so
`if any(self.mapped("date_end"))` under `@api.constrains("date_start")` was silent. It was
measured with the same reads before it took them. `depends-must-not-name-unread-fields` steps
aside when the body reads a field its decorator does not declare, because the depends check owns
that disagreement, and it now counts the same reads when it decides. That moved nothing on the
three trees.
| | before | after |
|---|---|---|
| OCA | 99 | **101** |
| OpenSPP | 157 | **163** |
| odoo core | 2,338 | **2,358** |
Nothing disappears, and no other rule moves. By part: the literal forms add 1, 6 and 11 depends
findings on OCA, OpenSPP and core, the subscripts add 6 on core, and the constrains check adds 1
on OCA and 3 on core. `filtered_domain()` and `grouped()` add nothing on the three trees. They
name fields exactly as `filtered()` does and are kept for code these trees do not hold. A loop
over `enumerate()`, the other shape the sweep sized, added nothing and was left out.
Every addition was read.
**OCA, 2, both true**, now in `artifacts/classification-oca-2026-09.json`. `account_move_name_sequence`
sets `made_sequence_gap` to `False` for moves outside a hashed journal with
`self.filtered("journal_id.restrict_mode_hash_table")`, and neither its decorator nor
`account`'s, which Odoo merges with it, names the flag: securing a journal leaves its existing
moves on `False`. `account_spread_cost_revenue`'s journal check reads
`mapped("line_ids.move_id")` and declares only `journal_id`. True as the rule states it, though
every move the module creates for a line takes the spread's own journal.
**OpenSPP, 6, all true**, in `artifacts/classification-openspp-rest-2026-09.json` with the other findings
later changes surfaced. `artifacts/classification-openspp-2026-09.json` is the reading of the two flagship
rules made on 2026-09-16 and is quoted as that reading. Five are `spp_programs`' registrant
counts, each set to 0 for the partners `self.filtered("is_registrant")` leaves out, without
`is_registrant` declared. Every create path in the tree sets the flag at creation and nothing
rewrites it. The sixth is `spp_approval`'s current tier, the first pending tier review by
`sequence`. A review's `sequence` is a stored related of its tier's, so reordering a
definition's tiers while reviews are pending leaves each review on the tier the old order put
first.
**Core, 20: 10 true, 10 false.** The true ones:
- `hr_expense`'s report is `reversed` once every entry has a reversal
(`filtered("reversal_move_ids")`). A company-paid report's entries are `entry` moves, whose
`payment_state` and `amount_residual`, the two fields declared, never move on a reversal, so
the report stays paid.
- `hr_holidays`' accrual plan takes its unit from `level_ids[0].added_value_type`. Changing the
plan's time off type recomputes every level's unit, and the plan's own, which allocations
read, stays where it was. Each level's copy of the first level's unit is the same shape,
reached only by writing the first level directly: the form writes `level_ids` on the plan,
which is declared.
- `hr_holidays_contract`'s multi-contract check filters on `employee_id` and declares the dates.
Moving a leave to an employee whose contracts under it keep different schedules is not
checked.
- `website_sale_gelato`'s publishing check filters on `gelato_template_ref` and declares
`is_published`. A template set on a published product whose print images have no data is not
checked. The core sample already holds its sister check on `product.document` as true.
- `stock.scrap` takes its quantity from `move_ids[0].quantity` and declares
`move_ids.move_line_ids.quantity`, one of the two things a move's quantity is computed from.
The other, the lines' unit, is the gap. Narrow.
- `point_of_sale` copies the first POS category's colour onto the product and does not follow
a later change to it, on a `readonly=False` default. What Β§3.66 said of `incoterm_location`
applies.
- Three with a field nothing in core rewrites, as Β§3.66 counted `response_code`.
`base_automation` gives an automatic name only to the actions `filtered("base_automation_id")`
keeps, and every core path creates the action already linked. `sale_expense_margin` costs a
reinvoiced line from its `expense_id`, set when the line is created. `l10n_id_efaktur_coretax`
names a document after its first and last invoice by `sorted("name")`, and a posted invoice
changes name only by resequencing.
The false ones are classes the check already had for attribute reads. None comes from reading a
line wrongly:
- storedness in another module, two. `icon_image` in `base` and `highest_name` in `account` are
declared without `store=True`.
- a decorator merged from another module, one. `sale_timesheet`'s project partner calls
`super()`, and `sale_project`'s `_compute_partner_id` declares `allow_billable`. Odoo merges
`@api.depends` across every override (Β§3.59), and the file in front of the check shows only
its own.
- covered through another declared compute, two. `hr_expense` filters on `product_has_cost`,
which depends only on `product_id`, and the declared `tax_ids` depends on `product_id` too.
`website_slides` filters on `is_category`, and flipping it re-sections the channel through
`_compute_category_id`, which changes this slide's `slide_ids`, which is declared. This is the
class the OpenSPP classification calls `covered-by-its-own-depends`, one step further removed.
- a value kept on purpose, two. An `account.payment.term.line` defaults its days to the
previous line's plus 30 only while they are unset, so declaring the sibling's days would
overwrite a deliberate 0 and make the field depend on itself. `l10n_id_efaktur`'s document
name is `readonly=False`, with a docstring saying it is computed at creation and may be
renamed.
- refused by the model while it matters, two. The POS session's cash journal reads its payment
methods' `is_cash_count` and `journal_id` (`filtered("is_cash_count")[:1].journal_id`), and a
payment method refuses every write but `sequence` while a session using it is open.
- advice that cannot be taken, one, on the constrains side. `mail.activity.schedule` filters on
`error`, a non-stored compute that `@api.constrains` cannot name: Odoo logs it as not
writeable. The decorator lists `error`'s writable inputs instead, and a comment says so.
So 28 findings, 18 true. On core the depends additions read 8 of 17, below the 11 of 15 that
rule's core sample read (Β§3.51), and the constrains additions 2 of 3, beside that rule's 10 of
15 (Β§3.57). On OCA and OpenSPP they read 8 of 8. Each part was weighed apart, as
Β§3.67 weighed its two, and none is left out. Each adds more true findings than false, and the
false ones are not a new kind of mistake: they are what this check already says when the same
field is read as an attribute, and keeping a spelling silent would not fix them, only hide them
behind the spelling. None of the core readings goes into `artifacts/classification-odoo-core-2026-09.json`,
which is a fixed-seed sample. The hand-classified true positives are now 288, and all 288 are
still reported.
What it still does not follow, named so nobody finds it later:
- a lambda, a name or a variable as the argument, a domain built at run time, and an Odoo 19
`Domain` object;
- a subscript by a name, `rec.line_ids[i]`, which could be either kind of subscript;
- a name assigned a recordset (`pending = ...sorted("sequence")[:1]`, then `pending.tier_id`):
`_aliases` binds loop variables only;
- `mapped()` on the result of another `mapped()`, and a loop over a `mapped()` result, as Β§3.66
left them;
- an attribute read straight off a call, `rec.sudo().partner_id.name` or
`rec.line_ids.filtered(...).amount`. Sized and not read: core +9 on the depends check (six
through `sudo()`, three through `with_context()`), OCA +1, OpenSPP 0;
- a read another declared path already reaches: a read compute's own dependencies
(`product_has_cost` above, and OpenSPP's three older `covered-by-its-own-depends`), or a flag
whose change re-sections a declared relation (`is_category` above). Following either would be
a narrowing of its own.
### 3.72 What a lambda reads, measured and not taken
**Measured 2026-09-27.** Β§3.66 and Β§3.71 left a lambda unread, and Β§3.66 gave the reason: "the
fields a lambda touches are a guess from here". For the recordset methods that reason does not
hold. `x.filtered(lambda r: ...)` calls the lambda with every record of `x`, and so do
`mapped()`, `sorted()` and `grouped()`; `sorted(x, key=...)`, `max()` and `min()` do the same by
iterating `x`. The parameter is bound to `x`'s records exactly as a loop's target is, so `r.f`
is a read of `x.f`. Whether that read is a dependency is a separate question, and this section
is the answer to it.
The binding was built, each lambda given its own scope with its first parameter bound to the
receiver's records, and measured against fed0255, the tree before Β§3.70 and Β§3.71 landed.
Depends and constrains were weighed apart:
| | before | depends | constrains |
|---|---|---|---|
| OCA | 99 | +8 | 0 |
| OpenSPP | 157 | +1 | 0 |
| odoo core | 2,338 | +127 | +27 |
Every one was read against the code, and **they do not read true.** On core, 40 of the 127
depends findings are true, and 28 of those 40 are smart defaults, `readonly=False` computes,
which Β§3.51 kept as a consideration inside a verdict rather than a rule. Twelve are a stored
value that goes stale. Constrains reads 9 of 27. OCA reads 2 of 8, judged the way core was, and
OpenSPP's one is true. Β§3.71 took each of its parts because each added more true findings than
false. These add about twice as many false as true, depends and constrains alike, and OpenSPP's
single finding cannot carry that.
The reason is what a lambda is for. A loop body computes the value from its inputs. A
`filtered()` predicate decides which records get which treatment, and the fields it tests say
what kind of record this is. Core's 87 false depends findings:
- 28 test an identifier or a type that nothing changes after creation, or that the model
refuses to change. Six are `not line.display_type` on sale and purchase lines, whose
`write()` raises on a change of type. The rest include an EDI document's format, a country's
ISO code, a done picking's type and destination, a location's usage, a POS order line's
company, a model's technical name and `create_date`.
- 12 are a guard on which records to touch: drafts only, e-way bills still pending, entries
not yet hashed, sale lines whose purchase price is kept.
- 12 sit in a compute whose own field another module declares without `store=True`, outside
the rule.
- 19 are covered already, in a way the file cannot show. Seven by the parent method's
decorator, which Odoo merges across overrides (Β§3.59). Seven through a declared computed
field that depends on them, since Odoo's triggers are transitive: a task's `project_id` is
computed from `parent_id.project_id`, so a compute declaring `project_id` already follows the
parent. Five through a declared field that always changes with them: a picking is `done`
exactly when `date_done` is set.
- 9 read the method's own output, filled through a helper, `update()` or `write()`.
- 7 are one of a kind: moves sorted by `ref` only to number a batch in order, residuals Odoo
rewrites in SQL when an account's reconcile flag is written, a Greek invoice type the compute
keeps once set, a journal flag that cannot change while it matters, a clause that runs only
for unsaved records, and a service type the parent already sets for every good.
The constrains additions read the same way. Of the 18 false, 11 name a non-stored related or
computed field, seven of them `country_code`, and `_constraint_methods`, the collector Β§3.70
reads, logs such a name in `@api.constrains` as not writeable. Six name a field fixed at
creation: a payment provider's
`code` on three providers, a Gelato print image's flag, a journal item's move, and a
sub-channel's parent, which `write()` refuses. The last is a move's company, which re-derives
the journal the check declares.
What is true is worth naming:
- `account.move.line._compute_amount_residual` decides which journal items carry a residual by
their account's reconcile flag and type, and declares only `account_id`. For the flag, Odoo
rewrites the residuals by hand in SQL when it is written (`_toggle_reconcile_to_true` and
`_toggle_reconcile_to_false`), so that half is false. The type has no such hand: switching an
account to Bank and Cash leaves its items' residuals where they were.
- `mrp.production._compute_reservation_state` filters the components on product, `picked`,
quantity and unit, and declares only their `state`, which `_recompute_state` writes only when
it changes. Marking a component picked leaves "MO Readiness" as it was.
- On constrains, `account_report`'s domain check declares `formula` and filters on `engine`, so
switching an expression's engine to domain over a formula that is not one is never checked.
So too a barcode rule switched to GS1 over a pattern without its two groups, a journal made an
AFIP point of sale with no number, a loyalty rule switched to a code, and a paper format given
a page size beside a named format.
OCA's two are `account_check_deposit`'s in-hand account, which follows a payment method line
whose method can be changed, and `account_move_name_sequence`'s split sequence, which skips moves
outside a hash-secured journal and does not re-run when a journal becomes one: the flag Β§3.71
found in the same module's gap check. Its six false are a field core declares without
`store=True`, a journal item's move, a posted bill's type, a payment method's code, and the
journal and type of a move that already has a sequence number, which `write()` and
`action_switch_move_type` both refuse to change. OpenSPP's one is `spp_area`'s center areas,
filtered on a role line's `role_type`, a related of the role's type: a role switched from local
to global through the API keeps its areas on every user, where the form's onchange clears them.
So the binding is not taken, and a test pins that. What would separate true from false here is
facts from outside the file: which fields are stored, related, computed from what, merged from a
parent's decorator, refused by `write()`. The tree facts the ondelete and check_company triggers
read are where that would start. It is proposed, not built, and even built it would leave the
guards and every identifier the model does not refuse to change, 34 of the 87, which no
declaration decides.
Two narrower questions were read on the way.
**A sort orders the batch only when it sorts the batch.** Four of the findings the binding
reached came from `sorted()`. Two sort `self`, the records being computed: `account.move._compute_name` numbers a
batch by date and `ref`, and no move's name follows another's `ref`, while
`_compute_balance_start` sorts statements by first line, a field it also reads for each
statement, so that finding stood already. The other two sort a relation and pick from it
(`[:1]`, or the first with an email), which is a dependency when the key can change, as a bank
account's `allow_out_payment` can and `create_date` cannot. The line runs between `self` and a
relation, not between iterating and picking.
**A lambda's parameter hides a loop variable of the same name**, and `_aliases`, one map for the
whole function, does not know it. In event's `_compute_event_mail_ids`,
`lambda mail: mail._origin.mail_done` runs over the event's own mail lines, and a loop further
down binds `mail` to the event type's. The check reports
`event_type_id.event_type_mail_ids._origin.mail_done`, a path the method never reads, twice. The
binding put `mail` on the event's own lines, whose field the method computes, and both went.
Hiding the parameter without binding it was measured too, and it is worse. The common spelling
is `for x in self.filtered(lambda x: ...)`, where the loop and the lambda walk the same records
and the one map is right by accident. Hiding took nine of those along with the two, and at least
two of the nine are true: `ir.actions.server`'s mail and SMS template checks filter on the
action's type, `state`, and do not declare it, so switching an action to sending an SMS over
another model's template is never checked. Only the binding fixes the two without losing the
nine, so they stay with it.
**What ships is the first false class: the method's own output.** `account.account`'s
`_compute_account_type` filters on `not account.account_type` and hands the records to
`_get_closest_parent_account` with the field's name. The check already exempted a field the
method computes, but it knew only what the method assigns, `rec.total = ...`, and a helper,
`update()` or `write()` makes no such store. So the field a method produces read as a field it
depends on. `_computed_fields` now maps each method to the fields its file declares with
`compute="<that method>"`, and those count as its output too. A field another method computes is
still an input, and the same field read off a relation, `parent_id.rrule`, is still a recursive
dependency Odoo wants declared.
That does not need the binding to matter. On the trees as they are:
| | before | after |
|---|---|---|
| OCA | 101 | 101 |
| OpenSPP | 163 | **161** |
| odoo core | 2,358 | **2,357** |
All three were read. OpenSPP's two are the pair its flagship classification already marked false
as `self-read`: `spp_area` clears and refills `center_area_ids` through `update()`, and
`spp_service_points` reads the service points it computes. Core's one is
`calendar.recurrence._compute_rrule`, which compares `rrule` with the serialized rule and
`write()`s it when they differ. With the binding, the same exemption took nine of its 87 false
findings on core, the `_compute_account_type` the sweep started from among them. Nothing is
added, and all 288 hand-classified true positives are still reported.
### 3.73 A path through a method's own output, measured and kept exempt
**Measured 2026-09-27.** The depends check never asks a compute to declare the field it
produces, and it used to give the wrong reason: that the ORM refuses it. Odoo 18 refuses
nothing here. A field named in its own `@api.depends` is dropped from its triggers without a
word (`resolve_depends`, "do not make self trigger itself", `odoo/fields.py` 842-846), so that
advice would do nothing. The skip covers only the field's own name, though. A path through it,
`@api.depends("team_id.member_ids")` on the compute of `team_id`, is kept, and the compute
re-runs when the team's members change. The exemption skips every read rooted at the output,
`lead.team_id.member_ids` included, so the part of it that rested on "cannot be followed" was
measured: the exemption narrowed to the bare field, on the three trees.
| | before | bare field only |
|---|---|---|
| OCA | 101 | 101 |
| OpenSPP | 161 | 161 |
| odoo core | 2,357 | 2,394 |
All 37 were read, and every one is the same shape: a compute that keeps its current value
while it still fits and picks a new one otherwise, reading the value it chose earlier to
decide. Declaring the path would re-run it when the record it chose changes.
**17 are true as the rule states it, and 16 of those are smart defaults**, `readonly=False`
computes (Β§3.51). A lead keeps its sales team while its salesperson is a member
(`team_id.member_ids`, `team_id.user_id`), as a lead mining request does. A lead or a task
keeps its stage while the stage belongs to its team or project. A contract keeps its salary
structure type while the type's country matches, and an allocation its accrual plan while the
plan's time off type matches. Maintenance equipment and requests keep their team, and a request
its technician, while they belong to its company. A reconcile model line keeps its journal
while the journal's type matches, a tax its tax group while the group's country and company
match, a product its purchase unit while it shares the unit's category, and a chatbot step the
answers that trigger it while their steps come first. In each, the kept value goes stale when
the configuration it was checked against changes, and in each a recompute would replace a value
somebody may have chosen; Β§3.51 reads that as a decorator kept narrow on purpose. The one plain
true is `sale_project`'s task sale order. The method fills the task's partner only when it is
empty, so the partner is mostly an input, and re-parenting it under another company leaves the
sale order in place.
**20 are false.** In 12, what the output points to cannot change what it is: a variant's
template, the BoM a BoM line or an operation belongs to, a production move's BoM line, a work
order's operation, a packaging's product, a milestone's project, a skill's type, a chatbot
answer's step, a custom value's attribute. In 7, `write()` refuses the change: the company
hierarchy ("The company hierarchy cannot be changed", twice), a picking type's or a location's
company (three), a journal's company once it holds entries, and a base field's relation. The
last acts only when a declared field changes: `hr_holidays` looks at the leave type's
`requires_allocation` only for a leave whose employee has just changed in the form.
So the narrowing reads 17 true to 20 false, and 16 of the 17 re-check a choice the compute made
itself. Β§3.71 took its parts because each added more true findings than false; this adds
fewer. The exemption stays whole, now on a measurement rather than on a premise that was wrong,
and a test pins it. Nothing moves on the three trees, and all 288 hand-classified true
positives are still reported.
### 3.74 A claim written as a list
**Measured 2026-09-27.** `layers.cited_ids` decides what an answer claims to have applied, and
it read the `Rules applied:` line from the colon on. The prompt asks for the ids comma separated
on that line, and the 7B does not always oblige. Re-capturing the README's plain-script sample
(3.75), it wrote the label on its own and listed its four ids under it, one per line. That
answer is a fixture now, `LOAD_PRICES_BULLETED`. Read from the label's line, the list was an
empty claim: the knowledge layer printed "the answer cites no rule ids" under an answer citing
four, and returned UNKNOWN without asking whether any of them exist.
The second half is the one that mattered. On the line, an id with no rule behind it is a
critical finding and a FAIL. In a list it was never looked at, so an invented citation in that
shape went through. `corpus.report_citations`, behind `nexa verify`, kept its own copy of the
parse with the same gap, and calls `cited_ids` now.
The claim is the label's line and, when nothing follows the colon, the list directly under it.
Blank lines before the first item are skipped, and the first line that is not a list item ends
it. A code block is still never a source of citations, and a rule named under `Not handled:` is
still not a claim to have applied it. Tests pin the recorded answer, an invented id in its list,
and where the list ends.
On the check subjects, the first run of 3.75, made before the fix, had 4 of its 15 reviews
citing nothing the parser could read: all four on `load_prices.py`, and all four PASS. The
committed run, made after it, has none. The verdicts did not follow the citations, though.
`load_prices.py` passed on all five committed runs with its citations read, since UNKNOWN on the
knowledge layer never stood in the way of a PASS. What those answers escaped was the check on
what they cited, not a verdict.
### 3.75 The check and retrieval rows, read from their runs
**Measured 2026-09-27.** The README's `nexa check` rows rested on `artifacts/eval-check-2026-09.json` from
2026-09-14. Two of its five subjects, `subjects/web/src/partners.ts` and `board.js`, went with the
TypeScript and JavaScript corpora in 8d155aa, and `nexa check` refuses both now. The rows still
agreed with the artifact, and nothing read the artifact, so nothing said so. The retrieval rows
had the opposite weakness: re-measured by hand whenever a rule changed, with no run committed to
check them against, and 8b165a1 found them a rule behind.
The check run is redone over the three subjects left, five runs each, and committed over the old
one. The old artifact held the second of the two runs the README quoted:
| | 2026-09-14, five files | 2026-09-27, three files |
|---|---|---|
| Reviews | 25 | 15 |
| Findings reported about the files | 23, in 5 of 5 | 10, in 3 of 3, and 3 advisory apart |
| The same findings on every run | 5 of 5 files | 3 of 3 files |
| PASS / WARN / REJECT | 0 / 12 / 13 | 5 / 5 / 5 |
| Same verdict every run | 3 of 5 | 1 of 3 |
| Rules the model cited beyond the checkers | 3 | 1 |
**Two counts in the eval were wrong, and neither was the tool's.** `file_finding_count` dropped
suppressed hits and kept advisory ones, though its comment said live findings only: the advisory
grade came after it. On these files that read 13 where `check` prints 10 and `fieldtest` counts
10, with 3 advisory apart. It counts what `check` counts now, and advisory hits go in a field of
their own that the report prints the way `check` does. The other was the citation parse, 3.74.
Both were found reading the first run, which is why the committed run is the second.
**What the run says.** The deterministic half is what it was: the same ten findings on every
review, and the same ten `fieldtest` finds with no model. `load_prices.py` carries four of them
where it carried eight, since three of the plain-Python rules were narrowed in between (3.47,
3.61). The model
cited one rule beyond the checkers in fifteen reviews, `check-constraint-cannot-cross-tables`,
the rule pinned into every review, and it cited it on the plain script, where there is no
constraint for it to be about. The verdicts spread evenly, and only `load_prices.py` held one for
all five runs: PASS, for an answer that reproduces the file with its defects repaired. The file
itself still exits 1. `--no-model` took 0.42 to 0.52 seconds on the subjects and 0.78 on
`area_core.py`, against 0.26 on 2026-09-14, so the README says under a second.
**figures.py reads both runs now.** `check.*` comes from `artifacts/eval-check-2026-09.json`: files, runs
and reviews, the findings taken once per file, whether they came out the same on every run, the
verdict counts, stability, and citations beyond the checkers. `--check` fails on a row naming a
file the repository no longer has, which is the drift this entry started from, and on files
reviewed unevenly.
`retrieval.*` comes from `artifacts/retrieval-2026-09.json`, the rows of `nexa eval --retrieval --json`,
which now writes a head beside them: the depth it ranked at, the embedder, and the corpus hash
it ranked against. One run at the operating depth answers both published figures, since
`retrieve()` fills its slots in one fixed order, and a separate top-3 run agreed with it on all
28 rules. `--check` fails when a rule is missing from the run and when the hash differs from
`corpus_meta.json`. The ids alone would have caught the drift 8b165a1 corrected, a rule added, and missed
this entry's other change, a rule reworded.
That rewording is `constrains-ignores-dotted-names`, whose title said "silently ignores". Odoo
18 logs "@constrains parameter ... is not a field name" when the model first collects its
constraints (3.70), so the title reads "ignores dotted field names without an error". Retrieval
was measured before and after it: 28 of 28 at top 6 and 26 at top 3 both times, and no rule
changed rank. The rule itself stays second for its own probe, behind the pinned rule, at a
score of 0.700 where it was 0.694.
**Left.** The README's `nexa ask` stability row and escalation row rest on `artifacts/eval-2026-09.json`
of 2026-09-14, and six of its twelve questions were TypeScript and JavaScript: 10 of the 21
escalations and 5 of the 11 rescues. The rows now carry that date and say so. Re-running them
needs the 30B for the escalations, which is a session of its own.
### 3.76 The probes' numbers, committed and checked
**Measured 2026-09-27.** The README said the corpus "currently meets all three" probes, with
99/99 rewrites still caught, 0 of 13 satisfied patterns bypassable and 0 contradictions, and
that "those numbers are regenerated on every build, so a checker cannot quietly weaken". The
build did re-run the probes, but nothing wrote their numbers down, and the sentence had not
moved since TypeScript and JavaScript left the corpus (8d155aa). The figures are 82/82 and
0 of 1. The coverage table had 82/82, corrected by hand in 8b165a1.
The claim was untrue in a second way. `adversarial.py` fails only when a critical rule loses a
rewrite, so a high or medium checker that stopped seeing a rewritten sample would have passed
the build and CI while the README went on quoting the old count.
`adversarial.py all --json` now writes every row the three probes produce: one per rewrite and
whether it was caught, one per satisfied_pattern with the comment it was attacked with, one per
cross-hit with its class, and the corpus hash. There is no timestamp, so two runs over the same
corpus and checkers write the same bytes, and `--check` compares a fresh run with the committed
file, fails on any difference, and names the counts that moved. The report the probes print is
unchanged byte for byte; it is printed from the same rows. `nexa build` writes the file on
every build, CI's corpus job runs `--check`, and figures.py reads `adversarial.*` from it, so
the coverage rows and that paragraph are marked. The README and `nexa help fieldtest` said
"32 samples"; they say one per rule now.
A checker change and a corpus change both fail CI until the file is rewritten, and figures.py
sees a corpus change on its own, from the hash. The coverage table's cross-hit row belongs to
crosscheck, which has no artifact yet, and stays unmarked here; Β§3.85 marks it.
### 3.77 The pipeline rows, re-measured on Odoo alone
**Measured 2026-09-27.** The README's stability and escalation rows rested on
`eval-2026-09.json` of 2026-09-14: twelve questions, six of them TypeScript and JavaScript,
which left the corpus in 8d155aa. 10 of its 21 escalations and 5 of its 11 rescues were on
those. It is re-run over the nine questions `eval.py` asks now, all Odoo, five runs each: 45
pipeline runs in 32 minutes on the 6 GB card, the 30B split 22% GPU and 78% CPU.
| | 2026-09-14, twelve questions | 2026-09-27, nine Odoo questions |
|---|---|---|
| PASS / UNVERIFIED | 50 / 10 | 38 / 7 |
| Same verdict every run | 12 of 12 | 8 of 9 |
| Escalation triggered | 21 of 60 | 18 of 45 |
| Escalation rescued a bad draft | 11 of 21 | 11 of 18 |
figures.py reads `pipeline.*` from the rows, and fails on an answer for a stack the corpus
no longer has, the drift this entry started from, and on rows without the escalation fields,
which is an ask run read as a pipeline run. The README rows are marked, and name `nexa run`,
which is what `--pipeline` measures.
**Two questions moved, and one of them is a finding.** The onchange question, UNVERIFIED five
times on 2026-09-14, now escalates every run and the 30B repairs it every run. The
negative-quantity question went the other way, PASS five times then and UNVERIFIED five times
now. Both models answer it with an `@api.constrains` comparing the quantity to zero, and
`prefer-sql-constraint` flags that shape: graded advisory in 3.38, a preference and not a
defect. The semantic layer reports the finding and keeps it out of its verdict, as 3.38 says
it must. The domain layer checks the same rule and lets it decide, and `worth_escalating`
counts it either way. So the draft escalates on a preference, the 30B writes the same
constraint, and the run ends UNVERIFIED.
The evidence bundles of the run (local, not committed) say how far that reaches. Of the 18
escalations, 10 were triggered by an advisory finding and nothing else: the five
negative-quantity runs, none rescued, and the five runs of the positive-amount question, all
five counted as rescued when the 30B's answer happened not to trip the rule. Without them the
rescue rate is 6 of 8. The eval rows record whether a run escalated, not why, so the README
row keeps 11 of 18 and this entry keeps the reason.
3.38 says an advisory finding gates nothing and nothing fails because of one. The pipeline's
own tests say otherwise: their escalation example is `NEGATIVE_QTY`, "a single-column bound
that belongs in a CHECK", written before 3.38 graded the rule. Which of the two holds is a
decision about the design rather than a bug in either, so nothing here changes it, and the
numbers above are what the tool does today.
Decided 2026-10-01: Β§3.38 holds. Β§3.81 makes the answer layers agree with it, and re-measures
both rows.
### 3.78 An attribute read straight off a call
**Measured 2026-09-28.** Β§3.71 left `rec.sudo().partner_id.name` unread: `_attr_path` stopped
at a call. `_resolve_iterable` already knew which calls hand back records of the same path,
`sudo()`, `with_context()`, `filtered()`, `sorted()` and `exists()`, because a loop's iterable
goes through it, so a chain whose base is a call now resolves through that same list. A loop
and a read cannot disagree about it. `mapped()` stays out, because it returns values, and so
does any other call, `browse()` and a method on the record included.
The three trees were scanned with the committed checkers first, and each scan reproduced its
artifact hit for hit, suppressed and advisory findings included. Then with the change:
| | before | after |
|---|---|---|
| OCA | 101 | **102** |
| OpenSPP | 161 | 161 |
| odoo core | 2,357 | **2,366** |
All ten are the depends check, as Β§3.71 sized them: core's nine through `sudo()` (six) and
`with_context()` (three), OCA's one through `filtered()`. Nothing disappears, and no other rule
moves: `depends-must-not-name-unread-fields` already steps aside for any body that calls a
method on its record, and `rec.sudo()` is one.
Every addition was read. **OCA, 1, true**, now in `artifacts/classification-oca-2026-09.json`.
`account_check_deposit` stores its in-hand check account from the `payment_account_id` of the
journal's manual inbound payment method line, read through `filtered()`, and declares only the
journal. A deposit drafted before that account is set stays empty after it is set, and
`get_all_checks` goes on raising the error that tells the user to set it. The sibling finding
on the same line, the payment method lines themselves, was already read true.
**Core, 9: 4 true, 5 false.** The true ones:
- `account.move`'s partner display name, for a move with no partner and no source email, is
"#Created by:" and `move.sudo().create_uid.name`, and declares neither. Renaming the user
leaves every such bill on the old name. `create_uid` itself is set once, at creation, and
is true as the rule states it, as Β§3.66 counted `response_code`.
- `l10n_sa_edi`'s QR code reads the journal's production CSID certificate and declares the
CSID JSON. True as the rule states it: in core the certificate is written in one place, on
the line after the JSON, so the declared field always moves with it.
- `loyalty.reward`'s description names the product through `with_context()` and does not
follow a rename, on a `readonly=False` default. What Β§3.66 said of `incoterm_location`
applies.
The false ones are classes the check already has for attribute reads:
- storedness in another module, three. `pos_sale` reads its order's POS lines through `sudo()`
in its overrides of `_compute_amount_to_invoice` and `_compute_amount_invoiced`, and `sale`
declares both fields without `store=True`, so they never go stale.
- covered through another declared compute, one. `certificate`'s private key reads `content`
and declares `pem_certificate`, a stored compute on `content`; Odoo builds its triggers from
transitive dependencies, so writing `content` already recomputes the key.
- a value kept on purpose, one. `purchase`'s line reads the product's display name in each
vendor's context only to recognise whether the description is still a default ("record
product names to avoid resetting custom descriptions"), and a rename would not change it.
So 10 findings, 5 true: an even split, where Β§3.71's parts each added more true than false and
Β§3.73 declined a narrowing that added fewer. It is taken on Β§3.71's other argument, which is
the deciding one here. Every false finding is what the check already says when the same field
is read as an attribute, and keeping `sudo()` silent would not fix any of them, only hide them
behind a spelling. Three of the five are the storedness class that tree facts would settle.
The hand-classified true positives are now 289, and all 289 are still reported.
### 3.79 A read through mapped(), measured and not taken
**Measured 2026-09-28.** `ondelete-is-a-decision` fires on a Many2one without `ondelete` that
its file reads through, and `_dereferences_of` counts only `<x>.<field>.<attribute>`. Β§3.66
taught the depends check that `x.mapped("a.b")` reads `x.a.b`, and this check never learned it,
so `self.mapped("partner_id.lang")` was not a read through `partner_id`. It fails its own way
once the parent is deleted: the attribute yields False, and `mapped()` leaves that record's
value out of what it returns.
The read was built, a literal `mapped("<field>.<attribute>")` counted beside the attribute
reads and worded its own way, and measured against the committed artifacts, which the checkers
of Β§3.78 made:
| | before | after |
|---|---|---|
| OCA | 102 | 102 |
| OpenSPP | 161 | 161 |
| odoo core | 2,366 | **2,367** |
Nothing disappears, and no other rule moves. The change saw thirteen such reads. OCA has none
through a field the check considers. OpenSPP's four and eight of core's nine are on fields an
attribute read already reports, and a finding names the attribute read when it has one, so
those findings read as they did.
The one it adds is false. `stock.location`'s parent, `location_id`, declares no `ondelete`, and
the read is `children_quants.mapped('location_id.display_name')`, in the error that refuses to
archive a location still holding stock: a quant's location, not a location's parent. The check
places a read by the field's name and not by the model it is read on, and five of the thirteen
reads are another model's field of that name. Besides the quant's, `pos_session` reads the
`move_id` of bank statement lines it has just created, twice, and `sales_team` the `user_id` of
team members, twice.
Attribute reads are placed the same way, and none of this rule's 41 hand readings, 12 on OCA,
14 on OpenSPP and 15 on core, was false for that reason. Β§3.71 and Β§3.78 took a spelling whose
false findings were classes the check already had for attribute reads. This one adds a single
finding, false, of a class no reading of this rule has met, and nothing true. So it is not
taken, and a test pins that. What would make `mapped()` worth reading is placing each read on
the model it is read on, for both spellings, which is a change of its own and is named here
rather than built. The hand-classified true positives are still 289, and all 289 are still
reported.
### 3.80 A name assigned records, measured and not taken
**Measured 2026-09-28.** Β§3.71 named the gap with OpenSPP's approval tiers: `pending =
review.tier_review_ids.filtered(...).sorted("sequence")[:1]`, then `pending.tier_id`, reads
nothing past the name, because `_aliases` binds loop variables only. A loop over such a name is
the same gap one step on: `moves = self.filtered(...)`, then `for move in moves:`.
The binding was built. A name is bound when every value the function assigns it resolves to one
path, the test `_held_recordset` already makes for `sudo()`. A value naming the name itself,
`lines = lines.filtered(...)`, keeps the path the name held. A name bound any other way as well,
as a parameter, a loop target, a `with`, an unpacking or a walrus, stays unbound. Measured
against the committed artifacts:
| | before | after |
|---|---|---|
| OCA | 102 | **107** |
| OpenSPP | 161 | **169** |
| odoo core | 2,366 | **2,480** |
By part: the depends check adds 5, 7 and 113, the constrains check 0, 1 and 5. Core also loses
four. Two are the event findings Β§3.72 showed the method never reads, the `_origin` paths
through the event type's mail lines: a comprehension over `mails_to_remove` now binds `mail` to
the event's own lines, which are what the lambda filters. The other two are
`depends-must-not-name-unread-fields` stepping aside, once because the body now reads
`commercial_partner_id.is_company` through `p`, which the depends check then reports, and once
because `len(project)` hands a record to a call, as `len(self.project_id)` always did. Five
more findings only moved to an earlier line, which changes no count.
Every addition was read. **OCA, 5, all true**, all smart defaults: `account_check_deposit`'s
currency and `account_spread_cost_revenue`'s template journal and accounts follow the company's
setting, read through a name assigned the record's company. The same reads spelled as
attributes in `account_spread.py` are already classified true.
**OpenSPP, 8: 4 true, 4 false.** Β§3.71's own example is true as nothing rewrites it: the tier
review form leaves `tier_id` editable, and the review's stored current tier keeps the old one.
`spp_vocabulary`'s mapping stores its display name from both codes' `code`, and `write()`
refuses a rename only for a system vocabulary's shared codes. Two findings, one per code. The
fourth is the constrains check: `spp_claim_169` allows one default issuer per company, checks
it when `is_default` is written, and never when a default issuer moves to a company that has
one. The false ones are `spp_registrant_gis`'s `geom = rec.coordinates`, whose `geom.x`,
`geom.y` and `geom.is_empty` are attributes of a geometry and not fields, and a read in
`spp_case_programs` that feeds only the method's non-stored output.
**Core's depends, 113: 37 true, 76 false.** The false ones:
- 27 keep a value on purpose, visible in the code: a compute that fills its field only while it
is empty or the record is new, a journal item's label rebuilt only while it still matches its
product, a lot's dates offset from the product only "when create", a manufacturing order
left on its product while the BoM that changed marks it outdated.
- 17 compute a field another module declares without `store=True`.
- 11 are covered from outside the method: a declared field that is itself a stored compute on
the read. A statement line's `internal_index` embeds its date, and a journal item's `debit`
and `credit` compute from `balance`.
- 11 name a field the model refuses to change or fixes at creation: a picking type's company, a
country's ISO code, a POS order's company.
- 5 are declared by another module's override of the same compute, which Odoo merges.
- 5 are one of a kind. `res.partner.write()` rewrites the commercial partner of every move by
hand, three expense amounts can only change after the reinvoiced line built from them is
zeroed and replaced, and one read sits in a fallback the form never lets run.
Of the 37 true, 28 are smart defaults, 4 read a field nothing in core rewrites, and 5 are a
stored value that goes stale: a stock move's weight when its product's weight is edited, a
task's working-time figures when it moves to another project or its company's calendar changes
(three findings), and an own-expense bill's commercial partner when the report's payment mode
changes.
**Core's constrains, 5: 2 true, 3 false.** `stock.rule` checks that its company matches its
route's, and `stock.route` checks it from its side, each only when its own `company_id` is
written, so a rule moved to another company's route is checked by neither. The other true one is
the GS1 barcode rule Β§3.72 read. Two false ones are covered through a declared stored compute:
Odoo validates the constraints of every field it recomputes (`_compute_field_value`,
`odoo/models.py` at 3c3e3b3), so writing a sale order's company recomputes `warehouse_id` and
runs `_check_warehouse`, and a move's `product_uom` follows its product the same way. The third
names a field `write()` refuses to change on the online payments it covers.
Β§3.71 took each part because each added more true findings than false, and Β§3.78 took an even
split. Core's depends additions read 37 of 113, the ratio Β§3.72 declined for the lambda binding
(40 of 127, 28 of them smart defaults then too), and OCA's five and OpenSPP's three cannot
carry that. The constrains additions read 3 of 6. So the binding is not taken, and a test pins
that.
The new binding never gave a name a path other than the one it holds. Two things were met on the
way that matter when this is revisited:
- **A name can hold a value that is not records.** OpenSPP's `coordinates` is a
`GeoPointField`, declared in the same file, and the declaration would have said so.
- **One map per function.** `_aliases` binds a name once for the whole method. `pos_sale`'s
`_compute_qty_invoiced` iterates `pos_line` over its POS lines and then over their refunds,
so the first read took the second binding. Β§3.72 met the same thing with a lambda's
parameter; binding assigned names reaches it more often. Two of the constrains findings came
in that way too, a lambda parameter sharing a name with a loop over a name now bound, and on
the same records by accident, as Β§3.72 found that spelling usually is.
What would separate true from false is what Β§3.72 found. Facts from outside the file decide 33
of the 76: storedness, merged decorators, a declared compute that already covers the read. The
keep-once shapes, 27, are mostly visible in the method itself, a guard on the field it computes
or on whether the record is new, and are a narrowing of their own. The hand-classified true
positives are still 289, and all 289 are still reported.
### 3.81 Advisory gates nothing in the answer layers either
**Decided and measured 2026-10-01.** Β§3.77 left a question about the design open: Β§3.38 says an
advisory finding gates nothing, and the answer layers let one decide. Three answers were on the
table. Advisory decides neither a layer's verdict nor escalation; it escalates but never ends a
run UNVERIFIED; or it keeps deciding and Β§3.38 is amended. The first is taken. It is what Β§3.38
already says, and it never spends a 30B pass on a preference.
**It reached further than Β§3.77 counted.** Β§3.77 named the domain layer's verdict and
`worth_escalating`. Reading the code for the change found two more:
- `decide` combines the layers by reading their findings, not their verdicts. So the semantic
layer's setting-aside, the one answer-side place Β§3.38's table names, never reached the verdict
printed at the end: `decide` counted the finding the layer had set aside, and would have
printed WARN on a preference with the domain layer fixed. An answer whose only critical
finding was advisory was printed REJECT, which is `nexa ask` exiting 1.
- The answer layers read the rule's flag alone. `scan_source` also reads the grade a trigger
gives one finding: the touch and escape clauses of `sudo-bypasses-record-rules` grade theirs
`unproven` (Β§3.47), and `sql-constraints-are-ignored-from-19` grades a bare snippet `unproven`,
since nothing was walked to say which series it is for (Β§3.62). `check` set those findings
aside in a file, and the answer layers let the same findings decide in an answer, critical
ones included.
**The finding carries the flag now.** `Finding.advisory` is set by one function,
`is_advisory(rule, grade)`, which `scan_source` calls too, so a file and an answer cannot
disagree about which findings count. The domain layer has one finding per rule, and it is
advisory only when every hit behind it is: a `sudo().write()` beside a `sudo().search()`
decides, because the search does. `render` labels an advisory finding "advisory, gates nothing",
and a PASS printed under findings says it found nothing but advisory ones. Telemetry still counts
them under `violated`, because reported everywhere means there too.
| | an advisory finding |
|---|---|
| `check` | its own heading, and out of the exit code |
| `fieldtest` | counted apart from the per-kloc rate |
| the semantic layer | recorded, and the verdict does not follow it |
| the domain layer | recorded, and the verdict does not follow it (new) |
| the decision | no reason for WARN or REJECT (new) |
| escalation | never sends a draft to the 30B (new) |
**The tests' escalation example moved.** `test_pipeline` escalated on `NEGATIVE_QTY`, written
before Β§3.38 graded `prefer-sql-constraint`. It now escalates on the onchange question, recorded
from one `nexa run` on 2026-10-01: the 7B refuses the date in an onchange
(`onchange-is-not-validation`, critical), and the 30B answers with `@api.constrains`. Both
answers are fixtures verbatim. `NEGATIVE_QTY` stays as the test that an advisory draft never
loads the 30B, and each place above has a test of its own.
**Both runs were redone**, since both sets of verdicts carried the effect. `nexa eval --check`
first, the three subjects five times each:
| | 2026-09-27 | 2026-10-01 |
|---|---|---|
| Findings reported about the files | 10, in 3 of 3, and 3 advisory apart | the same |
| The same findings on every run | 3 of 3 files | 3 of 3 files |
| PASS / WARN / REJECT | 5 / 5 / 5 | 4 / 7 / 4 |
| Same verdict every run | 1 of 3 | 0 of 3 |
| Rules the model cited beyond the checkers | 1 | 0 |
The change decides one of the fifteen: an `fsm_job.py` review whose only critical finding was a
`sudo()` call graded `unproven` on its own is WARN, where the old rule printed REJECT. No other
verdict in the rows rests on an advisory finding alone. The rest is the model's own spread at
temperature 0.15. `load_prices.py` gave up its five PASSes to one WARN from the structural
layer, which reads the shape of the answer and not its code, and `fsm_visit.py` drew one REJECT
fewer. `fsm_job.py` shows the same two REJECTs and three WARNs as before only because the change
took one REJECT back.
Then `nexa eval --pipeline`, the nine questions five times each. It took 20 minutes where
Β§3.77's took 32, nine passes on the 30B fewer:
| | 2026-09-27 | 2026-10-01 |
|---|---|---|
| PASS / UNVERIFIED | 38 / 7 | 43 / 2 |
| Same verdict every run | 8 of 9 | 8 of 9 |
| Escalation triggered | 18 of 45 | 9 of 45 |
| Escalation rescued a bad draft | 11 of 18 | 7 of 9 |
The evidence bundles carry each finding's flag now, so every draft can be read against both
rules. Under the old one these 45 drafts escalate 19 times, under this one 9, and the ten that
went are Β§3.77's ten exactly. The negative-quantity question's five runs end PASS, where all
five ended UNVERIFIED, and the positive-amount question no longer counts five rescues of a
preference. Every escalation left is on a critical finding a larger model can act on: the
onchange question five times, all five repaired, and the monetary question four times, two
repaired, each a stored compute reading a field it does not declare. 7 of 9 sits beside the 6
of 8 Β§3.77 counted without the advisory escalations, and the monetary question's spread is the
difference.
The eval rows still record whether a run escalated and not why, so `figures.py` cannot count
advisory escalations from them (Β§3.77). There are none left to count, and a test pins that.
Nothing here changes a checker. `scan_source` calls the shared function where it computed the
same thing inline, and a rescan of the two OCA checkouts reproduces their artifact hit for hit,
116 of 116. So the fieldtest artifacts stand, and the hand-classified true positives are 289 of
289, all still reported.
### 3.82 A field the constraint cannot name
**Decided and measured 2026-10-01.** `constrains-must-list-every-field-read` asks a constraint to
name every field its body reads, and Odoo will not let it name some of them. A field that is
neither stored nor inversible is never among the fields a write hands to `_validate_fields`, so a
constraint naming it never runs for it, and `_constraint_methods` logs "@constrains parameter ...
is not writeable" and does nothing more (odoo/models.py, 18.0 at 3c3e3b3). Β§3.71 met one such
finding, Β§3.72 counted eleven, and the README's OpenSPP reading carried one as false: the gap was
real, and the fix it named could not be applied.
The choice was between exempting those findings and asking for what the field is computed from.
Exempting them all would have hidden every real gap among them, so the finding asks for the
inputs, and only a constraint that already names every input is left alone.
**Read from the declaration.** The checker reads the fields the constraint's own class declares
(`_class_fields`). Whether one can be named is Odoo's own test, three-valued (`_writeable`): a
computed field is unstored unless `store=True` and inversible only with an `inverse=`, and a
related field is unstored and readonly unless it says otherwise and gets an inverse only when it
is not readonly (`_get_attrs` and `setup_related`, odoo/fields.py at 3c3e3b3). A field declared
elsewhere, or declared with anything but a literal, is unknown and changes nothing. What can
trigger the check is a write of what the field is computed from: the roots of its compute's
`@api.depends`, or the first field of its related path, followed through any of those that
cannot be named either (`_unwriteable_inputs`). A dotted dependency is reached through its first
field, as a dotted read always was, since `@api.constrains` names fields of its own model only.
When the compute is not in the class, or declares nothing a static pass can read, the finding
stays and says the field cannot be named, rather than asking for it.
**Measured.** Each tree reproduced its artifact hit for hit with the committed checkers first,
core from a clean export of HEAD. Then:
| | before | after |
|---|---|---|
| OCA | 102 | 102 |
| OpenSPP | 161 | 161 |
| odoo core | 2,366 | **2,358** |
Core loses eight, each a constraint that names every input of the field it reads: an invoice's
`tax_country_id`, an event's and an event ticket's `seats_available`, a task's `subtask_count`, an
orderpoint's `product_uom`, a quiz question's `correct_answer_id`, a product image's
`embed_code`, and an account report value's `report_country_id`. That last one is related
through `target_report_line_id`, itself related through the `target_report_expression_id` the
check names.
Ten more on core and three on OpenSPP stay, each now told the input it is missing instead of a
field it cannot name. A leave's mandatory-day check names its dates and reads `has_mandatory_day`,
computed from them and from `holiday_status_id`, so a leave moved to another type is not checked
again. The POS config's two currency checks read a currency computed from `company_id` and
`journal_id`. The payment capture wizard's three reads come from `transaction_ids`, the refund
wizard's from `payment_id`, and the Indian withholding wizard's from `withhold_id`. The activity
scheduler's `error` is computed from seven fields and its check names six, leaving out
`plan_available_ids`, a stored compute, which can be named. One is unknown:
`account.withholding.line` leaves `_compute_comodel_currency_id` to the models that inherit it,
and the finding says `comodel_currency_id` cannot be named. On OpenSPP, `spp_case_base`'s stage
check reads `has_active_plan` and is told to name `intervention_plan_ids`, and `spp_drims`'s
delivery wizard reads two fields computed from `move_id`.
**One verdict moved.** `has_active_plan` was the OpenSPP reading's one `advice-cannot-be-taken`,
false because the fix it named could not be applied. The same method's read of
`intensity_level` was read true, a stage check that does not run again when what it checks
changes, and with its advice now one that can be taken this finding reads the same. The two rules
read 88 true, 14 false and 3 undecidable on OpenSPP, 86% where they read 85%, and the
hand-classified true positives are 290, all still reported. One core reading leaves the count:
`event`'s sold-out message, false and unreported since Β§3.58, which `figures.py` had matched to the
`seats_available` finding three lines above it. With that finding exempt the row matches
nothing, and core's read findings are 88 where they were 89.
**Against the count of 2026-09-28**, which found 23 (8 to exempt, 14 missing an input, 1 unknown)
where there are 21 now (8, 12, 1): that count did not ask about `inverse=`.
`mrp.production`'s `move_byproduct_ids` and `ir.ui.view`'s `arch` have one, so Odoo runs a
constraint naming them, and both stay findings asking for the field, as before.
`report_country_id` moved from missing an input to exempt by following its related input through,
and `plan_available_ids`, which that count took for non-stored, is stored.
**Not added.** A constraint that names an unwriteable field of its own class, and so never runs
for it, is a shape none of the three trees has, so nothing is added for it. Each behavior above
has a test: covered, one input missing, stored or inversible as before, related through its
first field with and without `readonly=False`, followed through an unstored input, and the
compute that is not in the class.
### 3.83 Facts from the tree for the depends check
**Built and measured 2026-10-01.** Β§3.72 proposed it, and Β§3.71, Β§3.78 and Β§3.80 kept counting
what it would decide: findings of `stored-compute-needs-complete-depends` that are false for a
reason no single file shows. Three of those reasons are facts about declarations, and the tree a
fieldtest walks holds every declaration:
- **A, a compute nobody stores.** One whose field another module declares without `store=True` is
outside the rule, as one declared so in its own file always was. Β§3.78's three `pos_sale`
overrides of `sale`'s amounts were this, and 17 of Β§3.80's 76.
- **B, merged depends.** Odoo merges `@api.depends` from every definition of the method in the
model's MRO (`get_depends` and `resolve_mro`, odoo/fields.py at 3c3e3b3), so a dependency
another definition declares is declared. Β§3.71's "depends merged from `sale_project`" was this,
and 5 of Β§3.80's.
- **C, transitive triggers.** Every field with dependencies, stored or not, feeds the trigger
table, every prefix of a dotted dependency counts as a dependency of its own, and the trigger
tree is the transitive closure of both (`resolve_depends` in odoo/fields.py, `_field_triggers`
and `get_field_trigger_tree` in odoo/modules/registry.py, all at 3c3e3b3). So a declared field
that is computed or related covers what it depends on in turn. Β§3.78's certificate key was
this, and 11 of Β§3.80's.
What `write()` refuses stays undecidable from declarations, as Β§3.72 said, and nothing here tries it.
**What the walk records.** `context.tree_facts` now reads every Python file's ORM declarations
(`semantics.orm_declarations`): per class, the model (its `_name`, or a single `_inherit`, as
Odoo names it), the models it inherits, each field's compute, related path, store, inverse and
comodel, and each method's literal `@api.depends`. Every declaration keeps the module it ships
in, the directory holding its manifest, and every manifest's `depends` is read, so the checker
can ask which modules are installed whenever the one in front of it is (`context.module_closure`).
fieldtest hands each file the facts with its module (`context.for_file`), and `nexa check` walks
the module it was given, as before. A file over 200,000 characters is read whole for this:
`account_move.py`, `account_tax.py` and `mail_thread.py` are longer and would not parse cut
short. The older facts keep the limit they had, so nothing they decide moves.
**Each answer only where it holds.** A needs a complete walk that names the model and everything it
inherits with `_name`, since "no declaration stores it" is true only of everything read. One
`store=True` anywhere keeps a field stored, and `compute=` without `store=True` leaves it
unstored whatever an earlier declaration said (`_get_attrs`). `nexa check` walks one module and
never applies A. B takes only the definitions in the module itself and in the modules it depends
on, transitively: a sibling that overrides the same compute is not always installed with it. C
searches from the declared paths toward the read. A step replaces the rest of a path with what
the field there depends on and keeps the prefix that reached it, so a step where the path has
already left the read's can never come back to it, and is not taken. Every path met is then a
prefix of the read followed by one dependency some field declares, so the search ends without a
limit. The first version walked forward with a limit of 400 paths, reached it on core, and gave
different findings under different hash seeds. The search toward the read gives the same
findings every run, checked under three seeds.
**Measured.** The scans of the three trees and a rerun of the checker on every file with a depends
finding, once with no part, once with each part alone and once with all three, agree finding
for finding. With no part, the rerun reproduces the artifact's depends findings exactly.
| | before | after | A alone removes | B alone | C alone |
|---|---|---|---|---|---|
| OCA | 102 | **101** | 0 | 0 | 1 |
| OpenSPP | 161 | **158** | 0 | 2 | 1 |
| odoo core | 2,358 | **2,207** | 50 | 16 | 96 |
On core the parts overlap: 42 findings only A removes, 11 only B, 86 only C, 11 more that two or
three of them remove alone, and one that B and C remove only together, where B brings in a
journal the parent definition declares and C follows it to its company. Nothing is added.
**Every removal was explained and checked against the code.** The evidence for each of core's
151 was printed from the same facts: for A, every declaration of the computed fields with its
module and store; for B, the definitions whose depends cover the read and their modules; for C,
one chain from a declared path to the read. All 151 have one, and the four on OCA and OpenSPP
were read in full. The A fields are values no module in core stores, `account.move`'s
`needed_terms` and `show_reset_to_draft_button`, an employee's `hr_presence_state`, `sale`'s
`amount_to_invoice` under `pos_sale`. Each B merge comes
from a module the reader's depends on, `purchase` under `purchase_stock`, `sale_margin` under
`sale_stock_margin`, `point_of_sale` under `pos_sale`. The C chains are Odoo's own, a journal
item's currency following its move's, a timesheet's cost following its employee's
`hourly_cost`, a task's project following its parent's. The longest is mrp's: a production's
`state` depends on `move_finished_ids.state`, `move_finished_ids` on `date_finished`, that on
`workorder_ids.duration_expected`, and that on the work order's `operation_id`, so a change of
operation marks the production for recompute through four declared hops. Those were read in the
source, with a spread of the others.
**Three readings were wrong.** No core finding that went had been hand-classified, true or false.
Three elsewhere had been read true, and the facts that removed them show they were false:
- OCA's `account_spread_cost_revenue`: `_compute_all_posted` reads `currency_id` and declares
`unposted_amount`, which `_compute_amounts` computes with `currency_id` among its depends. A
change of currency recomputes `unposted_amount` and then `all_posted`. The same line's read of
`currency_id.rounding` is still reported and still true: nothing declares the rounding.
- OpenSPP's `spp_cel_event`: the override of `_compute_cel_expression` reads `source_type` and
`aggregate_target`, which `spp_cel_domain`'s definition declares, and `spp_cel_event` depends on
`spp_cel_domain`. Odoo merges the two lists.
All three were read before Β§3.59 found the merge and before anyone asked about transitive
triggers. Each row says so and why, and keeps its place: OCA's two flagship rules read 98% and
100% where they read 100% and 100%, OpenSPP's 84% where it read 86%. The hand-classified true
positives are 287, and all 287 are still reported.
**One fault in `figures.py` came out on the way.** It matched a classification row to a finding
by rule, path and line alone, so two findings on one line were interchangeable, and once the
`currency_id` finding went, its row could take the `currency_id.rounding` finding and leave that
true row reading lost. A row that carries its finding's words is now matched to a finding with
those words first, and line alone decides only what is left. Run over the committed artifacts
it changes nothing: 289 of 289, the same four re-anchored.
**Left.** `nexa check` walks only the module it is given, so a merge or a chain through another
module is invisible to it: `spp_cel_event`'s two findings still show there. Walking the closure
of the module's manifest, where the dependency modules sit beside it, would bring B and C to
`check`; it was not built here, and Β§3.84 builds it. And Β§3.72's lambda binding and Β§3.80's name binding were declined on
classes this decides in part, so both are worth measuring again on top of it. Β§3.87 measures
them.
### 3.84 `check` reads the modules its module depends on
**Built and measured 2026-10-01.** Β§3.83 left `nexa check` walking only the module it is given, so
the merged `@api.depends` (B) and the transitive triggers (C) it brought to the depends check could
not reach a definition or a field in another module. `spp_cel_event`'s two findings, which
fieldtest no longer reports, still showed there.
**What it reads now.** `module_facts` also reads every module the file's manifest depends on,
transitively, that sits beside its module, as the modules of an addons directory do
(`_dependency_sources`). Each of them is installed whenever the module is, which is the test B and
C already apply. They are read for their ORM declarations and their manifests' `depends` alone
(`tree_facts` takes them as `dependencies`), and test directories are left out, as fieldtest's
trigger walk leaves them out. Every other fact stays the module's own, and the walk stays
incomplete, so A, which needs every declaration, still never applies in `check`. A dependency
installed anywhere else is not looked for: Odoo core keeps `base` apart from its other modules,
and OCA's modules depend mostly on core's.
**Sized first.** Before anything was written, the closures were counted on the three trees. OCA's
reach 18 Python files, since its modules depend mostly on core. OpenSPP's reach 714. Core's reach
1,044, and 445 of its 656 modules depend on more than 400 files, the cap the module walk itself
has. The read is capped at 1,000 Python files, about two seconds of parsing, past which `check`
reads nothing beyond the module and has the facts it had before. In the three trees that is one
module, core's `test_event_full`. Parsing is most of the cost, and `orm_declarations` now parses
without the compile step `parse_python` adds, whose errors it never read: the same records,
measured over two closures of core, in about three quarters of the time.
| `nexa check --no-model`, median of five runs | before | after |
|---|---|---|
| `spp_cel_event`'s aggregation model, 183 files beside | 0.55 s | 0.79 s |
| `sale_stock`'s account move, 598 files beside | 0.85 s | 2.11 s |
| `website_sale_collect`'s payment transaction, 823 files beside | 0.32 s | 1.87 s |
| OCA `auditlog`'s autovacuum, nothing beside | 0.40 s | 0.40 s |
**Measured.** `check` was run on every file fieldtest reviews in the three trees, through the call
`check` makes (`module_facts`, then `scan_source`), from clean exports of the code before and
after. Each module's facts were computed once and handed to its files with `for_file`, which is
what `check` computes for each of them.
| hits `check` reports, suppressed and advisory included | before | after |
|---|---|---|
| OCA | 116 | 116 |
| OpenSPP | 519 | **517** |
| odoo core | 2,810 | **2,773** |
Nothing is added, and nothing moves for another rule. OpenSPP's two are `spp_cel_event`'s. Core's
37 are in 22 modules, each reading through a module it depends on. A merged definition alone
answers 8, a chain of triggers alone 9, either of them 15, and the two only together 5.
`sale_stock`'s delivered quantity reads the method `sale`'s definition declares,
`l10n_latam_check`'s destination account reads the partner `account`'s declares, and
`sale_stock_margin`'s purchase price reads three fields `sale_margin`'s definition declares; nine
localizations make up 14 of them. Each is one of the 151 Β§3.83 removed and explained.
**Against fieldtest.** On OCA and OpenSPP, `check` and fieldtest now report the same depends
findings. On core 38 differ, all in one direction and all one class: a compute whose field no
declaration in the tree stores, which only a walk of the whole tree can say (A). Run again with
the whole tree's facts marked incomplete, each of the 38 comes back. Of the 151 findings Β§3.83
removed on core, `check` drops the other 113, 37 of them since this change. The other rules still
differ between the two where their facts need the whole tree, record rules and deletes, as they
did before this.
**fieldtest is unchanged.** It passes no dependencies, and the parse gives the same records. A
rescan of the three trees reproduces each artifact hit for hit. Each behavior above has a test: a
dependency beside merged, a sibling it does not depend on left unread, a dependency's dependencies
read and its tests not, the cap, and a dependency elsewhere or a name that is not a module name
not followed.
**Left.** A module whose dependencies are installed elsewhere, as OCA's on core usually are, gets
none of them; reading an addons path a person names would reach them. And the read is made for
every file, while only a file with a depends finding left can use it: reading on demand would
spare the seconds on most runs. Β§3.86 builds both.
### 3.85 The cross-hit row, read from the probes' rows
**Built 2026-10-01.** The coverage table's "0 cross-hits across the corpus" was its last row
without a marker. Β§3.76 committed the probes' rows and left this one to crosscheck, which writes
nothing down. Reading the two commands to give crosscheck an artifact of its own showed the rows
were already committed. The contradiction probe fires every checkable rule at every other rule's
correct sample, over the same rules and through the same `layers.fires`, and keeps one row per
cross-hit before it classifies it. Its rows are crosscheck's list with a class beside each.
So there is no second artifact. What a cross-hit is has one definition now, `layers.cross_hits`,
which crosscheck lists and the probe classifies, so the two cannot drift apart. `figures.py`
counts the probe's contradiction rows as `adversarial.crosshits`, and the row is marked. CI's
corpus job already re-runs the probes against their committed rows, so a pattern that starts to
fire on another rule's correct sample fails CI until the rows are rewritten, and the README's
count moves with them. crosscheck prints what it printed before, byte for byte, the probes
reproduce their artifact, and a test plants a cross-hit and finds it in both.
### 3.86 The modules a module depends on, read when asked and where named
**Built and measured 2026-10-01.** Β§3.84 left two things. `check` read the modules beside the one
it was given for every file, while only a file with a depends finding left could use them, and it
looked for them beside the module only, so an OCA module, whose dependencies are core's and sit in
another directory, got none of them.
**Read when asked.** `module_facts` no longer reads the dependency modules itself. It leaves a
loader in the facts (`with_dependencies`), and the depends check calls it only for a read the
module's own declarations leave uncovered, then asks again about those reads alone. More
declarations can only cover more, so the reads already covered need no second look and the
findings are the eager read's exactly. The loader runs once per module, adds the dependency files'
declarations to what the module walk already read (`_with_declarations`, from the `orm_inputs` the
facts now keep), and reads nothing of the module twice. fieldtest's facts carry no loader: its walk
had the whole tree already.
**Where named.** `nexa check --addons-path <dir>[,<dir>...]` names where else the dependencies are
installed, spelt and written as Odoo's own option is. A dependency is looked for beside the module
first, then in each named directory in turn, the first holding it winning, as Odoo's own does. A
named directory that is not there is a bad argument, exit 2, since the modules it was meant to
reach would otherwise go unread without a word. Sized first: with core's two addons directories
named, OCA's closures reach 680 Python files, under the cap of 1,000. Both trees are 18.0, as the
core checkout is; OpenSPP is 19.0, and no core here is its own.
**Measured.** `check` was run, as Β§3.84 ran it, on every file fieldtest reviews in the three trees,
from clean exports of the code with the eager read and with the read on demand. The two report the
same findings on all three, hit for hit. The dependency modules are asked for in 13 of OCA's 98
modules, 23 of OpenSPP's 131 and 121 of core's 657; every other module never reads them.
| `nexa check --no-model`, median of five runs | read for every file | read when asked |
|---|---|---|
| `spp_cel_event`'s aggregation model, which needs them | 0.93 s | 0.93 s |
| `sale_stock`'s account move, which needs them | 2.56 s | 2.79 s |
| `website_sale_collect`'s payment transaction, which does not | 2.10 s | 0.35 s |
| OCA `auditlog`'s autovacuum, nothing beside | 0.41 s | 0.42 s |
`nexa help check` said `--no-model` runs in under a second and gives the list fieldtest gives. It
says usually under a second now, a second or two more when a depends finding needs the modules
beside, and the list fieldtest gives but for a finding only a walk of the whole tree can rule out
(Β§3.84).
**OCA with core named.** Run with core's addons directories as its addons path, `check` on OCA
reports one finding fewer, 116 hits to 115, and adds none. `account_cash_deposit`'s
`_compute_is_reconcile` declares `move_id.line_ids.reconciled` and reads each line's `account_id`.
`reconciled` is the journal item's stored compute `_compute_amount_residual` in core's `account`,
which depends on `account_id` (odoo/addons/account/models/account_move_line.py at 3c3e3b3), and
Odoo's triggers are transitive, so a change of a line's account recomputes `reconciled` and then
`is_reconcile`. The finding had been read true, before transitive triggers were asked about and
with core out of reach. It is re-read false, class `covered-transitively`, and keeps its place.
fieldtest of OCA still reports it, since it walks only the roots it is given, which is what the row
says. OCA's flagship depends rule reads 95% where it read 98%, OCA's read findings are 84 true and
7 false, and the hand-classified true positives are 286, all still reported.
Each behavior has a test: no read for a file that needs none and one read for a module that does,
a dependency in a named directory read, the module beside it preferred, the option's parsing, and a
named directory that is not there refused.
### 3.87 The two bindings, measured again on the tree facts
**Measured 2026-10-01.** Β§3.72 declined the lambda's binding and Β§3.80 the name assigned records,
each because what it added read about twice as many false findings as true, and each named facts
from outside the file as what would separate them. Β§3.83 built those facts, so both were measured
again on top of them. Neither binding's code was kept, so each was rebuilt from its section and
first run where it was measured. The name binding on 0539253 reproduces Β§3.80's table exactly:
OCA 102 to 107, OpenSPP 161 to 169, core 2,366 to 2,480, with the same four findings gone and the
same five moved. The lambda binding on fed0255 reproduces Β§3.72's: OCA +8, OpenSPP +1, core +127
depends and +27 constrains, and the same two event findings gone. So the rebuilds are the bindings
that were read.
On today's tree, against the committed artifacts:
| | name binding | lambda binding |
|---|---|---|
| OCA | 101 to 106 | 101 to 108 |
| OpenSPP | 158 to 166 | 158 to 159 |
| odoo core, depends | +86, was +113 | +89, was +127 |
| odoo core, constrains | +5, the same five | +25, was +27 |
**Neither needed reading again to decide.** The tree facts only remove findings, and every addition
today is one that was read then, but two. So the readings bound what is true now:
- The name binding's depends findings are all among Β§3.80's, of which core's 113 read 37 true,
OCA's five all true, and OpenSPP's seven three true. At most 45 of today's 98 are true, and at
least 53 false. Its constrains findings are the same six, three true and three false, as Β§3.80
found.
- The lambda binding's depends findings on core are 88 of Β§3.72's 127, which read 40 true, and one
more, `hr_expense`'s own-expense bill reading the report's payment mode, the finding Β§3.80 read
true through a name. With OCA's seven of Β§3.72's eight, two of them true, and OpenSPP's one,
true, at most 44 of 97 are true and at least 53 false. On constrains, 24 of 25 are among Β§3.72's
27, which read 9 true; the 25th is one Β§3.82 re-worded, `l10n_latam_invoice_document`'s document
check now asked for the journal behind a field it cannot name. Counted true, that is at most 10
of 25.
So both stay as they were, not taken, and the tests that pin them stand.
**What the facts decided.** Of the name binding's 113 core findings, the facts take 27: a compute
nobody stores (A) alone 15, a declared field covering the read (C) alone 5, a merged decorator (B)
alone 3, and 4 that more than one of them decides. Β§3.80 had counted 33 in those three classes.
The lambda binding's 127 were 121 by 0539253, since Β§3.72's own exemption for a method's output took
nine and later reads found three. The facts take 32 of the 121: A alone 14, C alone 9, B alone 3,
B and C only together 2, and 4 that more than one decides. Each part was switched off in turn on
each finding to say which decides it.
**What stands between them and the bar** is what Β§3.72 and Β§3.80 said no declaration decides. For
the name binding, the 27 keep-once shapes: a compute that fills its field only while it is empty
or the record is new, visible in the method. For the lambda binding, the 12 guards on which
records to touch, and the 28 identifiers nothing changes after creation. A narrowing for the
guards would be the next measurement, and the old readings put each binding near the bar with it;
those are estimates from readings made before the facts, so it would be measured, not assumed. It
is not built.
## 4. Performance traps, all found by measurement
Within four instrumented runs the report showed the 7B at ~10 tok/s against a
benchmarked 36. Three diagnoses were offered; **the first two were wrong**.
### 4.1 `num_gpu: 99` β€” the actual cause
Ollama's memory estimator is conservative and leaves layers on the CPU with VRAM still
free. **11.7 tok/s automatic versus 38.6 tok/s forced**, same model, same context.
The Modelfiles carry `PARAMETER num_gpu 99`, but the pipeline calls the **raw base
model**, which has no such parameter. Both call paths now pass it explicitly. The 30B
does *not* get it β€” it genuinely cannot fit and needs a real split.
### 4.2 KV cache environment variables must precede the server
`OLLAMA_KV_CACHE_TYPE=q8_0` and `OLLAMA_FLASH_ATTENTION=1` were set as user environment
variables while the server was already running. They never took effect. 5.4 GB β†’ 5.1 GB
once actually applied.
**Set them before starting the server, or restart it afterwards.** Setting them in your
shell does nothing for a server already running.
### 4.3 Embeddings belong on the CPU
274 MB is trivial except on a 6 GB card, where it is 274 MB the generator needs. Query
embedding now passes `num_gpu: 0`. Bulk indexing still uses the GPU, where nothing
competes.
### 4.4 Orphaned `llama-server` processes
Killing `ollama*` orphans its `llama-server` children, which keep their GPU allocations.
Two were found holding 1.4 GB of VRAM and 10 GB of RAM. `nexa doctor` checks for these.
**The general lesson:** the 36 tok/s figure was benchmarked once and then quietly untrue
for most of a working session. An unmeasured number decays into a claim.
---
## 5. Observed model behaviour
Recorded because it is what a 7B actually does, not what a model card suggests.
- Left alone it wrote **Pydantic v1** syntax (`@validator`) and deprecated
`datetime.utcnow`.
- It **followed the user's stated approach over the system prompt** β€” asked "I was going
to use an onchange, show me how", it wrote the onchange. An explicit override rule was
needed: *when the user's approach will not hold the invariant, your first line says so.*
- It **regressed under prompt hardening**: fixing the `false`-for-empty scalar handling
broke the Many2one tuple it had previously got right.
- It **cited a real rule while violating it**.
- It **copied instruction text into its output** when the format template looked like
something to emit (`<code block>` appeared literally).
The 30B is better at caveats and trade-offs and not uniformly better at mechanics: on
one question it produced a CHECK constraint containing a subquery β€” invalid in
PostgreSQL β€” while correctly identifying reserved stock and concurrent transactions as
the real difficulty.
---
## 6. Limitations
### Model capability
A 7B–30B local model does not match a frontier model on hard architectural reasoning.
That gap is a function of scale. Nexa narrows the *validation* gap by grounding answers
in a corpus; it does not close the reasoning gap.
### Checking is partial, and the layers say where
- 1 of <!--fig:corpus.rules-->28<!--/fig--> rules has no automated check at all. It returns **no verdict**, never a
false pass, and `nexa coverage` prints the reason.
- <!--fig:corpus.by_pattern-->8<!--/fig--> of the
<!--fig:corpus.checkable-->27<!--/fig--> checked rules are decided by a **regex over
generated code**. That catches textual signatures, not meaning. Code wrong in a way
no pattern describes passes silently. The other <!--fig:corpus.by_ast-->19<!--/fig-->
are decided by an AST pass (Β§3.12, Β§3.47, Β§3.54, Β§3.70). The regexes
no longer see docstrings, which are emptied before matching; they still see every
other string literal, so a violation quoted inside a triple-quoted SQL string or a
help text would still be reported.
- The value trace behind four of those AST checkers (Β§3.47) reads **one file and one
function**. It follows no import and infers no type. A float that arrives as a
parameter is still reported as a float comparison; an integer returned by a helper
in another module is too. A traversal or a money string that reaches `os.path.join`
or `float()` through a helper in another file is not reported at all.
- Semantic analysis is **Python only**, which is the whole subject now. An answer may
still contain a JavaScript block, Odoo's web client being OWL; that block gets
delimiter balance and nothing deeper, and the layer reports UNKNOWN rather than
PASS for it.
- The empirical layer resolves imports against this interpreter and assembles
bytecode. It does not execute anything unless `NEXA_EMPIRICAL_EXEC=1`, and even then
only for snippets importing nothing outside a short allowlist β€” which in practice
means no Odoo answer is ever executed, because they all import `odoo`. A subprocess
with a timeout reduces blast radius; it is **not a security boundary**.
- The context layer is only as good as the file it was given. `nexa ask` has no file,
so it reports UNKNOWN; `nexa check <file>` reads the manifest, the model names,
`ir.model.access.csv` and the record rules in XML, and can then say something real.
- Every Odoo rule declares `odoo_versions: >=16.0`, so the context layer has a
question it can answer about a module on any series and reports per-rule when one
does not apply. `corpus/check_sources.py` supports that range from the
documentation for 44 of the 45 versioned rule-series, and `--source` supports all
54 rule-series from odoo/odoo itself, `float-comparison-needs-float-utils` at 16.0
included. Neither is a test run: a page describing an API and a module defining it
both say the construct was there, not that it behaved identically.
- `PASS` means *the checks that ran found nothing*. It does not mean correct, and it
is printed with that sentence attached.
### The checkers themselves are only as tested as the probes
`nexa adversarial` measures recall under meaning-preserving rewrite, whether a
`satisfied_pattern` can be cleared by a comment, and whether two rules disagree. All
three currently pass. None of them proves a pattern behaves on a real codebase: the
mutations are formatting-level, so an equivalent algorithm written differently is not
generated, and the samples are still small and still written alongside the patterns.
`nexa fieldtest` (Β§3.18) closes part of that gap and opens an honest number in its
place: **<!--fig:oca.rate-->3.0<!--/fig--> hits per 1000 lines** on
<!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code, down from 11.3 when
this started. It was 2.9 after Β§3.43, and has moved since as rules were composed in and
narrowed; Β§3.53 is why this sentence can no longer fall behind it.
<!--fig:oca.read-->98<!--/fig--> of today's <!--fig:oca.findings-->101<!--/fig-->
findings have been read by hand, which makes it a false-positive rate rather than a hit
rate; the other <!--fig:oca.unread-->3<!--/fig--> arrived after the last reading. The
table below is the reading of 2026-09-15, done against the run in Β§3.42 and re-run
after Β§3.43 narrowed four checkers against what it found. It replaces a count of 117 true, 6
false and 14 undecidable, which was of a different 135-hit set produced by
materially different checkers β€” the last paragraph here says why the two numbers
cannot be put in the same column.
| | classified | after Β§3.43 |
|---|---|---|
| True positives | 79 | **79** |
| False positives | 24 | **8** |
| Undecidable from one file | 13 | 13 |
**Every true positive survived the narrowing.** That is the number to check when a
checker is narrowed, and the reason the classification is kept as a file rather than
as three totals.
**Seventy-nine findings in reviewed, merged OCA code** β€” 2.3 per 1000 lines of
things a maintainer accepted and a checker still has a case about. Forty-one are
stored computes reading a path their `@api.depends` does not declare, each one a
column that goes quietly stale; twelve are `Many2one`s left on the `set null`
default and then dereferenced; fourteen are relations to `account.account` or
`account.journal` from a company-owned model with no `check_company`, several
carrying only a UI `domain`, which constrains a dropdown and enforces nothing.
Seven are `@api.constrains` that validate a field they never declared β€” writing
that field alone skips the check, which is the defect the rule exists for.
The thirteen undecidable are computes whose field is declared in another module,
where storedness cannot be read from the file in front of the checker.
The twenty-four fell into seven named classes. Naming them was the point: each one
was a checker change rather than an exclusion list, and Β§3.43 took the four that are
a single predicate on evidence already in the file. The last column is what is left.
| Class | Was | What the checker missed | Now |
|---|---|---|---|
| `TransientModel` | 7 | A wizard record is vacuumed. "Written once, survives restarts, diverges from reality" is about persisted data. | **0** |
| `related=` mirror | 4 | The enforcement belongs on the source field, which is separately flagged β€” so this was also double counting. | **0** |
| Registry model | 4 | `sudo()` on `ir.model`, which has no `company_id` and so no tenant boundary to cross. | 4 |
| Non-stored compute | 3 | No database column exists, so `ondelete` and `check_company` have nothing to act on. | **0** |
| Wrong construct | 3 | A `parent_id` pointing at a *different* model; `==` on an `Integer`; a commit on a deliberately separate cursor. | 2 |
| Advice that cannot be taken | 2 | A table or column name cannot be passed as a query parameter. Only the values can, and both sites already do. | 2 |
| Message-only read | 1 | `_message_only_attrs` excluded a field read inside a `raise`. This one builds the message into a variable first. | **0** |
Every row of that classification is in `artifacts/classification-oca-2026-09.json`, with the
verdict, the class and the reason on each one β€” so the next person diffs it instead
of re-reading the tree, and a count that has gone stale says so. That file is what
the previous classification did not have, and the reason it could sit here for months
describing a hit set that had moved underneath it. It is also what made Β§3.43
checkable: narrowing four checkers is only safe if you can show the 79 are still 79.
The four that went were a single predicate each on evidence already in the file β€”
is the class a `TransientModel`, is the field `related=`, does it have a stored
column, was the read only ever a message argument. The same shape as the
`required=True` exemption and the read-through test that took
`ondelete-is-a-decision` from 198 hits to 16: narrowing by a fact the checker can
already see, rather than by a list of names it has to be told.
The eight that remain are not that shape, and are left rather than engineered away:
- **Registry model (4).** `sudo-bypasses-record-rules` is a regex, and `self.sudo()`
inside `ir_model.py` cannot be recognised by one β€” it needs the class's `_name`.
That is the same regex-to-AST move `check-company-on-relations` made, on the
largest rule in the corpus by volume: 128 hits on OpenSPP, none of them classified.
Doing it without that classification would be changing the loudest checker here on
the strength of four findings.
- **Wrong construct (2).** A `parent_id` whose comodel is a different model, and `==`
on an `Integer`. Both need the declaration's type, and both rules are regexes. Same
migration, much smaller stakes. The `parent_id` half made it in Β§3.54, and took a
larger class of false positives with it.
- **Advice that cannot be taken (2).** Deliberately not narrowed. Exempting an
interpolated *identifier* from `sql-needs-query-parameters` would mean deciding
from the shape of an f-string which holes are table names and which are values, and
getting that wrong hides an injection. This is the one checker here where a
narrowing could conceal the thing it exists to find, so it keeps its two.
**Neither 24 nor 8 is the 6 reported previously, and none of them compare.** Three
of these rules had been reimplemented since that count β€” `check-company-on-relations`
and `ondelete-is-a-decision` both moved from a regex to an AST pass, and the SQL,
commit-in-loop and recursion rules did not exist β€” so it was a different hit set
judged by different checkers. The 2026-09-15 pass was also stricter: it counted a
finding against a wizard or a column-less field as a false positive where the earlier
one did not, which is most of the distance between 6 and 24.
The honest summary is that this rate has never been measured twice the same way, and
that the 8 is the first one standing on a classification anybody can re-open.
It is still two repositories in one domain, and that has not changed.
And it is still only half the question. Β§3.25 measures the other half and the
answer is far worse: 1 catch in 56 real bugfixes. Precision is the half that was
easy to measure, which is presumably why it was measured first.
### Retrieval is question-driven
Similarity search cannot surface a rule whose relevance depends on the answer. Pinning
mitigates this for known traps and does not solve it in general.
Every rule is now reachable by its own probe (Β§3.19), which is a floor and not a
ceiling: it proves each rule beats its stack for one question chosen to suit it. It
says nothing about which rule wins when two are plausible.
Compliance checking no longer depends on retrieval: the domain and semantic layers run
every checkable rule for the stack, not only the ones the model was shown. Retrieval
decides what the model *saw*; it has no bearing on which rules the answer is allowed to
break, and a rule that was never retrieved is exactly the one most likely to be broken
unawares.
### Corpus provenance
<!--fig:corpus.sourced-->26<!--/fig--> of <!--fig:corpus.rules-->28<!--/fig--> rules cite upstream documentation. The rest rest on general knowledge and
deserve more scepticism. Most of the rules were written by an assistant rather than
harvested from production incidents, and rules drawn from real incidents are worth
more. Four now are: `commit-inside-a-loop` and `sql-needs-query-parameters` came
from an external ruleset disagreeing with this one (3.34), and
`depends-must-not-name-unread-fields` from a fix commit in OpenSPP's history (3.35), and
`sql-constraints-are-ignored-from-19` from an OpenSPP incident (3.62). Four of
twenty-eight is not a change of method yet.
This is the deepest unresolved problem in the design and no layer addresses it: the
validator and the thing being validated are the same class of system. Provenance
tracks sources, versions and checker identities. It cannot establish that a rule is
*right*, only where it came from.
### Scope
Odoo, and nothing else (Β§3.46). Odoo rules were checked against 18.0 documentation and
its source; behaviour differs across major versions.
One stack, <!--fig:corpus.rules-->28<!--/fig--> rules: <!--fig:corpus.origin.odoo-->20<!--/fig--> about the ORM and <!--fig:corpus.origin.python-->8<!--/fig--> about the Python language, which apply
because an Odoo module is a Python module. Both halves carry field evidence from two
ecosystems and a hand classification of every finding, and they are **not the same
quality** β€” <!--fig:cls.openspp.precision-->84<!--/fig-->% on the two flagship ORM
rules, <!--fig:cls.python.precision-->18<!--/fig-->% on the composed-in Python half.
Reporting one blended number would describe neither.
A `.py` or `.xml` file is reviewed. Anything else is refused by name rather than
reviewed against rules that cannot match it.
### Known imprecision, not yet fixed
`check-company-on-relations` was a regex over one declaration, and `check_company=True`
is only meaningful on a model that has a `company_id` of its own. Of its 37 hits on
OCA, 5 were in files declaring no `company_id` at all β€” where the advice is not merely
unnecessary but wrong β€” and 11 were on wizards. A regex cannot ask "does this model
have a company_id", so the rule moved to a `semantic_check`, as `ondelete-is-a-decision`
did. On the 2026-09-15 run it fires 19 times: every one on a model that declares its own
`company_id`, and none on a wizard. Both classes are gone.
Four of those 19 were `related=` mirrors of a field on the parent and one a computed
`Many2many` with no stored column. Both were single predicates on the field's own
declaration and both shipped in Β§3.43, and a SQL view followed in Β§3.58; this rule has
no live false positive on the three trees now.
What remains known and unfixed is listed with the rest of the worklist, not here:
one `sudo` rebind over registry models only, which needs every call after the rebind
proved safe and is left classified rather than engineered (Β§3.58), and the `sudo` calls
Β§3.64 and Β§3.65 leave standing on caller-supplied ids and helpers. The reset-on-change
`depends-unread` shape (Β§3.57) is not on that list: it stays a finding by decision, because
a value reset when its dependency changes and a leftover dependency look the same in one
file, and the OpenSPP fixes the rule was harvested from were leftovers. Nor are reads inside a
lambda: the depends and constrains checks leave them unread by measurement (Β§3.72), since what a
filter tests mostly selects records rather than feeds the value, and telling the two apart needs
facts from other files. Nor is a read through a compute's own output, which stays exempt by
measurement too (Β§3.73): 16 of its 17 true findings on core re-check a smart default's own
choice, and it made more false ones.
### `nexa check` on a large real file
Pointed at `base_multi_image/models/image.py` β€” a 7,657-character OCA module with
six constraint methods β€” the 7B answered "the code uses @api.constrains for
validation, which is correct for this use case", cited no rule ids, and found
nothing. The layers reported that honestly (`knowledge: nothing checked β€” the
answer cites no rule ids`) rather than dressing it up. That run was also
truncated at 6,000 characters without saying so, which is now fixed.
That was one observation. 3.26 is the measurement, and it was worse than the
observation suggested: over 25 reviews the command never returned PASS, and on
the two Odoo files it reported none of the nine findings the same corpus
produces on those files with no model involved.
3.28 is the repair. The checkers read the file now, so all nine are reported with
their line numbers, and the excerpt is chosen by symbol so the method carrying
three of them is reviewed rather than cut off. What remains true is the shape of
the risk: the deterministic checkers carry the weight on real code, and the
generative half still degrades as the file grows -- a median of two symbols named
out of nineteen on a 7,929-character file, in the run of 2026-09-27 (3.75). The
difference is that the tool no longer depends on that half to find anything.
### Untested platforms
macOS has never run this, and Windows Terminal specifically has not been exercised
(PowerShell and conhost have). Everything else in the environment matrix is covered:
Linux through WSL, Python 3.11 and 3.14 on both, the colour opt-outs, and a stdout
that cannot encode the banner.
### Escalation
Measured on 2026-10-01: `nexa eval --runs 5 --pipeline` over the nine Odoo questions,
45 pipeline runs, recorded in `artifacts/eval-2026-09.json` (3.81):
| | | 95% CI |
|---|---|---|
| PASS | 43/45 (96%) | 85–99% |
| UNVERIFIED | 2/45 (4%) | 1–15% |
| Escalation triggered | 9/45 (20%) | 11–34% |
| Escalation rescued a bad draft | 7/9 (78%) | 45–94% |
| Same verdict on every run | 8/9 questions | |
Nine escalations make a wide interval: the 30B repairs somewhere between a little under
half and nearly all of the drafts it is handed, and 45 runs cannot narrow that further.
The denominator is a firm one, though. Every escalation in it is on a critical finding a
larger model can act on, five onchanges and four stored computes, because an advisory
finding escalates nothing since 3.81. Under the old rule the same 45 drafts would have
escalated 19 times.
**Before it, on 2026-09-27**, the same nine questions under the old rule (3.77):
| | | 95% CI |
|---|---|---|
| PASS | 38/45 (84%) | 71–92% |
| UNVERIFIED | 7/45 (16%) | 8–29% |
| Escalation triggered | 18/45 (40%) | 27–55% |
| Escalation rescued a bad draft | 11/18 (61%) | 39–80% |
| Same verdict on every run | 8/9 questions | β€” |
10 of those 18 escalated on an advisory finding alone: the negative-quantity question five
times, never rescued, and the positive-amount question five times, each counted a rescue
when the 30B's answer happened not to trip the rule. Without them the rate read 6 of 8.
**Before that, on twelve questions.** The first measurement, on 2026-09-14, asked twelve
questions, six of them TypeScript and JavaScript, which have since left the corpus:
| | | 95% CI |
|---|---|---|
| PASS | 50/60 (83%) | 72–91% |
| UNVERIFIED | 10/60 (17%) | 9–28% |
| Escalation triggered | 21/60 (35%) | 24–48% |
| Escalation rescued a bad draft | 11/21 (52%) | 32–72% |
| Same verdict on every run | 12/12 questions | β€” |
The entry before that read "10 runs recorded, 2 escalated, 1 rescued β€” a rescue
rate of 1/2 on a sample of 2, which means nothing yet". 21 escalations was enough to
be evidence, and the interval was what that evidence looked like: the 30B repaired
somewhere between a third and three quarters of the drafts it was handed, and 60 runs
could not narrow that further. Quoting 52% as a figure would have been the same
mistake in a new suit.
**Re-run after the corpus grew to 43 rules**, with `ai-odoo` re-registered so the
measurement was of the prompt that actually ships rather than the one Ollama still
had. Nothing moved that was supposed to be stable:
| | before | after |
|---|---|---|
| PASS / UNVERIFIED | 50 / 10 | 50 / 10 |
| Same verdict on every run | 12/12 | 12/12 |
| Per-question verdict | β€” | identical on all twelve |
| Escalation triggered | 22/60 | 21/60 |
| Escalation rescued | 12/22 | 11/21 |
| Median seconds | 6.6 | 5.0 |
One answer's worth of movement in the escalation columns, well inside intervals
that span twenty-four points. The point of re-running was not to find a change: it
was that "three new rules should not have changed `ask`" is a prediction, and this
is the difference between having made it and having checked it.
The stability line is the one worth reading twice. On 2026-10-01, as on 2026-09-27,
eight of the nine questions reached the same verdict all five times, which is not a given
at temperature 0.15 and is the property that makes a single `nexa ask` worth anything
at all. The ninth is again storing a total in a monetary field. Four of its five drafts
had a stored compute reading a field its `@api.depends` does not declare, and the 30B
repaired two of the four.
Escalation is meant to fire only on findings a larger model could plausibly repair
(Β§3.16). An advisory finding is not one: on 2026-09-27 the negative-quantity question
escalated on one five times in five, and the 30B wrote the same constraint as the 7B
every time (3.77). Since Β§3.81 that question stays on the 7B, and it passed five times
in five.
---
## 7. Layout
```
nexa/
β”œβ”€β”€ nexa.py CLI entry point, banner, help
β”œβ”€β”€ nexa_cmds.py rules / check / doctor / build / models
β”œβ”€β”€ nexa.cmd, nexa launchers (Windows, POSIX)
β”œβ”€β”€ corpus/
β”‚ β”œβ”€β”€ rule.schema.json the schema rules are validated against
β”‚ β”œβ”€β”€ build_corpus.py RULE SOURCE OF TRUTH + patterns + provenance
β”‚ β”œβ”€β”€ corpus_meta.json generated; hash, build time, checked_against
β”‚ β”œβ”€β”€ selftest_patterns.py negative test for both self-checks
β”‚ └── rules/odoo.json generated; the whole corpus, do not hand-edit
β”œβ”€β”€ bin/
β”‚ β”œβ”€β”€ corpus.py validate, index, ask, crosscheck, coverage
β”‚ β”œβ”€β”€ layers.py the nine layers, and the rule that combines them
β”‚ β”œβ”€β”€ semantics.py AST checkers, comment stripping, fragment repair
β”‚ β”œβ”€β”€ context.py what is true about the file being checked
β”‚ β”œβ”€β”€ subject.py what `nexa check` decides before the model runs
β”‚ β”œβ”€β”€ adversarial.py evasion / counterexample / contradiction probes
β”‚ β”œβ”€β”€ fieldtest.py every checker against somebody else's source
β”‚ β”œβ”€β”€ recall.py what the corpus misses, against real bugfixes
β”‚ β”œβ”€β”€ surfaced.py how each fix came to light, checked against history
β”‚ β”œβ”€β”€ figures.py every present-tense figure, recomputed from its artifact
β”‚ β”œβ”€β”€ crosswalk.py this corpus against somebody else's ruleset
β”‚ β”œβ”€β”€ eval.py the distribution: ask, the pipeline, retrieval, check
β”‚ β”œβ”€β”€ endpoint.py where Ollama is, and what to say when it is not
β”‚ β”œβ”€β”€ evidence.py per-run bundles: the record behind a verdict
β”‚ β”œβ”€β”€ pipeline.py escalating pipeline, router-governed
β”‚ β”œβ”€β”€ telemetry.py JSONL events + optional OTel bridge
β”‚ β”œβ”€β”€ report.py aggregate analysis
β”‚ β”œβ”€β”€ rag.py index your own code and docs
β”‚ β”œβ”€β”€ local-ai.ps1 PowerShell helpers
β”‚ └── bench.ps1 raw throughput benchmark
β”œβ”€β”€ modelfiles/
β”‚ β”œβ”€β”€ gen-odoo.Modelfile generated; do not hand-edit
β”‚ └── arch-advisor.Modelfile hand-written 30B persona
β”œβ”€β”€ artifacts/ every committed measurement, which figures.py reads
└── subjects/ the fixed file set `nexa eval --check` reviews
```
One definition, not two. `layers.py` owns splitting an answer into code and prose,
deciding what counts as a citation, and answering *is this rule broken here* β€”
`corpus.py` re-exports those names so the CLI, the pipeline and the probes cannot
drift apart on any of the three.
Not in version control: `bin/index.sqlite` (rebuild with `nexa build`),
`bin/telemetry.jsonl` (local measurements), `bin/evidence/` (per-run bundles),
`__pycache__/`.