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_n labels 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 transcribe_audio.py; still an affordance mismatch.

FR7 vs FR8

Group vs batch are separate request types and UI targets; “batch” is also a legacy page key (Batch Ops → Run Analysis).

FR18

Watcher Phase 1 landed; audio path is offer/queue, not STT. Easy to over-read as “auto-transcribe”.

FR25 vs processing_state.json

Contract says run_results.json is run truth; rename/audio-link code still treats processing state as an index. Dual index, not dual product FR.

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

web/app.py main path; router.py; navigation.py PAGE_SPECS; state.py

DP2

Managed import workflow + adapters + admission

io/managed_import_workflow.py run_managed_import_workflow; admit_and_register.py; io/import_admission.py sanitize_upload_basename

DP3

TranscriptStore sole transcript JSON writer

core/store/transcript_store.py; contract test tests/contracts/test_write_authority.py

DP4

Import sidecars / managed-transcript gate

sidecar_path_for_transcript, validate_managed_transcript; pipeline_legacy_compat.enforce_managed_transcript_gate

DP5

File-backed groups

core/store/group_manifest_store.py uuid4 ids, names not unique

DP6

Speaker profile tree + assert_safe_relpath

core/speaker_profiles/; path_safety.py

DP7

Speaker ID page + action protocol + optional CCv2

web/page_modules/speaker_id.py (~2k LOC); web/workspaces/speaker_id_bridge.py; packages/transcriptx_workspaces

DP8

Corrections revisioned commands

app/corrections/protocol.py CorrectionsActionService; core/store/corrections_session_store.py

DP9

Analysis workflow + thin controller

app/workflows/analysis.py run_analysis; app/controllers/analysis_controller.py

DP10

RunOrchestrator + DAG execution

run_orchestrator.py; pipeline.py run_analysis_pipeline; dag_pipeline_*.py

DP11

Module registry + analysis packages

module_registry.py; core/analysis/

DP12

Write-side run persistence

pipeline_write_phases.py order: run_results.json then manifest.json; run_outcome_truth.py

DP13

Path roots / env path resolution

core/utils/paths.py PathSettings, PATHS; import fan-in ~111

DP14

Dual config: live dataclass facade + pydantic project config

core/utils/config/__init__.py _global_config; core/config/persistence.py; apply_project_config_to_live_facade() in web/app.py

DP15

Module/workflow ProfileManager JSON files

core/utils/profile_manager.py get_profile_path concatenates profile_name with no .. rejection

DP16

STT command generation (never executed)

services/transcription/command_gen.py; web/page_modules/transcribe_audio.py

DP17

Directory watcher thread + file JobStore

services/watcher/service.py process singleton

DP18

Ollama client

core/llm/ollama_client.py tenacity retries, Docker host remap

DP19

Export ZIP/EPUB

web/components/export_panel.py; HARD_CAP_BYTES in export/types.py

DP20

Managed rename journal + processing_state.json

rename_transaction.py; processing_state.py; Diagnostics repair

DP21

Recordings upload + preprocess/merge

web/services/recordings_service.py save_uploaded_file uses raw uploaded_file.name; app/workflows/preprocess.py, merge.py

DP22

Workspace backup/restore

services/workspace_backup.py

DP23

Schema-epoch gate

web/schema_epoch_gate.py; core/utils/schema_epoch.py

DP24

FileLock + atomic .tmp/os.replace

file_lock.py; store writers

DP25

Logging + optional Streamlit perf JSONL + Prometheus textfile

core/utils/logger.py; core/observability/perf.py; run_performance/exporter.py

DP26

Docker Compose one service

docker-compose.yml publish 127.0.0.1:8501, health /_stcore/health

DP27

Host scripts (not in-app orchestration)

scripts/inbox-watch.py, scripts/whispermlx-missing.py write originals/ only

DP28

Interface action strips

docs/contracts/interface-menus.md; web/action_menus/

DP29

Streamlit @st.cache_data corpus/module caches

web/cache_helpers.py

DP30

Typed-phrase destructive authorization

CleanupAuthorization, DuplicateAuthorizationnot identity auth

DP31

Analysis GUI worker thread + session snapshots

run_analysis.py daemon tx-run-analysis, analysis_run_in_progress

DP32

