feat(rules): a rule can carry its own check — verify_with, expires_when, verified_at (#3095, milestone 312 step 1)
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:
@@ -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),
|
||||
|
||||
@@ -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")),
|
||||
|
||||
Reference in New Issue
Block a user