Repository navigation
fix(engine): refuse drops that would make a negating rule fail open, and reject ground negation at registration - #253
Merged
Merged
Conversation
…haring at registration
Dropping a relation that a persistent rule negates made the rule read it
as empty and derive every row it blocked: `.rel drop kill_switch` turned a
kill-switch policy off with no error. The drop is now refused with a
conflict error naming the rules that negate the relation, directly or
through rules that read it.
A negated atom sharing no variable with the body (`!kill_switch("x")`,
`!kill_switch(_)`) registered fine and then failed every query of the
rule; registration now rejects it with the same explanation.
Docs and the GUI's universal-quantification example (which used the
rejected shape) are updated.
The rule-removal guard ran per statement, so a program that dropped a negated rule before the rule negating it (`.rule drop blocked` then `.rule drop allowed`) was refused and rolled back, although the error says to remove them together; `.ontology remove` drops a pack's rules in one program in pack order and hit the same refusal. Dropping and redefining a rule in one program was refused too. The check now runs once after the program's catalog changes are staged: a rule left without clauses while a remaining rule negates it is refused, blaming the last statement that edited it.
jsam
force-pushed
the
fm/fix-reldrop-negated
branch
from
October 4, 2026 17:38
a9c53b3 to
6d66874
Compare
…e code. Both attempts of the run died on the runner with ENOSPC (no space left on device) while building. The test build used full debuginfo on top of a restored target/ cache, and the blacksmith-4vcpu runner ran out of disk. Locally, all 83 test binaries pass. As you instructed, I changed only the Tests (fast) job in .github/workflows/ci.yml: - Added job-level env: CARGO_PROFILE_DEV_DEBUG=line-tables-only, CARGO_PROFILE_TEST_DEBUG=line-tables-only and CARGO_INCREMENTAL=0. Line numbers stay in RUST_BACKTRACE output and target/ is much smaller. - Bumped the cache key to `${{ runner.os }}-cargo-test-fast-v2-${{ hashFiles('**/Cargo.toml') }}`, so the old full-debuginfo target/ is not restored alongside the new artifacts. - Kept the existing comment explaining why there are no restore-keys, and added one line saying to bump the key version when build settings change. No engine code or tests were changed. I left the unrelated hnsw recall flake alone. Checks I ran: - Parsed the workflow YAML and confirmed the job's env and new cache key. - Ran `cargo test` on a scratch crate with cargo 1.99.0 using the same env; cargo accepted `line-tables-only` and the tests passed. - actionlint is not installed, so the workflow was not linted. I have not pushed this change, and Tests (fast) has not run with it. Pushing and confirming the CI result is left to the outer executor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Keep fixing bugs and QCing the engine. (Context: the seven-role review of the SDK, messaging and value proposition verified these defects on main.) The defect in scope is the review finding that "the model proposes; the rules decide" is not yet a security boundary: an Editor key can turn a policy off by dropping a relation that a rule negates. Reproduced: with the docs rule shape check_needed(Order, Shipment) <- shipment(Order, Shipment), tool_policy(Tool, "auto"), Tool = "carrier_check", !kill_switch(Tool) and the kill switch on, ?check_needed returns 0 rows; after
.rel drop kill_switchthe engine replies "Relation 'kill_switch' dropped." and ?check_needed returns [["ORD-1","S-1"]] with no error and no warning - the rule fails open silently. The review asks the engine to refuse (or warn and invalidate the rule on).rel dropof a negated relation, and to run the negation validity check at rule registration: a ground negation such as !kill_switch("carrier_check") is today accepted at registration and only rejected at query time with "Negated predicate ... shares no variables".What Changed
.rel dropis now refused with aconflicterror (newStorageError::RelationNegated) when a persistent rule negates the relation, either directly or through a rule that reads it. Before this, dropping the relation removed it and the negating rule silently failed open. A newRuleCatalog::rules_negatingfollows dependents to find the affected rules, and the error message names them.StorageError::RuleNegated,conflict)..rule drop,-name,.rule drop prefix,.rule clearand.rule removeof a rule's last clause are refused when they would empty a rule that another rule negates. The check runs on the program's end state, so a program can drop a rule together with the rules that negate it, in any order. The error is attributed to the last statement in the program that edited the emptied rule.validate_rulenow rejects, at registration, a negated atom that shares no variables, such as!kill_switch("carrier_check")or!kill_switch(_). Previously these registered and then failed every query. The commands/syntax docs, the Python SDK comment and the GUI "Universal Quantification" example (which relied on!all_eng(_)) are updated to match. Unit tests andtests/statement_status_tests.rscover the new refusals.Risk Assessment
✅ Low: The fix round makes the change the user chose:
rule_edited_byis now recorded only whenchange.edits_rules(), so a schema statement can no longer be blamed for emptying a negated rule. The new test fails under the old code, which blamed index 1, and passes under the fix, which blames index 0.Testing
I built the server and client and ran an isolated server on a temp data directory. Using an Editor login over the WebSocket API, I drove five scenarios: the intent's
.rel drop kill_switchrepro, ground-negation registration, every rule-removal path (including transitive negation), end-state program checks with atomic rollback and statement blame, and persistence of the guard across a restart through the shipped CLI client. All passed. The change's focused integration and unit tests also pass. I saved CLI and WebSocket transcripts as evidence and removed the temp server, data and driver..rel drop kill_switchwhile check_needed negates it: refused with a conflict naming check_needed, the relation stays, and ?check_needed still returns 0 rows.rule drop,-name,.rule clear,.rule removeof the last clause,.rule drop prefix, and.rel dropof a relation negated through a rule all refuse while the negating rule exist….rel drop kill_switchis still refused and the rule stays closedEvidence: Intent repro: .rel drop of negated kill_switch refused as Editor; rule stays closed
Evidence: Ground/wildcard negation rejected at registration
Evidence: Rule drop / -rule / clear / remove last clause / drop prefix / transitive rel drop refused
Evidence: Program end-state check, rollback and statement blame
Evidence: After restart, via inputlayer-client CLI
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed ✅
src/storage_engine/program_commit.rs:145- The fix the user already chose (recordrule_edited_byonly whenchange.edits_rules(), and add a test that a later schema statement is not blamed) is not in this commit. Lines 144-152 still recordchange.name()for every catalog outcome, schema changes included (CreateSchema, DefineSchema, RemoveSchema, DefineSessionSchema). Example: the program.rule drop blocked\n+blocked(t: string)runs whileallowednegatesblocked. The schema statement at index 1 overwritesrule_edited_by["blocked"], so the RuleNegated conflict at line 165 is reported on statement 1 instead of the.rule dropat statement 0. The map also repeats the touched-rule bookkeeping inStagedCatalog::apply(catalog_change.rs:216-222). Fix as decided: wrap the recording inif change.edits_rules()and add a statement_status test covering a schema statement placed after the rule drop. Optionally, haveStagedCatalogkeep the last statement index for each touched rule so the parallel map can go.🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
.rel drop kill_switchwhile check_needed negates it: refused with a conflict naming check_needed, the relation stays, and ?check_needed still returns 0 rows.rule drop,-name,.rule clear,.rule removeof the last clause,.rule drop prefix, and.rel dropof a relation negated through a rule all refuse while the negating rule exist….rel drop kill_switchis still refused and the rule stays closedcargo build --bin inputlayer-server --bin inputlayer-client; started the server on a temp--data-dirand port 18734 with a bootstrap admin keyAs admin:.user create ed edpw12345 editorand.kg acl grant default ed editor; all later scenarios ran as Editoredthrough a Node WebSocket driver (login frame + execute frames)Scenario 1 (the intent's repro): set up shipment, tool_policy and kill_switch facts and the check_needed rule, then?check_needed→ 0 rows,.rel drop kill_switch→ refused,.relstill lists it,?check_neededstill 0 rows; a fact delete still opens it; after.rule drop check_neededthe relation dropsScenario 2:+g1(...) <- ..., !kill_switch("carrier_check")and!kill_switch(_)rejected at registration;T = "carrier_check", !kill_switch(T)acceptedScenario 3:.rule drop p_blocked,-p_blocked,.rule clear p_blocked,.rule remove p_blocked 1,.rule drop prefix p_b, and.rel drop blocklist(negated transitively through p_blocked) all refused;?p_allowedunchanged;.rule drop prefix p_(drops the negator too) succeedsScenario 4: multi-statement programs — an unrelated edit plus.rule drop blockedis rolled back and statement 1 is blamed;.rule drop blockedplus+blocked(t: string)blames statement 0; drop plus redefine of blocked succeeds;.rule drop blockedthen.rule drop allowedsucceedsScenario 5: restarted the server on the same data dir, then raninputlayer-client --scriptwith.rule,?check_needed,.rel drop kill_switch,.rel,?check_neededcargo test --test statement_status_tests negatedandcargo test --lib -- negat(the change's focused tests)✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.