Orchestrata Ultimate › Plans › prolog-gates-implementation-plan

Prolog gates — implementation plan (LIVING DOCUMENT)

Source: plans/prolog-gates-implementation-plan.md at 8a2cb42ad · last changed 2026-08-09 · first committed 2026-08-06 · 936 lines.

Prose pending.

Plan text

Prolog gates — implementation plan (LIVING DOCUMENT)

Contract: plans/contracts/prolog-gates.contract.json · Diagram: plans/contracts/prolog-gates.mmd

Update protocol (RUL-015 plan fidelity). This file is edited in the SAME commit as the work it describes. A phase row moves to DONE only when its acceptance line is pasted into the Evidence column with real numbers. A replace-then-delete row moves to DONE only when the legacy code AND its tests are gone. No row is marked from intent.

Why

Measured on this tree, 2026-08-06:

The three constraints that decide whether this works

  1. Facts describe the code; rules describe the policy. Predicate count grows with the DOMAIN (kinds of thing that exist), never with the RULE COUNT — that ratio is the test, not a fixed number. If it tracks rule count, the duplication has been retyped in Prolog. (RUL-016, different store.)
  2. Facts come from plans/ast-ultimate.json, never from reading files — and snapshot freshness is itself facts, so a gate over a stale base returns invalid, never pass.
  3. Prolog reasons about the linters; it does not replace them. Where ruff/black/mypy already decide a question, the facts are the tool's catalogue, configured status, run outcome and findings — never a re-derivation of the same measurement.

Gate order (Mohamed, 2026-08-06)

Gate 0 — infra, BLOCKING. All infra up and verified before anything runs. Failure means stop and fix infra, not continue-and-report. Its verdict is a precondition, so it writes nothing to the run record.

Gate 1 — linters + env contract, PARALLEL. All linter work, plus validation that the env variables the tests will use resolve to the real infra IPs, URLs and paths. Also not part of the test-run record.

Then tests.

A precondition that failed is not a test result. Recording it as one is how a broken environment gets filed as a product defect — the failure mode behind several false greens already logged in this project.

Both gates are facts + rules like everything else — service_required/1, service_endpoint/3, service_probe/3, env_binding/2 — so "is the infra ready" is an inference, not a procedural checklist reimplemented per runner.

Measured baseline, 2026-08-06

Gate Component Time rc
0 (blocking) start_test_services 1.40s 0
1 (parallel) ruff (src tests scripts) 0.05s 0
1 black --check 0.86s 0
1 check_env_contract 3.13s 0
1 validate_env 0.05s 0
1 check_incus_not_docker 0.51s 0
1 graph_test_preflight --offline 1.21s 1

Gate 1 serial 5.81s, parallel 3.13s (env-contract dominates). Gate 0 + Gate 1 = 4.53s before the first test.

Open: graph_test_preflight exits 1 today — 128 potential constraint violations across 20 files, currently reported at WARNING so they vanish. If it joins Gate 1 as defined, Gate 1 fails on the current tree until those 128 are dispositioned. Needs a decision; not silently downgraded.


Phase table

# Phase Deliverable Acceptance Status Evidence
P0 Design contract + diagram + this plan artifacts exist, mermaid validates DONE plans/contracts/prolog-gates.{contract.json,mmd}
P1 Fact ontology + extractor code_facts.pl, gates/fact_extractor.py, scripts/gate.py, tests/gates/test_fact_extractor.py, tests/scripts/test_gate_cli.py gate facts --stats shows zero per-line facts and a linter-sourced tooling layer; test proves facts came from the snapshot, not the on-disk bytes DONE 20 predicates / 90,317 facts, table below; test_structural_facts_ignore_the_on_disk_bytes; 89 tests green
P2 Freshness as inference freshness.pl, gates/engine.py, scripts/gate.py run, tests/gates/test_freshness_inference.py touching a file → gate run exits invalid naming it, not 0 DONE both runs pasted below, rc=2 both, second names core/paths.py (mtime_newer); 94 tests green
P3 Slice-1 rules gate_rules.pl, gate_severity.pl, tests/gates/test_gate_rules.py 4 rules green, negative corpus ≥ positive DONE 15 positive / 23 negative across 4 rules; gate run PASS rc=0 on the live tree; 148 tests
P4 Explain surface scripts/gate.py explain / why-not, explain.pl fix proof tree for a violation; failing subgoal for a non-violation DONE both pasted below; 0>0 and defaulted_form(get) on the live tree; 168 tests
P5 Differential tests/gates/test_differential_vs_legacy.py, gate diff solution set == legacy finding set, or every difference stated intended DONE 7 agreed / 3 named gains / 0 loss / 0 unexplained over the positive corpus; 4 defects found, all fixed; 195 tests
P6 Replace-then-delete see ledger below legacy code + tests gone, battery green DONE every row ticked or STRUCK; gate PASS over src+tests+scripts
P7 Scale-out remaining clusters, one per slice each repeats P3→P6 TODO
P8 Gate 0/1 phases gate_phases.pl, service + env facts infra-down halts with zero tests run and an empty run record; env mismatch fails before the suite TODO

P1 evidence — measured 2026-08-08

python scripts/gate.py facts --stats over plans/ast-ultimate.json (847 modules, src/orchestrata_ultimate):

