Skip to content
Liminal
Menu

Changelog

What changed in each release of the Mac app and the service.

All notable changes to Liminal are documented here. This project adheres to Semantic Versioning.

[Unreleased]

Added

  • Correspondence federation F0, C1: the address syntax is reserved (no visible change for existing addresses). @<local>@<host> is reserved for a federated address and is refused everywhere until federation exists, by name (address_reserved) rather than as an invalid email or a silent typo: registration, key lookup, sealed publish recipients and deliveries, web-send, the desktop recipient box and identity field, and the MCP propose_send and whois. Bare addresses stay bare and emails stay emails. The rule now lives once, in @liminal/crypto, with one case table run by the crypto, backend, desktop and MCP tests.

  • grants.revoked_at column and its admin migration (D6, schema only, no visible change). The first half of bounded retention: a nullable timestamptz so a later sweep can delete a revoked grant 30 days after revocation. Nothing writes or reads it yet; run POST /api/admin/migrations/grant-revoked-at on prod before the writer merges. The operator settled the D6 retention numbers (revoked grants 30 days, delivery_opens collapsed into counters 90 days past revocation or expiry, padding to 4 KiB and 64 KiB above 256 KiB) and that D4b's removed rows follow the grant retention.

  • Correspondence federation F0 constraints C2, C3, C6, C7 (D7; no behaviour change). Four rules that keep a later federation buildable without a migration are now tests that fail when broken: an address is never split, truncated, re-lowercased or given a host (C2); the host of LIMINAL_PUBLIC_BASE_URL is the only identity host and the dead liminal.3mergen.com stays in routing and the desktop link rewrite only (C3); signerKeyFor is the only chooser of a client verifying key, with the delivery-page and session-participant verifiers named as exceptions (C6); key lookup authenticates before it reads and no public surface can emit an agent key (C7). The scans live in apps/backend/federation-f0.test.ts. C1 is the next entry; C4, C5, C8 are in #226.

  • The packaged smoke has a render-rich probe (E2). Inside the packaged app, a fixed in-memory note with one display formula and one mermaid flowchart is rendered through the preview's own page into a frame and measured: .katex and mermaid's SVG must have non-zero size, mermaid must not have drawn its error diagram, and no CSP violation may be reported. These are the two things that load KaTeX fonts and the self-hosted mermaid module, which only the packaged CSP can block. Inert without LIMINAL_SMOKE_MARKER; renderSelected and the probe now share renderPreviewHtml.

  • Free-placement diagrams F3: AI editing has a "Current diagram" scope. With the caret in a ```diagram block, the AI edit menu offers Current diagram (and Automatic picks it before the section around it). The request carries the block's exact source range, fence lines included, through the same disclosure, redaction and hash anchors as every other scope, to either rail; both rails give the model the same diagram format from @liminal/ai-edit. A result that is not exactly one closed diagram block with nothing around it, that has a line the parser cannot read, or that puts a box off the grid, on another box, without a position, or connects to a box that does not exist, is refused before any proposal exists, and the first problem is announced in the parser's words; problems the block already had do not count against it, as on the canvas. An accepted proposal keeps the note's own fence lines, sets generated_by to the rail's model (the Server's reported model, or Claude Opus for This Mac, which runs claude --model opus), and lists the boxes added, removed, moved, resized, renamed and restyled and the connections added, removed and changed beside the line diff. Apply writes only the lines that differ, as one transaction under the canvas's user event, so ⌘Z reverses it in one step; it never saves. Not offered where the canvas is not (a conflict, a live session, recipient surfaces).

  • Free-placement diagrams F4: a diagram in an ordinary note publishes, delivers and exports to Word. A ```diagram block in a page or a collection member draws the same figure a deck draws (the same renderDiagram bytes, the diagram rules appended to the page's stylesheet after the theme, only on a page that draws one), and an embedded note's block is found in that note's own lines. A page runs the structural rules only (diagram-unreadable, -off-grid, -overlap, -edge-unbound, -unplaced) through the same local gate as its tables, so a single publish, a collection member, a sealed delivery, a Word or Excel export and an AI proposal all refuse a block that cannot be drawn; figure-unbound and diagram-edge-crosses-node stay deck rules. A sealed delivery of a page carries the figure, its rules and its list, and passes the sealed-document policy unchanged (a Mermaid block is still refused). Word export writes the caption, then the list form as a two-level bulleted list, and the toast says the arrangement is not carried; nothing else in the .docx changed, and there is still no .pptx. A collection member and a page delivery now declare the note's language as a single publish already did, so <html lang> and a diagram's words (つながり / Connections) agree across the three. The office toast for a refused export reads "A table or diagram check did not pass".

  • Free-placement diagrams F2: Preview draws a diagram, and a canvas under the editor edits it. In Preview a ```diagram block shows the publisher's own figure (or its error) until the caret enters it. With the caret in a block, in Preview or Markdown, a Diagram canvas opens beneath the editor, drawn from the same HTML the publisher emits, with keyboard-operable controls laid over its boxes. Move (drag, or arrows), resize (corner handle, or ⇧ + arrows), rename (double-click, or Enter), add a box (double-click an empty cell, or N on the cell cursor), connect (drag from the side handle, or C, arrows to aim, Enter), change a connection (its toolbar, or H / D / V for heads, dashes and the side it leaves from), invert a box (T), group picked boxes (Space to pick, ⌘G), delete (⌫) and change the grid. Each action is one editor transaction that replaces only the lines the diagram writer changed, under a user event Undo never joins, so ⌘Z (also from the canvas) reverses exactly one action. A move or resize that would put a box off the grid or onto another box, and a line the reader would refuse, is not written and is announced as an alert; every change is announced as status. Tab follows the published list's reading order. Not offered while a live session owns the note, and not on recipient surfaces. The words are in the catalogue (m.editor.diagram, English and the Japanese draft).

  • Free-placement diagrams F1: a ```diagram block is drawn, in HTML and CSS only. Boxes, groups and connectors are grid items on a 2C+1 × 2R+1 track grid (gutters between cells, which connectors run in); a connector is one to three orthogonal legs, each one bordered element, with CSS-triangle heads; every label is DOM text. No <svg>, <canvas> or script, and style carries grid-area and nothing else. Colours are --fg/--bg either way round, --muted for second lines and group outlines, and an optional theme token --diagram-stroke that falls back to --fg. Below 768 px the grid is not drawn and the diagram's list form is shown (boxes in reading order, groups as headings, every connector as a sentence); above it the list stays in the accessibility tree. The router chooses, among paths of at most three legs, the one crossing the fewest boxes, then sharing the fewest cells with earlier connectors. New rules: diagram-label-overflow (error, measured: a label that does not fit its box, or a connector or group label drawn over a box or another label, at 768 / 1280 / 1920), diagram-edge-crosses-node (warning, read off the router's own path) and theme-diagram-stroke-faint (error). A block that cannot be drawn prints an error on the slide instead of a figure, and the 未対応 notice is gone. origin: author prints 「作成者の設計」 with the caption. A deck carrying a diagram seals and delivers.

  • What the server holds is inventoried column by column, and a new column cannot arrive unclassified (D6 P0, I1). apps/backend/lib/metadata-inventory.test.ts classifies all 251 columns of the 33 tables: sealed 20, content in the clear 28, metadata about people 103, and 100 that are about no one. Each routing or receipt column names the function that reads it, checked against that function's file. Four columns turned out to be read only by the person's export (grants.created_at, grants.granted_by, annotation_resolutions.resolved_at, loop_conversions.origin_link_id); they are listed as findings for the retention work.

  • Log context and Outband calls are held to fixed field lists (D6 I2, I9). A source scan holds every logEvent / captureError call to an id-only allowlist of context keys (no email, address, recipients, thread, IP or token reaches stdout or PostHog); it reads values as well as keys, so a request path under an allowed key fails, and covers account-delete's own log lines. Vercel's request log, which records /x/<token> paths, is outside it and named in INVARIANTS I2. A recording-fetch test holds every call to Outband to its field names and fails if another module reaches Outband.

  • Metadata privacy P3 and P5: deletion pseudonymises, and the product says what it records. Deleting an account whose address WorkOS has verified now replaces it with a keyed deleted:<hash> stand-in in other accounts' delivery receipts, for the links that person verified, so the sender keeps the row, the counters and the open times. Unverified browser accounts under the same address are deleted with it, and a pseudonymised link refuses Magic Auth and registration. Source-access leads, the WorkOS user and the Polar customer are not touched, and an unverified address changes nothing outside the account. Leads are now stored lowercased. The recipient page, the desktop inbox and the send sheet each gain one line, in English and Japanese, saying Liminal does not hold the key to read the content and does record who sent to whom and when. The operator confirmed both on 2026-10-04 (D6 Q4, Q7).

  • Several accounts on one Mac, one active (K4.1). Settings › Account lists every account this Mac is signed in to, marks the active one, and offers Add account, Switch, Sign out (of one) and Sign out of all accounts. Each account keeps its own Keychain session (workos-session.<workos user id>) and refreshes on its own, so one account's expired session no longer signs the others out. Published reads each other account's list with that account's session: a page another signed-in account published is a normal row again, re-published or unpublished as that account whichever is active. Switching is refused while a publish, a sealed send or a live collaboration runs, and Published, Readership and Deliveries reload as the new account. Every request still carries exactly one account's credential, chosen in Rust; a request addressed to an account with no session fails rather than using the legacy publish token. The existing session moves to its new name on first launch (copy, read back, index, then delete the old item), so an upgrade keeps you signed in. Deleting an account now forgets only that account's session and records. Modelina ID has no forced re-sign-in parameter (design §9a, Q3), so Add account says how to sign in as someone else, and reports when the browser came back as an account already here. An account whose session ends on its own stays the active one and shows as signed out, with a notice; nothing is then published or sent as any other account, and the legacy publish token is used only on a Mac with no account at all. This Mac's one correspondence identity belongs to the account that set it up: Send and the Inbox say so under any other account, and registering it again as another account is refused. A publish, send or live collaboration names the account it started as on every request, so neither a switch nor a sign-in that finishes meanwhile can split it across accounts; such a sign-in adds its account without making it active. Deliveries and comments act as the account their list was read as, even when a slow load overlaps a switch.

  • Free-placement diagrams F0: the notation, its one reader and writer, and the static checks (no visible change for authors). A ```diagram block is now a line-per-object notation (node, group, edge on an integer grid, default 12x8, at most 16x12) read and written by one module, packages/renderer/src/deck/diagram.ts. Its writer rewrites only the lines of the objects an edit changed, held by a property test over 2,400 random actions on three diagrams converted by hand from the vault (a 体制図, the JHS 構成図 and the BreathWall matrix). deck-check gains five blocking rules that no written reason can excuse (diagram-unreadable, diagram-off-grid, diagram-overlap, diagram-edge-unbound, diagram-unplaced); figure-unbound accepts origin: author in place of a source; diagram-below-threshold counts parsed boxes; and every label is held to numeral-unsupported, unbound-numeral and orphan-deixis like prose. 名 is now a counter the numeral check reads, folded to 人. deck_schema prints the grammar. The deck still renders a diagram as unsupported: drawing is F1.

  • Command palette on ⌘J (I6). View › Command Palette… opens one searchable list of every command the native menu has, each with its shortcut, and Enter runs the same handler a menu click runs (↑/↓ move, Esc closes and focus goes back where it was). The list is derived from MENU_COMMANDS and menu.json, so a command added there appears with no second definition, and shortcuts.test.ts holds the shortcuts the palette prints to menu.rs's accelerators. It is ⌘J, not ⌘K: ⌘K is the editor's Insert Link and ⌘⇧K is CodeMirror's Delete Line, and the window-level key handler would have run the palette on top of either.

  • Collection pages reorder by dragging (I6). Each row in the collection panel has a grip; drop a page on another to move it there. The Up and Down buttons already did this by keyboard and now share one move with the drag, so both write the same order the record stores and the staleness signature already reads, and both announce "Moved X to position n of N". Only the grip drags, so selecting text in the address field is not a drag.

  • A Dock badge for unread inbox items (I6). The Dock icon shows the same count the Inbox's rail badge shows (items that arrived since the Inbox was last open, from the list request the rail already makes, nothing new from the backend). It clears when the Inbox opens or the account signs out. Uses setBadgeCount, with the core:window:allow-set-badge-count permission. The count refreshes once a minute while the window is visible, so a badge can lag for a window that is hidden.

    now a counter the numeral check reads, folded to 人. deck_schema prints the grammar.

  • Settings › Identity › Emailing delivery links has a Connect mailbox in Outband button (H5). The section told the author to connect a mailbox but offered no way to; it now opens the same Outband page as the send result, and says when the browser could not be opened.

  • Connect a mailbox from the send result (H5). When emailing a delivery link fails because Outband has no mailbox connected for the sender (not-connected:declined), the send sheet offers Connect mailbox in Outband, which opens Outband's connected-accounts page (outband.ai/settings/connections) in the system browser. After that, Email the links again sends the same sealed links through Outband; nothing is published a second time. The app cannot read whether a mailbox is connected (that would be a fourth call across the Outband seam), so the state shown is the outcome of the last send, and no other failure code offers the button.

  • The packaged smoke checks more than "it rendered" (E2). bun run --cwd apps/desktop smoke now waits for four probes that run inside the packaged app and prints PASS/FAIL for each: ipc (a command's reply arrives intact under the enforced CSP), view (the Settings menu command, sent through menu::forward, reaches the webview and Settings renders), menu (every command is in the installed native menu and every accelerator parses), and keychain (a write, read-back and delete under the smoke-only account smoke-probe). Any failure, or a probe that never reports, exits non-zero; --app still smokes the signed release. All of it is keyed on LIMINAL_SMOKE_MARKER, so a normal launch runs none of it. scripts/ci-macos.sh runs cargo test and the smoke on a local Mac before a push. muda moves from a dev-dependency to a dependency at the same pinned version Tauri already resolves, so no crate is added.

  • Correspondence groups D4b G0: the schema, nothing that uses it (no visible change). correspondence_groups (a client-generated id, the owner account and agent, a version, the name as ciphertext only, the owner-signed manifest) and correspondence_group_members (one row per group and agent, active/removed/left held by a check, with the versions a member joined and ended at), plus publications.group_id (nulled when a group is dissolved) and group_version. Shipped with its idempotent admin route POST /api/admin/migrations/correspondence-groups, which reports the tables, columns, indexes and checks it can see; it runs on prod before G1 merges. The two publications columns are not declared in Drizzle until G1, so this deploy cannot break a publication read. Both tables sit in the export's exclusion list until G1 writes them and exports them. The operator answered the design's Q1, Q2 and Q7 as recommended (consent-gated add, owner-only changes, plaintext roster).

Changed

  • Federation F0, part: one message signer, frozen v1 wire formats, a sealed-column snapshot (no wire change). signMessage/verifyMessage in @liminal/crypto replace the seven direct digest(title, body) copies (three signers, four verifiers including the delivery page and the desktop's link reader); a golden signature and a both-ways equality test prove the bytes are what was signed before, and a source scan fails on a new copy (C4). The envelope, plaintext wrapper, pin store, sealed body and inbox blob readers are tested to ignore unknown keys, and putPin now keeps a store key it does not know (C5). db/sealed-columns.test.ts classifies every column of the four sealed-path tables and checks a sealed send writes only ciphertext and the listed metadata (C8). C1 (reserving @local@host) waits on the operator's Q3. Design: docs/superpowers/specs/2026-10-04-liminal-correspondence-federation-design.md §7.
  • The conversation's thread tag is sealed inside the title, and the server no longer stores it (D6 P1, I3). wrapTitle / unwrapTitle in @liminal/crypto carry the tag in the title envelope; the desktop and MCP senders and the delivery path seal through it, and the desktop inbox, the MCP inbox and the desktop's delivery-link reader read the tag from the decrypted title. The publish route still stores the plaintext thread an older desktop sends, so a backend that deploys first loses no tag; a current client sends none, so publications.thread_id stays NULL for it. That transition ends 30 days after this desktop and MCP release are in use. Every other Unicode TAG character is stripped from titles in both directions (hidden text could otherwise reach an agent unseen), and a thread is capped at 200 characters. Readers fall back to the served tag, so a conversation that spans the change stays one thread. An untagged title is byte-identical to before, and a tagged one is the title followed by invisible Unicode TAG characters, so an app or MCP released before this still shows the title (it groups such a message by participants only). The MCP's CLI approval now keeps the thread tag it used to drop. Old rows keep their plaintext tag until POST /api/admin/migrations/null-thread-tags runs, 30 days after this desktop release is in use.
  • Desktop localisation L7, code side (no visible change for an English user). <html lang> is now the resolved UI locale; the pre-paint script no longer copies navigator.language onto it, so a Japanese shell is no longer labelled with the webview's language and an English one no longer takes the note's (D9). Text that shows a document carries that document's language instead: a lang: in its front matter, else the same ratio guess a deck uses (documentLang); the editor, the preview frame, the published page and the deck report's quoted findings all follow it. The shell wraps react-aria in an I18nProvider for the same locale. The Office menu and the toast stack and confirm dialog moved into the catalogue, which empties the bare-literal baseline (5 + 5 to 0). Slide reading figures in the analytics list no longer wrap in a fixed 96px column when a Japanese counter makes them wider. Japanese stays a development-only draft until it is reviewed.
  • An image used by several publications is stored once (B5b). A desktop that sends the image's sha256 with POST /api/asset gets it stored under assets/<owner>/_sha256/, keyed by the digest the server computes from the bytes it received (a mismatching sha256 is refused). An upload without it keeps the per-slug key, so a v0.1.56-or-older desktop, which publishes singles without recording their images, never ends up on a blob another publication can delete. The desktop also sends each image's sha256 to POST /api/asset/existing, which now answers first from the publication's own recorded row (an unchanged image keeps its URL), then from the account's shared blobs, so an image another publication already uploaded is not sent again. Unpublishing or re-publishing deletes a blob only when no other publication of the account still records it or uses it as its og image, and a re-publish whose image moved to a new URL replaces the old row and deletes the URL it left. A publish records every image its new pages use before they go live and lets go of old ones only afterwards, so a publish that fails partway can leave extra records or an unused blob but never a live page on an image another publication may delete. An og image pasted with a query string still protects the blob it names, and an image recorded under a pre-S1 URL keeps that URL on re-publish. Blobs stored before this change keep their URLs.
  • An unpublish or re-publish no longer deletes an image another publish is about to use (B5c). Letting go of an image now queues its URL in asset_gc_queue instead of deleting it. The hourly /api/cron/reconcile-subscriptions cron also sweeps the queue, deleting a URL only after it has waited 24 hours and only if no publication of the account records it or uses it as its og image at that moment; a URL recorded again in the meantime leaves the queue untouched. POST /api/asset/existing answers a queued image as missing, so the desktop uploads it again, and POST /api/asset takes the key out of the queue before it writes the bytes. While the sweep is deleting an image its row stays queued, so a lookup still misses, and an upload of the same image waits for the delete (up to 25 s) and otherwise answers a retryable 503 rather than storing bytes the delete would then remove. Each sweep owns what it claims, so two overlapping cron deliveries cannot take or finish each other's rows. A failed delete keeps the image blocked for 10 minutes, in case the store still applies it, and the next sweep retries it. Unpublished images therefore stay public for up to a day longer. Deleting an account still deletes everything at once, including the queued images. Needs the schema change shipped first (docs/BUILD-DEPLOY.md, "Deferred asset GC queue").

Fixed

  • Publishing a note that another account published no longer copies it (K4.0). Signed in as ai, Publish on a note recorded under vox created /ai/p/x and overwrote the record, so the vox page stayed live and dropped out of the app. Page and collection records now carry ownerHandle, stamped from /api/me at publish; a record from before reads its owner from the address, and a custom-domain record without a stamp is unknown and not blocked. publishSingle and publishCollection take a required whoAmI and refuse a record owned by another account before anything is uploaded, which covers the editor, the collection panel, and Re-publish and Re-publish all on Published; the panel names both handles. Unpublish (editor, collection panel, Published) and Link to note refuse the same way, since each request goes to the signed-in account and would delete or replace that account's page instead. Published reads the stamp before the address, so a stamped custom-domain page of another account reads other-account, and a page recorded under another account reads other-account even when the signed-in account has a page at the same slug. The stamp is not a signature input.

  • A chart or diagram on a slide is no longer wrapped in a paragraph. A rendered declaration replaced a key markdown-it had put in <p>…</p>, so a <figure> sat inside a <p>, which a browser splits into an empty paragraph after the figure; that paragraph's margin counted toward overflow. The wrapper is now dropped with the key.

  • The contrast check no longer samples text inside something hidden. display: none is not inherited, so text inside a hidden container still computed its own display and was measured. The walk now looks up to the slide; found because a diagram's grid is hidden below 768 px, where the reader sees its list.

  • Messages read in the inbox stay verified after the sender rotates their key (D8b), without trusting the server to name the key. The inbox read now carries the signing key recorded at send time, and the MCP and desktop inboxes check a message against it only when this machine's pin already vouches for it: the key served now, the pinned key, or a key the person accepted a rotation away from (pins now remember those, previousSigPubs). A key the server records that no pin holds is ignored, so a forged message still fails.

  • The account export now includes the delivery tables and answers both directions (D6 I8). GET /api/me/export left out the sender's own receipts (delivery_links with the recipients' emails and every open), the links the account registered as a recipient, its loop row, and every grant to it, so it could say neither what the account holds about whom nor who has sent to it. It now carries deliveryLinks (with opens), linkRegistrations (with opens), loopConversions, inbox, annotationResolutions, and the remaining account-owned tables (deckQuestions, deckApprovals, knowledgeProducts, and per-page, referrer, source, slide and goal analytics). Still no-leak: no token hash of any kind, and nothing of another account's that this account is not party to. A test now enumerates every table in the schema and fails unless it is exported or listed as excluded with a reason (only rate_limits is).

Removed

  • Hosted CI. Liminal no longer uses GitHub Actions or Blacksmith; every workflow under .github/workflows is deleted (ci, backend-ci, desktop-e2e, rust, release, publish-update, release-doctor, meter-production-smoke). The gate is scripts/ci-local.sh, which runs every suite those workflows ran plus cargo test and the packaged smoke; ci-local.test.ts keeps it complete and fails if a workflow file returns. The scheduled release-doctor and Meter production smoke now run by hand.

Security

  • Sealed ciphertext is size-capped. POST /api/publish {type:'sealed'} and POST /api/web-send had no bound on ciphertext beyond the host's request limit. They now answer 413 sealed_too_large (naming the field and the cap, never echoing the value) for a signature over 128 characters, a title envelope over 64 KiB, or ciphertext over 4,500,000 characters in total, Vercel's request limit, so nothing that sends today is refused (lib/sealed-caps.ts).

  • An error log no longer records the request path (D6 I2). When Next.js gave no route pattern, onRequestError logged the raw path, which for /x/<token> is a delivery link's credential. It now logs unknown-route.

  • A delivery link minted from now on keeps its token out of every request path (metadata privacy D6 P4). New links are /x/#t=<token>&k=<key>: both halves in the fragment, which the browser never sends. The page opens the envelope with POST /api/x and the token in the body, and every recipient route moved the same way (/api/x/register, sign-in/request, sign-in/confirm, edits, annotations, annotations/list, annotations/resolve, collaboration, collaboration/poll, collaboration/updates), reads included, so a platform log that keeps paths no longer holds a working credential for every new link opened. Links sent before this release still put the token in the path of the page request, and so do the routes kept for compatibility below. The send page is /x/send#t=<token>. Links already sent (/x/<token>#k=) keep working: the same shell reads the token from the path and moves it into the fragment before it fetches. The desktop mints the new shape, "Open Delivery Link…" reads both, and its fetch is now POST /api/x; GET /api/x/<token> stays for desktop releases from before this one. A malformed or missing token gets the same answer as an unknown one, so links still cannot be probed (M4 T-8). The old /api/x/<token>/… routes stay as deprecated aliases until 2026-11-04 (a dated test fails after that day, and each use is logged without the token), so a recipient tab opened before the deploy keeps working, and an old /x/<token>/send address redirects to /x/send#t=<token>. Release note: desktop releases up to v0.1.58 cannot open the new link shape in "Open Delivery Link…" (they say it is not a delivery link). Update the app, or open the link in a browser.

  • Deleting an account no longer deletes another account's image. A publication's og image URL is whatever the desktop sent, and account deletion passed it to Blob del unchecked, so an account could name another owner's public image and erase it by deleting itself. Deletion now covers only URLs under the account's own assets/<accountId>/ prefix. Found by the B5b refutation review.

  • Client IPs are no longer stored in rate_limits, and its rows expire (D6 I5). Every anonymous route (delivery open, link register and sign-in, comments, verify, approve, unlock, source request and question, deck signal, Meter customer creation) keyed its limiter row by the raw client IP, and no row was ever deleted, so the table held every reader's address with a time, contrary to BRD §6. Keys now carry iph_<HMAC-SHA256> under a salt that rotates every UTC day, derived from RATE_LIMIT_SALT_SECRET (else LIMINAL_AUTH_SECRET), so the same IP is counted within a day but a leaked table neither yields addresses nor joins across days. The hourly cron deletes rows 24 h past their window; the limiter still fails open. The old rows go with POST /api/admin/migrations/rate-limit-raw-ip, run after deploy (docs/BUILD-DEPLOY.md).

[0.1.58] — 2026-10-04

Added

  • Settings › Plan names the billing cadence (C2-cadence). A subscriber sees "Billed monthly" or "Billed yearly", and a renewing monthly subscriber is offered Switch to yearly, which opens the billing portal where Polar makes the change and works out the charge; the app never changes a subscription. The Polar webhook now records the subscription's product in subscriptions.polar_product_id (an event without one keeps the recorded value), and /api/me reports billing.cadence by matching it against POLAR_PRO_PRODUCT_ID_MONTHLY/_ANNUAL. An unknown product, or a subscription no event has touched since this release, reports null and the app says nothing rather than guessing monthly; existing subscribers gain the line at their next Polar event (at the latest, their renewal).

Fixed

  • Published no longer calls another account's pages missing. A page this Mac published while signed in as a different Liminal account was shown as "Missing on server" with Re-publish and Unpublish, although it was live; Re-publish would have copied it into the current account at a second address. Such a row now reads Another account, offers only Open and Copy, stays out of "Re-publish all with changes", and the summary counts it separately. The account is read from the address (/<handle>/p/…), so a custom-domain page keeps the old behaviour.
  • Links recorded on liminal.3mergen.com open on the live host. That host has answered 404 since the Modelina move; records written before it now read as liminal.modelina.ai (same path) wherever the app shows, opens or copies them. The stored records are not rewritten.
  • An untracked page's address includes its account. It was built as /p/<slug>, the legacy default account's route, so Open showed a different page or a 404.
  • A sender rotating their signing key no longer discredits what they already delivered (D8). Every sealed publish now records the sender agent's signing key at the time (publications.sender_sig_public_key, on prod since 2026-10-04), and the recipient page, /api/x and the desktop's returned-edit check verify against that key, falling back to the agent's current key for deliveries sent before this release. Liminal-address deliveries read through the MCP inbox still check the current key.

[0.1.57] — 2026-10-04

Added

  • ⌘\ in the menu bar. View › Show or Hide Notes List now shows and answers ⌘
    (CmdOrCtrl+Backslash), so the shortcut is discoverable rather than known. The key and the menu run one command: AppKit offers the key to the webview first, whose handler takes it, as it already does for ⌘E and ⌘⇧P.
  • A Japanese UI, as a draft in development builds only (localisation L7, in progress). The whole desktop catalogue now exists in Japanese (apps/desktop/lib/i18n/ja/), model-written against the bilingual glossary and pending a native-speaker review and operator sign-off before any user can choose it. Settings gains a first section, General, with Language: System / English / 日本語; a release build lists the section only when there is more than one language to offer, so until Japanese ships nothing changes for anyone. System follows the Mac's preferred languages, read in Rust (system_locale), deciding by language and never by region, and never resolves to a draft. Choosing a language rebuilds the native menu in it (menu.json carries Japanese labels in Apple's own words under draftLocales); Info.plist still declares English only. docs/GLOSSARY.md gains its Japanese column.
  • Settings › Knowledge products (D9). A signed-in author offers one of their own published pages to agents, sets its price per search, read and answer in Meter credits, stops or resumes the offer, and copies the MCP address buyers connect to — with the account they are signed in with, not the shared publish token the web creator form asked for. The app reaches the existing owner-scoped /api/knowledge-products* routes through new Keychain-backed commands (knowledge_products_list, knowledge_product_offer, knowledge_product_update); pricing and metering stay in the REST twins. /meter/creator now points creators to the app.
  • Preview and Markdown, both editable. The editor no longer splits into a source pane and a read-only preview. Preview conceals Markdown syntax and draws headings, emphasis, links, wikilinks, tags, quotes, code, task boxes (clickable) and tables in place; the block holding the caret shows its Markdown. Markdown shows the source. Both edit the same file, and the choice is remembered on this Mac. A deck keeps its published render as a read-only Slides view, with the gate's widths. What Preview conceals comes from the publisher's own parse, so it bolds what the published page bolds, including the CJK emphasis rule.
  • Settings › Plan tells a subscriber what is happening to their plan (C2). A failed payment ("Pro stays on while the payment is retried"), a cancellation running out (with the date), and the renewal or trial-end date. GET /api/me carries a billing object for it. A comp account says Pro is included and no longer shows a Manage button that could not open.
  • Week over week in Readership (B6). Pro rows show the last 7 days against the 7 before, in the table and in the CSV/JSON export. It replaces "retention", which needs a visitor identity Liminal does not keep.
  • /meter/creator. Meter's creator setup has its own page with its own product slug field; /meter is the buyer's page. The two link to each other instead of sharing tabs.
  • docs/BRD.md and docs/PRD.md, and a rewritten docs/ROADMAP.md with the current backlog.
  • Send from the browser (ROADMAP H2). A recipient with a verified address can write a document at /x/<token>/send, seal it in the page and hand out the links — no install. The plaintext and the link keys never reach the server.
  • Sealed to the recipient's own key too (ROADMAP H1). A delivery to someone who already has an account or a verified browser key is sealed to that key as well as the link's, so a reload or a link whose fragment was lost still opens for them.
  • Writing in Liminal: New note (⌘N), rename, Move to Trash with Undo, Quick Open (⌘P), Search Vault (⌘⇧F), Open Recent; ⌘B/⌘I/⌘K, [[ and # completion, ⌘-click to follow a link, paste or drop an image into the vault, character and word count, an outline, ⌘E to switch Preview and Markdown, ⌘\ to hide the notes list. Preview now draws images, math, front matter as Properties, slide separators and callout titles.
  • Conflicts can be resolved without losing work: a diff, Keep my version, Use disk version, or Save mine as a copy.
  • Recipients: a one-time code for an address that already has an account, a "Your access" panel, the link's expiry date, reply and resolve on their own comments, retry for busy or offline answers, and the sender's verified address and key fingerprint.
  • Deliveries grouped by document with Sent and Expires, an Expired badge, per-recipient comment counts and Withdraw all; returned edits merge three ways and can be saved as a new note.
  • /download is a page (macOS install notes, or a macOS-only notice with email-me), /changelog and a factual /privacy notice exist, and decks that measure reading say so to the reader.
  • Desktop UI tests (Playwright over a mocked Tauri IPC) and cargo test on pull requests run in CI.
  • Open a delivery link in the app (ROADMAP H6). File › Open Delivery Link… (also a button in the Inbox) takes a pasted /x/ link and shows the document in the Inbox's permission-less frame, with the sender, their verification and whether the signature checks. It opens with the link's key or, for a document sealed to you (H1), with this Mac's own key — so a link whose #k= was lost still opens. The key never leaves the webview's memory: Rust fetches the envelope by token alone through the same GET /api/x/<token>, withdrawn/expired/unknown stay one answer, and a link for another server than the one in Settings is refused rather than fetched. The link-key reader is now one function in @liminal/crypto (decodeLinkKey), shared with the recipient page.
  • A branded error page for the website's pages (theme gallery, profiles, Meter), with Try again and a way home, instead of the framework's bare "Application error".
  • A "Skip to content" link on the home page, download, changelog, data notice and theme gallery.

Changed

  • The recipient page's comment rail folds away on a phone (D23). Below the page's narrow breakpoint the rail starts as a bar with a "Comments (n)" / "コメント(n)" toggle (aria-expanded, aria-controls), so the document starts above the fold. Opening a consent, sign-in or comment panel expands it and moves focus to the panel as before. Wide viewports are unchanged.
  • Themes declare the colour schemes they support, and the gate pages follow (D21). A theme's :root may carry --color-scheme: light dark (or light, dark, only …); every built-in theme declares light dark. The password gate, source gate, answer face and not-found pages emit <meta name="color-scheme"> from the declaration, so autofill and scrollbars are drawn dark on a dark theme. A theme that declares nothing gets nothing, as before. The property is inert in published pages, and already-published bytes are untouched. See docs/THEMES.md.
  • Extend a delivery link. In Deliveries, a link that has expired or expires within three days offers Extend…: 7 or 30 more days, counted from the later of now and its current expiry, never more than 90 days from now and never earlier than it was. New owner-scoped POST /api/me/deliveries/<id>/extend (resolveAccount, scoped through the publication join, rate-limited per account by POLICY.deliveryExtend; 404 for a link that is not the caller's, 409 for a withdrawn link or one with no expiry). It only moves delivery_links.expires_at — no schema change — and /api/x still answers live or the one identical 404 (M4 T-8). The desktop reaches it through the Keychain-backed extend_delivery command. Resend is not part of this.
  • Withdraw access for a document sealed to a Liminal address. Deliveries now lists address recipients beside link recipients (GET /api/me/deliveries returns addressDeliveries, owner-scoped) and withdraws them, with confirmation, through the existing POST /api/grant/revoke — the route outbox_revoke uses — via the new revoke_grant command. "Withdraw all for this document" includes them. Address deliveries carry no read receipt; the row says so.
  • Open in browser from a delivery opened in the app (H6). Comments and suggested edits are made on the web page, so the opened document can hand its full link (with the key) to the system browser through the app's opener. The link is held in memory only while the document is shown, dropped at the hand-off, and never written to the store, the DOM or a log.
  • Generation rail copy is deployment-neutral. Server reads "Runs on Liminal's server. Included with Pro." instead of "Billed per token against the API key"; the token estimate, the cancel note and the relaunch warning no longer speak of a per-token bill. This Mac keeps its copy about the author's own Claude subscription.
  • UX review 2026-10-03b decisions recorded. Extend, Open in browser, address withdrawal and the rail copy moved to "Decided 2026-10-03"; writing-surface N2–N4 are decided as "keep" (no status bar, no automatic save retry, no automatic folding of the notes list).
  • Localisation foundation (L0, English only). The desktop's words start moving into one typed catalogue (apps/desktop/lib/i18n, no i18n library); the rail and the view header read from it, with nothing visibly changed. Tests now hold every locale to the reference's shape, render migrated views under a pseudo-locale, and stop new inline English: a per-file ratchet recorded 1,083 bare strings, which may only go down.
  • Native menu and bundle (localisation L1, English only). The menu bar's words, including the standard items (Undo, Copy, Hide Liminal, Quit Liminal …), now come from one file (apps/desktop/lib/i18n/menu.json) that the Rust menu and the webview both read; the bundle declares its localisations (src-tauri/Info.plist, English only); and the editor's search panel takes its words from the catalogue. Every label reads exactly as before.
  • Errors as codes (localisation L2, English only). The desktop's native side now rejects with stable codes (path_outside_vault, theme_too_large, register_failed:<status>, too_large …) instead of English sentences, and the app turns each into words from the catalogue; a failure message is a whole sentence per action rather than a phrase spliced into a template. English text is unchanged except: an Outband email failure no longer prefixes its reason with a code (not-connected: …), and a "not found" or "declined by the model" failure says so in words instead of printing notFound or the raw error. Codes the app does not know are still shown as received.
  • Shell chrome (localisation L3, English only). The shell's toasts, confirmations and empty states, Settings (with account data and custom domains), onboarding, ⌘P, ⌘⇧F, the update banner and the notes list now read their words from the catalogue, counts included (one message per count, no plural(n, noun)); those nine files are at zero in the literal ratchet, and a pseudo-locale test walks each of them. No English text changed.
  • Publishing surfaces read from the catalogue (localisation L4, English only). The publish and collection panels, the theme picker, the published address, Published, Readership, the after-opening figures and the preview toolbar take their words from four new areas (publish, collection, dashboard, analytics); 258 bare strings leave the ratchet and every word reads as before. Published bytes are untouched.
  • Delivery and correspondence (localisation L5, English only). The send sheet, Deliveries, comments, returned edits, live collaboration, the Inbox, pending sends, contact verification, claim approvals, Open Delivery Link and Settings › Knowledge products read every word from the catalogue (13 files to zero bare strings); the "unverified — via link" label is now m.deliveries.provisional, held byte-equal to the recipient page's unverifiedVia in every shipped locale by a test (I7). Every label reads exactly as before.
  • Editor, AI editing and decks (localisation L6, English only). The note workspace, the AI instruction and proposal panels, the table grid, Preview’s own widget text, Make a deck, the deck versions list, Generate the deck and the verification report read every word from the catalogue (editor, aiEdit, deck, and a new deckReport area that also holds the report’s translations of known deck-check phrases); @liminal/editor takes its words as props with its English as the default, so the recipient’s browser editor is unchanged. deck-check’s own findings stay as the package writes them (D11). A test shows the AI edit’s disclosed and sent request are byte-identical under any UI locale (I1). Every label reads exactly as before.
  • Sending, receiving and Settings (UX review C, docs/ux-review-2026-10-03b/C-delivery-inbox-settings.md). The send result lists each recipient and what happened to them (delivered, emailed, or a link to copy, with the reason), and its headline counts who was actually reached. Copying a link says "Copied". Deliveries lines up its columns and names a document sent from another Mac in words rather than by its internal slug. Comments name the document they are on. The Inbox loads a conversation's messages when it is opened and dates each thread. Pending sends tell the server's "Identity verified" apart from your own key check ("New key, not verified", "Key changed"), with no glyphs. Open delivery link says before opening that the sender sees the open. Stopping a knowledge product, and rejecting a pending send, now ask first. A dismissed returned edit can be restored. Settings no longer repeats section headings, shows page titles when you choose a page to offer, says where the export was saved, and the deletion warning lists everything that is removed (domains, knowledge products, shared themes, the sending identity).
  • A re-publish sends only images the server does not already hold (B5). One POST /api/asset/existing per publish replaces one upload per image; any failure falls back to uploading everything.
  • Settings › Plan leads a free account with the yearly plan and its saving.
  • Status colours pass AA in both schemes. Light-mode success, warning and error text move to the 700 steps (they measured 2.9–4.8:1), the dark-mode brand tint is brand-950 instead of solid brand-500 (light text on it measured 2.2:1: the Pro badge, the update banner, the AI proposal, sent messages), and icon-only buttons' icons reach 3:1. e2e/layout.spec.ts measures every pairing in light and dark.
  • The editor toolbar sizes to its pane, not the window: at the 800px minimum "Markdown" is no longer clipped; the word count and "Readable width" wait for room.
  • Markdown mode draws a note's front matter as quiet monospace data instead of a setext heading.
  • Error toasts stay until dismissed; a confirm dialog announces its explanation with its title.
  • Onboarding no longer marks a skipped step as done, and its last screen matches what was set up.
  • Vault search marks the matched words; Quick Open and the empty editor say when the vault has no notes, and offer New note. The native menu says "Published" like the rail and lists Show or Hide Notes List.
  • The deck report says what did not run in its headline ("Ready to publish. 9 checks did not run, listed below"), marks each unrun check as blocking publishing or not, and stops telling an author whose Model judgement is already on to turn it on. It shows when it was checked.
  • A failed publish is reported in the Publish panel beside the button, with Try again, and replaces the previous "Published." notice; a single page shows "Uploading images 3/12…" while it publishes.
  • The collection panel names its slug Address, shows the full URL before publishing, and says when two included pages share an address while you type, not after Publish.
  • Readership keeps its table on screen while refreshing; its columns say "Views, all time" and "Last 30 days"; Make a deck no longer shows a second, inert "Generate a deck" button above the form.
  • The recipient page says that the link itself is the key, so anyone with the full link can open the document; the browser send page and the home page FAQ no longer imply only the named people can (BRD R6). Dates on a Japanese document's page are written in Japanese.
  • The theme gallery carries the site nav and footer; Meter pages link home and have their own canonical and preview. A delivery link unfurls as "A sealed document", not as the home page.
  • "Unlimited publications" reads "Unlimited pages" (glossary).

Fixed

  • The three model-decided deck warnings and the deck MCP's deck_judge description said 模型 (a physical scale model) for "model"; they now say モデル, matching the app's Japanese.
  • A menu accelerator Tauri cannot parse now fails cargo test. Tauri passes each accelerator string to muda as .parse().ok(), so a misspelt one was dropped silently and the item shipped without its shortcut. A test parses every string in menu.rs with muda 0.19.3's own parser (a dev-dependency pinned to the version Tauri resolves, so no new crate enters Cargo.lock), and lib/shortcuts.test.ts holds each accelerator to the webview key that runs the same command.
  • Toasts sit at the bottom centre instead of over the Publish and Send panels' buttons; the dialog backdrop is visible in dark mode; the Send tab's recipients box grows with the list and says "Will not be sent to …" while you type instead of "Not sent to".
  • A send that reached nobody said "Sent." and "Everyone else received it"; a send whose emails failed said so in one red paragraph listing every address. The links were also described as working "once its recipient opens it", which reads as single-use. They work until they expire or access is withdrawn, and anyone who has the link can open it.
  • An offline subscriber's plan read "Unknown" next to the Free plan's upgrade buttons and "Sign in first."
  • An Inbox message that failed to download said it "could not be opened with the key on this Mac". That pointed at re-registering, which is the one step that really does make received messages unreadable. It now says the message could not be loaded and offers Try again.
  • The profile address under "Make my profile discoverable" was hard-coded to liminal.modelina.ai instead of using the server set in Settings.
  • Unpublishing a page now takes its images down. Single pages never recorded their images, so unpublish and re-publish left them in public storage; the desktop now sends the page's image manifest and the server collects what is no longer used (ROADMAP B5b, prerequisites 1–2).
  • An asset URL outside the publisher's own assets/<owner>/<slug>/ prefix is no longer recorded, so no publish can make a later unpublish delete another account's image.
  • Switching between monthly and yearly no longer risks dropping a paying customer to free. The old subscription's revoked webhook, arriving after the new one started, overwrote the account's only subscription row.
  • From the 2026-10-03 review (docs/REVIEW-2026-10-03.md), the data-loss and trust defects: leaving a note mid-save no longer drops the last keystrokes; a save that finishes behind the draft re-arms autosave; Undo no longer reverts another app's edit; tables edit in CRLF notes; a live session makes the main editor read-only; reopening a collection keeps its order, left-out pages and slugs; re-publishing a protected collection keeps its password; a note no longer inherits another note's publication record (records are keyed by vault and path); unpublishing reports a failure as a failure; a network blip during token refresh no longer signs you out.
  • Recipient privacy: a recipient's comment is sealed to the sender and its author only, and is no longer shown under the reader's own address; the public profile lists only pages marked indexable; the password gate no longer shows the document's title.
  • Rate limits read as rate limits (with the wait), not "the server had a problem"; desktop requests time out; the vault watcher ignores content-only changes; account deletion stops if the subscription cannot be cancelled; checkout refuses a second subscription.
  • A callout whose body shares its title paragraph is published once, not twice; front matter behind a byte-order mark is read.
  • The "…" row button in the notes list and the "Recent vaults" button in the rail opened nothing, and a right-click opened the row menu in the window's top-left corner.
  • A save that failed kept showing only until the next keystroke; it now stays as a banner with Try again, Save as a new note and Copy text until a write succeeds.
  • With no vault open, the header no longer offers an Office import that did nothing.
  • A folder of notes named in Japanese can be published as a collection. Every page used to get the address untitled, so the publish was refused until each was renamed; pages now get distinct addresses, and a hyphen can be typed in a page address.
  • Unticking Password protect on a protected page or collection now says that re-publishing removes the password.
  • An image over 3 MB stops a page's publish before any other image is uploaded.
  • Unpublishing a page the server already removed clears it here instead of failing.
  • The Published table keeps every row's actions on screen at the minimum window width; a long title or address truncates.
  • Deck generation's elapsed clock is no longer announced to a screen reader every second.
  • The browser send page no longer loses a document on reload or a closed tab, and asks before leaving links that have not been copied or emailed yet.
  • The nav's Features, How it works and Pricing links work from every page, not only the home page.
  • Links on the password gate, source gate and not-found pages are legible on a dark theme (they were the browser's default blue, about 2.2:1).
  • Opening the consent or sign-in panel on the recipient page moves focus to it; send-page copy buttons name whose link they copy; an address is judged when its field is left, not per keystroke.
  • No horizontal scroll on a phone on /changelog and /meter; download-page buttons are no longer underlined; the home page's comment illustration is legible in dark mode.
  • A deck's verification report kept reading "Ready to publish." after the deck, a declared source, a data: file or the theme changed. It now says "Changed since this check — check again" (announced), puts its verdict in the past tense, and offers Re-run the check. The comparison uses the same staleness signature as the publish badge, which also now refreshes when the open file changes on disk or the window regains focus. Publishing still checks again on its own.
  • Readership showed a deck with the page icon (UX review B #25). The analytics API calls both single, so the desktop now reads its own publication records the way the Published table does (a .deck.md note is a deck), and a row with slide reads counts as a deck too.
  • The website's error page stayed white under a dark system scheme in a production build (UX review D22). When a page throws during server rendering, Next sends a bare error document and draws the root layout on the client, where its dark-mode script never runs; the error page now applies the system scheme itself.

[0.1.56] — 2026-10-02

Changed

  • The editor reads as a writing view (#189). Prose is set in the reading face (with the system Japanese face behind it), code stays monospace, and headings are sized. Ligatures are off, so --> shows as typed (#192). The AI edit strip starts folded.
  • An untitled publication is listed by its note name, and the Publications summary counts "up to date" rather than "published" (#192).

Fixed

  • Deliveries and Inbox name a missing correspondence identity once, with a button to Settings › Identity, instead of three "could not be loaded" errors (#189).
  • With no vault open the window explains what a vault is and offers to open one; Settings' load error has a working "Try again" button (#192).
  • The selected row is visible in light mode; the notes list is capped at 35% of the window; the deck preview no longer logs an error on every slide change (#189, #192).

[0.1.55] — 2026-10-02

Added

  • Contrastor entry (#186). The editor header names Contrastor: a deck shows a "Contrastor deck" badge, and a note offers "Make a deck" (or "Make another deck"). The publish button reads "Publish deck" for a .deck.md, and onboarding names Contrastor on its last step.

Fixed

  • A long note no longer scrolls the whole window and carries the header, rail and notes list away with it; only the editor pane scrolls (#187).

[0.1.54] — 2026-10-02

Changed

  • Workspace shell (#178). The editor saves 800 ms after typing pauses and flushes before a file or view change, an external open, a vault switch and the window close. Native alerts and confirms are replaced by toasts and an accessible confirm dialog. A rail of five places replaces the toolbar of four tabs and five overlays; the header reads vault › folder › file with a status badge; keyboard shortcuts and a native macOS menu send the same commands. The app follows the system dark mode, reads colour tokens throughout and applies Geist. The file tree is an ARIA tree and settings sections are tabs.
  • Web surfaces (#179). The recipient view has one branded shell for every state, real buttons, English and Japanese chrome, and no longer prints the recipient's own email; the landing page, 404, billing pages and credits tabs are redesigned.

[0.1.53] — 2026-10-02

Fixed

  • Reopening a published deck keeps every setting it was published with. 0.1.52 rebuilt the panel's settings from a hand-picked subset, so measurement, the source offer, question answering, the judge toggle and the format read as off, and a re-publish silently turned off the deck's source gate and reading measurement.
  • The slide outline no longer titles a table slide with its declaration, and the model-judgement disclosure names the configured rail before the first check.

[0.1.52] — 2026-10-02

Changed

  • Contrastor reader and author surfaces, after the 2026-10-02 UI/UX review. The cover names the reviewer, the date and the reading time and shows a scroll hint; a click inside the text no longer pages; the dots have a 24px target and a phone gets a progress bar; a slide can scroll inside itself when the window is shorter than the gate tried, and the gate now also measures 1280×650 and 844×390; only numeric cells are right-aligned and a table of three columns or fewer wraps instead of scrolling; the goal is reachable from every slide and the author's goal description is no longer printed; the colophon prints a date (review date, else publication date), a contact: link when declared, and makes the internal source entry the door to the source face; Japanese decks get a named Japanese face (--font-ja) and a narrower measure; a favicon, a light/ dark switch that touches no storage, print links in the body colour, a <main> landmark and per-slide labels, and localised figure errors.
  • One frame for the password gate, source gate, question face, approver's page and the not-found page, in the document's own language and theme, naming the document and the way back. A withdrawn document now says so instead of a bare 404. The source face carries a link back to the deck, and a published document without an og:image gets a generated card at /p/<slug>/og.
  • The verification report groups findings by rule with a readable title, names the first thing to fix, lists every check that did not run (including 未実行 outcomes) with its reason, folds the passed checks away, carries a slide/line on numeral findings with a jump button, and accepts an exception by button with the 10-character floor shown and an Undo. When every internal source fails to resolve, the figure checks are reported as not run instead of marking every figure. New warning goal-unmeasured. A source ref written from the vault root resolves from a sub-folder root. Rule titles exist in English as well as Japanese.
  • The deck's publish panel is ordered as the work goes (Generate, Check, What the reader gets, Where it is published, Publish), Publish is disabled while the report is blocked, outcomes land in the panel as a notice instead of a dialog, the generate form says why it is not ready, estimates what leaves the machine, can be cancelled, and clamps the slide target; the preview offers the gate's widths and a light/dark switch; a slide outline jumps the preview; a publication record is found by path from any root of its vault, so a deck opened from a sub-folder is no longer "not published" nor a slug collision with itself; the remaining Japanese-only chrome strings are English.

Fixed

  • Preserve recipient table editing and Undo across Preview, and keep collaborative table panels inside their editor viewport.
  • Apply table edits as bounded CodeMirror transactions, preserving the table panel, cell focus, and Undo.
  • Keep table filters stable when editing another column, and allocate usable grid height in the desktop editor.
  • Export Excel tables using the production Markdown parser, excluding fenced examples and preserving tables with omitted outer pipes or quote nesting.
  • Show the Server AI-edit retry time from a validated rate-limit delay without exposing response bodies.
  • Preserve newer edits made during Save, and keep selection and Undo history across workspace modes. Preview never shows a previous vault's note while a new selection is loading.
  • Keep AI disclosure aligned with the current selection and execution method; expose Server / This Mac selection, actionable failure guidance, accessible change review and reachable review actions. Cancelled or navigated-away requests cannot attach late proposals or perform late review work.
  • Exclude frontmatter from inferred AI context and resolve long sections using a complete syntax tree. Run the local CLI in a fresh temporary directory with configured MCP tools disabled, avoiding launch-directory-dependent generation failures. Bound AI-edit stdin and pipe completion by the same deadline, and terminate CLI descendants so timeout cleanup cannot wait indefinitely.

[0.1.51] — 2026-09-01

Added

  • Instruction-driven AI editing (Workspace M8). Rewrite an exact selection, current section, editable document body, or renderer-recognised Markdown table from a written instruction. Authors inspect the exact redacted payload before choosing the Pro Server rail or their locally signed-in claude CLI, then review a bounded diff before an explicit, one-transaction Apply. Double anchors reject stale proposals; internal targets never reach a model; model-written tables keep the M3 gate; no generation implicitly saves, publishes, delivers, exports, or mutates an active collaboration.

[0.1.50] — 2026-09-01

Added

  • Office round-trip (M7). Import .docx into Markdown while preserving inline tracked insertions/deletions with author and date, and export them back to WordprocessingML with Track Changes enabled. Import .xlsx worksheets as Markdown tables and export Markdown tables as values-only worksheets; formula cells import their cached values with an explicit warning and no formula engine crosses the boundary. Conversion stays local, imports never overwrite a vault note, and Office export runs the M3 table gate before writing bytes.
  • Encrypted live collaboration (M6). A delivery recipient and sender can edit one Yjs-backed Markdown document through a 750 ms HTTP delta relay. Every update is sealed to current participants, signed by the exact browser/desktop key, and linked into that signer's independently verified hash chain; the backend stores ciphertext and transport metadata only. The creator emits the sole canonical initial state, reconnects rebuild from encrypted history, and the shared M2 table surface remains available. The sender settles to the mapped vault file through a revision compare-and-swap and can close only at the exact last verified relay sequence.
  • Re-verifiable table derivations (M3). Numeric table cells can record a declared source and a human-readable arithmetic expression beside the value. A no-eval, unit-aware parser re-runs the expression, requires every factual operand in that source, and requires the exact result and unit to match the cell. Decks and explicitly model-generated tables fail—not warn—when derivation is absent or invalid. Single pages, collections, and sealed delivery run the same local check before content leaves the Mac; the shared table editor records the metadata without showing it as cell content.
  • Recipient editor (M5). New sealed deliveries carry a separately signed Markdown-source envelope, so an email-only recipient can affirmatively create a browser-held identity, edit with the same CodeMirror/table implementation as the desktop, and send back an encrypted signed proposed version. The backend stores append-only ciphertext and provenance only. The sender decrypts and verifies locally; one-click apply is available only when the vault file still exactly matches the delivered source and the final write passes the M1 revision check. Returned edits are covered by account export/deletion.
  • Markdown table surface (M2). Put the caret in a renderer-recognized table to edit it as a bounded, sticky-header grid: cell/header editing, add/remove columns and rows, row reorder, numeric-aware stable sort, alignment, and non-destructive per-column filter. The same markdown-it parse powers editor recognition and publication. Only the table range is serialised back to Markdown; surrounding source, alignment widths, and line endings remain intact. Numeric cells are explicitly unchecked until M3 adds source and derivation verification.
  • Markdown editor core (M1). Existing vault notes now open in a reusable CodeMirror 6 surface with Write, Split, and Preview modes and explicit Save / Command-S. Markdown remains canonical and byte-preserving rather than being parsed into a rich-document tree. Local writes are confined, atomic, and revision-checked: a change from Obsidian or a sync client is adopted when the buffer is clean and becomes a visible, non-overwriting conflict when the buffer is dirty.
  • Encrypted annotations and acquisition loop (M4.5). A delivery recipient can select a passage, affirmatively create a provisional Modelina ID identity, and append an encrypted comment without an email wall. Browser keys stay in IndexedDB; sender and participant keys can decrypt the body and quoted passage while the backend holds envelopes and anchor metadata only. WorkOS Magic Auth promotes the identity with a six-digit code, merging keys from multiple browsers. The desktop shows comment counts and locally decrypted comments with explicit “unverified — via link” provenance, one-level reply, and append-only resolve. The post-comment send invitation and first subsequent publish are measured in loop_conversions. After a comment is stored, Outband sends a fixed self-notification through the owner's connected mailbox; the comment ciphertext, quotation, document title, recipient, subject, body, From address, and protocol headers are never caller-controlled.
  • Sealed delivery (M4). Send a sealed document to an email address that has no Liminal account. The recipient opens it in a browser; the key rides in the URL fragment, which browsers never transmit, so the backend holds ciphertext and no key. seal() already took an array of recipient keys, so one envelope serves both a recipient who has Liminal (sealed to their own key, no key travels) and one who has only the link. The sender sees when it was opened and can withdraw access — which stops retrieval, and cannot un-decrypt what was already downloaded. Design: docs/superpowers/specs/2026-08-30-liminal-m4-delivery-design.md.

Fixed

  • Packaged preview navigation. Table-of-contents and heading links now stay inside the rendered document in the packaged macOS app instead of escaping the preview iframe to tauri://localhost/#… and replacing the note with an empty Liminal shell.
  • Signed-out delivery state. Deliveries now asks the user to sign in without mounting comments, returned edits, or live-collaboration panels that cannot load anonymously, removing the conflicting red errors and empty-state messages.
  • Sender identity stability. Changing only a correspondence display name no longer rotates the signing and encryption keys, so existing sealed documents keep a valid signature while showing the new name.
  • Agent outbox revocation. The MCP outbox_revoke tool now authenticates with its agent token and can revoke only sealed publications sent by that exact agent. Co-tenant agents and ordinary public pages remain outside that lookup; account-authenticated revocation is unchanged.
  • Modelina ID sign-in parity. The shared sign-in page now supports Google OAuth and WorkOS Magic Auth email codes as well as passwords, all through the same one-time desktop handoff. This closes the password-only limitation recorded in v0.1.49; passkeys remain intentionally deferred.

[0.1.49] — 2026-08-26

Changed

  • Sign-in moved to Modelina ID, the series' own page on a Vox domain (id.modelina.ai), replacing AuthKit's hosted screen. The shape of the flow is unchanged — open a browser, wait on the loopback listener, exchange a code — and what changes is who renders the form and who mints the code. WorkOS's password grant returns tokens and no authorization code, so a page on our own domain cannot hand one to a native process without a code of its own; modelina-core provides that, as a hashed, single-use, sixty-second row carrying PKCE and a redirect pinned to the loopback port. Refreshing an access token still talks to WorkOS directly and is untouched.
  • Liminal moved to the WorkOS environment the Modelina series shares. It was the only product on the other one, and a backend derives its issuer and JWKS from its own client id, so pointing the app at Modelina ID and moving the environment are the same act. WorkOS user ids are environment-scoped, so every stored link named a user the new environment does not have: the column was nulled and each account is re-claimed by email on its owner's next sign-in, through the path provisionOrClaimAccount already had. Handles, published sites, agents and subscriptions are untouched — they hang off accounts.id. Runbook: docs/MIGRATION-modelina-id.md.

Known gap

  • Modelina ID's page supports password only. Anyone whose account signs in with Google or a magic link cannot sign in until that lands. This was accepted deliberately rather than discovered: it is Task 6 of modelina-core/docs/superpowers/plans/2026-08-26-modelina-signin-handoff.md.

Fixed

  • MCP server dropped by the client at startup — Claude Desktop intermittently showed "Could not attach to MCP server liminal" and lost the correspondence tools for the whole session. The stdio entry read the three Keychain secrets before connecting the transport, so initialize went unanswered while the Keychain read waited on an authorization the MCP host has no way to present; the client gave up at its 60 s request timeout and tore the server down. main() now connects the transport first and loads the secrets lazily on the first tool call (memoized, and a rejection clears the memo so a locked keychain doesn't poison every later call), which took initialize from 0.9–60+ s under the host down to ~0.075 s standalone. A missing secret now surfaces as a tool error (missing Keychain secret: agent-token) instead of killing the process, so a half-provisioned profile yields a connected server with a readable error rather than a silent attach failure. packages/mcp only — ships by restarting the agent client, no desktop rebuild.

[0.1.39] – [0.1.41] — 2026-08-06

Versions 0.1.27–0.1.38 are not recorded here; the file drifted from the releases. This entry covers one day's work and does not close that gap.

Most of what follows was found by running real, already-sent documents through the product rather than by reading the spec — including four defects the checks called sound.

Fixed

  • A price was eaten as an equation. Two USD amounts on one line were paired into inline math, so KaTeX deleted both $, a table row lost its cell boundary to a math ∣ and two columns merged. The reader saw a bare number in a document that also quotes 万円. Measured on a real client document: 20 distinct amounts, all corrupted. It happens at render time, after validation, so numeral-unsupported passed and the reader still saw a figure that differed from what was written
  • The core check was skipping the numbers. numeral-unsupported ignored table rows, while §4.2.1 makes a markdown table the primary data path and the generator's prompt teaches exactly that. The real Taisei deck had 0 numbers checked. Prose additionally required an attribution phrase, a gate calibrated on notes while the rule only runs on decks; dropping it took a real proposal from 0 checks to 12 and the Taisei report from 46 to 61, with no false positives either way. The rule is now an error, as the spec always said, with the written-reason door §6.3 designs
  • The vault path reached the recipient. A deck sent to a client printed Projects/JHS/…​.md in its colophon. ref was stripped and display carried the same path
  • …and fixing that broke publishing (shipped in 0.1.40, fixed in 0.1.41): one field fed both ref and display, so a readable display left ref unresolvable and a freshly generated deck failed internal-unresolved, which cannot be excused
  • A stale publication said "Update available", which reads as an app update and sent the author to the updater instead of the Publish button
  • One CJK character decided a document's language. A 32,165-character English note containing 治験 once — 0.01% — generated a Japanese deck. The preview carried the same test, so it disagreed with what published
  • A declared source going stale was invisible. The staleness signature saw markdown and [[wikilink]] embeds, but a source declared by ref is a plain YAML path, so editing the very document the figures are checked against changed nothing

Added

  • The declared goal is something a reader can press, and the author can count. Every joint of that loop existed and none of them met: the goal was parsed and read by nothing, window.liminalDeckGoal shipped in each measured deck with no markup calling it, and raw HTML is disabled so it could not be wired by hand. The destination is set in Generate the deck, so the generator writes it into the front matter and nothing rewrites a published file
  • Generation can run on this Mac, through the claude CLI on the subscription, instead of the metered API. Removing ANTHROPIC_API_KEY from the child environment is the whole mechanism
  • A deck's own declarations govern its settings. visibility: private/internal is forced out of the index rather than left to a checkbox, and purpose: circulate requires a named reviewer
  • Facts may not move between variants. variant-claim-divergence compares sibling decks by source registry, dropped reservations and shared table cells; variant-under-record keeps a disclosure document to one version
  • Internal regions never reach the model. <!-- internal --> and a visibility: internal document are withheld on the desktop, so they do not cross even to our own backend
  • Components are testable. The earlier null hook dispatcher was two physical copies of React, not a missing plugin
  • liminal.modelina.ai serves both the product page and published decks

[0.1.26] — 2026-07-05

Added

  • Cross-account theme registry (B2; Pro publish) — a Pro author can now publish a custom theme to a Liminal-hosted registry from the desktop (the theme picker's new Publish… control), and anyone can browse the public /themes gallery and install a theme with the shipped From URL… flow — closing the cross-account sharing loop that export / local-install / URL-install left open. Publishing is Pro-gated in the backend (POST /api/themes → 402 for free) and rate-limited (20/h); the theme's liminal-theme@1 bundle is stored verbatim in a new registry_themes table and served byte-for-byte at /<handle>/themes/<id> (the URL the From-URL install re-fetches). The gallery is metadata-only (name / description / author / version + the install URL) — no third-party CSS runs on the canonical host. An author can remove their own theme, an operator can remove any; registry themes are included in the account data export and cascade on account deletion. New table → idempotent migration db/manual/2026-07-05-b2-theme-registry.sql. Backend + desktop.
  • UTM / source breakdown in analytics (B6; Pro) — published pages now capture the utm_source (+ optional utm_medium) tags on the links a publisher shares, and the desktop Pro analytics view surfaces a per-publication "Sources" breakdown (in the row-expand, next to Referrers), plus a utm_source column in the CSV export and a sources array in the JSON export. Capture is serve-time, bot-gated, and non-blocking (after()-deferred, failures swallowed — mirroring the referrer counter); only the two utm_* tags are read from the query string, normalized to a "source / medium" key (trimmed, lowercased, whitespace-collapsed, 64-char-capped per value), and untagged traffic records nothing (no Direct-style row). Stored in a new daily-grain page_view_sources(publication_id, source, day, count) table (idempotent migration db/manual/2026-07-05-b6-utm-sources.sql, applied dev→prod before merge). The breakdown is a Pro range field; Free is unchanged. Retention remains deferred (needs reframing under the no-visitor-identity posture). Backend + desktop.

Security

  • Password-unlock attempts are now rate-limited per page + client IP (backend) — the anonymous unlock POST (servePagePost / serveCollectionPost) throttles to 30 attempts/hour per (publication, client IP) to blunt online brute force, returning a 429 gate over the limit. It uses Vercel's trustworthy x-real-ip and fails open (no IP, or a limiter DB error, never blocks a legitimate unlock); the 30/hour bound never affects a real reader (who unlocks once and holds a cookie) but stops an attacker from grinding a weak publication password. Found in a backend security audit (2026-07-04). Backend-only, no schema change (reuses the rate_limits table).
  • Served pages now lock network egress + framing (backend) — every published page (single, collection TOC, member, gate, custom-domain) is returned with Content-Security-Policy: connect-src 'none'; frame-ancestors 'none' and X-Frame-Options: DENY. Because all tenants share the canonical host, SameSite=Lax did not stop a same-origin request: one account's page could fetch('/other/p/secret') with a visitor's unlock cookie and beacon out the protected HTML. The CSP blocks all fetch/XHR/beacon/ WebSocket from a served page (killing that exfiltration channel) and forbids framing. No default-src is set, so script/style/img/font — the inline reading runtime, images, KaTeX/mermaid, and the native password-form POST — are unaffected. Found in a backend security audit (2026-07-04). Backend-only.
  • Published-page URL sanitizer now neutralizes control-character scheme obfuscation (renderer) — safeUrl (the renderer's link/asset URL guard) matched the URL scheme on the raw value, so a tab, newline, or CR interleaved into the scheme (e.g. java⇥script:alert(1)) slipped past the javascript:/vbscript: denylist and passed through unchanged. Browsers strip those control characters from an href before parsing the scheme, re-forming an executing javascript: URL. safeUrl now strips C0 control characters + DEL before scheme detection and returns the de-obfuscated value, so a dangerous scheme can never be smuggled past the allowlist by interleaving. It is the sole guard on the wikilink clickable-href path (standard [text](url) links were already covered by markdown-it's link normalization), so this closes a defeatable security boundary in the reusable renderer. Found in a hardening audit (2026-07-04). Renderer-only; regression-tested.

Fixed

  • A canceled subscription past its period end is no longer stuck on Pro (C2; backend) — the effective tier is now lapse-aware: a canceled (cancel-at-period-end) subscription whose currentPeriodEnd has passed is treated as free at every gate immediately (via getEffectiveTier, the single tier source), and a new hourly reconcile-subscriptions cron converges the stored tier to free and privatizes the account's over-cap / Pro-only content. Previously, if Polar's terminating webhook (revoked) was missed or never arrived, the account stayed Pro forever — a quiet entitlement leak. Strictly limited to canceled: active/trialing/past_due are never downgraded (past_due already keeps Pro through Polar's dunning). Backend-only, no schema change. Found reviewing C2 billing follow-ups (2026-07-05).
  • Sealed E2EE correspondence pubs no longer pollute the free publication cap or suppression (backend) — listPublications (the free-cap gate + dashboard reconcile) and listReconcilePublications (tier-lapse suppression) now exclude sealed = true rows, matching listPublicPublicationsByOwner. Previously an active free-tier account whose agent sent many approved E2EE messages could be wrongly blocked from publishing (402 free_cap_exceeded) and have older public pages system-privatized, because operator-blind correspondence pubs were counted as published pages. Found in a backend security/correctness audit (2026-07-04). Backend-only, no schema change.

[0.1.25] — 2026-07-04

Added

  • Theme install from a URL (B2; desktop, custom themes) — the theme picker's new From URL… control installs a .liminaltheme.json bundle from an https:// link (fetched in Rust — the packaged webview may not reach external origins — with a 5 MB cap, a 15 s timeout, and https-only redirects), then runs the same validation + id-collision rename + confined write as a local-file install. This is the backend-free way to share a theme: host the bundle anywhere and share the link. Desktop + Rust only — no backend, schema, or account. Cross-account sharing (a registry) remains deferred.

[0.1.24] — 2026-07-04

Added

  • Theme distribution — export & install (B2; desktop, custom themes) — export a custom theme to a single portable .liminaltheme.json bundle (a self-contained JSON that inlines the theme.json manifest and every CSS file), and install a bundle from a local file into any vault via the theme picker's Export / Install… controls. On an id collision the installer asks you to install under a different id — an existing theme is never overwritten — and selects the installed theme. Writes are vault-confined; the bundle read/write use a native file dialog. Desktop + Rust only — no backend, schema, or network change. Installing from a URL and cross-account sharing remain deferred.

Changed

  • Backend test infra — tamed PGlite parallel-contention flakiness (E3) — apps/backend/vitest.config.ts now raises hookTimeout to 20s (matching the already-raised testTimeout, so a beforeEach makeTestDb() PGlite boot is no longer governed by the 10s default hook ceiling — the observed "10s hook timeout under load") and caps the fork pool at max(2, cores − 2) to leave the OS and any concurrent tauri dev / sibling workspace suite headroom. Floors at 2 so low-core CI still runs files in parallel. On a 10-core box this cut per-test contention (cumulative test-time 196s→163s) with no wall-time regression (~27s). Test-only — no runtime, schema, or behavior change.

[0.1.23] — 2026-06-24

Added

  • Analytics depth in the desktop view (B6; Pro) — each publication row now expands to show its per-page view breakdown (most-read first) and a compact list of top referrer domains, surfacing the aggregate data the backend already collects. The expanded figures cover the last 30 days. A new Export menu saves the current readership view as CSV or JSON via a native save dialog (Free exports its publication totals; Pro includes per-page and referrer detail). No backend, schema, or network change; the 7/30-day toggle is unchanged.

[0.1.22] — 2026-06-24

Added

  • Named groups (correspondence; desktop) — you can now give a multi-party conversation a name. The Inbox header (in both the conversation list and the open thread) shows the name you assign instead of the raw participant roster, and an inline Name / Rename / Clear control lives in the open-thread header. Names are stored locally and match a conversation by its exact participant set (sub-topic › thread labels still appear); one-to-one conversations are unchanged. Entirely on-device.

[0.1.21] — 2026-06-24

Added

  • Full-text message search (correspondence; desktop) — the Inbox now has a search box that finds messages by their title or body content across all conversations, shown as a flat list of matches with a highlighted snippet; clicking a result opens that thread. Search covers received message bodies too: the not-yet-decrypted ones are decrypted once on your first search and cached, so the normal Inbox display cost is unchanged. Entirely on-device — no backend, schema, or network change.

[0.1.20] — 2026-06-24

Added

  • Correspondence group / multi-party threads (Phase 2; desktop + backend) — received messages now surface their co-recipients, so a multi-party conversation converges into one symmetric group thread for every participant, not just the sender. The Inbox groups by the full participant set, and each incoming message is labeled with its actual sender. The backend's agent-scoped inbox now returns the other granted recipients of each message (excluding you and any revoked grants); it is additive and backward-compatible (older clients ignore it), with no database migration. Builds on the Phase 1 sender-side grouping shipped in 0.1.19.

[0.1.19] — 2026-06-24

Added

  • Correspondence group / multi-party threads (Phase 1; desktop) — the in-app Inbox now groups a conversation by its participant set rather than one thread per counterparty. A message the agent sent to several recipients is shown as a single group thread with a multi-party header (one verified-identity badge per participant), instead of being fanned out into a separate 1:1 thread per recipient. 1:1 conversations and sub-topic threadId grouping (D1) are unchanged. Desktop-only — no backend, schema, or MCP change. Surfacing the co-recipients of received messages, so every member converges on one symmetric thread, is a deferred Phase 2 (additive backend query, no migration).

[0.1.18] — 2026-06-24

Added

  • Correspondence sub-topic threads (D1; desktop + backend) — the agent-supplied thread tag on propose_send is now persisted (new nullable publications.thread_id) and the in-app Inbox groups conversations into sub-topic threads within each counterparty via a composite (counterparty, threadId) key, rendering "counterparty › thread". Legacy untagged messages keep their single per-counterparty conversation. Fully backward-compatible: the MCP-facing thread field is unchanged, and the backend deploy is a no-op for existing clients.

[0.1.17] — 2026-06-24

Added

  • Theme depth (B2; desktop, custom themes) — optional theme.json manifest (name / version / author / description + ordered files[] for multi-file CSS concatenation), multi-file CSS concatenation, and theme-version provenance on publications. A bare theme.css (no manifest) remains valid. Theme distribution (export/install, cross-account share) is the next planned B2 increment. See docs/THEMES.md.
  • External error sink — PostHog (C5; backend, opt-in via env) — wires the C1a observability emit() seam (lib/observe.ts) to a dedicated PostHog "Liminal" project through a new dependency-free forwarder (lib/sink.ts). error-level records (every unhandled route 500 via onRequestError, plus the curated swallowed reconcile/cron failures) post as PostHog Error Tracking $exception issues — grouped, with native alert rules — and warn records post as a liminal_backend_warn event. The forwarder is env-gated (a no-op unless POSTHOG_PROJECT_API_KEY is set, so dev/test/unconfigured deploys stay byte-identical), fail-open (a send reject/timeout/non-2xx never throws and never blocks or delays a request), and zero request latency (the POST is deferred via after()). No new dependency, no schema change, no behavior change to any route — this turns the previously logs-only observability into alerting. Set POSTHOG_PROJECT_API_KEY (optional POSTHOG_HOST) in Vercel to enable.

[0.1.16] — 2026-06-24

Added

  • Per-account rate limiting (Postgres fixed-window; backend, live in production) — a rate_limits table + lib/ratelimit.ts (one atomic INSERT … ON CONFLICT DO UPDATE … RETURNING, fail-open — a limiter DB error never blocks a request and is recorded via captureError). 429 + Retry-After guards on the expensive authenticated routes, keyed by account: publish (60/h), asset (100/h), billing/checkout (10/h), billing/portal (20/h) — the guard runs right after the auth check, before any body parse / Blob / Polar / DB write. New table → applied to prod with the idempotent apps/backend/db/manual/2026-06-23-c1b-rate-limits.sql (CREATE TABLE IF NOT EXISTS) dev → prod before merge. No new external dependency (no Redis/KV).
  • Backend observability (structured logging + global error capture; backend, live in production) — zero-dependency structured JSON logging in lib/observe.ts (logEvent + captureError, never-throws, with a single emit() seam for a future external sink). A Next 16 instrumentation.ts onRequestError hook records every unhandled route/render error across all routes with no per-route changes, and four previously-silent best-effort catch blocks (Polar-webhook reconcile, domain-verify cron, the two publish reconciles) now record their swallowed failures. Also makes the backend test files type-clean (37 tsc errors → 0) and adds an apps/backend typecheck script. Backend-only, no schema/behavior change.
  • Correspondence Inbox & Threads (Phase 1) — a read-only, in-app reading surface over the operator-blind E2EE agent-to-agent channel. A desktop Inbox panel groups correspondence into per-counterparty conversation threads, interleaving messages the agent sent (a local plaintext cache) with messages received (the agent-scoped /api/inbox, decrypted locally via @liminal/crypto), each thread headed by the counterparty's verified-identity badge. The actor model is agent writes, human reads (no compose/reply); it mirrors the MCP trust semantics (undecryptable → [unreadable]; a bad signature is shown but flagged verified:false). A 401 from the inbox maps to a not-provisioned empty-state, and the identity badge is tri-state (verified / unverified / unknown). Desktop-only — no backend, schema, or MCP change. Sub-topic threads (publications.threadId) are a deferred Phase 2.
  • Analytics depth — per-page views and aggregate referrers (B1, backend; live in production) — extends the S10 per-publication analytics with two privacy-preserving dimensions surfaced to Pro: per-collection-page view counts and aggregate referrer domains (no cookies, no IP, no personal data, no cross-site tracking). New page_view_pages / page_view_referrers tables captured at serve time; GET /api/me/analytics gains Pro pages / referrers while the Free response is byte-unchanged. The desktop analytics surface for these is a separate later cycle (Phase B).
  • Mermaid diagrams render again (in-app preview and published pages) — valid mermaid no longer shows "Syntax error in text". The copy-code button was being injected into the diagram <pre>, so mermaid's startOnLoad scraped a polluted source; the copy button is now scoped to pre:not(.mermaid).
  • Universal macOS build (Intel + Apple Silicon) — the desktop app now ships as a single universal binary, so Intel Macs can install and run it (previously Apple-Silicon-only). The release publishes one Liminal_<version>_universal.dmg, and latest.json advertises all three platform keys (darwin-aarch64, darwin-x86_64, darwin-universal) pointing at the same artifact, so existing Apple-Silicon installs keep auto-updating. The landing-page download copy now reads "Intel & Apple Silicon".

[0.1.14] — 2026-06-23

Added

  • Correspondence key attestation (Inc 5) — closes the residual trust-on-first-use / backend-MITM risk in the end-to-end-encrypted channel. Clients now pin each correspondent's keys locally (~/.liminal/pins.json, shared by the desktop and the MCP server) and recompute the key fingerprint client-side instead of trusting the backend's value. A key mismatch hard-blocks sending (the app never seals to a swapped key) and is flagged verified:false on read; the Approvals panel shows two distinct trust badges — WorkOS identity vs key-state (new key / pinned / key-verified / KEY CHANGED). Verify a contact (Connection Settings) shows a locally-computed safety number to compare out-of-band, then mark a key verified; an opt-in "Require verified keys before sending" strict toggle blocks sends to unverified keys. MCP inbox_read / whois now report keyState. Client-only — no backend or Outband change.

[0.1.13] — 2026-06-23

Fixed

  • Correspondence identity Address field now auto-lowercases as you type and validates the format inline (mirroring the backend rule), so an uppercase entry no longer fails with a cryptic register failed: 400; an invalid address disables "Set up identity" and shows a hint.
  • POST /api/correspondence/respond now reports the real accept/decline result (the desktop surfaces a failure) instead of an opaque ok.

[0.1.12] — 2026-06-23

Added

  • Correspondence recipient approval (Inc 4c) — the desktop Approvals panel gains an Incoming requests section: the recipient sees incoming relationship requests (with the requester's identity) and can Accept / Decline inside Liminal, instead of going to Outband web. Backed by account-only GET /api/correspondence/incoming + POST /api/correspondence/respond (recipient email resolved server-side; fail-closed). Completes the correspondence arc (4a–4c).

[0.1.11] — 2026-06-23

Added

  • Account data export and deletion in the app (Connection, account panel): export your published data to a JSON file, and delete your account behind a typed-handle confirmation. Deletion requires a personal sign-in; the default and complimentary accounts are protected. Backed by GET /api/me/export and DELETE /api/me.
  • Correspondence Inc 4b: requester-side approval UI and a per-agent policy toggle in the desktop Approvals panel.

[0.1.10] — 2026-06-23

Added

  • Syntax highlighting for fenced code blocks on published pages (Shiki, inline styles). Mermaid fences and unknown languages are left as plain code.

Changed

  • Internal cleanup of deferred minor items (analytics shaper de-duplication, vault-switch state reset).

[0.1.9] — 2026-06-22

Changed

  • Maintenance version bump. The cleanup intended for this release shipped in 0.1.10.

[0.1.8] — 2026-06-22

Added

  • Analytics view in the app: per-publication readership. All-time view totals for every account, plus a 7/30-day sparkline and window totals for Pro. Reads GET /api/me/analytics.

[0.1.7] — 2026-06-22

Added

  • Share-URL host selector: choose which active custom domain a copied share link uses.

[0.1.6] — 2026-06-22

Correspondence (MCP) — an operator-blind, end-to-end-encrypted, human-gated agent-to-agent channel, alongside the existing publishing app.

Added

  • @liminal/crypto E2EE primitives (X25519 sealing, XChaCha20-Poly1305, HKDF-SHA256, Ed25519 detached signatures via @noble/*) and @liminal/mcp, a stdio MCP server an external agent (Claude Desktop, Outband) runs to drive correspondence.
  • Sealed correspondence: two parties' AI agents exchange ciphertext-only addressed documents. The backend stores only ciphertext (agents/grants tables, publications.sealed/senderAgentId, pages.{bodyCiphertext,titleCiphertext,bodySig}) and cannot read titles or bodies; private keys never leave the local Keychain. Sealed publications are inbox-only (never served on public routes).
  • Human approval gate (HITL) in the desktop Approvals panel: propose_send only stages a document to ~/.liminal/pending/, and nothing is sealed or published until a human approves. Per-recipient, revocable view grants (outbox_revoke).
  • Correspondence identity setup in the desktop Connection panel (registers only public keys + a token hash). MCP tools: vault_list/vault_read/vault_write/vault_create/vault_search, propose_send, outbox_list/outbox_revoke, inbox_list/inbox_read (decrypt + verify locally).

Fixed

  • vault_list/vault_search are now bounded (capped results + total/truncated + optional dir/limit scope) so a large vault stays within the MCP 1 MB tool-result limit.

[0.1.1] – [0.1.5] — 2026-06-21

The SaaS "S-track" and release hardening: multi-tenant accounts + per-user handles, WorkOS AuthKit sign-in, server-side entitlement gates + Polar billing, tier-lapse reconcile, the customer-facing landing page / public profile / /download / first-run onboarding wizard (v0.1.3), Pro custom domains (v0.1.4), opt-in discoverability + apex/multiple custom domains + <link rel=canonical> (v0.1.5), plus the password-protected updater-key rotation and release-pipeline hardening (v0.1.1). See the per-stage specs/plans under docs/superpowers/.

[0.1.0] — 2026-06-21

First distributable release: a signed, notarized macOS app.

Added

  • Mount an Obsidian vault and preview notes with the Liminal renderer (wikilinks, embeds, callouts, KaTeX math, mermaid diagrams).
  • One-click Publish to a styled share URL — single pages (/p/<slug>) and collections (/c/<col>/<page>), with per-page password, SEO/OG config, and themes.
  • Publication Dashboard: reconcile every published artifact against the vault and backend, with staleness detection and re-publish / unpublish row actions.
  • Self-contained packaging: vendored KaTeX + mermaid and local Geist fonts so preview and builds work offline; explicit Content Security Policy forbidding external origins.
  • Developer ID code signing + Apple notarization; distributable .dmg.
  • In-app auto-update via tauri-plugin-updater.