TranscriptX architecture review (evidence, not docs)¶
Maintainer assessment under docs/reviews/. Dated snapshot (2026-09-02), not a contract. Where this disagrees with contracts or src/, code and contracts win. Hosted at /guide/reviews/architecture-review-2026-09-02/ after make docs.
Static reconstruction of the TranscriptX tree as of 2026-09-02. No runtime execution, no test run, no code changes. Where docs and code disagree, code wins.
A. System model — what the software actually does¶
TranscriptX is a single-process, local-first transcript analysis workbench. It does not transcribe audio itself, does not expose a REST API, does not authenticate users, and does not use the SQLite file that exists on disk.
flowchart TB
subgraph host [Host machine]
Media[Recordings and raw STT JSON]
STT[whispermlx / WhisperX / WebUI]
Ollama[Ollama HTTP]
end
subgraph process [One Python process]
GUI[Streamlit pages via session_state page key]
API[app.workflows Python API]
Import[Managed import + adapters]
Stores[JSON stores + FileLock]
DAG[RunOrchestrator + module DAG]
Watcher[In-process directory watcher thread]
end
Media --> STT
STT -->|writes originals/| Media
GUI -->|copyable commands only| STT
GUI --> Import
API --> Import
Watcher --> Import
Import --> Stores
GUI --> DAG
API --> DAG
DAG --> Ollama
DAG --> Out[outputs_dir run folders]
Stores --> GUI
Out --> GUI
Actual product path: external STT → managed import (canonical JSON + sidecar + archived original) → optional speaker ID / corrections → in-process DAG analysis → browse/export artifacts.
Trust domain: the OS user who runs the process, plus loopback Streamlit bind. TRANSCRIPTX_BIND_HOST=0.0.0.0 is an unauthenticated LAN bind (SECURITY.md).
Doc vs code: docs/runtime/STORAGE.md still lists state/transcriptx.db. That file exists locally with Alembic tables. Live src/ has zero SQLite/SQLAlchemy/Alembic references. Persistence is JSON + locks. Theme J (analytics DB) is roadmap, not current architecture.
B. FR list¶
Outcomes inferred from code, contracts, workflow docs, pages, and tests. Implementation details are DPs, not FRs.
Core product behaviour¶
FR1 Admit an external transcript into a managed library that later analysis will accept.
FR2 Help the user transcribe on the host without TranscriptX executing STT.
FR3 Prepare audio (preprocess / merge) before external STT.
FR4 Map diarized
SPEAKER_nlabels to durable names/profiles, with optional local voice matching.FR5 Propose and apply transcript text corrections (viewer span edits + Corrections Studio).
FR6 Run a selected analysis plan on one transcript and persist a inspectable run.
FR7 Run analysis across a named group of transcripts and persist group-level results.
FR8 Run the same analysis plan across many transcripts (batch).
FR9 Inspect a completed run: overview, transcript, insights, charts, artifacts.
FR10 Export selected run artifacts as a downloadable archive.
FR11 Create and edit groups of transcripts.
FR12 Search phrase text across the corpus.
FR13 Rename a managed transcript and keep companions consistent.
FR14 Maintain longitudinal speaker profiles (display names, links, optional avatars/voice).
FR15 Optionally interpret runs with a local LLM (Ollama).
FR16 Browse the library and delete a selected managed transcript after confirmation.
Supporting operational behaviour¶
FR17 Persist project settings, analysis/UI/STT presets, and dashboard layouts across sessions.
FR18 Watch folders and admit new transcripts (audio is queued, not transcribed).
FR19 Backup and restore a workspace.
FR20 Preview-and-confirm destructive cleanup (duplicate files, old runs).
FR21 Refuse to operate on an incompatible data-root schema epoch, with non-destructive remediation.
FR22 Run the same GUI via Docker Compose with host mounts.
FR23 Diagnose and repair incomplete renames, speaker-profile integrity, and dependency health.
FR24 Script import and analysis without the GUI (
app.workflows,run_managed_import_workflow).
Cross-cutting¶
FR25 Transcript JSON and managed artifacts have a single write authority and survive crash mid-write.
FR26 Run success/failure is recorded as typed execution truth, not inferred from leftover files.
FR27 Source material and outputs stay on the local machine; there is no product authn/authz layer.
FR28 Effective config is layered: environment, run/draft override, project
config.json, defaults.FR29 Missing optional extras (NLP, voice, LLM, plotly) degrade to skip/block rather than crash the workbench.
FR30 Speaker ID and Corrections mutations are revisioned and duplicate-safe across Streamlit reruns.
FR31 An operator can tell that a run started, which modules finished, and where logs/perf traces went.
Ambiguous / inferred / contradictory (flagged)¶
ID |
Issue |
|---|---|
FR2 |
Page is named Transcribe Audio but only generates copyable commands. Documented in public_surfaces.md and |
FR7 vs FR8 |
Group vs batch are separate request types and UI targets; “batch” is also a legacy page key ( |
FR18 |
Watcher Phase 1 landed; audio path is offer/queue, not STT. Easy to over-read as “auto-transcribe”. |
FR25 vs |
Contract says |
STORAGE vs code |
SQLite listed in storage contract; unused. Inferred that file-backed JSON is the real persistence FR. |
FR27 |
“Local-first security” is a trust-model FR, not implemented authorization. LAN bind is a supported config that silently drops the trust model. |
C. DP list with code evidence¶
ID |
Design parameter |
Evidence |
|---|---|---|
DP1 |
Streamlit shell: bootstrap, session page key, sidebar, lazy page import |
|
DP2 |
Managed import workflow + adapters + admission |
|
DP3 |
|
|
DP4 |
Import sidecars / managed-transcript gate |
|
DP5 |
File-backed groups |
|
DP6 |
Speaker profile tree + |
|
DP7 |
Speaker ID page + action protocol + optional CCv2 |
|
DP8 |
Corrections revisioned commands |
|
DP9 |
Analysis workflow + thin controller |
|
DP10 |
RunOrchestrator + DAG execution |
|
DP11 |
Module registry + analysis packages |
|
DP12 |
Write-side run persistence |
|
DP13 |
Path roots / env path resolution |
|
DP14 |
Dual config: live dataclass facade + pydantic project config |
|
DP15 |
Module/workflow |
|
DP16 |
STT command generation (never executed) |
|
DP17 |
Directory watcher thread + file |
|
DP18 |
Ollama client |
|
DP19 |
Export ZIP/EPUB |
|
DP20 |
Managed rename journal + |
|
DP21 |
Recordings upload + preprocess/merge |
|
DP22 |
Workspace backup/restore |
|
DP23 |
Schema-epoch gate |
|
DP24 |
|
|
DP25 |
Logging + optional Streamlit perf JSONL + Prometheus textfile |
|
DP26 |
Docker Compose one service |
|
DP27 |
Host scripts (not in-app orchestration) |
|
DP28 |
Interface action strips |
|
DP29 |
Streamlit |
|
DP30 |
Typed-phrase destructive authorization |
|
DP31 |
Analysis GUI worker thread + session snapshots |
|
DP32 |
Env key registry (partial) |
|
D. Current design matrix¶
X = changing that DP could reasonably affect that FR. Only couplings evidenced by imports, shared files, session keys, or write paths.
Abbreviated: rows = FRs, columns = DPs. Full grid is sparse; shown as FR → DP sets. Non-obvious Xs noted.
Core
FR1 → DP2, DP3, DP4, DP13, DP24, DP1 (GUI upload), DP17 (watcher), DP27 (host drop to originals)
FR2 → DP16, DP1, DP13 (path defaults in commands), DP32
FR3 → DP21, DP13, DP1, DP9-adjacent workflows
FR4 → DP7, DP3, DP6, DP1, DP30-protocol (
action_id), DP32 (CCv2 flag)FR5 → DP8, DP3, DP1, DP14 (corrections LLM flags)
FR6 → DP9, DP10, DP11, DP12, DP13, DP14, DP4 (managed gate), DP18, DP31, DP1, DP25
FR7 → FR6 DPs + DP5 +
group_analysis_runnerFR8 → FR6 DPs +
app/workflows/batch.py+batch_ops.pyFR9 → DP1, DP12, DP13, DP29, DP28, DP11 (module presentation)
FR10 → DP19, DP1, DP12, DP13
FR11 → DP5, DP1, DP13
FR12 → DP1, DP3 (read), DP13, DP29
FR13 → DP20, DP3, DP4, DP13, DP24, DP1, DP23-adjacent Diagnostics
FR14 → DP6, DP1, DP13, DP22 (backup includes PII)
FR15 → DP18, DP11, DP14, DP1
FR16 → DP1, DP3, DP4, DP5 (tidy membership), DP20 (
drop_processing_state), DP30
Operational / cross-cutting
FR17 → DP14, DP15, DP13, DP1, DP32
FR18 → DP17, DP2, DP13, DP1
FR19 → DP22, DP13, DP1
FR20 → DP30, DP13, DP1, DP12 (run dirs)
FR21 → DP23, DP13, DP1
FR22 → DP26, DP13, DP32, DP1
FR23 → DP20, DP6, DP1, DP25
FR24 → DP2, DP9, DP10 (no DP1 required)
FR25 → DP3, DP24, DP2, DP5, DP20
FR26 → DP12, DP10, DP9
FR27 → DP26, DP1 (no auth), DP15/DP21 path containment gaps
FR28 → DP14, DP32, DP13
FR29 → DP11, DP10, DP14
FR30 → DP7, DP8, DP1 (reruns)
FR31 → DP25, DP12, DP31 (in-memory job; not durable)
Hidden coupling called out
DP13 PATHS sits under almost every FR that touches disk (shared mutable roots, not shared mutable values after freeze — but env is read at import time via
_bootstrap).DP14
_global_configis process-wide mutable; Settings hydrates it; pipelineget_config()reads it. Changing a Settings field can change FR6/FR15/FR7 without touching those modules.DP20
processing_state.jsonis a second index for rename/audio-link/delete (FR13/FR16) while FR26 forbids treating file presence as run truth.DP1
st.session_stateis the only UI store: navigation, subject/run identity, in-flight analysis, flashes, CCv2 overrides.DP31 daemon thread shares snapshot dicts with the Streamlit script thread; process death drops UX state while leaving
outputs_dirpartial.Duplicate
path_safetyin speaker_profiles, llm_feedback, chart_descriptions — FR14/FR15/FR9 can drift independently.DP15 vs DP6 both called “profiles”; different trees (
config_dir/profilesvsspeaker_profiles_dir). Naming collision is an implicit contract.
E. Coupling and blast-radius findings¶
Matrix thought experiments (missed or under-marked Xs)¶
If DP13 (PATHS) is replaced: FR1–FR24 almost all break. Matrix already marks this systemic.
If DP14 (config facade) is replaced without the pydantic package: Settings (FR17) and pipeline (FR6/FR15) diverge. First-pass matrix understated FR9 (dashboard/overview knobs live in config models).
If DP3 (TranscriptStore) is bypassed: FR4/FR5 silently corrupt library JSON. Write-authority tests exist; UI pages must keep delegating.
If DP12 write order changes: FR9/FR26 consumers that still peek at
manifest.jsonor charts folders will lie. Contract is explicit; some GUI paths may still be file-presence based (on_missing_run_dir=Nonelegacy inrun_scoped_page.py).If DP31 worker is removed: FR6 GUI cancel/skip dies; Python API FR24 is unaffected. Matrix should show DP31 as GUI-only sequential, not engine-wide.
If DP15 ProfileManager path join changes: FR17 plus any JSON file the process can write (security), not just presets. First-pass matrix missed this FR27 blast.
DP classification¶
Class |
DPs |
|---|---|
Independent (mostly one FR) |
DP16 (FR2), DP19 (FR10), DP22 (FR19), DP23 (FR21), DP27 (FR2/FR1 handoff only) |
Sequentially coupled (deliberate pipeline) |
DP2→DP3→DP4; DP9→DP10→DP11→DP12; DP8/DP7→DP3; DP17→DP2 |
Cross-coupled (unrelated FRs meet) |
DP14 (settings vs analysis vs LLM vs group flags); DP20 (rename vs delete vs audio links vs processing_state); DP1 session keys; DP29 caches invalidation |
Systemic |
DP13 PATHS; DP14 live config; DP11 registry (adding a module ID touches GUI, presets, contracts, tests); DP24 locks (all writers); DP26 bind host (all FRs become network-reachable) |
Dangerous “local change” DPs
profile_manager.get_profile_path— looks like a filename helper; writes arbitrary JSON paths.RecordingsService.save_uploaded_file— looks like an upload helper; uses unsanitizedUploadedFile.name(transcript import does sanitize).get_config()/TranscriptXConfig— looks like a getter; process-global behaviour switch.module_registry— looks like a list; drives DAG, defaults, GUI pickers, extras, retired IDs.PAGE_SPECS/ sessionpage— looks like nav chrome; gates which FRs are reachable and which caches hydrate.processing_state.jsonhelpers — looks like bookkeeping; rename/delete/audio association depend on it remaining consistent with the filesystem.
Pressure points (smallest set)¶
God modules:
logger(~191 importers),paths(~111),config(~102),text_utils(~83). High fan-in is expected for paths/logger in a file-backed monolith; the problem is behaviour change, not the import count.Leaky dual config:
core.utils.config(runtime bag) vscore.config(pydantic/registry/persistence). Two sources of truth for “what is settings”.God pages:
speaker_id.py,speakers.py(~2k LOC each) own UI + orchestration. Domain services exist but pages still couple FR4/FR14 to Streamlit.Web import cycles:
navigation↔ transcript page;cache_helpers↔file_service. File separation ≠ architectural separation.Business rules in transport: admission size/path in IO (good); recording upload not using the same admission sanitizer (bad). Analysis launch flags live in session_state (necessary for Streamlit; fragile).
Persistence leaking upward: PATHS imported everywhere instead of handles; group members store project-relative paths resolved against multiple bases (
_project_relative_path).Duplicated rules:
normalize_language_code×4; path_safety ×3; two destructive-auth dataclasses.Vestigial:
data/state/transcriptx.db; STORAGE.md “DB”;keyringunused insrc/;archive/.Not a problem merely because unfashionable: in-process DAG, JSON stores, Streamlit as GUI, no REST API. Those match FR1–FR27.
F. UI/state findings¶
Streamlit has no URL routes. State machine is st.session_state["page"] + subject/run keys. Shared primitives: empty_state.py (five kinds), page_flash, run_scoped_page.py, progress_panel.py.
Workflow |
Goal |
Affordance |
Success |
Failure |
Recovery |
States evidenced |
|---|---|---|---|---|---|---|
Import |
FR1 |
Uploader + folder scan |
|
Per-file |
Retry; folder repair statuses |
Empty submit, mixed success/fail, recording optional — |
Transcribe |
FR2 |
Tool + paths + Copy |
Preset banners |
Preset load/delete errors |
Edit/load another |
No job/loading for STT (correct). Page signifier says Transcribe; behaviour is command gen. Explicit caption that Streamlit never executes. |
Run analysis |
FR6–8 |
Run / Skip / Cancel |
flash success + last-success strip |
flash error |
Re-run; chip returns to panel |
Initial empty (no transcripts/groups), loading (fragment poll 0.5s), cancel, validation errors. Offline/disk: generic exception flash. Double submit: |
Speaker ID |
FR4 |
Name/ignore/clips |
“All speakers identified” + acks |
mapping/schema errors |
Re-import; CCv2 rollback env |
Loading spinners for voice; empty speaker list info. Offline audio: clip failures in bridge. |
Corrections |
FR5 |
Propose/apply; Studio accept/reject |
ack |
stale revision, validation |
Resume session |
Protocol handles stale/duplicate |
Export |
FR10 |
Mode radio + Create |
download button |
size cap |
Narrow selection |
No selection info; >500MB confirm; >2GB hard cap. No partial ZIP resume. |
Run-scoped views |
FR9 |
Sidebar pickers |
render body |
missing subject/run empty_state |
Library/Overview CTAs |
Loading spinner on transcript page. |
Settings/cleanup |
FR17/20 |
Save; typed phrase |
panel success |
lock/corrupt errors |
Retry |
Schema-epoch blocks entire app (FR21) — not a flash, a gate. |
Watcher |
FR18 |
Enable in Settings |
status dict |
|
Disable/retry |
No durable GUI job list comparable to analysis progress. |
Affordance mismatches
Transcribe Audio does not transcribe (intentional, documented, still misleading).
Run Analysis looks like a form submit; actual execution is a daemon thread after rerun. Refreshing the browser mid-run loses the worker handle; artifacts may still be writing.
Library delete confirms; linked recordings and run folders remain (
library_delete.pydocstring). Visible “delete transcript” does not mean “delete analysis”. Easy to read as full purge.Cleanup “authorization” looks like security; it is a typed confirmation phrase.
Ambiguous UI states
Global progress chip vs Run Analysis page can disagree after session loss.
Caches (30–60s TTL on recordings list) can show stale files after upload until TTL/clear.
Group names collide with no uniqueness error.
G. Production failure findings¶
Failure |
User sees |
Leftover state |
Retry safe? |
Idempotent? |
Loss/corruption |
Recovery |
Operator knows |
|---|---|---|---|---|---|---|---|
Disk full mid-import |
Admission/OS error |
Attempt rollback of created archive/json/sidecar ( |
Usually yes |
Duplicate stem → |
Original user file untouched if staging |
Retry |
Logs; incomplete managed set if rollback fails |
Disk full mid-run persist |
Analysis failed flash |
Partial run dir; |
New run ( |
New run id |
Incomplete artifacts |
Re-run |
Logs; Diagnostics does not clearly list “interrupted runs” as first-class |
DB unavailable |
N/A — no app DB |
— |
— |
— |
— |
— |
Orphan |
Ollama timeout/down |
LLM modules failed/skipped; picker errors |
Run continues for other modules ( |
Module re-run |
LLM non-deterministic |
No transcript corruption |
Fix Ollama, re-run |
LLM errors; metrics sink default noop |
HF Hub timeout |
Extra/module blocked |
None beyond failed module |
Retry (one Hub retry in |
Downloads |
— |
Retry / disable downloads |
Logs |
Malformed import JSON |
Admission error, fail-closed |
Staging cleanup policy |
Yes |
Yes |
No library write |
Fix file |
UI error |
Auth expires |
N/A |
— |
— |
— |
— |
— |
— |
Authorization fail |
Typed phrase mismatch |
No delete |
Yes |
N/A |
None |
Re-type |
UI |
Watcher crash / process restart |
Watcher stopped until Settings/app start |
JobStore files; in-flight import rollback or incomplete |
Re-detect |
Job states on disk |
Possible incomplete admit |
Restart app; enable watcher |
|
Analysis thread crash |
flash |
Partial |
New run |
Not same run_id |
Partial artifacts |
Re-run |
Exception in UI; logs. No crash report file tied to run_id unless logging configured to file. |
Two concurrent analyses |
Possible overlapping writes to same slug if two sessions |
Two run dirs if new-run; processing_state races |
Unsafe on same transcript |
No |
processing_state / lock timeouts |
FileLock 15s typical on store |
Logs |
Network gone |
LLM/HF fail; local import/analysis OK |
Same as module fail |
Yes for local |
— |
— |
— |
|
Missing config |
Defaults / coded config errors |
Draft lock timeout |
Retry |
— |
Corrupt |
Restore backup / Settings |
UI + logs |
Unexpected schema epoch |
Full-app gate |
Data untouched unless user picks reset |
N/A |
— |
Reset path is explicit and optional |
Remediation UI |
Gate screen |
Double-click Run |
Second launch blocked by |
One worker |
— |
— |
— |
— |
|
Docker recordings |
Uploads go to |
Files not in user library root |
Yes |
Overwrite same dest name |
Can overwrite prior upload of same name |
Unique names not enforced |
Logs “Saved uploaded file” |
H. Data integrity / security findings (evidenced)¶
Authoritative sources
Data |
Authority |
|---|---|
Canonical transcript |
Files under |
Run execution truth |
|
Artifact inventory |
|
Groups |
|
Speaker profiles |
JSON tree under |
Settings |
|
Processing index |
|
Risks with evidence
Profile name path traversal (open) —
get_profile_pathjoins unsanitizedprofile_name. Same finding as docs/dev/security_review_2026-08-23.md SR-01. Guardrail tests do not include../. Under loopback this is a local footgun (overwrite JSON the process can write). If LAN-bound, it is unauthenticated filesystem write. Do not inflate to unconditional P0 given documented single-user trust; does escalate to P0 on non-loopback bind.Recording upload path traversal (open) —
dest = RECORDINGS_IMPORTS_DIR / uploaded_file.namewith nosanitize_upload_basename. Transcript upload does sanitize. No tests ofsave_uploaded_file. Same SR-02 class. Overwrite/escape depends on client filename (browsers sometimes send paths).No app auth — by design (FR27). Destructive UI is phrase-gated only. Binding
0.0.0.0is the real security boundary (SECURITY.md).Secrets — STT profiles strip token keys;
HF_TOKENintended in hostwhisperx.env. OptionalLLM_BASE_URLcan be any HTTP endpoint (SSRF-to-self / data exfil only if user configures it — local-first).Uniqueness — filesystem paths; group names not unique; speaker display names not globally unique. No DB constraints.
Library delete leaves runs and recordings — intentional; creates orphans (integrity vs storage cost, not silent corruption).
HTML —
unsafe_allow_htmlwidely;empty_stateescapes. XSS matters only if untrusted parties reach the UI.Deserialization — JSON everywhere; voice
np.load(allow_pickle=False)is the careful path. Noshell=Truefound in prior review; not re-audited line-by-line here.Trust boundary — managed import is the only library-valid admission;
TRANSCRIPTX_ALLOW_UNMANAGED_TRANSCRIPTSbypasses the gate (explicit escape hatch).
I. Observability findings¶
Workflow |
Did it run? |
Succeed? |
Where failed? |
Who/what? |
Reproduce? |
Repair? |
|---|---|---|---|---|---|---|
Import |
UI + logs |
Admit outcomes |
Per-file errors; watcher |
Path/stem |
Re-upload |
Rollback/retry |
Analysis |
Progress snapshot in session; |
FR26 if file written |
Module rows in run_results |
run_id in path |
Re-run with same request if config snapshotted in manifest |
Re-run; no surgical module repair GUI |
Analysis crash before persist |
Session flash only |
Blind after refresh |
Logs if file logging on |
Weak run correlation |
Hard |
Manual inspect |
Watcher |
JobStore on disk |
Job state machine |
last_errors (ring buffer) |
Path |
Replay file |
Re-enable |
LLM |
Module fail + ollama errors |
Partial run |
Error strings |
Model name in config |
Re-run |
Change model |
Docker |
Compose health = Streamlit core, not app epoch/import |
Process up ≠ data compatible |
— |
— |
— |
Concrete blind spots (only where they hurt)
Default logger is console;
DEFAULT_LOG_FILEexists but is optional — operators may have no durable log.LLM metrics sink defaults to noop.
No tracing. Fine for single-user; hurts “which module hung”.
Interrupted runs are not a first-class Diagnostics list (FR23 covers rename/speaker integrity, not half-written runs).
Web excluded from
.coveragerc— CI will not tell you GUI observability/regressions.
J. Test gaps¶
Strong: contracts (write authority, run outcomes, speaker voice stages, retired IDs), import workflow, pipeline DAG, corrections concurrency, many analysis modules, ~10 GUI E2E journeys opt-in.
FR |
Test character |
|---|---|
FR1 |
Strong ( |
FR2 |
Thin (few |
FR4/FR5 |
Protocol/unit + some E2E; CCv2 FE has 2 vitest files |
FR6–FR8 |
Strong engine; GUI worker/cancel less so in default pytest |
FR9 |
Presentation tests + E2E charts; run_scoped missing-dir legacy undertested |
FR13 |
Rename tests exist; processing_state dual-index undertested as invariant |
FR17 |
Profile guardrails omit traversal |
FR18 |
Service tests; process-restart incomplete jobs thinner |
FR21 |
Epoch tests likely in core/utils; GUI gate less |
FR25 |
Store atomic + import rollback tests |
FR26 |
Contract tests |
FR27/path |
Speaker path_safety tested; ProfileManager and recordings upload not |
Concurrency/idempotency |
Corrections/speaker ops yes; two Streamlit sessions / two analyses no |
Default |
Excludes smoke, integration, gui_e2e, gui_acceptance — false confidence if you only watch the fast lane |
Tests coupled to internals: apply_legacy_resolver_compat exists to preserve monkeypatched tests — that is a real compatibility DP, not product FR.
K. Prioritised findings¶
P0 — none under the documented loopback/single-user model that are independently “severe production outage”. The product is not a multi-tenant service.
P0-if-LAN-exposed (config is documented): DP15 + DP21 path traversal + unauthenticated destructive UI. Scenario: TRANSCRIPTX_BIND_HOST=0.0.0.0 + crafted profile name or upload filename writes/deletes JSON/audio outside intended dirs.
P1
Recording upload uses unsanitized names while import sanitizes. Scenario: file named
../../something.wavor same-basename overwrite inimports/. Evidence:recordings_service.pyvssanitize_upload_basename.ProfileManager path join. Scenario: user types
../../configas profile name; save/delete hits unexpected JSON. Evidence:get_profile_path.In-flight analysis is session-memory. Scenario: browser refresh or process restart during DAG: user thinks run vanished; disk has partial outputs; no Diagnostics inventory. Evidence: DP31 + persist-at-end write phases.
Two sessions, same transcript can race writers (FileLock timeout / processing_state). Likely local-only but a correctness hole.
P2
Dual config packages + process-global
get_config()(FR6/FR15/FR17 cross-coupled).processing_state.jsonas a parallel index to filesystem +run_results.json.God pages + web import cycles (change cost for FR4/FR9).
Duplicated path_safety / language normalize (drift).
TX_*flags outside env registry (FR4/FR32 surprise).Module registry as systemic hub (unavoidable, but edits need a checklist).
P3
“Transcribe Audio” naming vs command-gen.
STORAGE.md SQLite / unused DB file.
Non-unique group and display names.
Library delete leaving runs (document more loudly in UI).
Web omitted from coverage; E2E not in default CI.
unsafe_allow_htmlvolume (only matters if trust model changes).
P4
Logger docstring vs
core/utils/logger.pypath comment.Fashionable rewrite to React/jobs/SQLite/event bus.
L. Minimum viable architectural changes¶
No rewrite. No job queue framework, no SQLite Theme J, no new plugin bus. Preserve observable behaviour except the path-traversal defects.
L1. Close write-path sanitization (defect, highest leverage)¶
Problem: Two writers do not use the admission basename rules.
FRs: FR3, FR17, FR27.
DPs: DP15, DP21, DP2 (reuse sanitizer).
Boundary: “Any user-supplied filename becomes a single path segment under a known root” — already true for import.
Independent: profile/recording writes vs rest of pipeline.
Remains coupled: PATHS roots (legitimate).
Files:
profile_manager.py,recordings_service.py,import_admission.py(shared helper or wrap), tests.Unchanged: valid single-segment names; import behaviour.
Tests first: traversal
../, separators, overwrite policy for recordings.Migration: none. Rollback: revert the two call sites.
L2. One assert_safe_relpath implementation¶
Problem: three copies of security-relevant path rules.
FRs: FR9/FR14/FR15.
Boundary:
corepath-safety helper; callers keep domainwhat=labels.Do not introduce a “VFS framework”.
Tests: existing speaker_profiles tests become shared.
L3. Config ownership without merging packages¶
Problem: two config systems;
_global_configis a blast radius.FRs: FR17, FR6, FR28.
Boundary:
core.configowns on-disk schema + resolver;get_config()is a documented live snapshot hydrated only at app start and Settings save (alreadyapply_project_config_to_live_facade).Change: contract test that every Settings-persisted key has a pydantic field; stop adding dataclass-only knobs; register
TX_*inenv_key_registry.Deliberately keep: dual packages for now (merge is a rewrite).
Unchanged: precedence FR28.
Rollback: drop the contract test / env aliases.
L4. Stop growing processing_state as truth¶
Problem: FR26 vs rename/delete index.
Boundary: treat processing_state as a derived cache; new features read filesystem +
run_results.json.Small positive: Diagnostics list run dirs missing
run_results.json(interrupted-run repair UX) — uses DP12, no new queue.Keep: existing rename journal until a later incremental migration.
Tests: “listing/status must use run_results when present”.
L5. Break the worst web cycles only when touching those files¶
Problem:
navigation↔ transcript; cache_helpers cycles.Do not extract a frontend framework.
Boundary: navigation must not import page modules (app.py already lazy-reexports
navigate_to_segment). Finish that split; move remaining cycle edges toweb/services.Unchanged: page keys and UX.
L6. Explicitly out of scope (would add concepts FR do not require)¶
Durable job service / Redis / Celery (Theme H).
SQLite analytics (Theme J) — would add a second persistence model beside working JSON.
Replacing Streamlit.
Unifying speaker profiles and module profiles into one “Profile” abstraction (naming collision is annoying; merging trees would couple FR14 to FR17).
M. Revised design matrix¶
After L1–L5, intentional sequential chains remain. Off-diagonals to keep:
Remaining X |
Why keep |
|---|---|
FR6 → DP13 PATHS |
File-backed runs must know |
FR6 → DP14 config |
Analysis behaviour is settings. Direction should stay Settings save → hydrate facade → run, not modules reading |
FR1 → DP13 |
Library lives on disk roots. |
FR4 → DP3 |
Speaker names are transcript mutations; single writer is the point of FR25. |
FR13 → DP20 |
Rename is multi-file; a journalled transaction is proportionate. |
FR6 → DP11 registry |
One catalogue for DAG+GUI is sequential coupling, not an accident. |
FR27 → DP26 bind host |
Network exposure is an ops switch; do not add fake auth to “fix” matrix diagonality. |
FR9 → DP12 |
Views must read run truth. |
Removed/narrowed Xs
FR27 ↛ DP15/DP21 arbitrary filesystem (after sanitization).
FR3 ↛ “any path the browser sends”.
FR17 ↛ undocumented
TX_*(after registry).New features ↛ processing_state (after L4 policy).
Matrix will not be diagonal. A local file workbench should have PATHS and config as wide columns.
N. Change-risk map (use while developing)¶
core/utils/paths.py/ PATHS — Appears to be constants. Breaks every disk FR and Docker mounts. Tests: import/path contract, docker compose bind assert, any test using tmp roots.get_config()/core.configmodels — Appears to be a settings tweak. Can change module defaults, LLM, group enablement, charts, corrections. Tests: config resolver, the affected module’s unit tests, Settings round-trip.module_registry.py/retired_public_ids.py— Appears to add a module. Breaks presets, GUI lists, DAG deps, extras gating, contracts. Tests:tests/contracts/test_retired_*, registry smoke, one pipeline run with the module.TranscriptStore/ managed import / sidecar validation — Appears to be IO. Breaks FR1/FR4/FR5/FR6 gate. Tests:tests/io/, write_authority, managed gate.run_results.jsonwrite phases /run_outcome_truth.py— Appears to be reporting. Breaks Overview/Insights/status. Tests: contracts + pipeline finalize tests.st.session_statekeys (page,subject_*,run_id,analysis_run_in_progress) — Appears local to a page. Breaks nav, global progress, action strips. Tests: web navigation tests, gui_acceptance if behaviour changed.profile_manager.py— Appears to be preset CRUD. Until L1, filesystem escape. Tests: new traversal tests + existing guardrails.Rename transaction +
processing_state— Appears to be rename. Breaks audio links, delete, Diagnostics repair. Tests: rename pipeline tests, library delete, linked_transcripts.PAGE_SPECS/ router prerequisites — Appears to be IA. Can hide pages or crash run-scoped views. Tests: navigation access tests.Speaker mapping services — Appears to be Speaker ID UI. Must keep writing via store. Tests: write_authority, speaker ID e2e/deep.
O. Recommended implementation sequence¶
Stop here until you approve. Then, in order:
L1 tests then sanitization (profile names + recording uploads). Highest defect leverage, tiny surface.
L2 share path-safety helper; point the two other copies at it.
Register
TX_*in env registry (L3 slice); document bind-host blast in Settings if a bind control exists, else leave SECURITY.md as source.L4 policy + Diagnostics “incomplete run dirs” (read-only listing first, no auto-delete).
L3 contract test for settings keys ↔ pydantic (prevents further dual-write).
L5 only as a follow-up when editing navigation/transcript (do not open a cycle-break epic).
Do not start SQLite, in-app STT, or Streamlit replacement as part of this sequence.
Optional later (not required to satisfy current FRs): incremental extraction of remaining logic from speaker_id.py into services already used by CCv2 — only when that page is already being changed.
P. Implementation note (2026-09-02)¶
Sections A–O above are the pre-change reconstruction. L1–L5 from section L were implemented the same day. Observable behaviour is unchanged except the path-traversal defects (reject ../ and separators; recording names take the last path segment, matching import). L6 stayed out of scope.
L1 — Write-path sanitization¶
ProfileManager.get_profile_pathsanitizes bothmodule_nameandprofile_nameviaassert_safe_path_segment, thenassert_path_under_root. Save/load/delete/import/export/rename returnFalse/Noneon unsafe names instead of raising through the public API.RecordingsService.save_uploaded_fileusessanitize_upload_basenameand containment underRECORDINGS_IMPORTS_DIR. Same-basename overwrite in that directory is unchanged.Tests:
tests/core/utils/test_profile_manager_guardrails.py(traversal + separators),tests/web/test_recordings_upload_sanitize.py.
L2 — One path-safety helper¶
Shared implementation:
src/transcriptx/core/utils/path_safety.py(assert_safe_relpath,assert_safe_path_segment,assert_path_under_root,resolve_real,assert_not_symlink).Domain wrappers keep their error types:
core/speaker_profiles/path_safety.py,core/llm_feedback/path_safety.py. Chart descriptions reuseresolve_realonly.Tests:
tests/core/utils/test_path_safety.pyplus existing speaker-profile / LLM-feedback / chart-description path tests.
L3 — Config ownership (no package merge)¶
Registered in
INFRA_ENV_ALLOWLISTand documented in.env.example:TX_SPEAKER_ID_WORKSPACE_COMPONENT,TX_CORRECTIONS_WORKSPACE_COMPONENT,TX_SID_CLIP_POLL.Contract:
tests/contracts/test_settings_config_ownership.py— every Settings registry key is a pydantic field or listed intests/core/config/fixtures/non_pydantic_registry_baseline.json. Dual packages (core.utils.configlive facade vscore.config) remain.
L4 — processing_state as derived index¶
Docstring on
core/utils/processing_state.py: derived cache for rename/audio-link/delete; run truth staysrun_results.json. Existing rename journal kept.Diagnostics lists run dirs missing
run_results.json(read-only, no delete):core/pipeline/incomplete_runs.py+_render_incomplete_runs_sectionon the Diagnostics page.Tests:
tests/pipeline/test_incomplete_runs.py.
Verification¶
Targeted pytest: 103 passed (path safety, profile guardrails, recordings upload, incomplete runs, settings ownership, app imports, transcript navigation contracts, speaker-profile / chart-description / LLM-feedback path tests, env-key registry).
Matrix items now true in code¶
FR27 ↛ DP15/DP21 arbitrary filesystem.
FR3 ↛ any path the browser sends.
FR17 ↛ undocumented
TX_*.New features ↛
processing_stateas run truth.
SQLite Theme J, in-app STT, job queues, and Streamlit replacement were not started.