Changelog

Product changelog

Every Simmetric Chat release, from the newest version to the oldest.

Unreleased

Added

  • Sponsor-scoped model/provider enablement for sub-users (2026-10-06 quick 7, CLOUD-02 extension). The models and providers a sponsored sub-user can VIEW and USE are now decided by its parent (sponsor), not only by an admin: - The enablement route family (PUT :userId/cloud-model-mode, PUT/DELETE/GET :userId/model-access) moved from admin-only to agency:users:manage + sponsor-scope (admin OR the target's ACTIVE sponsor; self-service refused — a sub-user never widens its own surface; cross-scope/nonexistent target → 404 scoped-miss, never a leaky 403; the permission is non-delegable, D-03 — reads and writes share ONE trust model). Audit events now carry actorIsAdmin. - Picker visibility: in custom mode a provider with ZERO visible models (no local + no allowlisted) is hidden entirely — the sponsor's enablement governs provider rows, not just model rows. Mode "all" users keep the byte-identical surface; local models are never filtered (D-02). Usage is still gated by the orchestrator (isModelEnabledForUser). - Sponsor UI: the agency Team tab gains per-row "Cloud Models" action reusing the admin UserCloudModelsEditor (existing i18n keys — no new locale strings).
  • Widget ownership model (2026-10-05 "wown", quick 261005-scn). Widgets now have owners (their creator) and users (per-widget grants): - widget_users join (additive migration) + users.widgetQuotaLimit per-owner ceiling; NULL/fail-closed 0 — untouched installs see zero behavior change (the maxSponsoredUsers Phase-206 lattice idiom). - Widget CRUD re-armed for non-admin OWNERS: POST /api/widgets rides the new widget:manage permission + the admin-assigned ceiling (breach → 409 { error, quota: "widgets" }); the install-level license gates (widget_enabled / max_widgets) ride unchanged. GET list/detail, PUT settings + workspace bindings reach creator | widget-user | admin (foreign ids stay non-enumerating 404); DELETE stays creator | admin. - New admin section in the widget area (/widgets?tab=quotas, Quota tab): per-user rows — user | owned | max-widgets — with assign / clear (null) writes, paged search, audit-logged (user.widget_quota.updated). - New per-widget "Utenti" tab (WidgetDetail, edit mode): the OWNER selects the widget's users (replace-set PUT, grant directory probe /widgets/owner-user-search — identity-pick surface, no emails); granted users get the read-only arm. - Tests: 30-pin server matrix (widgetOwnership.test.ts), frontend arms (WidgetUsersTab/WidgetQuotasTab), e2e journey (widget-ownership.spec.ts).

Fixed

  • Storage quota GAPS closed — the per-user GB cap became REAL end-to-end (2026-10-06 quick "users-badge storage gap-close", GAP-1+GAP-2 owner-confirmed). The audit found the D-13 machinery sound (resolve chain per-user > install preset > unlimited; drafts-uploader byte accounting; in-tx race guard on the staging path; the 409 family + bands) but DE-FANGED: - GAP-1 — the DIRECT upload seam (POST /api/documents/upload: chat-attach + direct uploads) consulted ONLY the SaaS enforcer and created NO draft row: quota-limited users bypassed the cap by attaching in chat, and the bytes fell to the org bucket (uncounted per-user forever). Fix: the route now runs the pre-check fail-early (before the cost hold — a breach never opens a hold) AND the IN-TX re-check inside the create transaction (T-207-07 race-guard parity: parallel direct uploads cannot double-spend the same remaining bytes), and CREATES the durable attribution draft (parseStatus done + ragJobId=doc + horizon expiry — reaper-invisible, pending-list-invisible, D-13 chain wired for the document's lifetime). - The accounting DROPPED every document after the stage retention (QUOTA_STORAGE_GB_DEFAULT-era upload_draft_retention_days, 30d default): the done drafts kept the stage expiry and the D-13 filter (expiresAt > now) evicted them from the user's usage — the cap was a rolling ~30-day window. Fix: STORAGE_ATTRIBUTION_HORIZON_DAYS (quotaService, 100y) + the done-persist branch (uploadDraftService) renews expiresAt — durable attribution for staging uploads too; abandoned/failed drafts still reaped (their bytes honestly leave the gauge). - GAP-2 — the agency can now set the STORAGE quota of its OWN sponsored sub-users: PUT /api/quota/:userId gains the SPONSOR arm (requirePermission "agency:users:manage" keeps the admin bypass; the 219 team section-modify gate applies in-handler on the non-admin arm) with the SUBSET RULE (validateGrantSet philosophy): the sub's cap never exceeds the SPONSOR's own effective limit, unlimited only for an unlimited sponsor (no infinity delegation), clearing (→ preset) rejected for a bounded sponsor (the admin's lever), cost/token/anchor fields admin-only (key-presence enforced, fields named on 403); foreign ids → the non-enumerating 404. Schema: updateQuotaInputSchema fields relaxed to OPTIONAL (storage-only payloads legal; the refine requires at least one quota field) + the route write arms are KEY-PRESENCE-aware (the admin dialog always sends the full body — byte-identical; a storage-only save never clears the cost columns implicitly). - UI: the agency team panel (SettingsAgencyUsers) gains a per-row storage quota editor (used/limit line, GB input, unlimited checkbox disabled for a bounded sponsor, self-limit hint via GET /api/quota/me); i18n ×8. - The user-delete ownership transfer now ALSO moves the drafts' uploadedBy (the transferred artifacts keep their storage COST attributed to the inheriting admin — otherwise the cascade dropped them into the org bucket). Tests: shared quotaSchema +2 (storage-only accepted / all-blank rejected); quotaRoutes +4 sponsor pins (admin-only-fields 403 / foreign 404 / own-sub 200 storage-only write / exceeding + bounded-sponsor 403s) and the admin 200 write pin updated to the presence-aware arms; documentIdor +50 (attachment-status IDOR suite); uploads.test done-persist pins carry the horizon; mockPrisma: uploadDraft safe defaults (findMany [] + aggregate sum-null), the transfer arms + $transaction executes interactive/arrays forms (suites with specific $transaction pins override). Full batteries: frontend 174/174 (1509), server 356 suites / 5161 tests (the naming-suite failures belong to the PARALLEL quick 261006-s8v naming WIP — unowned here) · tsc ×3 · i18n parity ×8 · changelog + version checks green.
  • The system service account no longer pollutes the user-management surfaces (2026-10-06 quick "widget-service badge"). widget-service (the seed's api-key seam identity — ZERO roles, no interactive login) showed as a plain "user"-badged row in the users list: the getPrimaryRole fallback (`roles[0]?.name || "user"`) LIED about the role, and interactive grant pickers offered the seam identity for workspace access. Two-layer fix: GET /api/auth/users EXCLUDES the seeded account (single where literal — the Phase 213 D-10 scope arm NOT-split: never two where keys, a duplicate key would silently overwrite the scoped arm = a visibility leak); the display policy only — the row/data stay untouched (the widget path resolves the account via apiKeyMiddleware, never this endpoint). The roleless-badge arm is now honest regardless: rows with zero roles render the muted italic settings.users.noRole badge (8 locales) instead of the fabricated "user". Pin updates: usersVisibilityRoutes admin/agency/self arg pins now carry the merged where; SettingsUsers +1 row-scoped pin (within()). Frontend 174/174 (1509); server 356/356; changelog + version checks green.
  • Users management badge drift + role-permission audit (2026-10-06 quick "user-role-badge-drift"). The user user displayed the ADMIN badge: the live install's user role carried 3 admin:* permission links OUTSIDE its DEFAULT_USER_ROLE set (19 vs 16 — manual drift, unattributable: the Roles CRUD permission-replace was the only role mutation with NO event-log row). With admin:settings in the effective set, isAdmin() (the T-206-02 landmine: keyed on admin:settings) graded EVERY user of that role as admin — every future registration would have been an admin. Data fix executed on the live DB (the 3 links deleted → role = 16, byte-identical to the constant; post-fix audit: admin 45 / user 16 / Utente Cloud 10 / Web Agency 20 — all ≡ DEFAULT_ROLES). Guard: PUT /api/roles/:roleId now emits the role.permissions.updated audit event (actor + count + the granted adminPermissionNames[] — drift attribution for exactly this class of accidental grant); 2 pins in roles.test.ts (audit call shape + the admin:*-names metadata contract). User inventory verified: 4/4 users = the frontend list; nothing to delete. NOTE for the running docker stack: the DB fix is LIVE (data-side); the audit-log guard needs a server-image rebuild (docker compose build server).
  • User management can DELETE users now, with an AlertDialog confirmation (2026-10-06 "users-delete"). The user-management panel had NO delete arm: the API existed (DELETE /api/users/:id, admin-only, self-delete 400, personal-chain cleanup WR-02) but was unreachable from the UI and — worse — hard-failed (FK restrict 500) for any NON-pristine target: whoever authored projects/archives/archive-pages/widgets/html-snippets/chat-connectors/OCR- import-synthesis-wikiedit jobs/backup destinations got prisma.user.delete blocked by the restrict FKs (exactly the e2e agency test users that needed cleanup). Two-layer fix: - Server (routes/users.ts): the delete route now runs an OWNERSHIP-TRANSFER phase — the acting admin INHERITS authorship of every non-personal work artifact (11 updateMany arms; personal projects remain delete-only per D-08; roles/accesses/keys/quotas/sponsorships cascade; event_logs keeps its SET-NULL actor). A user.deleted audit event rides the event log (user + transferredTo). MIGRATION_SAFETY.md contract honored: never wipe, transfer + audit. New usersDelete.test.ts (3 pins: self-400 / transfer-arms-200+audit / P2025-404) + the WR-02 order suite updated with the transfer delegates (order-relative pins unchanged). - Frontend (SettingsUsers.tsx): an "Elimina" destructive action per row (admin-only column, HIDDEN on the current admin's own row) opening the standard AlertDialog confirmation (WorkspaceRow D-01 idiom) whose body states the username, the transfer semantics and the irreversible cleanup; confirm → apiDelete → row dropped + success toast; failure → error toast and the row stays. Full i18n (8 locales): deleteButton / deleteTitle / deleteBody ({{username}}) / deleteConfirm / deleteSuccess / deleteFailed. SettingsUsers.test.tsx +4 pins (confirm flow + row drop ± cancel-no-call ± me.id self-guard ± failure-keeps-row).
  • The "Web Agency" role is now SEEDED (Phase 206 seed contract fix, 2026-10-06 "agency-seed"). Audit request: verify web-agency users can manage their sub-users autonomously and can only delegate permissions ⊆ their own effective set. Verdict: the lattice was COMPLETE and heavily pinned — /api/agency CRUD rides requirePermission("agency:users:manage") (deliberately NOT requireAdmin — autonomy by design), validateGrantSet enforces grants ⊆ (sponsor effective ∖ DELEGATION_DENYLIST) with a non-enumerating 403, GET /api/agency/delegatable-permissions is the server-derived picker source (client never computes the lattice), and the escalation matrix (subset ok / admin:* denied even-if-possessed / agency:users:manage denied even-if-possessed / non-possessed denied / foreign sub-user 404 / ceiling fail-closed / D-03 no third level / section-modify gate) is pinned in agencyLattice+agencyRoutes (35 tests green). THE GAP: seed_rbac() created only admin + user — a fresh install had NO "Web Agency" role to assign (and no "Utente Cloud" landing role), violating the DEFAULT_ROLES constants' contract line ("These are seeded on first boot via prisma/seed.ts") and making the whole delegation machinery unreachable out of the box (manual role build via the Roles CRUD was required). seed.ts now creates both roles idempotently (ON CONFLICT DO NOTHING) + links their permission sets (mirrored inline per the KEEP-IN-SYNC discipline, index-checked against PERMISSION_NAMES) — seedMenuSections then picks up their DEFAULT_ROLE_MENU_SECTIONS entries for free (the keys already existed; the sections were skipped with a warning while the roles were absent). Existing installs re-run the seed harmlessly (idempotent); roles/links backfill without touching user_role assignments.
  • Sharing a workspace defaults the role to editor, not viewer (2026-10-05 "viewer-default"). The reported symptom — "as a granted user I cannot create new chats or write in existing chats of the granted workspace" — traced (via a new 4-pin integration probe, granteeChatWriteAccess.test.ts) to the grant ROLE, not the gates: with WORKSPACE_ROLE_ENFORCEMENT=true an editor WorkspaceAccess row passes GET /chats (200) AND POST /chat (200) server-side; a VIEWER row passes the read arm and is graded 403 on the write arm BY DESIGN, and a revoker-no-row grantee gets the non-enumerating 404 (SC-4). BOTH grant surfaces (the owner share dialog WorkspaceAccessDialog and the Settings panel SettingsWorkspaceAccess) preselected viewer for the single AND bulk role selects — an admin who granted without switching the dropdown produced read-only grants. The UI default also contradicted the schema-layer intent: the Phase 189 D-12 migration comment pins legacy-row backfill to editor ("NOT the spec's viewer suggestion"). Both surfaces now default to editor (single + bulk); the role WIRING pins (a test that explicitly picks viewer then asserts viewer flows through) are untouched. Note: users granted BEFORE this fix keep their persisted role — re-grant (upsert role) re-grades.
  • "Summarize the attached file" no longer lies with the canned no-results sentence (attached-file-before-ingest gap-close, 2026-10-05). Attaching a file in chat uploads it through the ASYNC ingest pipeline; the message asks a beat later, the document still has ZERO chunks, so the per-route attachedDocumentId resolution (both chat arms in chat.ts) injected NOTHING: the agent fell back to rag_search, the fresh document was not in the vector store yet, and buildSystemPrompt's rule 3 answered with the canned "Non ho trovato informazioni su questo argomento nei documenti del workspace. Verifica che i file pertinenti siano stati caricati e indicizzati." — a false claim (the workspace may be full; only the fresh attachment is not ready). Both resolution sites now detect the zero-chunk arm and inject a truthful notice instead (buildAttachmentNoticeChunk(new utils/attachmentNotice.ts)): the orchestrator's non-empty-ragContext arm strips rag_search, so the model answers from the notice — "still processing, try again in a few moments" (pending/processing) or "processing failed/cancelled, re-upload" — and the notice explicitly forbids the canned no-results sentence. Status- and progress-aware (0-100 collector progress surfaces only when > 0); DLP rag-context scan rides the notice unchanged. 5 unit pins on the helper (pending/processing/failed/cancelled arms + anti-rule-3 instruction). Frontend follow-up — the attachment-chip ingest waiter: the chip polls the new GET /api/documents/:id/attachment-status endpoint (metadata IDOR tri-check, { name, status, progress } projection, no chunks) every 1.5s, shows a spinner + % progress while ingesting, HOLDS the send gate (canSend + Enter path via attachmentPending) until "completed", and drops the attachment with a toast on failed/cancelled (404 → silent drop; transient network errors keep polling — a server hiccup must not nuke a healthy ingest). The gate is client-side politeness: the server notice stays the honest backstop for stale tabs. 4 endpoint pins in documentIdor.test.ts (access-user projection + D-04 admin arms); 2 ChatInputArea pins (pending disables send + progress chip; completed re-enables).
  • Settings → "Accesso ai workspace" grant rows show the user's name again (Q6-fast settings parity, 2026-10-05). The workspace-access dialog fixed this exact defect (username → id fallback chain) but the Settings panel kept the PRE-Q6 chain user ? displayName(user) : userId — and the D-15 wire never sends an embedded user object (T-189-11), so every expanded panel row fell back to the raw UUID. The panel now mirrors the dialog chain (embedded user legacy shape → grant.username → id) and its suite pins the real wire shape: the static mock dropped the never-sent user field, a new test asserts the username renders and the UUID never does (+1 test).
  • Shared-workspace entry point: the Workspaces page rows now OPEN (2026-10-05 "open-row"). A workspace-level grantee (viewer/editor WITHOUT project access) could never reach the granted workspace in the chat UI: the sidebar selectors are project-first — the project dropdown rides GET /api/projects (lists only creator-owned / ProjectAccess projects, no workspace-grant arm) and the workspace dropdown is disabled until a project is selected, filtered by projectId === selectedProjectId — while the Workspaces page listed the shared workspace (the GET /api/workspaces OR-filter includes the grant arm accessGrants.some(userId)) but its rows were manage-only, with no way to enter. WorkspaceRow gains an "Open" action (first in the actions column, rendered for EVERY listed row — list membership implies at least viewer per the server OR-filter; the client gate is UX-only, chat routes re-gate server-side per request) that mounts the workspace through ChatContext (setWorkspaceId(id) + navigate("/") — persists lastWorkspaceId, so the boot restore re-enters the context on reload without the selectors). A mirror effect in App keeps the sidebar dropdowns coherent when the active workspace changes outside the selectors — ONLY when the parent project is in the user's project list; the chat surface is driven by currentWorkspaceId alone. The sidebar selectors gain a static "Shared with me" entry (sentinel __shared__ in the PROJECT dropdown, rendered only when the share set is non-empty): with a selected project the workspace list scopes to that project as before; picking the sentinel (or an empty selection at boot) has the workspace dropdown list the project-invisible set (all OR-filter- reachable workspaces whose parent project is missing from GET /api/projects) — the workspace-only grantee's share list, previously unreachable because the control required a project first AND Radix Select cannot clear a concrete project id once picked (one-way dead-end; the named entry is the always-clickable path back). The placeholder is content-driven via the shared workspaceSelectPlaceholderKey helper in sidebar/navModel.tsx (stale empty sentinel from a revoked share degrades to the generic wording instead of an enabled-empty shared scope; workspaceSelectDisabled gates the AppNavOverlay mounts — the inline AppSidebarNav renders on host items alone). i18n keys workspace.open + sidebar.selectSharedWorkspace + sidebar.sharedProjects added to all 8 locales — parity green. New pins: viewer rows show Open and never delete/edit, absent onOpen renders no control, the page-level ride-along pin asserts the lastWorkspaceId localStorage write via the real ChatProvider (WorkspacesPage), the shared arm renders from host items without a project and hides when empty (AppSidebarNav), sentinel selection shows the shared placeholder while a stale sentinel degrades, and the overlay gating matrix pins enabled/disabled per arm + placeholder on desktop and mobile (AppNavOverlay).
  • Workspace "Accesso" dialog (the sidebar top button) no longer overflows its own border (2026-10-05). Every full-width block escaped past the rounded dialog border: ui-kit DialogContent is grid with NO explicit grid-template-columns, so children sat on ONE implicit auto track that grows to the largest item's min-content contribution — a grant row whose name is an unbreakable 36-char UUID username contributed 503–549px against the 488px content box (sm:max-w-[520px]), widening the track and STRETCHING every full-width item (title, selects row, checkbox list, grant <li>s) past the border. Two-layered fix: the ui-kit base now pins one explicit zero-min column (grid-cols-[minmax(0,1fr)] — the whole Dialog family shrinks items to the capped content box instead of pushing the track out, app-wide) and the dialog site adds break-words to the interpolated title + flex-wrap to the grant/bulk rows and each grant <li> (nowrap ui Buttons wrap to their own line under ~360px viewports). Playwright-verified: zero overflow 380→1280px (grant names truncate with ellipsis as designed, nothing crosses the border); new WorkspaceAccessDialog.test.tsx pins both layers — 174 suites / 1487 frontend tests green (+1 suite, +4 tests).
  • "Accesso" grant rows show the user's NAME again (Q6 fast follow-up, 2026-10-05). The D-15 server response carries {userId, username} but NOT an embedded user object (dropped with T-189-11 to stop leaking emails/roles metadata), while the dialog rendered the name ONLY from that dead optional user field — every assigned-user row fell back to the raw UUID id. Grant rows now resolve embedded user (legacy shape) → username → id; WorkspaceAccessGrant gains the username the wire actually sends. New pin: username rendered / id never leaked / legacy username-null rows keep the id fallback (+1 test).
  • Settings deep link restored (2026-10-05). A full page load of /settings (bookmark, sidebar entry, legacy nav items) had stopped opening the settings dialog since the R-5 "settings as dialog" revision: at first commit the auth queries were still in flight (user = null) and the close-on-logout effect — declared after the opener in the same component — re-closed it in the same batch (open-then-cancel), and the post-load pathname was already normalized so nothing re-opened. Soft navigation (user menu) was unaffected. The opener now trips only on a LOADED session and the normalize preserves the query string, so ?tab= deep links survive the dialog mount; ?tab= bookmarks also ENTER the target section now (the same arm a rail click gives: the group page opens and the voice marks data-active). e2e/settings-left-menu-nav.spec.ts is green again.
  • Also verified not-a-bug: POST/DELETE /api/system/settings/branding/icon are the ENTERPRISE plugin's routes (simmetric-enterprise/brandingRoutes, Phase 145, mountProtected + adminLicense("white_label")) — the community build answers 404 and the UI degrades with the documented upgrade toast by design (D-12 grep guard whiteLabelExtractionGuard); EE battery 37 suites / 737 tests green, community guard green.
  • Settings panel no longer logs out non-admin users who open it. The settings router was gated end-to-end with requireAdmin (the admin:settings permission) while the per-role settings visibility lattice (SETTINGS_TAB_PERMISSIONS / RoleSectionVisibility) still rendered sections — Profile, Appearance, LLM Providers, Advanced — to ordinary users. Opening the panel (or a section) made SettingsPage's shell fetch GET /api/system/settings, receive 403, and the page conflated 403 with 401: it showed "Session expired. Please log in again.", then wiped the token and redirected to /login 3 seconds later — destroying an entirely valid session on a mere permission gap. Two-part fix (2026-10-04 "settings-panel logout"): - Server: GET /api/system/settings is now permission-aware. Admins (admin:settings holders) keep the full console payload byte-identical to before; every other authenticated user receives only the NON_ADMIN_SETTINGS_READ_KEYS subset (shared) — the non-sensitive keys consumed by non-admin-reachable surfaces (BRANDING_* for the Appearance tab / app header, ALLOW_NON_ADMIN_UPLOAD for the uploads page) — each entry readOnly: true (anti-mismatch rule: never render a form the server would 403). Secrets (SMTP_PASS, LLM_API_KEY, JWT_SECRET, DATABASE_URL, VECTOR_DB_API_KEY, ...) can never reach a non-admin response. The org-scoped view (?organizationId=) and all write routes (PUT /, POST /mail-test) stay admin-only (fail-closed). - Frontend: only a 401 (authentication failure) destroys the session — in SettingsPage's 3-second redirect arm and in App.tsx's useMe error arm. A 403 now renders a distinct "You don't have permission to view these settings." banner (settings.errorNoPermission, all 8 locales) and the session survives.
  • Collector ingestion: files uploaded with a text-like extension (.txt .md .csv) but carrying PDF magic bytes are no longer indexed as raw UTF-8 (2026-10-01 AI-ACT.txt incident). Reading a renames-PDF as text fed the vector index only with uncompressed PDF internals (Info dictionary, XMP metadata, object dictionaries, structure elements) — 3321 chunks, zero of normative text — while the body inside FlateDecode-compressed streams degraded to U+FFFD garbage. parseFile now sniffs the %PDF- header (first 1024 bytes, ISO 32000-leading-junk window) on text-declared files and routes to the PDF parser; the chunk metadata gains a routedAsPdf stamp and a false-positive sniff (a text file merely mentioning "%PDF-" early) falls back to the text parser instead of failing the ingest.
v0.28.1
2026-10-02

Added

  • Connectors: existing connectors can now be EDITED from the admin UI (Q1-quick) — the pre-existing PUT /api/connectors/:id arm is finally surfaced: the card overflow menu gains Edit (first item), opening a pre-filled dialog for the mutable surface (name, knowledge-archive binding, welcome/fallback messages, fallback locale, rate limit + daily session limit — empty clears to null, 0 = the router's UNLIMITED sentinel). isEnabled stays on the card Switch, Telegram pollMode on the webhook buttons, and platform/botToken/workspace remain immutable by schema. The PUT arm also gains the chat_connector.updated audit event (parity with the DELETE arm, D-21).
  • Phase 221 (INSTLIC) — the instance-license paste slot (no-.env enterprise upgrade): a new admin surface `GET/PUT/DELETE /api/license/instance` (requireAdmin + tenant middleware; per-route stacking keeps GET /info and /diagnose untouched) plus the Settings → Security → License panel. The paste verifies an unclaimed ENTERPRISE-tier RS256 JWT probe-first (a rejected key never persists: closed-enum { error, reason } — invalid / expired / plugin_bound / community_tier), stores ONLY AES-256-GCM ciphertext in SystemConfig (key INSTANCE_LICENSE — the SMTP_PASS write-seam discipline), invalidates the D-07 Redis config cache, and applies to the RUNNING instance via the SAME resolver the boot step runs (setCachedLicense — the loader-injected limit overrides survive; boolean flags go live immediately; clearing the paste reverts to the next-boot state with boot parity). Precedence, boot and runtime identical: slot → verified plugin row → ENV LICENSE_KEY → Community. Bulk PUT /api/system/settings rejects the key (the chat-retention dedicated-write-route doctrine) and the settings listing never exposes the ciphertext.
  • Phase 219 (SETP-01/02) — per-role view + modify flags for every Settings section: additive RoleSectionVisibility.canModify (subtracts from the SETTINGS_SECTION_MODIFY_PERMISSIONS fallback — never grants, admin:settings terminal), settingsModify in GET /api/roles/me/menu-sections, server gate on section mutations returning 403 { error, section } (/api/providers mutations → llm; POST /api/agency/users → team), read-only client rendering of viewable-but-not-modifiable sections, and a Modify column in the role visibility editor (PUT /api/roles/:id/visibility accepts canModify per entry — leave-as-is when absent).

Fixed

  • Sidebar delete-confirmation dialogs no longer overflow (Q5-quick): the chat/folder delete confirms were w-80 boxes whose ui-kit Button is whitespace-nowrap shrink-0 BY DESIGN — the long localized folder-action labels ("Mantieni N chat…" / "Elimina N chat permanentemente") escaped the box horizontally, and long interpolated folder names overflowed the title. The boxes are now w-[22rem] max-w-[calc(100vw_-_2rem)] (wider + viewport-capped), the button rows flex-wrap, the two long action labels override the nowrap (whitespace-normal text-left), and interpolated titles/texts carry break-words. The chat-delete dialog's three missing i18n keys (sidebar.deleteChat / .deleteChatConfirm / .deleteChatButton) shipped in all 8 locales — the Italian UI no longer shows the English inline fallbacks there. Q5 follow-up: the first cap never compiled — calc(100vw-2rem) inside an arbitrary value is INVALID CSS (Tailwind requires the underscore idiom calc(100vw_-_2rem) for spaces around operators) and the rule was silently never emitted, leaving the 352px box uncapped on narrow viewports. The corrected utility ships in the built CSS (max-width:calc(100vw - 2rem) verified in the dist bundle).
  • Model resolution: chat turns no longer land on the hardcoded qwen2.5:3b default when no explicit pin exists (Q4-quick — the four-chats incident's root cause: fresh chats served a small local model instead of the workspace default, taking 34-98s per turn). Two fixes: (1) the Chat.model and WorkspaceAgentConfig.model schema defaults changed from "qwen2.5:3b" to the "" sentinel (additive migration — ALTER COLUMN … SET DEFAULT, existing rows untouched): the falsy chain (model || chat.model || undefined, orchestrator:388/1059) now falls through to the workspace agent-config chain exactly like the existing "default" sibling sentinel; (2) the per-workspace localStorage model preference is now EXPLICIT-ONLY — palette picks write explicit: true, the reseed auto-re-persist is gone (a chain-resolved default is not a user choice and must not become sticky), and legacy marker-less values are cleaned up once and never used as candidates. A stale pref (e.g. qwen2.5:3b, still valid-but-slow in the registry) can no longer shadow the workspace default on every new chat.
  • Chat: "one message opens N chats" — the client adopted the server chatId only on the SSE done event (up to ~90s later on tool-heavy turns), so a user retry inside that window re-sent chatId-less and the server created a NEW chat per retry, while the in-flight turn completed in background as an orphan row (Q3-quick). The stream route now emits an additive chat event carrying the chatId at turn START (before any token); the client adopts it immediately (guarded + idempotent — a send that already carries a chatId never re-adopts) and refreshes the sidebar at once. Retries now land in the SAME chat. Byte-identical for pre-Q3 clients (unknown SSE events are ignored).
  • Memory recall: a (user, workspace) pair with ZERO stored memories now skips the entire pre-LLM recall hook (Q2-quick) — one indexed Postgres count (@@index([userId, workspaceId])) gates the hook before any work: no query embedding, no collector round-trip, and no more per-turn [vector-store] Table "ws_user_memory_…" not found warn from a LanceDB table that legitimately doesn't exist until the FIRST memory write (vectorStore's ensureTable creates on first ingest; the search arm fails open to []). A DB hiccup in the count fails OPEN to the pre-gate recall (the hook's best-effort contract — never block chat).
  • shared: the FEATURE_FLAGS regression guard was left behind by the connectors EE-move (quick 260930-l4q) — pin bumped 12 → 13 for connectors_enabled.

Fixed

  • Backup/restore: a dry-run or restore failing with "Unable to decrypt (no key in chain matched)" (destination credentials written under a previous ENCRYPTION_KEY) now returns a guided error naming the cause, the recovery levers (LEGACY_PREVIOUS_ENCRYPTION_KEYS / previous ENCRYPTION_KEY, then restart), the number of keys tried, and the docs/ENCRYPTION_KEY_ROTATION.md runbook — which gained a Docker "Backup restore dry-run fails" recovery section. Only the key-chain length is disclosed, never key material.
  • Phase 220 (SPLIT-01/02) — repo-split coherence: docs/REPO_BOUNDARIES.md (4-repo module→repo audit matrix with the 7-criteria classification doctrine and by-design exceptions) + the repo boundary gate: scripts/repo-boundary-check.cjs (allowlist-enforced grep gate — the core must not reference the private plugin packages outside the two loader seams and documented contract sites) + self-test scripts/repo-boundary-check.test.cjs + CI job repo-boundary-check wired into build.needs
v0.25.0
2026-09-09

Added

  • UI: complete shell revision (R-2 → R-5). The chat empty state shows an elegant monochrome wordmark (the glitch cyan/magenta animation is now confined to the hacker theme); the sidebar becomes a minimal Claude-style rail (new chat + chat list + footer menu) with the full navigation living in a keyboard-friendly cmdk overlay (Cmd/Ctrl+/) that keeps the exact RBAC filtering; the user menu moved from the top bar into a dedicated dialog (account, language, theme, settings, sign out) with a left drawer on mobile; Settings opens as a large master-detail dialog (left menu + full- area detail with rail toggle and breadcrumb) instead of a routed page — the main area is now the chat alone on every page. Themes (light "Pearl", dark, hacker, system) received a coherence pass with WCAG-AA contrast fixes for muted text and chat accents, prefers-reduced-motion guards for all overlays, and white-label-aware branding on the wordmark.
  • Docker: optional searXNG metasearch service added to the compose stack (pure infrastructure — nothing depends on it yet; pinned release tag, wget-based healthcheck, settings auto-provisioned by the image entrypoint).
  • Docker: the private @simmetric-chat/saas plugin now mounts into the compose server like the enterprise plugin (whole-sibling-repo bind mount; the saasLoader no-ops on MODULE_NOT_FOUND in community mode).

Fixed

  • Seed: a boot crash during user seeding could wipe user roles and lock out the admin; seeds now self-heal — the idempotent grant re-applies the admin role to the surviving admin user on every boot (ledgered in STATE.md deferred items).
  • Tenancy: POST /api/auth/register now commits user creation, default-role grant, and default-org membership in a single transaction, and every seed/boot path re-runs the idempotent membership helper on its "user already exists" branch — a mid-boot/mid-request crash can no longer leave a membership-less user (Phase 182 review WR-01); tombstone-resurrect now applies the caller's roleInOrg so removal downgrades privileges (WR-02).
  • Enterprise builds: the Settings → Advanced → Backup section (and the /sso

and /logs routes) no longer stay locked behind the "requires Enterprise" card after an in-app login or SSO sign-in. The enterprise-modules manifest query (GET /api/enterprise/modules) is now auth-gated and session-reactive: previously it was fetched once, unauthenticated, at app boot and cached for the whole session (retry: false + staleTime: Infinity), so on the login screen it 401'd and Enterprise features stayed hidden until a full page reload WITH the stored token. The hook now subscribes to useMe() and refetches the manifest when a session appears (password login, SSO ?token= handoff), and never probes the endpoint without a session at all.

  • Docker: the production secret provisioning helper

(docker/provision-encryption-key.sh) no longer crash-loops the server when the root .env is deployed with an unfilled .env.example template placeholder (<sostituire-con-valore-generato>) as the API_KEY_HMAC_SECRET/ENCRYPTION_KEY value — a placeholder is now recognized as "unset" (loud warning) and provisioning falls through to the restore/generate paths. Any other non-empty invalid value still fails loud at the boot gate.

  • Security: the E2E helper router (/api/__tests__, unauthenticated

start/stop echo MCP server) is no longer mounted when the server runs with NODE_ENV=production — production boots now 404 there, enforcing the router's own documented dev/test-only contract (no unauthenticated process-spawn endpoint in production).

  • Security: the archive import collector-secret callback check

(PUT /api/archives/import/:jobId/callback) now compares the X-Collector-Secret header in constant time (crypto.timingSafeEqual with a length guard), matching the timing-safe discipline already used by the documents status callback and the collector's own secret gate — closing the last plaintext secret comparison on the server.

  • Auth: POST /api/auth/login now validates its body at the route level with

the shared loginSchema (safeParse) — an invalid or empty body returns 400 { error, details } consistent with every other auth route, instead of a 401 carrying a raw Zod message. Valid-but-wrong credentials still return 401 "Invalid credentials".

  • Auth: login now runs a dummy bcrypt.compare against a fixed throwaway

hash when the user is not found, so response timing no longer distinguishes existing from non-existing usernames (enumeration hardening). Both paths still return the same 401 "Invalid credentials".

  • Auth: POST /api/auth/admin-reset-password now validates its body with the

new shared adminResetPasswordSchema (userId UUID + newPassword 8-128 chars, safeParse → 400 { error, details }) — the last auth route moves off ad-hoc destructured body validation.

  • Auth: the closed-registration branch of POST /api/auth/register no

longer duplicates auth logic inline — it uses the statically imported verifyToken / isTokenRevoked / isAdmin / getCachedUserWithRoles (no dynamic await import, no dead header re-check, cached user lookup) while preserving every response shape and the revocation-before-lookup order.

  • Security: the widget cache-bust endpoint (POST /api/config/:widgetId/cache-bust) now compares the X-Api-Key header in constant time (crypto.timingSafeEqual with a length guard) and fails closed if the expected key is unset, instead of the previous plaintext compare with a fail-open expected && shape — the widget's key check now matches the timing-safe discipline used by the server and collector secret gates.
  • Release pipeline: all five Docker images are now built amd64-only. The

QEMU-emulated linux/arm64 builds hung up to ~5h (widget first, then the all-in-one pgvector source compile) and exhausted the runner disk on a private repo's paid minutes; setup-qemu is removed, every matrix entry publishes linux/amd64, and the build matrix is un-serialized to max-parallel: 2. arm64 users can self-build locally (the Dockerfiles are cross-build-safe) or attach a self-hosted arm64 runner — see docs/DEPLOYMENT.md's arm64 note.

Changed

  • Tooling: a dead-code gate now runs in CI (knip) and fails on unused files,

dependencies, or exports outside the documented allowlists; a repo-wide sweep removed ~3,400 lines of dead code, the widget API key generation now lives behind the standard admin flow, and both i18n scripts report defined-but-never-used translation keys as a non-blocking cleanup hint.

  • Widgets: per-widget response model pin — each widget's admin configuration

can now select the LLM provider + model that serves that widget's chat responses (responseProviderId/responseModel, additive migration add_widget_response_model). The pin is resolved server-side from the widget DB row on every chat request (never client-supplied — the widget proxy and visitor UI are untouched), takes priority over the workspace default, and unset widgets keep the existing workspace/global resolution chain. Widget chats now record the model that actually serves them.

v0.22.0
2026-08-30

Added

  • RAG: metadata filtering for rag_search — the agent can now scope retrieval

to document types (documentTypes: pdf/md/txt/csv/docx/xlsx) and ingest dates (dateFrom/dateTo, ISO; a date-only upper bound includes the whole day), fixing the "2023 docs for 2025 questions" failure mode. Filters thread through hybrid search into both retrieval legs: the vector leg reaches the collector as true pre-filters on stamped ingest metadata (pgvector JSONB predicates + Qdrant payload must-clauses; LanceDB and Chroma ignore the new keys with a logged warn), and the FTS leg gains parameterized SQL predicates on the documents table. A server-side post-retrieval backstop against the authoritative documents table guarantees filtered correctness on every provider. Documents ingested from now on are always filterable; legacy vectors (pre-stamping) are excluded while a filter is active and regain filterability via admin re-embed — no schema change, no re-embedding of existing documents, and behavior without filters is unchanged.

  • DLP: four new built-in patterns for common European (esp. Italian)

identifiers, seeded idempotently by a data-only migration and mirrored into the DB-down fallback set — it_vat_iva (Partita IVA; label-anchored, so a bare 11-digit number never matches), it_codice_fiscale (16-char codice fiscale; uppercase-only, prose words like "foschi" can never match), iban (country prefix + length ≥ 15; compact and space-grouped forms, linear-time/ReDoS-safe), and eu_phone (phone IT/EU; seeded DISABLED for admin review since any structured phone regex flags order/reference digit runs). Built-ins are now 10 in total; existing deployments get the new rows on migrate deploy with ON CONFLICT DO NOTHING replay safety.

  • Chat: admins can now reveal or re-hide the DLP-redacted matched texts across

the whole conversation at once, via a new "Show DLP texts" / "Hide DLP texts" item in the chat input's more-actions (+) menu (Eye/EyeOff icon, shown only to admins since only they can expand a DLP notice). The preference is session-scoped, defaults to hidden, and applies to every notice including newly arrived messages; the per-notice eye button still works for manual per-message overrides between global toggles.

  • DLP pattern configuration: the six DLP patterns are now database-backed

(dlp_patterns table, seeded idempotently by the migration so existing deployments get the built-ins) instead of hardcoded. Admins manage them in Settings → Advanced → DLP Patterns: enable/disable any pattern by row, rename built-ins (their regex is frozen — modify attempts return 400), and add up to 50 custom patterns (snake_case name, regex source with compile validation at save, flags, replacement) with a live test preview showing matched segments and the redacted text. Scans that can load the DB set — the filter plugin inlet/outlet and the end-of-response final flush — use the configured patterns through a 5-minute TTL cache with per-pattern compiled-regex caching, falling back to the built-in set when the database is unreachable; the token-by-token streaming flush keeps the built-in patterns (documented v1 limitation). The whole panel is localized in all 8 locales.

  • DLP role bypass: a new DLP_BYPASS_ROLES system setting (JSON array of

role names, default []) lets admins exempt selected roles from ALL DLP scanning and redaction — the filter plugin inlet/outlet and the streaming progressive-flush core (including the RAG-context scan and thinking redaction, for both JWT chat and the widget service account) all honor the same gate. Every bypassed run fires one fire-and-forget dlp.bypassed audit event recording WHO bypassed (the intersected role names) and the origin surface — never the scanned content. Admin UI: a roles multi-select with an exposure warning in Settings → Advanced → DLP, and a "Bypassed" action label in the DLP Match History panel, localized in all 8 locales.

  • DLP audit source tagging and filtering: every dlp.* event log carries

source: "chat" | "widget" in its metadata — the shared SSE stream core derives the tag from the widget X-Widget-Id header so widget conversations are redacted the same as chat ones but now distinguishable in the DLP Match History panel (Settings → Advanced). The panel gains client-side filters for source (All/Chat/Widget), chat ID and user.

  • Admin settings UI for the upload-draft reaper: a new section in

Settings → Maintenance exposes the enabled toggle (upload_draft_reaper_enabled) and the cadence cron expression (upload_draft_reaper_cron) via PUT /api/system/settings, with a client-side five-field cron shape pre-check (the server keeps its warn-and-fallback behavior for invalid values), localized success/error toasts, and descriptions in all 8 locales. .env.example documents the reaper keys as env-var overrides (upload_draft_reaper_enabled, upload_draft_reaper_cron, upload_draft_retention_days).

  • Config: the environment configuration is now defined once in a shared Zod

schema (@simmetric-chat/shared), the effective precedence is documented and test-pinned as DB > ENV > default (settings UI edits win immediately; 8-case matrix), and keys that would be ignored because an env var overrides them are shown with an envOverridden badge in the settings UI.

  • Config: the repository-root .env is now the single runtime config for the

server, collector and widget — a new zero-dependency loader (loadRootEnv()) merges it under per-package .env overrides (legacy), with a once-per-boot deprecation warning listing affected key names (never values); Docker and Tauri aligned (compose env_file points at the root file, no image bakes secrets).

  • Config: all three package .env.example files are regenerated from their Zod

schemas (83/15/6 keys, grouped, defaults synced) with Jest tripwires that fail when a future schema key goes undocumented; raw process.env reads kept outside the schema (HF/openai-quirks, encryption/HMAC keys, LOG_LEVEL, test gates) are pinned by 44 behavioral guard probes.

  • UX: deleting a workspace now asks a standard confirm dialog (was an inline

mini-confirm), workspaces support multi-select bulk delete with a confirmation dialog and a results toast counting skipped rows (no-permission rows are skipped, never a silent failure), the OCR preview side-by-side keeps the extracted text bound to the same page slice as the image pane, and the synthesis run detail shows the approve button disabled with the explicit reason on runs that cannot be approved yet.

  • UX: workspace selection checkboxes and bulk delete are localized in all 8

languages.

Changed

  • The upload-draft reaper is fully configurable via system settings: an

enabled/disabled toggle (upload_draft_reaper_enabled, default "true") and a cadence override (upload_draft_reaper_cron, default daily 03:00 UTC), both managed through PUT /api/system/settings. Invalid cron values log a warning and fall back to the default cadence instead of failing boot; default behavior is unchanged.

Fixed

  • Fixed a Docker production boot crash-loop where server start died with a

unique-constraint failure on api_keys.prefix: the widget API key seeder now derives the display prefix from the HMAC digest and tolerates prefix collisions (re-check by key hash, warn instead of crash), and API key generation retries with a fresh key on a prefix collision (max 3 attempts).

  • Chat citations now reflect the sources an answer actually grounds on

instead of everything the search tools retrieved: after citation deduplication, a grounding filter caps citations per document at the top-2 by relevance score and drops chunk citations whose text shares no meaningful lexical overlap with the final answer (conservative gate — wiki/archive pages, web/memory results, citations without chunk text, and very short answers are always kept). "No further info" replies no longer show "Fonti (5)" of one document at ~1.5% relevance; the "No supporting evidence" disclaimer is likewise judged on the filtered set (both the streaming and non-streaming paths). Citation pipeline only — no UI, i18n, or schema change.

  • Docker: ollama service healthcheck replaced the curl probe (the

ollama/ollama image ships no curl/wget/node — the container reported "unhealthy" forever) with an ollama ls probe; qdrant healthcheck replaced the wget probe (image ships no wget/busybox) with a bash /dev/tcp socket check — both services now report healthy. The single-container docker/entrypoint.sh now sources provision-encryption-key.sh (parity with the split image): production standalone boots auto-provision ENCRYPTION_KEY + API_KEY_HMAC_SECRET instead of failing the gate. All five Dockerfiles lint clean under hadolint (documented unpinned-deps policy + placeholder-ENV waivers).

  • DLP audit chronology events no longer show "no match details" when the

matched content arrived in an early stream chunk: the streaming dlp.output_match event now derives its match types AND matched-text list from the matches accumulated across the whole run (all progressive flushes plus the final tail scan) instead of the last chunk scan alone — historical rows could store an empty matchTypes array while the row still showed a match-type badge. Matched text is stored per the DLP filter-plugin precedent (dlp.input_match / non-streaming dlp.output_match already carry it); the audit panel is admin-only and Enterprise-gated. The audit panel's expanded view now renders the detected type list with a "type-only" hint for events that record match types without matches (localized in all 8 languages), keeping the "no details" fallback only for truly empty metadata.

  • Uploaded files remain available for retry: collector terminal status

callbacks no longer delete staged draft files from storage/uploads/drafts/ (any sibling document's callback could erase the file every retry depends on), and /retry//assign self-heal by restoring the staged file from the persistent OCR copy (storage/ocr-sources/) when present instead of failing with "no longer exists on disk".

  • Deleting a failed stale upload draft no longer returns blocked (409) —

DELETE /api/uploads/:id now treats terminal (completed or failed) leg states as deletable and only rejects drafts with a genuinely in-flight (non-terminal enabled) leg, and the frontend bulk delete uses the same in-flight predicate as the filter chips so what the UI offers to delete is exactly what the server accepts.

  • RAG/KB retry of an upload whose staged draft file is missing now returns a

clear re-upload error (400) instead of a false success toast, failed collector legs no longer delete the draft's staged file from storage/uploads/drafts/ (future retries stay possible — the 24h reaper and the DELETE route remain the only draft-file deleters), and retry toasts now reflect the per-leg outcome: a rejected leg on HTTP 200 shows a localized error toast and bulk retry tallies it as failed.

  • Docker production deployments auto-provision a persistent ENCRYPTION_KEY

(server-storage volume, /app/storage/.encryption-key) instead of crash-looping on the hard default. The server entrypoint generates the key once before any Prisma step, restores it on every restart/rebuild, prefers an operator-supplied value, and fails loud on a corrupt persisted key. .env.example and docs/DEPLOYMENT.md updated to mark the variable Required-in-production. See docs/ENCRYPTION_KEY_ROTATION.md for the Docker notes.

  • KB OCR pages no longer fail when the Ollama vision stream is closed by the

model engine without the final done marker after complete transcription (observed with the glm-ocr custom engine): ocrPage now salvages the accumulated non-empty output as the page result with a truncated flag and a warning log, instead of discarding a real transcription behind "Did not receive done or success response in stream." A done-less stream with no content still fails the page.

  • OCR job summaries are truthful about page health: pages that end with a

[FAILED: marker are counted and stored as failedPages in the job result JSON, and completion logs emit a [ocr] Job completed with failed pages warning, so a COMPLETED job with some failed pages is no longer indistinguishable from a clean success (zero tokens + floor quality score alone were ambiguous).

  • Documented + fixed failure mode for Enterprise deployments: when

@simmetric-chat/shared gains NEW source files, an out-of-date enterprise file: dependency snapshot can crash the server at boot with "Cannot find module './env.schema'" — pnpm snapshots the file: package into node_modules/.pnpm and files created after the last pnpm install never reach that snapshot (hardlinks only update already-existing files), so the snapshot's schemas/index.js requires a module that does not exist on the enterprise side, and the loader's fail-loud policy turns the plugin registration failure into a boot crash loop. The fix ships as documentation: the enterprise repo's rebuild runbook now covers the reinstall-not-rebuild distinction (refresh the snapshot with pnpm install, then pnpm build), and the air-gap install runbook in docs/ENTERPRISE_PLUGIN.md gains the matching troubleshooting subsection (symptom, cause, fix, sanity check) so operators without the private repo still find the remedy.

v0.21.0
2026-08-28

Debt Sweep & Release Hygiene (was: v1.5.0)

Added

  • docs/LICENSE_DECISION.md — license-model analysis for the dual distribution

(community + enterprise). Decision: AGPL-3.0 (community) + proprietary commercial (enterprise).

  • LICENSE replaced Apache-2.0 → GNU AGPL-3.0 (community repo).
  • LICENSE_EE.md — proprietary commercial license for the enterprise plugin

(@simmetric-chat/enterprise).

  • NOTICE — dual-license explanation at repo root.
  • CLA.md — Contributor License Agreement (v1.0) — copyright + patent grant,

dual-license-aware. Required for all external contributions.

  • AGPL-3.0 copyright headers added to all 981 source files across 5 packages

(.ts/.tsx). Applied via scripts/add-headers.cjs (idempotent).

  • docs/ENTERPRISE_LICENSE_TERMS.md — operator-facing commercial/SLA terms for

enterprise customers.

  • .github/workflows/release.yml — GitHub Release + GHCR image publish on tag

push.

  • CHANGELOG.md (this file) — Keep-a-Changelog format, seeded from milestone

history.

  • .planning/milestones/v1.5-debt-sweep-ROADMAP.md — debt-evaluation milestone

scope (E2E carry-forward, debug sessions, quick-task triage, version-stamp sync discipline).

Changed

  • package.json version bumped 0.17.0 → 1.4.0 (synced with the latest git

tag v1.4). Per-package versions remain at 0.22.0/0.23.0 (private packages, not published to any registry).

  • package.json + all packages/*/package.json license field set to

AGPL-3.0-or-later.

  • README.md license badge updated Apache-2.0 → AGPL-3.0; license section

updated to describe the dual-license model; Contributing section added with CLA requirement.

  • CONTRIBUTING.md updated with CLA signing instructions.
v0.20.1
2026-08-27

Horizontal Scale (Redis Layer Completion) (was: v1.4)

Goal: Close the 4 SCALE deferred items (CSW-19 + SCALE-02/03/04) and prove horizontal scaling with a real multi-instance integration test — enabling safe multi-instance server deployment behind a load balancer.

Shipped: 7 phases (161–167), 13 plans, 29 tasks. All 22/22 requirements satisfied. Git tag: v1.4.

Added

  • SCALE-01 (CSW-19): 3 remaining schedulers lock-wrapped →

then all 8 migrated to pg-boss cron jobs; setInterval + isRunning + withDistributedLock removed from all 8 migrated schedulers.

  • SCALE-02: scryptSync ENCRYPTION_KEY fallback removed in production —

fail-loud boot (logger.error + process.exit(1)) + defense-in-depth service throw; dev/test preserves scrypt; rotation runbook updated.

  • SCALE-03: bcrypt-loop API-key verification → keyed-HMAC O(1)

findUnique({key_hash}) digest lookup; breaking DROP COLUMN hashedKey + ADD COLUMN key_hash migration; API_KEY_HMAC_SECRET env var; operator migration runbook docs/API_KEY_MIGRATION.md.

  • SCALE-04: 8 setInterval schedulers migrated to pg-boss

(Postgres-backed job queue, air-gap friendly); 2 latency-sensitive 10s pollers (OCR + synthesis) stay as setInterval; pg-boss singleton (jobQueue.ts) with graceful degradation.

  • SSE fan-out verification: cross-instance deferral closed — 4

mock-based cross-instance tests proving the Redis pub/sub SSE relay (A publishes → B relays, origin-skip, teardown, degradation).

  • Operator docs: docs/SCALING.md (377 lines) — multi-instance deployment

topology, REDIS_URL requirement, pg-boss Postgres dependency, SCALE-02/03 cross-references, 10s pollers, graceful degradation.

Breaking changes — operator action required

  • `ENCRYPTION_KEY` must be set in production .env (base64, 32 bytes) or

the server refuses to boot. See docs/ENCRYPTION_KEY_ROTATION.md.

  • API keys switched from bcrypt (apiKeyHash) → HMAC-SHA256 (key_hash).

Operators must re-issue widget/collector API keys. See docs/API_KEY_MIGRATION.md.

Infrastructure

  • New dependency: pg-boss v12.28.0 (server). New env var:

API_KEY_HMAC_SECRET. Boot order changed — pg-boss init is now the 8th async init step with a shutdown teardown.

v0.20.0
2026-08-26

Concerns Sweep (was: v1.3)

Goal: Sweep the 2026-08-26 CONCERNS.md audit — fix all easy wins directly, tackle the medium-effort items in focused phases, and evaluate (defer or document) the blocking/large-effort points.

Shipped: 7 phases (154–160), 14 plans. Brownfield hardening milestone; no new user-facing features.

Added

  • docs gitignore fix, chat.ts file map, lint rule flips.
  • code hardening easy wins (apiKeyMiddleware take:10 cap, etc.).
  • frontend lint debt reduction (3 plans, warn→error flips).
  • ENCRYPTION_KEY production boot warning (CSW-11) + batched

title+tags+followUps generation.

  • module splits (provider parser extraction, capability

registries).

  • test coverage (chatImportService, chatExportService,

apiKeyService, 5 remaining services; collector + widget wired into root jest config).

  • evaluate & document (.env license JWT entitlements, license

signing key rotation runbook, stale AGENTS.md claim fix).

v0.19.1
2026-08-25

Refinements (was: v1.2)

Goal: Branding, AI disclaimer, TypeScript reimplementation of the wiki graph algorithm, RAG search fixes, MCP hardening, Setup Wizard first-run.

Shipped: 6 phases (149–153 + 153.1).

Added

  • BRAND-01/02: SVG S-monogram favicon, reusable Monogram component,

muted localized AI disclaimer below every assistant message (7 locales).

  • MCP-01/02/03: MCP hardening — per-session SSE transport Map,

MCP_API_KEY Bearer auth gate, list_workspaces IDOR closed, getMCPToolsForWorkspace wired into resolveSkillsForChat.

  • RAG-01/02/03: FTS locale fix (multi-locale tokenisation for

it/ru/de/es/fr/zh), deduplication of fallback results rag_search↔wiki_query, LLM affordance prompt for rag_search vs wiki_query.

  • WIKI-01: TypeScript reimplementation of the Graphify wiki algorithm

(community detection + wiki markdown generation, Apache-2.0 clean reimplementation, ~280 LOC Python → TS).

  • WIZ-01/02: Setup Wizard first-run UI (multi-step: admin account +

provider config + vector DB, consume /api/system/initialize); SEED_BOOTSTRAP_ADMIN disabled when the wizard is active.

  • .1: v1.2 tech debt cleanup (React Compiler warn→error flip).
v0.19.0
2026-08-19

Enterprise Plugin Architecture (was: v1.1)

Goal: Refactor the licensing model from "runtime feature flags in the public source" to a "separate private enterprise plugin": enterprise features (SSO, audit log, white-label, backup) live in a private package delivered only to paying customers; the community repo contains no enterprise feature code. Commodity features become always-ON in community.

Shipped: 9 phases (140–148), 19 plans, 26 tasks.

Added

  • EPA-01: PluginContext/EnterprisePlugin types in shared; loader seam

loadEnterprisePlugin(app) / shutdownEnterprisePlugin() with two-step require.resolve→require (MODULE_NOT_FOUND = community no-op, other errors = process.exit(1) fail-loud).

  • EPA-02: 9 commodity flags removed from FEATURE_FLAGS (20→10):

web_search, webhooks, push_notifications, memory, lead_export, widget_analytics, auto_title, synthesis_rate_limit. Commodity routes always-ON.

  • EPA-03: SSO (SAML + OIDC + SCIM 2.0) extracted to enterprise plugin.
  • EPA-04: Audit log (immutable event_log table + INSERT-only DB role)

extracted to enterprise plugin.

  • EPA-05: White-label branding (config-key validator IoC hook) extracted

to enterprise plugin.

  • EPA-06: Backup (~1400 LOC: backupService, provider registry, Bree

scheduler, restore, routes, UI) extracted to enterprise plugin.

  • EPA-07: overrideFeatureLimit resolver with reactive revocation;

GET /api/enterprise/modules manifest; React.lazy conditional loading of 4 enterprise panels.

  • EPA-08/09/10: priority_support removed; custom_agents = numeric

limit (community 3, enterprise Infinity); licensePayloadSchema byte-identical (RS256).

  • EPA-12: docs/env alignment (docs/ENTERPRISE_PLUGIN.md, docker-compose

volume mount, .env.example split), air-gap CI profile (grep gate on license service for HTTP primitives).

v0.18.0
2026-08-12

Public Release candidate (simmetric-chat) (was: v1.0)

Goal: Prepare and publish the project as a clean public repo, renamed to simmetric-chat, without secrets/personal data/local paths, with dependency license verification, enterprise keygen tool (separate, not published), and complete documentation.

Shipped: 8 phases (132–139), 12 plans, 16 tasks. Public repo published, rename complete, license audit, enterprise keygen (HS256 → migrated to RS256 post-v1.0), canonical docs, CI GitHub Actions, fresh repo.

v0.20
2026-08-11

Widget UX, i18n & Reliability

Shipped: 7 phases (125–131), 32 plans, 75 tasks, 16/16 requirements satisfied.

Added

  • WID-ADMIN: Widget create/edit tabbed subpage (/widgets/:id +

/widgets/new), live preview pane, dirty-guard, leads tab.

  • WID-I18N: JSON-block transport (kills %20 bug), visitor→widget-default→en

fallback chain, 7-locale chrome + content localization, locale threading widget→proxy→server→orchestrator.

  • WID-QUEST: Tri-state suggested questions (Default/None/Custom), 7 locale

groups, line-clamp overflow fix.

  • WID-CREDITS: Footer credits + AI badge, white-label gated removal,

real-embed popup relay verified.

  • WID-BUGS: 44px input buttons, host-FAB round-trip, SVG-only icon

contract, contact banner, wiki loop, lead persistence, RAG-degraded i18n.

v0.19
2026-08-08

Diagnostics & Debt

Shipped: 5 phases (120–124). License diagnostics (boot logs, admin-only diagnose endpoint, pnpm license:check script), test debt (ESM drift, LoginPage QueryClientProvider, fixture shape spec), tech debt evaluation (Redis scale layer spike — deferred to v1.4).

v0.18
2026-08-04

Stabilization

Shipped: 4 phases (116–119).

v0.17
2026-08-02

Enterprise & Polish

Shipped: 8 phases (108–115).

Earlier milestones (v0.1–v0.16) are documented in .planning/PROJECT.md (Milestone History section). The full phase-level history lives in .planning/MILESTONES.md.