predicate                facts
------------------------------
calls/3                 31,936
param/4                 11,649
defines/5                9,511
has_docstring/1          8,791
returns/2                7,800
decorates/3              5,691
imports/5                5,481
literal/4                2,540
content_hash/3           1,694
tool_rule/4                956
tool_rule_status/3         956
file_class/2               847
file_mtime/2               847
module/2                   847
subclass_of/2              658
env_access/4                96
infra_key/1                 10
banned_import/2              5
snapshot_built_at/1          1
tool_outcome/3               1
------------------------------
TOTAL                   90,317

distinct fact predicates: 20  (modules 847, files hashed 847)

20 ≤ 20, and zero per-line facts — the rejected lexical layer would have added 845,547. Four slice-1 rules over eleven structural predicates: the REQ-GATE-004 ratio holds.

The tooling layer is live, not deferred. tool_rule/4 and tool_rule_status/3 come from ruff rule --all --output-format json (956 rules) joined to this project's pyproject.toml select/ignore — 227 enabled, 1 ignored, 728 unknown. ignored and unknown are kept apart on purpose: the first is a deliberate exemption, the second an absence of opinion, and telling them apart is the whole of REQ-GATE-011.

The fact pair P3's uncovered/1 needs is present today:

tool_rule(ruff, 'E501', 'line-too-long', 'none').
tool_rule_status(ruff, 'E501', 'ignored').

— ruff CAN check long lines, this project turns it off, and black rewrites without reporting. No existing checker in this repo can state that; two facts make it a query.

tool_outcome(ruff, ok, <ns>) is why zero catalogue facts can never be silent: without it, a missing or broken ruff makes every policy look uncovered (or a coverage query vacuously true), and nothing distinguishes that from a genuinely uncovered policy. All four failure modes — missing, failed, timeout, unparseable output — are named and tested with real commands.

Predicate accounting. code_facts.pl DECLARES 26 predicates; the extractor emits 20. The remaining 6 — tool_verdict/5 (per-RUN findings, not base facts), policy_mechanism/3 (P3) and the Gate-0/1 layer (service_required/1, service_endpoint/3, service_probe/3, env_binding/2, P8) — are declared so a rule may ask about them with \+ without an existence error. The stats table counts what EXISTS, not what is declared; a table that counted declarations would report a fact base larger than the one a gate can query.

Consult, measured. code_facts.pl + the 10.6 MB fact base load into the patched free-threaded janus engine in 1.19 s, zero warnings, sys._is_gil_enabled() → False.

Slice-1-shaped probes over the live base (queries, not yet rules — P3):

probe result
imports(_,_,_,_Lv,_), _Lv > 0 0 relative imports (max level 0)
env_access(_,_,_,subscript) 0 bare os.environ[...]
env_access(_,_,_K,get_default), infra_key(_K) 0 infra keys with a default
imports(_,_,_M,_,_), banned_import(_M,_) 0 banned imports
content_hash(snapshot,_F,_H1), content_hash(disk,_F,_H2), _H1 \== _H2 0 stale files

All four slice-1 rules are already clean on src/. The differential (P5) therefore needs the P3 positive corpus to have anything to compare.

Three defects the build surfaced

  1. The snapshot could not answer two of the four slice-1 rules. os.environ.get("X", d) reached calls as the bare string os.environ.get, usually without arguments; os.environ["X"] is a Subscript and never appeared in calls at all; and ImportInfo carried no line number, so no_relative_import could not report file:line. Since REQ-GATE-001 forbids re-opening the file, the extractor had no honest source. Closed by adding EnvAccessInfo + ImportInfo.line to src/orchestrata_ast/ — verified against an independent AST walk: 96 = 96, exact.

  2. file_class/2 misclassified five PRODUCTION modules as tests. A basename-first rule tagged core/threading/test_executor.py (the parallel test runner), test_scheduler.py, gateway/handlers/test_metrics.py, gateway/test_runner.py and knowledge_graph/test_results_store.py as test. gate_severity.pl demotes test findings to advisory, so those five src modules would have been silently exempt from every rule the gate enforces — a hole in the layer built to close holes. Classification is now root-directory-first; the five are pinned by name in the test.

  3. ~3,400 SWI "clauses are not together" warnings. The freshness layer emits file_mtime/2 and content_hash/3 alternately per file. Enough noise to bury a real consult error. Fixed by emitting grouped by predicate rather than by suppressing with :- discontiguous — consult also dropped 4.49 s → 1.08 s.

Constraint recorded for P3. janus marshals every NAMED variable in a goal back to Python; one still unbound when the goal succeeds raises Arguments are not sufficiently instantiated, which reads like a fact-base defect and is not one. Variables local to an aggregate_all/findall must be _-prefixed. Pinned in test_the_live_fact_base_consults_and_answers.


P2 evidence — measured 2026-08-08

The acceptance pair, verbatim.

===== RUN A — snapshot freshly built =====
gate: INVALID
  - no rules are loaded — violation/4 has no clauses, so this run evaluated
    nothing. Zero violations found by zero rules is not a pass (REQ-GATE-003).
    Rules land with P3 (gate_rules.pl).
  fact base: 90,525 facts across 20 predicates
rc=2

===== touch src/orchestrata_ultimate/core/paths.py =====

===== RUN B — same snapshot, one file newer =====
gate: INVALID
  - fact base does not describe the working tree: 1 file(s) across 1 finding(s).
    Re-run: python scripts/serialize_codebase_ast.py serialize --source
    src/orchestrata_ultimate --name orchestrata-ultimate --output
    plans/ast-ultimate.json
  stale (1):
    src/orchestrata_ultimate/core/paths.py (mtime_newer)
  fact base: 90,525 facts across 20 predicates
