nexa / ARCHITECTURE.md
DBax127's picture
depends: the two bindings, measured again on the tree facts and not taken
a8f9476
|
Raw History Blame Contribute Delete
311 kB

Nexa β€” Architecture and Decision Record

How Nexa works, every significant decision and the evidence behind it, and an honest account of what it cannot do.

Decisions are recorded with the reasoning, including the ones that were wrong first. A decision record that only lists the final answer hides the most useful information.


1. Overview

  question
     β”‚
     β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   embed on CPU, cosine over the corpus
  β”‚ retrieve β”‚   + pinned rules that bypass retrieval
  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
       β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   Qwen2.5-Coder-7B, 100% GPU, ~37 tok/s
  β”‚  draft   β”‚
  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
       β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   nine layers, cheapest first, short-circuited
  β”‚  verify  β”‚   each reports a verdict AND what it could not check
  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
       β”‚ nothing repairable ─────────────► answer
       β”‚ repairable findings
       β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   Qwen3-Coder-30B-A3B, ~11 tok/s
  β”‚ escalate β”‚   max_invocations = 1, enforced by topology
  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
       β–Ό
     verify ────────────────────────────► answer + PASS / WARN / REJECT

The verify stage

# Layer Asks Mechanism
1 syntactic Does it parse at all? ast + compile; delimiter balance for TS/JS
2 structural Is the answer the shape the prompt demanded? required labels, leaked template text
3 semantic Does the code mean what it claims? AST checkers, curated import check
4 knowledge Are the cited rules real, offered, and sourced? citation scan against the corpus
5 domain Does the code obey the corpus? violation_pattern over comment-free code
6 context Is it right here β€” this module, this series? manifest, model names, tsconfig
7 empirical Does it hold up against this machine? import resolution, bytecode, opt-in execution
8 adversarial Would the verdict survive a harmless rewrite? meaning-preserving mutation
9 provenance Can the verdict be re-derived later? the evidence bundle, checked for completeness
β€” decision PASS / WARN / REJECT, and what was never checked one stated policy, four separate confidences

UNKNOWN is a result, not a failure. A layer that cannot run says so, and the decision carries it forward as INSUFFICIENT EVIDENCE rather than letting it read as a pass. See Β§3.14.

Three entry points, each independently useful:

Entry point Gives you Needs
nexa ask grounded answer + all nine layers Ollama + models
nexa check <file> the same, with a real context layer Ollama + models
nexa run the above plus bounded escalation + LLM Workflow Router
nexa report / nexa evidence measurement, and the record behind one verdict nothing extra

2. Hardware constraints that shaped everything

Developed on a Lenovo ThinkPad P15 Gen 2: Xeon W-11855M, 32 GB DDR4-3200 in a single channel, NVIDIA RTX A3000 Laptop with 6 GB VRAM.

The 6 GB ceiling is the single most important fact about this design. It is why:

  • the working model is a 7B at Q4, not a 14B
  • the escalation model is a Mixture-of-Experts 30B (~3B active parameters) that lives in system RAM, rather than a dense model that would run at 1–2 tok/s
  • embeddings run on the CPU
  • the 30B is capped at num_ctx 6144

Measured, not estimated

7B, full GPU offload 37–38 tok/s
7B, Ollama's automatic split 11.7 tok/s
30B MoE, num_ctx 6144 11.3 tok/s
30B MoE, num_ctx 8192 fails β€” CUDA out of memory

3. Decisions

3.1 Two single-stack models, not one dual-stack model

Decision. Separate ai-odoo, ai-ts and ai-js rather than one validation model.

Why. The first attempt used one system prompt covering both Odoo and TypeScript. Asked an Odoo question β€” "I want to cap a sale order line quantity, I was going to use an onchange" β€” it answered entirely in TypeScript with a Zod schema. A 7B does not reliably route between two stacks described in one prompt; the instructions bleed.

Consequence. Extra personas are effectively free: all three share one 4.7 GB weights blob because Ollama deduplicates. Adding a fourth stack costs a Modelfile.

Do not merge them back.

3.2 The corpus is data, and prompts are generated from it

