diff --git a/CHANGELOG.md b/CHANGELOG.md index 3894704..bcf3376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Simplify: aliases are labels (#44) + +- **One list.** Search already matched and ranked aliases exactly like labels, so "phished" or "suspicious email" is now simply a label. Migration `026_aliases_into_labels.sql` appends each item's aliases to its labels (lower case, no duplicates; the working row and both published copies, without counting as a new publish), then drops `items.aliases` and the `items_live.aliases` column. Nothing is cut: an item that ends up over the 20-label limit keeps every word, and its next edit asks for fewer. +- **`suggest_labels` keeps working** with `add_labels` / `remove_labels` (plus `reason`, `expected_revision`); `add_aliases` and `remove_aliases` are gone. Item output has no `aliases:` line, the edit form no Aliases field, the item page no "Also searched as" line, and Markdown export no Aliases line. + ### Simplify: one review date, no per-library freshness settings (#44) - **`review_by` is the only review date.** Policy items no longer have a **Review date** field (or its "on or after the effective date" check), so the Policy starter has 10 fields. Migration `025_single_review_date.sql` copies the published Review date into `review_by` where none was set, and removes `review_date` from Policy items (working row and both published copies, without counting as a new publish) and from Policy library schemas. "Still accurate" and the Policy page can no longer disagree. Agents that still send `review_date` get "Unknown field(s): review_date". diff --git a/README.md b/README.md index d46b7fa..57eb417 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ MCPEncode keeps the facts your IT team's AI agents rely on (help desk runbooks, - **Citations with version.** Every answer names the published version, date, publisher and link, so you can see where an answer came from. - **Freshness.** Policy and Procedure items get a review-by date 90 days after each publish; publishers can set one on any item. Agents relay a warning naming the owner when an item is past review. A **Review queue** lists what is due, with one-click **Still accurate**. -- **Aliases.** "We got phished" finds the Incident Response runbook. Agents can suggest labels and aliases with `suggest_labels`; a person approves them like any other edit. +- **Search words as labels.** Label the Incident Response runbook "phished" and "We got phished" finds it. Agents suggest labels with `suggest_labels`; a person approves them like any other edit. **Status:** Early (0.2). Issues welcome; please open one before a large pull request. @@ -45,14 +45,14 @@ The client opens a browser: sign in, then **Allow** on the consent page. That co ## Screenshots -All data below is fictional demo content (`npm run seed-demo`). Left to right: the Libraries home; a published security Policy; a Pending edit (agents keep the published version); approving hosted apps; the **Review queue** for help desk runbooks; an agent's **label and alias suggestion** waiting for review; a runbook with its **review-by date** and **aliases**; the consent page. +All data below is fictional demo content (`npm run seed-demo`). Left to right: the Libraries home; a published security Policy; a Pending edit (agents keep the published version); approving hosted apps; the **Review queue** for help desk runbooks; an agent's **label suggestion** waiting for review; a runbook with its **review-by date** and labels; the consent page. | | | | --- | --- | | Libraries list with demo Libraries for IT policies, help desk runbooks, customer support, a biology course, product docs, HR and sales | A published Policy Item with owner, version and effective date | | An Item with a Pending edit: banner says agents still see the published version, with a diff of what changed | Settings, Apps: one hosted app waiting for approval and one approved | -| Review queue for Help Desk Runbooks: Account Lockout overdue, Incident Response Runbook due next week, each with Still accurate | An agent's suggested labels and aliases for the Incident Response Runbook, Pending for a person to Publish or Cancel | -| VPN Troubleshooting runbook with its review-by date, Still accurate button and aliases (remote access, office network, work from home) | Allow access page asking whether an app may use this account for one Library | +| Review queue for Help Desk Runbooks: Account Lockout overdue, Incident Response Runbook due next week, each with Still accurate | An agent's suggested labels for the Incident Response Runbook, Pending for a person to Publish or Cancel | +| VPN Troubleshooting runbook with its review-by date, Still accurate button and search labels | Allow access page asking whether an app may use this account for one Library | ## Known limitations diff --git a/docs/concepts.md b/docs/concepts.md index 77f5823..72dbdc0 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -9,8 +9,8 @@ Use exactly these words in UI text, docs and code. Do not invent synonyms (vault | Word | Meaning | | --- | --- | | **Library** | A named set of items with one schema, one Connect link and one Access list. There are many libraries, not one global pile. | -| **Item** | One entry in a library: name, labels, aliases, summary, and a markdown body or template fields. | -| **Aliases** | Other words people use for an item ("phished", "suspicious email"). Search matches them like labels. Not shown as tags. | +| **Item** | One entry in a library: name, labels, summary, and a markdown body or template fields. | +| **Labels** | Free-form tags: categories ("security") and the other words people search with ("phished", "suspicious email"). | | **Publish** | A person approves an item. Readers and Reader keys only ever see published items. | | **Connect link** | A library's MCP address, with a secret token. One link reaches one library. The token is stored only as a SHA-256 hash, so the full link is shown once; to get a new one, rotate it. | | **Reader** / **Publisher** | Per-library roles, granted under **Access**. | @@ -55,7 +55,7 @@ Each Connect link is one library. Tools offered depend on the caller's role in t | `search_shelf` | Reader+ | Published summaries across the library's shelf | | `create_item` | Publisher | Save: Pending (Review Lock) or published (Review Unlock). With an `entity_key` that already exists in the library, updates that item instead (upsert). | | `update_item` | Publisher | Change an item. Fields you leave out keep their value; `replace: true` replaces the whole item (omitted optional fields are cleared). Unknown fields are refused. | -| `suggest_labels` | Publisher | Add or remove labels and aliases on a **published** item without touching its text: Pending for a person to approve (Review Lock) or live at once (Review Unlock) | +| `suggest_labels` | Publisher | Add or remove labels on a **published** item without touching its text: Pending for a person to approve (Review Lock) or live at once (Review Unlock) | | `delete_item` | Site admin | Hard-delete an item (Publishers archive instead) | | `create_library` | Site admin | Create a library, optionally with a proposed table | | `propose_table` / `propose_field` | Publisher | Design a library; a person approves the schema. After approval the tools pick up the new field on the next request; there is no need to reconnect. | @@ -71,17 +71,17 @@ Each Connect link is one library. Tools offered depend on the caller's role in t - **No match** from `list_items` (by `q` or `entity_key`) is a normal answer that says the item is not in the library, not a tool error. **How `list_items q=` searches.** -1. Every word must match (name, labels and aliases, summary, then body; the full-text index applies English stemming). Aliases score exactly like labels. +1. Every word must match (name, labels, summary, then body; the full-text index applies English stemming). 2. If nothing matches every word, any word may match. A clear winner that matched on its name or labels, and on most of the words, is returned as the one answer. Weaker candidates come back as **"Did you mean"** suggestions, which the agent must open and check before citing. -3. Still nothing: words that appear nowhere in the library's names, summaries, labels or aliases are corrected to the closest library word (1–2 typos), and steps 1–2 run again. +3. Still nothing: words that appear nowhere in the library's names, summaries or labels are corrected to the closest library word (1–2 typos), and steps 1–2 run again. -A word that is only a label several items share (for example `runbook`) returns those items as a list, not one of them as "the" answer. Questions with no word in common with the library are still refused. `search_shelf` falls back from the exact phrase to "every word somewhere in name, summary, labels or aliases". +A word that is only a label several items share (for example `runbook`) returns those items as a list, not one of them as "the" answer. Questions with no word in common with the library are still refused. `search_shelf` falls back from the exact phrase to "every word somewhere in name, summary or labels". -**Aliases and label suggestions.** Each item has an editable **Aliases** field (comma-separated, lower case, up to 20 of 40 characters). Aliases are part of the published copy, so changing them is an ordinary edit: under Review Lock it goes Pending and Readers keep searching the published aliases until Publish. Agents fix search misses with `suggest_labels` (`add_labels`, `remove_labels`, `add_aliases`, `remove_aliases`, optional `reason` and `expected_revision`): +**Label suggestions.** Labels double as search words: label an incident runbook "phished" and "we think we got phished" finds it. Agents fix search misses with `suggest_labels` (`add_labels`, `remove_labels`, optional `reason` and `expected_revision`): - It only works on published items (including ones with a Pending edit, which it joins). Name, summary and body are never changed; use `update_item` for drafts. -- **Review Lock:** the item goes Pending. "What changed" lists **Labels** and **Aliases** before and after. A person approves with **Publish** or rejects with **Cancel**, which restores the published labels and aliases. +- **Review Lock:** the item goes Pending. "What changed" lists **Labels** before and after. A person approves with **Publish** or rejects with **Cancel**, which restores the published labels. - **Review Unlock:** the change is live at once. -- Readers and Reader keys do not get the tool. Every call is audit-logged as `suggest_labels` (item id, resulting labels and aliases, reason). Approve and reject are logged by the existing publish and cancel events. +- Readers and Reader keys do not get the tool. Every call is audit-logged as `suggest_labels` (item id, resulting labels, reason). Approve and reject are logged by the existing publish and cancel events. - Citations follow the published copy: the version moves only when a suggestion is published (or saved under Review Unlock). **Citations.** Every published item that `list_items`, `open_item` or `search_shelf` returns carries a citation for its **published** copy (never a Pending draft). A full item ends with: diff --git a/migrations/026_aliases_into_labels.sql b/migrations/026_aliases_into_labels.sql new file mode 100644 index 0000000..1ae0ce5 --- /dev/null +++ b/migrations/026_aliases_into_labels.sql @@ -0,0 +1,94 @@ +-- Aliases fold into labels. Search already matched and ranked aliases exactly +-- like labels, and both went through Review Lock the same way, so one list +-- does the job: "phished" or "suspicious email" is simply a label. +-- Each item keeps its labels first, then any alias it did not already have +-- (lower case, trimmed, first-seen order). Nothing is dropped: an item that +-- ends up over the 20-label limit keeps every word, and the next edit asks +-- for fewer. The published copies get the same merge without counting as a +-- new publish. + +CREATE OR REPLACE FUNCTION mcpencode_merge_labels(labels JSONB, aliases JSONB) RETURNS JSONB +LANGUAGE sql IMMUTABLE AS $$ + SELECT COALESCE(jsonb_agg(label ORDER BY first_pos), '[]'::jsonb) + FROM ( + SELECT label, MIN(pos) AS first_pos + FROM ( + SELECT lower(btrim(value)) AS label, pos + FROM jsonb_array_elements_text( + (CASE WHEN jsonb_typeof(labels) = 'array' THEN labels ELSE '[]'::jsonb END) || + (CASE WHEN jsonb_typeof(aliases) = 'array' THEN aliases ELSE '[]'::jsonb END) + ) WITH ORDINALITY AS t(value, pos) + ) cleaned + WHERE label <> '' + GROUP BY label + ) merged +$$; + +UPDATE items SET labels = mcpencode_merge_labels(labels, aliases) +WHERE jsonb_typeof(aliases) = 'array' AND jsonb_array_length(aliases) > 0; + +ALTER TABLE items DISABLE TRIGGER items_published_version; +ALTER TABLE items DISABLE TRIGGER items_review_by; + +UPDATE items +SET published_snapshot = ( + jsonb_set(published_snapshot::jsonb, '{labels}', + mcpencode_merge_labels(published_snapshot::jsonb -> 'labels', published_snapshot::jsonb -> 'aliases')) - 'aliases' +)::text +WHERE published_snapshot IS NOT NULL + AND pg_input_is_valid(published_snapshot, 'jsonb') + AND jsonb_typeof(published_snapshot::jsonb) = 'object' + AND published_snapshot::jsonb -> 'aliases' IS NOT NULL; + +UPDATE items +SET previous_published_snapshot = ( + jsonb_set(previous_published_snapshot::jsonb, '{labels}', + mcpencode_merge_labels(previous_published_snapshot::jsonb -> 'labels', previous_published_snapshot::jsonb -> 'aliases')) - 'aliases' +)::text +WHERE previous_published_snapshot IS NOT NULL + AND pg_input_is_valid(previous_published_snapshot, 'jsonb') + AND jsonb_typeof(previous_published_snapshot::jsonb) = 'object' + AND previous_published_snapshot::jsonb -> 'aliases' IS NOT NULL; + +ALTER TABLE items ENABLE TRIGGER items_published_version; +ALTER TABLE items ENABLE TRIGGER items_review_by; + +DROP FUNCTION mcpencode_merge_labels(JSONB, JSONB); + +-- The view loses its aliases column (CREATE OR REPLACE cannot drop one). +DROP VIEW IF EXISTS items_live; +CREATE VIEW items_live AS +SELECT + i.id, + i.library_id, + i.entity_key, + CASE WHEN i.status = 'draft' AND i.published_live + THEN (i.published_snapshot::jsonb ->> 'name') ELSE i.name END AS name, + CASE WHEN i.status = 'draft' AND i.published_live + THEN COALESCE(i.published_snapshot::jsonb -> 'labels', '[]'::jsonb) ELSE i.labels END AS labels, + CASE WHEN i.status = 'draft' AND i.published_live + THEN (i.published_snapshot::jsonb ->> 'summary') ELSE i.summary END AS summary, + CASE WHEN i.status = 'draft' AND i.published_live + THEN (i.published_snapshot::jsonb ->> 'body') ELSE i.body END AS body, + CASE WHEN i.status = 'draft' AND i.published_live + THEN (i.published_snapshot::jsonb ->> 'details') ELSE i.details END AS details, + CASE WHEN i.status = 'draft' AND i.published_live + THEN 'published' ELSE i.status END AS status, + i.published_snapshot, + i.previous_published_snapshot, + (i.status = 'draft' AND i.published_live) AS has_pending_changes, + i.created_by, + i.updated_by, + i.created_at, + CASE WHEN i.status = 'draft' AND i.published_live + THEN COALESCE((i.published_snapshot::jsonb ->> 'published_at')::numeric::bigint, i.created_at) + ELSE i.updated_at END AS updated_at, + CASE WHEN i.status = 'draft' AND i.published_live + THEN + setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'name', '')), 'A') || + setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'summary', '')), 'B') || + setweight(to_tsvector('english', coalesce(i.published_snapshot::jsonb ->> 'body', '')), 'C') + ELSE i.search_tsv END AS search_tsv +FROM items i; + +ALTER TABLE items DROP COLUMN IF EXISTS aliases; diff --git a/src/db/items.ts b/src/db/items.ts index 560bae4..3afed3d 100644 --- a/src/db/items.ts +++ b/src/db/items.ts @@ -50,7 +50,6 @@ type ListHit = { entity_key: string | null; labels: string | string[]; /** Present only on a Pending review list. */ - aliases?: string | string[] | null; summary: string; status: string; updated_at: number; @@ -90,7 +89,6 @@ function toSummary( { name: row.name, labels, - aliases: parseLabels(row.aliases ?? []), summary: row.summary, body: row.body ?? "", details: typeof row.details === "string" ? row.details : null, @@ -158,7 +156,7 @@ export async function listItems( if (needle) { const pattern = `%${escapeLike(needle)}%`; where.push( - "(name ILIKE ? ESCAPE '\\' OR summary ILIKE ? ESCAPE '\\' OR (labels::text || ' ' || aliases::text) ILIKE ? ESCAPE '\\')", + "(name ILIKE ? ESCAPE '\\' OR summary ILIKE ? ESCAPE '\\' OR labels::text ILIKE ? ESCAPE '\\')", ); params.push(pattern, pattern, pattern); } @@ -172,7 +170,7 @@ export async function listItems( const base = "id, library_id, name, entity_key, labels, summary, status, updated_at"; const columns = (options.review - ? `${base}, aliases, body, details, published_snapshot` + ? `${base}, body, details, published_snapshot` : options.includeDetails ? `${base}, details` : base) + pendingColumn; @@ -255,8 +253,6 @@ export async function getLibraryItem( } type ItemSave = ItemInput & { - /** Search aliases. Omitted on update keeps the stored aliases. */ - aliases?: string[]; summary?: string; details?: string | null; entity_key?: string | null; @@ -282,7 +278,6 @@ export async function addDraftItem( libraryId: string, input: ItemWriteInput, actorSub: string, - extra: { aliases?: string[] } = {}, ): Promise<{ ok: true; item: ItemRow } | { ok: false; message: string }> { try { return await db.transaction(async (tx) => { @@ -291,8 +286,7 @@ export async function addDraftItem( const saved = itemForLibrary(library.schema, input); if (!saved.ok) return saved; const status = library.writeMode === "direct" ? "published" : "draft"; - const toSave = extra.aliases ? { ...saved.item, aliases: extra.aliases } : saved.item; - const item = await createItem(tx, toSave, actorSub, libraryId, { status }); + const item = await createItem(tx, saved.item, actorSub, libraryId, { status }); return { ok: true, item }; }); } catch (error) { @@ -328,7 +322,6 @@ export async function createItem( ? snapshotFromItem({ name: input.name, labels: input.labels, - aliases: input.aliases ?? [], summary, body: input.body, details: input.details ?? null, @@ -338,15 +331,14 @@ export async function createItem( await runSql( db, `INSERT INTO items ( - id, library_id, name, entity_key, labels, aliases, summary, body, details, status, published_snapshot, + id, library_id, name, entity_key, labels, summary, body, details, status, published_snapshot, created_by, updated_by, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?::jsonb, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, library_id, input.name, keyed.key, JSON.stringify(input.labels), - JSON.stringify(input.aliases ?? []), summary, input.body, input.details ?? null, @@ -429,7 +421,6 @@ export async function updateItem( ? { ok: true as const, key: existing.entity_key } : entityKeyForSave(input.entity_key); if (!keyed.ok) throw new Error(keyed.message); - const aliases = input.aliases ?? existing.aliases ?? []; if (writeMode === "direct") { // Review Unlock: any successful save is live. Snapshot tracks the live copy. @@ -438,7 +429,6 @@ export async function updateItem( const nextFields = { name: input.name, labels: input.labels, - aliases, summary, body: input.body, details: input.details ?? null, @@ -454,7 +444,7 @@ export async function updateItem( changed = await allRows<{ id: string }>( db, `UPDATE items - SET name = ?, entity_key = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?, + SET name = ?, entity_key = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?, status = 'published', published_snapshot = ?, previous_published_snapshot = ?, published_live = false, updated_by = ?, updated_at = ? WHERE id = ? AND library_id = ?${guard} @@ -462,7 +452,6 @@ export async function updateItem( input.name, keyed.key, JSON.stringify(input.labels), - JSON.stringify(aliases), summary, input.body, input.details ?? null, @@ -504,7 +493,7 @@ export async function updateItem( changed = await allRows<{ id: string }>( db, `UPDATE items - SET name = ?, entity_key = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?, + SET name = ?, entity_key = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?, status = CASE WHEN status = 'published' THEN 'draft' ELSE status END, published_live = CASE WHEN status = 'published' THEN ? ELSE published_live END, updated_by = ?, updated_at = ? @@ -513,7 +502,6 @@ export async function updateItem( input.name, keyed.key, JSON.stringify(input.labels), - JSON.stringify(aliases), summary, input.body, input.details ?? null, @@ -590,7 +578,6 @@ export async function publishItem( const nextFields = { name: existing.name, labels: existing.labels, - aliases: existing.aliases ?? [], summary: existing.summary, body: existing.body, details: existing.details, @@ -661,12 +648,11 @@ export async function cancelPublishItem( await runSql( tx, `UPDATE items - SET name = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?, + SET name = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?, status = 'published', published_live = false, updated_by = ?, updated_at = ? WHERE id = ? AND status = 'draft'`, snapshot.name, JSON.stringify(snapshot.labels), - JSON.stringify(snapshot.aliases ?? []), snapshot.summary, snapshot.body, snapshot.details, @@ -872,12 +858,11 @@ export async function revertToPublished( await runSql( tx, `UPDATE items - SET name = ?, labels = ?::jsonb, aliases = ?::jsonb, summary = ?, body = ?, details = ?, + SET name = ?, labels = ?::jsonb, summary = ?, body = ?, details = ?, status = 'draft', published_live = ?, updated_by = ?, updated_at = ? WHERE id = ?`, snapshot.name, JSON.stringify(snapshot.labels), - JSON.stringify(snapshot.aliases ?? []), snapshot.summary, snapshot.body, snapshot.details, diff --git a/src/db/label-suggestions.ts b/src/db/label-suggestions.ts index 72bb255..7122bf5 100644 --- a/src/db/label-suggestions.ts +++ b/src/db/label-suggestions.ts @@ -1,11 +1,12 @@ /** - * Agents recommend labels and aliases for a published item without touching - * its text. The suggestion is an ordinary edit of the working row that changes - * only labels and aliases, so it follows the library's write mode exactly like - * update_item: + * Agents recommend labels for a published item without touching its text: + * categories ("security") and the other words people search with ("phished", + * "suspicious email") are both labels. The suggestion is an ordinary edit of + * the working row that changes only labels, so it follows the library's write + * mode exactly like update_item: * - Review Lock: the item goes Pending, the published copy stays live for * Readers, and a person approves (Publish) or rejects (Cancel) it on the - * item page, where "What changed" lists the Labels / Aliases change. + * item page, where "What changed" lists the Labels change. * - Review Unlock: the change is live at once (a new published version). * Citations follow the published copy, so the version only moves on Publish * (or an Unlock save), as with any other edit. @@ -19,12 +20,10 @@ import { labelsEqual } from "./query-helpers.ts"; export type LabelSuggestionInput = { add_labels?: string[]; remove_labels?: string[]; - add_aliases?: string[]; - remove_aliases?: string[]; }; export type LabelSuggestionResult = - | { ok: true; item: ItemRow; changed: true; labels: string[]; aliases: string[] } + | { ok: true; item: ItemRow; changed: true; labels: string[] } | { ok: true; item: ItemRow; changed: false } | { ok: false; reason: "not_found" | "not_published" | "empty"; message: string }; @@ -33,21 +32,11 @@ function normalize(values: string[]): string[] { return parseLabelField(values.map((value) => value.replace(/,/g, " ")).join(",")); } -function applyChange(current: string[], add: string[] | undefined, remove: string[] | undefined): string[] { - const drop = new Set(normalize(remove ?? [])); - const kept = current.filter((value) => !drop.has(value.trim().toLowerCase())); - return normalize([...kept, ...normalize(add ?? [])]); -} - -/** Labels and aliases after the suggestion, without saving. */ -export function plannedLabels( - item: Pick, - input: LabelSuggestionInput, -): { labels: string[]; aliases: string[] } { - return { - labels: applyChange(item.labels, input.add_labels, input.remove_labels), - aliases: applyChange(item.aliases ?? [], input.add_aliases, input.remove_aliases), - }; +/** Labels after the suggestion, without saving. */ +export function plannedLabels(item: Pick, input: LabelSuggestionInput): string[] { + const drop = new Set(normalize(input.remove_labels ?? [])); + const kept = item.labels.filter((value) => !drop.has(value.trim().toLowerCase())); + return normalize([...kept, ...normalize(input.add_labels ?? [])]); } export async function suggestLabels( @@ -58,11 +47,8 @@ export async function suggestLabels( actorSub: string, options: { expectedRevision?: number } = {}, ): Promise { - const empty = [input.add_labels, input.remove_labels, input.add_aliases, input.remove_aliases].every( - (list) => !list || list.length === 0, - ); - if (empty) { - return { ok: false, reason: "empty", message: "Send at least one of add_labels, remove_labels, add_aliases, remove_aliases." }; + if (!input.add_labels?.length && !input.remove_labels?.length) { + return { ok: false, reason: "empty", message: "Send add_labels, remove_labels or both." }; } const existing = await getLibraryItem(db, libraryId, id, { publishedOnly: false }); if (!existing) return { ok: false, reason: "not_found", message: `No item with id ${id}` }; @@ -76,7 +62,7 @@ export async function suggestLabels( }; } const next = plannedLabels(existing, input); - if (labelsEqual(next.labels, existing.labels) && labelsEqual(next.aliases, existing.aliases ?? [])) { + if (labelsEqual(next, existing.labels)) { return { ok: true, item: existing, changed: false }; } const row = await updateItem( @@ -84,8 +70,7 @@ export async function suggestLabels( id, { name: existing.name, - labels: next.labels, - aliases: next.aliases, + labels: next, summary: existing.summary, body: existing.body, details: existing.details, @@ -94,5 +79,5 @@ export async function suggestLabels( { libraryId, expectedRevision: options.expectedRevision }, ); if (!row) return { ok: false, reason: "not_found", message: `No item with id ${id}` }; - return { ok: true, item: row, changed: true, labels: next.labels, aliases: next.aliases }; + return { ok: true, item: row, changed: true, labels: next }; } diff --git a/src/db/lookup.ts b/src/db/lookup.ts index fa93fc7..530d7d8 100644 --- a/src/db/lookup.ts +++ b/src/db/lookup.ts @@ -69,14 +69,13 @@ type Scored = { }; function scoreDetail( - row: { name: string; summary: string; body: string; labels?: string | string[]; aliases?: string | string[] | null }, + row: { name: string; summary: string; body: string; labels?: string | string[] }, tokens: string[], ): Scored { const name = row.name.toLowerCase(); const summary = row.summary.toLowerCase(); const body = row.body.toLowerCase(); - // Aliases are other words for the item; they count exactly like labels. - const labels = [...parseLabels(row.labels ?? []), ...parseLabels(row.aliases ?? [])].join(" ").toLowerCase(); + const labels = parseLabels(row.labels ?? []).join(" ").toLowerCase(); let score = 0; let nameHits = 0; let labelHits = 0; @@ -104,7 +103,7 @@ function scoreDetail( } function scoreItem( - row: { name: string; summary: string; body: string; labels?: string | string[]; aliases?: string | string[] | null }, + row: { name: string; summary: string; body: string; labels?: string | string[] }, tokens: string[], ): number { return scoreDetail(row, tokens).score; @@ -130,7 +129,6 @@ type LookupHit = { name: string; entity_key: string | null; labels: string | string[]; - aliases: string | string[] | null; summary: string; body: string; status: string; @@ -198,7 +196,7 @@ async function findNameSummaryHits( // is left empty here, so this pass stays name/summary-focused. const rows = await allRows & { relevance: number }>( db, - `SELECT id, library_id, name, entity_key, labels, aliases, summary, status, updated_at, + `SELECT id, library_id, name, entity_key, labels, summary, status, updated_at, ts_rank(search_tsv, plainto_tsquery('english', ?)) AS relevance FROM ${itemSource(scope)} WHERE ${libraryWhere(scope)} @@ -218,7 +216,7 @@ async function findNameSummaryHits( const pattern = `(^|[^a-z0-9])(${safeShort.join("|")})([^a-z0-9]|$)`; const rows = await allRows>( db, - `SELECT id, library_id, name, entity_key, labels, aliases, summary, status, updated_at + `SELECT id, library_id, name, entity_key, labels, summary, status, updated_at FROM ${itemSource(scope)} WHERE ${libraryWhere(scope)} AND (LOWER(name) ~ ? OR LOWER(summary) ~ ?) @@ -265,7 +263,7 @@ async function loadLookupHits( const placeholders = ids.map(() => "?").join(", "); return allRows( db, - `SELECT id, library_id, name, entity_key, labels, aliases, summary, body, status, updated_at + `SELECT id, library_id, name, entity_key, labels, summary, body, status, updated_at FROM ${itemSource(scope)} WHERE ${libraryWhere(scope)} AND id IN (${placeholders})`, @@ -274,7 +272,7 @@ async function loadLookupHits( ); } -/** Library item rows whose labels or aliases contain every token (all = true) or any token. */ +/** Library item rows whose labels contain every token (all = true) or any token. */ async function findLabelHits( db: Database, libraryId: string, @@ -290,11 +288,11 @@ async function findLabelHits( ? safe.map((token) => `(^|[^a-z0-9])${token}([^a-z0-9]|$)`) : [`(^|[^a-z0-9])(${safe.join("|")})([^a-z0-9]|$)`]; const where = patterns - .map(() => "LOWER(labels::text || ' ' || COALESCE(aliases, '[]'::jsonb)::text) ~ ?") + .map(() => "LOWER(labels::text) ~ ?") .join(all ? " AND " : " OR "); const rows = await allRows>( db, - `SELECT id, library_id, name, entity_key, labels, aliases, summary, status, updated_at + `SELECT id, library_id, name, entity_key, labels, summary, status, updated_at FROM ${itemSource(scope)} WHERE ${libraryWhere(scope)} AND (${where}) @@ -352,7 +350,7 @@ export function editDistance(a: string, b: string, cap = 3): number { return (rows[a.length] as number[])[b.length] as number; } -/** Words allowed for spelling correction: names, summaries, labels and aliases of this library. */ +/** Words allowed for spelling correction: names, summaries and labels of this library. */ const VOCABULARY_ROW_LIMIT = 2000; async function libraryVocabulary(db: Database, libraryId: string, scope: ReadScope): Promise> { @@ -360,10 +358,9 @@ async function libraryVocabulary(db: Database, libraryId: string, scope: ReadSco name: string; summary: string; labels: string | string[]; - aliases: string | string[] | null; - }>( + }>( db, - `SELECT name, summary, labels, aliases + `SELECT name, summary, labels FROM ${itemSource(scope)} WHERE ${libraryWhere(scope)} ORDER BY updated_at DESC @@ -372,7 +369,7 @@ async function libraryVocabulary(db: Database, libraryId: string, scope: ReadSco ); const vocabulary = new Set(); for (const row of rows) { - const text = `${row.name} ${row.summary} ${parseLabels(row.labels).join(" ")} ${parseLabels(row.aliases ?? []).join(" ")}`.toLowerCase(); + const text = `${row.name} ${row.summary} ${parseLabels(row.labels).join(" ")}`.toLowerCase(); for (const word of words(text)) if (word.length >= 3) vocabulary.add(word); } return vocabulary; diff --git a/src/db/models.ts b/src/db/models.ts index cb3098a..1809c18 100644 --- a/src/db/models.ts +++ b/src/db/models.ts @@ -54,8 +54,6 @@ export type ItemStatus = "draft" | "published" | "archived"; export type PublishedSnapshot = { name: string; labels: string[]; - /** Other words for this item that search matches like labels. [] in snapshots from before aliases. */ - aliases?: string[]; summary: string; body: string; details: string | null; @@ -71,7 +69,6 @@ export type ItemRow = { entity_key: string | null; labels: string[]; /** Other words people use for this item (e.g. "phished"). Search matches them like labels. */ - aliases?: string[]; summary: string; body: string; status: ItemStatus; diff --git a/src/db/query-helpers.ts b/src/db/query-helpers.ts index e84310b..839765b 100644 --- a/src/db/query-helpers.ts +++ b/src/db/query-helpers.ts @@ -11,7 +11,6 @@ export type SqlItem = { name: string; entity_key: string | null; labels: string | string[]; - aliases?: string | string[] | null; summary: string; body: string; details: string | null; @@ -33,11 +32,11 @@ export type SqlItem = { * pull the full-text document blob over the wire. */ export const ITEM_COLUMNS = - "id, library_id, name, entity_key, labels, aliases, summary, body, details, status, published_snapshot, previous_published_snapshot, published_live, created_by, updated_by, created_at, updated_at"; + "id, library_id, name, entity_key, labels, summary, body, details, status, published_snapshot, previous_published_snapshot, published_live, created_by, updated_by, created_at, updated_at"; /** Same shape from the items_live view (what Readers and agents see). */ export const LIVE_ITEM_COLUMNS = - "id, library_id, name, entity_key, labels, aliases, summary, body, details, status, published_snapshot, previous_published_snapshot, has_pending_changes, created_by, updated_by, created_at, updated_at"; + "id, library_id, name, entity_key, labels, summary, body, details, status, published_snapshot, previous_published_snapshot, has_pending_changes, created_by, updated_by, created_at, updated_at"; /** * Readers always read items_live: a Pending edit on a live item shows its @@ -93,7 +92,6 @@ export function itemDiffersFromSnapshot( item: { name: string; labels: string[]; - aliases?: string[]; summary: string; body: string; details: string | null; @@ -103,7 +101,6 @@ export function itemDiffersFromSnapshot( return ( item.name !== snapshot.name || !labelsEqual(item.labels, snapshot.labels) || - !labelsEqual(item.aliases ?? [], snapshot.aliases ?? []) || item.summary !== snapshot.summary || item.body !== snapshot.body || (item.details ?? "") !== (snapshot.details ?? "") @@ -128,7 +125,6 @@ export function revertPlan(item: { previous_published_snapshot: PublishedSnapshot | null; name: string; labels: string[]; - aliases?: string[]; summary: string; body: string; details: string | null; @@ -156,7 +152,6 @@ export function canRevertToPublished(item: { previous_published_snapshot: PublishedSnapshot | null; name: string; labels: string[]; - aliases?: string[]; summary: string; body: string; details: string | null; @@ -173,7 +168,6 @@ export function previousSnapshotAfterPublish( next: { name: string; labels: string[]; - aliases?: string[]; summary: string; body: string; details: string | null; @@ -198,9 +192,6 @@ export function parsePublishedSnapshot(raw: string | null | undefined): Publishe labels: Array.isArray(row.labels) ? row.labels.filter((entry): entry is string => typeof entry === "string") : [], - aliases: Array.isArray(row.aliases) - ? row.aliases.filter((entry): entry is string => typeof entry === "string") - : [], summary: row.summary, body: row.body, details: typeof row.details === "string" ? row.details : null, @@ -214,7 +205,6 @@ export function parsePublishedSnapshot(raw: string | null | undefined): Publishe export function snapshotFromItem(item: { name: string; labels: string[]; - aliases?: string[]; summary: string; body: string; details: string | null; @@ -223,7 +213,6 @@ export function snapshotFromItem(item: { const snapshot: PublishedSnapshot = { name: item.name, labels: item.labels, - aliases: item.aliases ?? [], summary: item.summary, body: item.body, details: item.details, @@ -240,7 +229,6 @@ export function toItem(row: SqlItem): ItemRow { name: row.name, entity_key: typeof row.entity_key === "string" && row.entity_key ? row.entity_key : null, labels: parseLabels(row.labels), - aliases: parseLabels(row.aliases ?? []), summary: row.summary, body: row.body, status: asStatus(row.status), diff --git a/src/db/shelves.ts b/src/db/shelves.ts index afa03f6..c6252c6 100644 --- a/src/db/shelves.ts +++ b/src/db/shelves.ts @@ -176,7 +176,7 @@ export async function searchShelf( WHERE l.shelf_id = ? AND i.status = 'published' AND l.id IN (${placeholders}) - AND (i.name ILIKE ? ESCAPE '\\' OR i.summary ILIKE ? ESCAPE '\\' OR (i.labels::text || ' ' || i.aliases::text) ILIKE ? ESCAPE '\\') + AND (i.name ILIKE ? ESCAPE '\\' OR i.summary ILIKE ? ESCAPE '\\' OR i.labels::text ILIKE ? ESCAPE '\\') ORDER BY LOWER(l.name) ASC, LOWER(i.name) ASC, i.name ASC LIMIT ${SHELF_HITS_MAX}`, shelfId, @@ -193,7 +193,7 @@ export async function searchShelf( ); if (tokens.length >= 2) { const clauses = tokens - .map(() => "(i.name ILIKE ? ESCAPE '\\' OR i.summary ILIKE ? ESCAPE '\\' OR (i.labels::text || ' ' || i.aliases::text) ILIKE ? ESCAPE '\\')") + .map(() => "(i.name ILIKE ? ESCAPE '\\' OR i.summary ILIKE ? ESCAPE '\\' OR i.labels::text ILIKE ? ESCAPE '\\')") .join(" AND "); const params = tokens.flatMap((token) => { const like = `%${escapeLike(token)}%`; diff --git a/src/http/helpers.ts b/src/http/helpers.ts index 947ee9d..9c92671 100644 --- a/src/http/helpers.ts +++ b/src/http/helpers.ts @@ -108,12 +108,6 @@ export function csrfHeader(req: Request): string { return typeof value === "string" ? value : ""; } -/** Aliases from a form, or undefined when the form has no aliases field (keep stored). */ -export function formAliases(body: unknown): string[] | undefined { - if (!body || typeof body !== "object" || !("aliases" in body)) return undefined; - return parseLabelField(formString(body, "aliases")); -} - export function requireCsrf(req: Request, csrf: string): boolean { const provided = formString(req.body, "csrf") || csrfHeader(req); return tokensMatch(provided, csrf); diff --git a/src/http/pages/forms.ts b/src/http/pages/forms.ts index 2eab9fb..96f4777 100644 --- a/src/http/pages/forms.ts +++ b/src/http/pages/forms.ts @@ -63,19 +63,6 @@ export function captureEditor(body: string, summaryRequired: boolean): string { return `${summaryField(split.summary, summaryRequired)}${descriptionMarkup(split.description)}${tableMarkup(split.table ?? EMPTY_TABLE)}`; } -/** - * Other words people use for this item. Search matches them like labels, so - * "we got phished" finds an item named Incident Response. - */ -export function aliasesField(value: string): string { - return ` - - `; -} - export function itemFields(input: { name: string; labels: string; diff --git a/src/http/pages/items.ts b/src/http/pages/items.ts index 59cac45..5d17e2c 100644 --- a/src/http/pages/items.ts +++ b/src/http/pages/items.ts @@ -6,7 +6,7 @@ import { canRevertToPublished, revertPlan } from "../../db/queries.ts"; import { fileKindLabel, formatFileSize, MAX_FILES_PER_ITEM } from "../../items/files.ts"; import { isPlainGeneral } from "../../items/library-schema.ts"; import { valuesForSchemaEdit } from "../../items/table-proposal.ts"; -import { aliasesField, itemFields, schemaItemFields } from "./forms.ts"; +import { itemFields, schemaItemFields } from "./forms.ts"; import { writeModeLabel } from "./schema-choice.ts"; import { actionsMenuSummary, @@ -120,7 +120,6 @@ export function addItemPage(input: { values: {}, }) : itemFields({ name: "", labels: "", body: "", summaryRequired: true }); - const fieldsWithAliases = `${fields}${aliasesField("")}`; const liveHint = input.library.writeMode === "direct" ? `This library is ${writeModeLabel("direct")} — saving makes the item live immediately.` @@ -149,7 +148,7 @@ export function addItemPage(input: { ${input.error ? `` : ""}
- ${fieldsWithAliases} + ${fields}
@@ -249,7 +248,6 @@ export function itemPage(input: { `; const labels = input.item.labels.map((label) => `${esc(label)}`).join(""); - const aliases = (input.item.aliases ?? []).map((alias) => esc(alias)).join(", "); const flash = noticeMessage(input.notice, status); const review = itemReviewBlock(input.item, input.library.schema); const unlockHint = @@ -317,7 +315,6 @@ export function itemPage(input: { : "" } ${labels ? `

${labels}

` : ""} - ${aliases ? `

Also searched as: ${aliases}

` : ""} ${input.freshness ?? ""} ${review} @@ -390,7 +387,6 @@ export function editPage(input: { summaryRequired: false, }) } - ${aliasesField((input.item.aliases ?? []).join(", "))} diff --git a/src/http/routes/items.ts b/src/http/routes/items.ts index eb72fdb..8cc94b0 100644 --- a/src/http/routes/items.ts +++ b/src/http/routes/items.ts @@ -34,7 +34,7 @@ import { import { itemToMarkdown, markdownExportFilename } from "../../items/export-markdown.ts"; import { parseTableValues, tableControl, tableParts } from "../../items/table-proposal.ts"; import { renderMarkdown } from "../../markdown/render.ts"; -import { formAliases, parseLibraryItem, requireCsrf, sessionAccess, tokensMatch } from "../helpers.ts"; +import { parseLibraryItem, requireCsrf, sessionAccess, tokensMatch } from "../helpers.ts"; import { editPage, errorPage, itemPage } from "../pages.ts"; import { readItemUpload } from "../upload.ts"; import { itemFreshness } from "../../db/freshness.ts"; @@ -362,9 +362,7 @@ export function registerItemRoutes(app: express.Express, ctx: AppRouteContext): const wasPublished = item.status === "published"; let row; try { - const aliases = formAliases(req.body); - const input = aliases === undefined ? parsed.data : { ...parsed.data, aliases }; - row = await updateItem(db, req.params.id, input, access.sub, { + row = await updateItem(db, req.params.id, parsed.data, access.sub, { expectedRevision: formRevision(req.body), }); } catch (error) { diff --git a/src/http/routes/libraries.ts b/src/http/routes/libraries.ts index 81945a1..ea7a24b 100644 --- a/src/http/routes/libraries.ts +++ b/src/http/routes/libraries.ts @@ -41,7 +41,6 @@ import { libraryInputSchema } from "../../items/schema.ts"; import { writeModeLabel } from "../pages/schema-choice.ts"; import { formString, - formAliases, formToItemWrite, postedCustomFields, libraryTemplateFromForm, @@ -1234,7 +1233,6 @@ export function registerLibraryRoutes(app: express.Express, ctx: AppRouteContext ctxLib.library.id, formToItemWrite(req.body, ctxLib.library.schema), access.sub, - { aliases: formAliases(req.body) }, ); if (!result.ok) { const shell = await publisherShell(db, access); diff --git a/src/items/export-markdown.ts b/src/items/export-markdown.ts index 115724e..efb356d 100644 --- a/src/items/export-markdown.ts +++ b/src/items/export-markdown.ts @@ -62,12 +62,6 @@ export function itemToMarkdown(item: ItemRow, library: LibraryRow): string { if (labels.length > 0) { chunks.push(`Labels: ${labels.join(", ")}`, ""); } - const aliases = Array.isArray(item.aliases) - ? item.aliases.filter((alias): alias is string => typeof alias === "string" && alias.trim() !== "") - : []; - if (aliases.length > 0) { - chunks.push(`Aliases: ${aliases.join(", ")}`, ""); - } const starter = library.schema?.starter ?? "general"; const fields = Array.isArray(library.schema?.fields) ? library.schema.fields : []; diff --git a/src/items/review.ts b/src/items/review.ts index b43f8f1..db48fc0 100644 --- a/src/items/review.ts +++ b/src/items/review.ts @@ -24,7 +24,6 @@ export type ItemReview = export type ReviewSource = { name: string; labels: string[]; - aliases?: string[]; summary: string; body: string; details: string | null; @@ -124,7 +123,6 @@ export function itemReview( const fields: ReviewField[] = []; pushChange(fields, "Name", snapshot.name, current.name, false); pushChange(fields, "Labels", labelsText(snapshot.labels), labelsText(current.labels), false); - pushChange(fields, "Aliases", labelsText(snapshot.aliases ?? []), labelsText(current.aliases ?? []), false); pushChange(fields, "Summary", snapshot.summary, current.summary, false); const structured = structuredChanges(schema, snapshot.details, current.details); diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 35b12fe..7f1ae84 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -62,8 +62,6 @@ export function labeledItemData(item: { entity_key?: string | null; name: string; labels: string[]; - /** Search aliases. Printed only when the item has some. */ - aliases?: string[]; summary?: string; body: string; files?: { name: string; content_type: string; byte_size: number }[]; @@ -95,7 +93,6 @@ export function labeledItemData(item: { item.revision === undefined ? "" : `revision: ${item.revision}`, `name: ${item.name}`, `labels: ${item.labels.join(", ") || "(none)"}`, - item.aliases && item.aliases.length > 0 ? `aliases: ${item.aliases.join(", ")}` : "", item.summary ? `summary: ${item.summary}` : "", "body:", item.body, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ae7f080..e0e0704 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -339,7 +339,6 @@ export function buildMcpServer( entity_key: item.entity_key, name: item.name, labels: item.labels, - aliases: item.aliases ?? [], summary: item.summary, body: bodyWithoutSummary(item.body, item.summary), files, @@ -907,17 +906,15 @@ export function buildMcpServer( server.registerTool( "suggest_labels", { - title: "Suggest labels or aliases", + title: "Suggest labels", description: library.writeMode === "direct" - ? "Recommend labels and aliases (other words people search for, e.g. \"phished\" for an incident runbook) for a PUBLISHED item. Only labels and aliases change; name, summary and body are untouched. This library is Review Unlock, so the change is live immediately. Use when a question should have found this item but did not." - : "Recommend labels and aliases (other words people search for, e.g. \"phished\" for an incident runbook) for a PUBLISHED item. Only labels and aliases change; name, summary and body are untouched. The suggestion goes to Pending: Readers keep the published copy until a person approves it (Publish) or rejects it (Cancel) in the publisher. Use when a question should have found this item but did not.", + ? "Recommend labels for a PUBLISHED item: categories, or other words people search with (e.g. \"phished\" for an incident runbook). Only labels change; name, summary and body are untouched. This library is Review Unlock, so the change is live immediately. Use when a question should have found this item but did not." + : "Recommend labels for a PUBLISHED item: categories, or other words people search with (e.g. \"phished\" for an incident runbook). Only labels change; name, summary and body are untouched. The suggestion goes to Pending: Readers keep the published copy until a person approves it (Publish) or rejects it (Cancel) in the publisher. Use when a question should have found this item but did not.", inputSchema: z.object({ id: z.string().uuid(), add_labels: labelList, remove_labels: labelList, - add_aliases: labelList, - remove_aliases: labelList, reason: z.string().trim().max(300).optional().describe("Why, in one line (e.g. the question that missed)."), expected_revision: z.number().int().optional(), }), @@ -942,15 +939,15 @@ export function buildMcpServer( } if (!result.changed) { await audit(true, `${id} no change`); - return ok(`No change: the item already has those labels and aliases.\n${await itemWire(result.item)}`); + return ok(`No change: the item already has those labels.\n${await itemWire(result.item)}`); } await audit( true, - JSON.stringify({ id, labels: result.labels, aliases: result.aliases, reason: reason ?? "" }), + JSON.stringify({ id, labels: result.labels, reason: reason ?? "" }), ); const head = library.writeMode === "direct" - ? "Saved. This library is Review Unlock, so the new labels and aliases are live now." + ? "Saved. This library is Review Unlock, so the new labels are live now." : "Suggested. The change is Pending: Readers and agents still see the published copy. A person must approve it (Publish) or reject it (Cancel) in the publisher."; return ok(`${head}\n${await itemWire(result.item)}`); } catch (error) { diff --git a/test/item-aliases.test.ts b/test/label-suggestions.test.ts similarity index 70% rename from test/item-aliases.test.ts rename to test/label-suggestions.test.ts index f8153ad..0b4c56b 100644 --- a/test/item-aliases.test.ts +++ b/test/label-suggestions.test.ts @@ -1,5 +1,6 @@ -/** Per-item aliases in search, and agent label/alias suggestions under Review Lock / Unlock. */ +/** Search words as labels, and agent label suggestions under Review Lock / Unlock. */ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import { test } from "node:test"; import type { AuthInfo } from "@modelcontextprotocol/server"; import { citationsFor } from "../src/db/citations.ts"; @@ -16,7 +17,6 @@ import { updateItem, } from "../src/db/queries.ts"; import type { Database } from "../src/db/types.ts"; -import { formAliases } from "../src/http/helpers.ts"; import { itemReview } from "../src/items/review.ts"; import { buildMcpServer } from "../src/mcp/server.ts"; import { withTestDatabase } from "./db.ts"; @@ -78,19 +78,19 @@ async function seedRunbooks(db: Database, writeMode: "reviewed" | "direct" = "re const PHISHED = "we think we got phished, what do we do"; -test("aliases are matched like labels, ranked, and typo-tolerant", async () => { +test("search words are plain labels: matched, ranked and typo-tolerant", async () => { await withTestDatabase(async (db) => { const { libraryId, incident, remote } = await seedRunbooks(db); - // Without aliases nothing in the library says "phished". + // Before the labels nothing in the library says "phished". assert.equal((await lookupItems(db, { library_id: libraryId, q: PHISHED }, reader)).kind, "none"); const row = await getItem(db, incident, { publishedOnly: false }); assert.ok(row); - await updateItem(db, incident, { name: row.name, body: row.body, labels: row.labels, aliases: ["phished", "phishing", "suspicious email"] }, "alice"); + await updateItem(db, incident, { name: row.name, body: row.body, labels: [...row.labels, "phished", "phishing", "suspicious email"] }, "alice"); await publishItem(db, incident, "alice"); const r = await getItem(db, remote, { publishedOnly: false }); assert.ok(r); - await updateItem(db, remote, { name: r.name, body: r.body, labels: r.labels, aliases: ["office network", "work from home", "wfh"] }, "alice"); + await updateItem(db, remote, { name: r.name, body: r.body, labels: [...r.labels, "office network", "work from home", "wfh"] }, "alice"); await publishItem(db, remote, "alice"); const hit = await lookupItems(db, { library_id: libraryId, q: PHISHED }, reader); @@ -101,19 +101,19 @@ test("aliases are matched like labels, ranked, and typo-tolerant", async () => { assert.equal(office.kind, "item"); assert.equal(office.kind === "item" && office.item.id, remote); - // Multi-word alias words match on their own too. + // Multi-word labels match too. const email = await lookupItems(db, { library_id: libraryId, q: "suspicious email" }, reader); assert.equal(email.kind === "item" && email.item.id, incident); - // Typo against an alias word is corrected from the library's vocabulary. + // A typo against a label word is corrected from the library's vocabulary. const typo = await lookupItems(db, { library_id: libraryId, q: "phisshed" }, reader); assert.equal(typo.kind, "item"); assert.equal(typo.kind === "item" && typo.item.id, incident); assert.equal(typo.kind === "item" && typo.corrected, "phished"); - // Readers see aliases on the item they open. + // Readers see the labels on the item they open. const opened = await getItem(db, incident, { ...reader, libraryId }); - assert.deepEqual(opened?.aliases, ["phished", "phishing", "suspicious email"]); + assert.deepEqual(opened?.labels, ["runbook", "security", "phished", "phishing", "suspicious email"]); }); }); @@ -125,8 +125,7 @@ test("suggest_labels under Review Lock: Pending until a person publishes; Reader const res = await call(db, libraryId, "suggest_labels", { id: incident, - add_aliases: ["Phished", "phishing", "suspicious email", "phished"], - add_labels: ["phishing"], + add_labels: ["Phished", "phishing", "suspicious email", "phished"], reason: PHISHED, }); assert.equal(res.isError, false, res.text); @@ -135,24 +134,23 @@ test("suggest_labels under Review Lock: Pending until a person publishes; Reader const working = await getItem(db, incident, { publishedOnly: false }); assert.ok(working); assert.equal(working.status, "draft"); - assert.deepEqual(working.aliases, ["phished", "phishing", "suspicious email"]); - assert.deepEqual(working.labels, ["runbook", "security", "phishing"]); + assert.deepEqual(working.labels, ["runbook", "security", "phished", "phishing", "suspicious email"]); // Text untouched. assert.equal(working.name, before.name); assert.equal(working.summary, before.summary); assert.equal(working.body, before.body); - // Review shows the label and alias change only. + // Review shows the label change only. const review = itemReview(working, working.published_snapshot, (await getLibrary(db, libraryId))!.schema); assert.equal(review.kind, "changed"); assert.deepEqual( review.kind === "changed" ? review.fields.map((field) => field.label) : [], - ["Labels", "Aliases"], + ["Labels"], ); - // Readers: still the published copy, and search does not use the pending alias. + // Readers: still the published copy, and search does not use the pending labels. const live = await getItem(db, incident, { ...reader, libraryId }); - assert.deepEqual(live?.aliases, []); + assert.deepEqual(live?.labels, ["runbook", "security"]); assert.equal((await lookupItems(db, { library_id: libraryId, q: PHISHED }, reader)).kind, "none"); assert.equal((await citationsFor(db, [incident])).get(incident)?.version, 1); @@ -171,16 +169,15 @@ test("suggest_labels under Review Lock: Pending until a person publishes; Reader }); }); -test("rejecting a suggestion (Cancel) restores the published labels and aliases", async () => { +test("rejecting a suggestion (Cancel) restores the published labels", async () => { await withTestDatabase(async (db) => { const { libraryId, incident } = await seedRunbooks(db); - const res = await call(db, libraryId, "suggest_labels", { id: incident, add_aliases: ["phished"], remove_labels: ["security"] }); + const res = await call(db, libraryId, "suggest_labels", { id: incident, add_labels: ["phished"], remove_labels: ["security"] }); assert.equal(res.isError, false, res.text); const cancelled = await cancelPublishItem(db, incident, "dana"); assert.equal(cancelled.ok, true); const row = await getItem(db, incident, { publishedOnly: false }); assert.equal(row?.status, "published"); - assert.deepEqual(row?.aliases, []); assert.deepEqual(row?.labels, ["runbook", "security"]); assert.equal((await citationsFor(db, [incident])).get(incident)?.version, 1); }); @@ -189,12 +186,13 @@ test("rejecting a suggestion (Cancel) restores the published labels and aliases" test("suggest_labels under Review Unlock is live at once and bumps the published version", async () => { await withTestDatabase(async (db) => { const { libraryId, remote } = await seedRunbooks(db, "direct"); - const res = await call(db, libraryId, "suggest_labels", { id: remote, add_aliases: ["office network", "wfh"] }); + const res = await call(db, libraryId, "suggest_labels", { id: remote, add_labels: ["office network", "wfh"] }); assert.equal(res.isError, false, res.text); assert.match(res.text, /Review Unlock/); - assert.match(res.text, /^aliases: office network, wfh$/m); + assert.match(res.text, /^labels: policy, office network, wfh$/m); + assert.doesNotMatch(res.text, /aliases/); const live = await getItem(db, remote, { ...reader, libraryId }); - assert.deepEqual(live?.aliases, ["office network", "wfh"]); + assert.deepEqual(live?.labels, ["policy", "office network", "wfh"]); assert.equal((await citationsFor(db, [remote])).get(remote)?.version, 2); }); }); @@ -203,14 +201,14 @@ test("suggest_labels refuses drafts, empty and stale requests; Readers do not ge await withTestDatabase(async (db) => { const { libraryId, incident } = await seedRunbooks(db); const draft = await createItem(db, { name: "Draft", body: "x", labels: [] }, "alice", libraryId); - const notLive = await call(db, libraryId, "suggest_labels", { id: draft.id, add_aliases: ["x"] }); + const notLive = await call(db, libraryId, "suggest_labels", { id: draft.id, add_labels: ["x"] }); assert.equal(notLive.isError, true); assert.match(notLive.text, /published items/); const empty = await call(db, libraryId, "suggest_labels", { id: incident }); assert.equal(empty.isError, true); - const stale = await call(db, libraryId, "suggest_labels", { id: incident, add_aliases: ["x"], expected_revision: 1 }); + const stale = await call(db, libraryId, "suggest_labels", { id: incident, add_labels: ["x"], expected_revision: 1 }); assert.equal(stale.isError, true); assert.match(stale.text, /Conflict/); @@ -228,30 +226,51 @@ test("suggest_labels refuses drafts, empty and stale requests; Readers do not ge }); }); -test("update_item and the edit form keep aliases unless they send new ones", async () => { +test("suggest_labels takes labels only", async () => { await withTestDatabase(async (db) => { - const { libraryId, incident } = await seedRunbooks(db); - const row = await getItem(db, incident, { publishedOnly: false }); - assert.ok(row); - await updateItem(db, incident, { name: row.name, body: row.body, labels: row.labels, aliases: ["phished"] }, "alice"); - const again = await getItem(db, incident, { publishedOnly: false }); - assert.ok(again); - await updateItem(db, incident, { name: again.name, body: `${again.body} More.`, labels: again.labels }, "alice"); - assert.deepEqual((await getItem(db, incident, { publishedOnly: false }))?.aliases, ["phished"]); - assert.ok(libraryId); - - assert.equal(formAliases({ name: "x" }), undefined); - assert.deepEqual(formAliases({ aliases: " Phished, phishing ,, PHISHED" }), ["phished", "phishing"]); - assert.deepEqual(formAliases({ aliases: "" }), []); + const { libraryId } = await seedRunbooks(db); + const schema = (await tools(db, libraryId, "publisher")).suggest_labels as unknown as { inputSchema?: { shape?: Record } }; + const keys = Object.keys(schema.inputSchema?.shape ?? {}); + assert.ok(keys.includes("add_labels")); + assert.ok(!keys.some((key) => key.includes("alias")), keys.join(",")); }); }); test("plannedLabels adds, removes and normalizes", () => { assert.deepEqual( - plannedLabels( - { labels: ["runbook", "Security"], aliases: ["vpn"] }, - { add_labels: [" Phishing "], remove_labels: ["security"], add_aliases: ["a,b"], remove_aliases: ["VPN"] }, - ), - { labels: ["runbook", "phishing"], aliases: ["a b"] }, + plannedLabels({ labels: ["runbook", "Security"] }, { add_labels: [" Phishing ", "a,b"], remove_labels: ["security"] }), + ["runbook", "phishing", "a b"], ); }); + +test("migration 026 merges aliases into labels (working row and published copy) without a new publish", async () => { + await withTestDatabase(async (db) => { + const { libraryId, incident } = await seedRunbooks(db); + // Recreate the pre-026 shape: an aliases column and aliases in the published copy. + await db.exec("ALTER TABLE items ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb"); + const snap = JSON.parse(((await db.get("SELECT published_snapshot FROM items WHERE id = ?", incident)) as { published_snapshot: string }).published_snapshot); + snap.aliases = ["Phished", "security", "suspicious email"]; + await db.run( + "UPDATE items SET aliases = ?::jsonb, published_snapshot = ? WHERE id = ?", + JSON.stringify(["Phished", "security", "suspicious email"]), + JSON.stringify(snap), + incident, + ); + const versionBefore = (await citationsFor(db, [incident])).get(incident)?.version; + + await db.exec(await readFile(new URL("../migrations/026_aliases_into_labels.sql", import.meta.url), "utf8")); + + const row = await getItem(db, incident, { publishedOnly: false }); + assert.deepEqual(row?.labels, ["runbook", "security", "phished", "suspicious email"]); + assert.deepEqual(row?.published_snapshot?.labels, ["runbook", "security", "phished", "suspicious email"]); + const raw = (await db.get("SELECT published_snapshot FROM items WHERE id = ?", incident)) as { published_snapshot: string }; + assert.equal(JSON.parse(raw.published_snapshot).aliases, undefined); + assert.equal((await citationsFor(db, [incident])).get(incident)?.version, versionBefore, "not a new publish"); + const columns = (await db.all( + "SELECT column_name FROM information_schema.columns WHERE table_name IN ('items', 'items_live') AND column_name = 'aliases'", + )) as unknown[]; + assert.equal(columns.length, 0); + const hit = await lookupItems(db, { library_id: libraryId, q: PHISHED }, reader); + assert.equal(hit.kind === "item" && hit.item.id, incident); + }); +}); diff --git a/test/published-while-pending.test.ts b/test/published-while-pending.test.ts index 8404906..796b707 100644 --- a/test/published-while-pending.test.ts +++ b/test/published-while-pending.test.ts @@ -276,11 +276,9 @@ test("migration backfill: edited-after-publish items go live again; others stay // Pretend the rows were written before 018: nothing is live while Pending. await db.run("UPDATE items SET published_live = false"); await db.run("UPDATE items SET published_snapshot = '{not json' WHERE id = ?", broken); - // Later migrations redefine items_live (021 adds aliases), so replay them in order too. + // 018 defines the same items_live view the later migrations end with. await db.run("DROP VIEW items_live"); - await db.run( - "DELETE FROM schema_migrations WHERE version IN ('018_published_while_pending.sql', '021_item_aliases.sql')", - ); + await db.run("DELETE FROM schema_migrations WHERE version = '018_published_while_pending.sql'"); await applyMigrations(db); const live = async (id: string) => (await getItem(db, id, { ...reader, libraryId: library.id }))?.body;