rc=2

Neither run exits 0, and they say different things. Run B is the acceptance line: touching a file makes the gate invalid and NAMES it. Run A is the state of this tree at P2 — gate_rules.pl does not exist yet, so the run evaluated zero rules, and reporting that as a pass would be the false-green class in a new costume. The engine refuses on both counts, with the remedy in the message.

Four ways a fact base stops describing the tree

freshness.pl names each, because the remedy differs.

kind detected by why it needs its own clause
content_changed snapshot hash ≠ disk hash authoritative; catches any real edit
mtime_newer disk mtime > snapshot_built_at cheap; errs toward re-serializing
deleted snapshot hash with no disk hash the extractor emits no placeholder, so absence IS the signal
not_in_snapshot disk hash with no snapshot hash the worst blind spot — the newest code is the likeliest to be missing, and without it the gate reports a clean pass over a file it never looked at

not_in_snapshot needed a directory walk, not a per-file stat: a brand-new .py has no snapshot entry, so iterating file_meta can never reach it. It needed no new predicate — content_hash(disk, F, _) with no matching content_hash(snapshot, F, _) is "missing from snapshot". Predicate count stays at 20.

Three defects the build surfaced

  1. Snapshot paths are absolute or relative depending on who built it. gates.py ast-refresh writes absolute paths; a direct serialize --source src/orchestrata_ultimate writes relative ones. With absolute paths classify_file sees parts[0] == '/', misses every root check, falls through to the basename heuristic — and re-misclassified the five production test_* modules P1 had just fixed. The P1 fix had silently stopped working. Two path forms also mean freshness compares keys that never match and rules report file:line differently between generations. The form is now decided once, at the extractor boundary (_rel).

  2. janus's process-global database leaked facts between runs. SWI replaces a predicate when a later file redefines it — but a predicate the second run emits NO clauses for is never redefined, and the first run's clauses survive. Measured: two runs where run 2's snapshot had no h/1 facts still answered h(9) from run 1. In a freshness layer that is fatal, since stale disk hashes from a previous run are precisely the evidence being reasoned over. The engine now unload_file/1s every file it consults, in a finally. retractall is not an option — file-loaded clauses are static, and it raises No permission to modify static procedure.

  3. os.utime(path, None) does not reliably make a file "newer". Linux inode timestamps come from a COARSE clock (~ms granularity), so a file touched in the same tick as the snapshot gets a byte-identical mtime — measured at 1786198568747442069 for both. Two mtime tests were passing on where the tick boundary happened to fall. The comparison stays strict > (widening to >= would report every file written in the snapshot's own tick, and content_changed catches any real edit regardless); the TESTS now set the mtime explicitly, so "this file is newer" is a fact rather than a race.


Slice 1 — the rules to migrate first

Chosen because the cluster has the most competing implementations, is genuinely rule-shaped, and needs policy data (the infra-key list) as facts — so it exercises the fact-vs-rule discipline before anything scales.

Rule id Fact atoms consumed Replaces
no_relative_import imports(F,L,Mod,Level,Names) standards no-relative-imports, code-review.py::_check_imports_with_ast
banned_import imports/5 + banned_import(Mod,Reason) standards forbidden-import, codemod BannedConstructs._check_banned_imports, code-review.py::_check_no_mocks
bare_os_environ env_access(F,L,Key,Form) standards no-bare-os-environ, code-review.py::_check_env_get, test_isolation_audit OS_ENVIRON_MUTATION, checks_safety
infra_key_with_default env_access/4 + infra_key(Key) core/infra_env_rule.py, code-review.py ENV_DEFAULT

P3 evidence — measured 2026-08-08

python scripts/gate.py run on a freshly built snapshot:

gate: PASS
  - 4 rule clause(s), no violations
  fact base: 89,700 facts across 20 predicates
rc=0

The first honest green in this layer: four rules evaluated a fresh fact base and found nothing on src/orchestrata_ultimate.

Corpora — negative ≥ positive, per rule

rule positive negative the negatives that matter
no_relative_import 4 5 absolute imports of dotted modules, which a naive "contains a dot" test flags
banned_import 5 7 import doc (prefix of docker), import grp (prefix of grpcio), dockerfile_parse, safe.docker
bare_os_environ 3 5 all four non-subscript forms plus other
infra_key_with_default 3 6 REDIS_HOST read WITHOUT a default (the required form), REDIS_HOSTNAME near-miss
total 15 23

A rule shown only to fire has not been shown to discriminate. banned_import's negatives are the sharpest: prefix matching is the obvious implementation and it claims import doc for a ban on docker, so the rule uses exact match after separator normalisation and the DISTRIBUTION-vs-IMPORT-name divergence (grpcio/grpc, pytest-xdist/xdist) is solved as policy data in .claude/banned-libraries.txt — where all four existing enforcement layers benefit — rather than by a heuristic in the rule.

grpc was added to that registry as part of this phase: a checker matching only distribution names never sees import grpc, which is the name grpcio actually installs.

Severity is data, and the split is tested

gate_severity.pl carries default_severity/2, severity_exception/3 and a cut-based severity/3, following the policy_layers.pl defeasible precedent. Two tests keep the files apart mechanically: no clause in gate_rules.pl may contain error/warning, and no clause in gate_severity.pl may mention a fact predicate.

Exceptions are deliberate and few. Generated code downgrades to advisory for no_relative_import and bare_os_environ — a rule violated by a generator's output is the generator's defect and should not block on a file no human wrote. There is no exception anywhere for banned_import (a generator emitting a banned dependency is more alarming, not less) or infra_key_with_default (a literal fallback for a required endpoint risks writing to PRODUCTION regardless of which directory the file is in).

Two rulings landed mid-phase

"Compare the file hash not the time." The mtime_newer staleness clause is gone, and file_mtime/2 with it. The clock was wrong in both directions: a touch moved it while the bytes were identical (forcing a multi-minute re-serialize to fix nothing), and Linux's coarse inode clock handed a file written in the snapshot's own tick a byte-identical mtime (so a real edit read as unchanged). 847 facts nobody reads are dead weight; content_hash/3 decides.

"Make sure to scope facts by project." New project(Name, Root) fact, and the extractor refuses to emit anything outside that root — so every File in the base belongs to the declared project by construction. Paths stay repo-relative, which keeps file:line clickable; a project:path composite would destroy that for no gain within one project. in_project/1 in gate_rules.pl is a second line, so a rule gaining a new fact source later cannot quietly start reporting on another project's files. Predicate count stays at 20 — project/2 in, file_mtime/2 out.

Three defects the build surfaced

  1. A project root of . silently emptied the entire fact base. Path(x).relative_to(x) is ., so a snapshot serialized with --source . normalised its root to ., and a prefix test against . matches nothing. The gate would have reported a clean tree over zero facts. Measured: it emptied seven fixtures at once. . and "" now both mean "the whole repo".

  2. Every banned package carried the wrong rationale. The registry reader accumulated comment lines without ever resetting, so the first entries got the file header plus every earlier block, and the 400-char clamp then cut their real reason off the end — xdist's "deadlocks THIS suite ~78%" never survived. It now resets at the registry's own --- entries --- divider and after each run of entries.

  3. An undefined severity/3 raised instead of falling back. SWI raises existence_error for an undefined predicate rather than failing, so the whole findall threw and the engine reported ZERO violations for a fact base full of them — a false green produced by the very code meant to prevent one. It took out four tests that supply their own rulebase with no severity layer. Guarded with current_predicate/1.


P4 evidence — measured 2026-08-08

A proof tree for a violation

$ gate explain no_relative_import src/pkg/sample.py 0
no_relative_import at src/pkg/sample.py:0
  · fact   + rule fired   = built-in
  + violation(no_relative_import,'src/pkg/sample.py',0,'relative import (level 1) of pkg.sub — use the absolute path')
    · imports('src/pkg/sample.py',0,'pkg.sub',1,[])
    = 1>0
    + in_project('src/pkg/sample.py')
      · project(demo,'src/pkg')
      = sub_atom('src/pkg/sample.py',0,7,10,'src/pkg')
    = format(atom('relative import (level 1) of pkg.sub — use the absolute path'), ...)
rc=0

Every step is inspectable, and the markers separate the two things a reader actually needs to tell apart: · is a fact taken from the snapshot, + is inference, = is a built-in. A finding that looks wrong can now be traced to a wrong FACT instead of argued about — and in_project/1 appearing in the proof is what shows the project scope was genuinely checked rather than assumed.

A failing subgoal for a non-violation, on the LIVE tree

$ gate why-not no_relative_import src/orchestrata_ultimate/core/paths.py 3
no_relative_import does NOT hold at src/orchestrata_ultimate/core/paths.py:3
  violation(no_relative_import,'.../core/paths.py',3,_16788)
    blocked at: 0>0

$ gate why-not infra_key_with_default src/orchestrata_ultimate/kernel/event_bus.py 97
infra_key_with_default does NOT hold at .../kernel/event_bus.py:97
  violation(infra_key_with_default,'.../kernel/event_bus.py',97,_16788)
    blocked at: defaulted_form(get)

The second is the one worth reading twice: REDIS_HOST is read there WITHOUT a default — the required form for an infra endpoint — and the gate names the exact check that made it correct. This is the question no checker in this repo could previously answer, and the tree-sitter relative-import checker died of precisely that gap: dead for its entire life, twice over, with nothing able to ask it why it never fired.

The defect that made why-not nearly useless

explain.pl's first_failing/4 tested each conjunct with \+ provable(A, ...). Negation undoes bindings, so by the time the failing subgoal was reported its variables were free: _15584>0 instead of 0>0. That names the comparison but not the value that failed it — and the value is the entire point of asking why-not.

A conjunct that IS provable is now actually called (bind_witness/2), so its bindings survive into the examination of the conjuncts after it. The binding is a witness committed by once, which is sound here because explain_whynot/3 is only ever asked about a goal that already failed overall — no solution of the earlier conjunct rescues the clause. The 13 existing explain.pl tests (tests/prolog, tests/governance) pass unchanged.

Both CLI surfaces refuse rather than return nothing

explain on a location that does not violate exits 2 and points at why-not; why-not where no clause head even unifies exits 2 and says so. An empty proof printed at rc=0 would read as "explained", and silence from why-not cannot distinguish a wrong rule id from a rule that actually holds — two causes with different remedies.


P5 evidence — measured 2026-08-08

gate diff runs BOTH implementations over one corpus and accounts for every disagreement. Both legacy source files, five checker classes, seven rules — the ledger below, made executable.

$ python scripts/gate.py diff --snapshot corpus.json --repo-root corpus \
      --source corpus --all-rules
agreed: 7
prolog-only (gain): 3
legacy-only (loss): 0
unexplained: 0
  legacy-only  tests/test_thing.py:2 [banned_import]  [legacy_substring_match] — mymocks merely contains a banned substring
  prolog-only  src/pkg/a.py:6 [banned_import]  [ban_list_broader_in_prolog] — xdist is in the registry, not in the legacy set
  prolog-only  src/pkg/a.py:7 [banned_import]  [ban_list_broader_in_prolog] — docker is in the registry, not in the legacy set
  prolog-only  src/pkg/a.py:9 [infra_key_with_default]  [key_set_broader_in_prolog] — CXP_GRPC_PORT is a required infra key the legacy list omits
rc=0

Live tree (--source src/orchestrata_ultimate): 0 / 0 / 0 / 0, rc=0 — both sides silent, which is what P1 already measured and why the corpus exists.

Exit 0 means "nothing lost and nothing unexplained", NOT "the sets are equal". Equality is the wrong acceptance test: it refuses a replacement for being better than what it replaces. A Prolog-only finding is a gain once its mechanism is named; a legacy-only finding is a hole whether or not its mechanism is named. The one legacy-only difference above is the exception — _check_no_mocks matches the substring "mock", so it flags mymocks, and substring matching is the false-positive class this layer rejected when it refused prefix matching for docker/doc.

Differences are classified, never allowlisted. A hand-kept list of "known-different" findings grows silently and buries the next real gap; a classifier must name the MECHANISM, and a difference with no known mechanism is reported unexplained and fails. Seven causes, each a fact about how the two implementations are built: rule_absent_from_legacy, ban_list_broader_in_prolog, key_set_broader_in_prolog, file_scope_broader_in_prolog, ban_list_narrower_in_prolog, legacy_substring_match, out_of_project_scope.

Four defects the differential found

None were anticipated; each was found by running the two implementations against each other, which is the entire argument for doing this before P6 deletes anything.

  1. import pyswip was banned in exactly one place — a code literal. standards held _FORBIDDEN_IMPORTS = {"pyswip"}, so the PreToolUse hook, the pyproject gate and test-review had never heard of it. The first differential run found it on the legacy side and nothing here: deleting that checker would have silently un-banned the library the whole janus migration exists to keep out. Closed by moving it into .claude/banned-libraries.txt, where all four enforcement layers read it.

  2. banned_import could not express what _check_no_mocks enforces. It compared TOP-LEVEL packages only, so "unittest.mock is banned and unittest is not" was unstateable — the ledger's claim that banned_import replaced that checker could not have been true. Closed by ban_target/3: a dotless registry entry names a distribution and matches the top-level package; a dotted entry names one module and matches the full path. Verified: import unittest clean, from unittest.mock import patch, import mock, import unittest.mock all flagged, import docker.errors still caught by the top-level fold.

  3. project(_, '.') silenced every rule while the gate reported PASS. Path(x).relative_to(x) is ., and in_project/1 prefix-tested against it, which matches nothing. The result was not an empty report but a green one: 1,959 facts, 4 rule clauses, gate: PASS, eight real violations sitting unreported in the base. The identical hole had already been found and closed in FactExtractor._in_scope; closing it there was not enough, because either check alone still passes for the wrong reason. This is the false-green class the whole layer exists to close, found in the layer itself.

  4. The classifier answered a ban-registry question about a relative import. A no_relative_import finding at a line importing .relative came back classified "relative is banned by the legacy checker and absent from .claude/banned-libraries.txt" — an answer-shaped lie. The module lookup is now restricted to banned_import, and anything else falls through to unexplained, which is the honest answer.

Two smaller ones, same run: from . import x printed `relative import (level

  1. of —, a message with a hole where the target belongs (the snapshot records that module as the empty atom) — now `` from . import ...``; and the fix's first version built the dots withmaplist, a LIBRARY predicate explain.plcannot descend into, which collapsed the proof tree for the rulewhy-notwas written for.~*cin oneformat/3` call restores it.

What the differential still cannot say

gate diff compares against the checkers named in the ledger. It does not know that RUL-002's mock ban is ALSO enforced by audit_test_purity.py and test-review.py, so it treats every _check_no_mocks finding as coverage that must survive. That is the conservative direction and it is deliberate: a differential that assumed another gate had the case covered would be assuming exactly what it exists to prove.


Replace-then-delete ledger

A row may only be ticked when the replacement is green and its differential test passes. Deletion happens in the same commit as the replacement landing — never before (leaves a gap), never in a later cleanup pass (never happens).

Legacy artifact Kind Replaced by Deleted? Commit
code-review.py BARE_OS_ENVIRON (in _check_code_patterns) code bare_os_environ ☑ (this commit)
code-review.py::_check_imports_with_ast code no_relative_import ☑ (this commit)
code-review.py::_check_env_get code infra_key_with_default ☑ (this commit)
code-review.py::_check_no_mocks code banned_import ☑ (this commit)
serialize_codebase_ast.py standards (3 rules) code all four ☑ (this commit)
codemod/review.py BannedConstructs._check_banned_imports code — STRUCK, see below n/a
core/infra_env_rule.py::find_infra_env_defaults code — STRUCK, see below n/a
test_isolation_audit.py OS_ENVIRON_MUTATION code env_mutation (NEW rule) ☑ (AST-widening commit)
tests covering each of the above tests tests/gates/* ☑ with each row

Ledger correction, measured 2026-08-08. The row above read core/infra_env_rule.py — the whole module — and that is wrong in a way worth recording, because it is the distinction this design is built on. INFRA_ENDPOINT_KEYS in that module is policy DATA: it is where infra_key/1 comes from, FactExtractor imports it, and the three key sets in this repo (it, validate_env.REQUIRED_KEYS, code-review's _REQUIRED_INFRA_KEYS) were measured identical on 2026-08-08. Only find_infra_env_defaults, the SCANNER over it, is replaced. Deleting the module would delete the facts along with the checker.

Prerequisite, done first (prolog-gate in scripts/gates.py). The replacement must be RUNNING before anything it replaces comes out — RUL-023, and the plain reading of replace-then-delete. standards had a battery entry; removing it without one for gate run would drop the rules out of the battery entirely for the length of one commit. Measured: 6.5s, gate: PASS, 89,902 facts. Its input set is src + .claude/banned-libraries.txt + pyproject.toml, wider than standards' src because its facts are wider — fingerprinting only src would let a package be un-banned with the gate cached as "unchanged since last PASS".

Two rows STRUCK, measured 2026-08-08

Both name an implementation that enforces the same POLICY at a different point in the lifecycle, not a duplicate checker over the same tree. Deleting either would move enforcement later, not remove a duplicate.

But striking the rows did not end the work, because the duplication concern behind them was real and one half of it was live. The registry is shared DATA; the READER was not. codemod/review.py held its ban list as a hardcoded ("pytest_xdist", "xdist") tuple while its own finding message told the reader the list came from .claude/banned-libraries.txt — missing docker, grpcio, grpcio-tools, grpc, unittest.mock, mock and pyswip. An agent's pending edit could introduce any of the seven and that review would pass.

That is the third hardcoded ban list this phase has found, after standards' _FORBIDDEN_IMPORTS = {"pyswip"} and the gate's own missing pyswip entry. A hardcoded copy does not announce itself: it looks exactly like an enforcement point that works, right until the registry gains an entry it never hears about.

So core/banned_libraries.py now owns BOTH the registry path and the parser, and every consumer reads through it — the gate's FactExtractor, the codemod review pass, and the differential's comparison helper. ban_targets/is_banned moved there too, mirroring ban_target/3: the codemod visitor folded every import to its top-level package, so the dotted unittest.mock entry could never have matched anything. Measured after: that pass now flags 5 of 6 banned imports in a probe that previously caught 2, and correctly leaves import unittest alone.

A fifth rule, because the fact could not tell two policies apart

The last row said OS_ENVIRON_MUTATION was replaced by bare_os_environ. It was not, and checking why exposed a live defect.

OS_ENVIRON_MUTATION detects a WRITE — os.environ['X'] = v. bare_os_environ was written for a READ. But env_access/4 recorded both as Form subscript, so the read rule fired on writes and told the reader:

src/pkg/a.py:3 [bare_os_environ/error] bare os.environ[REDIS_HOST] —
                                       use os.environ.get(REDIS_HOST, <default>)

.get() on an assignment is not a fix, it is a DELETION of the write, and a reader following the gate's own advice would have silently dropped a configuration write. Two policies, two remedies, one fact value.

The fix is in the FACT, not the rule: the analyzer now records subscript_store for a Store/Del context, subscript for a Load, and the rules split accordingly — bare_os_environ narrows to the read, env_mutation takes the write with the remedy that actually applies (pass the value through an env= seam, RUL-002). Measured on the same corpus:

src/pkg/a.py:2 [bare_os_environ/error] bare os.environ[REDIS_HOST] — use os.environ.get(...)
src/pkg/a.py:3 [env_mutation/error]    os.environ[REDIS_HOST] = ... mutates the process
                                       environment — pass the value explicitly

This is REQ-GATE-004 working as designed: the fact base grew a DOMAIN distinction (reads and writes are different things) and the rule count followed it, rather than a rule being bolted on to paper over a fact that could not say what happened.

TestCorpusDiscipline::test_every_declared_rule_has_a_corpus failed the moment the rule was added and before any corpus existed for it — the guard doing exactly its job. 3 positive / 6 negative, plus two tests that the read and the write fire ONE rule each, since two findings on one line with contradictory remedies is worse than either alone.

The last row could not be ticked until the AST saw tests

Mohamed, 2026-08-08: "the ast must see tests and scripts too."

OS_ENVIRON_MUTATION audits tests/ and nothing else. env_mutation reads the AST snapshot, and that snapshot covered src/orchestrata_ultimate alone — 850 modules, file_class 850/850 src, zero test files. So the row could not be ticked on the replacement being written: the rule was structurally incapable of seeing a single file the checker it replaced was pointed at, and deleting on that basis would have been pure coverage loss dressed as cleanup.

The snapshot now covers three trees (ast_pipeline.SOURCE_TREES): 2,722 modules, 337.5K facts, 21s to rebuild, 9s to evaluate. Then the differential, both detectors over tests/ with waivers ignored:

legacy(tests/): 32   prolog(tests/): 32   agreed: 32
legacy-only (LOSS if deleted): []      prolog-only (gain): 0

Deleted in the same commit, and the direct-detection test was rewritten as an ABSENCE assertion rather than removed — two detectors for one rule is how a finding gets reported twice with different wording, and that test is what fails if the old one is ever restored alongside the new.

Four defects surfaced the hour the snapshot widened, none of them findable before it did:

  1. The two env rules contradicted each other. bare_os_environ told the reader to .get(REDIS_HOST, <default>) on exactly the ten keys infra_key_with_default FAILS THE BUILD for defaulting (RUL-003 inverts for required infra endpoints: they must RAISE). 18 of 30 bare-read findings were infra keys in test fixtures — correct code, flagged by a rule that had never seen an infra-key subscript because every one of them lives outside src/. The corpus had the wrong case pinned as a POSITIVE; it is now a negative with the reason, plus a guard that an infra-key bare read fires nothing.
  2. environ was matched by NAME, not by binding. A test that builds environ: dict[str, str] = {} and passes it through the code's env= injection seam — the pattern RUL-003 mandates — got eight findings telling it to add defaults to a local dict. The analyzer now resolves the bare name against the module's own from os import environ, which is where the answer was all along.
  3. The # gate-ok: convention was invisible to the rules. Five hand-written checkers honour it, including the one being replaced here; the Prolog layer could not, because comments are not in the AST. 37 of 47 mutation findings sat at lines carrying a reason someone wrote when the site was audited. It is now waiver/3, a fact source (core.gate_waivers) exactly like the ban registry — and deliberately not honoured by banned_import or infra_key_with_default, which have no exception in any context.
  4. Two hash producers disagreed on CRLF. compute_file_meta hashed read_text(), which universal-newline-translates; the gate hashed raw bytes. Every LF file agreed, so src/ never noticed — and the five vendored scripts/_janus_patched/xsb_tests/*.py files reported content_changed against a snapshot built seconds earlier, taking the gate INVALID for a tree that had not moved. Both hash bytes now.

Two rule-shape defects came out of the same pass and are worth naming, because both are the same failure as the maplist collapse in relative_spec/3: once/1 around the scope check made gate explain unable to show WHICH source root admitted a file (the determinism moved into scope_admits/1, whose clauses clause/2 can reach), and a fact base carrying project/2 but no source_root/1 turned every rule off at once — now INVALID rather than a pass, because that is precisely the false green REQ-GATE-003 exists to refuse.

The 22 findings that survived all of the above were real and are fixed, not waived: four scripts carried byte-identical copies of a phase-1-only dotenv loader (.env and stop). .env is the prod-BASE layer — it carries CXP_GRPC_HOST=localhost for the host user-units — so each of those scripts resolved PRODUCTION endpoints while believing it was on the test fleet. All four now delegate to core.env_bootstrap.load_env_contract, and each verifiably resolves REDIS_HOST=10.93.53.11 afterwards. The nine remaining sites are genuine reviewed exceptions and now carry a reasoned marker apiece.

Five MORE scripts carried the same phase-1-only defect in a form the gate could not see — load_dotenv(.env, override=False) via python-dotenv, no subscript for env_mutation to match. override=False was the right instinct aimed at the wrong half: it was added on 2026-07-26 after a bootstrap intended for the test node wrote 592 chunks into the production cluster, and it protects phase 1 while leaving phase 2 — the profile overlay, the half that actually points a process at the right infrastructure — unapplied. All five now delegate to the canonical loader. Measured from a clean environment (env -i): phase 1 loads .env's secrets, phase 2 resolves REDIS_HOST=10.93.53.11 and CXP_GRPC_HOST=10.93.53.13 rather than .env's prod-base localhost; an explicitly-exported non-endpoint key survives, and an endpoint key is claimed back by declare_owned exactly as designed.

plans/ast-tests.json was the last piece of the same duplication class: a SECOND serialize --source tests run over files the wide snapshot already described. Two parses of one tree drift, and this pair had — the checked-in tests snapshot predated body_hash, residue_hash, env_accesses and per-import line numbers, and it answered is_pydantic: False for _RecordingVolk(BaseAgent), a question it was structurally unable to get right because BaseAgent(BaseModel) lives in src/ and a tests-only snapshot cannot see it. It is now PROJECTED from the one parse by orchestrata_ast.derive_subtree, wired into the pipeline. The differential against a real --source tests run: 1,689/1,689 module ids identical, inheritance and dependency graphs identical, 0 sha1 mismatches, and exactly two field-level differences — 35,137 runtime_calls entries that are [] in the derived artifact and None in the standalone one (the derived comes from the reaugmented snapshot), and the one is_pydantic the standalone got wrong.

Deletion is not optional. RUL-024 applies to duplication as much as to defects: leaving the legacy copy "just in case" recreates the exact condition this work exists to remove, and the second implementation drifts silently because nothing exercises it.


What must NOT happen

Open questions for Mohamed

  1. The 26 advisory codes — separate advisory channel, or does a rule that cannot fail anything stop being a rule? (Blocks P7 scope, not P1–P6.)
  2. Lexical facts — RESOLVED 2026-08-06, see below.

Resolved: no lexical fact layer — the linters ARE fact sources

Mohamed, 2026-08-06: "we could check ruff and black ruleset as facts and the fact that they ran and output, prolog facts doesn't mean replace linters."

Correct, and it removes a layer I had wrongly proposed. Measured:

Instead the tooling layer becomes facts:

tool_rule(ruff, 'E501', 'line-too-long', fixable).   % ruff rule --all --output-format json
tool_rule_status(ruff, 'E501', ignored).             % pyproject select/ignore
tool_outcome(ruff, ok, 1786031482).                  % it actually ran
tool_verdict(ruff, File, Line, Code, Severity).      % what it found

which buys the inference no checker here can currently express:

covered(Policy)   :- policy_mechanism(Policy, Tool, Code),
                     tool_rule_status(Tool, Code, enabled).
uncovered(Policy) :- policy_requirement(Policy), \+ covered(Policy).

Prolog then derives that long-line policy is unmechanized (ruff ignores E501, black rewrites without reporting) rather than us reimplementing a check to rediscover it. Two further payoffs: RUL-014 ("every ruling must resolve to a mechanism") becomes a query instead of rulings_lint.py's prose scan, and the 2026-08-06 false-green fix (62349326 — six "the tool never ran" codes promoted to ERROR) collapses into one rule over tool_outcome/3.

Net: lexical fact layer 845,547 → 0 facts.

Changelog

Date Change
2026-08-06 P0 done: contract, diagram, plan created. Slice 1 chosen.
2026-08-06 Gate order added (P8): Gate 0 infra BLOCKING, Gate 1 linters+env PARALLEL, neither in the run record. Baseline timings captured.
2026-08-06 4 advisory rules deleted, GRAPH-CONSTRAINT field map fixed (97e32f12); codes 46→42, advisory 20→16. CLASS_TOO_LONG kept as WARNING + P3 refactor task.
2026-08-08 P4 DONE. gate explain (proof tree) + gate why-not (failing subgoal) over explain.pl, 168 tests. Fixed a defect in explain.pl itself: first_failing/4's \+ provable/3 discarded bindings, so every reported subgoal came back with free variables (_15584>0 instead of 0>0) — the value is the point of asking why-not. bind_witness/2 added; the 13 existing explain tests pass unchanged.
2026-08-08 P3 DONE. gate_rules.pl (4 rules, one entry predicate), gate_severity.pl (defeasible, cut-based), 148 tests, 15 positive / 23 negative corpus. gate run PASS rc=0 on the live tree. Two rulings applied: hash-not-time (mtime clause + file_mtime/2 removed) and scope-by-project (project/2 in, extractor refuses out-of-root facts). grpc added to banned-libraries.txt. Fixed: root . emptied the fact base; every ban carried the wrong rationale; undefined severity/3 raised and zeroed the violation list.
2026-08-08 P2 DONE. freshness.pl (4 named staleness kinds), gates/engine.py (three-valued verdict, exit 0/1/2), scripts/gate.py run, 94 tests. Fixed: snapshot path form differed by producer and silently reverted P1's file_class fix; janus's global DB leaked facts across runs (now unload_file); coarse inode-clock made two mtime tests race.
2026-08-08 P1 DONE. code_facts.pl (26 declared / 20 emitted), gates/fact_extractor.py, scripts/gate.py facts, 89 tests. 90,317 facts incl. the live ruff catalogue (956 rules: 227 enabled / 1 ignored / 728 unknown), consult 1.19s GIL-off. Three defects fixed en route: snapshot could not answer 2 of 4 slice-1 rules (added env_access + import lines to orchestrata_ast, verified 96=96 against an independent walk); file_class/2 exempted 5 production modules; ~3,400 discontiguous warnings.
2026-08-06 Open question 2 RESOLVED: no lexical layer. Linter catalogue/config/outcome/findings become facts; coverage becomes inferable. Contract gains REQ-GATE-010/011 and two acceptance lines. Swarm stopped by Mohamed before any artifact was produced; re-dispatch pending.

Atoms and facts landed 2026-08-06 (Mohamed: "add atoms and facts to detect that")

Two rules were composed from named atoms/facts rather than written as ad-hoc scans, so they lift into the Prolog layer unchanged when P1 lands.

IMPERATIVE_ACCUMULATOR (scripts/code-review.py) — a loop whose only job is to grow a collection.

empty_collection(Expr).                  % atom
accumulator_init(Var, Line).             % fact
loop_mutates(Var, Method).               % fact
loop_body_size(Loop, N).                 % fact
branchless(Loop).                        % fact

imperative_accumulator(Var, Line) :-
    accumulator_init(Var, _),
    loop_mutates(Var, _),
    branchless(Loop),
    loop_body_only_mutates(Loop, Var).

Bounding body SIZE was not enough: out.append(x); sink.write(x) passed the size test and was reported, though a loop with a side effect is doing real work. The clause requires every statement to BE an accumulator mutation.

T9 module state (scripts/test-review.py) — was a four-name magic list (_cache, _state, _registry, _instances) nested four deep. Now:

mutable_literal(Expr).                   % atom
module_binding(Name, Expr).              % fact
constant_by_convention(Name) :- upper_case(Name).

module_state(Name) :- state_name(Name).
module_state(Name) :- module_binding(Name, E), mutable_literal(E),
                      \+ constant_by_convention(Name).

Measured: catches _pool = {} and _seen = [], which the name list missed, while leaving CANONICAL = {...} (UPPER = constant) and counter = 0 alone.

Checker cost — one walk, not seventeen

test-review.py called ast.walk(tree) in 17 separate places, so every one of ~1550 test modules was traversed 17 times. Walking once per file and sharing the node list: 36.07s -> 19.30s wall, 35.69s -> 19.04s CPU (-47%), with an identical finding profile.

Constraint single-sourcing

graph_test_preflight.py held TWO more hand-written copies of the canonical merge keys — CONSTRAINT_SENSITIVE_PROPS (21 labels, 8 disagreeing with the live cluster) and load_cached_constraints() (21 dicts used by --offline). Both now derive from CANONICAL_MERGE_KEYS: 73 labels, 0 disagreements with live. Online and offline validate the same contract by construction.

Definer count for graph constraints, measured: canonical_schema.py, schema.py, cxp_auditor.py, sync.py, semantic_graph/adapters/memgraph.py, canonical_conformance.py — six independent sources for one fact.