diff --git a/alembic/versions/0092_note_verification.py b/alembic/versions/0092_note_verification.py new file mode 100644 index 0000000..52f73d1 --- /dev/null +++ b/alembic/versions/0092_note_verification.py @@ -0,0 +1,80 @@ +"""a note can carry its own check — verify_with, expires_when, verified_at +(milestone 317 step 1) + +Revision ID: 0092 +Revises: 0091 +Create Date: 2026-08-28 + +The sibling of 0090, which gave rules the same three columns. Same +distinction, one table over: + +A NORM is a decision — no truth value, and it changes only when its author +changes it, which they know they did. A CONSTRAINT is a fact about someone +else's software, and nobody is present when it goes false. + +Notes hold far more constraints than rules do, and hold them for longer. A +cross-project reference note asserting what a signing service does on a +duplicate upload, or how a forge numbers its CI runs, is believed by every +project that reads it, and there is nothing in the record that says when +anyone last looked. `note_supersessions` only fires once a human has read +the note, disagreed, and written the correction — which is the case where +the note was already believed. + +Three nullable columns: + +- `verify_with` — how to tell whether this is still true. A command, a path, + a URL, a query. Prose is allowed; something runnable is better. +- `expires_when` — the STATE under which it stops being true. Deliberately + not a date: constraints do not expire on a schedule, they expire when the + world underneath them moves. +- `verified_at` — when the check last passed. NULL means never checked, and + sorts FIRST in the sweep: unexamined outranks examined-long-ago. + +WHICH ROWS THESE ARE FOR. `notes` is one table holding notes, tasks, +snippets and processes, so these columns land on all of them. Only non-task, +non-snippet records are OFFERED them (milestone 317 decisions 1 and 2, gated +at the service in step 2): a task's decay is its status, and a snippet +already carries a richer, location-aware verdict in `data.verification`. The +columns exist on the other rows and stay null there; a gate that lives in +the schema would have meant a partial index or a CHECK across three columns +to express something the write path can say in two lines. + +All three optional, because most notes should set none of them — the whole +value of the sweep is that its output is short. A null `verify_with` is not +an omission; it is the honest marker of "this one is a decision, and there +is nothing to go and check." + +No CHECK constraint is involved, so rule 36 does not apply. Nothing is +backfilled: a migration cannot invent a check. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0092" +down_revision = "0091" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("notes", sa.Column("verify_with", sa.Text(), nullable=True)) + op.add_column("notes", sa.Column("expires_when", sa.Text(), nullable=True)) + op.add_column( + "notes", + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), + ) + # No index, for 0090's reason — the sweep runs when a human asks, never on + # a request path — but the margin is thinner here and worth naming. `rules` + # is hundreds of rows; `notes` is thousands and grows with every session. + # + # Still a sequential scan's job at this size, and an index on + # (verified_at) filtered to `verify_with IS NOT NULL` would be maintained + # on every note write to serve one operator-initiated query. If step 3's + # live acceptance measures otherwise, add it there against a real plan + # rather than guessing here. + + +def downgrade() -> None: + op.drop_column("notes", "verified_at") + op.drop_column("notes", "expires_when") + op.drop_column("notes", "verify_with") diff --git a/alembic/versions/0093_rule_versions.py b/alembic/versions/0093_rule_versions.py new file mode 100644 index 0000000..1040196 --- /dev/null +++ b/alembic/versions/0093_rule_versions.py @@ -0,0 +1,83 @@ +"""rules gain an edit history — rule_versions (milestone 323 step 1) + +Revision ID: 0093 +Revises: 0092 +Create Date: 2026-08-29 + +The sibling `note_versions` has had for a long time. A note's every meaningful +edit is snapshotted, and the design-system note calls that history "the +changelog". A RULE — which binds behaviour on every session that loads it — +had nothing: an edit destroyed what it used to say, with no record anywhere. + +Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement +had to be hand-copied into a task log to survive the edit (#3237), which is +not a process, it is a person remembering. The more consequential record had +the weaker protection. + +Three things are deliberately NOT copied from note_versions, and each is a +guard that exists there for a reason that does not hold here: + +- **No pruning, and no MAX_VERSIONS.** That cap defends against note autosave + filling every slot. Rules have no autosave; every edit is a deliberate + update_rule. A rule is edited a handful of times in its life, and capping + invites losing the one edit somebody needed. +- **No pin columns.** `pin_kind`/`pin_label` exist so a note's version can + survive that pruning. With nothing pruning, a pin protects a row that was + never at risk. +- **No minimum interval.** 300 seconds between snapshots is also an autosave + defence; here it would only ever discard a second deliberate edit. + +`user_id` is the ACTOR rather than the owner, and is SET NULL rather than +CASCADE: deleting a user must not erase the history of the rules they edited. +The edit still happened and the rule still binds because of it. + +No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a +migration cannot invent the text a rule used to have, and inventing "the +current text, as of now" would be worse than an empty history, because it +would look like a record of an edit that never occurred. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0093" +down_revision = "0092" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "rule_versions", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column( + "rule_id", + sa.BigInteger(), + sa.ForeignKey("rules.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", + sa.BigInteger(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("title", sa.Text(), nullable=False, server_default=""), + sa.Column("statement", sa.Text(), nullable=False, server_default=""), + sa.Column("why", sa.Text(), nullable=True), + sa.Column("how_to_apply", sa.Text(), nullable=True), + sa.Column("when_to_apply", sa.Text(), nullable=True), + sa.Column("tier", sa.Text(), nullable=True), + sa.Column("verify_with", sa.Text(), nullable=True), + sa.Column("expires_when", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + # The only query this table serves is "the history of THIS rule, newest + # first" — unlike 0092's columns, which are read by an operator-initiated + # sweep over the whole set. Every read here is keyed on rule_id, so the + # index earns its write cost immediately rather than on a hunch. + op.create_index("ix_rule_versions_rule_id", "rule_versions", ["rule_id"]) + + +def downgrade() -> None: + op.drop_index("ix_rule_versions_rule_id", table_name="rule_versions") + op.drop_table("rule_versions") diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index f884999..9665f42 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -228,6 +228,49 @@ export async function unrelateRules(relationId: number): Promise { return apiDelete(`/api/rule-relations/${relationId}`); } +/** + * One entry in a rule's edit history. + * + * Each entry holds the text the edit REPLACED, not the text it introduced — + * so the newest entry is what the rule said before its most recent change, + * and what that change produced is the rule as it stands now. Read the other + * way round, every diff comes out backwards. + * + * The listing form omits the long fields; open one to get them. + */ +export interface RuleVersion { + id: number; + rule_id: number; + /** Who made the edit. Null when that account has since been deleted. */ + user_id: number | null; + title: string; + created_at: string; + statement?: string; + why?: string; + how_to_apply?: string; + when_to_apply?: string; + tier?: string; + verify_with?: string; + expires_when?: string; +} + +export async function listRuleVersions(ruleId: number): Promise { + const data = await apiGet<{ versions: RuleVersion[] }>( + `/api/rules/${ruleId}/versions`, + ); + return data.versions; +} + +export async function getRuleVersion( + ruleId: number, versionId: number, +): Promise { + return apiGet(`/api/rules/${ruleId}/versions/${versionId}`); +} + +// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording +// back goes through updateRule, which snapshots what it replaces — so the +// undo stays visible in the history like any other edit. + export async function deleteRule(id: number): Promise { return apiDelete(`/api/rules/${id}`); } diff --git a/frontend/src/assets/editor-shared.css b/frontend/src/assets/editor-shared.css index 9637035..7cd8ff3 100644 --- a/frontend/src/assets/editor-shared.css +++ b/frontend/src/assets/editor-shared.css @@ -127,7 +127,7 @@ border: 1px solid var(--fs-error); border-radius: var(--fs-radius-sm); font-size: 0.85rem; - color: var(--fs-error); + color: var(--fs-error-fg); } .diff-view { border: 1px solid var(--fs-border-color); @@ -147,11 +147,11 @@ } .diff-delete { background: color-mix(in srgb, var(--fs-error) 12%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } .diff-insert { background: color-mix(in srgb, var(--fs-success) 12%, transparent); - color: var(--fs-success); + color: var(--fs-success-fg); } .diff-equal { color: var(--fs-text-tertiary); diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index c4d7d9d..f174443 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -31,6 +31,7 @@ --fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */ --fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */ --fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */ + --fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary)); /* Accent TEXT on an accent tint */ --fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep)); --fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent); --fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent); @@ -102,8 +103,11 @@ /* semantic */ --fs-success: var(--fs-action-primary); + --fs-success-fg: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary)); /* Success TEXT on a success tint */ --fs-warning: #8B6F1E; + --fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-text-primary)); /* Warning TEXT on a warning tint */ --fs-error: #C04A1F; + --fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-text-primary)); /* Error TEXT on an error tint */ --fs-info: #3D5A6E; --fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */ @@ -148,7 +152,9 @@ /* text */ --fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */ --fs-text-secondary: #C2BFB4; + --fs-text-secondary-fg: color-mix(in srgb, var(--fs-text-secondary) 90%, var(--fs-text-primary)); /* Secondary TEXT on a secondary tint (barely moves; no exceptions) */ --fs-text-tertiary: #9C9A92; + --fs-text-tertiary-fg: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary)); /* Tertiary TEXT on a tertiary tint */ --fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */ /* type */ diff --git a/frontend/src/assets/viewer-shared.css b/frontend/src/assets/viewer-shared.css index 644fe1d..864c62d 100644 --- a/frontend/src/assets/viewer-shared.css +++ b/frontend/src/assets/viewer-shared.css @@ -25,7 +25,7 @@ border-color: var(--fs-accent); } .ctx-crumb-project { - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); text-decoration: none; diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index f12f472..0363f46 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -206,7 +206,7 @@ router.afterEach(() => { background: var(--fs-accent-soft); } .nav-link.router-link-active { - color: var(--fs-accent); + color: var(--fs-accent-fg); font-weight: 500; background: color-mix(in srgb, var(--fs-accent) 25%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent); @@ -257,7 +257,7 @@ router.afterEach(() => { font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm); diff --git a/frontend/src/components/DiffView.vue b/frontend/src/components/DiffView.vue index b2d4dde..7fb8754 100644 --- a/frontend/src/components/DiffView.vue +++ b/frontend/src/components/DiffView.vue @@ -137,12 +137,12 @@ function markerFor(type: DiffLine['type']): string { .diff-delete { background: color-mix(in srgb, var(--fs-error) 12%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } .diff-insert { background: color-mix(in srgb, var(--fs-success) 12%, transparent); - color: var(--fs-success); + color: var(--fs-success-fg); } .diff-equal { diff --git a/frontend/src/components/HistoryPanel.vue b/frontend/src/components/HistoryPanel.vue index 0688a9c..56491bc 100644 --- a/frontend/src/components/HistoryPanel.vue +++ b/frontend/src/components/HistoryPanel.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted } from "vue"; import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client"; import DiffView from "@/components/DiffView.vue"; -import type { DiffLine } from "@/composables/useAssist"; +import { computeDiff, type DiffLine } from "@/utils/diff"; import { fmtStamp } from "@/utils/dateFormat"; interface NoteVersion { @@ -33,28 +33,8 @@ const loadingDetail = ref(false); const diff = computed(() => { if (!selectedVersion.value?.body) return []; - const a = props.currentBody; - const b = selectedVersion.value.body; - const aLines = a.split('\n'); - const bLines = b.split('\n'); - const m = aLines.length, n = bLines.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = m - 1; i >= 0; i--) - for (let j = n - 1; j >= 0; j--) - dp[i][j] = aLines[i] === bLines[j] - ? dp[i+1][j+1] + 1 - : Math.max(dp[i+1][j], dp[i][j+1]); - const result: DiffLine[] = []; - let i = 0, j = 0; - while (i < m && j < n) { - if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; } - else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] }); - else result.push({ type: 'insert', text: bLines[j++] }); - } - while (i < m) result.push({ type: 'delete', text: aLines[i++] }); - while (j < n) result.push({ type: 'insert', text: bLines[j++] }); - return result; + return computeDiff(props.currentBody, selectedVersion.value.body); }); async function loadVersions() { diff --git a/frontend/src/components/InlineAssistPanel.vue b/frontend/src/components/InlineAssistPanel.vue index 313008b..60e89a5 100644 --- a/frontend/src/components/InlineAssistPanel.vue +++ b/frontend/src/components/InlineAssistPanel.vue @@ -227,11 +227,11 @@ const markers: Record = { .iap-diff-equal { color: var(--fs-text-tertiary); } .iap-diff-delete { background: color-mix(in srgb, var(--fs-error) 10%, transparent); - color: var(--fs-error); + color: var(--fs-error-fg); } .iap-diff-insert { background: color-mix(in srgb, var(--fs-success) 10%, transparent); - color: var(--fs-success); + color: var(--fs-success-fg); } .iap-diff-marker { diff --git a/frontend/src/components/MarkdownToolbar.vue b/frontend/src/components/MarkdownToolbar.vue index 7ba034a..8741855 100644 --- a/frontend/src/components/MarkdownToolbar.vue +++ b/frontend/src/components/MarkdownToolbar.vue @@ -156,7 +156,7 @@ const groups = [ .md-btn.active { background: color-mix(in srgb, var(--fs-accent) 14%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent); } diff --git a/frontend/src/components/NoteSweepPane.vue b/frontend/src/components/NoteSweepPane.vue new file mode 100644 index 0000000..cd18eb9 --- /dev/null +++ b/frontend/src/components/NoteSweepPane.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index 6bfbbf3..d3411f7 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -679,7 +679,7 @@ async function confirmDelete() { font-weight: 500; background: color-mix(in srgb, var(--fs-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); - color: var(--fs-accent); + color: var(--fs-accent-fg); border-radius: 999px; padding: 0.05rem 0.45rem; flex-shrink: 0; @@ -689,7 +689,7 @@ async function confirmDelete() { font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--fs-text-tertiary); + color: var(--fs-text-tertiary-fg); background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent); border-radius: 999px; padding: 0.05rem 0.45rem; diff --git a/frontend/src/components/TagInput.vue b/frontend/src/components/TagInput.vue index 986225b..00a4403 100644 --- a/frontend/src/components/TagInput.vue +++ b/frontend/src/components/TagInput.vue @@ -168,7 +168,7 @@ function focusInput() { border-radius: 999px; background: color-mix(in srgb, var(--fs-accent) 15%, transparent); border: 1px solid var(--fs-accent); - color: var(--fs-accent); + color: var(--fs-accent-fg); font-size: 0.8rem; white-space: nowrap; } diff --git a/frontend/src/components/VersionHistorySection.vue b/frontend/src/components/VersionHistorySection.vue index 6c7cc16..fb344c6 100644 --- a/frontend/src/components/VersionHistorySection.vue +++ b/frontend/src/components/VersionHistorySection.vue @@ -2,7 +2,7 @@ import { ref, computed } from "vue"; import { apiGet } from "@/api/client"; import DiffView from "@/components/DiffView.vue"; -import type { DiffLine } from "@/composables/useAssist"; +import { computeDiff, type DiffLine } from "@/utils/diff"; interface NoteVersion { id: number; @@ -31,25 +31,7 @@ const loadingDetail = ref(false); const diff = computed(() => { if (!selectedVersion.value?.body) return []; - const aLines = props.currentBody.split("\n"); - const bLines = selectedVersion.value.body.split("\n"); - const m = aLines.length, n = bLines.length; - const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); - for (let i = m - 1; i >= 0; i--) - for (let j = n - 1; j >= 0; j--) - dp[i][j] = aLines[i] === bLines[j] - ? dp[i + 1][j + 1] + 1 - : Math.max(dp[i + 1][j], dp[i][j + 1]); - const result: DiffLine[] = []; - let i = 0, j = 0; - while (i < m && j < n) { - if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; } - else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] }); - else result.push({ type: "insert", text: bLines[j++] }); - } - while (i < m) result.push({ type: "delete", text: aLines[i++] }); - while (j < n) result.push({ type: "insert", text: bLines[j++] }); - return result; + return computeDiff(props.currentBody, selectedVersion.value.body); }); function formatDate(iso: string): string { diff --git a/frontend/src/components/WorkspaceNoteEditor.vue b/frontend/src/components/WorkspaceNoteEditor.vue index d54fb55..a3462a4 100644 --- a/frontend/src/components/WorkspaceNoteEditor.vue +++ b/frontend/src/components/WorkspaceNoteEditor.vue @@ -548,7 +548,7 @@ defineExpose({ reload: loadProjectNotes }); .note-tag-pill { font-size: 0.58rem; - color: var(--fs-accent); + color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); border-radius: 999px; padding: 0 0.3rem; @@ -645,7 +645,7 @@ defineExpose({ reload: loadProjectNotes }); .btn-tag-suggestion.applied { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); border-color: var(--fs-accent); - color: var(--fs-accent); + color: var(--fs-accent-fg); } .link-suggest-strip { diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue index f8b101e..d98bdae 100644 --- a/frontend/src/components/WorkspaceTaskPanel.vue +++ b/frontend/src/components/WorkspaceTaskPanel.vue @@ -424,8 +424,8 @@ defineExpose({ reload: loadAll }); border-radius: 10px; text-transform: capitalize; } -.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); } -.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); } +.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); } +.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success-fg); } .task-items { list-style: none; @@ -516,8 +516,8 @@ defineExpose({ reload: loadAll }); user-select: none; margin-left: auto; } -.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); } -.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); } +.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); } +.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 10%, transparent); } .btn-edit-task { margin-left: 0.25rem; } .btn-edit-task:hover { text-decoration: underline; } diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue index 26523bf..22f54c4 100644 --- a/frontend/src/components/rules/RuleEditorSlideOver.vue +++ b/frontend/src/components/rules/RuleEditorSlideOver.vue @@ -3,6 +3,7 @@ import { computed, ref, watch, onMounted } from "vue"; import { useRulebooksStore } from "@/stores/rulebooks"; import { useCanonicalSystemsStore } from "@/stores/canonicalSystems"; import type { RuleTier } from "@/api/rulebooks"; +import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue"; const props = defineProps<{ ruleId: number | null; topicId: number | null }>(); const emit = defineEmits<{ close: [] }>(); @@ -256,6 +257,17 @@ watch(() => props.ruleId, load); How to apply + +
+ + +
+ +