17 KiB
Architecture
This document defines the development principles for Notarius. It is inward-facing: developers and LLM coding agents should use it to preserve the project's shape, boundaries, and invariants as the code evolves.
Project Shape
Notarius is a small, explicit, dependency-light Go application for extracting structured artifacts from source material using modular extractors.
The application should be contract-first but not abstraction-heavy. Add interfaces and extension points when they protect a real boundary:
- external source formats;
- extractor modules;
- validators;
- LLM providers and runtime plumbing;
- output schemas and embedded assets.
Avoid abstractions that only anticipate hypothetical complexity. Prefer narrow contracts that can be exercised by tests and real modules.
Core Invariants
The core framework must remain source-agnostic and domain-agnostic.
Source-format details belong in input modules. Transcript-specific concepts such as segments, speakers, timestamps, and transcript schemas must not spread into runner, extractor, or validator framework code.
Extraction-domain details belong in extract modules. D&D-specific concepts such as spells, NPCs, items, combat turns, and encounters must not spread into core source, runner, or LLM framework packages.
Extracted facts should be grounded with source references. Source references should point to generic source units, not to transcript-only structures. Artifact records should require source references by default unless their schema explicitly opts into ungrounded fields. Generic pipeline code should preserve source-reference ranges exactly and should not merge or rewrite overlapping ranges.
The application workflow is:
input -> chunk -> extract -> merge -> normalize -> output
These stages should remain explicit in the architecture. Chunking, merging, and normalization must not be hidden inside domain extract modules when they represent general pipeline behavior.
Dependency Policy
Prefer the Go standard library where practical.
Use external dependencies only when justified by correctness, security, interoperability, or substantial complexity reduction. Good reasons include widely used file formats, complex validation behavior, or secure transport handling.
Avoid dependencies for small conveniences. Do not let external dependency types leak across internal package boundaries unless the dependency is itself the explicit public contract of that package.
Package Layout
Use this layout unless a change documents a better project-specific reason.
CLI and executable entrypoint:
cmd/notarius: executable entrypoint.internal/cli: CLI command definitions, flags, argument parsing, and command wiring.
Core deterministic model and policy:
internal/core/source: source document, source unit, and source reference types.internal/core/artifacts: artifact envelope, artifact candidates, rejected artifacts, and manifests.internal/core/config: configuration structs, defaults, loading, precedence, and validation, once config exists.
Reusable framework plumbing:
internal/framework/contracts: core interfaces and transport-neutral request/response contracts.internal/framework/pipeline: runner, pipeline-stage orchestration, registries, and small shared stage helpers.internal/framework/validate: shared validator runtime behavior and decision checks.internal/framework/llm: LLM clients, scheduling, structured-output parsing, and response-schema registry, once LLM runtime exists.internal/framework/prompt: embedded prompt assets, prompt registry, and prompt rendering helpers, once prompt assets exist.
Domain implementations:
internal/modules/input/<name>: input-stage modules that parse external input into core source documents.internal/modules/chunk/<name>: chunk-stage modules.internal/modules/extract/<domain>/<name>: extract-stage extractor modules.internal/modules/merge/<name>orinternal/modules/merge/<domain>/<name>: merge-stage modules.internal/modules/normalize/<name>orinternal/modules/normalize/<domain>/<name>: normalize-stage modules.internal/modules/output/<name>: output-stage modules.internal/validators/<validator>: built-in validator implementations.internal/transport/http: shared HTTP client code, if needed by provider integrations.
Package-private implementation constants may live near the package that owns
them, preferably in constants.go when useful.
Start with fewer, larger framework packages. Split a package only when a real boundary proves itself through import direction, ownership, test seams, or substantial file size. Do not create catalog, diagnostics, reporting, structured-output, response-schema, output, merge, normalize, extraction, or warnings packages merely because the concepts exist in the architecture.
Stage Modules
Concrete business logic should live under internal/modules/<stage>/....
Stage-oriented module layout is preferred because it makes the application workflow visible in the filesystem:
internal/modules/input/...
internal/modules/chunk/...
internal/modules/extract/...
internal/modules/merge/...
internal/modules/normalize/...
internal/modules/output/...
Use short, lowercase, idiomatic Go package names. Prefer names such as
dndtranscript, appendorder, and spells over names like dnd_transcript,
serial_merge, or spell_extractor that repeat parent-stage context.
Input Modules
Use a hexagonal architecture style for source input.
Input modules translate external source formats into the core source model. They may know about external schema details, source-specific metadata, and format-specific validation rules. They should not own extraction-domain decisions.
Other packages should interact with source input through adapter contracts and core source types. Input module implementation details and external dependency types must not leak into framework or extract module packages.
Input module metadata may preserve source-specific facts such as transcript speaker, timestamps, Markdown heading path, page number, or block ID. Framework code may carry metadata through, but should not require a specific adapter's metadata shape.
Core source metadata should remain map[string]any. Document well-known keys
as conventions, and let input modules expose typed accessor helpers for their
own metadata when useful.
Extractors
Extractors are independent modules that process source chunks or whole source documents and produce one kind of structured artifact candidate. Each extract module owns:
- its artifact semantics;
- its prompt usage;
- its structured response schema selection;
- its validator chain;
- any domain-specific mapping or interpretation.
Extract modules should depend on framework contracts and core source/artifact types. They should not depend on concrete input module packages.
Extraction requests should carry the active source chunk plus optional ambient context, such as a document synopsis, prior-chunk summaries, known entities, or other module-provided state. The context may be empty for simple modules, but the contract should not assume extraction is always chunk-local.
Extractors should not be the only place where chunking, merging, or normalization happens. They may choose processing mode or provide domain-specific merge/normalization behavior when generic behavior is insufficient, but the pipeline stages themselves are framework concepts.
The runner should be able to compose, skip, resume, or run individual extractors when their prerequisites are satisfied. Ordering should be explicit through configuration, a default sequence, or documented orchestration rules.
Extractor selection must go through a registry or equivalent mechanism rather than scattered conditionals.
Pipeline Stages
The pipeline has six conceptual stages:
- input: external source material becomes a
SourceDocument; - chunk: a
SourceDocumentbecomes ordered source chunks; - extract: extractors produce artifact candidates from chunks or whole documents;
- merge: per-chunk candidates become a merged candidate collection;
- normalize: merged candidates are reconciled for duplicates, aliases, consistency, or cross-chunk issues;
- output: final artifacts are serialized.
Chunking is first-class because source documents may be too large for a single LLM pass. Chunkers should preserve source-unit order and produce stable chunk metadata.
Merge and normalize are separate concerns. Merge combines per-chunk results into a deterministic collection. Normalize performs semantic reconciliation after merge. Generic append-in-chunk-order merge and no-op normalization should be available for simple artifact types, while domain-specific behavior can be provided where needed.
The framework should allow serial and parallel chunk processing. The first implementation may execute chunks serially for determinism, but contracts should not prevent later parallel execution.
Final durable output should use one artifact file per artifact type plus a
run-level manifest/index file. Framework artifact flow should use a generic
envelope with json.RawMessage payloads; extract modules should own typed Go
structs at their boundaries and encode into that generic envelope before
returning to framework code.
Output-stage warnings are intentionally out-of-band. An OutputEncoder may
return warnings about serialization, formatting, truncation, or destination
concerns, but those warnings are appended to the runner result after encoding
and are not expected to appear inside the encoded artifact bytes. CLI,
diagnostic, or reporting layers should surface output-stage warnings from the
runner result.
Schemas should be versioned per extractor, with a separate envelope/manifest format version.
Validators
Validators should be independently testable and composable.
Deterministic validators should run before LLM-backed validators when both are present. Validator decision semantics should be explicit: each candidate artifact should receive exactly one decision from each validator that evaluates it.
LLM-backed review belongs in module-owned validator chains, not in a separate global review phase. Extract modules and normalize modules may both use deterministic and LLM-backed validators.
Shared validator runtime mechanics belong under internal/framework/validate.
Concrete validator behavior belongs under internal/validators/<validator>.
LLM Runtime
LLM provider details belong behind transport-neutral framework contracts.
Provider-specific HTTP request and response types should stay inside the LLM runtime package. Prompt construction should stay in extractors, validators, or shared prompt-context helpers; provider adapters should not own domain prompt logic.
Errors, diagnostics, reports, and redacted config must not expose secrets.
Configuration
Centralize configuration loading, processing, precedence, defaults, and
validation in internal/core/config.
The goal is to make configuration discoverable and avoid implicit or hidden operational values. User-visible defaults and cross-package operational defaults should be defined in config code.
Configuration should be organized around named pipeline profiles. A pipeline is a fixed-shape template for the application workflow, not a free-form DAG or general workflow program. The six-stage flow remains fixed:
input -> chunk -> extract -> merge -> normalize -> output
A pipeline profile should bind registered modules to those stage slots:
- one shared input module;
- one shared chunk module by default;
- one or more artifact lanes, each with extract, merge, normalize, and validator behavior;
- one output module.
The MVP should use one shared chunk module per pipeline. Per-lane chunk overrides are a future extension and should be added only if a real artifact lane needs different chunking.
The CLI should select a named pipeline by ID, such as
notarius run dnd-session --input session.json. Structural module selection
should come from configuration, not ad hoc CLI flags. CLI flags may select a
subset of configured artifact lanes, such as --only spells,npcs, and may
override operational settings such as model, concurrency, output directory, or
diagnostics directory.
Pipeline definitions should support compact defaults:
chunk:generic;- lane
merge:appendorder; - lane
normalize:noop; output:json;llm_profile:defaultwhere an LLM profile is needed.
Module bindings should support both string shorthand and object form. For
example, extract: dnd/spells and
extract: {module: dnd/spells, llm_profile: fast} should normalize to the same
internal binding type.
Module registries should expose module metadata, including flat string
capabilities, without requiring module construction. Config validation should
fail fast on unknown module keys, unknown pipeline IDs, missing required slots,
missing capabilities, unknown LLM profiles, empty artifact-lane sets, or
--only lane names that do not exist in the selected pipeline.
Keep capabilities as a flat string set. Do not evolve capabilities into a type system unless real module interactions prove the need.
Unless documented otherwise, precedence from lowest to highest is:
- built-in defaults
- configuration file
- environment variables
- CLI flags
Prefer YAML configuration unless the project has a strong reason to use another
format. Config files should be discoverable at
/usr/local/etc/notarius/config.yml, with a CLI override via --config.
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets.
Stage-module-specific configuration should remain inline with the pipeline slot that owns it. Do not add named module instances until repeated inline settings create real drift or duplication. LLM profiles are the justified top-level exception because model settings are cross-cutting.
The run manifest should record the selected pipeline_id and a digest of the
resolved pipeline definition after defaults and lane selection are applied.
pipeline_id alone is not sufficient provenance because a named pipeline can
change over time.
Embedded Assets
Store embedded JSON schemas, Markdown prompts, templates, and similar assets as separate files, not inline string literals, unless there is a strong reason otherwise.
Embedded prompts and response schemas should have stable IDs, versions, source metadata, and hashes suitable for diagnostics and run manifests.
Errors and Logging
Errors should be actionable and preserve context. Wrap errors with operation and path/resource context. CLI code should convert internal errors into concise user-facing messages.
Errors and logs must not expose secrets.
Use structured logging where practical. Logs should describe operations, paths, external calls, retries, and failure causes, but should not include large source or artifact payloads by default.
Context, Timeouts, and Cancellation
Long-running operations should accept context.Context. External calls,
subprocesses, HTTP requests, storage operations, LLM calls, and multi-stage
workflows should respect cancellation and timeouts.
State, Files, and Safety
If the application writes durable state, writes should be atomic where practical. Multi-step workflows should preserve enough state to support inspection, retry, or resume after failure.
Code that deletes, moves, or overwrites files must use narrow, explicit paths. Avoid broad parent-directory operations. Cleanup that can cause data loss must be opt-in.
Testing
Core logic should be testable without real external services. Use fakes, fixtures, or local test doubles for input modules, extract modules, validators, and LLM clients where practical.
Contract-first work should include fake implementations that prove interfaces compose before real input modules or extract modules depend on them.
Once the pipeline-stage contracts exist, maintain a fixture-driven walking skeleton that exercises input, chunk, extract, merge, normalize, and output stages with fake modules and fake external clients. This test should protect stage composition continuously while real modules are introduced over later checkpoints.
Config examples should be load-tested once config files exist. Important CLI workflows should have parser or command tests. Adapter, extractor, and validator contracts should have focused tests that do not require running the full application unless end-to-end coverage is intentional.
Documentation
Documentation should follow the project documentation policy. Keep user docs
focused on implemented behavior. Put future, planned, or aspirational work only
under docs/roadmap/.
Core documentation should use generic terms such as source document, source unit, source reference, input adapter, extractor, chunker, merger, normalizer, artifact, validator, and run manifest.
Source-format details belong in input module or integration docs. Domain-specific extraction details belong in extract module or artifact docs.
When changing architecture, config, CLI behavior, stage modules, extractor contracts, validator contracts, LLM runtime behavior, or artifact schemas, update the relevant docs and examples in the same change.