v0.11.0
Install — macOS (Apple Silicon)
Download arkiv_0.11.0_aarch64.dmg below, open it, and drag arkiv to Applications.
This build is unsigned — on first launch, right-click → Open once to clear Gatekeeper
(or run xattr -dr com.apple.quarantine /Applications/arkiv.app); afterwards it opens normally.
The app bundles the Python backend + ML libraries, but you still need FFmpeg + Ollama:
brew install ffmpeg ollama
ollama pull bge-m3 && ollama pull qwen2.5vl:7b && ollama pull qwen2.5:14bIntel Macs / Windows: no prebuilt app yet (the bundle ships an
aarch64Python +mlx-whisper) — run from source, see the README.
First release built and published by the automated release.yml pipeline — arkiv now
ships a downloadable macOS (Apple Silicon) .dmg instead of build-it-yourself. Bundles a
month of Phase 9.x editor / timeline / MCP work (below) plus the repo health-hardening
waves A–E (Windows correctness, CI truthfulness, dependency reproducibility, frontend
quality gates, and version/release hygiene). The .dmg is unsigned — right-click → Open
on first launch — until Apple signing secrets are configured.
Security
- MCP
_safe_pathleakedC:/…absolute paths whole (#182).mcp_servercarried its own copy of the leak guard because the original lived inserver.py, which it must not import (that pulls in the whole FastAPI app) — there was nowhere to share from. Four hours after that copy landed on 2026-06-08, Codex round-2 (fc35b8f) overruled round-1 and madeC:/count as a Windows absolute alongsideC:\; the fix touchedserver.pyonly, so the MCP copy kept passingC:/Users/me/secret.movthrough unchanged for 38 days — on the one surface whose stated red line is "no absolute-path leak", and the only one facing untrusted downstream agents. R5-25 extracted the guard into thepathresleaf, so the copy is now deleted rather than patched:_safe_pathdelegates topathres._display_path, one guard, nothing left to drift. Paths beginningC:/now basename like every other absolute; every other input is byte-identical. - The offload destination guard silently opened on Windows (Wave A, #223).
webguard._assert_offload_dst_safenormalised withPath(dst).expanduser().resolve()and then string-matched against/-anchored POSIX literals (/etc,/system, …). On Windowsresolve()drive-anchors a rootless path —/etcbecomesC:\etc— which no longer matches/etc, so the 403 gate quietly stopped denying (four hardening tests failed on windows-latest, on a gate nobody had ever run there). The sibling export/ingest guards were unaffected because they usePath.relative_to, which is drive-aware; offload has no allowlist by design, so its denylist itself had to become host-independent. It is now evaluated over the raw pre-resolve literal, the resolved canonical path, and a drive-stripped form, with drive-agnostic Windows system and persistence roots added (Windows, Program Files, Program Files (x86), ProgramData, the per-user Startup folder), and the decision extracted into a pure_offload_deny_reasonhelper that can be fed Windows-shaped strings from a Mac. Two independent audits (Codex and a harness reviewer) then found residual false-negatives through Windows namespace, device and admin-share paths —\\?\C:\Windows,\\.\C:\,\\localhost\C$\,\\?\UNC\…all still reached a system root — which are closed by a normalisation pass; a drive-letter check that only accepts ASCII letters (so1:/etcisn't mistaken for a drive); and anOSErrorwrapper so a malformed destination no longer 500s. 8.3 short names (C:\PROGRA~1) are recorded as an accepted residual, resolved by the caller's own Windowsresolve(). - The "explicit embeddings only" invariant on external Chroma is now locked (Wave B, #229).
federation._query_chromaopens external, semi-trusted project Chroma directories. chromadb builds and runs a collection's persisted embedding function when — and only when — a caller passesquery_texts=or documents without embeddings; that is the ChromaToast / chroma#6717 client-SDK-RCE class. Federation queries with explicitquery_embeddings=only, so the path is unreachable today, and this change is about keeping it that way: a SECURITY comment documenting the untrusted-persisted-EF rule, plus a regression guard that spies the external collection'squery()and asserts federation passesquery_embeddings=and neverquery_texts=oradd(documents=). The pgvector migration does not retire this path — federation hardcodeschromadb.PersistentClientregardless ofARKIV_VECTOR_BACKEND— so the guard belongs here rather than in the backend abstraction.
Added
- arkiv is a self-starting desktop app now, not a terminal dance (B3, #199, #200). The Tauri shell spawns the bundled Python backend on launch:
free_port()negotiates an OS-assigned port (no more hardcoded 8501 clash), the child runspython -m uvicorn server:appwithARKIV_PROJECT_ROOTpointed atapp_local_data_dir/arkiv— writable, and deliberately outside the read-only.appbundle — plusARKIV_TRUST_LOOPBACK=1so the loopback WebView skips the token dance;wait_ready()TCP-polls the port on a 45s budget before the window opens, andRunEvent::ExitRequestedkills the child so no orphan uvicorn survives. The backend resolves from an env override in dev or<resources>/backend/{python,site-packages,src}when packaged.assemble-backend.shrebuilds the ~1.3 GB staging bundle from python-build-standalone plus a trimmed site-packages — not PyInstaller; the spike showed this native-heavy tree boots cleanly under a stock portable interpreter. Validated end-to-end rather than assumed: Rustsetup()spawned the backend, WKWebView loaded the SPA, and every startup call (/api/collections,/api/tags,/api/media,/api/stats,/api/bins) returned 200. One packaging gotcha is recorded with it — the dmg step fails with AppleEvent timeout -1712 in any non-GUI build becausebundle_dmg.shruns a Finder-prettifying AppleScript, soCI=true cargo tauri build(which passes--skip-jenkins) is the recipe; the dmg still contains the.appand the Applications drag-target, just without the custom background layout. - The backend's output goes to a log file the user can actually retrieve (#201). A Finder-launched
.apphas no terminal, so the sidecar previously threw every uvicorn access line,print()and traceback into the void — a broken tester box was un-debuggable remotely. stdout and stderr are now redirected to<project_root>/logs/backend.log(writable, outside the read-only bundle) with a one-file rotation tobackend.log.prevso the previous run survives; both streams share one duped fd so lines interleave cleanly, and it falls back to inherit if the log can't be opened. - First-run readiness gate, Load-sample CTA and Report-a-problem (W0, #204, #207, #208).
MainLiveused to boot to an empty grid with silent runtime failures when Ollama, the models or FFmpeg were missing — the binding constraint the launch-readiness audit named. The landing surface now consumes a new unauthenticatedGET /api/health(a JSON readiness report mirroringhealth.py: ffmpeg/ffprobe required, exiftool optional, Ollama reachable and the three configured models pulled — booleans and model names only, no absolute paths, 200 when ready and 503 otherwise) alongsideGET /api/version, and drives a four-state machine: unreachable → Retry; not-ready-and-empty → the failing checks with their fixes plus Recheck/Settings; ready-and-empty → "Load sample library" or "ingest your footage". Backing it arePOST /api/sample/seed+GET /api/sample/seed/status(SingleFlight guard then the shared ingest slot — the seed spawns whisper, so it must not run alongside a real/api/ingest; idempotency short-circuits before any lock so an already-seeded no-op can't 409 a concurrent ingest) andGET /api/logs/tail(bounded 256 KB tail, per-line sanitisation, authed since a log can carry paths and tracebacks). Settings → System gains "Report a problem", assembling a diagnostic from version + health + log tail, each degrading independently. The bug-report template now asks desktop users for that JSON instead of the terminal-onlypython health.py. - A pre-built sample library that makes a fresh install searchable instantly (A1, #205, #209). First-open search-awe without the user owning footage: four tiny (~1 MB total) downscaled CC-BY Blender open-movie clips — Caminandes, Coffee Run, Glass Half, WING IT! — with licences and attribution in
sample/LICENSES.md. v1 (#205) shippedscripts/seed_sample.py, which ingests them into the current project root; the demo queries it advertises were deliberately chosen to be robust, because a 4-clip index is ranking-noisy under bge-m3 anisotropy. v2 (#209) removes the ingest wait entirely: the corpus is packaged pre-indexed into a tar and ATTACH-merged into a fresh project's default store on startup, so the main grid is searchable with zero re-ingest (verified with a Chinese query —駝羊→ llama clip). Only a non-install, fresh, never-dismissed root is ever seeded; a 範例素材 chip marks the demo as demo, and one click removes it. The merge takes the column intersection and reads ids from the pre-built database, never the live table, so remove can never reach a user's own footage — an adversarial review round caught exactly that: an earlier_merge_contentreturned every id in the table. - Taiwan Traditional zh transcripts, written at ingest time (Phase 9.8b, #210). Whisper large-v3 emits Simplified for
zhand the audience is Taiwan.zh_convert.convert_resultnow applies OpenCCs2twp(Taiwan idioms — 内存→記憶體) to transcript, segments and words on the write path, so the search index, the UI and every export are Traditional. Converting on display would not have worked: it can't fix search recall. Timing-safe by construction —start/end/scoreare copied verbatim, so idiom substitutions that change phrase length never shift a timestamp. Degrades to identity when opencc is absent (an optional dependency must never break a transcribe) and is gated to Python ≥3.10, where the arm64 wheel exists; production is 3.12. ingest.py --retraditionalizebackfills libraries indexed before 9.8b (#211). The write-path fix only reaches new transcriptions, so a 5-year archive still answers 記憶體 queries with nothing while holding 内存. This batch-convertsmedia.transcript/segments_json/words_jsonplus the per-language archive with no whisper re-run (follow withembed.py --rebuildfor the semantic half). The naive version of this corrupts data, and building it surfaced why: opencc's phrase layer re-segments valid Traditional (系統→係統, 音樂類型→型別, 設備→裝置), and even neutral s2t does it. So rows are classified first — genuinely Simplified (a Simplified character, no Traditional-only character) gets fulls2twpidioms matching the write path; mixed gets char-levels2tw, touching only genuinely-Simplified characters with no phrase layer, which is length-preserving so word timings survive; already-Traditional or empty is skipped. Idempotent (converted rows re-classify as Traditional), timing-safe, identity without opencc. Ten tests including the load-bearing already-Traditional-never-corrupted case, and verified on a copy of the real 62-media library: 34 Traditional rows byte-identical, 3 mixed rows char-fixed with zero corruption.GET /api/media/{id}/segments— sentence timecodes without the whole record (three-piece W1a, #217). The clip-detail route shipssegments_jsonas a raw string and dropswords_jsonfor transport size, so a downstream edit agent that only needs sentence-level timing to place an IN/OUT on a quote had to re-parse the entire record. This returns the projection alone —[{start, end, text}], no words, no frames or tags — reusing the same defensive JSON projection asmcp_server, so a corrupt column degrades instead of 500ing.- Speaker labels on transcript segments (A4, #221). arkiv had no diarization. After transcription, the audio is now diarized via the
speaker-alignpackage and each segment tagged with aspeaker_id(contract{start, end, text, speaker_id}). Optional and gated (ARKIV_DIARIZATION_ENABLED, off by default, plusARKIV_PYANNOTE_TOKEN) and soft-failing at every step: no token, package absent, or a diarizer error each return the segments unchanged — an optional label must never break a transcription. The load-bearing detail is which audio gets diarized: it must be the VAD-filtered wav the segments were timed against, not the original media, because VAD concatenates speech chunks and collapses the timeline — labels derived from the original would not line up with the timecodes. Read projections (/api/media/{id}/segments, MCPget_transcript) surfacespeaker_idonly when present, so non-diarized clips keep the exact prior shape and the existing contract tests are untouched.speaker-alignis not yet on PyPI; when it's absent the feature and its tests self-skip. - Per-clip camera identity and angle for multicam (A-cam, #224). arkiv knew a clip's camera make, model and reel, but not which angle it was in a multicam shoot — the missing premise for multicam edit decisions. Adds
camera_idandangleas free-form editorial media fields withPATCH /api/media/{id}/camera, mirroring thein_point/out_pointhuman-annotation pattern exactly: PATCH semantics (an omitted field is untouched, null clears), labels trimmed and length-capped, and — the reason that pattern exists — kept out of_ALLOWED_COLSso a re-ingest or refresh can never clobber the marks, with a test pinning it. Carried by the detail route and the REST list viaLIGHT_COLS, so a downstream planner picks them up automatically. - Smart Collections honor hand-added tags (#222). The classifier only saw vision-derived
frame_tags—list_collections' SELECT never joined thetagstable — so a tag added by hand throughPOST /api/media/{id}/tagscould never form a collection, and a user labelling clips a-roll/b-roll got no clickable sidebar entry, only a Query Builder workaround. One bulk tag load now attaches each clip's manual tags to the scored signal, and two edit-role collections key on them: A-roll · 主軸 and B-roll · 輔助, hidden at zero members on libraries that don't use them. A Codex audit caught the follow-on: thetagstable also holdssource='auto'vision copies, so an automatic tag that happened to be nameda-rollwould have joined an editorial collection with no user action — the load is now scoped tosource='manual'. Verified against a real 20-clip library (A-roll → 5, B-roll → 14, structural collections unchanged). - A tag-triggered release pipeline that signs and notarizes (Wave E, #245, #246).
.github/workflows/release.ymlbuilds the self-contained macOS-arm.app/.dmgon av*tag (or aworkflow_dispatchdry run) and attaches it to the GitHub Release, stamping the version from the tag so bundles can't drift from the tag again. Signing and notarization are conditional — they run only when the Apple secrets are configured, otherwise it builds unsigned. Shipsentitlements.plistwith the hardened-runtime exceptions the bundled Python and torch/mlx native libraries need (allow-jit, unsigned-exec-mem, disable-library-validation). Two bugs were found by actually running it rather than reading it: a step-levelif: ${{ secrets.X != '' }}silently never fires, because the secrets context isn't available in step conditions — the check had to be hoisted to job-level env; and an unset secret expands to an empty string, soAPPLE_SIGNING_IDENTITY: ""made Tauri attempt "sign with the identity""" and fail bundling outright. Since an arm64 binary must be at least ad-hoc signed to execute at all, the build is now split: a real Developer ID identity when the certificate is present, ad-hoc-otherwise. - Editor keyboard shortcuts for IN/OUT in the inspector. The frame-exact spike (#190) shipped the
◂格/格▸buttons and rVFC frame marking, but every mark was a mouse click — editors reach for keys. Now, whenever a clip with a player is open:,/.step one frame (only when fps is known, so the frame stepper stays inert for audio / unknown-fps),i/omark IN/OUT at the current frame (or playhead for audio). A single guardedsvelte:windowkeydownhandler inInspector.svelteignores events whose target is aninput/textarea/select/contenteditable and any event carrying a modifier (⌘/Ctrl/Alt) — so typing a tag never marks a clip and ⌘K (focus search) is never hijacked — and does nothing at all on the design-mock inspector (novideoSrc). Closes the last P1 item indocs/frame-exact-inout-roadmap.md. - IN/OUT trim marks now persist per clip (D1). The inspector's IN/OUT points were Svelte-local state — set a range, switch clips, and it was gone; the backend never knew, and the timeline export couldn't see them. Now
media.in_point/out_point(seconds) are stored via a newPATCH /api/media/{id}/inout(PATCH semantics like rating: an omitted field is untouched, explicit null clears; an invertedin ≥ outwindow is rejected 422; NaN/negative rejected by the model), surfaced by the detail route, and the inspector hydrates its marks from them on clip-open (once, without clobbering an edit already in progress) and persists changes debounced. This is the foundation for the multi-clip timeline export assembling the marked sub-clips instead of laying full clips end-to-end. Marks are written only by this endpoint — kept out of_ALLOWED_COLSso a re-ingest/refresh can never overwrite a user's range. - Proxy generation uses hardware decode on Apple Silicon (D3). Building a 720p playback proxy for a 125-clip 4K shoot was pure software:
ffmpeg -c:v libx264with a software decode of the (often 100+ Mbps) 4K source. Measured on an M2 Max off a real 140 Mbps 4K clip, the decode is the bottleneck, not the small encode — so proxy gen now prepends-hwaccel videotoolbox(VideoToolbox hardware decode), cutting 26% wall-clock and 59% CPU per clip (a big deal for a sequential batch, where less sustained CPU means less thermal throttling). The encoder stays libx264 on purpose: the videotoolbox encoder was measured slower and produced a ~20× larger file at the same target. A source whose codec/pix_fmt VideoToolbox can't decode falls back to software automatically (retry without-hwaccel), so nothing that built a proxy before stops building one. Defaults on for arm64 macOS (ARKIV_PROXY_HWDECODE=auto), off elsewhere; proxy height is nowARKIV_PROXY_HEIGHT(default 720) so the resolution can be lifted without a code change. - The timeline export lays the MARKED sub-clip, not the whole file (D2).
GET /api/export/timeline/{fmt}sequenced full clips end-to-end byduration_s, ignoring the IN/OUT marks entirely — so "mark IN/OUT across N clips → one EDL/FCPXML" never produced a cut list. Now each clip contributes only its persisted[in_point, out_point]window (clamped to the real duration; an unmarked clip still contributes its whole duration, so old behaviour is byte-identical until a range is set): the EDL source TC is cut from the in-point and record TC advances by the window; SRT captions are clipped to the window and re-based onto the timeline (a caption fully outside the trim is dropped); the FCPXML asset-clip duration and source start reflect the window. A defensive fallback treats an empty/inverted stored window as unmarked. Together with D1 this closes the "mark across 125 clips → drop one timeline into Resolve" loop. - MCP timecode tools — "which clip" → "which seconds of it" (#184, #185, #186). The MCP server was media-level only: an agent could find a clip but not learn what happens inside it, so arkiv's understanding of footage stopped at the HTTP API. Now:
get_scenes(media_id)— one entry per scene-detect boundary:start_s/end_s/duration_splus that keyframe's nine vision fields and akeyframe_path. Unknown id →null; a clip with no vision analysis →total: 0(distinct answers, deliberately).get_transcriptnow carriesduration_s,segments([{start, end, text}], on by default) andhas_words;include_words=trueadds[{word, start, end, score}], capped at 5000. The four original keys are unchanged. Word timing stays opt-in for the same reason the HTTP detail route dropswords_json(round-5 #26) — except over MCP the failure mode is a blown context window, not a slow transfer.- Segments are projected onto
{start, end, text}, not passed through: the three transcribe backends disagree on shape (mlx-whisper — every Mac ingest — stores its native dict includingtokens,seekand logprobs; faster-whisper writes six keys; whisperx a third), so a verbatim payload would depend on which machine ingested the clip. Measured on 8s of real footage: 1128 bytes verbatim → 213 projected. Clips ingested before Phase 9.4 have no segment timing, sosegmentsis[]and callers fall back totranscript. - Backed by a new
scenes.pyleaf that the HTTP route and the MCP tool both import, so the shapes cannot fork — the failure mode #182 had just demonstrated. They differ in exactly one key, pinned by a test: HTTPkeyframe_url(a URL for the authed thumbnail route) vs MCPkeyframe_path(PROJECT_ROOT-relative — a stdio client is on the same machine, so a path is actionable where a URL is not). The HTTP body is frozen byte-for-byte bytests/test_scenes_contract.py, authored against the pre-extraction route.
- A real end-to-end MCP stdio test (
tests/test_mcp_e2e.py). The docs and this changelog had claimed an "end-to-end stdio smoke" since Phase 14, but none was ever checked in — it was an ad-hoc manual run. Now it exists: real subprocess, realmcpclient, seeded temp project. It is the only thing covering FastMCP registration, stdio framing, dispatch, argument coercion and serialisation — every unit test calls the*_implfunctions directly. Includes a path-leak assertion made at the far end of the wire.
Changed
- License: MIT → PolyForm Noncommercial 1.0.0, with a Commercial Output Exception (#225). arkiv moves to source-available so it cannot be forked into a competing commercial product, while remaining free for any noncommercial use. The Commercial Output Exception is the part that matters to a working editor: videos, timelines and exports produced with arkiv stay fully usable commercially — the restriction is on the software, not on your work.
LICENSEcarries the canonical PolyForm-NC 1.0.0 text plus the exception and the Required Notice; both READMEs get the badge and section, and the wording moves from "open-source" to "source-available", because PolyForm-NC is not an OSI open source licence and saying otherwise would be false. Already-released versions remain MIT-forkable from their commits; this applies going forward. reel-scout stays MIT as the open funnel. Legal review is advisable before a commercial launch (the Output Exception wording, and the holding entity). - Ingest says what it skipped, caps a runaway scene detect, and warns before it garbles your audio (#203). Three silent failures a first-wave user shouldn't have to diagnose. A
--dirscan silently dropped pro/cinema footage (.mxf/.braw/.r3d/.m2ts/…), so someone emptying a C300, RED or BRAW card saw "Found 0 media files" and concluded arkiv was broken — it now prints "Skipped N unsupported file(s)" with a pro-codec callout explaining that ffmpeg has no decoder for these without vendor SDKs. The scene-detect timeout wasmax(120, duration_s * 2), so a corrupt file lying about a huge duration could wedge one ffmpeg for hours; capped at 30 minutes, mirroring the audio path. And with no--language, Whisper defaults tozhand silently garbles English or Japanese audio — there's now a one-time upfront notice to pass--language en/ja/ko. - Phase 1 aborts fast when the drive goes away (#206). Phase 2 (vision) halted after N consecutive whole-file failures, but Phase 1 (probe + whisper + frames) had no such guard: an external or network drive unplugged mid-batch would grind through every remaining file at up to ~240s each (
probe()= 2×120s plus a 2s retry), i.e. hours on a large queue. The Phase-2 design is now mirrored into Phase 1 withARKIV_INGEST_HALT_STREAK(default 5,0disables) and a pure, unit-testable predicate. The counter resets on any ok/skip/move — proof the drive is readable, so sporadically corrupt clips never trip it — and increments on both failure branches, including the empty-record path that is the dominant drive-unplug shape. The partial batch still flows on to Phase 2 and embed. - Vision failures name the right cause (#218, #219). Surfaced by the first large real-corpus stress test (1506 files). A fresh project with empty settings resolves the vision model to the hardcoded default; if that model was never pulled, ingest used to 404 on every frame and then halt with a message that pointed at the wrong thing.
vision.is_model_installed()(strict and tag-exact, returningNonewhen Ollama is unreachable so a missing signal never becomes a blocker) now backsingest._preflight_vision_model(), called before both vision entry points, failing loudly and up-front with the installed vision models and how to set one. Separately, the halt message stopped claiming Ollama was broken unconditionally: a halt from the consecutive-failure guard does implicate Ollama, but exceeding--max-failuresjust means isolated bad frames with Ollama perfectly healthy — that case now points at--skip-failedinstead. - One source of truth for the version label (#197). The header showed a stale
v0.9.2while the app shipped v0.10.0, because the string was hardcoded independently in about a dozen files.lib/version.jsis now the single source, imported by the six live routes that display it; future bumps are one line. The/_design/*mock artboards keep their frozen label on purpose — they are design reference snapshots, not the product. - Neutral placeholders in examples and fixtures (#215). Docstrings, a code comment and several test fixtures named a specific real-world client library (and one machine-specific media path). Swapped for neutral stand-ins that keep each example's teaching value — the registry-name-vs-directory-basename example still contrasts a CJK name with a latin directory, and the vocabulary fixture still exercises the CJK wordlist path. Verified as more than a find-and-replace: disabling
_basename_onlymakes the path-leak test fail, so the edited fixture still bites rather than passing vacuously. - The MCP server boots without a vector backend; the e2e now runs on CI.
mcp_serverimportedvectordb→chromadbat module scope, so a box without chromadb couldn't start the server at all — even though six of the seven tools never touch a vector index — and the e2e stdio test self-skipped on CI, which installs no heavy backends. The import is now lazy: onlysearch_mediapulls invectordb, inside a try that binds it toNoneand degrades to a SQL filename/transcript match when it (or chromadb) is unavailable. The dim-mismatch branch stays correct becausevdbis bound beforeexcept vdb.EmbeddingDimensionMismatchis evaluated. A new test blocks chromadb in a fresh interpreter and asserts the server still imports, serves the six non-vector tools, and degrades search — so the three e2e tests now execute on CI 3.12 instead of skipping. - Clear error when pointed at an uninitialised project root. A tool call against an
ARKIV_PROJECT_ROOTwith no ingested data returned a rawno such table: mediato the agent. Each tool now checks readiness first and fails with an actionable message (pointARKIV_PROJECT_ROOTat an existing library, or ingest there first) — the check is cached once the table exists, and never callsinit_db()(which prints to stdout and would corrupt the stdio channel). The server still starts and lists its tools regardless; only calls fail, legibly.search_media's SQL fallback is also wrapped so a live DB fault surfaces as a clear error rather than a raw driver string, kept distinct from the "vector index unavailable" degrade. - chromadb telemetry off.
vectordbnow setsANONYMIZED_TELEMETRY=Falsebefore importing chromadb, so a local, offline-capable media tool stops phoning home to PostHog on every client init. Set via env (not aSettingsobject) so it survives the test suite's fake chromadb and holds however the process is started; covers the MCP and HTTP surfaces alike.setdefault, so an explicit override still wins.
Fixed
- Federated search returned nothing for libraries indexed before the 8.0c rename (#212). Federation search and registry sync hardcoded
.arkiv/project.db. Libraries indexed before the Phase 8.0cmedia.db→project.dbrename keep their corpus in the legacy.arkiv/media.dband may carry only an emptyproject.dbstub — so cross-project search returned empty for exactly those projects, with no error to suggest anything was wrong. A small resolver now decides in three steps: an explicit override (ARKIV_DB_PATH/--db) wins, else the 8.0c default, else the legacy file. - The tag-merge LLM's answer was silently thrown away (#214).
json_modeguarantees only that the reply parses as JSON, never that it has the shape the prompt asked for —chat.pyhad already learned this from the M1/H9 audit findings and validates every field, butingest.py's two tag-canonicalization call sites never got the same treatment. Measured against the real provider (qwen2.5:14b / ollama 0.30.7), a prompt that literally says to return{"groups":[...]}answered{"慢跑":"路跑"}— nogroupskey at all, so.get("groups", [])yielded[], the loop never ran, and the cluster's judgment was discarded without a word. The model was right (慢跑 and 路跑 are synonyms); arkiv simply dropped it. Silent loss is worse than a crash because nothing surfaces it. Two harder shapes on the same path aborted the entire run and discarded every cluster judged so far, because the failing accessor sat outside the try. Fixed in two layers —llm.chat()takes an optionalschemapassed as ollama'sformatto constrain structure at the source, and the callers validate anyway, because schema adherence is model-dependent and a non-conforming provider must never crash a run.guard_canonical()'s semantic protections were already sound and are untouched; the gap was structural, not semantic. Verified end-to-end: the input that previously yielded nothing now returns{"pref":"慢跑","alts":["路跑"]}. - Ten Taiwan-standard characters made ordinary Traditional text look Simplified (#213).
_char_is_simplified— and thereforeclassify_zh— asked neutrals2twhether a character changes.s2t"corrects" exactly ten Taiwan-standard characters to archaic variants (吃→喫, 唇→脣, 峰→峯, 床→牀, 灶→竈, 痴→癡, 皂→皁, 秘→祕, 粽→糉, 群→羣), and those are common: any transcript containing 吃 or 群 classified as "mixed", and one that happened to carry no Traditional-only character could reach the "simplified" bucket and be handed to phrase-levels2twp— precisely the input that re-segments and corrupts valid Traditional. Probings2twinstead keeps all ten while still rewriting every genuinely Simplified character (软→軟), so the gate and the char-wise converter agree exactly. No output change on already-correct data; what goes away is a wasted pass and the residual mis-bucketing risk. Verified on a real 122-row library (the 3 rows previously mis-read as "mixed" now classify Traditional) and on a second library byte-identical old-vs-new with every genuine Simplified character still caught. - Every built DMG embedded version 0.2.0 (Wave E, #244).
tauri.conf.jsonandCargo.tomlsat at0.2.0across the entire v0.2 → v0.10 tag history, so the version a user saw in a downloaded bundle bore no relation to the release. Both (and theCargo.lockroot entry) are corrected to 0.10.0, matching the release tag at the time.CONTRIBUTING.mdgains a Releases section documenting the changelog-cut and annotated-tag process plus a release-artifact matrix (macOS arm64 supported; Intel and Windows not built). The structural fix is #245, which stamps the version from the tag so this cannot silently drift again. - The toolbar clipped instead of wrapping on a narrow window (C1, #196). Rendered at the 900px Tauri minimum width, the main toolbar genuinely clipped: the artboard is
overflow:hiddenand.toolrowwas a non-wrapping flex, so below ~1000px the VIDEO/AUDIO/GOOD/REVIEW/N·G filters and the GRID/LIST toggle ran off the right edge with no scroll — simply unreachable. The stats line also shattered one token per line. Fixed withflex-wrapon the tool row and filter row, amin-widthon the live search so it shrinks before it pushes buttons off-screen, andwhite-space:nowrapon the stats line. Verified by re-rendering at both 900px and 1440px. - A fresh install was told to pull a model it had correctly not pulled (B1, #198, #202).
health.pyhardcoded a check fornomic-embedwhileinstall.shand config had switched the default embedding model tobge-m3months earlier — so a correct fresh install reported the embed model MISSING and prompted a pointlessollama pull nomic-embed-text. It now readsconfig.OLLAMA_EMBED_MODELlike the vision, chat and intent checks already did. The same drift ran through the docs: the README pull commands and tables,.env.example, anddocs/install.mdall namedqwen3-vl:8b/nomic-embed-textwhile the actual defaults areqwen2.5vl:7b/bge-m3, so a README-follower pulled a 10× slower model thathealth.pythen flagged as missing. Everything is aligned to the defaults, with one note preserved thatqwen3-vl:8bremains a higher-quality/slower override. Addsdocs/quickstart-mac.md, a hand-holding Apple Silicon guide covering the Gatekeeper right-click → Open step, runninghealth.pyto READY before the first ingest, passing--languagefor non-Chinese footage, and where to findbackend.log.
Internal
- Wave B — CI stopped taking the build's word for it (#226, #227, #228). The Tauri (Rust) sidecar had no gate at all, so a
Cargo.toml/Cargo.lockdrift or a Rust regression could merge silently; a newtauri-checkjob on macos-latest (the only ship target — WKWebView is a system framework, so no apt webkit deps) builds the frontend first, becausegenerate_context!embedsfrontend/distat compile time and that directory is git-ignored, then runscargo check --locked. Docker was onlydocker compose config, a YAML parse — a broken Dockerfile merged clean; a newdocker-buildjob now builds the real image and boots it, curling/api/statsover loopback inside the container (arkiv trusts loopback and 401s external callers, matching the Dockerfile HEALTHCHECK) with no models or GPU involved. A full Linux build is green-able becauserequirements.txtgates mlx behind a platform marker. Compose also gains an ollamahealthcheckwithdepends_on: condition: service_healthy, so it waits for readiness rather than container-start — it does not wait for model pulls, which stay a runtime step. Buildx was dropped after its Docker Hub builder-image pull flaked the gate; the runner's built-in builder is sufficient. Separately, the coverage job's flake was diagnosed rather than retried:tests/test_mcp_e2e.pyspawns a real subprocess over async stdio, and under--covthe parent-side tracer slows the stdio pump into thefail_afterbound while the un-instrumented child adds ~0 coverage — it's now excluded from the--covleg by marker (and still runs unfiltered on the test legs), with CONTRIBUTING documenting why coverage stays a non-blocking ratchet and what would flip it to blocking. - Wave C — a clean rebuild reproduces (#230, #231). 19 of 21 Python dependencies were bare lower bounds and the Docker bases plus ollama were floating tags, so a rebuild months later could drift arbitrarily. The two build-path bases (
node:20-slim,python:3.11-slim) and the runtimeollama/ollamaimage — the worst offender — are pinned by@sha256, with the human-readable tag kept alongside for legibility and the digest authoritative. Dependabot then keeps those digests from rotting and opens one weekly PR per bump across pip, npm, cargo, github-actions and docker, each linking upstream release notes so a rollback is just a revert. CONTRIBUTING gains a Dependency updates section explaining why there is no Python lockfile (a 3.9 × 3.12 matrix with platform markers). First batch merged: xxhash, pillow, fastapi, silero-vad, faster-whisper (#232, #233, #234, #236, #237) and, on the frontend,@fontsource/jetbrains-monoand three (#239, #241). - Wave D — the frontend has quality gates, not just a build (#242, #243). The frontend CI job was build-only, its own comment conceding that lint and svelte-check were "a follow-up".
svelte-check --fail-on-warningsnow runs inside the already-required job (so no new required check), and the five pre-existing warnings were fixed to make zero the baseline: unusedexport let→export conston ArkivLogo and Thumb (no caller sets them, API parity kept), ansvelte-ignorewith a stated reason for the deliberate rename-field autofocus in Bins, and two dead CSS rules deleted. A bundle budget was added on the eager initial-load chunks (index js ≤512 KiB, css ≤560 KiB, currently ~404/481) — the lazy Pano360/three chunk at 501 kB, which loads only when inspecting 360 media, and the route chunks are intentionally exempt, because the budget exists to catch new growth in what every user downloads, not to re-litigate an already-split viewer.