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

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:
2026-08-27 07:44:13 -04:00
co-authored by Claude Opus 5
parent 02c1e37620
commit e08e999406
4 changed files with 177 additions and 5 deletions
@@ -0,0 +1,64 @@
"""a rule can carry its own check — verify_with, expires_when, verified_at
(milestone 312 step 1)
Revision ID: 0090
Revises: 0089
Create Date: 2026-08-27
A rulebook holds two kinds of row in one table. A NORM is a decision: it has
no truth value, and it changes only when its author changes it — which they
know they did. A CONSTRAINT is a fact about someone else's software: a
runner's shell, a bot's config, a tool that exists. Nobody is present when
that goes false.
Milestone 307's rulebook audit found nine stale sites. Every one was a
constraint; not one norm had rotted. One of them had been telling every
session to skip database-backed tests for weeks while the integration lane
sat green in the workflow.
Three nullable columns, so a rule can say how to check itself:
- `verify_with` — how to tell whether this is still true. A command, a path,
a URL, a query. Prose is allowed; something runnable is better.
- `expires_when` — the STATE under which it stops being true. Deliberately
not a date: constraints do not expire on a schedule, they expire when the
world underneath them moves.
- `verified_at` — when the check last passed. NULL means never checked, and
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
All three nullable and all three optional, because most rules should set
none of them. A null `verify_with` is not an omission — it is the honest
marker of "this one is a decision, and there is nothing to go and check."
That signal only works if the field stays empty wherever it belongs empty.
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
backfilled: a migration cannot invent a check any more than 0088 could
invent a trigger.
"""
import sqlalchemy as sa
from alembic import op
revision = "0090"
down_revision = "0089"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True))
op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True))
op.add_column(
"rules",
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
)
# No index on (verify_with, verified_at). The sweep this exists for reads
# an operator's whole rulebook — hundreds of rows, not millions — and runs
# when a human asks for it, never on a request path. An index here would
# be maintained on every rule write to serve a query that a sequential
# scan answers instantly.
def downgrade() -> None:
op.drop_column("rules", "verified_at")
op.drop_column("rules", "expires_when")
op.drop_column("rules", "verify_with")
+23
View File
@@ -107,6 +107,26 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on") tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
why: Mapped[str | None] = mapped_column(Text, nullable=True) why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
# The three fields that tell a CONSTRAINT apart from a NORM (milestone
# 312). A norm is a decision — no truth value, changes only when its
# author changes it. A constraint asserts a fact about someone else's
# software, and goes false with nobody watching: every stale rule the
# 307 audit found was one, and no norm had rotted.
#
# `verify_with` is how to check the rule is still true; `expires_when` is
# the STATE that ends it, deliberately 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: unexamined outranks
# examined-long-ago.
#
# Most rules should leave all three empty. A null `verify_with` is not a
# gap — it is the marker for "this is a decision, there is nothing to go
# and check," and the signal is only worth reading while that stays true.
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# The record that caused this rule — the edge notes and tasks already # The record that caused this rule — the edge notes and tasks already
# have. Rule 46's `why` names note 2813 in prose; this is that link as a # have. Rule 46's `why` names note 2813 in prose; this is that link as a
# field, so it survives a rewording of the paragraph. # field, so it survives a rewording of the paragraph.
@@ -126,6 +146,9 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
"tier": self.tier, "tier": self.tier,
"why": self.why or "", "why": self.why or "",
"how_to_apply": self.how_to_apply or "", "how_to_apply": self.how_to_apply or "",
"verify_with": self.verify_with or "",
"expires_when": self.expires_when or "",
"verified_at": iso(self.verified_at),
"arose_from_id": self.arose_from_id, "arose_from_id": self.arose_from_id,
"order_index": self.order_index, "order_index": self.order_index,
"created_at": iso(self.created_at), "created_at": iso(self.created_at),
+27
View File
@@ -112,6 +112,18 @@ def _dt(val: str | None) -> datetime:
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc) return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
def _dt_or_none(val: str | None) -> datetime | None:
"""Like _dt, but keeps an absent timestamp absent.
_dt substitutes now() because created_at/updated_at must not be null.
For a nullable column that MEANS something by being empty, that default
is a lie: a rule nobody ever verified would restore looking verified at
the moment of the restore, and drop straight to the bottom of the sweep
it should have topped.
"""
return datetime.fromisoformat(val) if val else None
def _d(val: str | None) -> date | None: def _d(val: str | None) -> date | None:
return date.fromisoformat(val) if val else None return date.fromisoformat(val) if val else None
@@ -385,6 +397,8 @@ def _rule_rows(rows) -> list[dict]:
"title": r.title, "statement": r.statement, "why": r.why, "title": r.title, "statement": r.statement, "why": r.why,
"how_to_apply": r.how_to_apply, "order_index": r.order_index, "how_to_apply": r.how_to_apply, "order_index": r.order_index,
"when_to_apply": r.when_to_apply, "tier": r.tier, "when_to_apply": r.when_to_apply, "tier": r.tier,
"verify_with": r.verify_with, "expires_when": r.expires_when,
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
"arose_from_id": r.arose_from_id, "arose_from_id": r.arose_from_id,
"created_at": r.created_at.isoformat(), "created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(), "updated_at": r.updated_at.isoformat(),
@@ -1007,6 +1021,19 @@ async def _restore_v2(data: dict) -> dict:
# is the pre-0088 behaviour, so an old backup restores rules # is the pre-0088 behaviour, so an old backup restores rules
# that bind exactly as they did when it was taken. # that bind exactly as they did when it was taken.
tier=r_data.get("tier") or "always_on", tier=r_data.get("tier") or "always_on",
verify_with=r_data.get("verify_with") or None,
expires_when=r_data.get("expires_when") or None,
# Restored as-is, NOT reset to null. `verified_at` records
# when someone last ran the check; a restore does not make
# that untrue, and clearing it would put every constraint at
# the top of the sweep with nothing having actually changed.
verified_at=_dt_or_none(r_data.get("verified_at")),
# Remapped through note_id_map like every other note edge.
# Exported since 0088 but dropped on the way back in until
# milestone 312 — a restore silently lost every rule's
# provenance link. SET NULL semantics apply here too: a
# source note that didn't restore leaves the rule intact.
arose_from_id=note_id_map.get(r_data.get("arose_from_id") or 0),
order_index=r_data.get("order_index", 0), order_index=r_data.get("order_index", 0),
created_at=_dt(r_data.get("created_at")), created_at=_dt(r_data.get("created_at")),
updated_at=_dt(r_data.get("updated_at")), updated_at=_dt(r_data.get("updated_at")),
+63 -5
View File
@@ -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: This is the no-database lane, so these cover the parts that need none: the
the version/coverage constants, the pure join-table row helpers, and the export version/coverage constants, the pure row helpers, and the export dict shape
dict shape (via a mocked session). Full FK-remapping round-trip is exercised (via a mocked session). The full FK-remapping round-trip needs real Postgres
manually against a real DB (export a backup, confirm rulebooks appear). 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 types import SimpleNamespace
from unittest.mock import patch 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": 4},
{"superseder_id": 9, "superseded_id": 5}, {"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
)