From c58529718bd98f5678ff3e3a660d77a81c9ba588 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 26 Aug 2026 12:58:30 -0400 Subject: [PATCH] feat(systems): the catalog reaches the moment a name is minted, and gets a face (#3028, milestone 307 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 found the reason the standard names never held, and it is sharper than "prose doesn't fire": the list WAS real and it WAS seeded — but only on the inception path, for a project with zero Systems. Ad-hoc create_system never consulted it, which is how Forge minted "CI and Release" and Portal minted "CI & release" after the constant already existed. This wires the vocabulary to the moment that mints a name. - services/systems.assess_system_name: the local duplicate gate AND the catalog lookup, in ONE service function both doors call. The gate lived only in the MCP tool, which is exactly how the web UI shipped without a check the agent surface enforced (#2482). REST now answers 409 with the System that already covers the area. - An `exact` catalog hit is APPLIED (mechanical — the names differ only in spelling). An `overlap` is only OFFERED, on both doors: applying a judgment call silently is how a cross-project rule surfaces in the wrong project. - canonical_systems.best_overlap is the ONE scorer behind the create-time offer and the review sweep, so the two surfaces can never name different areas for one System. It also takes the catalog the caller already holds, so the review is not an N+1. UI (folded in from step 1 — rule 27, that step shipped with no human surface): - SystemsSection: a Shared area picker on create and edit, the area on each card, and a collapsed review of proposals that appears only when there is something to decide. `exact` and `overlap` never share a style — one is mechanical, the other is the reviewer's judgment, and presenting them alike is how a wrong mapping gets waved through. - Settings → Admin → Areas: the catalog itself, showing each entry's slug, because the slug is what decides whether two names are the same area and a rename moves it. - A picker rather than a live matcher: reproducing the slug rule in TypeScript would give this feature two matchers to keep in step — the exact drift the catalog exists to end. The server stays authoritative. tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an auto-MagicMock and therefore truthy, which is the trap that helper exists for (note 2109) and a nullable FK walks straight into it. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/api/canonicalSystems.ts | 81 +++++++ frontend/src/api/systems.ts | 23 +- frontend/src/components/SystemsSection.vue | 257 ++++++++++++++++++++- frontend/src/stores/canonicalSystems.ts | 93 ++++++++ frontend/src/stores/systems.ts | 2 +- frontend/src/views/SettingsView.vue | 164 ++++++++++++- src/scribe/mcp/tools/systems.py | 68 ++++-- src/scribe/routes/systems.py | 26 ++- src/scribe/services/canonical_systems.py | 66 ++++-- src/scribe/services/systems.py | 60 +++++ tests/helpers.py | 4 +- tests/test_mcp_tool_systems.py | 60 +++++ 12 files changed, 855 insertions(+), 49 deletions(-) create mode 100644 frontend/src/api/canonicalSystems.ts create mode 100644 frontend/src/stores/canonicalSystems.ts 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/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 @@