Decision. Rules live in corpus/rules/*.json against a JSON Schema. System prompts and the retrieval index are generated by nexa build.

Why. Prompt tuning plateaued after two iterations. Hardening the Odoo false-for-empty-field rule fixed the scalar fields and simultaneously broke the Many2one tuple handling that had previously been correct. A 7B holds a bounded number of constraints; past that point, more instruction text trades one error for another.

Moving the rules into data changed the failure mode. Instead of rewriting prose and hoping, a rule is added once and flows to the prompt, the retrieval index, and the compliance checker together.

Consequence. modelfiles/gen-*.Modelfile are build artifacts. Editing them by hand is silently undone by the next build.

3.3 Rules carry a machine-checkable violation pattern

Decision. Each rule may carry a violation_pattern regex, optionally cleared by a satisfied_pattern.

Why. Citation checking proves an id exists. It cannot catch the failure that matters more: the model cites a real rule and then breaks it. Observed exactly that β€” the 7B cited odoo.onchange-is-not-validation above an @api.onchange.

Consequence. 32 of 43 rules are checkable by pattern. Eight more are checkable by an AST pass (3.12) and the remaining three return no verdict rather than a false pass. Patterns match the code with comments stripped (3.13).

3.4 Patterns are tested against the rule's own samples

Decision. nexa validate fails the build on three pattern faults: one that does not match its own wrong sample, one that fires on its own correct sample, and a satisfied_pattern that clears its own wrong sample.

Why. Every rule already carried a wrong/correct pair, so the test data existed for free. A pattern that catches nothing is useless; a pattern that fires on good code is worse, because it trains you to ignore the checker.

3.5 Cross-checking measures the false-positive rate

Decision. nexa crosscheck fires every pattern at every other rule's correct sample.

Why. Self-testing proves a pattern handles its own case. It says nothing about whether it fires on unrelated good code.

Evidence. The first run found three real defects:

Pattern Defect
float-comparison Fired on qty > 0, including inside SQL where float_compare does not apply. Narrowed to equality, the actual trap.
odoo-read-returns-array Fired on any Schema.parse(await ...). Narrowed to Odoo read().
ondelete-is-a-decision Correctly flagged a sample in this corpus that did not follow its own rule. Fixed the sample; excluded related= fields.

Now 0 cross-hits.

3.6 Some rules deliberately have no pattern

Decision. Rules that no regex can decide carry no violation_pattern, and the reason is recorded in NO_PATTERN_REASON and shipped with the rule itself as no_check_reason.

Rule Why not
odoo.schema-change-needs-migration The violation lives in a diff between module versions plus the presence of a migrations/ directory. Nothing in one snippet reveals it.
typescript.safeparse-when-failure-is-expected Depends on whether the caller can recover β€” context a snippet does not contain.
typescript.brand-validated-primitives Absence of branding is not detectable; plain strings are correct in most code.

Honest coverage with a named gap beats full coverage with patterns producing noise. The reason now travels with the rule rather than living only in the build script, so every layer that reports a gap can print why it exists. A coverage list without reasons is a number, and a number is what gets rounded up to "checked".

Two rules left this table for Β§3.12.

3.7 Documentation is consulted when authoring, never at query time

Decision. Rules carry a source URL to upstream documentation. That documentation is read while writing the rule, not while answering a question.

Why. Query-time lookup needs internet, adds latency, and reintroduces the hallucination surface the corpus exists to remove. Authoring-time verification bakes checked knowledge into offline data. Validate once at the boundary.

Evidence. Consulting the Odoo docs immediately surfaced a rule that had been missed: @api.constrains silently ignores dotted field names, while @api.depends accepts them. The decorators look interchangeable and are not, and the failure is silent β€” the module installs and the constraint simply never fires. Odoo's own wording on the load-bearing rule is blunter than the original draft: "Never ever use an onchange to add business logic to your model... they are only triggered in the form view."

3.8 Pinned rules bypass retrieval

Decision. Rules flagged "always": true are injected into every answer for their stack regardless of similarity score.

Why. Retrieval is driven by the question, but some traps only become relevant once the model picks an approach. "A CHECK constraint cannot read another table" cannot be surfaced by similarity against a question about stock levels β€” the question says nothing about CHECK constraints. The rule became relevant only because the model reached for SQL.

This is a real limit of retrieval, not a tuning problem.

3.9 Escalation is bounded by topology, not a retry counter

Decision. nexa run routes its transitions through LLM Workflow Router. 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-boundaries fires on every : any. The rule says "the body of an incoming webhook, a request payload, a handler parameter"; the samples are conditional types, a logger's rest parameter, a promise's reject callback.
  • validate-env-at-startup fires on every process.env read. The rule says never read it deep inside and wants it parsed once at startup β€” so a playwright config reading process.env.CI is the rule being followed. 109 of its 115 hits are in examples/ demo apps.
  • types-are-erased-at-runtime fires on a double cast and on a parsed response asserted rather than checked. Precise, and those look like true positives.

That is onchange-is-not-validation firing on all sixteen @api.onchange declarations again, in a different language, found the same way and at the same stage. Classifying the 277 is what decides whether either rule can be narrowed or needs a context a regex cannot ask for β€” and there is no JavaScript parser in the standard library, which is the limit Β§6 has always named.


3.45 The corpus was advising a constraint Odoo 19 does not create

--aim said prefer-sql-constraint had never once landed on a line anybody fixed: 47 placements, then 42, then 43, and zero on-target every time. Β§6 read that as a rule describing a preference rather than a defect. Reading the misses instead of the number found something else.

OpenSPP commit 208d9758:

fix(change_request_v2): enforce reason-document uniqueness with models.Constraint β€” the (cr_type_id, reason) rule was declared via the legacy _sql_constraints attribute, which Odoo 19 ignores β€” the registry only logs a warning and the constraint is never created, so duplicate rules could be saved silently since 19.0.3.0.0.

It shipped with a pre-migration to delete the duplicates that had accumulated, and with a lint rule of their own, because "Nothing in CI caught the legacy attribute." Their tree is 84 models.Constraint declarations against zero live _sql_constraints; the ten remaining mentions are migrations, tests, and comments reading "Odoo 19: Use models.Constraint".

This corpus was advising the dead spelling. prefer-sql-constraint declares odoo_versions: ">=16.0" and its correct sample was:

_sql_constraints = [
    ('code_uniq', 'UNIQUE(code)', 'Code must be unique.'),
]

onchange-is-not-validation β€” the rule the README opens with β€” carried the same sample, and worse, a satisfied_pattern of @api\.constrains|_sql_constraints. On 19 that cleared an onchange whose only "enforcement" was a declaration the registry throws away. A rule whose whole subject is a check that looks like it holds and does not was accepting one as the guard.

Both now advise models.Constraint, which arrived in Odoo 17, with the legacy spelling kept as a comment naming the version it still works on. So do the two finding messages, which were still telling the reader to put the UNIQUE entry in _sql_constraints.

The satisfied_pattern was narrowed to models\.Constraint too, and that part was wrong and is reverted. A satisfied_pattern is a plain regex with no way to read the module's series, and _sql_constraints IS a real constraint on 16 through 18. The next field test said so immediately: OCA's account_loan, branch 18.0, a genuine _sql_constraints block, an onchange properly backed by it, and a critical rule newly firing on it. The pattern accepts all three spellings again, and the 19 hole is closed where the series is legible instead.

And the checker was reading the dead declaration as satisfaction. _columns_already_constrained treated a _sql_constraints block as the column being spoken for, which on 19 means exempting a class on the strength of nothing. It now ignores the legacy form when the module pins 19 or later, which required carrying odoo_series in the tree facts β€” tree_facts reads it from every manifest in the walk and reports it only when they agree, because a tree holding a 16 module beside a 19 module has no single answer.

@api.constrains uniqueness check, beside… series fires
_sql_constraints 16.0 no β€” the constraint is real
_sql_constraints 19.0 yes β€” the constraint does not exist
models.Constraint any no
either unknown no

Unknown keeps the old reading deliberately. Firing on every 16, 17 and 18 module that did exactly the right thing is a worse trade than missing the 19 case, and check already reports an absent manifest as a gap.

What this does not do, and the first draft of this section said it did. It does not catch 208d9758. That file has no @api.constrains at all β€” the author went straight to _sql_constraints and the rule about preferring one has nothing to say. Catching that incident needs a rule that does not exist yet: the legacy attribute is ignored from 19. The fix here closes the narrower case, a Python check standing beside a constraint that will not be created, and it is a genuine one; it is not the one the commit was about.

It also changes nothing on OpenSPP, whose 71 findings are Python checks with no SQL constraint beside them at all. That is the right outcome rather than a null result: the tree already migrated.

The cull, and what the evidence would not support

The plan had been to split prefer-sql-constraint β€” 43 of its 71 findings are search_count uniqueness checks, which genuinely race, and 28 are a bound against a literal, which does not. Instead the corpus was searched for rules to remove outright.

No two rules duplicate each other. Across all five field tests β€” 226,937 lines of OpenSPP, 33,960 of OCA, 73,762 of TypeScript, 9,304 of JavaScript, 10,225 of Python β€” not one pair of rules ever fired on the same line. File level co-occurrence tops out at 43% and is volume rather than overlap: the highest-firing rule appears in most pairs. Textual similarity between any two rule statements on the same stack tops out at 0.40, and the pairs that reach it share a name prefix (constrains-*) rather than a subject. crosscheck already proved no pattern fires on another rule's correct sample; field evidence now says the same thing about code nobody wrote for this corpus.

One rule went. javascript.validate-at-the-boundary-once, on three grounds together:

  • Its checker cannot express its rule. The rule is parse untrusted input at the entry point; the pattern is if\s*\(![\w.]+\)\s*return[\s\S]{0,200}?if\s*\(![\w.]+\)\s*return β€” two guard clauses within two hundred characters. Nothing in it can see "untrusted", which is the whole rule.
  • Its only field evidence is two hits, both false: if (!this._registryRestricted) return; twice in one class, an internal boolean flag and an ordinary early return.
  • It has no upstream source β€” one of three rules resting on experience alone β€” and its subject is covered by two that do have one and that scored 6 true out of 6 each: json-parse-returns-any and, on the TypeScript side, types-are-erased-at-runtime.

Nothing else met that bar, and the near misses are worth naming. Sixteen rules fired zero times, and for almost all of them that says where the tool was pointed rather than anything about the rule: python.eval-and-pickle-execute-their-input and subprocess-shell-true-is-injection found nothing because llm-router does neither; the three typescript.odoo-* rules found nothing because hono and trpc are not Odoo clients. Removing those would be deleting good rules for being aimed at code nobody measured.

prefer-sql-constraint stays, against its own numbers. It is the clearest "useless" in the corpus by recall β€” 73 findings, zero catches, zero on-target three times running β€” but 43 of those findings are Python uniqueness checks that race, and a race nobody has hit yet produces no fix commit for recall to score against. The zero is evidence that --aim cannot see this class, not that the class is not there. It is advisory, so it is reported everywhere and gates nothing, which is the right place for a finding whose harm is real and latent.

before after
Rules 44 43
Machine-checkable 41 (93%) 40 (93%)
JavaScript findings, classified 21 true / 24 false 21 true / 22 false

3.46 Narrowing to Odoo, and composing the Python rules into it

Decided 2026-09-16. The corpus had four stacks. It has one, and the eight plain-Python rules are inside it rather than beside it.

Why narrow. Β§3.44 fired the other twenty-five rules at real code for the first time, and the answer decided this. JavaScript classified out at 21 true against 22 false. TypeScript was measured at 3.8 per thousand lines and never classified, so its number said nothing. Against those, the Odoo half carries 87 true / 15 false on its two flagship rules across two ecosystems. The edge is Odoo ORM semantics; everywhere else this corpus competes with mature tools and loses.

Removing them rather than labelling them experimental is the part worth arguing. A rule that costs nothing at query time is not free: it is a claim the README has to carry, a number somebody will quote, and a file nexa check will accept and then say nothing useful about. A .ts file now raises subject.Unreviewable and is refused by name, because an empty finding list reads as a clean bill of health, and silence about a file nobody checked is the one lie this design exists to prevent.

Why the Python rules were composed in instead. Rules were selected by strict equality on stack (corpus.py), so an Odoo module got the 19 ORM rules and none of the 8 Python ones. Odoo modules are Python. fieldtest --stack python over OpenSPP found 32 hits --stack odoo never reported.

"ruff covers it" was the first answer and it does not hold. OpenSPP's own .ruff.toml selects B, C90, E501, I, UP plus defaults β€” S and DTZ are off β€” so six of the eight are genuinely unreported in that deployment, and a narrow selection is what most Odoo shops run.

What it cost, measured before shipping. The composition was held to the bar in Β§3.43: 50 new findings across OpenSPP and OCA, every one read in its own file. 26 true, 24 false, in artifacts/classification-python-2026-09.json.

OpenSPP live findings 162 194 (0.71 β†’ 0.9 per kloc)
OCA live findings 102 120 (3.0 β†’ 3.5 per kloc)
Corpus precision, composed-in half β€” 26 true / 24 false

Twenty of the twenty-six are one rule, naive-and-aware-datetimes-do-not-compare, and one fact: an Odoo Datetime is naive UTC by contract, so a naive local datetime reaching one is off by the server's offset. Latent on a UTC-clocked server, wrong on any other, and OpenSPP's history carries two fixes of exactly it.

Three rules fired and were never right, and the most interesting is decimal-for-money-not-float: Odoo's Monetary field is a float, and the framework's answer to precision is float_round/float_compare, not Decimal. The rule fires on the idiom and names a fix that cannot be taken. Three findings do not settle a checker either way, so it is recorded rather than removed.

Two defects the classification found that no test would have. scan_source stripped # comments before matching and not string literals, so eval() named in a docstring read as code. And suppression read any marker within a simple statement without asking which rule it named, so a # nosemgrep: odoo-sudo-without-context silenced a datetime finding four lines above it β€” the mute button of Β§3.42 reached from the other side. Both were named in the README first, and both are closed in Β§3.47.

What the id prefix means now. python.decimal-for-money-not-float carries stack: odoo. The prefix is provenance β€” where the rule came from and which documentation check_sources verifies it against β€” and the stack is selection. The validator's old invariant was "id prefix equals stack"; it is now "id prefix is one of corpus.ORIGINS". Keeping the ids is what keeps every committed classification, LIKELIHOOD grade and evidence bundle pointing at the same rule: renaming the subject of a measurement is the quiet way a number stops meaning what it says.

One rule had to be reworded. Retrieval is scoped to a stack, so merging them put 27 rules in competition where 19 had been. prefer-sql-constraint ranked first for "stop anyone entering a negative quantity on a sale order line" against 19 and stopped being retrieved at all against 27 β€” that probe is the generic validation question every constraint rule here answers. Reworded to the one only it answers. 27 of 27 retrievable, 25 in the top 3.

3.47 Reading the value instead of the name, and the rest of the sudo sentence

Decided 2026-09-21. Six false-positive causes the README had named were closed in one pass, and the pass was ordered by cause rather than by rule, because the six had three causes between them.

The shared cause. float-comparison-needs-float-utils (2 true, 5 false), path-join-does-not-contain-a-path (0 of 4) and decimal-for-money-not-float (0 of 3) each matched a variable's name and treated that as evidence about its value. All five float falses compared an integer to zero β€” search_count, a read_group <field>_count, cr.fetchone()[0] of a SELECT COUNT(*), a fields.Integer β€” and three of the five plus both true positives were spelled total == 0. No name could ever have separated them. The assignment can.

So semantics.py grew a local value trace: which names a scope binds, what each was assigned, whether an expression is certainly a whole number, whether its root came from outside the file. One file, one function, no imports followed, no types inferred, and the limit is written into the limitations section rather than discovered later. Three regexes became AST passes on top of it. Every answer the trace gives that suppresses a finding must be certain β€” a name assigned twice is an integer only if both assignments are β€” because a suppressing guess is the direction that hides.

The window that crossed a function. onchange-is-not-validation was 4 true, 3 false, and the README's reading of the three ("the write is to a Many2one") was wrong. Each is a four-line onchange that assigns a field, followed by an action method that raises. The regex read [\s\S]{0,400} past the decorator; _onchange_variable_id is 200 characters long and the UserError it matched belongs to action_remap. A regex has no way not to do that. Scoped to the decorated function by an AST pass, the three are silent, and three new findings surfaced and were read: a phone-number validation living only in an onchange with no constraint anywhere in the file; a _check_date_collected onchange raising exceptions.ValidationError, a dotted spelling the regex's \w* never matched; and one that clears a field and returns the warning dict. The finding anchors on the decorator, because that is where the rule is and where a marker about it gets written β€” a FunctionDef's own lineno is the def, one line down, and reporting there lost a suppression a test already relied on.

Two infrastructure defects, fixed once. Docstrings are now emptied before any pattern sees the file β€” docstrings only, since _SQL_COLUMNS reads triple-quoted SQL on purpose β€” with quotes, newlines and length preserved so no reported line or column moves. And a suppression marker that names a code with words in it (odoo-sudo-without-context) now covers only rules sharing one of those words, vendor and language words excepted; a code with no words (B608, E501) or prose after the colon cannot be compared with anything and covers its line exactly as before. The direction of error is stated in _marker_is_about: a code the function cannot relate to a rule leaves the finding reported.

Fixing that surfaced a third defect nothing had reached before. Every statement span borrows the comment line above it, so consecutive statements overlap by one line, and a finding on the first line of one statement is also inside the borrowed line of the next. Two spans of equal width tied, and the winner was whichever ast.walk reached first. On spp_api_v2/wizards/show_secret_wizard.py the return on line 38 won the marker on line 36 away from the write on 37. A span whose statement covers the line now beats one that only borrows it.

The rest of the sudo sentence. The rule says three things β€” scope sudo() to the narrowest operation, re-apply company filtering by hand, never pass a sudo recordset onward β€” and the checker tested only .sudo().search(). Β§3.30's harvest put .sudo( at 70 missed against 6 caught, 47 of the 70 in files where a checker fired elsewhere; the README's own named miss, "remove sudo() when importing record", is a create. Counted on the OpenSPP tree, the query clause has 116 call sites and the unchecked one 136. sudo_search_drops_a_real_rule became sudo_drops_record_rules and checks all three: queries, touches (create, write, unlink, copy, read), and a sudo recordset bound to a name and then returned or passed as an argument β€” the bare name only, since telling return p.filtered(...) from return p.mapped('name') means knowing which recordset methods return recordsets, and a new clause on a critical rule should under-report.

A query decides which records exist for you and a touch acts on ones you already hold. That line is where the measurement fell, so it is where the code divides.

The record-rule trigger of Β§3.40 gates every clause, and it needed one more thing to do so: Plan.sudo().create(...) names no model, so the trigger read UNKNOWN and could not suppress. The receiver is now followed through its assignment β€” Plan = self.env["spp.case.plan"] a few lines up β€” and 12 of the 15 unresolved receivers in the file that produced them resolve. check_compliance also runs semantic checkers now; it ran patterns only, so every rule that moved to an AST pass had silently left the compliance check of generated answers, including the onchange rule that function's own docstring gives as its reason for existing.

The company_id exemption became the rule's own fields. The old test for "this call already re-applied the scoping" was the literal word company_id, from the rule's correct sample, and Β§3.40 recorded the cost: 14 of 20 surviving findings were scoped by a group, a partner or an area and read as unscoped. It called a narrowing a worklist entry because "deciding in general that a domain is already scoped means evaluating it, and a narrowing that guesses would cost the 6 true positives this keeps."

There is a version that does not guess. context already parses every ir.rule in the tree to decide whether one filters; it now also records which fields each domain is written about, and the checker compares the call against those instead of against one hardcoded word. Not one OpenSPP case model has a company rule at all β€” they are governed by [('case_worker_id', '=', user.id)] and [('case_id.case_worker_id', '=', user.id)] β€” so a create naming case_worker_id or case_id lands inside the caller's own visibility, and that is the rule's advice followed in the only spelling that model has. company_id stays as the fallback where the rules could not be read, so nothing exempt before stops being exempt now.

Two details cost a true positive each before they were right. The comparison reads the call's arguments, not the unparsed call: the receiver carries the model name, and spp.group.membership contains the word group, which is one of that model's own rule fields β€” it exempted an unscoped sweep of every membership in the database, classified true by hand. And an argument that is a bare name is followed to what it was assigned, because create(vals) otherwise hides every field it sets behind four letters. All six true positives survive, checked explicitly.

What the re-run says, against the artifacts of Β§3.43 and Β§3.46:

OpenSPP before after OCA before after
float-comparison 7 2 1 3
onchange-is-not-validation 7 7, all three falses replaced β€” β€”
decimal-for-money 2 0 1 0
path-join 1 0 3 0
eval-and-pickle (docstring) 1 0 β€” β€”
datetimes wrongly suppressed 1 0 β€” β€”
sudo deciding / advisory / decided 2 / 0 / 62 2 / 11 / 117 7 / 0 / 0 6 / 10 / 0
Rate per thousand lines 0.9 0.8 3.5 3.4

The OCA float number went up, and that is the old satisfied_pattern clearing a whole file on a float_compare anywhere in it. The three are Monetary and Float fields compared with != 0 in files that call float_compare somewhere else, each read.

What the classification says, and what was done about it. All 23 live sudo findings were read by hand against their own files, in artifacts/classification-sudo-2026-09.json. 0 true, 23 false. The touch and escape clauses have not met a real one in 260,897 lines. Every finding they made is code where the caller is an administrator by construction β€” a demo-data seeder, an accounting wizard behind group_account_manager β€” or a framework model with no tenant boundary: an ir.actions.act_window, an ir.sequence.date_range, a postgres tuning row.

So the two new clauses emit unproven per finding. That grade already exists for exactly this position β€” "a real defect nothing measured here has met yet, so it is reported and not trusted to stop a build" β€” and scan_source now derives a hit's advisory flag from the hit's own grade as well as its rule's, which is the same derivation the corpus builder already does one level up. The query clause keeps the rule's defect and its 6 true positives, because a grade travels with the finding.

Not deleted, and not by path. Skipping *_demo modules would be a heuristic invented to make a number smaller; grading a clause on what it was measured at is the thing this project does everywhere else. touches-a-record-already-held is 7 of the 23 and scoped-to-a-record-already-held an eighth of the same shape β€” a recordset the caller obtained under its own rules, then written under sudo. That is not nothing, because ir.rule governs write permission as well as visibility, but it is a different claim from the one the rule's why paragraph makes. Whether it is a second rule or a narrowing wants a tree where it fires on production code, and neither of these two is that tree.

3.48 A fourth sudo clause, taken from the missed list

Decided 2026-09-21. The harvest is supposed to be a worklist, so it was used as one.

--harvest over the OCA trees puts .sudo( at the top again, and one row in it is the miss the README had been naming by hand since Β§3.30: server-tools cbefaa511485, "[FIX] excel_import_export: remove sudo() when importing record". The diff deletes one line.

self = self.sudo()

Nothing below it mentions sudo. Every clause in Β§3.47 looks for .sudo().<method>() or a sudo recordset handed to something, and this is neither: it replaces the receiver the whole method is written against, so each subsequent self.env[...] inherits the elevated environment while naming nothing. The commit says what it cost β€” "finding multiple records across all companies during import when they have same record name. Previously, importing with sudo could bypass multi-company record rules" β€” which is the rule's own why paragraph, written by somebody who had just been bitten by it.

Three sites in 260,000 lines, so the clause is narrow as well as real. Run against that commit's own before and after images the finding sits on line 283, is gone afterwards, and 283 is one of the two lines the fix touched: an on-target catch by recall's own definition, which is why this clause carries no grade of its own and keeps the rule's defect where the touch clause takes unproven.

The same commit adds ModelData = self.sudo().env["ir.model.data"], a deliberate and narrowly scoped elevation. That is the evidence for not also flagging .sudo().env[, and it is worth stating: the author of the fix reached for the shape one clause would have reported, in the commit that fixed the thing another clause did report.

3.49 What the re-measurement says, and the grades that came out of it

Measured 2026-09-21, over 795 fix commits across both ecosystems.

OpenSPP before after OCA before after
Fix commits examined 406 409 385 386
…touching a covered construct 160 173 56 64
Caught 6 11 1 4
Recall against in-scope fixes 4% 6% 2% 6%

Both ecosystems land on the same number from different directions, which is worth more than either figure alone. The in-scope denominators rose too, so the improvement is not an artefact of measuring less.

.sudo( remains the largest cluster on OpenSPP but a different shape: 67 missed, 9 caught, 52 spoke, 15 silent, against 70/6/47/23 before. The silent column is what the new clauses ate. noupdate β€” 22 missed, 0 caught, 9 spoke, 13 silent β€” is now the cluster with the most silence against the least coverage, and is next.

Four rules earned a grade. recall --aim placed every finding a checker made in a file a fix touched against the method the fix edited, and the placements decide:

rule on-target same-symbol other caught grade
sql-needs-query-parameters 3 5 12 1 defect
naive-and-aware-datetimes-do-not-compare 2 5 16 2 defect
check-company-on-relations 1 3 10 1 construct
constrains-must-list-every-field-read 1 2 22 0 construct

The two construct grades are the same reading ondelete and stored-compute already have: the rule recognises the declaration and is about the method rather than the line anybody fixes.

3.50 A third ecosystem, and the two things it changed

Measured 2026-09-21. Every recall number this project had came from two trees, and the README's own caveat was that two repositories in one ecosystem is a narrow sample. So the pass was run again against odoo core 18.0 β€” 559,663 lines, 190,217 commits, 4,368 .sudo( call sites β€” with the recall half capped at 400 fix commits.

OpenSPP OCA odoo core all three
Fix commits examined 409 386 369 1,164
…touching a covered construct 173 64 97 334
Caught 11 4 5 20
Recall 6% 6% 5% 6%
Live findings per 1000 lines 0.8 3.5 5.0 β€”

Three ecosystems landing on the same recall figure from different directions is the first evidence this project has that ~6% is a property of the corpus rather than of a sample. The precision rate on core is the highest of the three and is not classified; core is the reference implementation, so a rate there is a statement about the rules rather than about somebody's house style, and reading those 2,784 findings is the largest single piece of work this repository now has outstanding.

It caught a checker defect that two trees could not. self = self.sudo(False) turns record rules back on. It is how a method that must run under the caller's own rights says so, and odoo core does it 60 times β€” including mail's _check_attachments_access, whose docstring reads "This method relies on access rules/rights and therefore it should not be called from a sudo env." Every sudo clause read .sudo and ignored its argument, so the checker reported the safeguard as the defect. Neither OpenSPP nor OCA contains a single sudo(False), so no amount of work on those two trees would have found it. _escalates() now reads the argument, and a non-literal argument still counts as an escalation because sudo(flag) can be either.

It stopped a grading mistake. Measured on OpenSPP and OCA alone, eleven rules had fired nowhere at all, and unproven β€” "a real defect nothing measured here has met yet" β€” is literally accurate for a rule like that. Four of the eleven fire on odoo core: bare-except-catches-the-exit 28 times, mutable-default-argument 15, eval-and-pickle-execute-their-input 4 and subprocess-shell-true-is-injection 4. Grading off two trees would have quietly downgraded four working rules on the strength of not having looked far enough.

The seven that fire nowhere across all three β€” 820,000 lines and 1,164 fix commits β€” are graded unproven on that evidence, which is more than the one rule already carrying the grade had. Two of the seven, decimal-for-money-not-float and path-join-does-not- contain-a-path, are silent because Β§3.47 narrowed them rather than because nothing was there; both readings are in the README and the grade records the second.

Sixteen rules are now graded and eleven are unmeasured, which remains the accurate word for them rather than a placeholder.

One thing found, measured, and then not acted on. Odoo core documents intentional elevation with its own comment convention β€” # sudo: discuss.channel - guests don't have access for creating mail.message β€” and uses it 136 times. The first reading was that this is the same artefact as a # nosemgrep and that recognising it would materially improve the rate on core.

The second half of that is simply false, and counting says so: of the 538 sudo findings on core, 6 carry a # sudo: note within three lines. One percent. The 136 notes sit overwhelmingly on elevations no clause here fires on β€” the record-rule trigger has already dropped those, which is the trigger working. So the practical case for reading the convention evaporates, and what is left is only the argument of principle.

That argument is worth writing down anyway, because it is the one that decides whether any future marker gets added. A # nosemgrep records that an analyser made a specific claim and a human rejected that claim. A # sudo: records that the author, while writing the line, noted why they wanted it. Those are different kinds of evidence: the first is a response to a challenge, the second is the intention of the person who chose the code. This corpus exists for defects that look correct in review β€” the headline measurement is that 17% of OpenSPP's fixes were silent until the data was visibly wrong β€” so an author's stated intention is close to the thing the tool is meant to second-guess, not evidence against the finding. Treating it as a suppression would also be the nexa-specific mute button Β§3.42 refuses to build, reached through a third party's comment style.

The convention stays unread, and the reason is the principle rather than the number.

There is a general rule in that, and it is worth stating because this file is otherwise an argument for measuring everything. A question that turns on interpretation does not become settled by being measured. The 1% above is a fact about two checkouts on one day; the thing actually in dispute is what a comment is evidence of, and no rerun answers that. Pinning a number to it would produce a reference that drifts on every tree and every rerun while the disagreement underneath it stays exactly where it was. Measure to check a claim already made β€” that is how the 1% corrected an overclaim in this very section β€” but do not manufacture a measurement in place of a decision.

3.51 Reading odoo core, and the first classification that is a sample

Measured 2026-09-21. Β§3.50 left core's findings unclassified and called reading them the largest outstanding piece of work. Reading all 2,784 is not a session's work, and pretending otherwise would have produced a worse artifact than admitting it.

So this is the first classification here that is a sample rather than a census: 15 findings of the largest rule β€” stored-compute-needs-complete-depends, 1,051 of the 2,784 β€” drawn with a fixed seed and read by hand. The draw is reproducible from the artifact's own description, without the script that made it. 11 true, 4 false, which is 73% with a 95% Wilson interval of roughly 48% to 89%. The interval is wide and it is the honest number for n=15: it says the rule is not mostly noise on core and it does not say much more.

All four false verdicts have one cause. odoo/models.py adds display_name to every model as fields.Char(automatic=True, compute='_compute_display_name', search=...) with no store. An override that omits a dependency therefore cannot leave a stale row, because there is no row. The checker read "no declaration in this file" as unknown storedness and kept the finding, which is the right reading for an ordinary field declared in another module and the wrong one here: the framework's own declaration is knowable and says not stored. 121 of the rule's 1,051 findings on core were this shape.

The narrowing is free, and that was checked rather than assumed. Ten hand-verified true positives compute display_name β€” every one of them in OpenSPP β€” and every one declares store=True on it in its own file, so in-file storedness reads True and they are untouched. Re-run: 244 of 244 hand-classified true positives across all five artifacts still found, none lost.

The bigger thing the sample found, and did not act on. 837 of odoo core's 1,360 stored computes are readonly=False: the "compute a smart default, then let the user own it" pattern, where a deliberately narrow @api.depends protects a user's edit from being clobbered. That is a plausible reading of most of this rule's volume on core, and it is not a safe narrowing. Three of the 47 resolvable hand-verified true positives on OCA and OpenSPP are themselves readonly=False, and nine of the eleven true verdicts in this very sample are too. The pattern is a consideration inside each verdict, not a rule, and the number that would have justified treating it as one is the number that refutes it.

What is still unread. 1,612 live findings on core. The next three by volume are read in Β§3.52.

3.52 The other three rules on core, and what precision turned out to depend on

Measured 2026-09-21. Same method as Β§3.51 and the same seed: 15 findings each of sudo-bypasses-record-rules, assert-is-not-validation and ondelete-is-a-decision, drawn and read by hand. With Β§3.51 that is 60 of core's findings across the four rules making 70% of them, in artifacts/classification-odoo-core-2026-09.json.

rule core previously 95% Wilson
ondelete-is-a-decision 13/15 12/12 OCA, 14/14 OpenSPP 60–95%
stored-compute-needs-complete-depends 11/15 49/57 OCA 48–89%
assert-is-not-validation 3/15 6/10 OCA 7–45%
sudo-bypasses-record-rules 0/15 6 true on OpenSPP 0–20%

Corrected 2026-09-27: the 49/57 in the stored-compute-needs-complete-depends row is OpenSPP's reading, not OCA's (artifacts/classification-openspp-2026-09.json: 49 true, 8 false, 3 undecidable). OCA's was 41 true of 41 decided when this was written, and 42 of 42 since Β§3.71.

The finding is that precision depends on what kind of code is under review, and this is the first tree here that made that legible. Two rules hold up on core. Two collapse, and both collapse for the same reason rather than because the checkers are broken.

assert-is-not-validation's own true class, set when it was classified on OCA, is the asserted value came from outside the module. A framework's asserts are overwhelmingly invariants between its own components β€” the registry asserting its cache-name convention, a field descriptor asserting its own configuration, the query builder asserting an alias it generated itself. There is no outside. Twelve of the fifteen are that shape.

sudo-bypasses-record-rules is 0 of 15 and every one is a deliberate framework elevation: a public route resolving an access token before any identity exists, an @api.ondelete guard that has to see records the caller cannot or the guard is bypassable, a cross-company sync whose entire purpose is to cross companies. The rule describes an application leaking between tenants. Core is the thing that implements tenancy.

Neither is a reason to change a grade. A precision number is about a subject, and the subject here is one this corpus was never aimed at β€” nobody reviews odoo core with it. It is a reason to say so in the README, which is done, and it is the strongest argument yet that a single precision figure for "the corpus" means less than the same figure per kind of codebase.

Three checker defects, all found by reading rather than by testing.

_auto = False marks a model odoo builds no table for: a SQL view. There is no foreign key, so ondelete is not a decision anybody can make about it. Two of the fifteen ondelete findings were report models of exactly that kind, and the exemption joins TransientModel in _transient_spans. None of the 26 hand-verified true positives for this rule sits on such a model, so it is free.

The other two are both sudo de-escalation the checker read as escalation, the same class as the sudo(False) defect in Β§3.50 and missed by the same reading. Odoo's signature is sudo(self, flag=True), so sudo(flag=False) is de-escalation by keyword and _escalates was only reading positional arguments. And x.sudo(self.env.su) sets su to whatever the environment already has, so it cannot raise privilege above ambient; core writes it beside the comment "Unsudo the invoice after creation if not already sudoed". sudo(self.env.su or field.compute_sudo) is deliberately not exempt, because the or can turn it on.

Rates after all three: 4.7 per 1000 lines on core, 0.8 on OpenSPP, 3.3 on OCA, with 244 of 244 hand-classified true positives still found.

One finding worth acting on outside this project. Core's ir.model.access.group_names_with_access interpolates its access_mode argument straight into SQL as perm_{access_mode}, and the only thing constraining it to four literals is an assert. The method is public and RPC-callable. Odoo does not run under python -O, so this is latent rather than live, and the assert also turns a bad argument into a 500 rather than a clean error β€” but it is exactly the harm the rule describes, found in the reference implementation.

Still unread on core: roughly 1,590 findings, led by naive-and-aware-datetimes (167), constrains-must-list-every-field-read (151) and depends-must-not-name-unread-fields (134).

3.53 The documents are checked against the artifacts, not only the other way round

Measured 2026-09-26. Every number in the sections above has an artifact behind it, and none of the sentences quoting them had anything checking that they still agreed. A rate was re-measured into a new paragraph while an older sentence kept stating the old one in the present tense. By this date the README gave OCA's rate as 3.0 in its introduction and 3.3 in its summary table, put "those 120 hits" beside a rate taken over 112, and gave recall as 1 of 56 and 7 of 162 in the table while the section below it said 20 of 334. Β§6 still opened on 2.9. No measurement was wrong. The prose had drifted from all of them, and in more than one direction at once.

bin/figures.py recomputes every present-tense figure from the committed artifacts, and --check fails on any figure in README.md or ARCHITECTURE.md that disagrees. A figure is marked where it stands, <!--fig:oca.rate-->3.0<!--/fig-->, which renders as nothing. Dated sentences are not marked: "re-measured on 2026-09-16: 3.5" is a record of a past run and stays true after the next one, and a check that demanded it change would be demanding that history be rewritten. It runs in CI as its own job, needing no checkout and no model.

Three things it needed that did not exist:

  • The fieldtest artifacts were ten days stale and carried no commit. Both were written before the checker changes of Β§3.47 to Β§3.52, and core had no artifact at all, so the 4.7 in Β§3.52 was the one headline rate with nothing behind it. All three are regenerated and now record the commit of each checkout and the time they were made.
  • Recall had no artifact. recall --json wrote bare rows with no head and no --limit, and nothing was committed. It now writes both beside the rows, and one file per ecosystem is committed.
  • "244 of 244 still found" was asserted by hand each time, by re-running against the checkouts. It is now computed from the artifacts: every true verdict in every classification is matched to a finding the committed fieldtest still reports. Across six classifications that is 271 of 271. The 244 was five artifacts and predated core's sample.

Four of the 271 moved, and the window that allows it is printed rather than absorbed. All four are sudo findings in OpenSPP read at the line of the .sudo() call; the AST pass now anchors them on the first line of the chained statement, one line up. A finding re-anchored within three lines is counted as found and listed as moved, so a window hiding a real loss would be visible in the output rather than in nobody's reading of it.

It also checks each artifact against itself. A classification's totals and by_rule are a second statement of what its rows say, and one of them had drifted: artifacts/classification-python-2026-09.json summarised 26 true over rows holding 27. The 27th is the consent-receipt timestamp that a sudo marker was wrongly suppressing; its note said it was counted apart for that reason. Β§3.47 scoped markers to the rules they name, so it is reported now, and it is counted. The composed-in Python rules are 27 true of 51, 53%.

A classification pinned to a different commit from the fieldtest it is matched against is a fault rather than something to match around. Line numbers from two commits say nothing about each other.

3.54 The recursion rule, and six true verdicts that were not

Measured 2026-09-26. Reading spp_area/models/area_core.py behind a verdict marked true turned up a model the rule should never have fired on. spp.area sets _parent_store = True, and odoo's _parent_store_update raises "Recursion Detected." on any write that would make a record its own ancestor. It has done so since at least October 2019, so across every series the rule claims. The constraint the rule asks for is one the ORM already enforces.

So every true verdict for recursion-check-on-hierarchy was re-read against the class around it. Six of the seven were _parent_store models: account.asset.group on OCA, and spp.area, spp.area.type, spp.audit.rule, spp.consent.purpose and spp.hazard.category on OpenSPP. All six are reversed to false, class parent-store-refuses-the-cycle, and each row keeps was: true so the reversal can be read rather than inferred. The seventh stands. spp.dms.directory declares a parent_path Char but never sets _parent_store, so nothing maintains it and nothing refuses a loop, while its complete_name and root_directory_id computes recurse up the chain. It is exactly the shape the rule is for.

The rule was a regex for parent_id = fields.Many2one, cleared by either guard's name anywhere in the file. It could ask neither question the harm depends on, and both answers are in the class, so it moved to an AST pass, parent_can_form_a_cycle:

  • Does the field point at its own model? A parent_id to a different model is an ordinary foreign key. That was the wrong-construct false positive on OCA that Β§6 left waiting for this migration. Self-reference is read through a string, the class's own _name variable (Many2one(_name, ...)), and a module constant both are assigned from (_name = _type_model).
  • Does the ORM already refuse the cycle? _parent_store = True on parent_id, the default _parent_name, clears it. The same flag on some other chain does not.

Core found three more defects in the new checker before it shipped, which is the reason core is measured at all. Its first run left 17 findings, and reading them showed eight that should not be there. Odoo's own test models write _name = _description = 'x', and a helper reading only single-target assignments left those classes nameless, so a parent_id to a different model read as unresolvable and fired. Two are SQL views (_auto = False), which nobody writes and which the ondelete rule already exempts for the same reason. One is res.partner's parent_id = fields.Many2one(tracking=3), which names no model at all: it re-declares an attribute of a field declared in base, where the relation and its _has_cycle guard both live. All three are fixed and tested.

before after
OCA 2 0
OpenSPP 6 1
odoo core 37 9

The nine left on core are all self-referencing and unguarded: mail threads, report lines, restaurant tables, website menus, and five test models. None is classified yet.

The rule stays graded unmeasured. recall --aim has never placed it, and eight read findings are too few to grade it by hand; they say the old checker was mostly wrong, and say little yet about the new one. The survival check now counts 265 hand-classified true positives rather than 271, because six of them never were, and all 265 are still found.

3.55 XML, which the tools had never read

Measured 2026-09-26. noupdate was the harvest cluster with the most silence and the least coverage. The reason turned out to be structural rather than a missing rule: noupdate-on-user-editable-data checks XML data files, and fieldtest and recall read .py only. The rule had never been fired at a line of real code. Its unproven grade was a statement about where the tools looked.

Pointing nexa check at the incident behind it showed three more things wrong. OpenSPP's 124a9d822f (#411) fixed a registry lock that every upgrade re-applied, because the setting's install default lived in config_parameters.xml with no noupdate. Before that fix, and after it:

  • Every XML file was parsed as Python. It failed at line 1, and the report said "15 semantic checkers never ran" and exited "not fully checked", on a valid file. It also said the file was "reviewed against the 8 plain-Python ones".
  • The rule fired once, on <odoo>, for any record at all. Views, menus and actions have to upgrade; advising noupdate on them is advice nobody should take.
  • One noupdate anywhere cleared the whole file. After the fix the file still holds a parameter outside the block, and the regex called it clean.

XML is a file kind now. A rule declares applies_to: xml, scan_source reads the kind off the content and runs only the rules that apply to it, and a file expat cannot read is a gap with its line rather than a Python syntax error. fieldtest reads XML and counts it apart: every per-kloc rate published before this is a rate over Python lines, and folding view definitions into that denominator would have made the next number incomparable with all of them. Not one Python figure moved. recall --xml is a separate measurement over XML fix commits, so the Python denominator it shares with surfaced.py is untouched. Demo files are skipped by both, because Odoo loads them with noupdate forced on. A data file named test_config.xml was being skipped as a test by its name, which is right for Python and wrong for XML a manifest lists under data.

Which records, measured rather than chosen. The checker is an expat pass that reports each <record> no noupdate="1" reaches, at its own line, for the models an administrator tunes. The list came from asking odoo core what it does with its own records: ir.config_parameter 26 of 28 under noupdate, ir.sequence 16 of 17, decimal.precision 7 of 8, stages 30 of 34 across seven stage models, against 0 to 2% for views, actions and reports. ir.rule is left out although core protects 566 of 598, because a frozen record rule never receives the security fix its module ships later, and that trade is not a checker's to make.

Crons came in on an incident and were narrowed on the reading. Core protects only 39 of its 93, so the measurement did not support them; OpenSPP's #473 did, naming two crons and two parameters whose tuned values every upgrade reset. Every XML finding on the three trees was then read, 76 of them, into artifacts/classification-xml-2026-09.json:

true false undecidable
parameters, sequences, stages 11 3 0
crons 3 57 2

A cron's schedule is usually the mechanism, not a setting: the midnight rate-limit reset, the hourly expiry check, the 2 a.m. posting run. An upgrade restoring those restores intended behaviour. The shape that held up ships active=False for an operator to switch on, which is exactly the decision an upgrade then undoes, so crons now count only in that shape. That gives up the two quarantine crons #473 names; their rows say so in given_up, and figures.py counts them apart from the survival check instead of letting the check fail on them or hiding them from it. Their retention parameters, which carry the harm #473 describes, are still reported. On core the 59 XML findings become 7.

Two findings stand out. spp_programs' program_fund_ref_num is a no_gap sequence that sets number_next to 1 in its XML, and ir.sequence.write stores that directly for no_gap: every upgrade of the module restarts fund reference numbers at 1. And core's base.template_portal_user_id is a Settings field in auth_signup, reset by every upgrade of base. The two core crons that ship disabled and are switched on at runtime, payment post-processing and predictive lead scoring, are recorded undecidable: an upgrade resets them, and whether anything re-enables them afterwards needs a running database to say.

Recall over XML. 197 fix commits on OpenSPP, 45 on OCA, and core capped as before. The OpenSPP catches are #411 itself, the incident this section started from, and one coincidence: 45c82bb0b701 deleted a parameter holding a shipped secret, and the finding went with the record rather than with a noupdate. It is reported as both caught and coincidental, because the criterion counts it and a reader should not. The fix that added noupdate to the #473 file, #479, landed after the commit these measurements are pinned to, so the checker reports those records as live, which is what it should do.

The rule stays advisory and unproven. One incident caught is the first evidence it has had, and it is not a grade.

3.56 Odoo runs in UTC, and the rule that carried the Python half did not know

Measured 2026-09-26. naive-and-aware-datetimes-do-not-compare was the one composed-in rule that justified the composition: 21 of its true positives, graded defect, and the README said so under "One rule carries it". Every true verdict but three rested on a single premise, written into the rule's own comment: an Odoo Datetime is naive UTC, so a naive local datetime.now() reaching one is wrong by the server's offset, latent on a UTC server and live on any other.

Sampling it on core, the same way as Β§3.51 (random.Random('20260921:' + rule_id)), found the premise false inside Odoo. import odoo runs odoo/_monkeypatches.set_timezone_utc, which sets TZ=UTC and calls time.tzset(); OpenERP did the same in its __init__ for the same reason. And fields.Datetime.now() is datetime.now().replace(microsecond=0). Inside an Odoo process there is no server offset for datetime.now() to be wrong by. Core's sample is 0 of 15: every finding stores or compares UTC against UTC, or takes an epoch timestamp, which does not depend on the zone at all.

Re-read under a criterion that does not depend on the server's clock, the OpenSPP and OCA verdicts are 3 true of 33, from 21. Eighteen are reversed, each keeping was: true:

class
utc-by-odoo 15 a "local" now against or into ORM values, a Z on a time that is UTC, a JWT whose library reads naive as UTC
default-never-reached 2 a server-date default after the required-field check that rejects its absence
created-a-day-ahead 1 a fiscal-year cron a whole day early, further than any offset

The three that stand are two defect shapes. A naive UTC timestamp leaving the system with no offset: two consent API responses, and exactly what the two commits recall credits this rule with fixed, by writing datetime.now(UTC). And the server's date used as a company's calendar day: OCA's purchase_unreconciled dates a write-off move with it, so for a company ahead of UTC the early hours land on the day before, at month end in the period before. OpenSPP's own birthdate fixes, #397 and #520, are that class too, which is where the old comment's "two fixes of exactly this class" came from; they were never about the server's offset.

The rule's advice was wrong for Odoo, not only its grade. "Never call datetime.now() without a tzinfo" applied to an ORM value raises: Datetime.to_datetime refuses an aware datetime with ValueError. The rule now says where aware belongs, at the edges, and names fields.Date.context_today for a user's day. Its grade moves from defect to preference, which gates nothing: inside the ORM the finding is the house spelling of the same value. The checker is unchanged and still fires on every datetime.now(); a narrowing to the two surviving shapes needs to see where the value goes next, which the regex cannot, and it is on the worklist.

The composed-in Python rules go from 27 true of 51 to 9 of 51. Β§6 said the two halves of the corpus were not the same quality, and the distance is larger than it said.

3.57 The other two core samples

Measured 2026-09-26. Same seed, same recipe, the two rules left from Β§3.52's list.

rule core OpenSPP OCA
constrains-must-list-every-field-read 10/15 38/45 7/7
depends-must-not-name-unread-fields 5/15 10/11 3/3

constrains holds up on the framework as it does on applications, which is what a rule about a mechanism rather than a use case should do. Its five false ones are three fields ir.model refuses to change after creation, one read that only phrases the error, and a rate that is only ever bad through a direct write, which fires the check. One true positive is a defect in core rather than a gap: ir.actions.server._check_sms_template_model declares template_id and reads sms_template_id, so the field it validates is the one whose writes never trigger it.

depends does not, and its ten false ones are three shapes, each a predicate the checker could learn from evidence already in the file:

  • The override calls super() (3). Redefining @api.depends on an override replaces the parent's dependencies, so an override has to name what the chain reads, and its own body reading nothing is correct. This one is safe to exempt outright.
  • A base stub declared for its overrides (2). The body assigns a constant; modules that override it read the declared fields and inherit this decorator.
  • A value meant to reset when its dependency changes (5). scrap_qty back to 1 when the product changes, a trigger cleared when the automation's model changes. The dependency is the behaviour. This is the hard one: it is not distinguishable from a leftover without knowing the intent, and the two OpenSPP fixes the rule was harvested from were leftovers.

The survival check counts 274 hand-classified true positives across seven classifications, all still reported.

3.58 The last three named false-positive classes

Measured 2026-09-26. The worklist named three classes left over from Β§3.43. Reading what was still live showed one was already closed and two had shapes nobody had written down.

check-company-on-relations: one left, a SQL view. The related= mirrors and the computed Many2many the worklist named had shipped in Β§3.43. The one live false positive was OpenSPP's fund_report, _auto = False: no table, so no foreign key to guard. The ondelete and stored-compute checkers already exempted views; this one now does too.

sudo-bypasses-record-rules on registry models: measured, then named. Three sudo().search() calls on ir.model stayed live on OCA after the AST move, because the rules that would say whether ir.model is filtered live in base, which the tree does not contain, so the trigger read UNKNOWN and kept them. Asked directly, odoo core gives record rules to five framework models only: ir.default, ir.embedded.actions, ir.filters, ir.ui.view and ir.ui.view.custom. ir.model, ir.model.fields, ir.model.data and ir.module.module have none, so sudo() on them drops nothing, and they are exempt by name. ir.attachment has none either and is kept out of that list: its own _search filters by access unless the environment is su, so sudo() drops a real check under another name. That only holds where the tree cannot see base. On core, the tree's own facts show ir.attachment has no record rule, and the existing trigger drops the finding, because this rule is about record rules and that access check is outside its scope. The gap is the rule's scope, and it is stated here rather than closed by a special case.

The checker also learned to read self as the enclosing class's model, which is how one of the three was spelled. On OCA that took two more unclassified findings, both searches on ir.model and ir.model.fields. On core it took 26, and every one was read. Twenty-five are models the tree shows unruled, or calls that re-apply the model's own rule: a sudo() search over ids a plain search had already filtered, wishlists filtered by the user's own partner, a company's own branches, sequence lookups inside one journal.

One is not, and the reason predates this pass. website_sale's set_sequence_up swaps website sequence with a neighbouring product.template under sudo(), filtered by website_published. product.template carries two rules, a company rule and a website visibility rule on website_published, and the re-scoping test counts a call as re-applying the rule when its arguments name any one rule field. Naming one of two rules is not re-applying both. Resolving self made this call reachable by that weakness; before, the model was unknown and the finding stayed. It is recorded on the worklist rather than fixed here: the fix is to require every rule's fields, and that changes the predicate every sudo verdict so far was read against.

The fourth OCA sudo false positive is the rebind clause, self = self.sudo(), over a method that then only reads registry models. Proving that means showing every call after the rebind is safe, which is a different checker; it stays classified false.

Read only to phrase a message: two spellings missing. The exemption knew a raise and a message assigned to a variable. OpenSPP's five live false positives of this class were all a log line, _logger.warning("... %s", rec.name): a value read to phrase something decides nothing, whether it is raised or logged. Core's one was a message collected in a list and raised joined. Both spellings are now exempt; a field read in the condition as well is still reported, and a test says so.

On the trees: OCA 103 to 98 live findings, OpenSPP 154 to 148. Every finding that went is listed in the commit and was read. All 274 hand-classified true positives are still reported.

3.59 An override that calls super(), and a reason that was backwards

Measured 2026-09-26. Β§3.57 named three shapes behind depends-must-not-name-unread-fields' false positives on core and called the first safe to exempt outright: a compute whose body calls super(). It is now exempt.

The reason Β§3.57 gave was wrong, and reading the framework rather than the finding showed it. It said redefining @api.depends on an override replaces the parent's dependencies. It does not. Field.get_depends resolves the compute through the MRO and extends one list with every override's _depends (odoo/fields.py, 18.0 at 3c3e3b3), so an override's decorator adds triggers for the chain, and the fields it names are read by the parent's body, which the override reaches through super(). Core says so in its own comments: "EXTENDS 'web' to add dependencies", "We are just adding depends on this compute." The exemption stands on the right reason now, and the docstring and test say that one.

The second shape, a body that only assigns a constant, was tried and withdrawn in the same pass. It lost three true positives on OpenSPP and OCA, leftovers read true by hand: a "legacy method", and a target model that is always res.partner. On core the same shape was a stub for overrides or a deliberate reset. The file cannot tell intent apart, so it stays a finding, and a test pins that it does.

On the trees: OCA and OpenSPP unchanged, and core 2,420 to 2,380. All 40 findings that went were read, and every one is an override whose body delegates to super(), most with nothing else in it: the partner and employee avatars, the need_cancel_request computes of four country modules, the currency-rate computes that Czech and Polish invoices re-base on the taxable supply date. Three of them were in core's read sample, so the sample is 79 now, 42 true and 37 false. All 274 hand-classified true positives are still reported.

3.60 Re-scoping has to re-apply every rule everybody gets

Measured 2026-09-26. Β§3.58 left one weakness recorded rather than fixed: a sudo() call counted as re-scoped when its arguments named a field of any one of its model's record rules. Odoo does not combine rules that way. Global rules, the ones with no groups, are ANDed for every user; group rules are ORed per audience. Naming a group rule's field re-applies that rule for that audience and nothing else. The tree facts now keep each rule apart with its audience, and a call is re-scoped only when it names a field of every global rule on its model. Where a model has no global rule the old test stands, any rule's field and then company_id, so nothing about group-only models moved.

It is the first change to this checker that can only add findings, so the reading was of what it added, not of what it removed. OCA: nothing. OpenSPP: 19, every one on a line OpenSPP's own nosemgrep: odoo-sudo-without-context marker already acknowledges, so all 19 are decided and out of every rate, as decided findings always have been. Core: 45, read in full into artifacts/classification-sudo-core-2026-09.json, not sampled.

One is true, and it is the one the change was made for. website_sale's set_sequence_up finds its neighbour under sudo() among every company's published templates and writes that template's website_sequence, so reordering one company's shop reorders another's. The file argues the case itself: set_sequence_down, six lines below, is the same code with a plain search(), which the company rule filters.

The other 44 were exempt before for a reason that was not the reason. Each named a field some group rule follows, employee_id, move_type, website_published, and for res.partner the portal rule's id, so search([('id', '=', partner_id)]) passed as re-scoped on the strength of a rule that only portal users get. Most of them are fine anyway, for reasons the checker cannot see:

class
domain-scopes-it-already 18 confined through a record the caller holds: one journal, self.ids, a leave's employee, the user's own partner
public-website-read 14 the /partners and /customers pages, a published combo, a forum redirect
guard-must-see-everything 5 a constraint or lock that has to see every company to be a guard at all
posts-in-the-records-own-journal 3 an entry created in the journal of the order or valuation it is for
touches-a-record-already-held 2 a write on records a plain search just found
company-refiltered-outside-the-call, refiltered-by-the-callers-search 2 the company check happens two lines later, or in the caller's own search

So the change trades 44 false positives on core, 5 of them advisory, for a test that says what it means and one true positive the old test hid. Core goes from 2,380 to 2,420 live findings; OpenSPP and OCA are unchanged in every rate. That is not obviously a good trade on precision alone, and it is recorded as a measured cost rather than presented as a win. What would recover most of it is the largest class, and it is one predicate: a domain leaf whose value is the ids of a recordset the caller obtained under its own rules. That exemption would restore what the old test let through on a stated reason instead of an accidental one. It is on the worklist rather than built here, because it changes OpenSPP and OCA findings as well, and the six OpenSPP true positives have to be re-read against it first.

No existing verdict changed. A stricter test cannot remove a finding, so every sudo verdict read against the old test still sits on a finding, and none of the 45 had been read before. The survival check now counts 275 true positives across eight classifications, all reported.

3.61 The datetime rule sees where the value goes

Measured 2026-09-26. Β§3.56 left naive-and-aware-datetimes-do-not-compare graded preference with a regex that fired on every datetime.now() and utcnow(), and named the two shapes that survived reading. Both need to know where the value goes next, so the rule is an AST pass now, naive_time_leaves_or_dates_a_day:

  • A naive timestamp leaving the system with no offset. Its isoformat() anywhere, unless a Z or +00 is written after it by hand; or the value itself as a keyword of a class being built, or in a returned dict, in a file that imports fastapi, pydantic, starlette or odoo.http.
  • The server's UTC date used as a calendar day. A .date() of it into a vals key or domain field spelled as a date, a Date field's default, or an attribute named for a date; and a naive now() into a key named exactly date, which Odoo truncates to UTC's day.

The sources did not change. date.today() and fields.Date.today() are the same UTC date and were measured rather than added: 48 and 114 sites on OpenSPP, 31 and 217 on core. Adding them would have turned a narrowing into a widening, and fields.Date.today() is the framework's own spelling of the value.

before after
OCA 7 1
OpenSPP 25 5
odoo core 167 2

All three hand-classified true positives are still reported, and both commits recall credits the rule with still fire on the files as they were before the fix. On OpenSPP and OCA every finding that went had been read false. On core the 167 were too many to read one by one, so they were triaged: the 84 in a file that speaks HTTP, or with .date(), isoformat, strftime or a date key within three lines, were read, and the rest are the utc-by-odoo shape the sample already named, a naive UTC time meeting naive UTC values inside the ORM.

The reading of what went is what found the two on core, and they changed the checker. The first draft gated shape A on HTTP alone, and it removed l10n_es_edi_facturae and l10n_es_edi_tbai writing datetime.now().isoformat() into xades:SigningTime, an xsd:dateTime in a signed e-invoice. Inside Odoo the value is UTC; with no offset, a reader takes it as local time, and in Spain that is one or two hours off. It is exactly the rule's sentence, "a file for another system", so isoformat() became a clause of its own. It costs three findings OpenSPP's classification already reads false, a generated-at line in two report scripts and a computed_at in a result dict, and that trade is kept in the open rather than tuned away. Both core findings are recorded in artifacts/classification-datetime-core-2026-09.json, a census rather than a sample.

What it misses, and says so: l10n_in's demo invoices write 'invoice_date': datetime.now() into a Date field. A key ending in _date can just as well be a Datetime (stock's scheduled_date), so a naive time into one is not reported and its .date() is.

The grade goes back to defect, the promotion Β§3.56 said should follow once the checker could see the shape. On the three trees it is 5 of 8 read true, and both recall catches are on-target. Those are small numbers and are stated as small. The rule's wrong sample moved with it: it was a comparison, the one shape that did not survive, and a checker has to fire on its own wrong sample. The composed-in Python rules gain nothing and lose nothing in their classification: the verdicts are unchanged, only which of them are still reported.

3.62 _sql_constraints is ignored from Odoo 19

Measured 2026-09-26. The rule an incident asked for, and the first new rule since the corpus reached 27. OpenSPP's 208d9758 (#394) found spp.cr.type.reason.document declaring its (cr_type_id, reason) uniqueness in _sql_constraints on a 19.0 module. Odoo 19 logs one warning at load and creates nothing, so duplicate rules were saved from 19.0.3.0.0 until the fix, and the fix added a lint of its own because nothing in CI had caught it. odoo.sql-constraints-are-ignored-from-19 is an AST pass over the file plus the manifest's series, legacy_sql_constraints_on_19.

A version claim was wrong on the way in. Β§3.45 and the prefer-sql-constraint rule said models.Constraint arrived in Odoo 17. Odoo 18.0 core at 3c3e3b3 has no models.Constraint anywhere and declares _sql_constraints in 174 files; the 18.0 ORM reference has no "Constraints and indexes" section and the 19.0 one does, declaring Constraint, Index and UniqueIndex and never naming _sql_constraints. So up to 18 the attribute is the only spelling, and from 19 it does nothing. The rule text, both correct samples that said "Odoo 16 only", and the checker docstring that repeated it now say 16 to 18. Β§3.45 is left as it was written; this is its correction.

Which series decides everything, and there are three answers. 19 or later: reported, at the assignment. 16 to 18: silent, because a finding would be advice that cannot be taken. Unknown: silent when a tree was walked and simply does not pin a series, which is odoo core, whose manifests carry versions like "1.0" and would otherwise yield 174 findings, every one wrong. Reported as advisory, unproven on the finding itself, when nothing was walked at all, because "nobody looked" and "looked and it does not say" are different answers, and silence on the first would hide the rule from a bare snippet and from validate's own samples. It is the first rule to declare odoo_versions other than >=16.0, and that exercised the context layer's per-rule check for the first time: grounding a 17.0 module with it is now a finding, which is what that check was written to say. A test pins both sides.

On the trees it fires nowhere, and that is the expected answer rather than a missing one. OpenSPP at the pinned commit already contains the fix and has 72 files of models.Constraint; OCA's 20 _sql_constraints are all in 18.0 modules; core is 18.0. So the evidence is OpenSPP's history. Five blocks were written on 19. Three were fixed together by ca44b5e3, "migrate _sql_constraints to models.Constraint (Odoo 19)", one is #394, and one went with a reverted feature rather than a fix. The checker fires on all four fixed ones as the files stood before their fix and on none after, and nexa check --no-model on the #394 file inside its 19.0 module reports it as high impact, likelihood defect, at the line the fix removed.

It is graded defect on that, which is more than noupdate got for one incident in Β§3.55, and the difference is the construct rather than the count. A heuristic can be wrong about intent. This cannot: on 19 the attribute does nothing, so every finding is a constraint that does not exist. The one way to be wrong is a series misread, and that is why the unknown series is handled the way it is.

Its probe, "after upgrading to Odoo 19 my unique constraint is not enforced and duplicates are saved", brings it back inside the top 3. Re-measured 2026-09-27 with the index rebuilt: all 28 rules retrievable at TOP_K 6, and 26 of 28 in the top 3, where it had been 25 of 27. The two that miss at 3 are commit-inside-a-loop and eval-and-pickle, not this one.

3.63 A named bug above the fold

Checked 2026-09-27. A maintainer reads the first screen, and the first screen described a class of defect without showing one. It now shows one: OpenSPP's spp.area stores area_level, computes it from parent_id.area_level, and declares only parent_id, so moving an area recomputes its own level and leaves every area beneath it on the old one. It was classified true in artifacts/classification-openspp-2026-09.json and is re-checked rather than remembered: still line 84 at cfa0dc8e, still reported by nexa check --no-model, and OpenSPPArea.write recomputes nothing by hand. spp_api_v2_gis groups areas by the level, which is where the stale value would show.

The second candidate the worklist named, spp_approval's _check_approval_config, is live too and stays out of the opening: it is a constraint that use_multitier alone skips, a real gap but a longer story to tell in four lines. One example says what the tool is for; two start to read like a list.

3.64 A domain on records the caller already holds

Measured 2026-09-27. Β§3.60 made sudo re-scoping honest and paid for it with 44 false positives on core, and named the largest class as one predicate. It is built now: _tied_to_held_records. A sudo() call counts as re-scoped when a domain leaf pins it to the ids of records the caller obtained under its own rules, with =, in or child_of. ('employee_id', 'in', self.ids) returns the contracts of employees the caller can already see; ('journal_id', '=', self.journal_id.id) returns the moves of one journal, which belongs to one company.

"Held" is narrow on purpose, because every yes suppresses a finding. self, reached through attributes and calls that return a subset of what the caller could see (filtered, mapped, sorted, with_context, exists, a plain search); the user's own records; a name every assignment to which is one of those; a loop variable over one. Not held: an id the caller passed in, anything under sudo(), browse(ids) and env.ref(...), which apply no rule, and the result of any other method on self. That last exclusion came from reading: the first draft trusted a controller's self._get_search_countries() because its root was self, and said nothing about whose rules produced the countries. Five findings came back when it was tightened. !=, < and not in never count, which is why set_sequence_up, whose leaf is ('website_sequence', '<', self.website_sequence), is still reported.

before after
OCA 99 99
OpenSPP 153 153
odoo core 2,422 2,350

Of the 45 that Β§3.60 added, 14 go: 11 of the 18 domain-scopes-it-already and 3 guards. The other seven of that class stay, each for a reason the predicate gives: four take a task, partner or commercial-partner id as a parameter, one builds its ids in a list appended to in a loop, the recruitment check is scoped by website rather than by records, and a channel list is assigned once from the user and once under sudo(). The one true positive stays.

It also reached 58 older findings nobody had read, and every one was read before accepting it. None is a defect. 20 are guards that must see every company: an account with journal items anywhere cannot be deleted, a product used in any stock move cannot change its unit. 21 are records belonging to held records: a leave's work entries, a record's own activities and scheduled messages, a message's notifications. 9 are the user's own: their loyalty card, their slide progress, their departments. 2 are deliberate syncs across companies of one shared record: a global milestone setting applied to every company's lines, and a partner's new address re-pricing that partner's draft carts everywhere.

The risk named before building it held up and is worth keeping in view. Partners and products are shared across companies, so a search tied to self.partner_id can still cross a company boundary. In what was read, every such call was a guard, a sync of the same person or product, or a count, and none returned another company's records to someone who could not see them. That is a statement about core, not a proof, and a tree where it is false would show up as a lost true positive in the survival check. All 277 are still reported.

3.65 A public page reading what it publishes

Measured 2026-09-27. The second class Β§3.60 named, public-website-read, 14 of the 44, was mostly one shape. website_customer's /customers and website_crm_partner_assign's /partners list, group and count partners under sudo(), because the anonymous visitor's own rules would hide most of them, and every domain they build starts from ('website_published', '=', True). That is _published_on_a_public_page.

The reason is in the framework, not in the page. Publishing is the record owner's decision to show it to everybody. And on a model with website.published.multi.mixin, which res.partner and product.template both carry, website_published is a computed field whose _search_website_published adds the current website's domain whenever the request has one (website/models/mixins.py, 18.0 at 3c3e3b3). A website=True route always has one. So the leaf re-applies the scoping a public visitor's page is meant to have, in a spelling the rule field test cannot read.

Narrow on purpose, as Β§3.64 was, because every yes suppresses a finding. All of these must hold:

  • a read (search, search_count, search_read, read_group), not a write;
  • inside a function decorated route(..., auth='public', website=True);
  • the literal leaf ('website_published', '=', True) ANDed at the top of the domain. The domain is read as the prefix notation it is: a leaf under | or ! does not hold for every record returned, a list glued on with an operator short of terms is a fragment, and an insert can put an operator in front of the leaf, so each of those keeps the finding. A domain built in a name counts only when every plain assignment to it carries the leaf; += and append only AND terms on.

is_published does not count: it applies no website. Neither does a published flag compared with anything but True, nor the same search in a model method, which is how set_sequence_up stays reported.

before after
OCA 99 99
OpenSPP 153 153
odoo core 2,350 2,336

Of the 14, the 12 on /customers and /partners go. The other two stay, each for a reason the predicate gives: the forum redirect looks a partner up by an id from the URL, and the combo configurator's search runs in a helper, not in the route, with no published leaf of its own. Two older findings also went, in website_membership's /members page: a read_group of published members by country and a search for published free members. Both were read; both are the same shape as the twelve. The dotted ('partner.website_published', '=', True) on the membership lines three calls away is not the leaf this reads, and those stay.

The cost is named, as Β§3.64 named its own. A partner published with no website_id is published on every website, so a company's /partners page can list another company's published reseller. That is Odoo's design for a record published everywhere, and the owner chose it; it is still a company boundary the page crosses, and a tree that means otherwise would show up as a lost true positive in the survival check. All 277 are still reported.

3.66 A mapped() path is a read

Measured 2026-09-27. Building the sample findings report for the Odoo review, the depends check was silent on this:

@api.depends("invoice_ids")
def _compute_outstanding_total(self):
    for account in self:
        account.outstanding_total = sum(account.invoice_ids.mapped("amount_residual"))

and reported the same read written as sum(inv.amount_residual for inv in account.invoice_ids). _field_reads collects attribute accesses, and the only one in the first spelling is invoice_ids, which is declared. The field that goes stale is a string argument.

_mapped_reads now reads x.mapped("a.b") with a literal string as a read of x.a.b. The receiver resolves the way a loop's iterable does, through filtered(), sorted() and sudo(), and everything after that is the checker as it was: a trailing .id names the relation, a field the method computes is never asked for, a non-stored compute is outside the rule, and a declared a.b.c covers a read of a.b. A mapped() inside a raise or a log line is phrasing a message and is skipped, as an attribute read there is. mapped(lambda ...) and mapped(name) stay unread: the fields a lambda touches are a guess from here, and every guess in this checker is a finding somebody has to read.

before after
OCA 99 99
OpenSPP 153 153
odoo core 2,336 2,345

OCA and OpenSPP do not move, and that was checked rather than taken from a quiet run. OCA has 5 computes reading through a literal mapped(), 3 of those reads undeclared: a display_name override, whose column the framework never stores, and two in a TransientModel wizard. OpenSPP has 22 and 3, and all three compute fields declared without store=True. Each is exempt for a reason the checker already had.

Core has 117 such computes, and 9 findings survive the exemptions. Nothing disappears. All 9 were read:

  • pos_sale, three. sale.order.amount_unpaid is stored, reads transaction_ids.invoice_ids and order_line.pos_order_line_ids.price_subtotal_incl, and declares the relations but neither field. sale.order.line.untaxed_amount_invoiced, stored in sale, adds pos_order_line_ids.price_subtotal and declares only pos_order_line_ids. An amount written on a linked POS line leaves both totals where they were.
  • survey, one. scoring_percentage and scoring_total are stored "for perf reasons" and read predefined_question_ids.suggested_answer_ids.answer_score. The decorator names predefined_question_ids.answer_score, the score of a simple question, and not the scores of the answers. Editing a choice's score leaves every submitted attempt on the old percentage, while editing a simple question's score recomputes them.
  • account_peppol_response and l10n_dk_nemhandel_response, one each, the same shape: the stored move state reads the responses' response_code and declares only their state. True as the rule states it. In core response_code is required and written only when a response is created, which the declared relation already covers, so nothing in this tree edits one in place.
  • purchase_stock and sale_stock, one each: incoterm_location read from the linked orders. purchase_stock declares only purchase_id, a store=False autocomplete field; sale_stock declares the orders and not their incoterm_location. Both true, and both on a readonly=False compute, the smart-default pattern Β§3.51 kept as a consideration inside a verdict rather than a rule: a bill's incoterm location following its order after the fact may not be what anybody wants.
  • sale_mrp, one, false. _compute_qty_to_deliver reads move_ids.bom_line_id.bom_id, but the two fields it computes are declared in sale_stock without store=True. The file in front of the checker cannot see that; it is the storedness-in-another-module class the OCA reading left at 13 undecidable.

So 8 true as the rule states it, two of them with a field nothing rewrites and two on a smart default, and 1 false. None of the nine goes into a classification file: core's is a fixed-seed sample, and these were read as a census of what one change added. All 277 hand-classified true positives are still reported.

What it still does not follow, named so nobody finds it later: a lambda or a variable as the argument; mapped() on the result of another mapped(); and a loop over rec.line_ids.mapped("move_id"), whose loop variable is not bound to a path, because a mapped() result is not a recordset in general and _resolve_iterable still says so.

3.67 What backs an onchange, measured and mostly not taken

Measured 2026-09-27. onchange-is-not-validation stays quiet when the file names any constraint at all. So a UNIQUE(reference) silences an onchange that raises on credit_limit, and on an Odoo 19 module a _sql_constraints block counts as the backstop even though Odoo 19 never creates it (Β§3.62). Two narrowings were proposed together: count a constraint only when it covers a field the onchange watches, and never count _sql_constraints when the tree pins 19 or later. The rule was that this ships only if what it adds reads true. Both were measured, apart and together.

before field-matched, and 19 19 only (shipped)
OCA 99 100 99
OpenSPP 153 157 153
odoo core 2,345 2,376 2,345

Field matching added 36 findings, and 11 read true. On OpenSPP, 3 of 4. spp_area caps an area at level 10 in _onchange_parent_id and nowhere else; its constraint is unique(code). spp_change_request_v2 keeps required documents inside the available list only in the form. spp_registry's one-head-per-group rule is checked in the membership onchange and in the group's own write(), and a change request's add_member creates memberships directly, past both. The false one is spp_import_match: the parent model's @api.constrains("field_ids") enforces the same no-duplicates rule, under a field name the child's onchange does not watch.

On core, 8 of 31. Four are the partner "block" warnings in account, sale, purchase and stock: each clears the partner in the form, and nothing refuses a blocked partner on import or RPC. That is Odoo's design for the feature, and it is also exactly the rule's sentence. The other four refuse a value nothing else checks: a loyalty card's expiration date, a negative price rounding, an expense edited by someone who may not edit it, and a manufacturing order whose component is its own product. Four of the false ones are checked on the server elsewhere: a work order's dates by the inverse _set_dates, the reference unit by _check_category_reference_uniqueness, an API key's duration when the wizard creates it, and the Argentinian journal pairing when the invoice posts. The other 19 refuse nothing. They are notices: "Note: the reference already exists", a packaging quantity suggestion, "the new rate is quite far from the previous rate", a sequence format that changed. On OCA the single addition is account_loan's rate warning, and it is the same kind of notice.

That last finding corrects an old reading. Β§3.45 described account_loan as "an onchange properly backed by" its _sql_constraints. The block is unique(name, company_id) and backs nothing the onchange watches. The finding was false because the onchange only informs.

So the file-wide guard was hiding a second, wider problem: the checker counts any returned warning dict as a refusal. Narrowing the guard first would ship that noise, 25 false for 11 true, so it was not taken. A variant that counts a warning dict only when the onchange also resets a field it watches was run for information. It keeps all 11 true additions and 4 of the false ones, but it also removes 17 existing core findings and 1 on OpenSPP that nobody has read. Whether that is the right next change depended on reading those 18 first. They were read the same day, and Β§3.68 is what came of it.

The series half ships. A _sql_constraints block no longer counts as backing on a tree that pins 19 or later; on 16 to 18 and on an unknown series it still does, as Β§3.45 requires. It changes nothing on the three trees: OpenSPP's modules are all on 19, but every file there that has an onchange and a constraint uses models.Constraint or @api.constrains. That includes the state before OpenSPP's own incident fix (208d9758), whose file also carried @api.constrains methods. It is shipped for the same reason Β§3.62 was: a declaration the registry throws away is not a backstop, and the checker should not say it is. All 277 hand-classified true positives are still reported.

3.68 A warning refuses only when it takes the value back

Measured 2026-09-27. Β§3.67 left the field-matched guard out because the file-wide one was hiding a wider test: any returned warning dict counted as a refusal. Both changes ship now, together, and the second is what makes the first read true.

_refuses_in_branch asks one thing of a warning: does the branch that returns it also take the value back? The branch is the innermost if, for or while around the warning, or the whole body when there is none. Taking the value back is writing a field the onchange watches (the quantity clamped to zero, the removed document restored, the component taken out again), or clearing any field on the record to False, None or empty (the blocked partner, the registrant that no longer fits). A raise still counts on its own. A warning with no write beside it is a notice, and the user saves past it in the form as easily as in an import, so nothing is being enforced anywhere and the rule's harm is not there. A write outside the warning's branch does not count either: account.move sets its show_name_warning flag in an earlier if, and that flag is not a refusal.

The first draft only looked for writes to watched fields, and reading what it removed stopped it. spp_change_request_v2's _onchange_request_type_id watches the request type and clears the registrant when a group lands on an individual-only type. Nothing else checks that pairing: the registrant's domain is a form attribute, so a change request made through the API can pair them. Clearing a field the onchange does not watch is a refusal too, and the test now says so. Setting one to a suggested value is not: a scrap order's recommended location, moved in for the user with a note, refuses nothing.

before after
OCA 99 99
OpenSPP 153 157
odoo core 2,345 2,339

Every change was read. Added: on OpenSPP the four from Β§3.67, 3 true and 1 false, now in artifacts/classification-openspp-rest-2026-09.json under a new class, backed-by-the-parent-constraint. On core, 11: the 8 true from Β§3.67 (the four partner blocks, loyalty expiry, negative price rounding, expense edit rights, a component equal to its product) and 3 of the 4 that are checked on the server elsewhere (the work order's dates, the reference unit, the Argentinian journal). The API key's duration no longer appears: its onchange only warns. OCA's account_loan note does not come back.

Removed: 17 on core, all read, and none refuses anything. Six are advice about a value that saves anyway: an OCR number that fails its checksum, an invoice name too long for GST rule 46(b), an EGP rate with more than five decimals, a rounding finer than the decimal accuracy, a package heavier than its type allows, a pricelist minimum quantity that event tickets will ignore. The other eleven say what is about to happen: tokens archived with a payment method or provider, a payment method attached to a provider, pricelists archived with the feature, a carrier's rating error, a subcontracting purchase order, a decreased service quantity, tracked products already in stock, a storage category with no location, an open cart keeping its pricelist, and a scrap order's recommended location. None of them is in a classification file.

So the rule now reports 15 more onchanges than it did and 17 fewer, and of the 15, 11 are true. OpenSPP's census gains 3 true and 1 false; core's 17 removals were notices nobody had read, and they are not claimed as false positives fixed. All 280 hand-classified true positives are still reported, the 3 new ones among them.

What it still does not see: a constraint whose fields sit under another name, as spp_import_match's parent @api.constrains("field_ids") does for its child's field_id, and a check the server makes in an inverse, a wizard's create() or at posting, none of which this checker reads as a constraint. The 3 false additions on core are that. Β§3.69 takes the first two of those three places; posting stays.

3.69 The same check, made again where the server runs it

Measured 2026-09-27. Of Β§3.68's three false additions on core, two were checks the server already makes in the same class under a name the constraint test does not read. mrp.workorder's date_finished onchange refuses to unplan one work order, and _set_dates, the inverse= of both date fields, raises the same sentence. The API key wizard's onchange and its create() both call res.users.apikeys._check_expiration_date on the expiration date.

_server_repeats_the_check treats an onchange as backed when a method of its own class that the server runs whatever the client is (create(), write(), a field's inverse=, an @api.constrains method) makes the same check. The same check means one of two things, and both are strict because every yes suppresses a finding:

  • the exact message the onchange raises, raised there too, at least 20 characters so a stock word does not match. A paraphrase is not the same check;
  • the same check-shaped helper (check or validat in its name) called there, where the onchange hands it a field it watches. A bare self._check_company() in the onchange is checking something else, and write() calling it too says nothing about this value.

Not counted: a method the client chooses to call, like an action_ button, and posting, which is why l10n_ar's journal pairing stays reported. A draft can hold the value until somebody posts, and that window is the gap the rule is about.

before after
OCA 99 99
OpenSPP 157 157
odoo core 2,339 2,338

One finding goes, the work order's, read as false in Β§3.67. The API key wizard was already out: its onchange only warns, and Β§3.68 stopped counting a bare warning. Nothing else moves on the three trees, which is the evidence that the two signals are narrow. They are there for code this corpus has not met yet, where a check written once for the form and once for the server is a common way to be careful. All 280 hand-classified true positives are still reported.

3.70 A dotted name is read, not matched

Measured 2026-09-27. constrains-ignores-dotted-names was a regex for a quoted name of two segments, lowercase letters and underscores, anywhere in the decorator's arguments. A sweep of the checkers found two shapes it could not see. @api.constrains('partner_id.country_id.code') has three segments, so where the regex wanted the closing quote there was a dot. And the class had no digits, so @api.constrains('l10n_in_gst_treatment', 'partner_id.l10n_in_pan') was invisible, as was any dotted name through an l10n_ field. A check that looks like it runs and does not is what the rule is about, and its own checker was one.

constrains_declares_a_dotted_name reads the arguments the way the other constrains checker already did, with _decorator_args, and reports each method whose declaration contains a dot, on the decorator line. The message says what is left to trigger the check: nothing when every name is dotted, and the plain names when some are. On 18.0 a dotted name is not an error. _constraint_methods logs "is not a field name" the first time the model collects its constraints, and _validate_fields never finds it among the written fields (odoo/models.py, 18.0 at 3c3e3b3). A computed list, @api.constrains(*FIELDS) or the callable form Odoo also accepts, goes to the gap channel, as it does for constrains-must-list-every-field-read. Both checkers meet the same unread method, so _note_unreadable now records a method once. Twice, check would print it twice and fieldtest would count it as two files it could not fully read.

before after
OCA 99 99
OpenSPP 157 157
odoo core 2,338 2,338

Nothing moves, row for row, and that was measured rather than assumed. The same AST read over every @api.constrains in the three trees, tests included, finds 815 decorators (497 on core, 43 on OCA, 275 on OpenSPP) and not one dotted name. 41 name a field with a digit in it, which the regex would have missed had it been dotted. The 9 computed lists are all on core and were gaps already. The rule stays in the NEVER FIRED list and keeps its unproven grade.

Its value is somewhere else: in client code under nexa check, and in generated answers, where an @api.depends beside the constraint takes the same dotted name and means it. That is the mistake the rule was written for (Β§3.7), and the three-segment spelling of it would have passed there too. The corpus now decides 8 rules by a regex and 19 by an AST pass. All 280 hand-classified true positives are still reported.

3.71 What a string names, and what a subscript picks

Measured 2026-09-27. Β§3.66 taught the depends check that x.mapped("a.b") reads x.a.b. The sweep that found Β§3.70's gap found the siblings of mapped() still silent, and one more place where a path stopped:

  • x.filtered("is_paid") reads x.is_paid, and x.filtered("a.b") reads x.a.b.
  • x.sorted("sequence") reads x.sequence. Odoo 18 takes one field name there, as itemgetter(key). On 19 the argument is an order, "date desc, id", whose terms each name a field (_sorted_order_to_function, odoo/orm/models.py on 19.0).
  • x.filtered_domain([("state", "=", "done")]) reads x.state for every leaf written out, and through an any leaf to the related records. x.grouped("f") keys on x.f.
  • rec.line_ids[0].amount read nothing past line_ids, because _attr_path and _resolve_iterable stopped at a subscript. rec.partner_id.child_ids[:1].email reported partner_id.child_ids and never the email, which is the field whose change goes unnoticed.

_named_reads replaces _mapped_reads and reads all five methods, literals only. A lambda, a name and a domain built at run time stay unread, and an order with any word after a field but asc, desc, nulls, first or last is read not at all rather than in part. A subscript is looked through when its index is a literal integer or a slice, since indexing or slicing a recordset gives records of the same path. The other thing a subscript does is record[name], which reads the field called name, so a string or a variable as the index stops the path as it did before. Every exemption the check has applies to every new read: a trailing .id, a field the method computes, a non-stored compute, a wizard, display_name, a declared longer path, and a read inside a raise or a log line.

constrains-must-list-every-field-read had only ever read attributes, so if any(self.mapped("date_end")) under @api.constrains("date_start") was silent. It was measured with the same reads before it took them. depends-must-not-name-unread-fields steps aside when the body reads a field its decorator does not declare, because the depends check owns that disagreement, and it now counts the same reads when it decides. That moved nothing on the three trees.

before after
OCA 99 101
OpenSPP 157 163
odoo core 2,338 2,358

Nothing disappears, and no other rule moves. By part: the literal forms add 1, 6 and 11 depends findings on OCA, OpenSPP and core, the subscripts add 6 on core, and the constrains check adds 1 on OCA and 3 on core. filtered_domain() and grouped() add nothing on the three trees. They name fields exactly as filtered() does and are kept for code these trees do not hold. A loop over enumerate(), the other shape the sweep sized, added nothing and was left out.

Every addition was read.

OCA, 2, both true, now in artifacts/classification-oca-2026-09.json. account_move_name_sequence sets made_sequence_gap to False for moves outside a hashed journal with self.filtered("journal_id.restrict_mode_hash_table"), and neither its decorator nor account's, which Odoo merges with it, names the flag: securing a journal leaves its existing moves on False. account_spread_cost_revenue's journal check reads mapped("line_ids.move_id") and declares only journal_id. True as the rule states it, though every move the module creates for a line takes the spread's own journal.

OpenSPP, 6, all true, in artifacts/classification-openspp-rest-2026-09.json with the other findings later changes surfaced. artifacts/classification-openspp-2026-09.json is the reading of the two flagship rules made on 2026-09-16 and is quoted as that reading. Five are spp_programs' registrant counts, each set to 0 for the partners self.filtered("is_registrant") leaves out, without is_registrant declared. Every create path in the tree sets the flag at creation and nothing rewrites it. The sixth is spp_approval's current tier, the first pending tier review by sequence. A review's sequence is a stored related of its tier's, so reordering a definition's tiers while reviews are pending leaves each review on the tier the old order put first.

Core, 20: 10 true, 10 false. The true ones:

  • hr_expense's report is reversed once every entry has a reversal (filtered("reversal_move_ids")). A company-paid report's entries are entry moves, whose payment_state and amount_residual, the two fields declared, never move on a reversal, so the report stays paid.
  • hr_holidays' accrual plan takes its unit from level_ids[0].added_value_type. Changing the plan's time off type recomputes every level's unit, and the plan's own, which allocations read, stays where it was. Each level's copy of the first level's unit is the same shape, reached only by writing the first level directly: the form writes level_ids on the plan, which is declared.
  • hr_holidays_contract's multi-contract check filters on employee_id and declares the dates. Moving a leave to an employee whose contracts under it keep different schedules is not checked.
  • website_sale_gelato's publishing check filters on gelato_template_ref and declares is_published. A template set on a published product whose print images have no data is not checked. The core sample already holds its sister check on product.document as true.
  • stock.scrap takes its quantity from move_ids[0].quantity and declares move_ids.move_line_ids.quantity, one of the two things a move's quantity is computed from. The other, the lines' unit, is the gap. Narrow.
  • point_of_sale copies the first POS category's colour onto the product and does not follow a later change to it, on a readonly=False default. What Β§3.66 said of incoterm_location applies.
  • Three with a field nothing in core rewrites, as Β§3.66 counted response_code. base_automation gives an automatic name only to the actions filtered("base_automation_id") keeps, and every core path creates the action already linked. sale_expense_margin costs a reinvoiced line from its expense_id, set when the line is created. l10n_id_efaktur_coretax names a document after its first and last invoice by sorted("name"), and a posted invoice changes name only by resequencing.

The false ones are classes the check already had for attribute reads. None comes from reading a line wrongly:

  • storedness in another module, two. icon_image in base and highest_name in account are declared without store=True.
  • a decorator merged from another module, one. sale_timesheet's project partner calls super(), and sale_project's _compute_partner_id declares allow_billable. Odoo merges @api.depends across every override (Β§3.59), and the file in front of the check shows only its own.
  • covered through another declared compute, two. hr_expense filters on product_has_cost, which depends only on product_id, and the declared tax_ids depends on product_id too. website_slides filters on is_category, and flipping it re-sections the channel through _compute_category_id, which changes this slide's slide_ids, which is declared. This is the class the OpenSPP classification calls covered-by-its-own-depends, one step further removed.
  • a value kept on purpose, two. An account.payment.term.line defaults its days to the previous line's plus 30 only while they are unset, so declaring the sibling's days would overwrite a deliberate 0 and make the field depend on itself. l10n_id_efaktur's document name is readonly=False, with a docstring saying it is computed at creation and may be renamed.
  • refused by the model while it matters, two. The POS session's cash journal reads its payment methods' is_cash_count and journal_id (filtered("is_cash_count")[:1].journal_id), and a payment method refuses every write but sequence while a session using it is open.
  • advice that cannot be taken, one, on the constrains side. mail.activity.schedule filters on error, a non-stored compute that @api.constrains cannot name: Odoo logs it as not writeable. The decorator lists error's writable inputs instead, and a comment says so.

So 28 findings, 18 true. On core the depends additions read 8 of 17, below the 11 of 15 that rule's core sample read (Β§3.51), and the constrains additions 2 of 3, beside that rule's 10 of 15 (Β§3.57). On OCA and OpenSPP they read 8 of 8. Each part was weighed apart, as Β§3.67 weighed its two, and none is left out. Each adds more true findings than false, and the false ones are not a new kind of mistake: they are what this check already says when the same field is read as an attribute, and keeping a spelling silent would not fix them, only hide them behind the spelling. None of the core readings goes into artifacts/classification-odoo-core-2026-09.json, which is a fixed-seed sample. The hand-classified true positives are now 288, and all 288 are still reported.

What it still does not follow, named so nobody finds it later:

  • a lambda, a name or a variable as the argument, a domain built at run time, and an Odoo 19 Domain object;
  • a subscript by a name, rec.line_ids[i], which could be either kind of subscript;
  • a name assigned a recordset (pending = ...sorted("sequence")[:1], then pending.tier_id): _aliases binds loop variables only;
  • mapped() on the result of another mapped(), and a loop over a mapped() result, as Β§3.66 left them;
  • an attribute read straight off a call, rec.sudo().partner_id.name or rec.line_ids.filtered(...).amount. Sized and not read: core +9 on the depends check (six through sudo(), three through with_context()), OCA +1, OpenSPP 0;
  • a read another declared path already reaches: a read compute's own dependencies (product_has_cost above, and OpenSPP's three older covered-by-its-own-depends), or a flag whose change re-sections a declared relation (is_category above). Following either would be a narrowing of its own.

3.72 What a lambda reads, measured and not taken

Measured 2026-09-27. Β§3.66 and Β§3.71 left a lambda unread, and Β§3.66 gave the reason: "the fields a lambda touches are a guess from here". For the recordset methods that reason does not hold. x.filtered(lambda r: ...) calls the lambda with every record of x, and so do mapped(), sorted() and grouped(); sorted(x, key=...), max() and min() do the same by iterating x. The parameter is bound to x's records exactly as a loop's target is, so r.f is a read of x.f. Whether that read is a dependency is a separate question, and this section is the answer to it.

The binding was built, each lambda given its own scope with its first parameter bound to the receiver's records, and measured against fed0255, the tree before Β§3.70 and Β§3.71 landed. Depends and constrains were weighed apart:

before depends constrains
OCA 99 +8 0
OpenSPP 157 +1 0
odoo core 2,338 +127 +27

Every one was read against the code, and they do not read true. On core, 40 of the 127 depends findings are true, and 28 of those 40 are smart defaults, readonly=False computes, which Β§3.51 kept as a consideration inside a verdict rather than a rule. Twelve are a stored value that goes stale. Constrains reads 9 of 27. OCA reads 2 of 8, judged the way core was, and OpenSPP's one is true. Β§3.71 took each of its parts because each added more true findings than false. These add about twice as many false as true, depends and constrains alike, and OpenSPP's single finding cannot carry that.

The reason is what a lambda is for. A loop body computes the value from its inputs. A filtered() predicate decides which records get which treatment, and the fields it tests say what kind of record this is. Core's 87 false depends findings:

  • 28 test an identifier or a type that nothing changes after creation, or that the model refuses to change. Six are not line.display_type on sale and purchase lines, whose write() raises on a change of type. The rest include an EDI document's format, a country's ISO code, a done picking's type and destination, a location's usage, a POS order line's company, a model's technical name and create_date.
  • 12 are a guard on which records to touch: drafts only, e-way bills still pending, entries not yet hashed, sale lines whose purchase price is kept.
  • 12 sit in a compute whose own field another module declares without store=True, outside the rule.
  • 19 are covered already, in a way the file cannot show. Seven by the parent method's decorator, which Odoo merges across overrides (Β§3.59). Seven through a declared computed field that depends on them, since Odoo's triggers are transitive: a task's project_id is computed from parent_id.project_id, so a compute declaring project_id already follows the parent. Five through a declared field that always changes with them: a picking is done exactly when date_done is set.
  • 9 read the method's own output, filled through a helper, update() or write().
  • 7 are one of a kind: moves sorted by ref only to number a batch in order, residuals Odoo rewrites in SQL when an account's reconcile flag is written, a Greek invoice type the compute keeps once set, a journal flag that cannot change while it matters, a clause that runs only for unsaved records, and a service type the parent already sets for every good.

The constrains additions read the same way. Of the 18 false, 11 name a non-stored related or computed field, seven of them country_code, and _constraint_methods, the collector Β§3.70 reads, logs such a name in @api.constrains as not writeable. Six name a field fixed at creation: a payment provider's code on three providers, a Gelato print image's flag, a journal item's move, and a sub-channel's parent, which write() refuses. The last is a move's company, which re-derives the journal the check declares.

What is true is worth naming:

  • account.move.line._compute_amount_residual decides which journal items carry a residual by their account's reconcile flag and type, and declares only account_id. For the flag, Odoo rewrites the residuals by hand in SQL when it is written (_toggle_reconcile_to_true and _toggle_reconcile_to_false), so that half is false. The type has no such hand: switching an account to Bank and Cash leaves its items' residuals where they were.
  • mrp.production._compute_reservation_state filters the components on product, picked, quantity and unit, and declares only their state, which _recompute_state writes only when it changes. Marking a component picked leaves "MO Readiness" as it was.
  • On constrains, account_report's domain check declares formula and filters on engine, so switching an expression's engine to domain over a formula that is not one is never checked. So too a barcode rule switched to GS1 over a pattern without its two groups, a journal made an AFIP point of sale with no number, a loyalty rule switched to a code, and a paper format given a page size beside a named format.

OCA's two are account_check_deposit's in-hand account, which follows a payment method line whose method can be changed, and account_move_name_sequence's split sequence, which skips moves outside a hash-secured journal and does not re-run when a journal becomes one: the flag Β§3.71 found in the same module's gap check. Its six false are a field core declares without store=True, a journal item's move, a posted bill's type, a payment method's code, and the journal and type of a move that already has a sequence number, which write() and action_switch_move_type both refuse to change. OpenSPP's one is spp_area's center areas, filtered on a role line's role_type, a related of the role's type: a role switched from local to global through the API keeps its areas on every user, where the form's onchange clears them.

So the binding is not taken, and a test pins that. What would separate true from false here is facts from outside the file: which fields are stored, related, computed from what, merged from a parent's decorator, refused by write(). The tree facts the ondelete and check_company triggers read are where that would start. It is proposed, not built, and even built it would leave the guards and every identifier the model does not refuse to change, 34 of the 87, which no declaration decides.

Two narrower questions were read on the way.

A sort orders the batch only when it sorts the batch. Four of the findings the binding reached came from sorted(). Two sort self, the records being computed: account.move._compute_name numbers a batch by date and ref, and no move's name follows another's ref, while _compute_balance_start sorts statements by first line, a field it also reads for each statement, so that finding stood already. The other two sort a relation and pick from it ([:1], or the first with an email), which is a dependency when the key can change, as a bank account's allow_out_payment can and create_date cannot. The line runs between self and a relation, not between iterating and picking.

A lambda's parameter hides a loop variable of the same name, and _aliases, one map for the whole function, does not know it. In event's _compute_event_mail_ids, lambda mail: mail._origin.mail_done runs over the event's own mail lines, and a loop further down binds mail to the event type's. The check reports event_type_id.event_type_mail_ids._origin.mail_done, a path the method never reads, twice. The binding put mail on the event's own lines, whose field the method computes, and both went. Hiding the parameter without binding it was measured too, and it is worse. The common spelling is for x in self.filtered(lambda x: ...), where the loop and the lambda walk the same records and the one map is right by accident. Hiding took nine of those along with the two, and at least two of the nine are true: ir.actions.server's mail and SMS template checks filter on the action's type, state, and do not declare it, so switching an action to sending an SMS over another model's template is never checked. Only the binding fixes the two without losing the nine, so they stay with it.

What ships is the first false class: the method's own output. account.account's _compute_account_type filters on not account.account_type and hands the records to _get_closest_parent_account with the field's name. The check already exempted a field the method computes, but it knew only what the method assigns, rec.total = ..., and a helper, update() or write() makes no such store. So the field a method produces read as a field it depends on. _computed_fields now maps each method to the fields its file declares with compute="<that method>", and those count as its output too. A field another method computes is still an input, and the same field read off a relation, parent_id.rrule, is still a recursive dependency Odoo wants declared.

That does not need the binding to matter. On the trees as they are:

before after
OCA 101 101
OpenSPP 163 161
odoo core 2,358 2,357

All three were read. OpenSPP's two are the pair its flagship classification already marked false as self-read: spp_area clears and refills center_area_ids through update(), and spp_service_points reads the service points it computes. Core's one is calendar.recurrence._compute_rrule, which compares rrule with the serialized rule and write()s it when they differ. With the binding, the same exemption took nine of its 87 false findings on core, the _compute_account_type the sweep started from among them. Nothing is added, and all 288 hand-classified true positives are still reported.

3.73 A path through a method's own output, measured and kept exempt

Measured 2026-09-27. The depends check never asks a compute to declare the field it produces, and it used to give the wrong reason: that the ORM refuses it. Odoo 18 refuses nothing here. A field named in its own @api.depends is dropped from its triggers without a word (resolve_depends, "do not make self trigger itself", odoo/fields.py 842-846), so that advice would do nothing. The skip covers only the field's own name, though. A path through it, @api.depends("team_id.member_ids") on the compute of team_id, is kept, and the compute re-runs when the team's members change. The exemption skips every read rooted at the output, lead.team_id.member_ids included, so the part of it that rested on "cannot be followed" was measured: the exemption narrowed to the bare field, on the three trees.

before bare field only
OCA 101 101
OpenSPP 161 161
odoo core 2,357 2,394

All 37 were read, and every one is the same shape: a compute that keeps its current value while it still fits and picks a new one otherwise, reading the value it chose earlier to decide. Declaring the path would re-run it when the record it chose changes.

17 are true as the rule states it, and 16 of those are smart defaults, readonly=False computes (Β§3.51). A lead keeps its sales team while its salesperson is a member (team_id.member_ids, team_id.user_id), as a lead mining request does. A lead or a task keeps its stage while the stage belongs to its team or project. A contract keeps its salary structure type while the type's country matches, and an allocation its accrual plan while the plan's time off type matches. Maintenance equipment and requests keep their team, and a request its technician, while they belong to its company. A reconcile model line keeps its journal while the journal's type matches, a tax its tax group while the group's country and company match, a product its purchase unit while it shares the unit's category, and a chatbot step the answers that trigger it while their steps come first. In each, the kept value goes stale when the configuration it was checked against changes, and in each a recompute would replace a value somebody may have chosen; Β§3.51 reads that as a decorator kept narrow on purpose. The one plain true is sale_project's task sale order. The method fills the task's partner only when it is empty, so the partner is mostly an input, and re-parenting it under another company leaves the sale order in place.

20 are false. In 12, what the output points to cannot change what it is: a variant's template, the BoM a BoM line or an operation belongs to, a production move's BoM line, a work order's operation, a packaging's product, a milestone's project, a skill's type, a chatbot answer's step, a custom value's attribute. In 7, write() refuses the change: the company hierarchy ("The company hierarchy cannot be changed", twice), a picking type's or a location's company (three), a journal's company once it holds entries, and a base field's relation. The last acts only when a declared field changes: hr_holidays looks at the leave type's requires_allocation only for a leave whose employee has just changed in the form.

So the narrowing reads 17 true to 20 false, and 16 of the 17 re-check a choice the compute made itself. Β§3.71 took its parts because each added more true findings than false; this adds fewer. The exemption stays whole, now on a measurement rather than on a premise that was wrong, and a test pins it. Nothing moves on the three trees, and all 288 hand-classified true positives are still reported.

3.74 A claim written as a list

Measured 2026-09-27. layers.cited_ids decides what an answer claims to have applied, and it read the Rules applied: line from the colon on. The prompt asks for the ids comma separated on that line, and the 7B does not always oblige. Re-capturing the README's plain-script sample (3.75), it wrote the label on its own and listed its four ids under it, one per line. That answer is a fixture now, LOAD_PRICES_BULLETED. Read from the label's line, the list was an empty claim: the knowledge layer printed "the answer cites no rule ids" under an answer citing four, and returned UNKNOWN without asking whether any of them exist.

The second half is the one that mattered. On the line, an id with no rule behind it is a critical finding and a FAIL. In a list it was never looked at, so an invented citation in that shape went through. corpus.report_citations, behind nexa verify, kept its own copy of the parse with the same gap, and calls cited_ids now.

The claim is the label's line and, when nothing follows the colon, the list directly under it. Blank lines before the first item are skipped, and the first line that is not a list item ends it. A code block is still never a source of citations, and a rule named under Not handled: is still not a claim to have applied it. Tests pin the recorded answer, an invented id in its list, and where the list ends.

On the check subjects, the first run of 3.75, made before the fix, had 4 of its 15 reviews citing nothing the parser could read: all four on load_prices.py, and all four PASS. The committed run, made after it, has none. The verdicts did not follow the citations, though. load_prices.py passed on all five committed runs with its citations read, since UNKNOWN on the knowledge layer never stood in the way of a PASS. What those answers escaped was the check on what they cited, not a verdict.

3.75 The check and retrieval rows, read from their runs

Measured 2026-09-27. The README's nexa check rows rested on artifacts/eval-check-2026-09.json from 2026-09-14. Two of its five subjects, subjects/web/src/partners.ts and board.js, went with the TypeScript and JavaScript corpora in 8d155aa, and nexa check refuses both now. The rows still agreed with the artifact, and nothing read the artifact, so nothing said so. The retrieval rows had the opposite weakness: re-measured by hand whenever a rule changed, with no run committed to check them against, and 8b165a1 found them a rule behind.

The check run is redone over the three subjects left, five runs each, and committed over the old one. The old artifact held the second of the two runs the README quoted:

2026-09-14, five files 2026-09-27, three files
Reviews 25 15
Findings reported about the files 23, in 5 of 5 10, in 3 of 3, and 3 advisory apart
The same findings on every run 5 of 5 files 3 of 3 files
PASS / WARN / REJECT 0 / 12 / 13 5 / 5 / 5
Same verdict every run 3 of 5 1 of 3
Rules the model cited beyond the checkers 3 1

Two counts in the eval were wrong, and neither was the tool's. file_finding_count dropped suppressed hits and kept advisory ones, though its comment said live findings only: the advisory grade came after it. On these files that read 13 where check prints 10 and fieldtest counts 10, with 3 advisory apart. It counts what check counts now, and advisory hits go in a field of their own that the report prints the way check does. The other was the citation parse, 3.74. Both were found reading the first run, which is why the committed run is the second.

What the run says. The deterministic half is what it was: the same ten findings on every review, and the same ten fieldtest finds with no model. load_prices.py carries four of them where it carried eight, since three of the plain-Python rules were narrowed in between (3.47, 3.61). The model cited one rule beyond the checkers in fifteen reviews, check-constraint-cannot-cross-tables, the rule pinned into every review, and it cited it on the plain script, where there is no constraint for it to be about. The verdicts spread evenly, and only load_prices.py held one for all five runs: PASS, for an answer that reproduces the file with its defects repaired. The file itself still exits 1. --no-model took 0.42 to 0.52 seconds on the subjects and 0.78 on area_core.py, against 0.26 on 2026-09-14, so the README says under a second.

figures.py reads both runs now. check.* comes from artifacts/eval-check-2026-09.json: files, runs and reviews, the findings taken once per file, whether they came out the same on every run, the verdict counts, stability, and citations beyond the checkers. --check fails on a row naming a file the repository no longer has, which is the drift this entry started from, and on files reviewed unevenly.

retrieval.* comes from artifacts/retrieval-2026-09.json, the rows of nexa eval --retrieval --json, which now writes a head beside them: the depth it ranked at, the embedder, and the corpus hash it ranked against. One run at the operating depth answers both published figures, since retrieve() fills its slots in one fixed order, and a separate top-3 run agreed with it on all 28 rules. --check fails when a rule is missing from the run and when the hash differs from corpus_meta.json. The ids alone would have caught the drift 8b165a1 corrected, a rule added, and missed this entry's other change, a rule reworded.

That rewording is constrains-ignores-dotted-names, whose title said "silently ignores". Odoo 18 logs "@constrains parameter ... is not a field name" when the model first collects its constraints (3.70), so the title reads "ignores dotted field names without an error". Retrieval was measured before and after it: 28 of 28 at top 6 and 26 at top 3 both times, and no rule changed rank. The rule itself stays second for its own probe, behind the pinned rule, at a score of 0.700 where it was 0.694.

Left. The README's nexa ask stability row and escalation row rest on artifacts/eval-2026-09.json of 2026-09-14, and six of its twelve questions were TypeScript and JavaScript: 10 of the 21 escalations and 5 of the 11 rescues. The rows now carry that date and say so. Re-running them needs the 30B for the escalations, which is a session of its own.

3.76 The probes' numbers, committed and checked

Measured 2026-09-27. The README said the corpus "currently meets all three" probes, with 99/99 rewrites still caught, 0 of 13 satisfied patterns bypassable and 0 contradictions, and that "those numbers are regenerated on every build, so a checker cannot quietly weaken". The build did re-run the probes, but nothing wrote their numbers down, and the sentence had not moved since TypeScript and JavaScript left the corpus (8d155aa). The figures are 82/82 and 0 of 1. The coverage table had 82/82, corrected by hand in 8b165a1.

The claim was untrue in a second way. adversarial.py fails only when a critical rule loses a rewrite, so a high or medium checker that stopped seeing a rewritten sample would have passed the build and CI while the README went on quoting the old count.

adversarial.py all --json now writes every row the three probes produce: one per rewrite and whether it was caught, one per satisfied_pattern with the comment it was attacked with, one per cross-hit with its class, and the corpus hash. There is no timestamp, so two runs over the same corpus and checkers write the same bytes, and --check compares a fresh run with the committed file, fails on any difference, and names the counts that moved. The report the probes print is unchanged byte for byte; it is printed from the same rows. nexa build writes the file on every build, CI's corpus job runs --check, and figures.py reads adversarial.* from it, so the coverage rows and that paragraph are marked. The README and nexa help fieldtest said "32 samples"; they say one per rule now.

A checker change and a corpus change both fail CI until the file is rewritten, and figures.py sees a corpus change on its own, from the hash. The coverage table's cross-hit row belongs to crosscheck, which has no artifact yet, and stays unmarked here; Β§3.85 marks it.

3.77 The pipeline rows, re-measured on Odoo alone

Measured 2026-09-27. The README's stability and escalation rows rested on eval-2026-09.json of 2026-09-14: twelve questions, six of them TypeScript and JavaScript, which left the corpus in 8d155aa. 10 of its 21 escalations and 5 of its 11 rescues were on those. It is re-run over the nine questions eval.py asks now, all Odoo, five runs each: 45 pipeline runs in 32 minutes on the 6 GB card, the 30B split 22% GPU and 78% CPU.

2026-09-14, twelve questions 2026-09-27, nine Odoo questions
PASS / UNVERIFIED 50 / 10 38 / 7
Same verdict every run 12 of 12 8 of 9
Escalation triggered 21 of 60 18 of 45
Escalation rescued a bad draft 11 of 21 11 of 18

figures.py reads pipeline.* from the rows, and fails on an answer for a stack the corpus no longer has, the drift this entry started from, and on rows without the escalation fields, which is an ask run read as a pipeline run. The README rows are marked, and name nexa run, which is what --pipeline measures.

Two questions moved, and one of them is a finding. The onchange question, UNVERIFIED five times on 2026-09-14, now escalates every run and the 30B repairs it every run. The negative-quantity question went the other way, PASS five times then and UNVERIFIED five times now. Both models answer it with an @api.constrains comparing the quantity to zero, and prefer-sql-constraint flags that shape: graded advisory in 3.38, a preference and not a defect. The semantic layer reports the finding and keeps it out of its verdict, as 3.38 says it must. The domain layer checks the same rule and lets it decide, and worth_escalating counts it either way. So the draft escalates on a preference, the 30B writes the same constraint, and the run ends UNVERIFIED.

The evidence bundles of the run (local, not committed) say how far that reaches. Of the 18 escalations, 10 were triggered by an advisory finding and nothing else: the five negative-quantity runs, none rescued, and the five runs of the positive-amount question, all five counted as rescued when the 30B's answer happened not to trip the rule. Without them the rescue rate is 6 of 8. The eval rows record whether a run escalated, not why, so the README row keeps 11 of 18 and this entry keeps the reason.

3.38 says an advisory finding gates nothing and nothing fails because of one. The pipeline's own tests say otherwise: their escalation example is NEGATIVE_QTY, "a single-column bound that belongs in a CHECK", written before 3.38 graded the rule. Which of the two holds is a decision about the design rather than a bug in either, so nothing here changes it, and the numbers above are what the tool does today.

Decided 2026-10-01: Β§3.38 holds. Β§3.81 makes the answer layers agree with it, and re-measures both rows.

3.78 An attribute read straight off a call

Measured 2026-09-28. Β§3.71 left rec.sudo().partner_id.name unread: _attr_path stopped at a call. _resolve_iterable already knew which calls hand back records of the same path, sudo(), with_context(), filtered(), sorted() and exists(), because a loop's iterable goes through it, so a chain whose base is a call now resolves through that same list. A loop and a read cannot disagree about it. mapped() stays out, because it returns values, and so does any other call, browse() and a method on the record included.

The three trees were scanned with the committed checkers first, and each scan reproduced its artifact hit for hit, suppressed and advisory findings included. Then with the change:

before after
OCA 101 102
OpenSPP 161 161
odoo core 2,357 2,366

All ten are the depends check, as Β§3.71 sized them: core's nine through sudo() (six) and with_context() (three), OCA's one through filtered(). Nothing disappears, and no other rule moves: depends-must-not-name-unread-fields already steps aside for any body that calls a method on its record, and rec.sudo() is one.

Every addition was read. OCA, 1, true, now in artifacts/classification-oca-2026-09.json. account_check_deposit stores its in-hand check account from the payment_account_id of the journal's manual inbound payment method line, read through filtered(), and declares only the journal. A deposit drafted before that account is set stays empty after it is set, and get_all_checks goes on raising the error that tells the user to set it. The sibling finding on the same line, the payment method lines themselves, was already read true.

Core, 9: 4 true, 5 false. The true ones:

  • account.move's partner display name, for a move with no partner and no source email, is "#Created by:" and move.sudo().create_uid.name, and declares neither. Renaming the user leaves every such bill on the old name. create_uid itself is set once, at creation, and is true as the rule states it, as Β§3.66 counted response_code.
  • l10n_sa_edi's QR code reads the journal's production CSID certificate and declares the CSID JSON. True as the rule states it: in core the certificate is written in one place, on the line after the JSON, so the declared field always moves with it.
  • loyalty.reward's description names the product through with_context() and does not follow a rename, on a readonly=False default. What Β§3.66 said of incoterm_location applies.

The false ones are classes the check already has for attribute reads:

  • storedness in another module, three. pos_sale reads its order's POS lines through sudo() in its overrides of _compute_amount_to_invoice and _compute_amount_invoiced, and sale declares both fields without store=True, so they never go stale.
  • covered through another declared compute, one. certificate's private key reads content and declares pem_certificate, a stored compute on content; Odoo builds its triggers from transitive dependencies, so writing content already recomputes the key.
  • a value kept on purpose, one. purchase's line reads the product's display name in each vendor's context only to recognise whether the description is still a default ("record product names to avoid resetting custom descriptions"), and a rename would not change it.

So 10 findings, 5 true: an even split, where Β§3.71's parts each added more true than false and Β§3.73 declined a narrowing that added fewer. It is taken on Β§3.71's other argument, which is the deciding one here. Every false finding is what the check already says when the same field is read as an attribute, and keeping sudo() silent would not fix any of them, only hide them behind a spelling. Three of the five are the storedness class that tree facts would settle. The hand-classified true positives are now 289, and all 289 are still reported.

3.79 A read through mapped(), measured and not taken

Measured 2026-09-28. ondelete-is-a-decision fires on a Many2one without ondelete that its file reads through, and _dereferences_of counts only <x>.<field>.<attribute>. Β§3.66 taught the depends check that x.mapped("a.b") reads x.a.b, and this check never learned it, so self.mapped("partner_id.lang") was not a read through partner_id. It fails its own way once the parent is deleted: the attribute yields False, and mapped() leaves that record's value out of what it returns.

The read was built, a literal mapped("<field>.<attribute>") counted beside the attribute reads and worded its own way, and measured against the committed artifacts, which the checkers of Β§3.78 made:

before after
OCA 102 102
OpenSPP 161 161
odoo core 2,366 2,367

Nothing disappears, and no other rule moves. The change saw thirteen such reads. OCA has none through a field the check considers. OpenSPP's four and eight of core's nine are on fields an attribute read already reports, and a finding names the attribute read when it has one, so those findings read as they did.

The one it adds is false. stock.location's parent, location_id, declares no ondelete, and the read is children_quants.mapped('location_id.display_name'), in the error that refuses to archive a location still holding stock: a quant's location, not a location's parent. The check places a read by the field's name and not by the model it is read on, and five of the thirteen reads are another model's field of that name. Besides the quant's, pos_session reads the move_id of bank statement lines it has just created, twice, and sales_team the user_id of team members, twice.

Attribute reads are placed the same way, and none of this rule's 41 hand readings, 12 on OCA, 14 on OpenSPP and 15 on core, was false for that reason. Β§3.71 and Β§3.78 took a spelling whose false findings were classes the check already had for attribute reads. This one adds a single finding, false, of a class no reading of this rule has met, and nothing true. So it is not taken, and a test pins that. What would make mapped() worth reading is placing each read on the model it is read on, for both spellings, which is a change of its own and is named here rather than built. The hand-classified true positives are still 289, and all 289 are still reported.

3.80 A name assigned records, measured and not taken

Measured 2026-09-28. Β§3.71 named the gap with OpenSPP's approval tiers: pending = review.tier_review_ids.filtered(...).sorted("sequence")[:1], then pending.tier_id, reads nothing past the name, because _aliases binds loop variables only. A loop over such a name is the same gap one step on: moves = self.filtered(...), then for move in moves:.

The binding was built. A name is bound when every value the function assigns it resolves to one path, the test _held_recordset already makes for sudo(). A value naming the name itself, lines = lines.filtered(...), keeps the path the name held. A name bound any other way as well, as a parameter, a loop target, a with, an unpacking or a walrus, stays unbound. Measured against the committed artifacts:

before after
OCA 102 107
OpenSPP 161 169
odoo core 2,366 2,480

By part: the depends check adds 5, 7 and 113, the constrains check 0, 1 and 5. Core also loses four. Two are the event findings Β§3.72 showed the method never reads, the _origin paths through the event type's mail lines: a comprehension over mails_to_remove now binds mail to the event's own lines, which are what the lambda filters. The other two are depends-must-not-name-unread-fields stepping aside, once because the body now reads commercial_partner_id.is_company through p, which the depends check then reports, and once because len(project) hands a record to a call, as len(self.project_id) always did. Five more findings only moved to an earlier line, which changes no count.

Every addition was read. OCA, 5, all true, all smart defaults: account_check_deposit's currency and account_spread_cost_revenue's template journal and accounts follow the company's setting, read through a name assigned the record's company. The same reads spelled as attributes in account_spread.py are already classified true.

OpenSPP, 8: 4 true, 4 false. Β§3.71's own example is true as nothing rewrites it: the tier review form leaves tier_id editable, and the review's stored current tier keeps the old one. spp_vocabulary's mapping stores its display name from both codes' code, and write() refuses a rename only for a system vocabulary's shared codes. Two findings, one per code. The fourth is the constrains check: spp_claim_169 allows one default issuer per company, checks it when is_default is written, and never when a default issuer moves to a company that has one. The false ones are spp_registrant_gis's geom = rec.coordinates, whose geom.x, geom.y and geom.is_empty are attributes of a geometry and not fields, and a read in spp_case_programs that feeds only the method's non-stored output.

Core's depends, 113: 37 true, 76 false. The false ones:

  • 27 keep a value on purpose, visible in the code: a compute that fills its field only while it is empty or the record is new, a journal item's label rebuilt only while it still matches its product, a lot's dates offset from the product only "when create", a manufacturing order left on its product while the BoM that changed marks it outdated.
  • 17 compute a field another module declares without store=True.
  • 11 are covered from outside the method: a declared field that is itself a stored compute on the read. A statement line's internal_index embeds its date, and a journal item's debit and credit compute from balance.
  • 11 name a field the model refuses to change or fixes at creation: a picking type's company, a country's ISO code, a POS order's company.
  • 5 are declared by another module's override of the same compute, which Odoo merges.
  • 5 are one of a kind. res.partner.write() rewrites the commercial partner of every move by hand, three expense amounts can only change after the reinvoiced line built from them is zeroed and replaced, and one read sits in a fallback the form never lets run.

Of the 37 true, 28 are smart defaults, 4 read a field nothing in core rewrites, and 5 are a stored value that goes stale: a stock move's weight when its product's weight is edited, a task's working-time figures when it moves to another project or its company's calendar changes (three findings), and an own-expense bill's commercial partner when the report's payment mode changes.

Core's constrains, 5: 2 true, 3 false. stock.rule checks that its company matches its route's, and stock.route checks it from its side, each only when its own company_id is written, so a rule moved to another company's route is checked by neither. The other true one is the GS1 barcode rule Β§3.72 read. Two false ones are covered through a declared stored compute: Odoo validates the constraints of every field it recomputes (_compute_field_value, odoo/models.py at 3c3e3b3), so writing a sale order's company recomputes warehouse_id and runs _check_warehouse, and a move's product_uom follows its product the same way. The third names a field write() refuses to change on the online payments it covers.

Β§3.71 took each part because each added more true findings than false, and Β§3.78 took an even split. Core's depends additions read 37 of 113, the ratio Β§3.72 declined for the lambda binding (40 of 127, 28 of them smart defaults then too), and OCA's five and OpenSPP's three cannot carry that. The constrains additions read 3 of 6. So the binding is not taken, and a test pins that.

The new binding never gave a name a path other than the one it holds. Two things were met on the way that matter when this is revisited:

  • A name can hold a value that is not records. OpenSPP's coordinates is a GeoPointField, declared in the same file, and the declaration would have said so.
  • One map per function. _aliases binds a name once for the whole method. pos_sale's _compute_qty_invoiced iterates pos_line over its POS lines and then over their refunds, so the first read took the second binding. Β§3.72 met the same thing with a lambda's parameter; binding assigned names reaches it more often. Two of the constrains findings came in that way too, a lambda parameter sharing a name with a loop over a name now bound, and on the same records by accident, as Β§3.72 found that spelling usually is.

What would separate true from false is what Β§3.72 found. Facts from outside the file decide 33 of the 76: storedness, merged decorators, a declared compute that already covers the read. The keep-once shapes, 27, are mostly visible in the method itself, a guard on the field it computes or on whether the record is new, and are a narrowing of their own. The hand-classified true positives are still 289, and all 289 are still reported.

3.81 Advisory gates nothing in the answer layers either

Decided and measured 2026-10-01. Β§3.77 left a question about the design open: Β§3.38 says an advisory finding gates nothing, and the answer layers let one decide. Three answers were on the table. Advisory decides neither a layer's verdict nor escalation; it escalates but never ends a run UNVERIFIED; or it keeps deciding and Β§3.38 is amended. The first is taken. It is what Β§3.38 already says, and it never spends a 30B pass on a preference.

It reached further than Β§3.77 counted. Β§3.77 named the domain layer's verdict and worth_escalating. Reading the code for the change found two more:

  • decide combines the layers by reading their findings, not their verdicts. So the semantic layer's setting-aside, the one answer-side place Β§3.38's table names, never reached the verdict printed at the end: decide counted the finding the layer had set aside, and would have printed WARN on a preference with the domain layer fixed. An answer whose only critical finding was advisory was printed REJECT, which is nexa ask exiting 1.
  • The answer layers read the rule's flag alone. scan_source also reads the grade a trigger gives one finding: the touch and escape clauses of sudo-bypasses-record-rules grade theirs unproven (Β§3.47), and sql-constraints-are-ignored-from-19 grades a bare snippet unproven, since nothing was walked to say which series it is for (Β§3.62). check set those findings aside in a file, and the answer layers let the same findings decide in an answer, critical ones included.

The finding carries the flag now. Finding.advisory is set by one function, is_advisory(rule, grade), which scan_source calls too, so a file and an answer cannot disagree about which findings count. The domain layer has one finding per rule, and it is advisory only when every hit behind it is: a sudo().write() beside a sudo().search() decides, because the search does. render labels an advisory finding "advisory, gates nothing", and a PASS printed under findings says it found nothing but advisory ones. Telemetry still counts them under violated, because reported everywhere means there too.

an advisory finding
check its own heading, and out of the exit code
fieldtest counted apart from the per-kloc rate
the semantic layer recorded, and the verdict does not follow it
the domain layer recorded, and the verdict does not follow it (new)
the decision no reason for WARN or REJECT (new)
escalation never sends a draft to the 30B (new)

The tests' escalation example moved. test_pipeline escalated on NEGATIVE_QTY, written before Β§3.38 graded prefer-sql-constraint. It now escalates on the onchange question, recorded from one nexa run on 2026-10-01: the 7B refuses the date in an onchange (onchange-is-not-validation, critical), and the 30B answers with @api.constrains. Both answers are fixtures verbatim. NEGATIVE_QTY stays as the test that an advisory draft never loads the 30B, and each place above has a test of its own.

Both runs were redone, since both sets of verdicts carried the effect. nexa eval --check first, the three subjects five times each:

2026-09-27 2026-10-01
Findings reported about the files 10, in 3 of 3, and 3 advisory apart the same
The same findings on every run 3 of 3 files 3 of 3 files
PASS / WARN / REJECT 5 / 5 / 5 4 / 7 / 4
Same verdict every run 1 of 3 0 of 3
Rules the model cited beyond the checkers 1 0

The change decides one of the fifteen: an fsm_job.py review whose only critical finding was a sudo() call graded unproven on its own is WARN, where the old rule printed REJECT. No other verdict in the rows rests on an advisory finding alone. The rest is the model's own spread at temperature 0.15. load_prices.py gave up its five PASSes to one WARN from the structural layer, which reads the shape of the answer and not its code, and fsm_visit.py drew one REJECT fewer. fsm_job.py shows the same two REJECTs and three WARNs as before only because the change took one REJECT back.

Then nexa eval --pipeline, the nine questions five times each. It took 20 minutes where Β§3.77's took 32, nine passes on the 30B fewer:

2026-09-27 2026-10-01
PASS / UNVERIFIED 38 / 7 43 / 2
Same verdict every run 8 of 9 8 of 9
Escalation triggered 18 of 45 9 of 45
Escalation rescued a bad draft 11 of 18 7 of 9

The evidence bundles carry each finding's flag now, so every draft can be read against both rules. Under the old one these 45 drafts escalate 19 times, under this one 9, and the ten that went are Β§3.77's ten exactly. The negative-quantity question's five runs end PASS, where all five ended UNVERIFIED, and the positive-amount question no longer counts five rescues of a preference. Every escalation left is on a critical finding a larger model can act on: the onchange question five times, all five repaired, and the monetary question four times, two repaired, each a stored compute reading a field it does not declare. 7 of 9 sits beside the 6 of 8 Β§3.77 counted without the advisory escalations, and the monetary question's spread is the difference.

The eval rows still record whether a run escalated and not why, so figures.py cannot count advisory escalations from them (Β§3.77). There are none left to count, and a test pins that.

Nothing here changes a checker. scan_source calls the shared function where it computed the same thing inline, and a rescan of the two OCA checkouts reproduces their artifact hit for hit, 116 of 116. So the fieldtest artifacts stand, and the hand-classified true positives are 289 of 289, all still reported.

3.82 A field the constraint cannot name

Decided and measured 2026-10-01. constrains-must-list-every-field-read asks a constraint to name every field its body reads, and Odoo will not let it name some of them. A field that is neither stored nor inversible is never among the fields a write hands to _validate_fields, so a constraint naming it never runs for it, and _constraint_methods logs "@constrains parameter ... is not writeable" and does nothing more (odoo/models.py, 18.0 at 3c3e3b3). Β§3.71 met one such finding, Β§3.72 counted eleven, and the README's OpenSPP reading carried one as false: the gap was real, and the fix it named could not be applied.

The choice was between exempting those findings and asking for what the field is computed from. Exempting them all would have hidden every real gap among them, so the finding asks for the inputs, and only a constraint that already names every input is left alone.

Read from the declaration. The checker reads the fields the constraint's own class declares (_class_fields). Whether one can be named is Odoo's own test, three-valued (_writeable): a computed field is unstored unless store=True and inversible only with an inverse=, and a related field is unstored and readonly unless it says otherwise and gets an inverse only when it is not readonly (_get_attrs and setup_related, odoo/fields.py at 3c3e3b3). A field declared elsewhere, or declared with anything but a literal, is unknown and changes nothing. What can trigger the check is a write of what the field is computed from: the roots of its compute's @api.depends, or the first field of its related path, followed through any of those that cannot be named either (_unwriteable_inputs). A dotted dependency is reached through its first field, as a dotted read always was, since @api.constrains names fields of its own model only. When the compute is not in the class, or declares nothing a static pass can read, the finding stays and says the field cannot be named, rather than asking for it.

Measured. Each tree reproduced its artifact hit for hit with the committed checkers first, core from a clean export of HEAD. Then:

before after
OCA 102 102
OpenSPP 161 161
odoo core 2,366 2,358

Core loses eight, each a constraint that names every input of the field it reads: an invoice's tax_country_id, an event's and an event ticket's seats_available, a task's subtask_count, an orderpoint's product_uom, a quiz question's correct_answer_id, a product image's embed_code, and an account report value's report_country_id. That last one is related through target_report_line_id, itself related through the target_report_expression_id the check names.

Ten more on core and three on OpenSPP stay, each now told the input it is missing instead of a field it cannot name. A leave's mandatory-day check names its dates and reads has_mandatory_day, computed from them and from holiday_status_id, so a leave moved to another type is not checked again. The POS config's two currency checks read a currency computed from company_id and journal_id. The payment capture wizard's three reads come from transaction_ids, the refund wizard's from payment_id, and the Indian withholding wizard's from withhold_id. The activity scheduler's error is computed from seven fields and its check names six, leaving out plan_available_ids, a stored compute, which can be named. One is unknown: account.withholding.line leaves _compute_comodel_currency_id to the models that inherit it, and the finding says comodel_currency_id cannot be named. On OpenSPP, spp_case_base's stage check reads has_active_plan and is told to name intervention_plan_ids, and spp_drims's delivery wizard reads two fields computed from move_id.

One verdict moved. has_active_plan was the OpenSPP reading's one advice-cannot-be-taken, false because the fix it named could not be applied. The same method's read of intensity_level was read true, a stage check that does not run again when what it checks changes, and with its advice now one that can be taken this finding reads the same. The two rules read 88 true, 14 false and 3 undecidable on OpenSPP, 86% where they read 85%, and the hand-classified true positives are 290, all still reported. One core reading leaves the count: event's sold-out message, false and unreported since Β§3.58, which figures.py had matched to the seats_available finding three lines above it. With that finding exempt the row matches nothing, and core's read findings are 88 where they were 89.

Against the count of 2026-09-28, which found 23 (8 to exempt, 14 missing an input, 1 unknown) where there are 21 now (8, 12, 1): that count did not ask about inverse=. mrp.production's move_byproduct_ids and ir.ui.view's arch have one, so Odoo runs a constraint naming them, and both stay findings asking for the field, as before. report_country_id moved from missing an input to exempt by following its related input through, and plan_available_ids, which that count took for non-stored, is stored.

Not added. A constraint that names an unwriteable field of its own class, and so never runs for it, is a shape none of the three trees has, so nothing is added for it. Each behavior above has a test: covered, one input missing, stored or inversible as before, related through its first field with and without readonly=False, followed through an unstored input, and the compute that is not in the class.

3.83 Facts from the tree for the depends check

Built and measured 2026-10-01. Β§3.72 proposed it, and Β§3.71, Β§3.78 and Β§3.80 kept counting what it would decide: findings of stored-compute-needs-complete-depends that are false for a reason no single file shows. Three of those reasons are facts about declarations, and the tree a fieldtest walks holds every declaration:

  • A, a compute nobody stores. One whose field another module declares without store=True is outside the rule, as one declared so in its own file always was. Β§3.78's three pos_sale overrides of sale's amounts were this, and 17 of Β§3.80's 76.
  • B, merged depends. Odoo merges @api.depends from every definition of the method in the model's MRO (get_depends and resolve_mro, odoo/fields.py at 3c3e3b3), so a dependency another definition declares is declared. Β§3.71's "depends merged from sale_project" was this, and 5 of Β§3.80's.
  • C, transitive triggers. Every field with dependencies, stored or not, feeds the trigger table, every prefix of a dotted dependency counts as a dependency of its own, and the trigger tree is the transitive closure of both (resolve_depends in odoo/fields.py, _field_triggers and get_field_trigger_tree in odoo/modules/registry.py, all at 3c3e3b3). So a declared field that is computed or related covers what it depends on in turn. Β§3.78's certificate key was this, and 11 of Β§3.80's.

What write() refuses stays undecidable from declarations, as Β§3.72 said, and nothing here tries it.

What the walk records. context.tree_facts now reads every Python file's ORM declarations (semantics.orm_declarations): per class, the model (its _name, or a single _inherit, as Odoo names it), the models it inherits, each field's compute, related path, store, inverse and comodel, and each method's literal @api.depends. Every declaration keeps the module it ships in, the directory holding its manifest, and every manifest's depends is read, so the checker can ask which modules are installed whenever the one in front of it is (context.module_closure). fieldtest hands each file the facts with its module (context.for_file), and nexa check walks the module it was given, as before. A file over 200,000 characters is read whole for this: account_move.py, account_tax.py and mail_thread.py are longer and would not parse cut short. The older facts keep the limit they had, so nothing they decide moves.

Each answer only where it holds. A needs a complete walk that names the model and everything it inherits with _name, since "no declaration stores it" is true only of everything read. One store=True anywhere keeps a field stored, and compute= without store=True leaves it unstored whatever an earlier declaration said (_get_attrs). nexa check walks one module and never applies A. B takes only the definitions in the module itself and in the modules it depends on, transitively: a sibling that overrides the same compute is not always installed with it. C searches from the declared paths toward the read. A step replaces the rest of a path with what the field there depends on and keeps the prefix that reached it, so a step where the path has already left the read's can never come back to it, and is not taken. Every path met is then a prefix of the read followed by one dependency some field declares, so the search ends without a limit. The first version walked forward with a limit of 400 paths, reached it on core, and gave different findings under different hash seeds. The search toward the read gives the same findings every run, checked under three seeds.

Measured. The scans of the three trees and a rerun of the checker on every file with a depends finding, once with no part, once with each part alone and once with all three, agree finding for finding. With no part, the rerun reproduces the artifact's depends findings exactly.

before after A alone removes B alone C alone
OCA 102 101 0 0 1
OpenSPP 161 158 0 2 1
odoo core 2,358 2,207 50 16 96

On core the parts overlap: 42 findings only A removes, 11 only B, 86 only C, 11 more that two or three of them remove alone, and one that B and C remove only together, where B brings in a journal the parent definition declares and C follows it to its company. Nothing is added.

Every removal was explained and checked against the code. The evidence for each of core's 151 was printed from the same facts: for A, every declaration of the computed fields with its module and store; for B, the definitions whose depends cover the read and their modules; for C, one chain from a declared path to the read. All 151 have one, and the four on OCA and OpenSPP were read in full. The A fields are values no module in core stores, account.move's needed_terms and show_reset_to_draft_button, an employee's hr_presence_state, sale's amount_to_invoice under pos_sale. Each B merge comes from a module the reader's depends on, purchase under purchase_stock, sale_margin under sale_stock_margin, point_of_sale under pos_sale. The C chains are Odoo's own, a journal item's currency following its move's, a timesheet's cost following its employee's hourly_cost, a task's project following its parent's. The longest is mrp's: a production's state depends on move_finished_ids.state, move_finished_ids on date_finished, that on workorder_ids.duration_expected, and that on the work order's operation_id, so a change of operation marks the production for recompute through four declared hops. Those were read in the source, with a spread of the others.

Three readings were wrong. No core finding that went had been hand-classified, true or false. Three elsewhere had been read true, and the facts that removed them show they were false:

  • OCA's account_spread_cost_revenue: _compute_all_posted reads currency_id and declares unposted_amount, which _compute_amounts computes with currency_id among its depends. A change of currency recomputes unposted_amount and then all_posted. The same line's read of currency_id.rounding is still reported and still true: nothing declares the rounding.
  • OpenSPP's spp_cel_event: the override of _compute_cel_expression reads source_type and aggregate_target, which spp_cel_domain's definition declares, and spp_cel_event depends on spp_cel_domain. Odoo merges the two lists.

All three were read before Β§3.59 found the merge and before anyone asked about transitive triggers. Each row says so and why, and keeps its place: OCA's two flagship rules read 98% and 100% where they read 100% and 100%, OpenSPP's 84% where it read 86%. The hand-classified true positives are 287, and all 287 are still reported.

One fault in figures.py came out on the way. It matched a classification row to a finding by rule, path and line alone, so two findings on one line were interchangeable, and once the currency_id finding went, its row could take the currency_id.rounding finding and leave that true row reading lost. A row that carries its finding's words is now matched to a finding with those words first, and line alone decides only what is left. Run over the committed artifacts it changes nothing: 289 of 289, the same four re-anchored.

Left. nexa check walks only the module it is given, so a merge or a chain through another module is invisible to it: spp_cel_event's two findings still show there. Walking the closure of the module's manifest, where the dependency modules sit beside it, would bring B and C to check; it was not built here, and Β§3.84 builds it. And Β§3.72's lambda binding and Β§3.80's name binding were declined on classes this decides in part, so both are worth measuring again on top of it. Β§3.87 measures them.

3.84 check reads the modules its module depends on

Built and measured 2026-10-01. Β§3.83 left nexa check walking only the module it is given, so the merged @api.depends (B) and the transitive triggers (C) it brought to the depends check could not reach a definition or a field in another module. spp_cel_event's two findings, which fieldtest no longer reports, still showed there.

What it reads now. module_facts also reads every module the file's manifest depends on, transitively, that sits beside its module, as the modules of an addons directory do (_dependency_sources). Each of them is installed whenever the module is, which is the test B and C already apply. They are read for their ORM declarations and their manifests' depends alone (tree_facts takes them as dependencies), and test directories are left out, as fieldtest's trigger walk leaves them out. Every other fact stays the module's own, and the walk stays incomplete, so A, which needs every declaration, still never applies in check. A dependency installed anywhere else is not looked for: Odoo core keeps base apart from its other modules, and OCA's modules depend mostly on core's.

Sized first. Before anything was written, the closures were counted on the three trees. OCA's reach 18 Python files, since its modules depend mostly on core. OpenSPP's reach 714. Core's reach 1,044, and 445 of its 656 modules depend on more than 400 files, the cap the module walk itself has. The read is capped at 1,000 Python files, about two seconds of parsing, past which check reads nothing beyond the module and has the facts it had before. In the three trees that is one module, core's test_event_full. Parsing is most of the cost, and orm_declarations now parses without the compile step parse_python adds, whose errors it never read: the same records, measured over two closures of core, in about three quarters of the time.

nexa check --no-model, median of five runs before after
spp_cel_event's aggregation model, 183 files beside 0.55 s 0.79 s
sale_stock's account move, 598 files beside 0.85 s 2.11 s
website_sale_collect's payment transaction, 823 files beside 0.32 s 1.87 s
OCA auditlog's autovacuum, nothing beside 0.40 s 0.40 s

Measured. check was run on every file fieldtest reviews in the three trees, through the call check makes (module_facts, then scan_source), from clean exports of the code before and after. Each module's facts were computed once and handed to its files with for_file, which is what check computes for each of them.

hits check reports, suppressed and advisory included before after
OCA 116 116
OpenSPP 519 517
odoo core 2,810 2,773

Nothing is added, and nothing moves for another rule. OpenSPP's two are spp_cel_event's. Core's 37 are in 22 modules, each reading through a module it depends on. A merged definition alone answers 8, a chain of triggers alone 9, either of them 15, and the two only together 5. sale_stock's delivered quantity reads the method sale's definition declares, l10n_latam_check's destination account reads the partner account's declares, and sale_stock_margin's purchase price reads three fields sale_margin's definition declares; nine localizations make up 14 of them. Each is one of the 151 Β§3.83 removed and explained.

Against fieldtest. On OCA and OpenSPP, check and fieldtest now report the same depends findings. On core 38 differ, all in one direction and all one class: a compute whose field no declaration in the tree stores, which only a walk of the whole tree can say (A). Run again with the whole tree's facts marked incomplete, each of the 38 comes back. Of the 151 findings Β§3.83 removed on core, check drops the other 113, 37 of them since this change. The other rules still differ between the two where their facts need the whole tree, record rules and deletes, as they did before this.

fieldtest is unchanged. It passes no dependencies, and the parse gives the same records. A rescan of the three trees reproduces each artifact hit for hit. Each behavior above has a test: a dependency beside merged, a sibling it does not depend on left unread, a dependency's dependencies read and its tests not, the cap, and a dependency elsewhere or a name that is not a module name not followed.

Left. A module whose dependencies are installed elsewhere, as OCA's on core usually are, gets none of them; reading an addons path a person names would reach them. And the read is made for every file, while only a file with a depends finding left can use it: reading on demand would spare the seconds on most runs. Β§3.86 builds both.

3.85 The cross-hit row, read from the probes' rows

Built 2026-10-01. The coverage table's "0 cross-hits across the corpus" was its last row without a marker. Β§3.76 committed the probes' rows and left this one to crosscheck, which writes nothing down. Reading the two commands to give crosscheck an artifact of its own showed the rows were already committed. The contradiction probe fires every checkable rule at every other rule's correct sample, over the same rules and through the same layers.fires, and keeps one row per cross-hit before it classifies it. Its rows are crosscheck's list with a class beside each.

So there is no second artifact. What a cross-hit is has one definition now, layers.cross_hits, which crosscheck lists and the probe classifies, so the two cannot drift apart. figures.py counts the probe's contradiction rows as adversarial.crosshits, and the row is marked. CI's corpus job already re-runs the probes against their committed rows, so a pattern that starts to fire on another rule's correct sample fails CI until the rows are rewritten, and the README's count moves with them. crosscheck prints what it printed before, byte for byte, the probes reproduce their artifact, and a test plants a cross-hit and finds it in both.

3.86 The modules a module depends on, read when asked and where named

Built and measured 2026-10-01. Β§3.84 left two things. check read the modules beside the one it was given for every file, while only a file with a depends finding left could use them, and it looked for them beside the module only, so an OCA module, whose dependencies are core's and sit in another directory, got none of them.

Read when asked. module_facts no longer reads the dependency modules itself. It leaves a loader in the facts (with_dependencies), and the depends check calls it only for a read the module's own declarations leave uncovered, then asks again about those reads alone. More declarations can only cover more, so the reads already covered need no second look and the findings are the eager read's exactly. The loader runs once per module, adds the dependency files' declarations to what the module walk already read (_with_declarations, from the orm_inputs the facts now keep), and reads nothing of the module twice. fieldtest's facts carry no loader: its walk had the whole tree already.

Where named. nexa check --addons-path <dir>[,<dir>...] names where else the dependencies are installed, spelt and written as Odoo's own option is. A dependency is looked for beside the module first, then in each named directory in turn, the first holding it winning, as Odoo's own does. A named directory that is not there is a bad argument, exit 2, since the modules it was meant to reach would otherwise go unread without a word. Sized first: with core's two addons directories named, OCA's closures reach 680 Python files, under the cap of 1,000. Both trees are 18.0, as the core checkout is; OpenSPP is 19.0, and no core here is its own.

Measured. check was run, as Β§3.84 ran it, on every file fieldtest reviews in the three trees, from clean exports of the code with the eager read and with the read on demand. The two report the same findings on all three, hit for hit. The dependency modules are asked for in 13 of OCA's 98 modules, 23 of OpenSPP's 131 and 121 of core's 657; every other module never reads them.

nexa check --no-model, median of five runs read for every file read when asked
spp_cel_event's aggregation model, which needs them 0.93 s 0.93 s
sale_stock's account move, which needs them 2.56 s 2.79 s
website_sale_collect's payment transaction, which does not 2.10 s 0.35 s
OCA auditlog's autovacuum, nothing beside 0.41 s 0.42 s

nexa help check said --no-model runs in under a second and gives the list fieldtest gives. It says usually under a second now, a second or two more when a depends finding needs the modules beside, and the list fieldtest gives but for a finding only a walk of the whole tree can rule out (Β§3.84).

OCA with core named. Run with core's addons directories as its addons path, check on OCA reports one finding fewer, 116 hits to 115, and adds none. account_cash_deposit's _compute_is_reconcile declares move_id.line_ids.reconciled and reads each line's account_id. reconciled is the journal item's stored compute _compute_amount_residual in core's account, which depends on account_id (odoo/addons/account/models/account_move_line.py at 3c3e3b3), and Odoo's triggers are transitive, so a change of a line's account recomputes reconciled and then is_reconcile. The finding had been read true, before transitive triggers were asked about and with core out of reach. It is re-read false, class covered-transitively, and keeps its place. fieldtest of OCA still reports it, since it walks only the roots it is given, which is what the row says. OCA's flagship depends rule reads 95% where it read 98%, OCA's read findings are 84 true and 7 false, and the hand-classified true positives are 286, all still reported.

Each behavior has a test: no read for a file that needs none and one read for a module that does, a dependency in a named directory read, the module beside it preferred, the option's parsing, and a named directory that is not there refused.

3.87 The two bindings, measured again on the tree facts

Measured 2026-10-01. Β§3.72 declined the lambda's binding and Β§3.80 the name assigned records, each because what it added read about twice as many false findings as true, and each named facts from outside the file as what would separate them. Β§3.83 built those facts, so both were measured again on top of them. Neither binding's code was kept, so each was rebuilt from its section and first run where it was measured. The name binding on 0539253 reproduces Β§3.80's table exactly: OCA 102 to 107, OpenSPP 161 to 169, core 2,366 to 2,480, with the same four findings gone and the same five moved. The lambda binding on fed0255 reproduces Β§3.72's: OCA +8, OpenSPP +1, core +127 depends and +27 constrains, and the same two event findings gone. So the rebuilds are the bindings that were read.

On today's tree, against the committed artifacts:

name binding lambda binding
OCA 101 to 106 101 to 108
OpenSPP 158 to 166 158 to 159
odoo core, depends +86, was +113 +89, was +127
odoo core, constrains +5, the same five +25, was +27

Neither needed reading again to decide. The tree facts only remove findings, and every addition today is one that was read then, but two. So the readings bound what is true now:

  • The name binding's depends findings are all among Β§3.80's, of which core's 113 read 37 true, OCA's five all true, and OpenSPP's seven three true. At most 45 of today's 98 are true, and at least 53 false. Its constrains findings are the same six, three true and three false, as Β§3.80 found.
  • The lambda binding's depends findings on core are 88 of Β§3.72's 127, which read 40 true, and one more, hr_expense's own-expense bill reading the report's payment mode, the finding Β§3.80 read true through a name. With OCA's seven of Β§3.72's eight, two of them true, and OpenSPP's one, true, at most 44 of 97 are true and at least 53 false. On constrains, 24 of 25 are among Β§3.72's 27, which read 9 true; the 25th is one Β§3.82 re-worded, l10n_latam_invoice_document's document check now asked for the journal behind a field it cannot name. Counted true, that is at most 10 of 25.

So both stay as they were, not taken, and the tests that pin them stand.

What the facts decided. Of the name binding's 113 core findings, the facts take 27: a compute nobody stores (A) alone 15, a declared field covering the read (C) alone 5, a merged decorator (B) alone 3, and 4 that more than one of them decides. Β§3.80 had counted 33 in those three classes. The lambda binding's 127 were 121 by 0539253, since Β§3.72's own exemption for a method's output took nine and later reads found three. The facts take 32 of the 121: A alone 14, C alone 9, B alone 3, B and C only together 2, and 4 that more than one decides. Each part was switched off in turn on each finding to say which decides it.

What stands between them and the bar is what Β§3.72 and Β§3.80 said no declaration decides. For the name binding, the 27 keep-once shapes: a compute that fills its field only while it is empty or the record is new, visible in the method. For the lambda binding, the 12 guards on which records to touch, and the 28 identifiers nothing changes after creation. A narrowing for the guards would be the next measurement, and the old readings put each binding near the bar with it; those are estimates from readings made before the facts, so it would be measured, not assumed. It is not built.

4. Performance traps, all found by measurement

Within four instrumented runs the report showed the 7B at ~10 tok/s against a benchmarked 36. Three diagnoses were offered; the first two were wrong.

4.1 num_gpu: 99 β€” the actual cause

Ollama's memory estimator is conservative and leaves layers on the CPU with VRAM still free. 11.7 tok/s automatic versus 38.6 tok/s forced, same model, same context.

The Modelfiles carry PARAMETER num_gpu 99, but the pipeline calls the raw base model, which has no such parameter. Both call paths now pass it explicitly. The 30B does not get it β€” it genuinely cannot fit and needs a real split.

4.2 KV cache environment variables must precede the server

OLLAMA_KV_CACHE_TYPE=q8_0 and OLLAMA_FLASH_ATTENTION=1 were set as user environment variables while the server was already running. They never took effect. 5.4 GB β†’ 5.1 GB once actually applied.

Set them before starting the server, or restart it afterwards. Setting them in your shell does nothing for a server already running.

4.3 Embeddings belong on the CPU

274 MB is trivial except on a 6 GB card, where it is 274 MB the generator needs. Query embedding now passes num_gpu: 0. Bulk indexing still uses the GPU, where nothing competes.

4.4 Orphaned llama-server processes

Killing ollama* orphans its llama-server children, which keep their GPU allocations. Two were found holding 1.4 GB of VRAM and 10 GB of RAM. nexa doctor checks for these.

The general lesson: the 36 tok/s figure was benchmarked once and then quietly untrue for most of a working session. An unmeasured number decays into a claim.


5. Observed model behaviour

Recorded because it is what a 7B actually does, not what a model card suggests.

  • Left alone it wrote Pydantic v1 syntax (@validator) and deprecated datetime.utcnow.
  • It followed the user's stated approach over the system prompt β€” asked "I was going to use an onchange, show me how", it wrote the onchange. An explicit override rule was needed: when the user's approach will not hold the invariant, your first line says so.
  • It regressed under prompt hardening: fixing the false-for-empty scalar handling broke the Many2one tuple it had previously got right.
  • It cited a real rule while violating it.
  • It copied instruction text into its output when the format template looked like something to emit (<code block> appeared literally).

The 30B is better at caveats and trade-offs and not uniformly better at mechanics: on one question it produced a CHECK constraint containing a subquery β€” invalid in PostgreSQL β€” while correctly identifying reserved stock and concurrent transactions as the real difficulty.


6. Limitations

Model capability

A 7B–30B local model does not match a frontier model on hard architectural reasoning. That gap is a function of scale. Nexa narrows the validation gap by grounding answers in a corpus; it does not close the reasoning gap.

Checking is partial, and the layers say where

  • 1 of 28 rules has no automated check at all. It returns no verdict, never a false pass, and nexa coverage prints 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.join or float() through a helper in another file is not reported at all.
  • Semantic analysis is Python only, which is the whole subject now. An answer may still contain a JavaScript block, Odoo's web client being OWL; that block gets delimiter balance and nothing deeper, and the layer reports UNKNOWN rather than PASS for it.
  • The empirical layer resolves imports against this interpreter and assembles bytecode. It does not execute anything unless NEXA_EMPIRICAL_EXEC=1, and even then only for snippets importing nothing outside a short allowlist β€” which in practice means no Odoo answer is ever executed, because they all import odoo. A subprocess with a timeout reduces blast radius; it is not a security boundary.
  • The context layer is only as good as the file it was given. nexa ask has no file, so it reports UNKNOWN; nexa check <file> reads the manifest, the model names, ir.model.access.csv and the record rules in XML, and can then say something real.
  • Every Odoo rule declares odoo_versions: >=16.0, so the context layer has a question it can answer about a module on any series and reports per-rule when one does not apply. corpus/check_sources.py supports that range from the documentation for 44 of the 45 versioned rule-series, and --source supports all 54 rule-series from odoo/odoo itself, float-comparison-needs-float-utils at 16.0 included. Neither is a test run: a page describing an API and a module defining it both say the construct was there, not that it behaved identically.
  • PASS means the checks that ran found nothing. It does not mean correct, and it is printed with that sentence attached.

The checkers themselves are only as tested as the probes

nexa adversarial measures recall under meaning-preserving rewrite, whether a satisfied_pattern can be cleared by a comment, and whether two rules disagree. All three currently pass. None of them proves a pattern behaves on a real codebase: the mutations are formatting-level, so an equivalent algorithm written differently is not generated, and the samples are still small and still written alongside the patterns.

nexa fieldtest (Β§3.18) closes part of that gap and opens an honest number in its place: 3.0 hits per 1000 lines on

33,960 lines of reviewed OCA code, down from 11.3 when

this started. It was 2.9 after Β§3.43, and has moved since as rules were composed in and narrowed; Β§3.53 is why this sentence can no longer fall behind it.

98 of today's 101

findings 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-rules is a regex, and self.sudo() inside ir_model.py cannot be recognised by one β€” it needs the class's _name. That is the same regex-to-AST move check-company-on-relations made, on the largest rule in the corpus by volume: 128 hits on OpenSPP, none of them classified. Doing it without that classification would be changing the loudest checker here on the strength of four findings.
  • Wrong construct (2). A parent_id whose comodel is a different model, and == on an Integer. Both need the declaration's type, and both rules are regexes. Same migration, much smaller stakes. The parent_id half made it in Β§3.54, and took a larger class of false positives with it.
  • Advice that cannot be taken (2). Deliberately not narrowed. Exempting an interpolated identifier from sql-needs-query-parameters would mean deciding from the shape of an f-string which holes are table names and which are values, and getting that wrong hides an injection. This is the one checker here where a narrowing could conceal the thing it exists to find, so it keeps its two.

Neither 24 nor 8 is the 6 reported previously, and none of them compare. Three of these rules had been reimplemented since that count β€” check-company-on-relations and ondelete-is-a-decision both moved from a regex to an AST pass, and the SQL, commit-in-loop and recursion rules did not exist β€” so it was a different hit set judged by different checkers. The 2026-09-15 pass was also stricter: it counted a finding against a wizard or a column-less field as a false positive where the earlier one did not, which is most of the distance between 6 and 24.

The honest summary is that this rate has never been measured twice the same way, and that the 8 is the first one standing on a classification anybody can re-open.

It is still two repositories in one domain, and that has not changed.

And it is still only half the question. Β§3.25 measures the other half and the answer is far worse: 1 catch in 56 real bugfixes. Precision is the half that was easy to measure, which is presumably why it was measured first.

Retrieval is question-driven

Similarity search cannot surface a rule whose relevance depends on the answer. Pinning mitigates this for known traps and does not solve it in general.

Every rule is now reachable by its own probe (Β§3.19), which is a floor and not a ceiling: it proves each rule beats its stack for one question chosen to suit it. It says nothing about which rule wins when two are plausible.

Compliance checking no longer depends on retrieval: the domain and semantic layers run every checkable rule for the stack, not only the ones the model was shown. Retrieval decides what the model saw; it has no bearing on which rules the answer is allowed to break, and a rule that was never retrieved is exactly the one most likely to be broken unawares.

Corpus provenance

26 of 28 rules cite upstream documentation. The rest rest on general knowledge and

deserve more scepticism. Most of the rules were written by an assistant rather than harvested from production incidents, and rules drawn from real incidents are worth more. Four now are: commit-inside-a-loop and sql-needs-query-parameters came from an external ruleset disagreeing with this one (3.34), and depends-must-not-name-unread-fields from a fix commit in OpenSPP's history (3.35), and sql-constraints-are-ignored-from-19 from an OpenSPP incident (3.62). Four of twenty-eight is not a change of method yet.

This is the deepest unresolved problem in the design and no layer addresses it: the validator and the thing being validated are the same class of system. Provenance tracks sources, versions and checker identities. It cannot establish that a rule is right, only where it came from.

Scope

Odoo, and nothing else (Β§3.46). Odoo rules were checked against 18.0 documentation and its source; behaviour differs across major versions.

One stack, 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__/.