feat(systems): the catalog reaches the moment a name is minted, and gets a face (#3028, milestone 307 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 12:58:30 -04:00
co-authored by Claude Opus 5
parent 879ef3053e
commit c58529718b
12 changed files with 855 additions and 49 deletions
+81
View File
@@ -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<CanonicalSystem[]> {
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<CanonicalSystem> {
return apiPost("/api/canonical-systems", data);
}
export async function updateCanonicalSystem(
id: number,
data: Partial<{ name: string; description: string; order_index: number }>,
): Promise<CanonicalSystem> {
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<MappingProposal[]> {
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 });
}
+21 -2
View File
@@ -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<System[]> {
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<System> {
data: { name: string; description?: string; color?: string; canonical_id?: number },
): Promise<CreatedSystem> {
return apiPost(`/api/projects/${projectId}/systems`, data);
}
+253 -4
View File
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useSystemsStore } from "@/stores/systems";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { useToastStore } from "@/stores/toast";
import { getProjectIssues } from "@/api/systems";
import type { System, TaskLike } from "@/api/systems";
import type { CanonicalMatch } from "@/api/canonicalSystems";
import { apiErrorMessage } from "@/api/client";
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
const props = defineProps<{ projectId: number }>();
const store = useSystemsStore();
const canon = useCanonicalSystemsStore();
const toast = useToastStore();
const error = ref<string | null>(null);
@@ -19,14 +23,26 @@ const issues = ref<TaskLike[]>([]);
const showCreate = ref(false);
const newName = ref("");
const newDescription = ref("");
// The global area, chosen explicitly. A PICKER rather than a live matcher on
// purpose: reproducing the server's slug rule in TypeScript would give this
// feature two matchers to keep in step, which is the exact drift the catalog
// exists to end. The server still applies an exact hit on submit.
const newCanonicalId = ref<number | null>(null);
const creating = ref(false);
// An `overlap` the server offered after a create — an offer, never applied.
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
// Edit state
const editingId = ref<number | null>(null);
const editName = ref("");
const editDescription = ref("");
const editCanonicalId = ref<number | null>(null);
const savingEdit = ref(false);
// Mapping review
const showReview = ref(false);
const reviewBusy = ref<number | null>(null);
// Delete confirmation
const deletingSystem = ref<System | null>(null);
@@ -37,6 +53,12 @@ const visibleSystems = computed(() =>
showArchived.value ? systems.value : activeSystems.value,
);
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? null;
}
async function load() {
error.value = null;
try {
@@ -49,6 +71,14 @@ async function load() {
} catch {
issues.value = [];
}
// Both fail soft: the catalog is a naming aid, and a review prompt that
// cannot load must not take the Systems list down with it.
await canon.fetchCatalog();
try {
await canon.fetchProposals(props.projectId);
} catch {
/* no proposals shown */
}
}
onMounted(load);
@@ -58,12 +88,14 @@ function openCreate() {
showCreate.value = true;
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
}
function cancelCreate() {
showCreate.value = false;
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
}
async function submitCreate() {
@@ -71,23 +103,60 @@ async function submitCreate() {
if (!name || creating.value) return;
creating.value = true;
try {
await store.createSystem(props.projectId, {
const created = await store.createSystem(props.projectId, {
name,
description: newDescription.value.trim() || undefined,
canonical_id: newCanonicalId.value ?? undefined,
});
cancelCreate();
toast.show("System created");
} catch {
toast.show("Failed to create system", "error");
if (created.canonical_suggestion) {
// An overlap: shown as an offer beside the new System, never applied.
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
}
toast.show(
created.canonical_id
? `System created and filed under ${canon.byId(created.canonical_id)?.name}`
: "System created",
);
} catch (e) {
// 409 = this project already has that System. Say WHICH one, so the
// answer is actionable rather than "it didn't work".
toast.show(apiErrorMessage(e, "Failed to create system"), "error");
} finally {
creating.value = false;
}
}
async function acceptSuggestion() {
const pending = suggestion.value;
if (!pending) return;
suggestion.value = null;
try {
await canon.mapSystem(props.projectId, pending.systemId, pending.match.id);
await store.fetchSystems(props.projectId);
toast.show(`Filed under ${pending.match.name}`);
} catch {
/* the store already reported it */
}
}
async function applyProposal(systemId: number, canonicalId: number) {
reviewBusy.value = systemId;
try {
await canon.mapSystem(props.projectId, systemId, canonicalId);
await store.fetchSystems(props.projectId);
} catch {
/* the store already reported it */
} finally {
reviewBusy.value = null;
}
}
function startEdit(system: System) {
editingId.value = system.id;
editName.value = system.name;
editDescription.value = system.description;
editCanonicalId.value = system.canonical_id;
}
function cancelEdit() {
@@ -103,6 +172,12 @@ async function submitEdit(system: System) {
name,
description: editDescription.value.trim(),
});
// The mapping is a separate write with its own validation — one column,
// one writer (services/canonical_systems.set_system_canonical).
if (editCanonicalId.value !== system.canonical_id) {
await canon.mapSystem(props.projectId, system.id, editCanonicalId.value);
await store.fetchSystems(props.projectId);
}
editingId.value = null;
toast.show("System updated");
} catch {
@@ -161,6 +236,65 @@ async function confirmDelete() {
</ul>
</div>
<!-- Mapping review. Only appears when there is something to decide, and
it says HOW MANY rather than nagging with a permanent banner. -->
<div v-if="proposals.length" class="area-review">
<button class="area-review-head" @click="showReview = !showReview">
<span class="area-review-count">{{ proposals.length }}</span>
{{ proposals.length === 1 ? "system" : "systems" }} may belong to a shared area
<span class="area-review-chev">{{ showReview ? "▾" : "▸" }}</span>
</button>
<ul v-if="showReview" class="area-proposals">
<li v-for="p in proposals" :key="p.system_id" class="area-proposal">
<div class="area-proposal-text">
<span class="area-proposal-name">{{ p.system_name }}</span>
<span class="area-proposal-arrow" aria-hidden="true"></span>
<span class="area-proposal-target">{{ p.canonical_name }}</span>
<!-- The basis is the decision the reviewer is making: `exact`
differs only in spelling, `overlap` is a judgment call.
Showing them identically is how a wrong mapping is waved
through, so they never share a style. -->
<span
class="area-basis"
:class="p.basis === 'exact' ? 'area-basis--exact' : 'area-basis--overlap'"
:title="
p.basis === 'exact'
? 'Same name up to spelling — safe to accept.'
: 'Shares a word. Accept only if it is really the same area.'
"
>{{ p.basis === "exact" ? "same name" : "similar" }}</span>
</div>
<div class="area-proposal-actions">
<button
class="btn-primary btn-compact"
:disabled="reviewBusy === p.system_id"
@click="applyProposal(p.system_id, p.canonical_id)"
>
{{ reviewBusy === p.system_id ? "Filing…" : "File here" }}
</button>
<button
class="btn-ghost btn-compact"
@click="canon.dismissProposal(props.projectId, p.system_id)"
>
Not this
</button>
</div>
</li>
</ul>
</div>
<!-- An overlap offered by the server after a create. Never applied. -->
<div v-if="suggestion" class="area-offer">
<span>
Is this the same area as
<strong>{{ suggestion.match.name }}</strong>?
</span>
<div class="area-proposal-actions">
<button class="btn-primary btn-compact" @click="acceptSuggestion">File it there</button>
<button class="btn-ghost btn-compact" @click="suggestion = null">No, it's ours</button>
</div>
</div>
<!-- Toolbar -->
<div class="systems-toolbar">
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
@@ -189,6 +323,20 @@ async function confirmDelete() {
placeholder="What is this subsystem responsible for? (optional)"
aria-label="System description"
></textarea>
<label v-if="canon.catalog.length" class="area-field">
<span class="area-label">Shared area</span>
<select v-model="newCanonicalId" class="fs-input area-select" aria-label="Shared area">
<option :value="null">None specific to this project</option>
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
{{ entry.name }}
</option>
</select>
<!-- .field-hint is the shared hint class beside .fs-input
(components.css) not restated scoped. -->
<span class="field-hint">
Files this system under an area shared by every project. Your name stays as you typed it.
</span>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }}
@@ -240,6 +388,15 @@ async function confirmDelete() {
placeholder="Description (optional)"
aria-label="System description"
></textarea>
<label v-if="canon.catalog.length" class="area-field">
<span class="area-label">Shared area</span>
<select v-model="editCanonicalId" class="fs-input area-select" aria-label="Shared area">
<option :value="null">None specific to this project</option>
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
{{ entry.name }}
</option>
</select>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }}
@@ -264,6 +421,13 @@ async function confirmDelete() {
:title="`${system.open_issue_count} open issue(s)`"
>{{ system.open_issue_count }} open</span>
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
<!-- Not a TagPill: that recipe prefixes "#" and means a tag.
This is the shared AREA this system is an instance of. -->
<span
v-if="areaName(system)"
class="area-chip"
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
>{{ areaName(system) }}</span>
</div>
<p v-if="system.description" class="system-description">{{ system.description }}</p>
</div>
@@ -335,6 +499,91 @@ async function confirmDelete() {
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
/* ── Shared-area mapping (milestone 307) ──────────────────────────
The review is a disclosure, not a banner: it exists only while there is
something to decide, and collapses to one line until opened. */
.area-review {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
}
.area-review-head {
display: flex;
align-items: center;
gap: var(--fs-space-2);
width: 100%;
padding: var(--fs-space-3);
background: none;
border: none;
color: var(--fs-text-secondary);
font: inherit;
font-size: 0.82rem;
text-align: left;
cursor: pointer;
border-radius: var(--fs-radius-lg);
}
.area-review-head:hover { color: var(--fs-text-primary); }
.area-review-head:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
.area-review-count {
background: var(--fs-accent-soft);
color: var(--fs-accent);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.45rem;
font-variant-numeric: tabular-nums;
}
.area-review-chev { margin-left: auto; color: var(--fs-text-tertiary); }
.area-proposals { list-style: none; margin: 0; padding: 0 var(--fs-space-3) var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-2); }
.area-proposal {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fs-space-3);
flex-wrap: wrap;
padding: var(--fs-space-2) var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.area-proposal-text { display: flex; align-items: center; gap: var(--fs-space-2); flex-wrap: wrap; font-size: 0.85rem; min-width: 0; }
.area-proposal-name { color: var(--fs-text-primary); }
.area-proposal-arrow { color: var(--fs-text-tertiary); }
.area-proposal-target { color: var(--fs-accent); }
.area-proposal-actions { display: flex; gap: var(--fs-space-2); flex-shrink: 0; }
/* The two bases must never look alike — one is mechanical, the other is the
reviewer's judgment, and that difference is the whole decision. */
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
.area-offer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fs-space-3);
flex-wrap: wrap;
padding: var(--fs-space-3);
font-size: 0.85rem;
color: var(--fs-text-secondary);
background: var(--fs-accent-faint);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.area-field { display: flex; flex-direction: column; gap: 0.3rem; }
.area-label { font-size: 0.78rem; color: var(--fs-text-tertiary); }
.area-select { box-sizing: border-box; width: 100%; }
.area-chip {
font-size: 0.66rem;
color: var(--fs-accent);
background: var(--fs-accent-soft);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.45rem;
white-space: nowrap;
}
/* ── Toolbar ──────────────────────────────────────────────────── */
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.btn-add-system {
+93
View File
@@ -0,0 +1,93 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/canonicalSystems";
import type { CanonicalSystem, MappingProposal } from "@/api/canonicalSystems";
import { useToastStore } from "@/stores/toast";
import { apiErrorMessage } from "@/api/client";
/**
* The global area catalog (milestone 307). Shared by every project, so it is
* fetched ONCE per session rather than per project — the whole point of the
* table is that it is the same list everywhere.
*/
export const useCanonicalSystemsStore = defineStore("canonicalSystems", () => {
const catalog = ref<CanonicalSystem[]>([]);
const loaded = ref(false);
const loading = ref(false);
const proposalsByProject = ref<Record<number, MappingProposal[]>>({});
async function fetchCatalog(force = false) {
if (loaded.value && !force) return catalog.value;
loading.value = true;
try {
catalog.value = await api.listCanonicalSystems();
loaded.value = true;
} catch {
// A naming aid must never break the screen it rides on — an empty
// catalog degrades the suggestion, it does not fail the form.
catalog.value = [];
} finally {
loading.value = false;
}
return catalog.value;
}
function byId(id: number | null): CanonicalSystem | undefined {
if (id == null) return undefined;
return catalog.value.find((c) => c.id === id);
}
async function fetchProposals(projectId: number) {
proposalsByProject.value[projectId] = await api.proposeMappings(projectId);
return proposalsByProject.value[projectId];
}
/** Apply or clear one mapping, then drop it from the pending proposals. */
async function mapSystem(projectId: number, systemId: number, canonicalId: number | null) {
try {
await api.mapSystem(systemId, canonicalId);
} catch (e) {
useToastStore().show(apiErrorMessage(e, "Failed to map system"), "error");
throw e;
}
dismissProposal(projectId, systemId);
}
/** Remove a proposal from the pending list without writing anything. */
function dismissProposal(projectId: number, systemId: number) {
const list = proposalsByProject.value[projectId];
if (list) {
proposalsByProject.value[projectId] = list.filter((p) => p.system_id !== systemId);
}
}
async function createEntry(data: { name: string; description?: string }) {
const entry = await api.createCanonicalSystem(data);
catalog.value.push(entry);
return entry;
}
async function updateEntry(
id: number,
data: Partial<{ name: string; description: string; order_index: number }>,
) {
const entry = await api.updateCanonicalSystem(id, data);
const idx = catalog.value.findIndex((c) => c.id === id);
if (idx >= 0) catalog.value[idx] = entry;
return entry;
}
return {
catalog,
loaded,
loading,
proposalsByProject,
fetchCatalog,
byId,
fetchProposals,
mapSystem,
dismissProposal,
createEntry,
updateEntry,
};
});
+1 -1
View File
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string },
data: { name: string; description?: string; color?: string; canonical_id?: number },
) {
const system = await api.createSystem(projectId, data);
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
+162 -2
View File
@@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -12,6 +13,62 @@ import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
// ── Shared areas (milestone 307) ────────────────────────────────────────
// The global vocabulary a project's Systems map onto. Admin-only to WRITE —
// a global list anyone can extend stops being a shared list — but every user
// reads it, which is why the catalog lives in a store rather than here.
const canonStore = useCanonicalSystemsStore();
const newAreaName = ref("");
const newAreaDescription = ref("");
const creatingArea = ref(false);
const editingAreaId = ref<number | null>(null);
const editAreaName = ref("");
const editAreaDescription = ref("");
const savingArea = ref(false);
async function createArea() {
const name = newAreaName.value.trim();
if (!name || creatingArea.value) return;
creatingArea.value = true;
try {
await canonStore.createEntry({
name,
description: newAreaDescription.value.trim() || undefined,
});
newAreaName.value = "";
newAreaDescription.value = "";
toastStore.show("Area added");
} catch (e) {
// A 409 means an area with the same match key already exists — say which,
// because "CI and Release" vs "CI & Release" looks like a different name.
toastStore.show(apiErrorMessage(e, "Failed to add area"), "error");
} finally {
creatingArea.value = false;
}
}
function startEditArea(id: number, name: string, description: string | null) {
editingAreaId.value = id;
editAreaName.value = name;
editAreaDescription.value = description ?? "";
}
async function saveArea() {
const id = editingAreaId.value;
const name = editAreaName.value.trim();
if (id == null || !name || savingArea.value) return;
savingArea.value = true;
try {
await canonStore.updateEntry(id, { name, description: editAreaDescription.value.trim() });
editingAreaId.value = null;
toastStore.show("Area updated");
} catch (e) {
toastStore.show(apiErrorMessage(e, "Failed to update area"), "error");
} finally {
savingArea.value = false;
}
}
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
@@ -134,7 +191,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -143,6 +200,7 @@ function _loadTabContent(tab: string) {
if (tab === "users") loadUsersPanel();
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
else if (tab === "areas") canonStore.fetchCatalog(true);
}
if (tab === "apikeys") { fetchApiKeys(); }
}
@@ -1212,7 +1270,7 @@ async function deleteUser(userId: number) {
<div v-if="authStore.isAdmin" class="sidebar-group">
<div class="sidebar-group-label">Admin</div>
<button
v-for="tab in ['config', 'users', 'groups', 'logs']"
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -2263,6 +2321,78 @@ async function deleteUser(userId: number) {
</div>
<!-- Users -->
<!-- Shared areas -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'areas'" class="settings-grid">
<section class="settings-section full-width">
<h2>Shared areas</h2>
<p class="field-hint">
The vocabulary every project's Systems can be filed under, so the same word means the
same thing everywhere. A project keeps its own name for an area — mapping records which
shared area it is, it never renames anything. Editing a name here re-derives its match
key, so existing mappings are kept but future name matching follows the new spelling.
</p>
<ul class="area-admin-list">
<li v-for="entry in canonStore.catalog" :key="entry.id" class="area-admin-row">
<template v-if="editingAreaId === entry.id">
<form class="area-admin-form" @submit.prevent="saveArea">
<input v-model="editAreaName" class="fs-input" aria-label="Area name" />
<textarea
v-model="editAreaDescription"
class="fs-input"
rows="2"
aria-label="Area description"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editAreaName.trim() || savingArea">
{{ savingArea ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-ghost btn-compact" @click="editingAreaId = null">Cancel</button>
</div>
</form>
</template>
<template v-else>
<div class="area-admin-body">
<div class="area-admin-name-row">
<span class="area-admin-name">{{ entry.name }}</span>
<code class="area-admin-slug" title="The match key. Names that reduce to this are the same area.">{{ entry.slug }}</code>
</div>
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
</div>
<button
class="btn-ghost btn-compact"
@click="startEditArea(entry.id, entry.name, entry.description)"
>Edit</button>
</template>
</li>
</ul>
<p v-if="!canonStore.catalog.length && !canonStore.loading" class="settings-empty">
No areas yet.
</p>
<form class="area-admin-form area-admin-create" @submit.prevent="createArea">
<input
v-model="newAreaName"
class="fs-input"
placeholder="New area name (e.g. Search &amp; Indexing)"
aria-label="New area name"
/>
<textarea
v-model="newAreaDescription"
class="fs-input"
rows="2"
placeholder="What belongs in this area? One paragraph — a bare name is never enough."
aria-label="New area description"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newAreaName.trim() || creatingArea">
{{ creatingArea ? "Adding…" : "Add area" }}
</button>
</div>
</form>
</section>
</div>
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
<section class="settings-section full-width">
@@ -3401,4 +3531,34 @@ async function deleteUser(userId: number) {
color: var(--fs-accent);
font-weight: 500;
}
/* ── Shared areas (milestone 307) ──────────────────────────────────
The slug is shown deliberately: it is what decides whether two names are
the same area, and an admin renaming an entry needs to see it move. */
.area-admin-list { list-style: none; margin: 1rem 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.area-admin-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--fs-space-3);
padding: var(--fs-space-3);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.area-admin-body { flex: 1; min-width: 0; }
.area-admin-name-row { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.area-admin-name { color: var(--fs-text-primary); }
.area-admin-slug {
font-family: var(--fs-font-mono);
font-size: 0.72rem;
color: var(--fs-text-tertiary);
background: var(--fs-surface-code-inline);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.35rem;
}
.area-admin-desc { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.area-admin-create { margin-top: var(--fs-space-4); }
.area-admin-actions { display: flex; gap: 0.4rem; }
</style>
+46 -22
View File
@@ -197,36 +197,60 @@ async def create_system(
normalized name already exists in this project (archived included), the
call returns {"duplicate": true, "existing_id": ...} instead of creating —
tag records to that one, or update_system it if its charter needs work.
Also mapped against the GLOBAL area catalog, so the same word means the
same thing in every project (milestone 307). A name that IS a catalog area
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
the response says so. A name that merely RESEMBLES one comes back with
`canonical_suggestion` — an offer, not a decision: apply it with
map_system_to_canonical if it really is that area, ignore it if this is a
project-specific area. Either way the System is created; the catalog never
blocks a name.
"""
uid = current_user_id()
norm = " ".join(name.split()).lower()
if norm:
try:
existing = await systems_svc.list_systems(
uid, project_id, include_archived=True
)
except Exception:
existing = []
for s in existing:
if " ".join(s.name.split()).lower() == norm:
return {
"duplicate": True,
"existing_id": s.id,
"message": (
f"System '{s.name}' (#{s.id}) already covers this area "
"in this project. Tag records to it with system_ids, "
"or update_system it if the charter needs revising — "
"a second System with the same name would split the "
"area's records across two piles."
),
}
assessment = await systems_svc.assess_system_name(uid, project_id, name)
duplicate = assessment["duplicate"]
if duplicate:
return {
"duplicate": True,
"existing_id": duplicate["id"],
"message": (
f"System '{duplicate['name']}' (#{duplicate['id']}) already "
"covers this area in this project. Tag records to it with "
"system_ids, or update_system it if the charter needs "
"revising — a second System with the same name would split "
"the area's records across two piles."
),
}
# An exact match is mechanical, so it is applied; an overlap is a judgment
# call, so it is only offered (see services/canonical_systems).
canonical = assessment["canonical"]
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
system = await systems_svc.create_system(
uid, project_id=project_id, name=name,
description=description or None, color=color or None,
canonical_id=applied,
)
if system is None:
raise ValueError(f"cannot create system in project {project_id} (no write access)")
return system.to_dict()
out = system.to_dict()
if applied:
out["canonical_note"] = (
f"Mapped to the global area '{canonical['name']}' — the same "
"spelling-insensitive name. Your System keeps the name you gave it."
)
elif canonical:
out["canonical_suggestion"] = {
**canonical,
"message": (
f"The global catalog has '{canonical['name']}', which may be "
f"this same area. If it is, map_system_to_canonical("
f"{system.id}, {canonical['id']}) so records and rules about "
"this area line up across projects. If this area is specific "
"to this project, ignore it — unmapped is a valid state."
),
}
return out
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
+25 -1
View File
@@ -62,14 +62,38 @@ async def create_system_route(project_id: int):
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
# The same gate the MCP door enforces. It lived only in the tool layer
# until now, which is exactly how the web UI shipped without gates the
# agent surface had (#2482) — one service call, one answer (rule 33).
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
duplicate = assessment["duplicate"]
if duplicate and not data.get("force"):
return jsonify({
"duplicate": True,
"existing_id": duplicate["id"],
"error": (
f"{duplicate['name']}” already covers this area in this "
"project. Tag records to it, or rename it if its charter has "
"moved on — a second System with the same name splits the "
"area's records across two piles."
),
}), 409
canonical = assessment["canonical"]
# Exact is mechanical and applied; overlap is a judgment call and is only
# offered back for the form to present.
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
system = await systems_svc.create_system(
uid, project_id=project_id, name=data["name"],
description=data.get("description"), color=data.get("color"),
order_index=data.get("order_index", 0),
canonical_id=data.get("canonical_id") or applied,
)
if system is None:
return jsonify({"error": "Permission denied"}), 403
return jsonify(system.to_dict()), 201
out = system.to_dict()
if canonical and canonical["basis"] == "overlap" and not system.canonical_id:
out["canonical_suggestion"] = canonical
return jsonify(out), 201
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
+50 -16
View File
@@ -96,6 +96,47 @@ async def find_by_name(name: str) -> CanonicalSystem | None:
)
def _overlap(local: frozenset[str], other: frozenset[str]) -> float:
return len(local & other) / max(len(local | other), 1)
async def best_overlap(name: str, catalog: list | None = None) -> dict | None:
"""The closest catalog entry that shares a meaningful word, or None.
The ONE scorer behind both offers: the create-time suggestion and the
review surface. Two scorers would eventually disagree about which area a
name resembles, and the operator would be asked one question at create
time and a different one at review.
The threshold is any shared meaningful word, deliberately generous: a
wrong offer costs one dismissal, a missing one costs a mapping nobody
thinks to make again. Nothing here ever applies — `overlap` is always an
offer (see propose_mappings).
"""
slug = canonical_slug(name)
if not slug:
return None
local = _tokens(slug)
if not local:
return None
# A caller already holding the catalog passes it: this runs once per
# unmapped System in the review sweep, and re-reading the table each time
# would make an N+1 out of a report.
if catalog is None:
catalog = await list_canonical_systems()
best, best_score = None, 0.0
for entry in catalog:
score = _overlap(local, _tokens(entry.slug))
if score > best_score:
best, best_score = entry, score
if best is None or best_score <= 0:
return None
return {
"id": best.id, "name": best.name,
"basis": "overlap", "score": round(best_score, 3),
}
async def create_canonical_system(
user_id: int, name: str, description: str | None = None,
) -> CanonicalSystem | dict | None:
@@ -230,27 +271,20 @@ async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
continue
exact = by_slug.get(slug)
if exact is not None:
match, basis, score = exact, "exact", 1.0
match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
else:
local = _tokens(slug)
scored = [
(len(local & _tokens(entry.slug)) / max(len(local | _tokens(entry.slug)), 1), entry)
for entry in catalog
]
# Any shared meaningful word is enough to ASK. The threshold is
# deliberately generous because a wrong proposal costs one click
# and a missing one costs a mapping nobody thinks to make again.
best_score, best = max(scored, key=lambda pair: pair[0])
if best_score <= 0:
# Same scorer the create-time offer uses, so the two surfaces can
# never name different areas for one System.
match = await best_overlap(system.name, catalog)
if match is None:
continue
match, basis, score = best, "overlap", round(best_score, 3)
proposals.append({
"system_id": system.id,
"system_name": system.name,
"canonical_id": match.id,
"canonical_name": match.name,
"basis": basis,
"score": score,
"canonical_id": match["id"],
"canonical_name": match["name"],
"basis": match["basis"],
"score": match["score"],
})
proposals.sort(key=lambda p: (-p["score"], p["system_name"]))
return proposals
+60
View File
@@ -19,6 +19,66 @@ from scribe.services import canonical_systems as canonical_systems_svc
logger = logging.getLogger(__name__)
def local_name_key(name: str) -> str:
"""The within-project uniqueness key: case and spacing, nothing else.
Deliberately weaker than `canonical_slug`. This one answers "is this the
same System I already have here", where the operator's own spelling is the
thing being compared; the canonical slug answers "is this the same AREA as
some other project's System", where spelling is exactly what must be
ignored.
"""
return " ".join(name.split()).lower()
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
"""What BOTH doors must know before minting a System name (milestone 307).
Lived in the MCP tool alone until now, which is how the web UI shipped
without a gate the agent surface enforced (#2482). One service function, so
the two doors cannot answer the same question differently (rule 33).
Returns `{"duplicate": …|None, "canonical": …|None}`:
- `duplicate` — this project already has a System by that name. A hard stop
for the caller: a second one splits the area's records across two piles.
- `canonical` — the global catalog covers this area, with a `basis`.
`exact` is mechanical and safe to apply on the spot; `overlap` is a
judgment call and must be OFFERED, never applied. Neither ever blocks:
an unmatched name is a project-specific area, which is legitimate.
Fails open on both arms — a naming aid must never break a create.
"""
out: dict = {"duplicate": None, "canonical": None}
key = local_name_key(name)
if not key:
return out
try:
for existing in await list_systems(user_id, project_id, include_archived=True):
if local_name_key(existing.name) == key:
out["duplicate"] = {"id": existing.id, "name": existing.name}
return out
except Exception:
logger.debug("system name assessment: local scan failed", exc_info=True)
return out
try:
exact = await canonical_systems_svc.find_by_name(name)
if exact is not None:
out["canonical"] = {
"id": exact.id, "name": exact.name, "basis": "exact",
}
return out
# No exact hit: fall back to the same overlap scoring the review
# surface uses, so a create-time offer and a later proposal never
# disagree about which area a name resembles.
near = await canonical_systems_svc.best_overlap(name)
if near is not None:
out["canonical"] = near
except Exception:
logger.debug("system name assessment: catalog lookup failed", exc_info=True)
return out
async def standard_systems() -> list[tuple[str, str]]:
"""The standard cross-project vocabulary (#2798) as (name, charter) pairs.
+3 -1
View File
@@ -132,7 +132,9 @@ def fake_milestone(**attrs) -> MagicMock:
def fake_system(**attrs) -> MagicMock:
return _with_defaults({"id": 1, "name": "Reader", "project_id": 5}, attrs)
return _with_defaults(
{"id": 1, "name": "Reader", "project_id": 5, "canonical_id": None}, attrs,
)
def fake_rulebook(**attrs) -> MagicMock:
+60
View File
@@ -5,26 +5,86 @@ import pytest
from tests.helpers import fake_note, fake_system
# The name assessment both doors run before minting (milestone 307). A test
# that patches systems_svc wholesale must stub it, or the awaited MagicMock
# raises — this shape is the "nothing matched" answer.
_NO_MATCH = {"duplicate": None, "canonical": None}
@pytest.mark.asyncio
async def test_create_system_returns_dict():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=fake_system(name="Reader"))
from scribe.mcp.tools.systems import create_system
result = await create_system(project_id=5, name="Reader", description="pdf reader")
assert result["name"] == "Reader"
# An unmatched name is a project-specific area: created, no offer, no fuss.
assert "canonical_suggestion" not in result and "canonical_note" not in result
@pytest.mark.asyncio
async def test_create_system_no_access_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=None)
from scribe.mcp.tools.systems import create_system
with pytest.raises(ValueError):
await create_system(project_id=5, name="Reader")
@pytest.mark.asyncio
async def test_create_system_applies_an_exact_area_and_offers_a_similar_one():
"""The two bases must behave differently, and this is where it is decided.
`exact` differs from the catalog name only in spelling, so it is APPLIED —
that is the mechanical case the catalog exists to collapse. `overlap` is a
judgment call, so it is only OFFERED: applying it silently is how a
cross-project rule ends up surfacing in the wrong project.
"""
from scribe.mcp.tools.systems import create_system
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": None,
"canonical": {"id": 3, "name": "CI & Release", "basis": "exact"},
})
svc.create_system = AsyncMock(return_value=fake_system(name="CI and Release"))
exact = await create_system(project_id=5, name="CI and Release")
assert svc.create_system.await_args.kwargs["canonical_id"] == 3
assert "canonical_note" in exact and "canonical_suggestion" not in exact
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": None,
"canonical": {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33},
})
svc.create_system = AsyncMock(return_value=fake_system(id=9, name="CI & runners"))
similar = await create_system(project_id=5, name="CI & runners")
assert svc.create_system.await_args.kwargs["canonical_id"] is None
assert similar["canonical_suggestion"]["id"] == 3
assert "map_system_to_canonical(9, 3)" in similar["canonical_suggestion"]["message"]
@pytest.mark.asyncio
async def test_create_system_duplicate_names_the_existing_one_and_creates_nothing():
from scribe.mcp.tools.systems import create_system
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value={
"duplicate": {"id": 4, "name": "Reader"}, "canonical": None,
})
svc.create_system = AsyncMock()
result = await create_system(project_id=5, name="reader")
assert result["duplicate"] is True and result["existing_id"] == 4
assert "Reader" in result["message"]
svc.create_system.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_system_splits_records_by_kind():
issue = MagicMock(); issue.to_dict.return_value = {"id": 10}; issue.task_kind = "issue"; issue.status = "todo"