Skip to content

fix(engine): refuse drops that would make a negating rule fail open, and reject ground negation at registration - #253

Merged
jsam merged 6 commits into
mainfrom
fm/fix-reldrop-negated
Oct 4, 2026
Merged

jsam merged 6 commits into
mainfrom
fm/fix-reldrop-negated

Conversation

@jsam

@jsam jsam commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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_switch the 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 drop of 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 drop is now refused with a conflict error (new StorageError::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 new RuleCatalog::rules_negating follows dependents to find the affected rules, and the error message names them.
  • The same protection now covers rule removal (StorageError::RuleNegated, conflict). .rule drop, -name, .rule drop prefix, .rule clear and .rule remove of 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_rule now 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 and tests/statement_status_tests.rs cover the new refusals.

Risk Assessment

✅ Low: The fix round makes the change the user chose: rule_edited_by is now recorded only when change.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_switch repro, 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.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Editor runs .rel drop kill_switch while check_needed negates it: refused with a conflict naming check_needed, the relation stays, and ?check_needed still returns 0 rows ✅ pass live s1-rel-drop-negated.txt
Registering a rule with ground negation !kill_switch("carrier_check") or !kill_switch(_) is rejected at registration ('shares no variables'); the assignment form is accepted ✅ pass live s2-ground-negation-registration.txt
Adversarial: .rule drop, -name, .rule clear, .rule remove of the last clause, .rule drop prefix, and .rel drop of a relation negated through a rule all refuse while the negating rule exist… ✅ pass live s3-rule-removal-paths.txt
Multi-statement program: a refused removal rolls back the whole program and blames the rule-editing statement, not a later schema statement; dropping together with negators or redefining in one progra… ✅ pass live s4-program-end-state.txt
After a server restart on the same data, the shipped CLI client's .rel drop kill_switch is still refused and the rule stays closed ✅ pass live s5-after-restart-cli.txt
Evidence: Intent repro: .rel drop of negated kill_switch refused as Editor; rule stays closed
[auth] authenticated role=editor

>>> +shipment[("ORD-1", "S-1")]
<<< {"type":"result","id":"r0","columns":["message"],"rows":[["Inserted 1 fact(s) into 'shipment'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":4,"row_provenance":["unknown"],"errors":[]}

>>> +tool_policy[("carrier_check", "auto")]
<<< {"type":"persistent_update","knowledge_graph":"default","relation":"shipment","operation":"insert","count":1,"timestamp_ms":1791135302298,"seq":1}
<<< {"type":"result","id":"r1","columns":["message"],"rows":[["Inserted 1 fact(s) into 'tool_policy'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown"],"errors":[]}

>>> +kill_switch[("carrier_check")]
<<< {"type":"persistent_update","knowledge_graph":"default","relation":"tool_policy","operation":"insert","count":1,"timestamp_ms":1791135302303,"seq":2}
<<< {"type":"persistent_update","knowledge_graph":"default","relation":"kill_switch","operation":"insert","count":1,"timestamp_ms":1791135302308,"seq":3}
<<< {"type":"result","id":"r2","columns":["message"],"rows":[["Inserted 1 fact(s) into 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown"],"errors":[]}

>>> +check_needed(Order, Shipment) <- shipment(Order, Shipment), tool_policy(Tool, "auto"), Tool = "carrier_check", !kill_switch(Tool)
<<< {"type":"rule_change","knowledge_graph":"default","rule_name":"check_needed","operation":"registered","timestamp_ms":1791135302316,"seq":4}
<<< {"type":"result","id":"r3","columns":["message"],"rows":[["Rule 'check_needed' registered."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":7,"row_provenance":["unknown"],"errors":[]}

>>> ?check_needed(O, S)
<<< {"type":"result","id":"r4","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":4,"timing_breakdown":{"total_us":4727,"parse_us":202,"sip_us":166,"magic_sets_us":87,"ir_build_us":77,"optimize_us":235,"shared_views_us":0},"errors":[]}

>>> .rel drop kill_switch
<<< ERROR [conflict] Error: Cannot drop relation 'kill_switch': rule(s) check_needed negate it or a rule that reads it, so dropping it would make them derive the rows it blocks. Drop or redefine those rules first (.rule drop <name>), or delete its facts instead.

>>> .rel
<<< {"type":"result","id":"r6","columns":["message"],"rows":[["Relations:"],["  kill_switch (arity: 1, columns: [col0: any], tuples: 1)"],["  shipment (arity: 2, columns: [col0: any, col1: any], tuples: 1)"],["  tool_policy (arity: 2, columns: [col0: any, col1: any], tuples: 1)"]],"row_count":4,"total_count":4,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown","unknown"],"errors":[]}

>>> ?check_needed(O, S)
<<< {"type":"result","id":"r7","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":3,"timing_breakdown":{"total_us":3631,"parse_us":177,"sip_us":148,"magic_sets_us":69,"ir_build_us":48,"optimize_us":142,"shared_views_us":0},"errors":[]}

>>> -kill_switch("carrier_check")
<<< {"type":"result","id":"r8","columns":["message"],"rows":[["Deleted 1 facts from 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"errors":[]}

>>> ?check_needed(O, S)
<<< {"type":"persistent_update","knowledge_graph":"default","relation":"kill_switch","operation":"delete","count":1,"timestamp_ms":1791135302330,"seq":5}
<<< {"type":"result","id":"r9","columns":["O","S"],"rows":[["ORD-1","S-1"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":4,"row_provenance":["unknown"],"timing_breakdown":{"total_us":3971,"parse_us":179,"sip_us":169,"magic_sets_us":81,"ir_build_us":49,"optimize_us":158,"shared_views_us":1},"errors":[]}

>>> +kill_switch[("carrier_check")]
<<< {"type":"persistent_update","knowledge_graph":"default","relation":"kill_switch","operation":"insert","count":1,"timestamp_ms":1791135302337,"seq":6}
<<< {"type":"result","id":"r10","columns":["message"],"rows":[["Inserted 1 fact(s) into 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":1,"row_provenance":["unknown"],"errors":[]}

>>> .rule drop check_needed
<<< {"type":"rule_change","knowledge_graph":"default","rule_name":"check_needed","operation":"dropped","timestamp_ms":1791135302344,"seq":7}
<<< {"type":"result","id":"r11","columns":["message"],"rows":[["Rule 'check_needed' dropped."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":5,"row_provenance":["unknown"],"errors":[]}

>>> .rel drop kill_switch
<<< {"type":"schema_change","knowledge_graph":"default","entity":"kill_switch","operation":"dropped","timestamp_ms":1791135302353,"seq":8}
<<< {"type":"result","id":"r12","columns":["message"],"rows":[["Relation 'kill_switch' dropped."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":8,"row_provenance":["unknown"],"errors":[]}

>>> .rel
<<< {"type":"result","id":"r13","columns":["message"],"rows":[["Relations:"],["  shipment (arity: 2, columns: [col0: any, col1: any], tuples: 1)"],["  tool_policy (arity: 2, columns: [col0: any, col1: any], tuples: 1)"]],"row_count":3,"total_count":3,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown"],"errors":[]}
Evidence: Ground/wildcard negation rejected at registration
[auth] authenticated role=editor

>>> +kill_switch[("carrier_check")]
<<< {"type":"result","id":"r0","columns":["message"],"rows":[["Inserted 1 fact(s) into 'kill_switch'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":5,"row_provenance":["unknown"],"errors":[]}

>>> +g1(Order, Shipment) <- shipment(Order, Shipment), tool_policy(Tool, "auto"), !kill_switch("carrier_check")
<<< ERROR [validation] Failed to register rule: Unsafe negation in rule 'g1': Negated atom !kill_switch(...) shares no variables with positive body atoms. Negation requires at least one shared variable: bind the constant to a variable first, e.g. `T = "value", !kill_switch(T)`.

>>> +g2(Order, Shipment) <- shipment(Order, Shipment), !kill_switch(_)
<<< ERROR [validation] Failed to register rule: Unsafe negation in rule 'g2': Negated atom !kill_switch(...) shares no variables with positive body atoms. Negation requires at least one shared variable: bind the constant to a variable first, e.g. `T = "value", !kill_switch(T)`.

>>> .rule
<<< {"type":"result","id":"r3","columns":["message"],"rows":[["No rules defined."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}

>>> +g3(Order, Shipment) <- shipment(Order, Shipment), T = "carrier_check", !kill_switch(T)
<<< {"type":"result","id":"r4","columns":["message"],"rows":[["Rule 'g3' registered."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":7,"row_provenance":["unknown"],"errors":[]}

>>> ?g3(O, S)
<<< {"type":"rule_change","knowledge_graph":"default","rule_name":"g3","operation":"registered","timestamp_ms":1791135308614,"seq":10}
<<< {"type":"result","id":"r5","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":3,"timing_breakdown":{"total_us":3086,"parse_us":176,"sip_us":123,"magic_sets_us":56,"ir_build_us":83,"optimize_us":160,"shared_views_us":0},"errors":[]}

>>> .rule drop g3
<<< {"type":"rule_change","knowledge_graph":"default","rule_name":"g3","operation":"dropped","timestamp_ms":1791135308625,"seq":11}
<<< {"type":"result","id":"r6","columns":["message"],"rows":[["Rule 'g3' dropped."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":5,"row_provenance":["unknown"],"errors":[]}
Evidence: Rule drop / -rule / clear / remove last clause / drop prefix / transitive rel drop refused
[auth] authenticated role=editor

>>> +blocklist[("T9")]
<<< {"type":"result","id":"r0","columns":["message"],"rows":[["Inserted 1 fact(s) into 'blocklist'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":4,"row_provenance":["unknown"],"errors":[]}

>>> +order[("O1", "T1"), ("O2", "T9")]
<<< {"type":"result","id":"r1","columns":["message"],"rows":[["Inserted 2 fact(s) into 'order'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown"],"errors":[]}

>>> +p_blocked(T) <- blocklist(T)
<<< {"type":"result","id":"r2","columns":["message"],"rows":[["Rule 'p_blocked' registered."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":6,"row_provenance":["unknown"],"errors":[]}

>>> +p_allowed(O) <- order(O, T), !p_blocked(T)
<<< {"type":"result","id":"r3","columns":["message"],"rows":[["Rule 'p_allowed' registered."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":7,"row_provenance":["unknown"],"errors":[]}

>>> ?p_allowed(O)
<<< {"type":"result","id":"r4","columns":["O"],"rows":[["O1"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown"],"timing_breakdown":{"total_us":3662,"parse_us":193,"sip_us":139,"magic_sets_us":71,"ir_build_us":55,"optimize_us":107,"shared_views_us":0},"errors":[]}

>>> .rule drop p_blocked
<<< ERROR [conflict] Cannot remove rule 'p_blocked': rule(s) p_allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together.

>>> -p_blocked
<<< ERROR [conflict] Cannot remove rule 'p_blocked': rule(s) p_allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together.

>>> .rule clear p_blocked
<<< ERROR [conflict] Cannot remove rule 'p_blocked': rule(s) p_allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together.

>>> .rule remove p_blocked 1
<<< ERROR [conflict] Cannot remove rule 'p_blocked': rule(s) p_allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together.

>>> .rule drop prefix p_b
<<< ERROR [conflict] Cannot remove rule 'p_blocked': rule(s) p_allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together.

>>> .rel drop blocklist
<<< ERROR [conflict] Error: Cannot drop relation 'blocklist': rule(s) p_allowed negate it or a rule that reads it, so dropping it would make them derive the rows it blocks. Drop or redefine those rules first (.rule drop <name>), or delete its facts instead.

>>> ?p_allowed(O)
<<< {"type":"result","id":"r11","columns":["O"],"rows":[["O1"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown"],"timing_breakdown":{"total_us":3160,"parse_us":180,"sip_us":172,"magic_sets_us":76,"ir_build_us":42,"optimize_us":77,"shared_views_us":0},"errors":[]}

>>> .rule
<<< {"type":"result","id":"r12","columns":["message"],"rows":[["Rules:"],["  p_allowed (1 clause(s))"],["  p_blocked (1 clause(s))"]],"row_count":3,"total_count":3,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown"],"errors":[]}

>>> .rule drop prefix p_
<<< {"type":"result","id":"r13","columns":["message"],"rows":[["Dropped 2 rule(s) with prefix 'p_': p_allowed, p_blocked"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":13,"row_provenance":["unknown"],"errors":[]}

>>> .rule
<<< {"type":"result","id":"r14","columns":["message"],"rows":[["No rules defined."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
Evidence: Program end-state check, rollback and statement blame
[auth] authenticated role=editor

>>> +blocked(T) <- blocklist(T)
... +allowed(O) <- order(O, T), !blocked(T)
<<< {"type":"result","id":"r0","columns":["message"],"rows":[["Rule 'blocked' registered."],["Rule 'allowed' registered."]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":8,"row_provenance":["unknown","unknown"],"errors":[]}

>>> ?allowed(O)
<<< {"type":"result","id":"r1","columns":["O"],"rows":[["O1"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":4,"row_provenance":["unknown"],"timing_breakdown":{"total_us":3766,"parse_us":206,"sip_us":140,"magic_sets_us":105,"ir_build_us":62,"optimize_us":113,"shared_views_us":0},"errors":[]}

>>> +other(X) <- order(X, _)
... .rule drop blocked
<<< {"type":"result","id":"r2","columns":["message"],"rows":[["Cannot remove rule 'blocked': rule(s) allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together. (rolled back: none of the program's 2 write statements 0-1 was applied)"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[{"index":1,"code":"conflict","message":"Cannot remove rule 'blocked': rule(s) allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together. (rolled back: none of the program's 2 write statements 0-1 was applied)"}]}

>>> .rule
<<< {"type":"result","id":"r3","columns":["message"],"rows":[["Rules:"],["  allowed (1 clause(s))"],["  blocked (1 clause(s))"]],"row_count":3,"total_count":3,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown"],"errors":[]}

>>> .rule drop blocked
... +blocked(t: string)
<<< {"type":"result","id":"r4","columns":["message"],"rows":[["Cannot remove rule 'blocked': rule(s) allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together. (rolled back: none of the program's 2 write statements 0-1 was applied)"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[{"index":0,"code":"conflict","message":"Cannot remove rule 'blocked': rule(s) allowed negate it or a rule that reads it, so removing it would make them derive the rows it blocks. Drop or redefine those rules first, or remove them together. (rolled back: none of the program's 2 write statements 0-1 was applied)"}]}

>>> .rule drop blocked
... +blocked(T) <- order(_, T)
<<< {"type":"result","id":"r5","columns":["message"],"rows":[["Rule 'blocked' dropped."],["Rule 'blocked' registered."]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":7,"row_provenance":["unknown","unknown"],"errors":[]}

>>> ?allowed(O)
<<< {"type":"result","id":"r6","columns":[],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":3,"timing_breakdown":{"total_us":3180,"parse_us":206,"sip_us":153,"magic_sets_us":75,"ir_build_us":52,"optimize_us":105,"shared_views_us":0},"errors":[]}

>>> .rule drop blocked
... .rule drop allowed
<<< {"type":"result","id":"r7","columns":["message"],"rows":[["Rule 'blocked' dropped."],["Rule 'allowed' dropped."]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":5,"row_provenance":["unknown","unknown"],"errors":[]}

>>> .rule
<<< {"type":"result","id":"r8","columns":["message"],"rows":[["No rules defined."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
Evidence: After restart, via inputlayer-client CLI
Connecting to server at http://127.0.0.1:18734...
Connected!

Server status: healthy
Authenticated as: admin
Current knowledge graph: default

> .rule
Rules:
  check_needed (1 clause(s))
> ?check_needed(O, S)
No results.
> .rel drop kill_switch
Error: Cannot drop relation 'kill_switch': rule(s) check_needed negate it or a rule that reads it, so dropping it would make them derive the rows it blocks. Drop or redefine those rules first (.rule drop <name>), or delete its facts instead.
> .rel
Relations:
  blocklist (arity: 1, columns: [col0: any], tuples: 1)
  kill_switch (arity: 1, columns: [col0: any], tuples: 1)
  order (arity: 2, columns: [col0: any, col1: any], tuples: 2)
  shipment (arity: 2, columns: [col0: any, col1: any], tuples: 1)
  tool_policy (arity: 2, columns: [col0: any, col1: any], tuples: 1)
> ?check_needed(O, S)
No results.

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 (record rule_edited_by only when change.edits_rules(), and add a test that a later schema statement is not blamed) is not in this commit. Lines 144-152 still record change.name() for every catalog outcome, schema changes included (CreateSchema, DefineSchema, RemoveSchema, DefineSessionSchema). Example: the program .rule drop blocked\n+blocked(t: string) runs while allowed negates blocked. The schema statement at index 1 overwrites rule_edited_by[&#34;blocked&#34;], so the RuleNegated conflict at line 165 is reported on statement 1 instead of the .rule drop at statement 0. The map also repeats the touched-rule bookkeeping in StagedCatalog::apply (catalog_change.rs:216-222). Fix as decided: wrap the recording in if change.edits_rules() and add a statement_status test covering a schema statement placed after the rule drop. Optionally, have StagedCatalog keep 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.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Editor runs .rel drop kill_switch while check_needed negates it: refused with a conflict naming check_needed, the relation stays, and ?check_needed still returns 0 rows ✅ pass live s1-rel-drop-negated.txt
Registering a rule with ground negation !kill_switch("carrier_check") or !kill_switch(_) is rejected at registration ('shares no variables'); the assignment form is accepted ✅ pass live s2-ground-negation-registration.txt
Adversarial: .rule drop, -name, .rule clear, .rule remove of the last clause, .rule drop prefix, and .rel drop of a relation negated through a rule all refuse while the negating rule exist… ✅ pass live s3-rule-removal-paths.txt
Multi-statement program: a refused removal rolls back the whole program and blames the rule-editing statement, not a later schema statement; dropping together with negators or redefining in one progra… ✅ pass live s4-program-end-state.txt
After a server restart on the same data, the shipped CLI client's .rel drop kill_switch is still refused and the rule stays closed ✅ pass live s5-after-restart-cli.txt
  • cargo build --bin inputlayer-server --bin inputlayer-client; started the server on a temp --data-dir and port 18734 with a bootstrap admin key
  • As admin: .user create ed edpw12345 editor and .kg acl grant default ed editor; all later scenarios ran as Editor ed through 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, .rel still lists it, ?check_needed still 0 rows; a fact delete still opens it; after .rule drop check_needed the relation drops
  • Scenario 2: +g1(...) &lt;- ..., !kill_switch(&#34;carrier_check&#34;) and !kill_switch(_) rejected at registration; T = &#34;carrier_check&#34;, !kill_switch(T) accepted
  • Scenario 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_allowed unchanged; .rule drop prefix p_ (drops the negator too) succeeds
  • Scenario 4: multi-statement programs — an unrelated edit plus .rule drop blocked is rolled back and statement 1 is blamed; .rule drop blocked plus +blocked(t: string) blames statement 0; drop plus redefine of blocked succeeds; .rule drop blocked then .rule drop allowed succeeds
  • Scenario 5: restarted the server on the same data dir, then ran inputlayer-client --script with .rule, ?check_needed, .rel drop kill_switch, .rel, ?check_needed
  • cargo test --test statement_status_tests negated and cargo test --lib -- negat (the change's focused tests)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

jsam added 5 commits October 4, 2026 14:46
…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
jsam force-pushed the fm/fix-reldrop-negated branch from a9c53b3 to 6d66874 Compare October 4, 2026 17:38
…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
@jsam
jsam merged commit bc381b2 into main Oct 4, 2026
21 checks passed
@jsam
jsam deleted the fm/fix-reldrop-negated branch October 4, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant