Vol. 03 · Field Guideswallowtail · changelogupdated 2026 · 05 · 12
No. 15 · Logbook

What we shipped, and when.

A complete release log. The newest at the top.

v3.0.5
2026-05-12
minor

Ask the model again: per-row semantic re-rank from the review drawer.

The review drawer can now ask MiniLM to re-rank a single row's target candidates on demand. The Ask the model again section sits between the cached Other candidates list and the manual URL input. One click hits a synchronous backend endpoint that embeds just the source URL, scores it against the map's cached target embeddings, and returns the top five by cosine. The candidates render inline alongside the existing runner-ups and clicking one fills in the same pending-target slot the runner-up rows already use, so saving works the way it always did. The feature requires the per-map target embedding cache to be warm, so the user must have run Resolve with semantics on the whole map at least once. If the cache is empty the drawer shows an explicit message instead of silently building the index in the background.

  • ·New Suggest semantically button in the review drawer's Ask the model again section. Disabled while a request is in flight and on archived maps. Picking a model-suggested candidate prefills the same pending-target slot the runner-up clicks use, so Save behaves identically
  • ·New backend endpoint POST /v1/maps/{id}/sources/{source_id}/semantic-suggest. Synchronous: returns up to five candidates with target_id, target_url, and cosine, sorted by cosine descending. No threshold gating so the user sees the model's full top-5 even when scores are low
  • ·Endpoint requires the per-map target embedding cache to be populated (running Resolve with semantics on the whole map at least once warms it). When the cache is empty the API returns 409 and the drawer shows: Run Resolve with semantics on the whole map first so MiniLM has its target index built
  • ·API process loads the MiniLM session lazily on first call and shares it across requests via a singleton, so the model cold-start hit lands on the first user click rather than on every health check. The container image now bakes the model files in at build time so first-call latency does not include a HuggingFace download
v3.0.4
2026-05-12
patch

Review page header cleanup and responsive ops menu.

The review page header used to stack the map title row (with Rename, Pin, Archive, Delete strung out inline) on top of a second header that listed Re-run match, Send unmatched to..., Resolve with semantics, and Export side by side. On narrow viewports the title overlapped the action buttons and long map names pushed everything into a jumbled wrap. The top row now uses the same kebab pattern as the /edit page, so Rename/Pin/Archive/Delete collapse behind one button next to the title. The second header keeps Export visible as the primary CTA and folds Re-run match, Send unmatched to..., and Resolve with semantics into a kebab to the right of Export. Long map names break instead of overflowing.

  • ·Review page top header now uses MapHeader (the same component the /edit page uses) so the map title row carries pills + a single kebab for Rename/Pin/Archive/Delete instead of four inline buttons
  • ·Re-run match, Send unmatched to..., and Resolve with semantics moved into a kebab to the right of Export on the review header. Export stays as the primary visible button. The kebab closes on outside click and Esc
  • ·Long map names no longer push pills and actions off the row. The title container gains min-w-0 + break-words so headers stay aligned on narrow viewports
  • ·Removed the redundant {mapName} / Review eyebrow line from the review subheader (the breadcrumb already carries that context)
v3.0.3
2026-05-12
patch

Embedding routes below-floor rows through the map's fallback strategy.

When the semantic re-rank pass produces a top-1 cosine below the configured threshold, the row used to stay orphaned and surface to the user as unmatched on the review page. The embedding pipeline now consults the same fallback_strategy the export layer respects (nearest_parent, home, static, none) and routes the row to a sensible destination at match time. The destination is written as method='fallback' so it stays visually distinct from a real semantic match, but the cosine and threshold ride along in signals.embedding so the why-this-match panel can show the user exactly why the row took the fallback path. fallback_strategy='none' remains the explicit opt-out for users who want orphans to surface for manual review.

  • ·Below-threshold rows now resolve to a target_url_id at match time when the map's fallback_strategy is set. nearest_parent walks the source path up one segment at a time and stops at the first hit in the target set; home picks the / target if one exists; static reuses the map's fallback_url when it points at an existing target URL row
  • ·method='fallback' rows resolved by the embedding pipeline carry signals.embedding with the cosine, threshold, model name, the routed_to strategy, and reason='below_threshold' so the why-this-match panel can show why the row took the fallback path
  • ·fallback_strategy='none' (the default) preserves the prior behaviour exactly: below-floor rows stay unmatched and surface on the review page as orphans
  • ·Routing is best-effort: if the strategy is set but the resolver cannot pick an existing target URL (e.g. home with no homepage row, static with an empty fallback_url, nearest_parent with nothing to walk back to), the row stays unmatched rather than fabricating a destination
  • ·Why-this-match panel grew a Routed via row inside the embedding signal table that names the strategy the pipeline used to pick the destination
v3.0.2
2026-05-12
patch

Match progress folds into the review page.

Running a match no longer bounces the user to a dedicated /maps/{id}/match page. The progress indicator now renders as a slim strip at the top of /maps/{id}/review for the duration of the job, then morphs into the existing completion banner once the job finishes. The same SSE stream feeds the strip, so resume-on-refresh still works. The standalone /match route is gone; every entry point (Run match on /edit, Re-run match on /review, Resolve with semantics on /review, Quick map auto-match, and the background-job pill) now lands the user on /review with a ?job= query param.

  • ·/maps/{id}/match route deleted, including the standalone MatchProgress page and component. Existing browser tabs on the old URL will 404; new entry points all route to /review
  • ·New match-progress-strip on the review page consumes the same /v1/jobs/{id}/events SSE stream the old page used. Resumes from last event_seq on reconnect with 5x auto-retry and 1s backoff. Cancel button is preserved
  • ·On done, the strip hands off to the existing match-complete banner via the same ?match=done&processed=&total= query-param protocol. router.refresh() runs so the table picks up newly-matched rows immediately
  • ·On failure, the strip turns red, surfaces the error, and exposes Dismiss. Partial results show because router.refresh() fires before unmount
  • ·Run match, Re-run match, Resolve with semantics, Quick map auto-match, and the background-job pill all point at /review?job= now
v3.0.1
2026-05-12
patch

Sources page compaction and same-day Quick map fix.

