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:
@@ -105,6 +105,24 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
# default preserves existing behaviour exactly: nothing stops binding
|
||||
# because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36).
|
||||
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
|
||||
# WHAT KIND of instruction this is — force, where `tier` is delivery.
|
||||
# `rule` must be FOLLOWED: ignoring it breaks something or crosses a
|
||||
# boundary. `preference` is how this person wants work DONE: ignoring it
|
||||
# costs consistency, not correctness.
|
||||
#
|
||||
# The second half is what makes it a kind rather than a softer label — a
|
||||
# preference is expected to CHANGE as the work teaches it, and the agent
|
||||
# updates it in the ordinary course of working, where a rule waits for its
|
||||
# author. So one column decides two behaviours: whether create's approval
|
||||
# gate fires, and which voice the injected line speaks in.
|
||||
#
|
||||
# Lives here and not in its own table because a preference needs exactly
|
||||
# what a rule has and a note does not — a trigger column, a
|
||||
# trigger-dominated document, ownership-scoped search, the retrieval arms,
|
||||
# relations, and `rule_versions`, which is where its drift is recorded.
|
||||
# Defaults to `rule` so nothing changes force on upgrade.
|
||||
# CHECK ck_rules_kind (migration 0098, rule 36).
|
||||
kind: Mapped[str] = mapped_column(Text, default="rule", server_default="rule")
|
||||
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
|
||||
@@ -144,6 +162,13 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"statement": self.statement,
|
||||
"when_to_apply": self.when_to_apply or "",
|
||||
"tier": self.tier,
|
||||
# Unconditional, unlike the `if present` keys below. A reader
|
||||
# deciding how much force a record carries must never infer it
|
||||
# from an ABSENT key: "no kind field" and "kind is rule" would be
|
||||
# the same payload, and that equivalence is the defect shape this
|
||||
# codebase keeps re-encountering. Twenty bytes buys an answer that
|
||||
# cannot be misread.
|
||||
"kind": self.kind or "rule",
|
||||
"why": self.why or "",
|
||||
"how_to_apply": self.how_to_apply or "",
|
||||
"verify_with": self.verify_with or "",
|
||||
|
||||
Reference in New Issue
Block a user