diff --git a/alembic/versions/0119_rule_moment_misfires.py b/alembic/versions/0119_rule_moment_misfires.py
new file mode 100644
index 00000000..655a4f74
--- /dev/null
+++ b/alembic/versions/0119_rule_moment_misfires.py
@@ -0,0 +1,36 @@
+"""rule_moment_judgments.misfire — a mount reported as arriving where it does
+not apply (milestone 458 step 7b, #4955)
+
+Revision ID: 0119
+Revises: 0118
+Create Date: 2026-10-05
+
+The open-after-moment signal proposes a mount; nothing proposed taking one
+off. This column is the evidence for that direction: each time a session says
+a mounted rule arrived at a moment where it did not apply, the report is
+counted here per distinct day, with its reason and the action that reached
+the moment. At the bar it becomes an unmount proposal for the operator.
+
+A column on the judgment row rather than a table of its own: a misfire is
+always about a (rule, moment) pair that is mounted, and that pair already has
+its row. Nullable and no backfill — nobody has reported a misfire yet.
+"""
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+from alembic import op
+
+revision = "0119"
+down_revision = "0118"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "rule_moment_judgments",
+ sa.Column("misfire", postgresql.JSONB(), nullable=True),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("rule_moment_judgments", "misfire")
diff --git a/frontend/src/api/moments.ts b/frontend/src/api/moments.ts
index 70dd0b07..381b567b 100644
--- a/frontend/src/api/moments.ts
+++ b/frontend/src/api/moments.ts
@@ -101,19 +101,32 @@ export function unmapAction(change: MappingChange): Promise {
}
/**
- * Proposals that a rule belongs on a moment (milestone 458 step 7) — from a
- * pass that read the rule, or from the rule being opened just after the
- * moment fired. `GET /api/retrieval/moments/proposals`, the payload the
+ * Proposals about a rule's moments, waiting on a person. A `mount` proposal
+ * (milestone 458 step 7) comes from a pass that read the rule, or from the
+ * rule being opened just after the moment fired. An `unmount` proposal (step
+ * 7b) comes from sessions reporting that the mount arrived where it did not
+ * apply. `GET /api/retrieval/moments/proposals`, the payload the
* `rule_moment_proposals` MCP tool returns.
*/
-export type ProposalSource = "pass" | "signal" | "edit";
+export type ProposalSource = "pass" | "signal" | "edit" | "misfire";
+
+export interface MisfireReason {
+ why: string;
+ reached_by: string;
+ at: string;
+}
export interface MomentProposal {
+ proposal: "mount" | "unmount";
moment: string;
source: ProposalSource;
why: string;
+ /** For a misfire, `situations` counts distinct days and `co_surfaced` the reports. */
evidence: { situations: number; projects: number; co_surfaced: number };
created_at: string | null;
+ /** Unmount proposals only: the newest reasons given, and how often each action reached the moment. */
+ reasons?: MisfireReason[];
+ reached_by?: Record;
}
export interface RuleProposals {
@@ -151,7 +164,10 @@ export function getProposals(ruleId?: number): Promise {
return apiGet(`/api/retrieval/moments/proposals${ruleId ? `?rule_id=${ruleId}` : ""}`);
}
-/** A confirm MOUNTS the rule on the moment; a reject stops it being proposed again. */
+/**
+ * A confirm MOUNTS the rule on the moment (on an unmount proposal, keeps it);
+ * a reject unmounts it if mounted and stops the pair being proposed again.
+ */
export function judgeProposals(judgments: MomentJudgment[]): Promise {
return apiPost("/api/retrieval/moments/proposals/judge", { judgments });
}
diff --git a/frontend/src/components/MomentProposals.vue b/frontend/src/components/MomentProposals.vue
index 2250bf29..c5af5293 100644
--- a/frontend/src/components/MomentProposals.vue
+++ b/frontend/src/components/MomentProposals.vue
@@ -7,11 +7,13 @@ import { useMomentsStore } from "@/stores/moments";
import { useToastStore } from "@/stores/toast";
/**
- * Proposals that a rule belongs on a moment, waiting on a person (milestone
- * 458 step 7). They come from a pass that read the rule, or from the rule
- * being opened just after the moment fired in several sessions. Nothing here
- * is mounted until "Mount" is pressed — the same judgment `judge_rule_moments`
- * makes in a session, through the same service.
+ * Proposals about a rule's moments, waiting on a person. A mount proposal
+ * (milestone 458 step 7) comes from a pass that read the rule, or from the
+ * rule being opened just after the moment fired in several sessions; nothing
+ * is mounted until "Mount" is pressed. An unmount proposal (step 7b) comes
+ * from sessions reporting that the mount arrived where it did not apply;
+ * nothing is taken off until "Take it off" is pressed. Both are the judgment
+ * `judge_rule_moments` makes in a session, through the same service.
*/
const store = useMomentsStore();
const toast = useToastStore();
@@ -35,27 +37,48 @@ function key(rule: RuleProposals, p: MomentProposal): string {
return `${rule.id}:${p.moment}`;
}
+function plural(n: number, one: string, many: string): string {
+ return `${n} ${n === 1 ? one : many}`;
+}
+
function sourceLabel(p: MomentProposal): string {
+ if (p.proposal === "unmount") {
+ return `reported as not applying here on ${plural(p.evidence.situations, "day", "days")}`;
+ }
if (p.source === "signal") {
return `opened just after this moment in ${p.evidence.situations} sessions`;
}
return "proposed from the rule's text";
}
+// What the action that reached the moment was, most frequent first — so the
+// operator can see whether it is the rule or one action that keeps misfiring.
+function actions(p: MomentProposal): string[] {
+ return Object.entries(p.reached_by ?? {})
+ .sort((a, b) => b[1] - a[1])
+ .map(([action, n]) => `${action} ×${n}`);
+}
+
+function note(p: MomentProposal, verdict: Verdict): string {
+ if (p.proposal === "unmount") {
+ return verdict === "confirm" ? "Kept in Settings." : "Taken off in Settings.";
+ }
+ return verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.";
+}
+
async function decide(rule: RuleProposals, p: MomentProposal, verdict: Verdict) {
busy.value = key(rule, p);
try {
const out = await judgeProposals([{
- rule_id: rule.id, moment: p.moment, verdict,
- note: verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.",
+ rule_id: rule.id, moment: p.moment, verdict, note: note(p, verdict),
}]);
if (out.refused.length) {
toast.show(out.refused[0].error, "error");
return;
}
await load();
- // A mount changes the per-moment counts the list below shows.
- if (verdict === "confirm") await store.load(true);
+ // A mount or an unmount changes the per-moment counts the list below shows.
+ if ((p.proposal === "unmount") === (verdict === "reject")) await store.load(true);
} catch (e) {
toast.show(apiErrorMessage(e, "Could not record that"), "error");
} finally {
@@ -75,8 +98,10 @@ onMounted(load);
- Moments these rules may belong on. Mounting one makes the rule arrive whenever that
- moment happens, whatever the work is about; declining keeps it from being proposed again.
+ Moments these rules may belong on, or may not. Mounting one makes the rule arrive
+ whenever that moment happens, whatever the work is about; declining keeps it from being
+ proposed again. A mount that sessions keep reporting as beside the point can be taken
+ off — or, when one action is reaching the moment wrongly, unmapped in the list below.
@@ -133,6 +177,14 @@ onMounted(load);
.proposal-why { flex: 1 1 16rem; color: var(--fs-text-secondary); }
.proposal-source { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
.proposal-actions { display: inline-flex; gap: var(--fs-space-2); margin-left: auto; }
+.proposal-kind { font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
+.proposal-reasons {
+ flex-basis: 100%;
+ margin: 0;
+ padding-left: var(--fs-space-4);
+ font-size: var(--fs-size-tiny);
+ color: var(--fs-text-tertiary);
+}
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index 1c8d3f57..bc96d731 100644
--- a/plugin/.claude-plugin/plugin.json
+++ b/plugin/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
- "version": "2026.10.05.2039",
+ "version": "2026.10.05.2219",
"author": {
"name": "Bryan Van Deusen"
},
diff --git a/plugin/skills/using-scribe/missed-retrieval.md b/plugin/skills/using-scribe/missed-retrieval.md
index 92524151..2e42d598 100644
--- a/plugin/skills/using-scribe/missed-retrieval.md
+++ b/plugin/skills/using-scribe/missed-retrieval.md
@@ -22,7 +22,8 @@ did not arrive, the action did not reach the moment on this install — offer
does not apply — is a mount or a mapping that is wrong: take the moment off
the rule, or `unmap_action` the action that reached it. Each of these
changes what the operator's sessions receive, so offer it in one line and make
-it on their yes. The skill's moments
+it on their yes. When it is not plain enough to offer yet, `rule_misfired`
+records it, and repeated reports become the offer. The skill's moments
reference has the detail. A rule about a subject missed its WHAT, and the rest of this page is
for that.
diff --git a/plugin/skills/using-scribe/moments.md b/plugin/skills/using-scribe/moments.md
index 22840e8b..94e43683 100644
--- a/plugin/skills/using-scribe/moments.md
+++ b/plugin/skills/using-scribe/moments.md
@@ -2,7 +2,8 @@
Part of the using-scribe skill. Read it when a line says a rule arrived *at* a
moment, when a reply is held for one read, when an action reached the wrong
-moment or none, and when a line proposes mounting a rule.
+moment or none, when a mounted rule arrived and did not apply, and when a line
+proposes mounting or unmounting a rule.
## What a moment is
@@ -50,6 +51,26 @@ is a deliver and nothing fired — map it?"), and make it on their yes. The
correction lasts for every later session on the install; `list_moments` shows
what each action reaches now.
+## When a mounted rule arrives and does not apply
+
+A mount delivers its rule at every occurrence of the moment, so a mount that
+is wrong is noise every time — and a rule you read and set aside leaves no
+trace anyone else can see. When a rule arrived *at* a moment, you read it,
+and it has nothing to do with what you were doing there, say so with
+`rule_misfired(rule_id, moment, why, reached_by)`: one call, nothing asked of
+the operator. Name the action the line said reached the moment, and say what
+you were doing, because the reports are what the operator decides from —
+whether the rule does not belong at the moment, or one action is reaching it
+that is not that moment here.
+
+Reports gather across days. Once a rule has them on three, the response
+carries a line asking you to offer the fix, and the operator sees it under
+Settings › Moments too: `judge_rule_moments` `reject` takes the rule off,
+`unmap_action` stops the action reaching the moment, and `confirm` keeps the
+mount and ends the question. A rule that applied — even one you were already
+following — is not a misfire, and a rule that arrived by its words rather
+than a mount is fixed through its trigger instead.
+
## When a line proposes a mount
A rule that keeps being opened just after the same moment, across several
diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py
index 1457a511..7e4f628c 100644
--- a/src/scribe/mcp/server.py
+++ b/src/scribe/mcp/server.py
@@ -218,6 +218,9 @@ _WRITE_TOOLS = frozenset({
# Proposing moments for a rule writes a judgment row; judging one mounts
# or unmounts the rule (step 7).
"propose_rule_moments", "judge_rule_moments",
+ # A misfire report writes a counted row that can become an unmount
+ # proposal (step 7b).
+ "rule_misfired",
# A reviewer's verdicts on logged menu lines (#4772) — rows carrying free
# prose the agent authored, `rule_outcome`'s reason for being a write.
"judge_menu",
diff --git a/src/scribe/mcp/tools/moments.py b/src/scribe/mcp/tools/moments.py
index 731a6ade..d943384c 100644
--- a/src/scribe/mcp/tools/moments.py
+++ b/src/scribe/mcp/tools/moments.py
@@ -147,10 +147,13 @@ async def propose_rule_moments(proposals: list[dict]) -> dict:
async def rule_moment_proposals(rule_id: int = 0) -> dict:
"""The moment proposals waiting on the operator, grouped by rule.
- Each rule carries what it is mounted on now and its proposals: the moment,
- where the proposal came from (`pass` — read from the rule; `signal` — the
- rule kept being opened just after that moment fired), the reason, and the
- evidence counts. `rule_id` narrows to one rule.
+ Each rule carries what it is mounted on now and its proposals. Each
+ proposal is a `mount` or an `unmount`, with the moment, where it came
+ from (`pass` — read from the rule; `signal` — the rule kept being opened
+ just after that moment fired; `misfire` — sessions reported the mount
+ arriving where it did not apply, with their `reasons` and the actions
+ that `reached_by` the moment), the reason, and the evidence counts.
+ `rule_id` narrows to one rule.
"""
return await judgments_svc.pending(current_user_id(), rule_id=rule_id or None)
@@ -160,14 +163,54 @@ async def judge_rule_moments(judgments: list[dict]) -> dict:
Each item is `{"rule_id": N, "moment": "work.finish", "verdict":
"confirm" | "reject", "note": "why"}`. Confirm mounts the rule on that
- moment beside what it already has; reject records that it does not belong
- there (and unmounts it if it was mounted), so neither the pass nor the
- signal proposes the pair again. Moment `""` judges a "no moment fits"
+ moment beside what it already has — on an `unmount` proposal, it keeps
+ the mount and stops the misfire question; reject records that it does not
+ belong there (and unmounts it if it was mounted), so neither the pass nor
+ the signal proposes the pair again. Moment `""` judges a "no moment fits"
answer. Put the operator's reason in `note`.
"""
return await judgments_svc.judge(current_user_id(), judgments)
+async def rule_misfired(rule_id: int, moment: str, why: str,
+ reached_by: str = "", project_id: int = 0) -> dict:
+ """Report that a rule MOUNTED on a moment arrived there and did not apply.
+
+ A mount delivers its rule every time the moment fires, whatever the work
+ is about — so a mount that is wrong is noise at every occurrence, and
+ nothing else notices. Call this when a line said a rule arrived *at* a
+ moment ("at work.verify, reached by `actions_run_read`"), you read it,
+ and it does not govern what you were doing there. It costs one call and
+ asks nothing of the operator; reports gather, counted once per day, and
+ once a pair has them on three distinct days the response carries a line
+ asking you to offer the operator the fix — take the rule off the moment,
+ or unmap the action when it is the action that is wrong here.
+
+ A rule that applied, even one you were already following, is not a
+ misfire. A rule that arrived by resemblance rather than a mount is
+ refused with the fix for that (its trigger).
+
+ Args:
+ rule_id: the rule the line named.
+ moment: the moment it arrived at, as the line names it.
+ why: what you were doing and why the rule did not bear on it. Required
+ — it is what tells the operator whether the rule or the action
+ is wrong.
+ reached_by: the action the line says reached the moment (`git push`,
+ `status=done`). Counted, so the operator can see which action
+ keeps bringing it.
+ project_id: the project you are working in (0 = none).
+
+ Returns `recorded`, `days` so far against the `bar`, and `context`: a line
+ to act on once the bar is crossed, else "". A mount the operator chose to
+ keep says so under `kept`.
+ """
+ return await judgments_svc.misfired(
+ current_user_id(), rule_id, moment, why=why, reached_by=reached_by,
+ project_id=project_id or None,
+ )
+
+
def register(mcp) -> None:
mcp.tool(name="list_moments")(list_moments)
mcp.tool(name="map_action")(map_action)
@@ -176,3 +219,4 @@ def register(mcp) -> None:
mcp.tool(name="propose_rule_moments")(propose_rule_moments)
mcp.tool(name="rule_moment_proposals")(rule_moment_proposals)
mcp.tool(name="judge_rule_moments")(judge_rule_moments)
+ mcp.tool(name="rule_misfired")(rule_misfired)
diff --git a/src/scribe/models/rule_moment_judgment.py b/src/scribe/models/rule_moment_judgment.py
index 2f83721e..8cdbf14d 100644
--- a/src/scribe/models/rule_moment_judgment.py
+++ b/src/scribe/models/rule_moment_judgment.py
@@ -60,6 +60,11 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
note: Mapped[str | None] = mapped_column(Text, nullable=True)
# The co-occurrence evidence (signal source), in lesson_rules' shape.
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ # The other direction (step 7b, migration 0119): sessions saying this
+ # MOUNTED rule arrived at the moment and did not apply. lesson_rules'
+ # evidence shape keyed per day, plus the reasons given and the actions
+ # that reached the moment; `kept_at` once the operator kept the mount.
+ misfire: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
judged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
@@ -76,6 +81,7 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
"source": self.source,
"note": self.note or "",
"evidence": self.evidence or {},
+ "misfire": self.misfire or {},
"judged_at": iso(self.judged_at),
"created_at": iso(self.created_at),
}
diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py
index 1bb8bfc4..4fd60a97 100644
--- a/src/scribe/services/backup.py
+++ b/src/scribe/services/backup.py
@@ -109,8 +109,11 @@ logger = logging.getLogger(__name__)
# proposals waiting on a mount, and the rejections and "no moment fits"
# answers that stop a pass or the open-after-moment signal proposing the same
# pair again. Losing them puts every answered question back on the list.
+# v24 (2026-10) added rule_moment_judgments.misfire (milestone 458 step 7b):
+# the reports that a mounted rule arrived where it did not apply, and the
+# operator's "keep it" that stops them being proposed as an unmount again.
# Bump when the serialized schema changes.
-BACKUP_VERSION = 23
+BACKUP_VERSION = 24
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -772,6 +775,7 @@ def _rule_moment_judgment_rows(rows) -> list[dict]:
{
"rule_id": r.rule_id, "moment": r.moment, "state": r.state,
"source": r.source, "note": r.note, "evidence": r.evidence,
+ "misfire": r.misfire,
"judged_at": r.judged_at.isoformat() if r.judged_at else None,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
@@ -1580,6 +1584,8 @@ def _build_rule_moment_judgment(row: dict, maps: _Maps) -> RuleMomentJudgment |
source=row.get("source") or "pass",
note=row.get("note") or None,
evidence=row.get("evidence"),
+ # Archives before v24 carry no misfire reports.
+ misfire=row.get("misfire"),
# Absent stays absent: a suggestion was never judged.
judged_at=_dt_or_none(row.get("judged_at")),
created_at=_dt(row.get("created_at")),
diff --git a/src/scribe/services/lesson_rules.py b/src/scribe/services/lesson_rules.py
index 8c073f6f..eac1c2d3 100644
--- a/src/scribe/services/lesson_rules.py
+++ b/src/scribe/services/lesson_rules.py
@@ -486,17 +486,19 @@ def situation_key(arm: str, text: str) -> str:
"""A fingerprint of one situation, so a repeat counts once.
`arm` is part of the key — "p" for a prompt, "w" for a file being written,
- "s" for a session (the rule↔moment signal, milestone 458 step 7) —
+ "s" for a session (the rule↔moment signal, milestone 458 step 7), "d"
+ for a day (the misfire signal, step 7b, whose door carries no session) —
because each names a different kind of situation and must not collide.
On the prompt arm the text is the prompt: lowercased, split into word
tokens of _MIN_TOKEN or more, de-duplicated and sorted, so the same ask
re-sent with different spacing, punctuation, case or word order is one
situation. On the write arm the text is the PATH, not the code: every
edit to one file is one situation, however much the code differs between
- them. The session arm keys on the session id as given. Empty when there
- is nothing to key on.
+ them. The session and day arms key on the id or date as given — a date's
+ "10" and "05" are shorter than _MIN_TOKEN, so tokenising it would make
+ every day of a year one situation. Empty when there is nothing to key on.
"""
- if arm in ("w", "s"):
+ if arm in ("w", "s", "d"):
basis = (text or "").strip()
else:
tokens = sorted({t for t in re.findall(r"[a-z0-9]+", (text or "").lower())
diff --git a/src/scribe/services/moment_delivery.py b/src/scribe/services/moment_delivery.py
index 34c7148c..66f1c1ad 100644
--- a/src/scribe/services/moment_delivery.py
+++ b/src/scribe/services/moment_delivery.py
@@ -181,13 +181,22 @@ def reply_hold_reason(held: list[dict]) -> str:
trigger = f"; it applies when {item['trigger']}" if item.get("trigger") else ""
parts.append(f"“{item['title']}” ({why}{trigger}) — get_rule({item['rule_id']})")
noun = "a standing rule" if len(held) == 1 else "standing rules"
+ # A mount that does not bear on this reply is a misfire worth one call:
+ # reports gather into an unmount proposal for the operator (step 7b).
+ mounted = [item for item in held if item.get("moment")]
+ misfire = (
+ " If one mounted on a moment has nothing to do with this reply, "
+ f"rule_misfired({mounted[0]['rule_id']}, \"{mounted[0]['moment']}\", why) "
+ "records that."
+ if mounted else ""
+ )
return (
f"Held for one read before this reply goes out: {noun} this session "
f"has not opened: " + "; ".join(parts) + ". Read "
+ ("it" if len(held) == 1 else "them")
+ ", then send the reply — unchanged if it already does what the rule "
- "asks, which is a judgement only you can make. This check runs once "
- "per rule; the rewrite is never held."
+ "asks, which is a judgement only you can make." + misfire
+ + " This check runs once per rule; the rewrite is never held."
)
diff --git a/src/scribe/services/rule_moment_judgments.py b/src/scribe/services/rule_moment_judgments.py
index 3eced3b3..12243afc 100644
--- a/src/scribe/services/rule_moment_judgments.py
+++ b/src/scribe/services/rule_moment_judgments.py
@@ -19,6 +19,16 @@ Neither source mounts anything by itself. A suggestion that delivered its rule
would manufacture the opens it counts, and the operator's corpus is theirs to
mount.
+THE MISFIRE (step 7b) is the same machinery pointed the other way. A session
+that was handed a mounted rule at a moment where it did not apply says so
+(`misfired`), with why and the action that reached the moment. Reports count
+per distinct DAY — the MCP door is stateless and no session id reaches it, and
+a misfire seen on three days is a pattern where three in one afternoon may be
+one piece of work. At the bar the pair becomes an UNMOUNT proposal; the
+operator takes the rule off (reject), or unmaps the action, or keeps it
+(confirm), which stops the asking. A rule left unopened is never counted:
+most delivered rules go unopened, and the reply hold forces an open.
+
EDITS ARE JUDGMENTS TOO. A person changing a rule's moments directly says
which moments it belongs on, so `record_mount_change` (called from the one
write path, `rulebooks.set_rule_moments`) confirms what was added and rejects
@@ -68,6 +78,17 @@ SIGNAL_WINDOW_SECONDS = 180
# propose every rule onto both.
SIGNAL_SKIP = frozenset({"work.run", "work.change"})
+# The reasons a misfire proposal carries, newest kept. Enough for the operator
+# to see whether the reports agree; few enough to read in the panel.
+MISFIRE_REASONS = 5
+# Distinct actions counted per pair. A moment is reached by a handful of
+# actions; past this the counts stop telling the operator which one to unmap.
+_MISFIRE_ACTIONS_CAP = 10
+_MISFIRE_WHY_CHARS = 400
+# On a mounted pair that has no judgment row — mounted before the rows were
+# kept (migration 0118) — when its first misfire gives it one.
+_UNRECORDED_MOUNT_NOTE = "mounted before judgments were recorded"
+
def _clean_moment(name) -> str:
"""A catalog moment, normalised; "" stays "" (no moment fits)."""
@@ -279,22 +300,73 @@ async def pending(user_id: int, rule_id: int | None = None, limit: int = 200) ->
"proposals": [],
})
entry["proposals"].append({
+ "proposal": "mount",
"moment": judgment.moment,
"source": judgment.source,
"why": judgment.note or "",
"evidence": lesson_rules.evidence_summary(judgment.evidence),
"created_at": judgment.created_at.isoformat() if judgment.created_at else None,
})
- rules = list(grouped.values())
+ for judgment, rule, mounted in await _misfire_proposals(user_id, rule_id, limit):
+ entry = grouped.setdefault(rule.id, {
+ **_brief(rule),
+ "mounted": sorted(mounted, key=lambda n: (order.get(n, len(order)), n)),
+ "proposals": [],
+ })
+ entry["proposals"].append(_unmount_item(judgment))
+ rules = sorted(grouped.values(), key=lambda r: r["id"])
return {"rules": rules, "total": sum(len(r["proposals"]) for r in rules)}
+def _misfire_due_clause():
+ """Proposed by the misfire signal and not yet kept by the operator."""
+ mf = RuleMomentJudgment.misfire
+ return mf["proposed_at"].astext.isnot(None), mf["kept_at"].astext.is_(None)
+
+
+async def _misfire_proposals(user_id: int, rule_id: int | None, limit: int):
+ """The unmount proposals: pairs past the misfire bar, unanswered, and
+ still mounted — a pair taken off since has had its answer."""
+ from scribe.services.rulebooks import _owned_rules_clause
+
+ async with async_session() as session:
+ stmt = (
+ select(RuleMomentJudgment, Rule)
+ .join(Rule, Rule.id == RuleMomentJudgment.rule_id)
+ .where(_owned_rules_clause(user_id), *_misfire_due_clause())
+ )
+ if rule_id:
+ stmt = stmt.where(Rule.id == int(rule_id))
+ rows = (await session.execute(
+ stmt.order_by(Rule.id, RuleMomentJudgment.moment).limit(limit)
+ )).all()
+ mounts = await _mounts(session, {r.id for _, r in rows})
+ return [(j, r, mounts.get(r.id, set())) for j, r in rows
+ if j.moment in mounts.get(r.id, set())]
+
+
+def _unmount_item(judgment: RuleMomentJudgment) -> dict:
+ mf = judgment.misfire or {}
+ reasons = list(mf.get("reasons") or [])
+ return {
+ "proposal": "unmount",
+ "moment": judgment.moment,
+ "source": "misfire",
+ "why": reasons[-1]["why"] if reasons else "",
+ "reasons": reasons,
+ "reached_by": dict(mf.get("reached_by") or {}),
+ "evidence": lesson_rules.evidence_summary(mf),
+ "created_at": mf.get("first_at"),
+ }
+
+
async def judge(user_id: int, judgments: list[dict]) -> dict:
"""Confirm or reject proposals — or any (rule, moment) pair, proposed or not.
- Confirm MOUNTS the rule on the moment (alongside what it already has);
- reject records that it does not belong there and, if it was mounted,
- unmounts it. Both go through `rulebooks.set_rule_moments`, the one write
+ Confirm MOUNTS the rule on the moment (alongside what it already has) —
+ on a pair already mounted, it KEEPS the mount and answers a misfire
+ proposal; reject records that it does not belong there and, if it was
+ mounted, unmounts it. Both go through `rulebooks.set_rule_moments`, the one write
path, which records the judgment with this item's `note`. A moment of ""
judges the "no moment fits" answer itself. A bad item is refused alone.
"""
@@ -382,11 +454,16 @@ async def record_mount_change(
for moment in added:
put(moment, CONFIRMED, _MOUNTED_NOTE)
+ # A fresh mount is a fresh judgment: misfires counted against an
+ # earlier mount of the pair are not evidence against this one.
+ rows[(rule_id, moment)].misfire = None
for moment in removed:
put(moment, REJECTED, _UNMOUNTED_NOTE)
for moment in explicit:
if verdict in (CONFIRMED, REJECTED):
put(moment, verdict, "")
+ if verdict == CONFIRMED and moment in after:
+ _keep(rows[(rule_id, moment)], note, now)
if after:
none_row = rows.get((rule_id, NO_MOMENT))
if none_row is not None and none_row.state == CONFIRMED:
@@ -394,6 +471,15 @@ async def record_mount_change(
none_row.note = _OVERTURNED_NOTE
+def _keep(row: RuleMomentJudgment, note: str, now: datetime) -> None:
+ """The operator kept a mount the misfire signal proposed taking off. The
+ reports stay; the asking stops."""
+ mf = row.misfire
+ if not isinstance(mf, dict) or not mf.get("proposed_at") or mf.get("kept_at"):
+ return
+ row.misfire = {**mf, "kept_at": now.isoformat(), "kept_note": note or ""}
+
+
# ── The signal ───────────────────────────────────────────────────────────────
@@ -525,3 +611,114 @@ async def opened_after(
user_id, rule_id, moments, situation=session_id.strip(), project_id=project_id,
)
return {"moments": moments, "context": context}
+
+
+# ── The misfire: a mounted rule that arrived where it does not apply ─────────
+
+
+def misfire_situation(now: datetime) -> str:
+ """One situation per UTC day. The MCP door carries no session id; a day
+ is the coarser, honest unit, and the slower of the two to cross the bar."""
+ return lesson_rules.situation_key("d", now.date().isoformat())
+
+
+def add_misfire(misfire, key: str, project_id: int | None, now: datetime, *,
+ why: str, reached_by: str) -> dict:
+ """A NEW misfire dict with this report counted. Pure, like
+ `lesson_rules.add_evidence`, whose counting it reuses: the per-day
+ situations and the bar are the same as every other proposal's."""
+ ev = lesson_rules.add_evidence(misfire, key, project_id, now)
+ reasons = list(ev.get("reasons") or [])
+ reasons.append({"why": why[:_MISFIRE_WHY_CHARS], "reached_by": reached_by,
+ "at": now.isoformat()})
+ ev["reasons"] = reasons[-MISFIRE_REASONS:]
+ actions = dict(ev.get("reached_by") or {})
+ if reached_by and (reached_by in actions or len(actions) < _MISFIRE_ACTIONS_CAP):
+ actions[reached_by] = int(actions.get(reached_by) or 0) + 1
+ ev["reached_by"] = actions
+ return ev
+
+
+def _unmount_line(rule: Rule, moment: str, misfire: dict) -> str:
+ days = len(misfire.get("situations") or [])
+ actions = ", ".join(f"`{a}` ×{n}" for a, n in sorted(
+ (misfire.get("reached_by") or {}).items(), key=lambda kv: -kv[1]))
+ kind = rule.kind or "rule"
+ return (
+ f"> {kind.capitalize()} #{rule.id} \"{rule.title}\" has been reported arriving at "
+ f"`{moment}` where it did not apply, on {days} distinct days"
+ + (f" (reached by {actions})" if actions else "")
+ + ". Offer the operator the fix in one line. If the rule does not belong "
+ f"at that moment, `judge_rule_moments([{{\"rule_id\": {rule.id}, \"moment\": "
+ f"\"{moment}\", \"verdict\": \"reject\", \"note\": \"why\"}}])` takes it off. "
+ "If it is the ACTION that is not that moment here, `unmap_action` for "
+ "it instead (`list_moments` shows the mapping). If they keep it, "
+ "`\"confirm\"` with their why stops the asking."
+ )
+
+
+async def misfired(
+ user_id: int, rule_id: int, moment: str, *, why: str,
+ reached_by: str = "", project_id: int | None = None,
+) -> dict:
+ """Record that a mounted rule arrived at `moment` where it did not apply.
+
+ Only a MOUNT can misfire: a rule that arrived by resemblance is a
+ retrieval question, answered by its trigger. `why` is required — a report
+ without its reason cannot tell the operator what to fix. Returns the
+ count so far and, once the pair crosses the bar, `context`: the line
+ asking the reader to offer the unmount. A kept mount still counts its
+ reports and asks nothing.
+ """
+ try:
+ name = moments_svc.require_moment((moment or "").strip())
+ except ValueError as exc:
+ return {"recorded": False, "error": str(exc)}
+ why = (why or "").strip() if isinstance(why, str) else ""
+ if not why:
+ return {"recorded": False, "error": (
+ "say why it did not apply here — the reason is what tells the "
+ "operator whether the rule or the action is wrong")}
+ rid = _int(rule_id)
+ reached_by = (reached_by or "").strip()[:200] if isinstance(reached_by, str) else ""
+ now = datetime.now(timezone.utc)
+ async with async_session() as session:
+ owned = await _owned_rules(session, user_id, [rid] if rid else [])
+ rule = owned.get(rid) if rid else None
+ if rule is None:
+ return {"recorded": False, "error": "not a rule you own"}
+ mounted = (await _mounts(session, [rid])).get(rid, set())
+ if name not in mounted:
+ return {"recorded": False, "error": (
+ f"rule {rid} is not mounted on {name}"
+ + (f" (it is on {', '.join(sorted(mounted))})" if mounted else "")
+ + ". A rule that arrived by its words rather than a mount is "
+ "fixed through its trigger — update_rule(when_to_apply=...).")}
+ row = (await _rows(session, [rid])).get((rid, name))
+ if row is None:
+ row = RuleMomentJudgment(rule_id=rid, moment=name, state=CONFIRMED,
+ source="edit", note=_UNRECORDED_MOUNT_NOTE,
+ judged_at=now)
+ session.add(row)
+ mf = add_misfire(row.misfire, misfire_situation(now), project_id, now,
+ why=why, reached_by=reached_by)
+ kept = bool(mf.get("kept_at"))
+ due = not kept and lesson_rules.proposal_due(mf, now)
+ if due:
+ mf["proposed_at"] = now.isoformat()
+ mf["proposed_count"] = int(mf.get("proposed_count") or 0) + 1
+ row.misfire = mf
+ try:
+ await session.commit()
+ except IntegrityError:
+ await session.rollback()
+ return {"recorded": False, "error": "a report for this pair landed at the same moment; try again"}
+ out = {
+ "recorded": True, "rule_id": rid, "moment": name,
+ "days": len(mf.get("situations") or []),
+ "bar": lesson_rules.PROPOSE_SITUATIONS,
+ "context": _unmount_line(rule, name, mf) if due else "",
+ }
+ if kept:
+ out["kept"] = {"at": mf.get("kept_at"), "note": mf.get("kept_note") or ""}
+ return out
diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py
index fba0bfea..897ddf19 100644
--- a/tests/test_guidance_ownership.py
+++ b/tests/test_guidance_ownership.py
@@ -195,6 +195,9 @@ TOPICS: tuple[Topic, ...] = (
Topic("a new rule about when gets its moments as it is written", U,
("moments=[...]", "create_process(moments="),
"then ask when it applies, as well as what it is about"),
+ Topic("a mount that arrives where it does not apply is reported, and reports become the offer", U,
+ ("rule_misfired",),
+ "a rule you read and set aside leaves no trace anyone else can see"),
Topic("a missed when is mounted or mapped, not reworded", U,
("update_rule(moments=",),
"first ask whether it missed a when or a what"),
diff --git a/tests/test_integration_rule_moment_judgments.py b/tests/test_integration_rule_moment_judgments.py
index eba4f22c..691d3eb4 100644
--- a/tests/test_integration_rule_moment_judgments.py
+++ b/tests/test_integration_rule_moment_judgments.py
@@ -7,7 +7,10 @@ What the step promises, against the real tables:
pair is never proposed again by either source;
- an edit that takes a moment off a rule is a rejection the signal respects;
- the signal counts SESSIONS, not opens, and asks once the bar is crossed;
-- only the owner can propose, judge, or feed the signal.
+- only the owner can propose, judge, or feed the signal;
+- the misfire (step 7b): only a mount can misfire, reports count DAYS, the
+ bar makes an unmount proposal, reject takes it off, confirm keeps it and
+ stops the asking, and a re-mount starts the count again.
"""
import pytest
import pytest_asyncio
@@ -191,3 +194,139 @@ async def test_the_open_resolves_acts_through_the_installs_mappings(world):
assert "work.deliver" in out["moments"]
assert "work.run" not in out["moments"]
assert (await _row(done.id, "work.deliver")).source == "signal"
+
+
+# ── The misfire (step 7b) ────────────────────────────────────────────────────
+
+
+@pytest.fixture
+def days(monkeypatch):
+ """Each report lands on the day named next, so a test can cross days
+ without waiting for them. `misfire_situation` is the one place a day
+ becomes a situation, so this is what a real date change does."""
+ queue: list[str] = []
+
+ def situation(_now):
+ from scribe.services import lesson_rules
+ return lesson_rules.situation_key("d", queue.pop(0))
+
+ monkeypatch.setattr(judgments_svc, "misfire_situation", situation)
+ return queue
+
+
+async def _misfire(uid, rule_id, moment="work.finish", why="closing a docs-only task",
+ reached_by="status=done"):
+ return await judgments_svc.misfired(uid, rule_id, moment, why=why, reached_by=reached_by)
+
+
+async def test_only_a_mount_can_misfire_and_only_with_a_reason(world, days):
+ uid, sid, done = world["uid"], world["sid"], world["done"]
+ out = await _misfire(uid, done.id)
+ assert not out["recorded"] and "not mounted on work.finish" in out["error"]
+ assert "when_to_apply" in out["error"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ out = await judgments_svc.misfired(uid, done.id, "work.finish", why=" ")
+ assert not out["recorded"] and "why" in out["error"]
+ out = await judgments_svc.misfired(uid, done.id, "work.finished", why="x")
+ assert not out["recorded"] and "work.finished" in out["error"]
+ out = await _misfire(sid, done.id)
+ assert out == {"recorded": False, "error": "not a rule you own"}
+ assert (await _row(done.id, "work.finish")).misfire is None
+
+
+async def test_misfires_count_days_and_propose_the_unmount(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ days.extend(["2026-10-01", "2026-10-01", "2026-10-02"])
+ for _ in range(3):
+ out = await _misfire(uid, done.id)
+ assert out["recorded"] and out["context"] == ""
+ assert out["days"] == 2 and out["bar"] == 3
+ assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
+
+ days.append("2026-10-03")
+ out = await _misfire(uid, done.id, why="closing a planning stub",
+ reached_by="status=cancelled")
+ line = out["context"]
+ assert out["days"] == 3
+ assert f"#{done.id}" in line and "`work.finish`" in line and "3 distinct days" in line
+ assert "`status=done` ×3" in line and "`status=cancelled` ×1" in line
+ assert '"verdict": "reject"' in line and "unmap_action" in line and '"confirm"' in line
+ # Asked once; the cooldown keeps the next report quiet.
+ days.append("2026-10-04")
+ assert (await _misfire(uid, done.id))["context"] == ""
+
+ [entry] = (await judgments_svc.pending(uid, rule_id=done.id))["rules"]
+ assert entry["mounted"] == ["work.finish"]
+ [proposal] = entry["proposals"]
+ assert (proposal["proposal"], proposal["source"], proposal["moment"]) == (
+ "unmount", "misfire", "work.finish")
+ assert proposal["evidence"]["situations"] == 4
+ assert proposal["reached_by"] == {"status=done": 4, "status=cancelled": 1}
+ assert len(proposal["reasons"]) == judgments_svc.MISFIRE_REASONS
+ assert proposal["why"] == "closing a docs-only task"
+
+
+async def _past_the_bar(uid, rule_id, days, moment="work.finish"):
+ days.extend(["2026-10-01", "2026-10-02", "2026-10-03"])
+ for _ in range(3):
+ out = await _misfire(uid, rule_id, moment=moment)
+ assert out["context"]
+
+
+async def test_reject_takes_the_mount_off(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish", "reply.report"])
+ await _past_the_bar(uid, done.id, days)
+ out = await judgments_svc.judge(uid, [
+ {"rule_id": done.id, "moment": "work.finish", "verdict": "reject", "note": "not at task close"},
+ ])
+ assert out["refused"] == []
+ assert (await rulebooks_svc.list_rule_moments([done.id]))[done.id] == ["reply.report"]
+ assert (await _row(done.id, "work.finish")).state == "rejected"
+ assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
+ # And it cannot misfire where it no longer is.
+ assert not (await _misfire(uid, done.id))["recorded"]
+
+
+async def test_confirm_keeps_the_mount_and_stops_the_asking(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ await _past_the_bar(uid, done.id, days)
+ await judgments_svc.judge(uid, [
+ {"rule_id": done.id, "moment": "work.finish", "verdict": "confirm", "note": "it does belong"},
+ ])
+ assert (await rulebooks_svc.list_rule_moments([done.id]))[done.id] == ["work.finish"]
+ row = await _row(done.id, "work.finish")
+ assert row.state == "confirmed" and row.misfire["kept_note"] == "it does belong"
+ assert (await judgments_svc.pending(uid, rule_id=done.id))["rules"] == []
+ # Reports still count, and ask nothing — past the cooldown too.
+ days.extend(["2026-10-10", "2026-10-11"])
+ for _ in range(2):
+ out = await _misfire(uid, done.id)
+ assert out["recorded"] and out["context"] == ""
+ assert out["kept"]["note"] == "it does belong"
+
+
+async def test_a_remount_starts_the_count_again(world, days):
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ await _past_the_bar(uid, done.id, days)
+ await rulebooks_svc.set_rule_moments(done.id, uid, [])
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ row = await _row(done.id, "work.finish")
+ assert row.state == "confirmed" and row.misfire is None
+
+
+async def test_a_mount_with_no_judgment_row_gets_one_on_its_first_misfire(world, days):
+ """Mounts made before migration 0118 have no row; a misfire must still count."""
+ uid, done = world["uid"], world["done"]
+ await rulebooks_svc.set_rule_moments(done.id, uid, ["work.finish"])
+ async with async_session() as s:
+ await s.delete(await s.get(RuleMomentJudgment, (await _row(done.id, "work.finish")).id))
+ await s.commit()
+ days.append("2026-10-01")
+ assert (await _misfire(uid, done.id))["recorded"]
+ row = await _row(done.id, "work.finish")
+ assert (row.state, row.source) == ("confirmed", "edit")
+ assert len(row.misfire["situations"]) == 1
diff --git a/tests/test_integration_rule_moments.py b/tests/test_integration_rule_moments.py
index 9f7ce1f3..8b883453 100644
--- a/tests/test_integration_rule_moments.py
+++ b/tests/test_integration_rule_moments.py
@@ -117,6 +117,14 @@ async def test_the_detail_carries_the_mounts_and_omits_an_empty_set(world):
async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
uid, rule = world["uid"], world["rule"]
await rulebooks_svc.set_rule_moments(rule.id, uid, ["work.finish", "reply.report"])
+ # A misfire report on one of them (v24) — the operator's evidence for
+ # taking a mount off must not be lost to a restore.
+ from scribe.services import rule_moment_judgments as judgments_svc
+ reported = await judgments_svc.misfired(
+ uid, rule.id, "reply.report", why="the reply only asked a question",
+ reached_by="the reply that ends this turn",
+ )
+ assert reported["recorded"]
async with async_session() as s:
payload = {
@@ -172,9 +180,14 @@ async def test_a_backup_carries_the_mounts_to_the_restored_rule(world):
]
async with async_session() as s:
judged = (await s.execute(
- select(RuleMomentJudgment.moment).where(RuleMomentJudgment.rule_id == restored_rule.id)
+ select(RuleMomentJudgment).where(RuleMomentJudgment.rule_id == restored_rule.id)
)).scalars().all()
- assert sorted(judged) == ["reply.report", "work.finish"]
+ assert sorted(j.moment for j in judged) == ["reply.report", "work.finish"]
+ misfire = {j.moment: j.misfire for j in judged}
+ assert misfire["work.finish"] is None
+ assert [r["why"] for r in misfire["reply.report"]["reasons"]] == [
+ "the reply only asked a question",
+ ]
async def _purge_projects(username: str) -> None:
diff --git a/tests/test_moments.py b/tests/test_moments.py
index c4eab398..505c26e8 100644
--- a/tests/test_moments.py
+++ b/tests/test_moments.py
@@ -118,12 +118,12 @@ def test_the_tools_are_registered_and_classified():
assert mcp.names == [
"list_moments", "map_action", "unmap_action",
"rules_to_mount", "propose_rule_moments", "rule_moment_proposals",
- "judge_rule_moments",
+ "judge_rule_moments", "rule_misfired",
]
assert {"list_moments", "rules_to_mount", "rule_moment_proposals"} <= _READ_ONLY_TOOLS
# A confirm mounts a rule, so a read key must not reach it.
assert {"map_action", "unmap_action",
- "propose_rule_moments", "judge_rule_moments"} <= _WRITE_TOOLS
+ "propose_rule_moments", "judge_rule_moments", "rule_misfired"} <= _WRITE_TOOLS
def test_both_doors_read_one_catalog():
diff --git a/tests/test_rule_moment_judgments.py b/tests/test_rule_moment_judgments.py
index 611fb051..2084dccf 100644
--- a/tests/test_rule_moment_judgments.py
+++ b/tests/test_rule_moment_judgments.py
@@ -8,6 +8,7 @@ the signal hands a session names the tool that answers it.
from __future__ import annotations
import importlib.util
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
@@ -58,3 +59,46 @@ def test_the_proposal_line_names_the_tool_that_answers_it():
assert "`work.verify`" in line and "3 distinct sessions" in line
assert '"rule_id": 11' in line and '"moment": "work.verify"' in line
assert "judge_rule_moments" in line and '"reject"' in line
+
+
+# ── The misfire (step 7b) ────────────────────────────────────────────────────
+
+
+def test_a_day_is_one_situation_and_days_are_distinct():
+ """The day arm keys verbatim: tokenised, "10" and "05" fall under the
+ minimum token length and every day of a year would be one situation."""
+ a = datetime(2026, 10, 5, 1, tzinfo=timezone.utc)
+ assert svc.misfire_situation(a) == svc.misfire_situation(a + timedelta(hours=20))
+ assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=1))
+ assert svc.misfire_situation(a) != svc.misfire_situation(a + timedelta(days=31))
+
+
+def test_a_misfire_counts_reasons_and_actions():
+ now = datetime(2026, 10, 5, tzinfo=timezone.utc)
+ mf = None
+ for i in range(svc.MISFIRE_REASONS + 2):
+ mf = svc.add_misfire(mf, svc.misfire_situation(now + timedelta(days=i)), 7, now,
+ why=f"reason {i}", reached_by="git push" if i % 2 else "make ship")
+ assert len(mf["situations"]) == svc.MISFIRE_REASONS + 2
+ assert [r["why"] for r in mf["reasons"]][-1] == f"reason {svc.MISFIRE_REASONS + 1}"
+ assert len(mf["reasons"]) == svc.MISFIRE_REASONS
+ assert mf["reached_by"] == {"make ship": 4, "git push": 3}
+ assert mf["projects"] == [7]
+
+
+def test_the_unmount_line_offers_both_fixes():
+ rule = SimpleNamespace(id=10, title="CI verifies", kind="rule")
+ mf = {"situations": ["a", "b", "c"], "reached_by": {"actions_run_read": 3}}
+ line = svc._unmount_line(rule, "work.verify", mf)
+ assert line.startswith("> Rule #10") and "3 distinct days" in line
+ assert "`actions_run_read` ×3" in line
+ assert '"verdict": "reject"' in line and "unmap_action" in line and '"confirm"' in line
+
+
+def test_a_held_reply_names_the_misfire_call_only_for_a_mount():
+ from scribe.services.moment_delivery import reply_hold_reason
+
+ mounted = {"rule_id": 11, "title": "Definition of done", "moment": "reply.report", "trigger": ""}
+ scored = {"rule_id": 12, "title": "Other", "moment": "", "score": 0.81, "trigger": ""}
+ assert 'rule_misfired(11, "reply.report", why)' in reply_hold_reason([mounted])
+ assert "rule_misfired" not in reply_hold_reason([scored])
diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py
index 66d12c2b..5c8e159b 100644
--- a/tests/test_services_backup.py
+++ b/tests/test_services_backup.py
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
- assert backup.BACKUP_VERSION == 23
+ assert backup.BACKUP_VERSION == 24
def _exportable_note(**over):