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.mmdUpdate 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:
- one rule, up to four implementations (bare
os.environ: AST toolkitstandards,code-review.py,test_isolation_audit.py,checks_safety.py) - the "duplicates" are not equivalent — three spot-checked pairs each caught different things, so the de-facto policy is the union of accidents
- a rule can be dead for its whole life and nothing notices (the tree-sitter
relative-import copy, dead two separate ways, deleted in
1d8532f2) - 26 of 46
code-review.pycodes cannot fail anything, and produced 3,147 of 3,177 findings on 47 files - every checker re-reads and re-parses files the snapshot already holds
The three constraints that decide whether this works
- 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.)
- Facts come from
plans/ast-ultimate.json, never from reading files — and snapshot freshness is itself facts, so a gate over a stale base returnsinvalid, neverpass. - 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
-
The snapshot could not answer two of the four slice-1 rules.
os.environ.get("X", d)reachedcallsas the bare stringos.environ.get, usually without arguments;os.environ["X"]is a Subscript and never appeared incallsat all; andImportInfocarried no line number, sono_relative_importcould not reportfile:line. Since REQ-GATE-001 forbids re-opening the file, the extractor had no honest source. Closed by addingEnvAccessInfo+ImportInfo.linetosrc/orchestrata_ast/— verified against an independent AST walk: 96 = 96, exact. -
file_class/2misclassified five PRODUCTION modules as tests. A basename-first rule taggedcore/threading/test_executor.py(the parallel test runner),test_scheduler.py,gateway/handlers/test_metrics.py,gateway/test_runner.pyandknowledge_graph/test_results_store.pyastest.gate_severity.pldemotes 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,400 SWI "clauses are not together" warnings. The freshness layer emits
file_mtime/2andcontent_hash/3alternately 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
-
Snapshot paths are absolute or relative depending on who built it.
gates.py ast-refreshwrites absolute paths; a directserialize --source src/orchestrata_ultimatewrites relative ones. With absolute pathsclassify_fileseesparts[0] == '/', misses every root check, falls through to the basename heuristic — and re-misclassified the five productiontest_*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 reportfile:linedifferently between generations. The form is now decided once, at the extractor boundary (_rel). -
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/1facts still answeredh(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 nowunload_file/1s every file it consults, in afinally.retractallis not an option — file-loaded clauses are static, and it raisesNo permission to modify static procedure. -
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 at1786198568747442069for 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, andcontent_changedcatches 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
-
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". -
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. -
An undefined
severity/3raised instead of falling back. SWI raisesexistence_errorfor an undefined predicate rather than failing, so the wholefindallthrew 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 withcurrent_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.
-
import pyswipwas banned in exactly one place — a code literal.standardsheld_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. -
banned_importcould not express what_check_no_mocksenforces. It compared TOP-LEVEL packages only, so "unittest.mockis banned andunittestis not" was unstateable — the ledger's claim thatbanned_importreplaced that checker could not have been true. Closed byban_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 unittestclean,from unittest.mock import patch,import mock,import unittest.mockall flagged,import docker.errorsstill caught by the top-level fold. -
project(_, '.')silenced every rule while the gate reported PASS.Path(x).relative_to(x)is., andin_project/1prefix-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 inFactExtractor._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. -
The classifier answered a ban-registry question about a relative import. A
no_relative_importfinding at a line importing.relativecame 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 tobanned_import, and anything else falls through tounexplained, which is the honest answer.
Two smaller ones, same run: from . import x printed `relative import (level
- 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 predicateexplain.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.
codemod/review.py BannedConstructsreviewsentry.mutated_tree— a pending codemod edit, before any bytes are written to disk. The gate reads a snapshot of files that already exist. It structurally cannot review an unwritten edit, so deleting this pass would mean an agent could write a banned import and find out on the next full gate run, after it landed.core/infra_env_rule.py::find_infra_env_defaultsfeedsverification/review/engine.py— the review of code a VOLK just generated, in the pipeline, before it is committed. Same argument.
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:
- The two env rules contradicted each other.
bare_os_environtold the reader to.get(REDIS_HOST, <default>)on exactly the ten keysinfra_key_with_defaultFAILS 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 outsidesrc/. 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. environwas matched by NAME, not by binding. A test that buildsenviron: dict[str, str] = {}and passes it through the code'senv=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 ownfrom os import environ, which is where the answer was all along.- 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 nowwaiver/3, a fact source (core.gate_waivers) exactly like the ban registry — and deliberately not honoured bybanned_importorinfra_key_with_default, which have no exception in any context. - Two hash producers disagreed on CRLF.
compute_file_metahashedread_text(), which universal-newline-translates; the gate hashed raw bytes. Every LF file agreed, sosrc/never noticed — and the five vendoredscripts/_janus_patched/xsb_tests/*.pyfiles reportedcontent_changedagainst 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
- a rule-shaped predicate in
code_facts.pl(e.g.relative_import_violation/2) - a fact extractor that opens a
.pyfile for structural facts - a gate that reports
passwhen the fact base is stale - severity embedded in a rule clause instead of
gate_severity.pl - a legacy checker surviving its replacement
- a rule with no negative corpus — the property all four hooks fixed on
2026-08-06 were missing (
91db53f3,85a422a6,bc74be35,1e56ace3)
Open questions for Mohamed
- 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.)
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:
- a per-line layer is 845,547 facts across 2,748 files — several times the entire structural fact base, spent on the four least valuable rules
- black already rewrites three of the four. Verified in one pass: trailing whitespace removed, tab indentation converted, final newline added. A rule reporting what the formatter silently fixes is noise by construction
- black left a 126-char line at 126 chars, so
LINE_TOO_LONGis genuinely uncovered — but the answer is not to reimplement it
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.