Download ARCHITECTURE.md from DBax127/nexa: direct link, hf CLI and curl.
- Browser
- Download file 311 kB
-
https://huggingface.co/DBax127/nexa/resolve/main/ARCHITECTURE.md
- Command line
-
hf download hf://DBax127/nexa/ARCHITECTURE.md
-
curl -L -o ARCHITECTURE.md https://huggingface.co/DBax127/nexa/resolve/main/ARCHITECTURE.md
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. 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 Many2ones 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:
@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:
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:
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-boundariesfires 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-startupfires on everyprocess.envread. The rule says never read it deep inside and wants it parsed once at startup β so a playwright config readingprocess.env.CIis the rule being followed. 109 of its 115 hits are inexamples/demo apps.types-are-erased-at-runtimefires 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_constraintsattribute, 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:
_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-anyand, 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.
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 --jsonwrote 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
trueverdict 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_idto a different model is an ordinary foreign key. That was thewrong-constructfalse positive on OCA that Β§6 left waiting for this migration. Self-reference is read through a string, the class's own_namevariable (Many2one(_name, ...)), and a module constant both are assigned from (_name = _type_model). - Does the ORM already refuse the cycle?
_parent_store = Trueonparent_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; advisingnoupdateon them is advice nobody should take. - One
noupdateanywhere 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.dependson 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_qtyback 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 aZor+00is 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 orodoo.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, aDatefield's default, or an attribute named for a date; and a naivenow()into a key named exactlydate, 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 aninsertcan 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;+=andappendonly 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:
@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_unpaidis stored, readstransaction_ids.invoice_idsandorder_line.pos_order_line_ids.price_subtotal_incl, and declares the relations but neither field.sale.order.line.untaxed_amount_invoiced, stored insale, addspos_order_line_ids.price_subtotaland declares onlypos_order_line_ids. An amount written on a linked POS line leaves both totals where they were.survey, one.scoring_percentageandscoring_totalare stored "for perf reasons" and readpredefined_question_ids.suggested_answer_ids.answer_score. The decorator namespredefined_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_responseandl10n_dk_nemhandel_response, one each, the same shape: the stored move state reads the responses'response_codeand declares only their state. True as the rule states it. In coreresponse_codeis 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_stockandsale_stock, one each:incoterm_locationread from the linked orders.purchase_stockdeclares onlypurchase_id, astore=Falseautocomplete field;sale_stockdeclares the orders and not theirincoterm_location. Both true, and both on areadonly=Falsecompute, 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_deliverreadsmove_ids.bom_line_id.bom_id, but the two fields it computes are declared insale_stockwithoutstore=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 (
checkorvalidatin its name) called there, where the onchange hands it a field it watches. A bareself._check_company()in the onchange is checking something else, andwrite()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")readsx.is_paid, andx.filtered("a.b")readsx.a.b.x.sorted("sequence")readsx.sequence. Odoo 18 takes one field name there, asitemgetter(key). On 19 the argument is an order,"date desc, id", whose terms each name a field (_sorted_order_to_function,odoo/orm/models.pyon 19.0).x.filtered_domain([("state", "=", "done")])readsx.statefor every leaf written out, and through ananyleaf to the related records.x.grouped("f")keys onx.f.rec.line_ids[0].amountread nothing pastline_ids, because_attr_pathand_resolve_iterablestopped at a subscript.rec.partner_id.child_ids[:1].emailreportedpartner_id.child_idsand 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 isreversedonce every entry has a reversal (filtered("reversal_move_ids")). A company-paid report's entries areentrymoves, whosepayment_stateandamount_residual, the two fields declared, never move on a reversal, so the report stays paid.hr_holidays' accrual plan takes its unit fromlevel_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 writeslevel_idson the plan, which is declared.hr_holidays_contract's multi-contract check filters onemployee_idand 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 ongelato_template_refand declaresis_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 onproduct.documentas true.stock.scraptakes its quantity frommove_ids[0].quantityand declaresmove_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_salecopies the first POS category's colour onto the product and does not follow a later change to it, on areadonly=Falsedefault. What Β§3.66 said ofincoterm_locationapplies.- Three with a field nothing in core rewrites, as Β§3.66 counted
response_code.base_automationgives an automatic name only to the actionsfiltered("base_automation_id")keeps, and every core path creates the action already linked.sale_expense_margincosts a reinvoiced line from itsexpense_id, set when the line is created.l10n_id_efaktur_coretaxnames a document after its first and last invoice bysorted("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_imageinbaseandhighest_nameinaccountare declared withoutstore=True. - a decorator merged from another module, one.
sale_timesheet's project partner callssuper(), andsale_project's_compute_partner_iddeclaresallow_billable. Odoo merges@api.dependsacross every override (Β§3.59), and the file in front of the check shows only its own. - covered through another declared compute, two.
hr_expensefilters onproduct_has_cost, which depends only onproduct_id, and the declaredtax_idsdepends onproduct_idtoo.website_slidesfilters onis_category, and flipping it re-sections the channel through_compute_category_id, which changes this slide'sslide_ids, which is declared. This is the class the OpenSPP classification callscovered-by-its-own-depends, one step further removed. - a value kept on purpose, two. An
account.payment.term.linedefaults 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 isreadonly=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_countandjournal_id(filtered("is_cash_count")[:1].journal_id), and a payment method refuses every write butsequencewhile a session using it is open. - advice that cannot be taken, one, on the constrains side.
mail.activity.schedulefilters onerror, a non-stored compute that@api.constrainscannot name: Odoo logs it as not writeable. The decorator listserror'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
Domainobject; - 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], thenpending.tier_id):_aliasesbinds loop variables only; mapped()on the result of anothermapped(), and a loop over amapped()result, as Β§3.66 left them;- an attribute read straight off a call,
rec.sudo().partner_id.nameorrec.line_ids.filtered(...).amount. Sized and not read: core +9 on the depends check (six throughsudo(), three throughwith_context()), OCA +1, OpenSPP 0; - a read another declared path already reaches: a read compute's own dependencies
(
product_has_costabove, and OpenSPP's three oldercovered-by-its-own-depends), or a flag whose change re-sections a declared relation (is_categoryabove). 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_typeon sale and purchase lines, whosewrite()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 andcreate_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_idis computed fromparent_id.project_id, so a compute declaringproject_idalready follows the parent. Five through a declared field that always changes with them: a picking isdoneexactly whendate_doneis set. - 9 read the method's own output, filled through a helper,
update()orwrite(). - 7 are one of a kind: moves sorted by
refonly 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_residualdecides which journal items carry a residual by their account's reconcile flag and type, and declares onlyaccount_id. For the flag, Odoo rewrites the residuals by hand in SQL when it is written (_toggle_reconcile_to_trueand_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_statefilters the components on product,picked, quantity and unit, and declares only theirstate, which_recompute_statewrites only when it changes. Marking a component picked leaves "MO Readiness" as it was.- On constrains,
account_report's domain check declaresformulaand filters onengine, 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:" andmove.sudo().create_uid.name, and declares neither. Renaming the user leaves every such bill on the old name.create_uiditself is set once, at creation, and is true as the rule states it, as Β§3.66 countedresponse_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 throughwith_context()and does not follow a rename, on areadonly=Falsedefault. What Β§3.66 said ofincoterm_locationapplies.
The false ones are classes the check already has for attribute reads:
- storedness in another module, three.
pos_salereads its order's POS lines throughsudo()in its overrides of_compute_amount_to_invoiceand_compute_amount_invoiced, andsaledeclares both fields withoutstore=True, so they never go stale. - covered through another declared compute, one.
certificate's private key readscontentand declarespem_certificate, a stored compute oncontent; Odoo builds its triggers from transitive dependencies, so writingcontentalready 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_indexembeds its date, and a journal item'sdebitandcreditcompute frombalance. - 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
coordinatesis aGeoPointField, declared in the same file, and the declaration would have said so. - One map per function.
_aliasesbinds a name once for the whole method.pos_sale's_compute_qty_invoicediteratespos_lineover 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:
decidecombines 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:decidecounted 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 isnexa askexiting 1.- The answer layers read the rule's flag alone.
scan_sourcealso reads the grade a trigger gives one finding: the touch and escape clauses ofsudo-bypasses-record-rulesgrade theirsunproven(Β§3.47), andsql-constraints-are-ignored-from-19grades a bare snippetunproven, since nothing was walked to say which series it is for (Β§3.62).checkset 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=Trueis outside the rule, as one declared so in its own file always was. Β§3.78's threepos_saleoverrides ofsale's amounts were this, and 17 of Β§3.80's 76. - B, merged depends. Odoo merges
@api.dependsfrom every definition of the method in the model's MRO (get_dependsandresolve_mro, odoo/fields.py at 3c3e3b3), so a dependency another definition declares is declared. Β§3.71's "depends merged fromsale_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_dependsin odoo/fields.py,_field_triggersandget_field_trigger_treein 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_postedreadscurrency_idand declaresunposted_amount, which_compute_amountscomputes withcurrency_idamong its depends. A change of currency recomputesunposted_amountand thenall_posted. The same line's read ofcurrency_id.roundingis still reported and still true: nothing declares the rounding. - OpenSPP's
spp_cel_event: the override of_compute_cel_expressionreadssource_typeandaggregate_target, whichspp_cel_domain's definition declares, andspp_cel_eventdepends onspp_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 deprecateddatetime.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 28 rules has no automated check at all. It returns no verdict, never a
false pass, and
nexa coverageprints the reason. - 8 of the 27 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 19 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.joinorfloat()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 importodoo. 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 askhas no file, so it reports UNKNOWN;nexa check <file>reads the manifest, the model names,ir.model.access.csvand 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.pysupports that range from the documentation for 44 of the 45 versioned rule-series, and--sourcesupports all 54 rule-series from odoo/odoo itself,float-comparison-needs-float-utilsat 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. PASSmeans 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: 3.0 hits per 1000 lines on
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.
98 of today's 101findings have been read by hand, which makes it a false-positive rate rather than a hit rate; the other 3 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 Many2ones 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-rulesis a regex, andself.sudo()insideir_model.pycannot be recognised by one β it needs the class's_name. That is the same regex-to-AST movecheck-company-on-relationsmade, 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_idwhose comodel is a different model, and==on anInteger. Both need the declaration's type, and both rules are regexes. Same migration, much smaller stakes. Theparent_idhalf 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-parameterswould 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
26 of 28 rules cite upstream documentation. The rest rest on general knowledge anddeserve 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, 28 rules: 20 about the ORM and 8 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 β 84% on the two flagship ORM rules, 18% 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__/.