Env key registry (partial)

env_key_registry.py; unregistered TX_SPEAKER_ID_WORKSPACE_COMPONENT in web/workspaces/flags.py


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_runner

  • FR8 → FR6 DPs + app/workflows/batch.py + batch_ops.py

  • FR9 → 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_config is process-wide mutable; Settings hydrates it; pipeline get_config() reads it. Changing a Settings field can change FR6/FR15/FR7 without touching those modules.

  • DP20 processing_state.json is a second index for rename/audio-link/delete (FR13/FR16) while FR26 forbids treating file presence as run truth.

  • DP1 st.session_state is 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_dir partial.

  • Duplicate path_safety in speaker_profiles, llm_feedback, chart_descriptions — FR14/FR15/FR9 can drift independently.

  • DP15 vs DP6 both called “profiles”; different trees (config_dir/profiles vs speaker_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.json or charts folders will lie. Contract is explicit; some GUI paths may still be file-presence based (on_missing_run_dir=None legacy in run_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

  1. profile_manager.get_profile_path — looks like a filename helper; writes arbitrary JSON paths.

  2. RecordingsService.save_uploaded_file — looks like an upload helper; uses unsanitized UploadedFile.name (transcript import does sanitize).

  3. get_config() / TranscriptXConfig — looks like a getter; process-global behaviour switch.

  4. module_registry — looks like a list; drives DAG, defaults, GUI pickers, extras, retired IDs.

  5. PAGE_SPECS / session page — looks like nav chrome; gates which FRs are reachable and which caches hydrate.

  6. processing_state.json helpers — 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) vs core.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_helpersfile_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”; keyring unused in src/; 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

st.success + action strip

Per-file st.error

Retry; folder repair statuses

Empty submit, mixed success/fail, recording optional — upload_transcript.py

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: analysis_run_in_progress + pending.started. Two browser tabs: two sessions → two runs (unhandled).

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 ok; apply_export committed flag

stale revision, validation

Resume session

Protocol handles stale/duplicate action_id. Viewer vs Studio concurrency: tests exist for loser-raises.

Export

FR10

Mode radio + Create

download button

size cap st.error

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. on_missing_run_dir=None still allows some pages to proceed without a folder.

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

last_errors on service

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.py docstring). 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 (_AttemptCreated)

Usually yes

Duplicate stem → FileExistsError

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; run_results.json may be missing (FR26 consumers must not infer from files)

New run (rerun_mode)

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 transcriptx.db unused

Ollama timeout/down

LLM modules failed/skipped; picker errors

Run continues for other modules (CONTINUE_ON_ERROR exists on contracts)

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 hf_hub_load)

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

last_errors; job files under data/watcher/jobs/

Analysis thread crash

flash Analysis failed:

Partial outputs_dir/<slug>/<run>

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 LockAcquisitionError

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 config.jsonConfigCorruptError

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 analysis_run_in_progress in that session

One worker

Docker recordings :ro

Uploads go to RECORDINGS_IMPORTS_DIR fallback

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 transcripts_dir only if managed sidecar validates; writer = TranscriptStore

Run execution truth

outputs/.../run_results.json via DP12

Artifact inventory

manifest.json (not status)

Groups

{group_id}.group.json

Speaker profiles

JSON tree under speaker_profiles_dir

Settings

{config_dir}/config.json + env + draft/run override

Processing index

processing_state.jsonnot run truth; still used for rename/audio

Risks with evidence

  1. Profile name path traversal (open)get_profile_path joins unsanitized profile_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.

  2. Recording upload path traversal (open)dest = RECORDINGS_IMPORTS_DIR / uploaded_file.name with no sanitize_upload_basename. Transcript upload does sanitize. No tests of save_uploaded_file. Same SR-02 class. Overwrite/escape depends on client filename (browsers sometimes send paths).

  3. No app auth — by design (FR27). Destructive UI is phrase-gated only. Binding 0.0.0.0 is the real security boundary (SECURITY.md).

  4. Secrets — STT profiles strip token keys; HF_TOKEN intended in host whisperx.env. Optional LLM_BASE_URL can be any HTTP endpoint (SSRF-to-self / data exfil only if user configures it — local-first).

  5. Uniqueness — filesystem paths; group names not unique; speaker display names not globally unique. No DB constraints.

  6. Library delete leaves runs and recordings — intentional; creates orphans (integrity vs storage cost, not silent corruption).

  7. HTMLunsafe_allow_html widely; empty_state escapes. XSS matters only if untrusted parties reach the UI.

  8. Deserialization — JSON everywhere; voice np.load(allow_pickle=False) is the careful path. No shell=True found in prior review; not re-audited line-by-line here.

  9. Trust boundary — managed import is the only library-valid admission; TRANSCRIPTX_ALLOW_UNMANAGED_TRANSCRIPTS bypasses 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 last_errors