The /maps/{id}/edit page no longer buries management actions and the pre-crawl block below the URL grid. Rename, Pin, Archive, and Delete now live in a single kebab menu in the page header next to Save and exit and Run match. The redundant mid-page Sources label is gone, and the Match quality / Pre-crawl titles section is removed from the page entirely (the backend endpoint stays warm for a future revival). Result: the URL grid is what the user sees above the fold on a fresh load instead of competing with two large explainer sections below it. Separately, generating a second Quick map on the same day with non-parseable URLs no longer fails with a duplicate-name error.

  • ·Rename, Pin/Unpin, Archive/Unarchive, and Delete collapse into a single kebab menu in the page header. The menu closes on outside click and Esc. The Map settings section at the bottom of the page is gone
  • ·The mid-page Sources uppercase label between the header and the grid is removed (the breadcrumb already says Sources)
  • ·Pre-crawl titles UI is removed from the sources page. The crawl pass was running long and the UX never communicated the cosine boost it produced, so the affordance currently confused more than it helped. The backend endpoint, worker dispatcher slot, and component file remain in place so the feature can be revived once it has a clearer in-product story
  • ·Quick map fallback name now includes HH:MM:SS so a second same-day map with no parseable source URL (plain text on both sides) no longer collides with the existing day's row under the unique-name constraint
v3.0.0
2026-05-11
major

Never unmatched: local semantic resolve with MiniLM.

A new Resolve with semantics button on the review page pairs every still-unmatched source URL with its closest target by cosine similarity over MiniLM-L6-v2 embeddings, computed locally. The pass runs only when the user asks for it (no auto-fire, no background magic) and only persists a match when the similarity clears a configurable threshold. Below-threshold rows stay unmatched and continue through the map's fallback strategy. The why-this-match panel now surfaces the cosine score for any embedding-resolved row so users can see exactly why the model picked what it picked. Closes the never-unmatched gap without surrendering the transparency that separates us from black-box tools.

  • ·POST /v1/maps/{id}/embed-unmatched enqueues an embedding_fallback job. The button on the review page is enabled only when the map has unmatched rows and is not archived. The endpoint refuses to queue when zero unmatched remain (409), when the map is archived (404), and when another embedding job is already running on the map (409 with existing_job_id so the frontend redirects to its progress page)
  • ·Worker grows a third dispatcher for kind='embedding_fallback' with one slot. The MiniLM model is shared in-process; parallel embedding jobs across maps would contend on the same gomlx session, so we serialise them. Other job kinds (match, title_crawl) are unaffected and continue to run in their own dispatchers
  • ·Embeddings are computed via knights-analytics/hugot with the gomlx simplego backend. Pure Go: no CGO, no libonnxruntime, no Rust tokenizers binary. Slower per-call than ONNX Runtime would be, but the worker image stays a static binary and the deployment posture (distroless nonroot) is unchanged
  • ·Model bakes into the worker image at build time. The Dockerfile invokes a small download-model helper that calls hugot.DownloadModel; the resulting ~90 MB of weights lands at /opt/swallowtail/models in the final image. First-job latency on a fresh pod is just the cold-load time, not a network round-trip
  • ·New app.target_embeddings table caches per-target vectors keyed on url_id with the model name and the exact source_text the model embedded. Cache invalidates automatically when the source text changes (e.g., when a title-crawl run populates an empty title), because the recomputed key no longer matches the persisted row
  • ·Pipeline batches match writes in chunks of 256 inside a single fenced transaction. SSE progress flushes at chunk boundaries so the match-progress page shows real motion instead of a binary 0-then-N flip. The matcher uses the same pattern at 500-row granularity; embedding stays a touch finer-grained because each row's cosine pass is heavier than a fuzzy comparison
  • ·Why-this-match panel surfaces cosine, threshold, and model name for any row resolved by the semantic pass. The signals JSON carries the values; the panel renders a dedicated table block rather than mixing them into the per-signal weights view used by the fuzzy matcher
  • ·Migration 00021 re-adds 'embedding' to the matches.method enum (it was dropped in 00010 alongside the experimental embedding code that has now returned in a hardened form), expands the jobs.kind enum to include 'embedding_fallback' (and incidentally tracks 'title_crawl' which the API has been inserting against an enum that did not list it), and creates app.target_embeddings with RLS scoped to the calling org. The down migration refuses to run if any matches still carry method='embedding'
  • ·Worker memory bumped from 1280 MiB to 3 GiB (compose limit) with GOMEMLIMIT at 2750 MiB to leave headroom for the gomlx tensor pool. The MiniLM resident weights sit around 150 MiB; the rest is buffer for batches and the cosine candidate vectors
v2.3.0
2026-05-11
minor

Background matches and pre-export orphan triage.

Phase 2D and 2F finish lines. Matches no longer require sitting on the progress page; a Watch in background button hands the job to a pill in the app shell so the user can keep working. Browser notifications fire on completion when the tab is hidden, with cross-tab dedupe so two-tab users do not get notified twice. Export now interrupts on unmatched rows, surfacing the orphan groups by path pattern so users can resolve them before shipping a half-baked file.

  • ·New Watch in background button on the match progress page. Click registers the job in localStorage, asks for browser-notification permission synchronously (works on Safari and Firefox), and routes the user back to the map's edit page. A pill in the app shell tracks progress across every route until the match completes
  • ·Pill lives bottom-left so it does not collide with the propagate or fallback toasts at bottom-right. Caps visible rows at three with a +N more running line for very active workspaces
  • ·Background pill resumes the SSE event stream from the last event_seq seen rather than replaying the whole event log on every page navigation. Persistence happens on a separate per-job storage key so the cross-tab storage event does not storm
  • ·Pre-export orphan triage gate on the review page. When the user clicks Export with unmatched rows present, a modal lists the orphan groups by source_pattern_stem with row counts and three sample paths per group. The user can cancel, send all unmatched to one URL via the existing fallback flow, or proceed with the export and accept the current fallback strategy
  • ·Background-job registry is now namespaced by signed-in user. Sign-out wipes the registry, per-job seq writes, and fired-notification flags so a shared browser cannot retain prior-user job ids and map names
  • ·Auth-failure SSE responses (401 / 403) now propagate the HTTP status to consumers and call onClose so the pill drops the job rather than displaying stale progress forever
  • ·Browser notifications fire only when the document is hidden and only once across simultaneously-open tabs (first tab to observe the terminal event claims the OS notification via a localStorage flag)
v2.2.0
2026-05-11
minor

