feat(rules): a rule can carry its own check — verify_with, expires_when, verified_at (#3095, milestone 312 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 25s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 25s
A rulebook holds two kinds of row in one table. A NORM is a decision: no truth value, changes only when its author changes it, and they know they did. A CONSTRAINT asserts a fact about someone else's software, and goes false with nobody present. Milestone 307's audit found nine stale sites; every one was a constraint, and not one norm had rotted. Three nullable columns so a rule can say how to check itself. expires_when is a STATE, not a date — constraints expire when the ground moves, not on a schedule. verified_at NULL means never checked and sorts FIRST in the sweep to come: unexamined outranks examined-long-ago. Most rules set none of the three; a null verify_with is the marker for "this is a decision, there is nothing to go and check," and it only reads that way while it stays honest. Nothing is backfilled and nothing is indexed. A migration cannot invent a check any more than 0088 could invent a trigger, and the sweep reads a whole rulebook — hundreds of rows, on operator demand, never on a request path. Also, in the backup service the fields had to pass through: - Restore now remaps arose_from_id through note_id_map. It has been exported since 0088 and silently dropped on the way back in ever since, so every restore lost every rule's provenance link. - _dt_or_none, because _dt substitutes now() for an absent value. That is right for created_at/updated_at and wrong here: a rule nobody ever checked would restore looking freshly checked and fall to the bottom of the sweep it should top. Column additions do not move BACKUP_VERSION; only new sections do, as when 0088 added when_to_apply/tier/arose_from_id to the same helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
"""Unit tests for the v4 backup export contract.
|
||||
"""Unit tests for the backup export contract.
|
||||
|
||||
CI runs pytest with no database, so these cover the parts that don't need one:
|
||||
the version/coverage constants, the pure join-table row helpers, and the export
|
||||
dict shape (via a mocked session). Full FK-remapping round-trip is exercised
|
||||
manually against a real DB (export a backup, confirm rulebooks appear).
|
||||
This is the no-database lane, so these cover the parts that need none: the
|
||||
version/coverage constants, the pure row helpers, and the export dict shape
|
||||
(via a mocked session). The full FK-remapping round-trip needs real Postgres
|
||||
and belongs in a `@pytest.mark.integration` module — it is not written yet,
|
||||
which is why every row helper here is a plain function that can be tested
|
||||
without a session.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -132,3 +135,58 @@ def test_supersession_rows_serialise_the_pair():
|
||||
{"superseder_id": 9, "superseded_id": 4},
|
||||
{"superseder_id": 9, "superseded_id": 5},
|
||||
]
|
||||
|
||||
|
||||
def test_rule_rows_carry_the_verification_fields():
|
||||
"""A rule's check must survive a backup.
|
||||
|
||||
`verify_with`/`expires_when`/`verified_at` (milestone 312) say whether a
|
||||
rule is a fact that can go false and when it was last confirmed. A backup
|
||||
that drops them restores a rulebook that has forgotten which of its rules
|
||||
can rot — the exact blindness the fields were added to end.
|
||||
|
||||
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
||||
call made for when_to_apply/tier/arose_from_id in 0088 (commit 6ddb8bf).
|
||||
"""
|
||||
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why="w", how_to_apply="h", order_index=0,
|
||||
when_to_apply="when", tier="conditional",
|
||||
verify_with="cat some/file", expires_when="the file grows a shell",
|
||||
verified_at=checked, arose_from_id=99,
|
||||
created_at=checked, updated_at=checked,
|
||||
)
|
||||
out = backup._rule_rows([row])[0]
|
||||
|
||||
assert out["verify_with"] == "cat some/file"
|
||||
assert out["expires_when"] == "the file grows a shell"
|
||||
assert out["verified_at"] == checked.isoformat()
|
||||
# Provenance was exported from 0088 onward but silently dropped on the way
|
||||
# back IN until milestone 312. Export side asserted here; the restore side
|
||||
# remaps it through note_id_map.
|
||||
assert out["arose_from_id"] == 99
|
||||
|
||||
|
||||
def test_rule_rows_keep_an_unverified_rule_unverified():
|
||||
"""NULL verified_at means never checked, and it must round-trip as null.
|
||||
|
||||
_dt substitutes now() so created_at/updated_at are never null. Reusing it
|
||||
here would restore a rule nobody ever checked as though it had just been
|
||||
checked — dropping it to the BOTTOM of the sweep it should top. That is
|
||||
why _dt_or_none exists.
|
||||
"""
|
||||
row = SimpleNamespace(
|
||||
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
||||
why=None, how_to_apply=None, order_index=0,
|
||||
when_to_apply=None, tier="always_on",
|
||||
verify_with=None, expires_when=None, verified_at=None,
|
||||
arose_from_id=None,
|
||||
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
||||
)
|
||||
assert backup._rule_rows([row])[0]["verified_at"] is None
|
||||
assert backup._dt_or_none(None) is None
|
||||
assert backup._dt_or_none("2026-08-27T12:00:00+00:00") == datetime(
|
||||
2026, 8, 27, 12, 0, tzinfo=timezone.utc
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user