# Changelog All notable changes to Agnes AI Data Analyst. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html), pre-1.0 — public surface (CLI flags, REST endpoints, `instance.yaml` schema, `extract.duckdb` contract) may shift between minor versions; breaking changes called out under **Changed** or **Removed** with the **BREAKING** marker. CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every CI build; semver tags (`v0.X.Y`) are cut at release boundaries and reference the same commit as a `stable-*` tag from the same day. --- ## [Unreleased] ### Changed - **BREAKING (marketplace identifier)**: synthetic plugin bundling flea skills + agents renamed from `agnes-store-bundle` to `flea`. The served `marketplace.json` now lists `flea` (previously `agnes-store-bundle`); on-disk ZIP / git tree path is `plugins/flea/` (previously `plugins/store-bundle/`). Claude Code JSONL invocation prefix becomes `flea:` going forward. **Clean cut — no legacy-prefix backward compat.** Historic `usage_events` rows whose JSONL was written before the rename will stay attributed as `source='builtin'` (acceptable in dev phase per user direction; nothing to migrate). **Client rollover**: `agnes refresh-marketplace` will install the new `flea@agnes` plugin and reset the local marketplace clone (the old `plugins/store-bundle/` source folder gets removed from disk via `git reset --hard`). Whether Claude Code itself auto-prunes the orphan `agnes-store-bundle@agnes` registry entry is undocumented in our codebase — to be verified empirically on the dev VM. If the orphan entry lingers, users can manually run `claude plugin uninstall agnes-store-bundle@agnes`. - Marketplace detail page **Details sidebar** unified across all five surfaces (curated plugin / flea plugin / curated inner skill+agent / flea inner skill+agent / flea standalone skill+agent). Render order now scans **identity → life-stage → telemetry → debug-tier**: Curator / Owner → (Parent plugin for inner / Released for top-level) → Last used → Active days → Version (flea standalone only) → Bundle size. Drops the previous Slug row (debug-tier, never user- relevant) from plugin detail and the Category + Installs rows (duplicated hero badge + telemetry chip) from flea standalone detail. Flea plugin Owner row now reads `d.owner_display` — the fullname resolved via `users.name → users.email → owner_username` — instead of the kebab-case `owner_username` slug. - Flea marketplace cards and detail pages now render the user-friendly **title** instead of the kebab-case `-by-` slug, the owner's full name from `users.name` (with email → `owner_username` fallback) instead of the bare username, and the optional **tagline** as the hero subtitle (description still shows below the hero on detail pages). Phase 2 of the Flea refactor — phase 1 (commit `7f4cfcbb`) seeded the columns; phase 2 wires them through `_flea_to_item`, `flea_detail`, and the two detail templates. Breadcrumb last segment on `/marketplace/flea/{id}` drops the suffixed slug fallback in favour of the title. - Flea inner skill/agent detail pages (`/marketplace/flea/{id}/skill/{name}`, `/agent/{name}`) now show the parent plugin's **title** in the breadcrumb 3rd segment, the hero "part of …" meta-row, the helper "This skill is part of …" panel, and the Details sidebar's "Parent plugin" row. Sourced from `store_entities.title` via `_flea_inner_parent_fields.parent_display_name`; falls back to `strip_archive_suffix(name)` for any legacy rows that somehow lack a title. - Flea standalone skill/agent detail (`/marketplace/flea/{id}` where `type IN ('skill','agent')`) drops the hero meta-row that read "by <author> · N installed · <size>". Install count is already rendered in the hero telemetry chip below; owner + bundle size live in the Details sidebar. The row was duplicating those three values in a less-prominent position. - Read paths (marketplace card name, detail manifest_name, response `invocation_name`, My-Stack invocation, served-bundle manifest in `marketplace_filter`) now source the suffixed slug from `store_entities.synthetic_name` directly instead of recomputing `-by-` on the fly. The column is NOT NULL + the repo `create` / `update` / `archive` paths keep it in sync, so reading it is safe; no fallback to a recompute — a missing value would be a genuine bug worth surfacing as `KeyError`, not masked. `suffixed_name()` stays as the primitive used by **write paths only** (POST create insert, PUT rename collision check + new suffix for `_rename_baked_tree` + new synthetic for `repo.update`, archive new/old suffix for on-disk rename). `_suffixed_already_taken` collision query swaps the inline `name || '-by-' || owner_username` concat for `WHERE synthetic_name = ?` — indexable + single source of truth. ### Fixed - Flea **plugin entity** cards (`/marketplace?tab=flea`) and detail pages (`/marketplace/flea/{id}` for `type='plugin'`) now show the sum of nested skill/agent invocations. Pre-fix the plugin-level rollup pass in `services/session_processors/usage_lib.py:_aggregate_events` was hardcoded to `source='curated'` only, so flea plugin entities never got a `(source='flea', type='plugin', parent_plugin='', name=)` aggregated row. The API path's `_load_invocation_stats('flea')` filters `parent_plugin=''` and returned nothing for plugin cards even though nested children had correct rollup rows. Triggered by empirical observation on dev VM (`codex-second-opinion-by-c-marustamyan` plugin showed 0 calls while its three inner skills had 1+1+3 invocations). Fix extends the aggregation pass to `source in ('curated', 'flea')` and preserves the original source tag in the synthetic plugin row. `USAGE_PROCESSOR_VERSION` bumped 8→9 so the reprocess pass fills the new aggregated rows for historic data. - Flea-market attribution layer now keys its lookup tables by `store_entities.synthetic_name` instead of `name`, matching what Claude Code writes in the JSONL invocation local-part (`flea:` e.g. `flea:xlsx-by-c-marustamyan`). Pre-fix every flea skill/agent invocation silently fell through to `usage_events.source = 'builtin'` because the dict was keyed by the un-suffixed `name`. Result: marketplace cards, detail telemetry chips, and admin group-by-source had 0 flea invocations even though raw events were arriving correctly. Both `MarketplaceItemLookup` (live writer) and `_attribute_event` (rollup rebuilder) updated; rollup `name`/`parent_plugin` columns now carry the synthetic_name keyspace. API stats lookups in `app/api/marketplace.py` switched from `entity["name"]` to `entity["synthetic_name"]` (4 callsites: `_flea_to_item`, `flea_detail`, two flea inner-detail endpoints). `_attribute_event` also gains the flea-plugin-nested branch it was missing since v6 — nested skills/agents inside flea plugins now flow into rollup tables too. `USAGE_PROCESSOR_VERSION` bumped 7→8 so the session-pipeline reprocess loop re-attributes existing events with the corrected lookup. Closes issue #335. - Flea-tab marketplace listing endpoint (`GET /api/marketplace/items?tab=flea`) no longer issues an N+1 query against `users`. The owner-display resolution previously fired one `SELECT name, email FROM users WHERE id = ?` per item inside the list comprehension; now batched into a single `WHERE id IN (…)` prefetch via `_load_users_display`. With 50 flea items per page that drops 51 queries to 2. ### Added - Flea-market upload + edit forms now collect a user-friendly **Title** (humanized from the kebab-case `name`, acronym-aware: `mcp-builder` → `MCP Builder`, `oauth-server-v2` → `OAuth Server V2`), an optional **Short description** (`tagline`, ≤200 chars), and show a read-only live preview of the final synthetic invocation slug (`/-by-`) next to the Name field. Phase 1 of a larger Flea refactor — fields are persisted on `store_entities` but not yet rendered on marketplace cards / detail pages (Phase 2). Schema v49 adds `title NOT NULL`, `tagline`, and `synthetic_name NOT NULL` columns; backfill humanizes existing names (archive-suffix stripped first) and composes synthetic from the deterministic formula. - Schema **v50** adds a UNIQUE INDEX on `store_entities.synthetic_name` (`idx_store_entities_synthetic_name`). v49 made `synthetic_name` the canonical attribution key (rollup keyspace, JSONL invocation prefix, marketplace bundle naming) but uniqueness was only enforced application-side at upload/rename time via `_suffixed_already_taken`. v50 promotes the invariant to the DB layer so admin DB hand-fixes or future write-path bugs can't silently introduce duplicates. DuckDB has no `ALTER TABLE ADD CONSTRAINT UNIQUE`, but `CREATE UNIQUE INDEX` is functionally equivalent. Migration pre-checks for existing duplicates and raises `RuntimeError` listing them rather than letting the index create fail mid-way with a raw DuckDB error. ## [0.54.28] — 2026-05-18 ### Fixed - `/api/v2/sample` (and `agnes describe`) no longer returns HTTP 500 for materialized BigQuery tables (`source_type='bigquery'`, `query_mode='materialized'`). The handler previously routed any `source_type='bigquery'` row to `_fetch_bq_sample` regardless of query mode, attempting a live BigQuery query for data that lives locally as parquet. Fix mirrors the existing guard in `app/api/v2_schema.py` from #261 — materialized tables fall through to the local parquet read path. Regression-locked by `test_materialized_bq_table_reads_parquet_not_bq`. Closes #341. ## [0.54.27] — 2026-05-18 ### Fixed - `/admin/tables` edit modal no longer throws `ReferenceError` on non-Keboola instances (BigQuery, CSV). Two JS helpers (`_getEditKbSyncMode`, `onEditKbSyncModeChange`) were wrapped in the `{% if data_source_type == 'keboola' %}` template guard but called unconditionally from sync-mode radio buttons rendered for all instance types. The guard now scopes only the discover / prefill helpers that actually talk to the Keboola Storage API; the shared sync-mode helpers ship to every instance. ## [0.54.26] — 2026-05-18 ### Changed - **BREAKING:** eight `DELETE` endpoints that previously returned `200` with a JSON body now correctly return `204 No Content` (HTTP semantics for idempotent removal). External clients that parsed the response body (e.g. `r.json()["status"]`) will hit JSON-decode errors against the now- empty payload and must drop the body read: `DELETE /api/admin/metrics/{id}`, `DELETE /api/memory/{id}/dismiss`, `DELETE /api/store/entities/{id}`, `DELETE /api/store/entities/{id}/install`, `DELETE /api/marketplace/curated/{marketplace}/{plugin}/install`, `DELETE /api/marketplaces/{marketplace}/plugins/{plugin}/system`, `DELETE /api/admin/store/submissions/{id}`, and `DELETE /api/admin/observability/views/{id}`. - **BREAKING:** `POST /api/memory/admin/contradictions` now returns `201 Created` instead of `200 OK` on success (creator-POST contract). ### Internal - Added `tests/test_api_design_rules.py` — four forward-only design guardrails that prevent new endpoints from adding to existing REST debt: no new verbs in URL paths, `DELETE` must declare 204, creator `POST`s must declare 201, and all protected `/api/*` routes must declare 401 and 403. - `_add_auth_error_responses()` injected into `app.openapi()` at startup to declare 401/403 on all protected `/api/*` operations centrally — 220 ops now carry the auth-error responses in the spec. ## [0.54.25] — 2026-05-18 ### Fixed - `POST /api/sync/table-subscriptions` now enforces the same RBAC gate as `POST /api/sync/settings` — authenticated users can no longer subscribe to tables they have no `resource_grants` row for (ADV-001, issue #336). - **BREAKING:** `GET /webhooks/jira/health` is now admin-only; `jira_domain` removed from the response to prevent anonymous information disclosure (ADV-002). Uptime monitors that polled this endpoint anonymously must now attach an admin PAT or switch to `/api/health` (which remains public). - **BREAKING:** `GET /api/version` no longer exposes `commit_sha` or `schema_version` — only `version`, `channel`, `image_tag`, `deployed_at` remain (ADV-003). Deploy scripts / dashboards scraping the removed fields must either authenticate against a (separate, forthcoming) admin endpoint or read them from the GHCR image labels. - **BREAKING:** `/docs`, `/redoc`, and `/openapi.json` now require a valid session — the full admin API surface is no longer visible to unauthenticated requests (ADV-005). CLI tools generating client code from the schema must attach a PAT or use an authenticated browser session. ### Changed - `/cli/` and `/webhooks/` prefixes added to `_API_PATH_PREFIXES` so any future auth-gated endpoint under those paths returns JSON `401` rather than an HTML redirect (ADV-006). - `GET /api/users` and `GET /auth/admin/tokens` accept `limit` (default 1000, max 10 000) and `offset` query parameters; `POST /api/sync/table-subscriptions` now rejects `tables` dicts with more than 500 entries (ADV-008, ADV-009). - `GET /api/catalog/tables` now has a typed `response_model` (`CatalogTablesResponse`) so Swagger generates an accurate schema for that endpoint (ADV-007). ## [0.54.24] — 2026-05-16 ### Fixed - Flea-market admin submissions UI now derives the per-submission `v#` label by **submission_id**, not **hash**. Hash-based lookup mislabeled every byte-identical reupload (and every reused-verdict restore — common after the restore-reuse fix below) as `v1` because the loop picked the FIRST history entry with matching hash. Affected both the admin queue column (`v#`) and the per- section chips on the detail page. Same fix pattern as PR #330 (runner / override paths). - Flea-market admin submission detail page gained a version-switcher card listing every submission linked to the same entity with status badge + reviewed_by_model + click-to-jump. Lets admins compare verdicts across versions without bouncing back to the queue. - Flea-market initial POST now backfills the v1 seed entry's `submission_id` immediately after creating the v1 submission row. Pre-fix the v1 history entry always carried `submission_id=None` so downstream lookups (`_version_no_for_submission`, admin queue v#, admin detail chip, restore-reuse) silently failed for v1. - Flea-market restore endpoint now reuses the prior approved submission's LLM verdict when the restored bundle is byte-identical to a history entry already reviewed by the same `review_model`. Pre-fix every restore re-ran the LLM; Anthropic structured output is non-deterministic — same bytes flipped `content_quality.verdict` pass↔fail across calls, so a second restore of an already-approved version could spuriously land at `blocked_llm`. Reuse skips the LLM, stamps the new submission with the prior verdict + `reused_from_submission_id` marker, and saves the Anthropic token cost. Surfaced live on a development deployment where the third restore of a v1 bundle (same hash as v1/v2/v4/v6 — multiple identical re-uploads) landed `blocked_llm` while sibling submissions were `approved`. - Admin submission detail page now surfaces `llm_findings.content_quality.issues` in its own table next to the security-findings table. Pre-fix the template only rendered security findings, so a submission blocked purely on `content_quality.verdict='fail'` (no security findings) showed up as "No findings — model verdict was clean" even though `status='blocked_llm'`. Also adds an explicit "Blocked but no findings recorded" notice when the verdict is blocked but neither findings list is populated (transient LLM non-determinism), pointing admin at Rescan / Override. Reuse markers (`reused_from_submission_id`) render too. ## [0.54.23] — 2026-05-16 ### Fixed - Flea-market admin **Rescan** of a non-current v2+ submission with `guardrails.enabled: false` now promotes the entity forward (mirrors the inline-promote in create / update / restore). Pre-fix the branch flipped submission status to `approved` and entity visibility to `approved` but never called `promote_to_version` — the rescan re-approved the version without making it current. Codex adversarial-review follow-up on PR #330. The guardrails-on path is unchanged (rescan schedules an LLM review; promotion lands when the verdict approves through `runner.run_llm_review`). ## [0.54.22] — 2026-05-15 ### Fixed - **Flea-market — promote-on-approve + admin-override now look up the submission's `version_no` in `version_history` by `submission_id`, not by `hash`.** Hash-based lookup broke whenever the user uploaded byte-identical bundles across versions (e.g. same content as v2 and v4): the loop matched the FIRST history entry with that hash — always v1 — so `target_version_no` landed at 1, the forward-only `target > current` guard skipped the promote, and the entity stayed stuck at v1 even though the new submission was `status='approved'`. UI kept showing v1 as "current". Both `runner.run_llm_review` (background auto-approve) and `admin_override_store_submission` now reuse the existing `_version_no_for_submission` helper. Closes the live development- deployment case where an entity had 5+ identical-hash history rows. - **Admin "ask telemetry" feature** (`POST /api/admin/telemetry/ask`) no longer emits SQL against the dropped `usage_plugin_daily` table. `src/usage_ask.py` `SCHEMA_DIGEST` and `SYSTEM_PROMPT` bumped to describe the v48 rollups (`usage_marketplace_item_daily` / `_window`) and rule 5 of the prompt updated. Pre-fix, the LLM would happily emit `SELECT … FROM usage_plugin_daily` per the stale prompt and the DuckDB binder would reject it. ### Internal - **CHANGELOG `[0.54.20]` section restored to its canonical content from the `v0.54.20` git tag.** The #329 self-merge had carried 226 lines of author's pre-rebase bullets that ended up mis-attributed to `[0.54.20]`; the published v0.54.20 GitHub Release (FTS BM25 + batch bar) now matches the CHANGELOG section verbatim. - `tests/conftest.py` — dropped the unused `conn_with_usage_schema_and_attribution` fixture that seeded into the now-removed `usage_attribution_*` tables. Zero callers today but a tripwire — the first future test to request it would have failed with a DuckDB binder error. - `app/web/templates/marketplace.html` — replaced a customer- specific token (`groupon-marketplace`) in the Most Popular sort- tiebreaker comment with a generic `-marketplace` placeholder per `CLAUDE.md § Vendor-agnostic OSS`. ## [0.54.21] — 2026-05-15 ### Added - **Marketplace — flea inner skill/agent detail page parity with curated.** New backend endpoints `GET /api/marketplace/flea/{id}/skill/{name}` and `…/agent/{name}` plus matching web routes that render `marketplace_item_detail.html`. Stack-install is blocked on inner items (same rule curated has had since launch — "Open parent plugin →" button + helper text instead). Breadcrumb: `Marketplace › Flea Market › `. - **Marketplace — funnel chip + Most adopted sort + listing polish.** The funnel chip (`N active · N calls · ±X% trend · N installed`) now lives on the marketplace cards, plugin detail hero, inner detail hero, AND the inner skill/agent cards on the parent plugin detail page. New `Most adopted (30d)` sort; deterministic Most Popular ordering. Trending sort hidden when no trend data. Breadcrumb second segment is now a generic clickable `Curated Marketplace` / `Flea Market` link instead of the opaque per-instance marketplace name. Flea sidebar uses `Owner` label (vs `Curator` on curated); flea-inner sidebar mirrors curated nested layout (Parent plugin / Bundle size / Active days / Last used / Owner). ### Changed - **BREAKING:** `MarketplaceItem.unique_users_30d` renamed to `distinct_users_30d` in the `/api/marketplace/items` response. The new value is a true distinct count across the 30-day window (from the `usage_marketplace_item_window` snapshot), not the old sum-of-daily proxy that over-counted active multi-day users. - `usage_events.source` is now populated per-event by `MarketplaceItemLookup` (live join against `marketplace_plugins` + `store_entities`). Previously it sat at `'builtin'` for every row because the v42 `AttributionLookup` matched skill/command names without the plugin prefix that Claude Code actually writes — `usage_events.source = 'curated'` / `'flea'` / `'builtin'` becomes meaningful for the first time. `usage_events.ref_id` semantics shift in lockstep — curated stores the plain plugin name, flea stores `NULL`. - `USAGE_PROCESSOR_VERSION` bumped 5 → 6 so the session-pipeline reprocess loop re-attributes historic events on next tick. - `_build_telemetry` returns `None` (not a zero-shape dict) when `invocations_30d == 0`, so detail endpoints can omit the chip payload entirely. The frontend hero / sidebar are already None-safe (`d.telemetry || {}` guard, `if (!d.telemetry || …)` on daily_series). ### Removed - **BREAKING:** four schema-v42 telemetry tables (v48 migration): - `usage_attribution_skills`, `usage_attribution_agents`, and `usage_attribution_commands` — replaced by live prefix-split lookup against `marketplace_plugins` + `store_entities`. Verified empty or derivable; no unique data lost. - `usage_plugin_daily` — replaced by `usage_marketplace_item_daily` + `_window`. Verified empty in production-shape data (the v42 rollup `INSERT` was gated on `source IN ('curated','flea')` but the broken attribution layer always produced `'builtin'`). - `src/repositories/usage_attribution.py`, `src/usage_attribution_helpers.py`, `scripts/backfill_usage_attribution.py`, and their test fixtures (`tests/test_usage_attribution.py`, `tests/test_backfill_usage_attribution.py`) — no callers remain. ### Internal - **Schema v48** — marketplace telemetry refactor. `usage_marketplace_item_daily` (per-day fact with count + distinct_users + error_count, keyed by `(day, source, type, parent_plugin, name)`) and `usage_marketplace_item_window` (sliding-window snapshot, labels `last_7d` refreshed every UsageProcessor tick, `last_30d` refreshed hourly) replace the dropped v42 attribution + plugin-daily tables. Auto-migrates on first boot; fresh installs receive the new tables via `_SYSTEM_SCHEMA`. The migration was renumbered v45→v46 → v47→v48 on rebase since the v46 / v47 slots were already taken by #316 (per-user dismiss) and #326 (FTS BM25 index). - `scripts/backfill_marketplace_rollup.py` — one-shot script to populate the new rollup tables from historic `usage_events` after a v48 deploy. - **Repo-committed Claude Code agents + skills under `.claude/`.** Four knowledge skills (`agnes-orchestrator`, `agnes-rbac`, `agnes-connectors`, `agnes-release-process`) auto-load into the main agent's context when their description matches the work or are invokable explicitly via `Skill()`. Four specialist subagents (`agnes-reviewer-rules`, `agnes-reviewer-rbac`, `agnes-reviewer-architecture`, `agnes-releaser`) wire into the Agent tool — reviewers fire in parallel at the end of PR work; the releaser handles pre-merge release-cut + post-merge tag / GitHub Release. `.gitignore` un-ignores `.claude/agents/` and `.claude/skills/` while keeping the rest of `.claude/` local-only. Source of truth for the rules these encode remains `CLAUDE.md` + `docs/RELEASING.md`. Design rationale + implementation plan: `docs/superpowers/specs/2026-05-15-agnes-agents-design.md` and `docs/superpowers/plans/2026-05-15-agnes-agents.md`. ## [0.54.20] — 2026-05-15 ### Added - **Corporate Memory — BM25 relevance ranking on knowledge search.** Replaces the `title ILIKE '%q%' OR content ILIKE '%q%'` ranked-by-insertion-order query in `KnowledgeRepository.search` with DuckDB's `fts` extension (BM25). Czech queries match across diacritics (`cesky` → `česky`) via `strip_accents=1` + `lower=1`. Schema v47 builds the initial index over `knowledge_items(title, content)`; per-mutation rebuild fires only when `title` / `content` change (status flips skip). The lifespan in `app/main.py` rebuilds once at boot as a safety net for restarts on v47. Result rows now carry a `bm25_score` column (always present — `None` on the ILIKE fallback for shape uniformity). When the `fts` extension can't be loaded (offline / sandboxed install) **or** the index is missing (migration soft-fail, concurrent `overwrite=1` rebuild's drop-then- create window), `search` and `count_items` transparently fall through to the pre-#121 ILIKE query — same result-set membership, ordering regresses to `updated_at DESC`. Closes #121. - **Corporate Memory — bulk-edit batch bar on the All Items tab.** Symmetric to the Review-tab bar shipped in #126; row checkboxes, "Select all" header, and the five bulk-edit actions (Move to category / Move to domain / Add tag / Remove tag / Set audience) now appear on `/corporate-memory-admin` All Items as well. Approve / Reject stay scoped to Review per #129's scope decision (status flips belong with the per-row actions or the keyboard workflow). Closes #129. ### Internal - **Schema v47** — adds DuckDB `fts` BM25 index over `knowledge_items(title, content)`. Auto-migrates on first boot; soft-fails to ILIKE if the extension repo is unreachable. Index is a snapshot — see `src/fts.py` for the on-mutation / lifespan rebuild contract. ## [0.54.19] — 2026-05-15 ### Changed - `connectors/jira/scripts/consistency_check.py` — `AUTO_FIX_THRESHOLD` bumped from 10 to 20. Auto-backfill now covers typical SLA-poller hiccups before escalating to ERROR. `WARNING_THRESHOLD` unchanged. ### Fixed - **`connectors/jira` — transient Jira API failure no longer wipes existing `remote_links` parquet rows.** Pre-fix, all three `fetch_remote_links` sites (`service.py`, `scripts/backfill.py`, `scripts/backfill_remote_links.py`) silently returned `[]` on 401/403/429/5xx or `httpx.RequestError`. Callers overlaid that `[]` onto cached issue JSON, and `transform_remote_links` interpreted the empty list as "issue legitimately has no remote links — delete its existing rows", so a transient Jira auth blip (or a webhook burst hitting Jira's rate limiter) permanently wiped remote-link history. Now: every fetch site raises `JiraFetchError` on non-200/non-404 status and `httpx.RequestError` (including the "service not configured" path — a webhook arriving while API creds are missing no longer surfaces as a silent wipe), overlay sites skip the `_remote_links` key on raise (leaving it ABSENT, not present-but-empty), and `transform_remote_links` returns `None` for absent / `null` keys (preserve existing rows) vs `[]` (legitimate empty — wipe). Both consumers (batch `transform_all` and incremental `transform_single_issue`) honor the new contract. End-to-end tests lock both halves: `test_incremental_preserves_remote_links_when_overlay_absent` + `test_incremental_wipes_remote_links_when_overlay_present_but_empty`. The bulk-backfill scripts retain their existing `Retry-After` sleep+retry loop for 429 (appropriate for non-interactive batch contexts); only the webhook hot path raises on 429. ### Internal - `CLAUDE.md` — `connectors/jira/transform.py` removed from the "Files NOT to modify" list. The `_remote_links` hardening required modifying `transform_remote_links` and `transform_all` to honor the new "overlay absent → preserve existing rows" contract; the module remains sensitive (touch only with end-to-end understanding of the JSON-overlay / parquet-rewrite pipeline) but is no longer off-limits. ## [0.54.18] — 2026-05-15 ### Added - "Curated Memory" now sits in the primary navigation next to Data Packages, visible to every authenticated user. - **Per-user Dismiss** for Curated Memory items — analysts can opt-out of approved items from their AI-agent bundle and gray them out on `/corporate-memory`. Schema v46 adds `knowledge_item_user_dismissed`; new endpoints `POST /api/memory/{id}/dismiss` and `DELETE` (idempotent). **Mandatory items can never be dismissed** — the governance hard rule is enforced at two layers (API rejects with 400, SQL filter exempts mandatory rows even if a stale dismissal exists). `GET /api/memory` gains `hide_dismissed=false` (default off — dismissed items still visible with a badge + Undismiss button) and per-item `dismissed_by_me` flag. `GET /api/memory/bundle` always excludes dismissed items. - **"My Upvotes" filter** on `/corporate-memory` replaces the old dead "My Rules" sentinel. Backed by a new `?upvoted_by_me=true` filter on `/api/memory` that subquerying against `knowledge_votes`. - **Inline tag typeahead** in the admin edit modal — focus the tag input to browse all existing tags as a dropdown, type to filter (case-insensitive), ↑/↓ + Enter to add as pill, type a fresh value to surface "+ Add new tag: ". Tags now render as removable pills (× to remove); Backspace on empty input pops the last pill. - **Bulk-edit modal pickers** for `/admin/corporate-memory` — Category, Audience, and Add tag get `