Project view: filter chips, table view, match-complete toast, domain column.

Phase 2D, 2E, and 2F gaps fold into one release. Project detail page gains a table view alongside cards and four filter chips for triaging maps by state. New maps default to a date-stamped name so creating two in the same hour does not collide. Super-table shows a Domain column when a map is many-to-one. Finishing a match now drops a dismissible completion banner on the review page so the auto-redirect from /match no longer feels like the work disappeared.

  • ·Project detail page (/projects/{id}) gains a Table / Cards toggle. Table is the default since the spec called for dense scanning of maps; the preference saves to localStorage per browser so opting back to cards sticks across sessions
  • ·Project detail page filter chips: all / active / audits / archived, each with a live count. Active is the default selection so archived maps stay out of the way until you ask for them. The audits chip filters on source_tag = ghost-audit and stays empty until quick-lookup ships in Phase 2C
  • ·New map form prefills the name with New map · YYYY-MM-DD so users can submit without thinking up a name. On collision the client retries with · v2, v3, ... up to v6 before surfacing the 409. Users who type their own name see the 409 directly so they can fix it
  • ·Super-table grows a Domain column that appears only when the map is many-to-one (more than one unique target hostname across loaded rows). 1:1 maps stay column-quiet since every row points at the same domain anyway
  • ·Review page shows a dismissible Match complete banner after auto-redirect from the match progress screen. Shows processed / total counts pulled from the SSE done event, auto-dismisses after six seconds, strips its own query params on read so a refresh does not re-fire it
  • ·Job SSE done event now carries progress and total alongside status and error, so the toast can render counts without an extra job fetch
v2.1.0
2026-05-11
minor

Map admin polish: inline delete, archived-aware UI, edit-page rebuild.

Workspace admin work, not new product surface. The edit-page top row used to carry the title, status pills, four management buttons, two title-crawl buttons, a divider, and a helper paragraph wedged in between. Reworked into four labeled sections so each surface has a clear purpose. Archived maps now visibly disable every write action across edit and review, with one-line copy explaining why. Maps and projects can be deleted directly from their tile in the grid, no need to click in first. Plus a marketing-side cleanup: hero version pulls live from the changelog, SOC 2 claim removed, every primary CTA reads Map your redirects.

  • ·Map edit page restructured into four labeled sections: a clean Header (title + pills + Save and exit + Run match), Sources (the picker grid), Match quality (Pre-crawl titles + Refresh stale titles with a real explanation), and Map settings (Rename + Pin + Archive + Delete in a muted footer band)
  • ·Archived maps now visibly disable Run match, Re-run match, Pre-crawl titles, Refresh stale titles, source upload, bulk dock, row drawer Save, and the propagate toast Apply button. Each disabled button carries a title attribute pointing the user at unarchiving. Export, filter, and drawer view stay enabled so archived maps remain useful read-only artifacts
  • ·Cancel match on /maps/{id}/match stays enabled even when the map is archived, so a job started before archiving can still be stopped
  • ·Map tiles on /projects/{id} surface a trash-icon affordance in the top right corner. Click goes through a native confirm naming the resource, then deletes via DELETE /v1/maps/{id} and refreshes the grid
  • ·Project tiles on /projects surface the same trash-icon affordance. Scratch projects hide the icon entirely (backend already enforces scratch-immutable). Non-empty projects short-circuit client-side with an inline message instead of a 409 round-trip
  • ·Landing hero trust strip now reads live version + release date from the changelog instead of a hardcoded v1.4.2 string that was nine releases stale
  • ·Landing hero no longer claims SOC 2 type II certification. We are not certified; a regulatory claim on the marketing page was misleading
  • ·Every primary CTA across landing, the marketing site-CTA band, and the PageStub-based legal pages reads Map your redirects. Variants like Map a site free up to 250 URLs and Begin a notebook retired
  • ·Pricing Studio CTA reads Start with Studio. Free-trial language removed; the product no longer offers a trial
  • ·Pricing tier descriptions rewritten in plain who-it's-for copy. Mixed metaphors (naturalist / printing press / expedition) dropped in favor of one register
  • ·Contact page pruned to two cards. The [email protected] sales card and the post / mailing-address card are gone; [email protected] handles general and security
  • ·About page fixes a typo on the founder name: Aidan -> Aiden
  • ·Site-CTA band headline changes from Begin with the Notebook / Upgrade when you need the press to Two sitemaps in / One redirect file out
  • ·Masthead Vol/Issue line drops the Issue token on dot-zero releases so v2.0 reads as Vol. 02 - Field Guide instead of Vol. 02 - Issue 00 - Field Guide. The next minor release brings the issue counter back
v2.0.0
2026-05-10
major

Why this match.

