CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
272 lines
12 KiB
Python
272 lines
12 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import (
|
|
BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table,
|
|
Text, UniqueConstraint, text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
|
|
|
|
|
|
class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "rulebooks"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
owner_user_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE")
|
|
)
|
|
title: Mapped[str] = mapped_column(Text)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"owner_user_id": self.owner_user_id,
|
|
"title": self.title,
|
|
"description": self.description or "",
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|
|
|
|
|
|
class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "rulebook_topics"
|
|
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
|
|
# trashed topic doesn't block recreating/restoring the same title.
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_topic_per_rulebook", "rulebook_id", "title",
|
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
rulebook_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE")
|
|
)
|
|
title: Mapped[str] = mapped_column(Text)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"rulebook_id": self.rulebook_id,
|
|
"title": self.title,
|
|
"description": self.description or "",
|
|
"order_index": self.order_index,
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|
|
|
|
|
|
class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "rules"
|
|
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
|
|
# rules don't block recreating/restoring the same title).
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_rule_per_topic", "topic_id", "title",
|
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
# Exactly one of topic_id / project_id is set — enforced by CHECK
|
|
# constraint ck_rule_topic_xor_project (migration 0059).
|
|
topic_id: Mapped[int | None] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
)
|
|
project_id: Mapped[int | None] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
title: Mapped[str] = mapped_column(Text)
|
|
statement: Mapped[str] = mapped_column(Text)
|
|
# WHEN this rule applies — the trigger, not the instruction. Required of
|
|
# new rules at the service layer and nullable here, because rules written
|
|
# before migration 0088 have none and a migration cannot invent one.
|
|
# It carries three jobs at once (note 3026): it is the readable form of
|
|
# the canon tag, the half of the document that makes a rule findable by
|
|
# meaning, and — since milestone 394 removed the always-on tier — the ONLY
|
|
# thing that decides whether a rule ever reaches a session at all. A rule
|
|
# with no trigger is not a quiet rule, it is an unreachable one.
|
|
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# WHAT KIND of instruction this is. `tier` used to sit beside this and
|
|
# carry delivery; milestone 394 removed it, so kind is now the only axis
|
|
# on a rule and delivery belongs entirely to retrieval.
|
|
# `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
|
|
# 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.
|
|
arose_from_id: Mapped[int | None] = mapped_column(
|
|
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"topic_id": self.topic_id,
|
|
"project_id": self.project_id,
|
|
"title": self.title,
|
|
"statement": self.statement,
|
|
"when_to_apply": self.when_to_apply or "",
|
|
# 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 "",
|
|
"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),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|
|
|
|
|
|
# Which global AREA a rule is about (milestone 307). Points at the canonical
|
|
# catalog, NEVER at a project's `systems` row: a rule that spans projects
|
|
# cannot be chained to one project's vocabulary. This is the edge three
|
|
# projects were drawing by hand, as rule text copied into a System's charter.
|
|
rule_systems = Table(
|
|
"rule_systems",
|
|
Base.metadata,
|
|
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("canonical_id", Integer, ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|
|
|
|
|
|
class RuleRelation(Base, CreatedAtMixin):
|
|
"""A typed edge between two rules. Each kind exists because its ABSENCE
|
|
forced a workaround somewhere in the operator's rulebook (note 3026).
|
|
|
|
- ``co_surfaces`` — these fail together, so they must arrive together.
|
|
Without it, the only way to guarantee that was to merge them into one
|
|
row, which is what happened to rule 46: split into 144, folded back the
|
|
same day because "either rule could surface without the other."
|
|
Symmetric in meaning; stored once and read both ways.
|
|
- ``overrides`` — this rule supersedes that one for its scope. Only
|
|
*suppression* existed, so an override had to be written as a parallel
|
|
rule that then drifts from its parent.
|
|
- ``elaborates`` — this rule adds local specifics to that one; surfacing
|
|
the parent brings the addendum with it.
|
|
|
|
``note`` records WHY the edge was drawn, for the same reason a rule
|
|
carries `why`: a later reader deciding whether it still holds needs the
|
|
reasoning, not just the fact.
|
|
"""
|
|
|
|
__tablename__ = "rule_relations"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
from_rule_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
|
|
)
|
|
to_rule_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# CHECK ck_rule_relations_kind (migration 0088, rule 36).
|
|
kind: Mapped[str] = mapped_column(Text)
|
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"from_rule_id": self.from_rule_id,
|
|
"to_rule_id": self.to_rule_id,
|
|
"kind": self.kind,
|
|
"note": self.note or "",
|
|
"created_at": iso(self.created_at),
|
|
}
|
|
|
|
|
|
# Pure many-to-many — no model class, just the join table.
|
|
project_rulebook_subscriptions = Table(
|
|
"project_rulebook_subscriptions",
|
|
Base.metadata,
|
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|
|
|
|
# Suppressions — let a project mute individual rules or whole topics from
|
|
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
|
|
# FKs CASCADE so the row vanishes when its parent is removed.
|
|
project_rule_suppressions = Table(
|
|
"project_rule_suppressions",
|
|
Base.metadata,
|
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|
|
|
|
# `project_rulebook_exclusions` lived here until milestone 394. It recorded a
|
|
# project's opt-out of a whole always-on rulebook — which only made sense
|
|
# while a rulebook could bind a project WITHOUT being asked. Subscription is
|
|
# now the only reach a rulebook has, so declining one is expressed by not
|
|
# subscribing, and there is nothing left to opt out of.
|
|
|
|
project_topic_suppressions = Table(
|
|
"project_topic_suppressions",
|
|
Base.metadata,
|
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|