Skip to content

Commit 63f1de3

Browse files
committed
Let a rule be switched off, and make that mean something
Work item V7 of the M3 plan. The rules table has carried an enabled flag since the schema was written, set to true on every import, and nothing read it. This makes it load-bearing and gives it a screen. A switched-off rule is left out of the snapshot a new review is frozen with, so its text never reaches the model and its mechanical sweeps never run. The end of that chain is tested where it actually matters: the recorded argv of the adversarial call does not contain the disabled rule's text, and does contain the next one's. Toggling moves the ruleset's version, because a review's snapshot names the version. Without the bump two different sets of rules would share a name and a number, and a report saying which version it used would not identify what the review was actually judged against. Toggling a rule to the value it already has moves nothing. The exported document keeps every rule, disabled ones included, and comes back byte-identical to what was imported. That is the difference between the document and the choice this app made about applying it: switching a rule off is not an edit to what the author wrote. Freezing a ruleset with nothing enabled is refused, for the same reason importing an empty one is: a review judged against no rules comes back clean and reads exactly like a review that found nothing wrong. Driven live: importing gave twelve rules at version 1, switching rule 3 off returned version 2 and showed it off, and the export was byte-identical to the source document with the disabled rule still in it. Three mutations checked and all caught: keeping disabled rules in the snapshot, not moving the version, and allowing an empty ruleset to be frozen.
1 parent 2bc903a commit 63f1de3

11 files changed

Lines changed: 526 additions & 8 deletions

File tree

docs/DECISIONS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,3 +761,20 @@ verified evidence, in writing, here.
761761
check whose whole job is to answer a question calmly. Found by pointing
762762
TRYSQUARE_CLAUDE_PATH at the fake and watching the route return a JSON
763763
syntax error instead of "not signed in".
764+
- 2026-07-31 DECIDED (V7, D-40): switching a rule off bumps the ruleset
765+
version. A review's frozen snapshot names the version, so without the bump
766+
two different sets of rules would share a name and a number, and a report
767+
saying which version it used would not identify what the review was actually
768+
judged against. Toggling to the value a rule already has moves nothing.
769+
- 2026-07-31 DECIDED (V7): the frozen snapshot stores enabled rules only, and
770+
the exported document always contains every rule. That is the difference
771+
between the document and the choice this app made about applying it: the
772+
export has to reproduce what was imported, byte for byte, and a rule someone
773+
switched off is not part of what a new review is judged against.
774+
- 2026-07-31 DECIDED (V7): freezing a ruleset with nothing enabled is refused.
775+
A review judged against no rules comes back clean and reads exactly like a
776+
review that found nothing wrong, which is the same hazard the empty-import
777+
guard exists for.
778+
- 2026-07-31 DECIDED (V7, D-44): the new-review screen links to the ruleset
779+
page rather than opening a drawer. At one screen of this size a drawer
780+
duplicates a page that already exists.

docs/plans/M3-FINISH-PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ confirms the wiring.
349349
| V4 | preflight route and panel, linked toggle with suggestion | V3 | DONE |
350350
| V5 | report renderer, report/export routes, report UI | V2 | DONE |
351351
| V6 | resume/queued/merged/delete UI, settings editor, probe buttons | V2 | DONE |
352-
| V7 | rulesets detail, enable toggle, snapshot filter, export | V1 | |
352+
| V7 | rulesets detail, enable toggle, snapshot filter, export | V1 | DONE |
353353
| V8 | e2e journey, theme screenshots, design audit, CI --e2e | V2-V7 | |
354354
| V9 | PROJECT-STATE, indexes, FG-2 checklist and evidence, G1 row | V8 | |
355355

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* The protocol document this ruleset came from.
3+
*
4+
* Byte-exact, because every rule's verbatim markdown is stored alongside its
5+
* parsed fields. Disabled rules are included: the document is the document,
6+
* and switching a rule off is a choice this app made about applying it, not an
7+
* edit to what the author wrote.
8+
*/
9+
10+
import { exportProtocol } from "@/lib/rulesets/import";
11+
import { loadRuleset, requireRuleset } from "@/server/db/repositories/rulesets";
12+
import { handler } from "@/server/api/respond";
13+
import { runtime } from "@/server/runtime";
14+
15+
export const dynamic = "force-dynamic";
16+
17+
export async function GET(
18+
_request: Request,
19+
context: { params: Promise<{ id: string }> },
20+
): Promise<Response> {
21+
return handler(async () => {
22+
const { db } = runtime();
23+
const { id } = await context.params;
24+
const row = requireRuleset(db, id);
25+
26+
return new Response(exportProtocol(loadRuleset(db, id)), {
27+
headers: {
28+
"Content-Type": "text/markdown; charset=utf-8",
29+
"Content-Disposition": `attachment; filename="${row.name.replace(/[^\w.-]+/g, "-")}.md"`,
30+
},
31+
});
32+
});
33+
}

