19 KiB
D&D Session Orchestrator Architecture
Purpose
This repository implements a Go-based orchestration application called narratio for processing recorded Dungeons & Dragons session audio into durable transcripts and downstream analysis artifacts.
The orchestrator coordinates an existing pipeline consisting of:
- Per-speaker FLAC audio recordings from Mumble.
- A self-hosted WhisperX HTTP transcription service.
seriatim, a deterministic transcript merger.audita, an LLM-backed transcript polisher.- A future (not yet implemented, name subject to change)
dnd-session-analyzer, which generates final D&D session artifacts such as session logs, event logs, meta-analysis, and table-read reports. - Long-term storage (which may be local filesystem, S3-compatible object storage, SFTP endpoint, or similar) for all inputs, intermediate outputs, logs, generated configs, and final artifacts.
The orchestrator is not responsible for implementing transcription, transcript merging, transcript polishing, or artifact generation. Its job is to coordinate those components reliably, maintain durable state, validate stage boundaries, manage local and remote artifacts, and make the pipeline resumable.
Design Goals
The application should be:
- Modular: Each stage and external component should be isolated behind a narrow interface.
- Composable: Stages should consume and produce declared artifacts.
- Resumable: Long-running workflows should be restartable without repeating completed work.
- Idempotent: Re-running the same pipeline should not corrupt or duplicate outputs.
- Observable: The orchestrator should produce structured logs, captured subprocess logs, and a durable run manifest.
- Config-driven: Most user-adjustable behavior should live in configuration files, not hardcoded logic.
- Strict at boundaries: Inputs and outputs should be validated at every major stage boundary.
- Boring and reliable: Prefer explicit, maintainable code over clever workflow abstractions.
The orchestrator should be written in Go.
Non-Goals
The orchestrator should not:
- Reimplement WhisperX.
- Reimplement
seriatim. - Reimplement
audita. - Contain D&D-specific prompt logic directly in the core orchestration layer.
- Parse or modify LLM outputs except for high-level validation of expected files or schemas.
- Become a general-purpose distributed workflow engine.
- Require a database for v1.
- Hide important run state only in logs.
High-Level Pipeline
The intended pipeline is:
prepare
↓
transcribe speaker tracks in parallel
↓
normalize speaker transcripts
↓
merge transcripts with seriatim
↓
polish transcript with audita
↓
generate D&D artifacts with dnd-session-analyzer
↓
archive outputs
↓
notify user
Some stages may be implemented in the initial scaffold only as interfaces or placeholders.
The initial implementation should create the application framework and contracts without implementing the full real behavior of all stages.
Primary Concepts
Configuration
Configuration is user-authored and describes how the pipeline should run.
There should be two primary configuration files:
pipeline.yml session.yml
pipeline.yml contains durable/default pipeline configuration:
workspace root
S3 bucket/prefix
WhisperX service settings
seriatim binary path and options
audita binary path and options
analyzer binary path and artifact settings
notification settings
concurrency and timeout defaults
session.yml contains per-session inputs and metadata:
session ID
campaign ID
session date/title
audio input directory or explicit audio files
speakers.yml
autocorrect.yml
glossary.yml
references to previous-session context
The application should strictly decode config files and reject unknown fields. Config validation should happen before any stage is run.
Environment variables may be used for secrets and deployment-specific credentials, but ordinary pipeline behavior should live in config files.
Manifest
The manifest is machine-authored durable run state.
Each session run should have a manifest.json in the local work directory and eventually mirrored to S3.
The manifest should track:
session ID
pipeline version
created/updated timestamps
resolved input files
checksums for important inputs/configs
stage statuses
stage timestamps
stage outputs
captured logs
generated config files
component versions where available
errors
artifact locations
S3 object keys
Stage statuses should be explicit:
pending
running
succeeded
failed
skipped
stale
interrupted
The manifest is the source of truth for resume/skip decisions.
Stages
A stage is a pipeline unit that consumes declared inputs and produces declared outputs.
Examples:
prepare
transcribe
normalize
merge
polish
analyze
archive
notify
Stages should be organized around the lifecycle of a session, not around executable names.
For example, the merge stage may call seriatim, but the stage should be named for the pipeline operation, not the tool.
Every stage should:
- Determine required inputs.
- Check whether expected outputs already exist.
- Decide whether to run, skip, or fail.
- Execute through an adapter or local logic.
- Validate expected outputs.
- Write outputs to the local work directory.
- Record stage status and metadata in the manifest.
- Allow safe rerun with
--force.
Adapters
Adapters isolate interactions with external components.
The orchestrator should have thin adapters for:
- WhisperX HTTP API
- seriatim
- audita
- future dnd-session-analyzer
- storage layer (local filesystem, S3-compatible, SFTP, etc.)
- optional email/notification backend
Adapters should hide details such as:
- CLI argument construction
- generated config file format
- HTTP request/response details
- stdout/stderr capture
- process exit handling
- timeout and cancellation behavior
The stage layer should call adapter methods and should not know implementation details of the underlying external tool.
Artifact Store
The orchestrator should use a local work directory as the primary working area.
Each stage writes outputs locally first. The orchestrator then validates and archives those outputs to S3.
The storage layer should expose an abstraction for artifact references and common operations:
write local artifact read local artifact calculate checksum upload to S3 record local path and S3 key validate existence
For v1, local filesystem + S3 is enough. A database is not required.
Expected Local Work Directory Layout
Each session should have an isolated local work directory.
Example:
work/{session_id}/
inputs/
speakers.yml
autocorrect.yml
glossary.yml
session.yml
pipeline.resolved.yml
audio/
adam.flac
eric.flac
other-speaker.flac
transcripts/
raw/
adam.json
eric.json
other-speaker.json
normalized/
adam.json
eric.json
other-speaker.json
merged.json
processed.json
artifacts/
event-log.json
session-log.md
meta-analysis.md
table-read.md
config/
seriatim.generated.yml
audita.generated.yml
analyzer.event-log.generated.yml
analyzer.session-log.generated.yml
logs/
whisperx.adam.log
whisperx.eric.log
seriatim.stdout.log
seriatim.stderr.log
audita.stdout.log
audita.stderr.log
analyzer.session-log.stdout.log
analyzer.session-log.stderr.log
manifest.json
.lock
The exact layout may evolve, but the distinction between inputs, audio, transcripts, artifacts, generated config, logs, and manifest should remain.
Expected S3 Layout
S3 should mirror the conceptual local layout.
Example:
sessions/{session_id}/
inputs/
audio/
transcripts/
raw/
normalized/
merged.json
processed.json
artifacts/
config/
logs/
manifest.json
The orchestrator should be able to upload outputs after each successful stage, not only at the end of the full workflow.
Suggested Go Package Layout
Initial scaffold should use a package layout similar to:
cmd/narratio/
main.go
internal/app/
app.go
plan.go
run.go
commands.go
internal/config/
config.go
load.go
validate.go
internal/manifest/
manifest.go
store.go
status.go
internal/stage/
stage.go
prepare.go
transcribe.go
normalize.go
merge.go
polish.go
analyze.go
archive.go
notify.go
internal/adapters/
whisperx/
client.go
seriatim/
runner.go
audita/
runner.go
analyzer/
runner.go
notify/
sender.go
internal/artifacts/
store.go
local.go
s3.go
paths.go
checksum.go
internal/contracts/
transcript.go
session.go
artifact.go
internal/logging/
logging.go
The initial build step should create these packages and core interfaces, but should not fully implement all real external behavior.
CLI Shape
The CLI should eventually support commands like:
dnd-orchestrator run --config pipeline.yml --session session.yml
dnd-orchestrator plan --config pipeline.yml --session session.yml
dnd-orchestrator status --session-id 2026-05-03
dnd-orchestrator resume --session-id 2026-05-03
dnd-orchestrator run-stage polish --session-id 2026-05-03 --force
For the initial scaffold, it is acceptable to implement only partial command behavior, but the command structure should anticipate these operations.
run
Loads config, validates inputs, creates or loads a manifest, builds a plan, and executes the pipeline.
plan
Prints the stages that would run and which stages would be skipped.
status
Reads a manifest and prints stage status.
resume
Continues a previous run from the latest valid manifest state.
run-stage
Runs one stage explicitly, with optional --force.
Core Stage Interface
The application should define a narrow stage interface.
Example shape:
type Stage interface {
Name() string
Run(ctx context.Context, env *Env, manifest *manifest.Manifest) (*StageResult, error)
}
The exact types may vary, but the idea should remain:
- a stage has a stable name
- a stage runs with context
- a stage receives shared dependencies through an environment object
- a stage may inspect/update manifest state through controlled methods
- a stage returns declared outputs and metadata
Suggested supporting type:
type StageResult struct {
Outputs []artifacts.Ref
Metadata map[string]any
}
Application Environment
The application should pass shared dependencies through an explicit environment object rather than globals.
Example shape:
type Env struct {
Config *config.Config
ArtifactStore artifacts.Store
Logger *slog.Logger
WhisperX whisperx.Client
Seriatim seriatim.Runner
Audita audita.Runner
Analyzer analyzer.Runner
Notifier notify.Sender
}
Avoid package-level mutable state.
Main Loop
The main application loop should be a simple state machine.
Conceptual behavior:
load configuration
validate configuration
create/load manifest
acquire session lock
resolve input files
build execution plan
for each stage:
decide whether stage should run
mark stage running
run stage
validate outputs
archive outputs as appropriate
mark stage succeeded or failed
release lock
The implementation should prefer clear control flow over a generic workflow engine.
Long-Running Stage Handling
audita can run for more than an hour. seriatim is very fast (typically less than a second). Each whisperx file transcription job can take anywhere from 5 to 30 minutes.
The orchestrator should therefore treat long-running stages as normal and expected.
Long-running subprocess stages should:
- receive a generous configurable timeout
- use context.Context for cancellation
- capture stdout and stderr continuously
- write logs to the work directory
- update manifest status before and after execution
- optionally update heartbeat timestamps while running
- preserve temporary work directories on failure
- avoid treating partial output as success
A stage should write to a temporary output path first, then promote to the final output path only after validation.
Example:
processed.json.tmp
processed.json
Idempotency and Resume Behavior
Every stage should be designed around the question:
If this stage runs twice, what happens?
Preferred behavior:
- If expected output exists and validates, skip the stage.
- If expected output exists but input/config checksum changed, mark stale.
- If
--forceis supplied, rerun and replace output atomically. - If a previous run failed, preserve logs and temporary files for inspection.
- If rerunning, do not delete unrelated successful outputs.
The manifest should allow the application to resume after interruption without rerunning completed expensive stages.
Locking
The orchestrator should prevent two processes from operating on the same session work directory at the same time.
For v1, a lock file in the work directory is acceptable:
work/{session_id}/.lock
The lock should be acquired before mutating manifest or stage outputs.
Logging
The orchestrator should use structured logs, preferably Go's log/slog.
Subprocess logs should be captured separately:
orchestrator structured log
stdout log per external tool invocation
stderr log per external tool invocation
Do not combine all component logs into one global text file.
External Component Boundaries
WhisperX
The whisperx adapter should handle:
- HTTP submission of audio files
- polling or waiting for completion, depending on API shape
- timeout and retry policy
- returning raw transcript JSON
- per-speaker parallelism with bounded concurrency
The orchestrator should not assume WhisperX internals beyond the adapter contract.
Seriatim
seriatim is a deterministic transcript merger.
The seriatim adapter should handle:
- generating Seriatim config
- invoking the binary
- passing input transcript paths
- passing output path
- capturing logs
- returning merged transcript path
The orchestrator should not implement seriatim merge logic.
Audita
audita is an LLM-backed transcript polisher.
The audita adapter should handle:
- generating
auditaconfig - invoking the binary or service
- passing merged transcript path
- passing glossary/autocorrect paths
- capturing logs
- preserving Audita work/checkpoint files
- returning processed transcript path
The orchestrator should not implement LLM correction logic.
D&D Session Analyzer
The analyzer is a future component.
The initial orchestrator should include interfaces/placeholders for it, but should not need a complete implementation.
The analyzer adapter should eventually handle:
- artifact type selection
- generated analyzer config
- prompt template references
- processed transcript path
- prior session context references
- output artifact paths
- structured validation where appropriate
The orchestrator should not contain prompt-specific D&D logic. The analyzer should own artifact-specific prompt behavior.
Contracts and Schema Versioning
Core data artifacts should include schema versions where practical.
Important contracts include:
- SpeakerTranscript (
whisperxoutput) - CanonicalTranscript (
seriatimoutput) - ProcessedTranscript (
autidaoutput) - ArtifactResult
- SessionManifest
The orchestrator may not need to deeply understand every field in every artifact, but it should validate enough to know that expected outputs were produced and are parseable.
Schema versions should appear in JSON artifacts where possible:
{
"schema": "processed_transcript.v1",
"session_id": "2026-05-03",
"segments": []
}
Validation Boundaries
Validate at each boundary:
input config is valid
audio files exist
speakers.yml maps expected filenames
WhisperX output exists and is parseable
normalized speaker transcripts exist
Seriatim merged transcript exists and is parseable
Audita processed transcript exists and is parseable
analyzer artifacts exist in expected formats
For the initial scaffold, validation methods may be stubs or simple parse/existence checks, but the architecture should make validation a first-class concern.
Testing Strategy
The orchestrator should be testable without running whisperx, seriatim, audita, or LLMs.
Use fake adapters for tests:
- fake
whisperxreturns canned transcript JSON - fake
seriatimwrites a known merged transcript - fake
auditawrites a known processed transcript - fake
analyzerwrites fixed artifacts - fake artifact store writes to a temp directory
Tests should cover:
- config loading and validation
- plan generation
- manifest creation/update
- stage skip behavior
- force rerun behavior
- failure handling
- resume behavior
- subprocess adapter construction where practical
Testing Non-Goals
Tests must not do any of the following:
- include hard-coded assertions of specific default configuration values (although a test may validate that a default value is defined and has the correct type, it may not require it to equal any particualar value)
Initial Implementation Scope
The first implementation pass should create the application framework and interfaces, not the full working pipeline.
Implement:
Go module setup
CLI skeleton
config structs and loader
manifest structs and local manifest store
artifact reference/store interfaces
stage interface
app environment object
plan/main-loop skeleton
placeholder stages
adapter interfaces
fake/no-op adapter implementations where helpful
basic structured logging
basic tests for config/manifest/stage planning
Do not yet implement:
real WhisperX HTTP API behavior
real Seriatim subprocess execution
real Audita subprocess execution
real S3 upload/download
real email notification
full transcript schema validation
full analyzer behavior
D&D prompt logic
The initial code should compile, include clear TODOs, and establish durable package boundaries.
Engineering Preferences
Use idiomatic Go.
Prefer:
context.Context
log/slog
explicit interfaces
small packages
table-driven tests
strict config decoding
clear errors with context
local filesystem abstractions where useful
standard library where sufficient
Avoid:
global mutable state
reflection-heavy frameworks unless justified
premature generic DAG engines
hidden side effects in constructors
mixing orchestration logic with adapter-specific implementation details
writing business logic directly in main.go
Architectural Invariant
The most important invariant of the application is:
Every stage consumes declared artifacts, produces declared artifacts, validates them, records provenance, and can be safely skipped or rerun.
All design and implementation choices should support that invariant.