Phase 20: matcher transparency and bulk leverage land in one release. The review drawer exposes the matcher's reasoning per row, manual overrides on a clustered source path offer to apply to every sibling, the bulk dock speaks each confidence band's language, exports carry a provenance preamble for deterministic verification, title-crawl gains a refresh button, and a v1.21 SSRF hole in the title-crawl HTTP client closes alongside.

  • ·Review drawer gains a How-we-matched panel with the matcher's per-signal breakdown (slug, depth, segment alignment, title similarity) and the row's override history. Lazy-loaded so the drawer still opens in under 100ms
  • ·New GET /v1/maps/{id}/matches/{source_id}/audit endpoint backs the panel; RLS-scoped per-row history reading
  • ·Picking a runner-up on a row whose source path belongs to a wildcard cluster (>=3 eligible sibling rows in /blog/2019/*, /products/legacy-*, etc.) surfaces an Apply to N sibling rows toast. One click propagates the chosen destination across the cluster's unmatched and sub-0.6-confidence rows; high-confidence auto-matches are never clobbered
  • ·Bulk dock copy now reads each band's intent: green selections frame the action as Override destination on N, red selections as Pick destination for N, wildcard selections as Verify children. Mark unmatched is hidden for low-confidence and wildcard selections where it has no useful meaning
  • ·Every export (CSV, htaccess, nginx, JSON) now carries a provenance preamble with the generation timestamp, map id, sha256 of the body, and a deterministic rule-set hash. The same fields ship as X-Swallowtail-* response headers so tooling that strips the in-body preamble can still verify content
  • ·Two exports of the same map state yield byte-identical signal_hash regardless of re-render order, supporting the deterministic-redirect-mapper positioning
  • ·Title-crawl gets a Refresh stale titles button on /edit alongside Pre-crawl titles. New force_refresh flag on the job payload re-fetches every URL, not only those with NULL title_fetched_at
  • ·Title-crawl button relocated into the map header row next to Rename/Pin/Archive with an inline explanation of when the feature helps (opaque slugs like /p/123)
  • ·Title-crawl HTTP client now routes through the SSRF-safe crawl package: requests to RFC1918 / loopback / link-local addresses are rejected at the dial layer. Previously a crafted sitemap could have steered the worker at 169.254.169.254 or localhost:5432
  • ·Migration 00018 adds app.matches.source_pattern_stem (nullable text) with a partial index on (map_id, source_pattern_stem). Populated on every match run; existing pre-v2 rows stay NULL until the map is re-matched
  • ·New op=propagate on POST /v1/maps/{id}/matches:bulk targets every row matching a source_pattern_stem (e.g. /blog/2019/*) whose target is unmatched or sub-0.6. Audit event records the stem and affected count
  • ·MatchRow JSON now exposes source_pattern_stem alongside the existing signals and runner_ups fields
  • ·Quick map error banner no longer renders Match couldn't start as a literal entity string when the upstream match request fails
  • ·Title-crawl error toast translates raw backend problem titles like db error into actionable copy (Couldn't start crawl, try again in a moment) and routes 401 and 404 to honest user-facing messages
  • ·Project new-map submit button shortens to Create map (was Create map · add sources)
  • ·Pricing matrix legend (present / limited / absent key) moves under the Provision column header and renders at a readable size so the symbols are legible at a glance
v1.22.0
2026-05-10
minor

Reliability sweep: idempotency, status page, account delete.

Bug-fix sweep across the API and frontend. The idempotency middleware now reserves the slot before running the handler, so two requests racing the same key cannot both run. The status page no longer fakes a green signal when the worker probe is broken. Account deletion no longer races concurrent invitation acceptance.

  • ·Concurrent requests with the same Idempotency-Key now return 409 with Retry-After while the first is in flight
  • ·Reusing an Idempotency-Key with a different body returns 409
  • ·If the API cannot record the response, it returns 503 so the retry path stays honest
  • ·Multipart uploads, SSE GETs, and other safe-method requests bypass idempotency caching
  • ·Stuck pending idempotency claims self-recycle after 5 minutes so a crashed handler does not block all retries on that key
  • ·Status page now surfaces the worker as unknown until a dedicated liveness endpoint ships, and the headline distinguishes unknown components from a fully green state
  • ·Deleting your account no longer silently wipes a workspace if someone accepted an invitation at the same moment
v1.21.6
2026-05-08
patch

Left-align app content for better use of horizontal space.

Pages inside the rail + crumbs shell now sit flush-left in the main column instead of being centered, so whitespace falls on the right and reading lines stay anchored. Settings placeholder cards switch to a left-aligned stack as well.

  • ·App page containers (home, jobs, quick map, projects, project folder, match, new map) drop mx-auto so the max-w line-length constraint anchors flush-left in the main column
  • ·Settings placeholder cards (billing, api-keys, webhooks) drop text-center, swap py-12 for p-8, and let eyebrow / heading / body stack left-aligned with a max-w-[520px] body for readability
  • ·Settings shell verified: 240px + 1fr grid layout already keeps the main pane flush-left; no changes needed
v1.21.5
2026-05-08
patch

Audit follow-up: SMTP port guard, secret printable check, JWK kid logging.

Closes the medium and nice-to-have items from the production audit. SMTP_PORT no longer silently becomes NaN, the Better Auth secret loader rejects binary garbage, the API logs which JWK kids it loaded so kid mismatches are debuggable, and the in-memory refresh-rate map gets a soft-cap eviction so a long-lived process can't grow it forever.

  • ·SMTP_PORT now fails fast in production if SMTP_HOST is set and the port string is non-numeric or out-of-range; default 587 still applies when SMTP_PORT is unset
  • ·readSecretEnvOrFile in lib/auth/index.ts now logs the path on fs error and rejects non-printable bytes so binary garbage cannot satisfy the 32-char length check
  • ·app-shell catches around session and org lookups now log the underlying error before falling back to the signed-out state or 'Workspace' label, so DB outages are visible to on-call
  • ·lib/auth/db.ts drops the no-op statement_timeout / idle_in_transaction_session_timeout Pool constructor options (pg ignored both) and applies them via a per-connection SET on the connect event instead
  • ·token-for-go refresh-rate Map evicts entries older than the 1h window once size crosses 10k, capping per-process memory growth without a scheduled task
  • ·API boot logs the kids loaded from JWK_RING_PATH so ops can confirm the frontend's JWT_KID is actually in the ring before the first authenticated POST
  • ·Worker title-crawl persist documents why it intentionally skips claim.FencedWrite (UPDATE-by-id to scalar columns is idempotent under racing workers; no row count diverges)
  • ·Worker heartbeat documents the deliberate asymmetry vs FencedWrite: production already connects as swallowtail_app, tests must set the role explicitly when exercising heartbeat directly
v1.21.4
2026-05-08
patch

Production hardening: titlecrawl RLS, JWT boot checks, eager key load.

Closes a production-only audit. Boot fails fast on missing JWT envs, the JWT private key is parsed at module load instead of first request, and the title-crawl worker no longer runs an app.urls SELECT outside an org context, which would have RAISEd the moment a real job hit it.

  • ·Worker title-crawl SELECT now wraps in pool.Begin + WithOrgContext so the app.urls policy's app.current_org_id() call sees a bound GUC. Previously the SELECT ran outside any tx and would have RAISEd under the swallowtail_app role (NOBYPASSRLS)
  • ·BETTER_AUTH_URL is now required at boot when NODE_ENV=production and must start with https://. Silent undefined was making invitation links point at localhost and breaking trustedOrigins on the cookie domain
  • ·JWT_KID, JWT_PRIVATE_KEY_PATH, and ALLOWED_ORIGINS validated at boot via a shared lib/auth/jwt-config helper imported by the token mint and proxy routes; production refuses to start without them set
  • ·JWT private key now loads at module import via a top-level Promise. Malformed PEM crashes the container at boot with a visible stack instead of returning a generic 500 on the first authenticated POST
  • ·auth.api.* calls during NEXT_PHASE=phase-production-build now throw a clear 'auth.api called during build phase' error instead of attempting a real DB hit and failing with an opaque error
v1.21.3
2026-05-08
patch

Better Auth pool Proxy now forwards has trap.

The lazy pg.Pool Proxy in lib/auth/db.ts only implemented the get trap, leaving has, ownKeys, and getPrototypeOf to fall through to the empty target object. Better Auth's kysely-adapter detects dialect with `"connect" in db`, which returns false on a Proxy with no has trap, so the adapter init bailed with `Failed to initialize database adapter` even though every other connection path worked.

  • ·lib/auth/db.ts: Proxy gains has, ownKeys, getOwnPropertyDescriptor, and getPrototypeOf traps that all build the pool on demand and forward to the real instance
  • ·Refactored construction into ensurePool() so each trap calls a single helper instead of duplicating the lazy-init pattern
v1.21.2
2026-05-08
patch

PgBouncer auth wired through to apps.

Production sign-up was still 500ing after migrations 00015/00016 because pgbouncer had no way to authenticate clients (empty userlist) and the app DATABASE_URL had no password to send. Both now read from a single SWALLOWTAIL_APP_PASSWORD env var so client + server sides of the SCRAM handshake stay in sync across redeploys.

  • ·compose.yml: pgbouncer service gets DB_PASSWORD wired from SWALLOWTAIL_APP_PASSWORD. The edoburu/pgbouncer entrypoint uses that env to populate /etc/pgbouncer/userlist.txt at start; without it the userlist was empty and every connection was rejected
  • ·compose.yml: frontend, api, worker, and netdata DATABASE_URL/PG_DSN now interpolate ${SWALLOWTAIL_APP_PASSWORD} so the client SCRAM dance has a password to verify against the role
  • ·Dropped the obsolete pgbouncer_userlist Docker secret. PgBouncer cannot reverse a stored SCRAM hash to authenticate to Postgres anyway, so the file approach was a dead end. Plaintext-in-DB_PASSWORD is the documented edoburu pattern
  • ·DEPLOY.md 5a rewritten: generate plaintext password once, set on the role via ALTER ROLE, paste the same plaintext into the Dokploy Environment tab as SWALLOWTAIL_APP_PASSWORD. Editing .env directly is futile because Dokploy regenerates that file from the UI tab on every redeploy
v1.21.1
2026-05-08
patch

Sign-up unblocked: Better Auth schema + role login.

Sign-up was 500ing on a fresh production deploy because the Better Auth tables had never been encoded as a migration and the swallowtail_app role was created NOLOGIN. Both are now part of the migration set so a cold deploy boots into a working auth flow without any out-of-band psql.

  • ·Migration 00015 creates user, session, account, verification, organization, member, invitation, and rateLimit tables with the indexes, FKs, and grants Better Auth expects
  • ·Migration 00016 flips swallowtail_app to LOGIN. Password is still set out-of-band by the ops admin so the secret never lives in source
  • ·DEPLOY.md gains step 5a (set the role password, regenerate pgbouncer userlist, restart frontend + pgbouncer) and 5b (chmod 0444 on secrets the non-root nextjs container needs to read, with the parent dir locked to 0700)
v1.21
2026-05-08
minor

Title-crawl signal + production hardening.

The matcher gets its first content signal: an opt-in title crawl populates a cached page title for every URL on a map, and the fuzzy scorer mixes title similarity in alongside slug, depth, and segment-alignment when titles are present on both sides. Bumps match quality on sites where slugs are opaque (/p/123 vs descriptive titles). Plus two production-readiness fixes from the launch audit.

  • ·New /v1/maps/{id}/title-crawl endpoint enqueues a title_crawl job. Worker dispatcher gets a second slot pool (2 workers, 2s poll) so a slow crawl does not block match dispatch
  • ·Title fetcher pulls each URL with an 8s timeout, extracts <title> and meta description from the first 512KB of HTML, and persists onto app.urls.title (max 512 chars) and app.urls.meta_description (max 1024). On fetch error the row still gets title_fetched_at set so subsequent runs do not re-try forever
  • ·Fuzzy matcher reweights when both sides have titles: 42% slug + 14% depth + 14% segment-alignment + 30% title. Otherwise the original 60/20/20 path-only formula stays. The new title_similarity signal is recorded alongside the existing signals on every fuzzy match
  • ·Pre-crawl titles button on /maps/{id}/edit kicks the job; the dialog reports queued or already-running with the existing 409 conflict shape
  • ·Migration 00014 adds title, meta_description, and title_fetched_at columns to app.urls with length-bounded check constraints and a partial index on (map_id, title_fetched_at) for fast unscanned-row lookups
  • ·Production hardening: BETTER_AUTH_SECRET is now required at boot when NODE_ENV=production (32+ chars). Dev still uses the existing fallback so local stacks stay one-command-runnable
  • ·Production hardening: sendMail now throws when no transport is configured in production. Console logging reset emails and invitations into the void was a silent failure mode that would lock real users out without surfacing the cause
  • ·Mail transport supports SMTP via nodemailer in addition to Resend. Set SMTP_HOST + SMTP_PORT + SMTP_USER + SMTP_PASS for Google Workspace, Postmark, Mailgun, etc. SMTP wins over Resend if both are configured
  • ·Branded HTML email templates for verify, reset password, change email confirmation, and invitations. Plain text alternatives stay first-class for no-HTML clients
  • ·Better Auth gets database-backed rate limiting (sign-in 5/60s, sign-up 5/10m, forgot/verify/change-email 3/10m, change-password 5/10m, delete-user 3/10m). Cookie attributes now explicitly set sameSite=lax, secure (in production), httpOnly. trustedOrigins seeded from BETTER_AUTH_URL
  • ·BETTER_AUTH_SECRET can now be loaded from a Docker secret file via BETTER_AUTH_SECRET_FILE. Compose mounts /run/secrets/better_auth_secret automatically
  • ·Content-Security-Policy added to next.config.mjs alongside the existing X-Frame-Options, STS, Referrer-Policy, and X-Content-Type-Options headers. Permissions-Policy denies camera, microphone, geolocation, and FLoC
  • ·Compose env wires production-only INTERNAL_BASE_URL=PUBLIC_ORIGIN, NODE_ENV=production, BETTER_AUTH_SECRET_FILE, full SMTP variable surface, Google + GitHub client ids
  • ·Deploy guide: infra/dokploy/DEPLOY.md walks through Cloudflare Tunnel setup, secrets generation (including the JWT_KID flow), env config, smoke test, and ongoing ops
  • ·.env.example files for both the frontend (dev) and infra/dokploy (prod), documenting every required and optional variable
  • ·Social provider list now reflects what we actually wire: Google + GitHub. Microsoft removed; each provider's button only renders when both client id AND secret are set so unconfigured providers stay hidden
v1.20
2026-05-07
minor

Account settings.

Real /settings/account surface wired to Better Auth. Update your name, change your email through a verification link, change your password (with optional sign-out of other devices), see and revoke active sessions, and delete the account if you want out. Account dropdown picks up a direct Account settings link.

  • ·/settings/account loads the active session, lists all sessions, and shows profile, email, password, sessions, and delete sections
  • ·Profile form updates user.name via authClient.updateUser
  • ·Change email goes through Better Auth changeEmail. Verified users receive a confirmation link before the change applies; unverified users update directly via updateEmailWithoutVerification, which keeps the wizard working before email verification ships
  • ·Change password validates length, confirmation, and difference, with a checkbox to sign out other devices via revokeOtherSessions
  • ·Sessions table renders short browser+OS labels, marks the current session, and lets the user revoke others individually or all at once
  • ·Delete account requires typing the email and current password, calls authClient.deleteUser, then routes to /sign-in with a flash
  • ·Settings shell sidebar gains an Account tab between General and Members
  • ·Account dropdown gains a direct Account settings link
  • ·Bug fix unrelated to account settings but caught while testing it: the Better Auth org-create hook used the legacy organizationCreation.afterCreate key, which does not exist in current Better Auth and was silently dropping the app.org_refs insert for every new organization. Renamed to organizationHooks.afterCreateOrganization so new orgs get their tenant ref row again
  • ·Matcher Tier 2: the fuzzy slug compare now normalizes both sides through a stop-word strip + light suffix stem (the-best-of-the-week and best-week now match; running matches run; categories matches category). Per-segment alignment uses the same normalized tokens so kebab vs snake vs dot-delimited slugs no longer suppress real matches
  • ·Status page now runs live probes against the Application API health endpoint, the Postgres pool, and the match worker heartbeat instead of the static 90-day mock. Reports up, down, or unknown per component, with the headline switching to Service degraded when any probe fails. The fake uptime bars and percentages are gone; a small note explains that long-term history lands when the dedicated uptime monitor ships
  • ·Email verification on sign-up: Better Auth now sends a verification email automatically (sendOnSignUp). /settings/account renders a verified or unverified pill next to the user's email and exposes a Resend verification button when the address is still pending
  • ·Settings sidebar gains Billing, API keys, and Webhooks tabs. The first stays at the existing Phase 14 coming-soon panel; the API keys and Webhooks pages ship as labelled stubs so the docs/api chapter no longer references missing routes
v1.19
2026-05-07
minor

Quick map.

One-shot path from two domains to a finished redirect file. Drop the old URL, the new URL, and Swallowtail handles the project, the sitemap fetches, and the match in a single submit. Plus a UX sweep: dead Product nav anchor, missing pinned chip variant, indirect /team redirect, and stale ASCII arrows on the marketing nav all fixed.

  • ·/quick supports all four source types per side via a slim, four-tab compact pane (URL, File, CSV, Paste). Bootstraps a scratch map on mount, lets you mix any combination, only enables Generate redirects once both sides have at least one URL, and lands you on /maps/{id}/match. Files, CSV, and paste are first-class here, not deferred to the full setup
  • ·/quick sitemap-url tab now auto-fetches once the URL parses; no Fetch button required. Manual mode on /edit unchanged
  • ·Quick maps no longer pre-create rows on page load. The wizard buffers the chosen URL/file/paste per side client-side and only writes to the DB the moment the user clicks Generate redirects. Abandoned wizard visits leave nothing behind
  • ·Auto-fetch on the sitemap-url tab now waits for blur (with a 250ms grace) instead of firing mid-keystroke
  • ·Smart fallback strategy on /projects/new: choose how unmatched URLs flow into the export. Options are None (current behavior, drop them), Send to home (always /), Send to fallback URL (uses the configured fallback), and Nearest parent on new site (walks the source path up and emits the first parent that exists on the new side, else /). Backend validates the strategy and resolves it at export time across CSV, htaccess, nginx, and JSON
  • ·Send unmatched to... button on /review opens a small dialog with quick-pick destinations (home, 404, help) plus a free-text URL field. Click Apply and every unmatched row in the map gets the chosen target via the existing filter-aware bulk endpoint
  • ·/quick reuses the existing scratch project (won't create a junk Scratch folder) and gates on a real session
  • ·If a quick-map step fails, the user lands on /edit with a banner identifying which side broke and why, plus the original reason from the API
  • ·Quick map CTA promoted on /home above the regular New map button; New map page surfaces a banner pointing back to /quick
  • ·App rail and mobile drawer get a Create section listing Quick map and New map
  • ·Site nav Product link now points at the existing #specimen anchor instead of /#product (which did not exist)
  • ·Home page pinned chip renders with pill ok, matching the rest of the app
  • ·App rail org link and mobile drawer point straight at /settings/members instead of bouncing through the /team redirect
  • ·Marketing nav Dashboard CTA renders with a real arrow instead of ASCII
  • ·Project and map list cards become a real tile component: paper-100 surface, hard 4px corners, accent left rule, hover lift with subtle accent tint, and a footer row separating updated time from the open affordance
  • ·Sitemap URL form normalizes input: bare domains auto-extend to /sitemap.xml; missing scheme defaults to https://
  • ·Masthead Vol/Issue and updated-date lines across the site now derive from the newest changelog entry, so they stay in sync without per-page edits
v1.18
2026-05-07
minor

App-shell polish.

Source uploads gain a real drag-and-drop zone with filename chips and a clear button. Plain-text paste lands as its own first-class tab. Map status (pinned, archived, name) follows users across edit, match, and review. The app shell now links back to the marketing site so users can move freely between the two surfaces. Members tab cleans up role styling.

  • ·Drag-and-drop file upload with filename + size chip and clear button; click-to-browse still works
  • ·Plain-text paste tab posts directly to the kind=text endpoint as a Blob
  • ·Shared MapStatus component renders pin/archive chips and Rename/Pin/Archive buttons consistently across edit, match, and review tabs
  • ·New pill muted token so unpinned and active states render as visible chips, not absent ones
  • ·App shell exposes Home, Docs, and Changelog links (desktop top bar + mobile drawer) so users can return to marketing without editing the URL
  • ·Members tab role column renders as a chip; you marker promoted to a proper pill
v1.17
2026-05-07
minor

Comprehensive review pass.

Five-agent audit of every interactive surface, the design-system tokens, accessibility, mobile layouts, and empty/error states. Dead buttons removed or wired, missing keyboard paths added, mobile gets a real nav drawer, and the app gets global error and loading boundaries.

  • ·Review page: filter chips render with the right class, zero-state CTA leads to /match, dead sort arrows and keyboard hint footer removed, rows get keyboard equivalence, drawer and export modal expose a proper accessible name
  • ·App rail collapses into a hamburger drawer on mobile; skip-to-content link added; members tables scroll horizontally instead of overflowing
  • ·Auth form inputs are properly label-associated; dead OAuth buttons removed
  • ·Account dropdown supports Escape, ArrowUp/Down, Home/End, returns focus to the trigger
  • ·Match progress dial exposes role=progressbar plus an aria-live status region
  • ·App-level error.tsx + loading.tsx so unhandled crashes get a branded recovery page
  • ·Settings forms now use the design-system text-error token instead of Tailwind text-red-600
  • ·Replaced /settings/billing dead surface with a Phase 14 coming-soon panel; removed dead /status subscribe and RSS buttons; /field-notes redirects to /docs
  • ·Match drawer candidates now show the actual destination path instead of a UUID prefix, so runner-up URLs are visibly comparable
  • ·Match progress numerator clamped to total so a stale job total never renders as e.g. 64,800 / 45,050 URLs
v1.16
2026-05-07
minor

Job history goes live.

/jobs lists real match runs across the workspace; click into running ones for live progress, completed ones for review. /exports redirects to /home (export surface lives in the /review modal). Bulk Set destination and drawer manual entry reject javascript:, data:, vbscript:, file: schemes.

  • ·/jobs lists real match runs across the workspace; click into running ones for live progress, completed ones for review
  • ·/exports redirects to /home (export surface lives in the /review modal)
  • ·Bulk Set destination and drawer manual entry reject javascript:, data:, vbscript:, file: schemes
v1.15
2026-05-07
minor

Marketing/app continuity and review-page polish.

Signed-in users no longer look signed out when they bounce to the landing page or docs. Match progress now climbs monotonically and never reports more than 100. CSV exports use the column shape hosting tools expect. /review picks up real total counts, breadcrumbs, working select-all, and filter-aware bulk actions. Source uploads truncate at the tier cap instead of rejecting the whole upload.

  • ·SiteNav surfaces an account menu + Dashboard link when a session is present; it stays statically renderable so marketing pages don't pay a per-request session lookup
  • ·App top bar brand link routes to / so users can leave the app without dropping the session
  • ·Match progress denominator seeds at worst-case (2x sources) and tightens after phase 1, so the percentage only ever goes up; UI clamps at 100
  • ·CSV export reshapes to Old Page URL, Destination Page URL, Redirect Type with a constant 301
  • ·GET /v1/maps/{id}/matches returns total_matched and total_unmatched; review header shows both
  • ·/review tab adds breadcrumbs and step pills consistent with edit and match pages
  • ·Header select-all checkbox now toggles every visible row
  • ·Bulk dock gains Set destination; backend bulk endpoint accepts a filter spec for apply-to-all-matching ops, validated to reject empty filters
  • ·Source uploads truncate at the tier cap with a truncated flag and an inline warning instead of rejecting the upload
  • ·Edit-page side header drops the duplicate old-site / new-site title under the eyebrow
  • ·Source Serif 4, Inter Tight, and JetBrains Mono now actually load via next/font
v1.14
2026-05-07
patch

Security hardening + ops.

Round of fixes from the PR #14 review. Open-redirect surface tightened, error paths in invitation accept and signup now check setActive, invitation expiry copy now matches reality (7 days), email and flash inputs sanitized. Compose file wires JWK_RING_PATH and JWT_PRIVATE_KEY_PATH so api and frontend boot in production.

  • ·safeCallback uses URL constructor + same-origin check; rejects backslash and control character bypasses
  • ·setActive .error checked in accept-invitation and signup before navigating, surfaces failure inline
  • ·invitationExpiresIn set to 7 days to match the email copy
  • ·Flash banner caps decoded value at 200 chars and strips control characters
  • ·Email subject scrubs CR/LF and tab in user-controlled fragments (defense in depth against future SMTP transports)
  • ·compose.yml now mounts jwt_private_key and jwt_public_jwks secrets and wires GO_API_URL, INTERNAL_BASE_URL, RESEND_API_KEY
v1.13
2026-05-07
minor

Invitation flow + auth UX polish.

Closing the invitation loop and tightening the auth experience. Invited users now have a real landing page. Sign-in surfaces flash messages from upstream actions. Password reset and invitation emails go through Resend when RESEND_API_KEY is set; console logs in dev otherwise.

  • ·/accept-invitation/{id} renders signed-in vs signed-out states, fetches invitation details, and accepts or rejects
  • ·Sign-in shows a flash banner when ?flash= is present (e.g. after password reset)
  • ·Invited users are routed back to /accept-invitation after sign-up via callbackURL, and skip workspace creation
  • ·Resend integration for password reset and invitation emails when RESEND_API_KEY is set; falls back to server-console logs
v1.12
2026-05-07
minor

Auth completion and ops.

Three gaps that kept users locked out, stuck inside, or alone now close. Forgot/reset password works end to end. Long-running match jobs can be cancelled from the progress page. Workspaces can invite teammates with real Better Auth invitations.

  • ·POST /v1/jobs/{id}/cancel sets job to cancelled, audits match.cancelled, fences worker writes via existing claim_epoch protocol
  • ·Cancel match button on /maps/{id}/match aborts the SSE stream and routes back to /edit
  • ·/forgot-password and /reset-password wired to Better Auth requestPasswordReset / resetPassword
  • ·/settings/members lists real members and pending invitations; admins can invite by email and revoke pending invites
  • ·Dev console logs reset and invitation URLs until production email integration lands
v1.11
2026-05-07
minor

Happy-path completeness.

Closing the gaps that blocked a real signed-up user from actually using the product end to end. Sign-out, settings save, real folder view, working step nav, /lookup redirect.

  • ·Account avatar in the top bar opens a real menu with the signed-in email and a working Sign out
  • ·/settings now reads the active org and saves a rename via Better Auth
  • ·/projects/{id} replaces the placeholder redirect with a real folder view (maps grid + New map)
  • ·ProjectSteps pills on the map header are now real Links that navigate sources / match / review
  • ·Save and exit button on the edit page routes to /home
  • ·/lookup redirects to /projects/new instead of showing the static mock
v1.10
2026-05-07
minor

Exports + depth pass.

End of the happy path now produces a real file. CSV, .htaccess, nginx.conf, JSON formats all stream from the API and trigger a browser download. Cleaned up shallow surfaces from the agent-team audit so signed-up users stop hitting dead buttons.

  • ·GET /v1/maps/{id}/export?format=csv|htaccess|nginx|json streams matched rows
  • ·Review export modal wires to real download with correct filename and Content-Disposition
  • ·/onboarding now redirects to /projects/new (the wired path) instead of static mock
  • ·App-shell nav hides /jobs, /exports, /lookup, /settings stubs until those phases land
  • ·Decorative confidence slider on /review removed (was hardcoded width:60%)
v1.9
2026-05-07
minor

Review interactions go live.

The /review super-table now lets you accept, override, clear, and bulk-edit match outcomes. Manual entries persist as 'manual' matches with a full audit trail. Long-running match streams auto-reconnect from the last event so a network blip no longer freezes the progress dial.

  • ·Drawer Save wires PATCH /v1/maps/{id}/matches/{source_id}: pick a runner-up, type a custom URL, or mark unmatched
  • ·Bulk dock supports Mark unmatched and Delete across selected rows via POST /v1/maps/{id}/matches:bulk
  • ·Header Re-run match enqueues a new match job and routes to the live progress page
  • ·SSE consumer tracks event_seq and resumes via ?since= on disconnect; capped at 5 attempts with 1s backoff
  • ·All overrides and bulk operations append to the audit log under match.override and match.bulk
v1.8.1
2026-05-07
patch

Match engine hardening.

Post-launch fixes from the agent-team review pass. Worker queue now uses a dedicated swallowtail_worker role with BYPASSRLS scoped to app.jobs only, fixing a silent starvation in production. Failed match jobs now durably record state and an audit event. SSE consumer tolerates CRLF and multi-line data fields. Long-running streams no longer get killed by the API write timeout.

  • ·New swallowtail_worker role; ClaimNext, Requeue, and heartbeat all run with org context set so RLS admits the writes
  • ·failJob fences on claim_epoch, sets org context, and writes a match.failed audit event
  • ·Fuzzy loop checks ctx.Err() per iteration so heartbeat self-abort and tier deadlines actually preempt
  • ·Dispatcher applies a 30 minute handler timeout per claim
  • ·SSE consumer normalizes CRLF and joins multi-line data with \n; ignores keep-alive comment blocks
  • ·API SSE handler clears the server WriteTimeout so streams survive long matches
  • ·Drawer no longer shows fabricated runner-up URLs; renders real runner_ups or a clean empty state
  • ·Filter chips on /review expose aria-pressed for screen readers
v1.8
2026-05-07
minor

Match engine goes live.

Two-phase matcher (exact + wildcard, then fuzzy on residual) runs against every map. Live progress streams over SSE while the worker chews. Super-table on /review reads from app.matches and supports band filters and cursor pagination.

  • ·POST /v1/maps/{id}/match enqueues a job; idempotent (409 with existing job_id on retry)
  • ·Worker dispatcher claims with FOR UPDATE SKIP LOCKED, fences writes by claim_epoch, requeues stale claims at 60s
  • ·Fuzzy scorer emits signals (slug similarity, depth, segment alignment) and top-3 runner_ups per row
  • ·GET /v1/jobs/{id}/events streams progress via fetch + ReadableStream; resumes via since=event_seq
  • ·/maps/[id]/match shows live progress dial; redirects to /review on completion
  • ·/maps/[id]/review reads /v1/maps/{id}/matches with band + unmatched filters and Load more pagination
v1.7
2026-05-06
minor

Sources go live.

Sitemap URL fetch, sitemap file upload, CSV, and plain text uploads all parse, normalize, and persist into app.urls. Tier caps enforced at 250/50K/500K rows. Re-uploads append by default; replace=true clears a side first.

  • ·Sitemap URL ingestion fetches with cap-aware HTTP client and recurses sitemapindex up to depth 3
  • ·CSV parser handles BOM, CRLF, quoted commas, header detection, one or two column shapes
  • ·Plain text parser skips blank lines and # comments
  • ·Per-map normalization toggles (trailing slash, www, query, lowercase path) apply on insert
  • ·GET /v1/maps/{id}/urls returns parsed-sample rows for the edit-page panel
  • ·Live crawl tab still parking lot pending the crawler engine spec
v1.6
2026-05-06
minor

Projects and maps go live.

First end-to-end wiring of the dashboard. Home, Projects, and New map all read and write real data through the Go API. Folders, maps, archive, pin, rename, and per-map matching settings all persist. Sources upload and match runs land in subsequent releases.

  • ·Projects list and create reach the live /v1/projects endpoint
  • ·New map form persists fuzzy and wildcard thresholds, normalization toggles, and fallback URL
  • ·Map header on the sources page shows real name and exposes rename, pin, archive
  • ·Tenant isolation enforced via WithOrgContext on every read and write
v1.5
2026-05-05
minor

Field Guide docs section.

Six chapter docs landing with sticky contents, scannable prose, and one canonical answer per page. Quickstart, Sources, Matcher passes, Export presets, API and webhooks, Billing and seats. Site footer also tightened up: refreshed tagline, condensed spacing, relocation to Columbus.

  • ·New /docs landing with chapter cards
  • ·Six dedicated chapter pages with shared shell
  • ·Sticky contents nav with Roman numeral chapter index
  • ·Site footer polish: tighter spacing and refreshed tagline