src/app/api/rulesets/[id]/route.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/** One ruleset: what it checks, and which of those checks are switched on. */
2+
3+
import { loadRuleset, requireRuleset } from "@/server/db/repositories/rulesets";
4+
import { handler, ok } from "@/server/api/respond";
5+
import { runtime } from "@/server/runtime";
6+
7+
export const dynamic = "force-dynamic";
8+
9+
export async function GET(
10+
_request: Request,
11+
context: { params: Promise<{ id: string }> },
12+
): Promise<Response> {
13+
return handler(async () => {
14+
const { db } = runtime();
15+
const { id } = await context.params;
16+
const row = requireRuleset(db, id);
17+
// Everything, disabled included: this screen is where a rule is switched
18+
// back on, so it cannot show only the ones that are already on.
19+
const ruleset = loadRuleset(db, id);
20+
const enabledOnly = loadRuleset(db, id, { enabledOnly: true });
21+
const enabled = new Set(enabledOnly.rules.map((rule) => rule.code));
22+
23+
return ok({
24+
ruleset: { id: row.id, name: row.name, tier: row.tier, version: row.version },
25+
directives: ruleset.directives.map((directive) => ({
26+
section: directive.section,
27+
title: directive.title,
28+
})),
29+
rules: ruleset.rules.map((rule) => ({
30+
code: rule.code,
31+
title: rule.title,
32+
severity: rule.severity,
33+
tags: rule.tags,
34+
sweepPatterns: rule.sweepPatterns.length,
35+
enabled: enabled.has(rule.code),
36+
})),
37+
});
38+
});
39+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Switching one rule on or off.
3+
*
4+
* Moves the ruleset's version, because a review's frozen snapshot names it.
5+
* Without the bump two different sets of rules would share a name and a
6+
* number, and a report saying which version it used would not identify what
7+
* the review was actually judged against.
8+
*/
9+
10+
import { z } from "zod";
11+
import { setRuleEnabled } from "@/server/db/repositories/rulesets";
12+
import { failed, handler, ok, readJson } from "@/server/api/respond";
13+
import { runtime } from "@/server/runtime";
14+
15+
export const dynamic = "force-dynamic";
16+
17+
const body = z.object({ enabled: z.boolean() });
18+
19+
export async function PATCH(
20+
request: Request,
21+
context: { params: Promise<{ id: string; code: string }> },
22+
): Promise<Response> {
23+
return handler(async () => {
24+
const { db } = runtime();
25+
const { id, code } = await context.params;
26+
const { enabled } = await readJson(request, body);
27+
28+
try {
29+
return ok(setRuleEnabled(db, id, decodeURIComponent(code), enabled));
30+
} catch (error) {
31+
return failed(error, 400);
32+
}
33+
});
34+
}

src/app/reviews/new/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,10 @@ function NewReview() {
335335
</Select>
336336
</Field>
337337

338-
<Field label="Rules" hint="What the change is judged against, frozen at the start.">
338+
<Field
339+
label="Rules"
340+
hint="What the change is judged against, frozen when the review starts."
341+
>
339342
<Select value={rulesetId} onChange={(event) => setRulesetId(event.target.value)}>
340343
{rulesets.length === 0 ? <option value="">No rulesets imported yet</option> : null}
341344
{rulesets.map((ruleset) => (
@@ -344,6 +347,14 @@ function NewReview() {
344347
</option>
345348
))}
346349
</Select>
350+
{rulesetId ? (
351+
<Link
352+
href={`/rulesets/${rulesetId}`}
353+
className="text-xs text-[var(--color-accent)] hover:underline"
354+
>
355+
See which rules apply
356+
</Link>
357+
) : null}
347358
</Field>
348359

349360
<Field label="Model">

src/app/rulesets/[id]/page.tsx

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"use client";
2+
3+
/**
4+
* One ruleset: every rule it contains, and which of them apply.
5+
*
6+
* Switching a rule off moves the ruleset's version, which the page says out
7+
* loud, because a review's report names the version it was judged against and
8+
* two different rule sets must never share one.
9+
*/
10+
11+
import { use, useEffect, useState } from "react";
12+
import { PageBody, PageHeader } from "@/components/page";
13+
import { Badge, Button, Card, Mono, Problem, severityTone } from "@/components/ui";
14+
15+
interface Detail {
16+
ruleset: { id: string; name: string; tier: string; version: number };
17+
directives: { section: string; title: string }[];
18+
rules: {
19+
code: string;
20+
title: string;
21+
severity: string;
22+
tags: string[];
23+
sweepPatterns: number;
24+
enabled: boolean;
25+
}[];
26+
}
27+
28+
export default function RulesetPage({ params }: { params: Promise<{ id: string }> }) {
29+
const { id } = use(params);
30+
const [detail, setDetail] = useState<Detail | null>(null);
31+
const [error, setError] = useState("");
32+
const [busy, setBusy] = useState("");
33+
const [showDirectives, setShowDirectives] = useState(false);
34+
35+
useEffect(() => {
36+
let cancelled = false;
37+
void (async () => {
38+
const response = await fetch(`/api/rulesets/${id}`);
39+
if (response.ok && !cancelled) setDetail((await response.json()) as Detail);
40+
})();
41+
return () => {
42+
cancelled = true;
43+
};
44+
}, [id]);
45+
46+
async function toggle(code: string, enabled: boolean) {
47+
setError("");
48+
setBusy(code);
49+
try {
50+
const response = await fetch(`/api/rulesets/${id}/rules/${encodeURIComponent(code)}`, {
51+
method: "PATCH",
52+
body: JSON.stringify({ enabled }),
53+
});
54+
if (!response.ok) {
55+
setError(((await response.json()) as { error?: string }).error ?? "That did not work.");
56+
return;
57+
}
58+
const refreshed = await fetch(`/api/rulesets/${id}`);
59+
if (refreshed.ok) setDetail((await refreshed.json()) as Detail);
60+
} finally {
61+
setBusy("");
62+
}
63+
}
64+
65+
if (!detail) return <PageBody>Loading...</PageBody>;
66+
67+
const off = detail.rules.filter((rule) => !rule.enabled).length;
68+
69+
return (
70+
<>
71+
<PageHeader
72+
title={detail.ruleset.name}
73+
subtitle={
74+
<span className="flex flex-wrap items-center gap-3">
75+
<Badge>{detail.ruleset.tier}</Badge>
76+
<span>version {detail.ruleset.version}</span>
77+
<span>
78+
{detail.rules.length} rule(s)
79+
{off > 0 ? `, ${off} switched off` : ""}
80+
</span>
81+
</span>
82+
}
83+
actions={
84+
<a href={`/api/rulesets/${id}/export`} download>
85+
<Button>Export document</Button>
86+
</a>
87+
}
88+
/>
89+
<PageBody>
90+
{error ? (
91+
<div className="mb-4">
92+
<Problem>{error}</Problem>
93+
</div>
94+
) : null}
95+
96+
<p className="mb-4 max-w-2xl text-sm text-[var(--color-ink-muted)]">
97+
A switched-off rule is left out of the rules a new review is judged against, and its
98+
mechanical sweeps do not run. Reviews already started keep the rules they were frozen
99+
with. The exported document always contains every rule, because it is the document that
100+
was imported.
101+
</p>
102+
103+
<Card className="overflow-x-auto">
104+
<table className="w-full text-sm">
105+
<thead>
106+
<tr className="border-b border-[var(--color-border)] text-left text-xs text-[var(--color-ink-muted)]">
107+
<th className="px-3 py-2 font-medium">Rule</th>
108+
<th className="px-3 py-2 font-medium">Severity</th>
109+
<th className="px-3 py-2 font-medium">Tags</th>
110+
<th className="px-3 py-2 font-medium">Sweeps</th>
111+
<th className="px-3 py-2 text-right font-medium">Applies</th>
112+
</tr>
113+
</thead>
114+
<tbody>
115+
{detail.rules.map((rule) => (
116+
<tr
117+
key={rule.code}
118+
className={`border-b border-[var(--color-border)] last:border-0 ${
119+
rule.enabled ? "" : "opacity-55"
120+
}`}
121+
>
122+
<td className="px-3 py-2">
123+
<Mono className="text-xs text-[var(--color-ink-muted)]">{rule.code}</Mono>
124+
<span className="ml-2">{rule.title}</span>
125+
</td>
126+
<td className="px-3 py-2">
127+
<Badge tone={severityTone(rule.severity)}>{rule.severity}</Badge>
128+
</td>
129+
<td className="px-3 py-2 text-xs text-[var(--color-ink-muted)]">
130+
{rule.tags.join(", ") || "any"}
131+
</td>
132+
<td className="px-3 py-2 text-[var(--color-ink-muted)]">{rule.sweepPatterns}</td>
133+
<td className="px-3 py-2 text-right">
134+
<label className="inline-flex items-center gap-2">
135+
<span className="text-xs text-[var(--color-ink-muted)]">
136+
{rule.enabled ? "on" : "off"}
137+
</span>
138+
<input
139+
type="checkbox"
140+
checked={rule.enabled}
141+
disabled={busy !== ""}
142+
onChange={(event) => void toggle(rule.code, event.target.checked)}
143+
/>
144+
</label>
145+
</td>
146+
</tr>
147+
))}
148+
</tbody>
149+
</table>
150+
</Card>
151+
152+
<button
153+
type="button"
154+
className="mt-6 text-sm text-[var(--color-accent)] hover:underline"
155+
onClick={() => setShowDirectives((shown) => !shown)}
156+
>
157+
{showDirectives ? "Hide" : "Show"} the {detail.directives.length} process directive(s)
158+
</button>
159+
{showDirectives ? (
160+
<ul className="mt-2 grid gap-1 text-sm text-[var(--color-ink-muted)]">
161+
{detail.directives.map((directive) => (
162+
<li key={`${directive.section}-${directive.title}`}>
163+
{directive.section}: {directive.title}
164+
</li>
165+
))}
166+
</ul>
167+
) : null}
168+
</PageBody>
169+
</>
170+
);
171+
}

src/app/rulesets/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* than saving it out first.
99
*/
1010

11+
import Link from "next/link";
1112
import { useCallback, useEffect, useState } from "react";
1213
import { PageBody, PageHeader } from "@/components/page";
1314
import {
@@ -140,8 +141,13 @@ export default function RulesetsPage() {
140141
<ul className="grid gap-2">
141142
{rulesets.map((ruleset) => (
142143
<li key={ruleset.id}>
143-
<Card className="flex items-center justify-between gap-3 p-3">
144-
<span className="font-medium">{ruleset.name}</span>
144+
<Card className="flex items-center justify-between gap-3 p-3 hover:border-[var(--color-border-strong)]">
145+
<Link
146+
href={`/rulesets/${ruleset.id}`}
147+
className="font-medium hover:underline"
148+
>
149+
{ruleset.name}
150+
</Link>
145151
<span className="flex items-center gap-2">
146152
<Badge>{ruleset.tier}</Badge>
147153
<span className="text-xs text-[var(--color-ink-muted)]">

0 commit comments

Comments
 (0)