feat(rules): a preference is a rule that does not bind (#3849 step 1)
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m11s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m11s
CI & Build / Build & push image (push) Skipped
Adds `kind` to rules — `rule` binds, `preference` is how the operator wants work done. One column, because the two differ in exactly one dimension and everything else a preference needs already lives on `rules`: a trigger column, a trigger-dominated embedding document, ownership-scoped search, three retrieval arms with telemetry, typed relations, and versioning. Defaults to `rule`, so nothing changes force on upgrade — 0088's argument for `tier`, unchanged. `rule_versions` gets the column too, and that half is not bookkeeping. `record_if_changed` decides whether an edit deserves a snapshot by comparing the fields a version carries, so a field absent from SNAPSHOT_FIELDS is a field whose change records no history at all. Without it, turning a rule into a preference — the moment something stops binding, and the single most consequential edit either kind can undergo — would leave the history silent. Backup carries it through all four seams. A missed one would have restored every preference as a rule, quietly. Guarded on real Postgres in three halves: a preference writes, a typo is refused (without which every other assertion would pass against a table whose CHECK had been dropped), and a row written with no kind reads back as `rule` — the migration's whole safety claim, asserted rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
This commit is contained in:
@@ -513,7 +513,7 @@ def _rule_version_rows(rows) -> list[dict]:
|
||||
"id": rv.id, "rule_id": rv.rule_id, "user_id": rv.user_id,
|
||||
"title": rv.title, "statement": rv.statement, "why": rv.why,
|
||||
"how_to_apply": rv.how_to_apply, "when_to_apply": rv.when_to_apply,
|
||||
"tier": rv.tier, "verify_with": rv.verify_with,
|
||||
"tier": rv.tier, "kind": rv.kind, "verify_with": rv.verify_with,
|
||||
"expires_when": rv.expires_when,
|
||||
"created_at": rv.created_at.isoformat(),
|
||||
}
|
||||
@@ -575,7 +575,7 @@ def _rule_rows(rows) -> list[dict]:
|
||||
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
|
||||
"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,
|
||||
"when_to_apply": r.when_to_apply, "tier": r.tier, "kind": r.kind,
|
||||
"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,
|
||||
@@ -1283,6 +1283,11 @@ 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",
|
||||
# Same shape, same reason: a file written before 0098 has no
|
||||
# kind, and every rule in it was a rule. Defaulting the other
|
||||
# way would restore an old backup with things that had always
|
||||
# bound quietly no longer binding.
|
||||
kind=r_data.get("kind") or "rule",
|
||||
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
|
||||
@@ -1423,6 +1428,10 @@ async def _restore_v2(data: dict) -> dict:
|
||||
how_to_apply=rv.get("how_to_apply"),
|
||||
when_to_apply=rv.get("when_to_apply"),
|
||||
tier=rv.get("tier"),
|
||||
# NOT defaulted, unlike the rule above. A version records what
|
||||
# was; absent means nobody wrote it down, and inventing "rule"
|
||||
# here would put an artifact where a measurement belongs.
|
||||
kind=rv.get("kind"),
|
||||
verify_with=rv.get("verify_with"),
|
||||
expires_when=rv.get("expires_when"),
|
||||
created_at=_dt(rv.get("created_at")),
|
||||
|
||||
@@ -36,7 +36,7 @@ from scribe.models.rule_version import RuleVersion
|
||||
# snapshots would bury the edits somebody is actually looking for.
|
||||
SNAPSHOT_FIELDS = (
|
||||
"title", "statement", "why", "how_to_apply", "when_to_apply",
|
||||
"tier", "verify_with", "expires_when",
|
||||
"tier", "kind", "verify_with", "expires_when",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -295,6 +295,9 @@ async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> No
|
||||
# two in step; this keeps the error readable).
|
||||
TIERS = ("always_on", "conditional")
|
||||
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
# Migration 0098's CHECK. `rule` binds; `preference` is how the operator
|
||||
# wants work done — see the model comment for why both live on one table.
|
||||
KINDS = ("rule", "preference")
|
||||
|
||||
|
||||
# The rule columns that are nullable, and therefore the ones where EMPTY has
|
||||
@@ -319,6 +322,22 @@ def _valid_tier(tier: str) -> str:
|
||||
return tier if tier in TIERS else "always_on"
|
||||
|
||||
|
||||
def _valid_kind(kind: str) -> str:
|
||||
"""An unrecognised kind falls back to `rule` — the SAFE direction.
|
||||
|
||||
Same shape as _valid_tier and the same argument, pointed at force instead
|
||||
of delivery. A preference wrongly treated as binding costs a little
|
||||
friction: the reader is told something is required that was only
|
||||
preferred. A rule wrongly treated as a preference costs the thing the rule
|
||||
was written to prevent, and costs it silently, because nothing downstream
|
||||
can tell a softened rule from a preference that was always one.
|
||||
|
||||
Between a reader who is too careful and a reader who is not careful
|
||||
enough, the typo should produce the first.
|
||||
"""
|
||||
return kind if kind in KINDS else "rule"
|
||||
|
||||
|
||||
# Re-exported, not redefined. Notes gained the same trio in milestone 317 and
|
||||
# this reading of it is genuinely common, so it moved to services/verification
|
||||
# — the DRY win note 3163 names, as against sharing the QUERY, which the two
|
||||
@@ -351,6 +370,13 @@ def rule_brief(rule: Rule, **extra) -> dict:
|
||||
"statement": rule.statement,
|
||||
"topic_id": rule.topic_id,
|
||||
"tier": rule.tier,
|
||||
# Unconditional, and the payload cost is accepted deliberately. Every
|
||||
# other optional key below is attached only when present, because an
|
||||
# absent key should never read as a capability the record lacks. Force
|
||||
# is the opposite case: a reader seeing no `kind` would have to assume
|
||||
# one, and the assumption it would reach for — "this binds" — is the
|
||||
# expensive one to get wrong in the other direction. Say it outright.
|
||||
"kind": rule.kind or "rule",
|
||||
"updated_at": rule.updated_at.date().isoformat() if rule.updated_at else None,
|
||||
}
|
||||
# Attached only when present (#2483: never a null key that reads as a
|
||||
@@ -478,7 +504,7 @@ async def create_rule(
|
||||
topic_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "",
|
||||
verify_with: str = "", expires_when: str = "", kind: str = "rule",
|
||||
) -> Rule:
|
||||
async with async_session() as session:
|
||||
await _assert_topic_owned(session, topic_id, user_id)
|
||||
@@ -488,6 +514,7 @@ async def create_rule(
|
||||
statement=statement,
|
||||
when_to_apply=when_to_apply or None,
|
||||
tier=_valid_tier(tier),
|
||||
kind=_valid_kind(kind),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
verify_with=verify_with or None,
|
||||
@@ -506,7 +533,7 @@ async def create_project_rule(
|
||||
project_id: int, user_id: int, title: str, statement: str,
|
||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||
verify_with: str = "", expires_when: str = "",
|
||||
verify_with: str = "", expires_when: str = "", kind: str = "rule",
|
||||
) -> Rule:
|
||||
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||
|
||||
@@ -522,6 +549,7 @@ async def create_project_rule(
|
||||
statement=statement,
|
||||
when_to_apply=when_to_apply or None,
|
||||
tier=_valid_tier(tier),
|
||||
kind=_valid_kind(kind),
|
||||
why=why or None,
|
||||
how_to_apply=how_to_apply or None,
|
||||
verify_with=verify_with or None,
|
||||
@@ -752,7 +780,7 @@ async def update_rule(
|
||||
return None
|
||||
allowed = {
|
||||
"title", "statement", "why", "how_to_apply", "order_index",
|
||||
"when_to_apply", "tier", "arose_from_id",
|
||||
"when_to_apply", "tier", "kind", "arose_from_id",
|
||||
"verify_with", "expires_when",
|
||||
}
|
||||
check_before = rule.verify_with
|
||||
@@ -770,6 +798,8 @@ async def update_rule(
|
||||
continue
|
||||
if key == "tier":
|
||||
value = _valid_tier(value)
|
||||
elif key == "kind":
|
||||
value = _valid_kind(value)
|
||||
elif key in NULLABLE_RULE_TEXT:
|
||||
value = value or None
|
||||
elif key == "arose_from_id":
|
||||
|
||||
Reference in New Issue
Block a user