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
+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")
why: 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
# 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.
@@ -126,6 +146,9 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
"tier": self.tier,
"why": self.why 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,
"order_index": self.order_index,
"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)
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:
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,
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
"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,
"created_at": r.created_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
# that bind exactly as they did when it was taken.
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),
created_at=_dt(r_data.get("created_at")),
updated_at=_dt(r_data.get("updated_at")),