Path/stem

Re-upload

Rollback/retry

Analysis

Progress snapshot in session; run_results.json if persist finished

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 outputs/

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_FILE exists 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 (tests/io/, gui_acceptance import)

FR2

Thin (few transcribe_audio tests)

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 pytest

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.wav or same-basename overwrite in imports/. Evidence: recordings_service.py vs sanitize_upload_basename.

  • ProfileManager path join. Scenario: user types ../../config as 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.json as 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_html volume (only matters if trust model changes).

P4

  • Logger docstring vs core/utils/logger.py path 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: core path-safety helper; callers keep domain what= 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_config is a blast radius.

  • FRs: FR17, FR6, FR28.

  • Boundary: core.config owns on-disk schema + resolver; get_config() is a documented live snapshot hydrated only at app start and Settings save (already apply_project_config_to_live_facade).

  • Change: contract test that every Settings-persisted key has a pydantic field; stop adding dataclass-only knobs; register TX_* in env_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 to web/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 outputs_dir. Injecting handles everywhere is more abstraction than this app needs.

FR6 → DP14 config

Analysis behaviour is settings. Direction should stay Settings save → hydrate facade → run, not modules reading os.environ ad hoc.

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)

  1. 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.

  2. get_config() / core.config models — 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.

  3. 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.

  4. TranscriptStore / managed import / sidecar validation — Appears to be IO. Breaks FR1/FR4/FR5/FR6 gate. Tests: tests/io/, write_authority, managed gate.

  5. run_results.json write phases / run_outcome_truth.py — Appears to be reporting. Breaks Overview/Insights/status. Tests: contracts + pipeline finalize tests.

  6. st.session_state keys (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.

  7. profile_manager.py — Appears to be preset CRUD. Until L1, filesystem escape. Tests: new traversal tests + existing guardrails.

  8. Rename transaction + processing_state — Appears to be rename. Breaks audio links, delete, Diagnostics repair. Tests: rename pipeline tests, library delete, linked_transcripts.

  9. PAGE_SPECS / router prerequisites — Appears to be IA. Can hide pages or crash run-scoped views. Tests: navigation access tests.

  10. Speaker mapping services — Appears to be Speaker ID UI. Must keep writing via store. Tests: write_authority, speaker ID e2e/deep.



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_path sanitizes both module_name and profile_name via assert_safe_path_segment, then assert_path_under_root. Save/load/delete/import/export/rename return False/None on unsafe names instead of raising through the public API.

  • RecordingsService.save_uploaded_file uses sanitize_upload_basename and containment under RECORDINGS_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 reuse resolve_real only.

  • Tests: tests/core/utils/test_path_safety.py plus existing speaker-profile / LLM-feedback / chart-description path tests.

L3 — Config ownership (no package merge)

  • Registered in INFRA_ENV_ALLOWLIST and 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 in tests/core/config/fixtures/non_pydantic_registry_baseline.json. Dual packages (core.utils.config live facade vs core.config) remain.

L4 — processing_state as derived index

  • Docstring on core/utils/processing_state.py: derived cache for rename/audio-link/delete; run truth stays run_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_section on the Diagnostics page.

  • Tests: tests/pipeline/test_incomplete_runs.py.

L5 — Navigation cycle only

  • navigate_to_segment moved to web/transcript_navigation.py. navigation.py imports that module, not the Transcript page. web/app.py and page_modules/transcript.py re-export it. Page keys and UX unchanged.

  • cache_helpersfile_service / sidebar_options cycles were not opened (no epic rewrite).

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_state as run truth.

SQLite Theme J, in-app STT, job queues, and Streamlit replacement were not started.