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
+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 {