diff --git a/alembic/versions/0087_canonical_systems.py b/alembic/versions/0087_canonical_systems.py new file mode 100644 index 0000000..a765b80 --- /dev/null +++ b/alembic/versions/0087_canonical_systems.py @@ -0,0 +1,109 @@ +"""canonical_systems — the global area vocabulary, promoted from a constant +to a table (milestone 307 step 1, decision note 3026) + +Revision ID: 0087 +Revises: 0086 +Create Date: 2026-08-26 + +The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in +services/systems.py that milestone 297 seeds into a project at inception. A +constant cannot be referenced: a rule that applies across projects has nothing +to point at, because `systems.project_id` is NOT NULL and a family rule cannot +be chained to one project's row. This makes the vocabulary a table so it can be +a foreign key, and adds the nullable `systems.canonical_id` that maps a +project's local System onto it. + +Deliberately no `user_id`: the catalog is GLOBAL so a shared project inherits +the vocabulary rather than re-earning it. `record_systems` is untouched — it +joins note_id/system_id and never sees this table, so no association data +moves, and no System's own `name` is rewritten. + +The seed rows are written here verbatim rather than imported from the service: +a migration is a historical record and must keep running unchanged after the +service's list moves on. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0087" +down_revision = "0086" +branch_labels = None +depends_on = None + + +# (name, slug, description) — the milestone-297 vocabulary, with the slug the +# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics +# collapsed to "-"). Charters stay generic on purpose: a project refines its +# own System's description, never this one. Nothing here names an app, a repo, +# a vendor or a house convention — the catalog ships to every install (rule 115). +_SEED = ( + ("CI & Release", "ci-and-release", + "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."), + ("Auth & Access", "auth-and-access", + "Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."), + ("Data Model & Storage", "data-model-and-storage", + "What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."), + ("API Surface", "api-surface", + "The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."), + ("UI & Design", "ui-and-design", + "What people see and touch: views, components, client state, and the design tokens/recipes they are built from."), + ("Import & Export", "import-and-export", + "Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."), + ("Background Jobs", "background-jobs", + "Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."), + ("Observability", "observability", + "How the system reports on itself: logging, metrics, audit trails, health and diagnostics."), +) + + +def upgrade() -> None: + canonical_systems = op.create_table( + "canonical_systems", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("slug", sa.Text(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("deleted_batch_id", sa.Text(), nullable=True), + ) + # Unique among LIVE rows only, so a soft-deleted entry doesn't block + # recreating or restoring the same area (the rules/topics convention). + op.create_index( + "uq_canonical_systems_slug", "canonical_systems", ["slug"], + unique=True, postgresql_where=sa.text("deleted_at IS NULL"), + ) + op.bulk_insert( + canonical_systems, + [ + {"name": name, "slug": slug, "description": description, "order_index": index} + for index, (name, slug, description) in enumerate(_SEED) + ], + ) + + op.add_column( + "systems", + sa.Column("canonical_id", sa.Integer(), nullable=True), + ) + # SET NULL, not CASCADE: retiring a catalog entry must never delete a + # project's System along with it. + op.create_foreign_key( + "fk_systems_canonical_id", "systems", "canonical_systems", + ["canonical_id"], ["id"], ondelete="SET NULL", + ) + op.create_index("ix_systems_canonical_id", "systems", ["canonical_id"]) + + # Existing Systems are left UNMAPPED on purpose. An exact-slug match would + # be safe, but a near miss ("CI & runners" vs "CI & Release") is a judgment + # call — those go through the propose/confirm path so a human approves each + # one, rather than being decided by a migration nobody reviews. + + +def downgrade() -> None: + op.drop_index("ix_systems_canonical_id", table_name="systems") + op.drop_constraint("fk_systems_canonical_id", "systems", type_="foreignkey") + op.drop_column("systems", "canonical_id") + op.drop_index("uq_canonical_systems_slug", table_name="canonical_systems") + op.drop_table("canonical_systems") diff --git a/alembic/versions/0088_rule_trigger_tier_tags_relations.py b/alembic/versions/0088_rule_trigger_tier_tags_relations.py new file mode 100644 index 0000000..502f147 --- /dev/null +++ b/alembic/versions/0088_rule_trigger_tier_tags_relations.py @@ -0,0 +1,105 @@ +"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307 +step 3, decision note 3026) + +Revision ID: 0088 +Revises: 0087 +Create Date: 2026-08-26 + +A rule could not say WHEN it applies, WHICH area it is about, or WHAT other +rule it belongs with. All three were being written as prose instead — a +project's System description restating rule text, a rule's `why` naming the +note that caused it, and two halves of one shape merged into a single row +because either could surface without the other. + +Four additions, each replacing something that was already being said in words: + +- `when_to_apply` — the trigger. Nullable HERE and required at the service + layer, because existing rules have none and a migration cannot invent one. +- `tier` — `always_on` (preloaded, as everything is today) or `conditional` + (reachable, surfaced when its trigger fires). Defaults to `always_on`, so + this migration changes NOTHING about which rules bind: an install upgrades + and every rule keeps arriving exactly as it did. +- `arose_from_id` — the record that caused the rule, the edge notes and tasks + already have. +- `rule_systems` / `rule_relations` — the canon tag and the typed edges. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0088" +down_revision = "0087" +branch_labels = None +depends_on = None + +# Kept in one place so upgrade and the CHECK agree by construction (rule 36: +# a whitelisted value means DROP + ADD CONSTRAINT in the same migration — +# there is no prior constraint here, so the pair is created together). +_TIERS = ("always_on", "conditional") +_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates") + + +def _in_list(column: str, values: tuple[str, ...]) -> str: + return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")" + + +def upgrade() -> None: + op.add_column("rules", sa.Column("when_to_apply", sa.Text(), nullable=True)) + op.add_column( + "rules", + sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"), + ) + op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS)) + + # SET NULL, not CASCADE: the record that prompted a rule can be trashed + # without taking the rule with it — provenance is a claim about history, + # and losing the source does not repeal the rule. + op.add_column("rules", sa.Column("arose_from_id", sa.BigInteger(), nullable=True)) + op.create_foreign_key( + "fk_rules_arose_from_id", "rules", "notes", + ["arose_from_id"], ["id"], ondelete="SET NULL", + ) + + # Which global AREA a rule is about. 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 (0087). + op.create_table( + "rule_systems", + sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True), + sa.Column("canonical_id", sa.Integer(), sa.ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_index("ix_rule_systems_canonical_id", "rule_systems", ["canonical_id"]) + + # Typed edges between rules. Each kind exists because its absence forced a + # workaround: co_surfaces (merging two rules into one row), overrides (a + # stricter project rule written as a duplicate), elaborates (a local + # addendum sitting beside its parent with nothing to say it is one). + op.create_table( + "rule_relations", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column("from_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False), + sa.Column("to_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.CheckConstraint(_in_list("kind", _RELATION_KINDS), name="ck_rule_relations_kind"), + # A rule cannot relate to itself, and one pair carries a given kind + # once — a second row would surface the same rule twice. + sa.CheckConstraint("from_rule_id <> to_rule_id", name="ck_rule_relations_not_self"), + sa.UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"), + ) + op.create_index("ix_rule_relations_from", "rule_relations", ["from_rule_id"]) + op.create_index("ix_rule_relations_to", "rule_relations", ["to_rule_id"]) + + +def downgrade() -> None: + op.drop_index("ix_rule_relations_to", table_name="rule_relations") + op.drop_index("ix_rule_relations_from", table_name="rule_relations") + op.drop_table("rule_relations") + op.drop_index("ix_rule_systems_canonical_id", table_name="rule_systems") + op.drop_table("rule_systems") + op.drop_constraint("fk_rules_arose_from_id", "rules", type_="foreignkey") + op.drop_column("rules", "arose_from_id") + op.drop_constraint("ck_rules_tier", "rules", type_="check") + op.drop_column("rules", "tier") + op.drop_column("rules", "when_to_apply") diff --git a/alembic/versions/0089_rule_embeddings.py b/alembic/versions/0089_rule_embeddings.py new file mode 100644 index 0000000..3537c82 --- /dev/null +++ b/alembic/versions/0089_rule_embeddings.py @@ -0,0 +1,58 @@ +"""rule_embeddings — rules become findable by meaning (milestone 307 step 4, +decision note 3026) + +Revision ID: 0089 +Revises: 0088 +Create Date: 2026-08-26 + +Rules were the only major record type with no vector, so `search` could never +return one and a rule could only ever arrive by being preloaded. That single +fact is what made every rule compete for the same always-on budget. + +A sibling table rather than a generalisation of note_embeddings: the row could +have been made polymorphic, but the SEARCH could not — semantic_search_notes is +Note-specific scoping end to end, and a rule shares none of it. See the model +docstring for the full reasoning. + +The vectors are DERIVED data. Nothing is backfilled here: the startup backfill +regenerates them, which is also how a chunker-version bump is handled. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0089" +down_revision = "0088" +branch_labels = None +depends_on = None + +# Matches note_embeddings — bge-small-en-v1.5, 384-dim unit-normalized. +_EMBEDDING_DIM = 384 + + +def upgrade() -> None: + op.create_table( + "rule_embeddings", + sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True), + sa.Column("chunk_index", sa.Integer(), primary_key=True), + sa.Column("chunk_text", sa.Text(), nullable=False), + sa.Column("chunker_version", sa.Integer(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + # The vector column is added by raw DDL for the same reason 0067 did it: + # the type comes from the pgvector extension, not from SQLAlchemy's + # type system. + op.execute(f"ALTER TABLE rule_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL") + # HNSW for cosine distance — matches Vector.cosine_distance (`<=>`), so the + # search is an indexed ORDER BY ... LIMIT k rather than a full scan. + op.execute( + """ + CREATE INDEX ix_rule_embeddings_embedding_hnsw + ON rule_embeddings + USING hnsw (embedding vector_cosine_ops) + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_rule_embeddings_embedding_hnsw") + op.drop_table("rule_embeddings") diff --git a/frontend/src/api/canonicalSystems.ts b/frontend/src/api/canonicalSystems.ts new file mode 100644 index 0000000..d7c2649 --- /dev/null +++ b/frontend/src/api/canonicalSystems.ts @@ -0,0 +1,81 @@ +/** + * Canonical systems — the GLOBAL area vocabulary every project's Systems can + * map onto (milestone 307). + * + * The mapping is an ASSOCIATION, never a rename: a project's System keeps the + * name the project gave it, and `canonical_id` only records which shared area + * it is an instance of. An unmapped System is fully usable — the catalog is a + * convergence aid, not a gate. + */ +import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client"; + +export interface CanonicalSystem { + id: number; + name: string; + /** The match key: lowercase, "&" folded to "and", punctuation collapsed. */ + slug: string; + description: string | null; + order_index: number; + created_at: string | null; + updated_at: string | null; +} + +/** + * A suggested mapping. `basis` is the whole point of showing it: + * - `exact` — the names differ only in spelling. Mechanical. + * - `overlap` — they share a meaningful word. A judgment call the reviewer is + * making, and it must never be presented as if it were the first. + */ +export interface CanonicalMatch { + id: number; + name: string; + basis: "exact" | "overlap"; + score?: number; +} + +export interface MappingProposal { + system_id: number; + system_name: string; + canonical_id: number; + canonical_name: string; + basis: "exact" | "overlap"; + score: number; +} + +export async function listCanonicalSystems(): Promise { + const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>( + "/api/canonical-systems", + ); + return data.canonical_systems; +} + +/** Admin only — a global list anyone can extend stops being shared. */ +export async function createCanonicalSystem(data: { + name: string; + description?: string; +}): Promise { + return apiPost("/api/canonical-systems", data); +} + +export async function updateCanonicalSystem( + id: number, + data: Partial<{ name: string; description: string; order_index: number }>, +): Promise { + return apiPatch(`/api/canonical-systems/${id}`, data); +} + +/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */ +export async function proposeMappings(projectId: number): Promise { + const data = await apiGet<{ proposals: MappingProposal[] }>( + `/api/projects/${projectId}/canonical-proposals`, + ); + return data.proposals; +} + +/** Apply or clear one mapping. `null` unmaps. */ +export async function mapSystem( + systemId: number, + canonicalId: number | null, +): Promise<{ id: number; canonical_id: number | null }> { + return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId }); +} diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 1c52447..2c3eb8c 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -1,5 +1,24 @@ import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +/** How a rule reaches a session (milestone 307). */ +export type RuleTier = "always_on" | "conditional"; + +/** + * A typed edge between two rules. Each kind exists because its absence forced + * a workaround: merging two rules into one row, writing an override as a + * near-copy, or leaving a local addendum with nothing to say it is one. + */ +export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates"; + +export interface RuleRelation { + id: number; + kind: RuleRelationKind; + /** The rule at the OTHER end. */ + rule_id: number; + direction: "outgoing" | "incoming"; + note: string; +} + export interface Rulebook { id: number; owner_user_id: number; @@ -26,35 +45,53 @@ export interface Rule { project_id: number | null; title: string; statement: string; + /** WHEN this rule fires — the trigger, not the instruction. */ + when_to_apply: string; + /** + * always_on preloads into every session; conditional is reachable and + * surfaced when its trigger fires. A rule with no tier set behaves as + * always_on, which is how every rule behaved before this existed. + */ + tier: RuleTier; why: string; how_to_apply: string; + /** The note or task that caused this rule, if one was recorded. */ + arose_from_id: number | null; order_index: number; created_at: string | null; updated_at: string | null; + /** Present only when the rule has them (the server omits empty keys). */ + systems?: { id: number; name: string }[]; + relations?: RuleRelation[]; } +/** + * A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the + * age deliberately: a rule written before the capability it duplicates is + * otherwise indistinguishable, at a glance, from one still doing work. + */ export interface RuleHeader { id: number; title: string; statement: string; topic_id: number | null; + tier: RuleTier; + /** A date (YYYY-MM-DD), not a timestamp. */ + updated_at: string | null; + when_to_apply?: string; + arose_from_id?: number; } export interface ApplicableRules { - rules: { - id: number; - title: string; - statement: string; - topic_id: number; + // Both lists are rule_brief's output — the SAME builder, so they are + // described the same way here rather than as two hand-written shapes that + // drift from it and from each other (which is what the server side had). + rules: (RuleHeader & { topic_title: string; rulebook_id: number; rulebook_title: string; - }[]; - project_rules: { - id: number; - title: string; - statement: string; - }[]; + })[]; + project_rules: RuleHeader[]; suppressed_rules: { id: number; title: string; @@ -133,14 +170,39 @@ export async function getRule(id: number): Promise { return apiGet(`/api/rules/${id}`); } -export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise { +/** The fields both write paths accept. `system_ids` REPLACES a rule's areas. */ +export interface RuleWrite { + title: string; + statement: string; + when_to_apply: string; + tier: RuleTier; + why: string; + how_to_apply: string; + order_index: number; + system_ids: number[]; + arose_from_id: number | null; +} + +export async function createRule(topicId: number, data: Partial & { title: string; statement: string }): Promise { return apiPost(`/api/rulebook-topics/${topicId}/rules`, data); } -export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise { +export async function updateRule(id: number, data: Partial): Promise { return apiPatch(`/api/rules/${id}`, data); } +/** Draw a typed edge from one rule to another. Idempotent. */ +export async function relateRules( + fromRuleId: number, + data: { to_rule_id: number; kind: RuleRelationKind; note?: string }, +): Promise<{ id: number }> { + return apiPost(`/api/rules/${fromRuleId}/relations`, data); +} + +export async function unrelateRules(relationId: number): Promise { + return apiDelete(`/api/rule-relations/${relationId}`); +} + export async function deleteRule(id: number): Promise { return apiDelete(`/api/rules/${id}`); } @@ -161,7 +223,7 @@ export async function getProjectApplicableRules(projectId: number): Promise & { statement: string }, ): Promise { return apiPost(`/api/projects/${projectId}/rules`, data); } diff --git a/frontend/src/api/systems.ts b/frontend/src/api/systems.ts index 1fa194d..24d3637 100644 --- a/frontend/src/api/systems.ts +++ b/frontend/src/api/systems.ts @@ -1,9 +1,15 @@ import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client"; +import type { CanonicalMatch } from "@/api/canonicalSystems"; export interface System { id: number; project_id: number; name: string; + /** + * The global area this System is an instance of, or null. Null is a valid + * resting state — a project-specific area should stay unmapped. + */ + canonical_id: number | null; description: string; color: string | null; status: "active" | "archived"; @@ -18,10 +24,23 @@ export async function listSystems(projectId: number): Promise { return data.systems; } +/** + * A created System, plus the catalog's answer about its name. An `exact` + * catalog hit is applied by the server and arrives as a populated + * `canonical_id`; an `overlap` is only OFFERED, and comes back here for the + * caller to accept or ignore. + * + * A same-named System in this project is a 409 ApiError carrying + * `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482). + */ +export interface CreatedSystem extends System { + canonical_suggestion?: CanonicalMatch; +} + export async function createSystem( projectId: number, - data: { name: string; description?: string; color?: string }, -): Promise { + data: { name: string; description?: string; color?: string; canonical_id?: number }, +): Promise { return apiPost(`/api/projects/${projectId}/systems`, data); } diff --git a/frontend/src/components/SystemsSection.vue b/frontend/src/components/SystemsSection.vue index 6bf72ae..fbc7e77 100644 --- a/frontend/src/components/SystemsSection.vue +++ b/frontend/src/components/SystemsSection.vue @@ -1,14 +1,18 @@