Architecture & tech stack

Vespera — Architecture & Tech Stack#

Project
Document Curation Pipeline → Knowledge Base
Source
compiled 2026-08-21 from the ADRs and CONTEXT.md. Demoted from a hand-off note to this repo's standing architecture document on 2026-08-22, when the lost ADR text was reconstituted from the condensed ledger into docs/adr/.
Reading order
§1 and §2 describe the system and are the fuller record — most ADR files carry only a one-line summary and point back here. The condensed ledger now lives in docs/decision-ledger.md, kept as the provenance witness for those files rather than as the place to read a decision.
Status
design is ahead of code — stages 0 to 4 judge, stage 5 measures without judging yet, and 6a and 6b are recorded decisions with no code. AGENTS.md is where that state is kept current; this document describes the system as decided, not as built. Open questions are not tracked here — they live on the issue tracker, and AGENTS.md says which of them is takeable. The stage 6a/6b slice's wayfinder map was charted on 2026-09-12 and is open; the stage 5 slice's closed on 2026-09-09.

1. Architecture#

1.1 What it does#

Vespera curates an unknown, multi-format local document archive (hundreds of GB of .txt, .docx, .pdf, images) into a publication-ready knowledge base. It measures the corpus before judging it (ADR-006), removes what is mechanically broken, redundant, or topically irrelevant (ADR-007/ADR-004), and synthesises connective material over what survives (ADR-021). The engine is domain-agnostic: it carries no built-in knowledge of the corpus's subject matter, and the operator supplies domain knowledge only through a seed folder of known-relevant documents (ADR-003, ADR-004).

1.2 The cascade (ADR-017)#

Eight stages, each defined by the verdicts it writes. Stages never call each other — every stage reads and writes only through the ledger (ADR-036, ADR-042):

#StageWritesNotes
0Census(no verdicts)Filesystem walk → file occurrence rows. Pure measurement (ADR-006).
1Byte-level reductionbroken, duplicate-of, superseded-byCheapest discriminating filter, runs first.
2Extractionextraction-failed, degenerate-outputDocling, out-of-process, cached; silent about text fidelity, never about failure (ADR-010, ADR-070).
3Content census(no verdicts)The corpus-wide pass over what stage 2 stored — document frequency for boilerplate (ADR-038), report distributions. Per-document metrics and shingles are written in stage 2's own pass, under stage 2's run (ADR-019, ADR-073).
4Content redundancy (lexical)redundant-withMinHash + LSH banding over shingles (ADR-018), boilerplate-stripped (ADR-038).
5Relevance (embeddings)below-thresholdScoring against the seed set (ADR-020), clustering within each seed partition (ADR-027, ADR-045).
6aArrangement(a page tree, nothing rendered)Seed-named taxonomy + within-seed clusters (ADR-022). Human gate before 6b.
6bGenerationOne overview per cluster, citations resolved to occurrence ids (ADR-022, ADR-026).

Ordering principle: the cheapest filter runs first, so every occurrence removed early is extraction or embedding never paid for (ADR-017).

The cascade. Every stage reads and writes only through the ledger; none of them calls another. The chain ends at 6b: what it produces is the deliverable, and nothing in this project renders or uploads it anywhere (ADR-101).

flowchart TD
    S0["<b>0 · Census</b><br/>filesystem walk<br/><i>writes no verdicts</i>"]
    S1["<b>1 · Byte-level reduction</b><br/>broken · duplicate-of · superseded-by"]
    S2["<b>2 · Extraction</b><br/>extraction-failed · degenerate-output"]
    S3["<b>3 · Content census</b><br/>corpus-wide pass over stage 2's columns<br/><i>writes no verdicts</i>"]
    S4["<b>4 · Content redundancy</b><br/>redundant-with"]
    S5["<b>5 · Relevance</b><br/>below-threshold"]
    S6A["<b>6a · Arrangement</b><br/>a page tree, nothing rendered"]
    S6B["<b>6b · Generation</b><br/>cited overviews per cluster"]
    LEDGER[("<b>Ledger</b><br/>occurrences · verdicts · runs")]
    ART["The generated documents<br/><i>the run ends here</i>"]

    S0 --> S1 --> S2 --> S3 --> S4 --> S5 --> S6A --> S6B --> ART
    ART -. "a person starts it" .-> S7

    S0 <-.-> LEDGER
    S1 <-.-> LEDGER
    S2 <-.-> LEDGER
    S3 <-.-> LEDGER
    S4 <-.-> LEDGER
    S5 <-.-> LEDGER
    S6A <-.-> LEDGER
    S6B <-.-> LEDGER

    classDef measure fill:#eef4ff,stroke:#4a6fa5,color:#12243d
    classDef judge fill:#fff4e6,stroke:#b5762a,color:#3d2a12
    classDef out fill:#eafaf1,stroke:#2f8f5b,color:#0f2e1e
    classDef store fill:#f3eaff,stroke:#7a4fb5,color:#241238
    class S0,S3 measure
    class S1,S2,S4,S5 judge
    class S6A,S6B,S7,ART out
    class LEDGER store

1.3 Core architectural model#

Identity and the ledger. Two independent lifetimes: a walk owns occurrence rows because they are filesystem observations, a run owns verdict rows because they are derived under a configuration. Content identity is a discovered relation over occurrences, never a collapse of them.

classDiagram
    class Walk {
        +id
        +corpusRoot
        +finished
    }
    class FileOccurrence {
        +id : surrogate key
        +path
        +size
        +mtime
        +hash : nullable
    }
    class ContentIdentity {
        +hash
        +representativeOccurrence
    }
    class Run {
        +id
        +stage
    }
    class Verdict {
        +id : surrogate key
        +occurrenceId
        +stage
        +kind : from a fixed closed vocabulary
        +blocking
        +reason
    }
    class Profile {
        +thresholds : unset until measured
        +provenance : human / carried-over / auto-derived
    }
    class CapabilityCache {
        +extraction : keyed by extractor identity
        +chunks : keyed by content hash, chunker, tokenizer
        +vectors : keyed by chunk hash, model identity
    }

    Walk "1" --> "*" FileOccurrence : owns
    Run "1" --> "*" Verdict : owns
    FileOccurrence "1" --> "*" Verdict : judged by
    FileOccurrence "*" --> "0..1" ContentIdentity : discovered to share
    Run "*" --> "1" Walk : reads occurrences from
    Run "*" --> "1" Profile : snapshots what it consumed
    FileOccurrence "1" --> "*" CapabilityCache : keyed by occurrence or content hash

    note for Walk "One observation of a filesystem. Owns occurrence rows because they are filesystem observations."
    note for Run "id = hash(implementation version, config consumed, walk id, upstream run ids), chained to upstream runs."
    note for ContentIdentity "A relation discovered over occurrences, never a collapse of them."

The pipeline never blocks. A gate is a required input, not a pause: the run ends there having recorded everything it learned, and iteration happens between runs rather than inside them.

stateDiagram-v2
    direction LR
    [*] --> Invoked : one call of the command
    Invoked --> StageRunning : resume predicate finds unjudged occurrences
    StageRunning --> StageRunning : next stage, new run id
    StageRunning --> Terminated : a required input is missing
    StageRunning --> ArtifactReady : 6b complete
    Terminated --> [*]
    ArtifactReady --> [*]
    note right of Terminated
        Nothing is lost: verdicts written
        so far stay in the ledger.
        A person supplies the value,
        then re-invokes.
    end note
    note right of ArtifactReady
        The run ends here: what
        the operator does with the
        documents is their own.
    end note

1.4 Module boundaries (ADR-040, ADR-041, ADR-042)#

Modules are capability-shaped, not stage-shaped — stage assignment has already moved twice in this design (shingling, clustering) while the underlying capability didn't, so encoding stages in package structure was rejected.

ModuleOwns
ledgerOccurrence identity, verdict vocabulary + rows, run identity, the survivors(runId) query
corpusWalking, byte-level facts
extractionDocling client, extraction cache, derived metrics, chunking (chunker gets tokenizer identity from pipeline)
similarityShingles, MinHash/LSH — shingles are computed during stage 2's pass, but the code and the table are similarity's and the call is composed in pipeline (ADR-073), since a capability module may not depend on another except where a decision records it
embeddingSQLite vector cache, Chroma projection, scoring, clustering
synthesisArrangement (6a), generation (6b)
profileThresholds, provenance, gate inputs
pipelineBatch job definitions; the only module that knows the phrase "stage 4"
Rule
a capability module may depend on ledger and nothing else horizontal, with one recorded exception — extraction names corpus's two detection enumerations, because Docling's pipeline choice is derived from them (ADR-100); pipeline depends on all of them (it's the composition root). Enforced by a Spring Modulith ApplicationModules.verify() boundary test — with a known, recorded gap: the test checks Java type references via ArchUnit on bytecode, so a raw SQL string crossing a table-ownership boundary is invisible to it. Table ownership (ledger owns identity/verdicts; every other capability owns its own tables keyed by occurrence_id) is therefore enforced in Java and conventional in the database.

Module boundaries. Capability-shaped, not stage-shaped: stage assignment moved twice during design while the underlying capability did not. A capability module may depend on ledger and nothing else horizontal — the one exception is extraction, which also names corpus's two detection enumerations (ADR-100), declared in its @ApplicationModule and pinned by name in ModuleBoundariesTest; pipeline is the composition root and depends on all of them.

flowchart TD
    PIPELINE["<b>pipeline</b><br/>batch job definitions<br/>the only module that names a stage"]

    subgraph CAPABILITIES["capability modules — no horizontal dependencies"]
        direction LR
        CORPUS["<b>corpus</b><br/>walking · byte-level facts"]
        EXTRACTION["<b>extraction</b><br/>Docling · cache · metrics · chunking"]
        SIMILARITY["<b>similarity</b><br/>shingles · MinHash/LSH"]
        EMBEDDING["<b>embedding</b><br/>vector cache · Chroma · scoring · clustering"]
        SYNTHESIS["<b>synthesis</b><br/>arrangement · generation"]
        PROFILE["<b>profile</b><br/>thresholds · provenance · gate inputs"]
    end

    LEDGER["<b>ledger</b><br/>occurrence identity · verdict vocabulary and rows · run identity · the survivors query"]

    PIPELINE --> CORPUS
    PIPELINE --> EXTRACTION
    PIPELINE --> SIMILARITY
    PIPELINE --> EMBEDDING
    PIPELINE --> SYNTHESIS
    PIPELINE --> PROFILE
    PIPELINE --> LEDGER

    CORPUS --> LEDGER
    EXTRACTION --> LEDGER
    SIMILARITY --> LEDGER
    EMBEDDING --> LEDGER
    SYNTHESIS --> LEDGER
    PROFILE --> LEDGER

    classDef root fill:#f3eaff,stroke:#7a4fb5,color:#241238
    classDef cap fill:#eef4ff,stroke:#4a6fa5,color:#12243d
    classDef core fill:#fff4e6,stroke:#b5762a,color:#3d2a12
    class PIPELINE root
    class CORPUS,EXTRACTION,SIMILARITY,EMBEDDING,SYNTHESIS,PROFILE cap
    class LEDGER core

1.5 Data architecture#

1.6 Orchestration & invocation#

One invocation, end to end. What a person starting the command actually sets in motion, as the code is wired today. The root is the argument, and vespera.corpus-root in application.yaml answers only an invocation that names none (ADR-066) — unset by default, and an invocation with neither refuses rather than guessing a tree to census. The working directory is prepared before Spring can open anything inside it (ADR-054), the schema is checked before any stage runs (ADR-049), and the job is a single Spring Batch job whose steps are the cascade — census is the only one that exists in this slice, and every later stage is another step appended to the same job. The run ends at stage 6b, and nothing follows it (ADR-101).

flowchart TD
    OP(["a person types<br/><b>vespera run</b>, naming a root<br/>or leaving it to configuration"])
    PREP["<b>working directory prepared</b><br/>vespera.working-dir created<br/><i>before the datasource is opened</i>"]
    BOOT["<b>application starts</b><br/>SQLite opened · schema applied<br/>schema_version checked, refuses on mismatch"]
    JOB["<b>job 'vespera' started</b><br/>one job parameter: the root<br/><i>never started by the app coming up</i>"]
    S0["<b>step: census</b><br/>stage 0 — walk, record, merge the profile"]
    LATER["<b>steps: stages 1 to 6b</b><br/><i>not built in this slice</i>"]
    EXIT(["exit code<br/>0, or non-zero if the job failed"])


    STORE[("<b>working directory</b><br/>the database · the profile")]

    OP --> PREP --> BOOT --> JOB --> S0 --> LATER --> EXIT

    PREP -.-> STORE
    BOOT <-.-> STORE
    S0 <-.-> STORE
    PUB <-.-> STORE

    classDef human fill:#fff4e6,stroke:#b5762a,color:#3d2a12
    classDef step fill:#eef4ff,stroke:#4a6fa5,color:#12243d
    classDef later fill:#f5f5f5,stroke:#9a9a9a,color:#3a3a3a
    classDef store fill:#f3eaff,stroke:#7a4fb5,color:#241238
    classDef out fill:#eafaf1,stroke:#2f8f5b,color:#0f2e1e
    class OP human
    class PREP,BOOT,JOB,S0,PUB step
    class LATER later
    class STORE store
    class EXIT out

Census in detail. Stage 0 is a tasklet rather than a chunk-oriented step, because it is the one stage that reads no survivors — it produces the occurrences every later stage reads, so there is no input to chunk. Its two walks are independent (ADR-064): the same instrument walks the corpus root and, if the profile names one, the seed folder, and neither costs the other its chance to run. The chunking that matters is the walk's own commit cadence: everything between two checkpoints is buffered and written in the same transaction as the checkpoint, which is what makes a killed session resumable rather than merely fast (ADR-055).

flowchart TD
    START(["census step begins<br/>with the root it was given"])
    LOAD["<b>load the profile</b><br/>keys the file lacks arrive unset (ADR-062)"]

    subgraph WALK["walking a root — the same instrument for either one"]
        direction TB
        CANON["canonicalise the root"]
        RESUME{"an unfinished walk<br/>over this root?"}
        MINT["<b>mint a walk id</b>"]
        CONT["<b>resume that walk id</b><br/>skip past the checkpointed subtree<br/>fail loudly if the tree no longer agrees"]
        VISIT["<b>visit an entry</b><br/>file occurrence · anomaly · descend<br/><i>links are recorded, never followed</i>"]
        BUF["buffer occurrences and anomalies"]
        CP{"a thousand entries since<br/>the last checkpoint?"}
        COMMIT["<b>one transaction</b><br/>buffered rows + checkpoint + counts"]
        NEXT{"another entry,<br/>and the session still alive?"}
        DONE{"was the whole tree walked?"}
        STOPPED["<b>leave it unfinished</b><br/>rows past the last checkpoint are dropped<br/><i>ineligible as run input until finished</i>"]
        FIN["<b>finish the walk</b><br/>final rows + cumulative counts"]
        RECON["<b>the excludes-nothing check</b> (ADR-056)<br/>entries seen against occurrences,<br/>anomalies and directories written<br/><i>throws if it does not balance</i>"]

        CANON --> RESUME
        RESUME -- no --> MINT --> VISIT
        RESUME -- yes --> CONT --> VISIT
        VISIT --> BUF --> CP
        CP -- not yet --> NEXT
        CP -- yes --> COMMIT --> NEXT
        NEXT -- yes --> VISIT
        NEXT -- no --> DONE
        DONE -- no --> STOPPED
        DONE -- yes --> FIN --> RECON
    end

    CORPUS["<b>walk the corpus root</b><br/><i>a failure is held, not raised</i>"]
    SEED{"does the profile<br/>name a seed folder?"}
    SEEDWALK["<b>walk the seed folder</b><br/>its own walk id, no purpose tag"]
    UNSET["record that no seed folder is set<br/><i>a gap, not a failure</i>"]
    MEAS["<b>measure the seed folder</b><br/>the walk id, or why it could not be walked"]
    SAVE["<b>save the profile</b><br/>census drafts it, humans write it thereafter"]
    RAISE{"did the corpus<br/>walk fail?"}
    FAILED(["the step fails<br/>the held failure is raised"])
    OK(["the step finishes<br/>no verdicts, so no run is minted (ADR-048)"])

    START --> LOAD --> CORPUS --> WALK
    WALK --> SEED
    SEED -- yes --> SEEDWALK --> MEAS
    SEED -- no --> UNSET --> MEAS
    MEAS --> SAVE --> RAISE
    RAISE -- yes --> FAILED
    RAISE -- no --> OK

    classDef measure fill:#eef4ff,stroke:#4a6fa5,color:#12243d
    classDef gate fill:#fff4e6,stroke:#b5762a,color:#3d2a12
    classDef write fill:#f3eaff,stroke:#7a4fb5,color:#241238
    classDef out fill:#eafaf1,stroke:#2f8f5b,color:#0f2e1e
    classDef bad fill:#fdecea,stroke:#b53a2a,color:#3d1512
    class CANON,VISIT,BUF,CORPUS,SEEDWALK,UNSET,MINT,CONT measure
    class RESUME,CP,NEXT,DONE,SEED,RAISE gate
    class COMMIT,FIN,LOAD,SAVE,MEAS write
    class RECON,STOPPED,START,OK out
    class FAILED bad

1.7 Open items#

Tracked on the wayfinder map, Census slice: the way to a hand-off spec, rather than in this file. Its open child issues are the live list; its Out of scope section carries the two items parked on measurement data — shingle granularity, blocked on stage-3 OCR error rates, and target hardware, blocked on a census scanned-page count — each with the trigger that revives it.

The standing design question, Is the seed set profiled with the corpus instrument, is resolved: the walk instrument generalizes to any root, a seed folder is walked the same way as the corpus (ADR-064), and the full mismatch-detection question is deferred to stage 5, which this slice does not build. ADR-073 has since placed the three signals that question is blocked on — chunk count is a query against the chunk cache and comparable only within one tokenizer identity, language is a stage-2 column, and no OCR-error rate exists as a Docling signal, so stage 2 stores the counters a definition of one can be computed from — and records the precondition stage 5 inherits: the comparison needs the seed set extracted with the same instrument.

This section previously duplicated docs/frontier.md, which no longer exists. The map replaced both: a second open-items register drifts from the first, and the tracker is the one with a claim to being canonical.


2. Tech stack#

Fixed as an input constraint (ADR-001), refined through the ledger below.

LayerChoiceDecided by
Language / frameworkJava, Spring BootADR-001
AI integrationSpring AI (model + embedding access)ADR-001
OrchestrationSpring Batch, ResourcelessJobRepository (no JDBC job repo)ADR-036
Modularity / boundariesSpring Modulith (starter-core only — no event publication registry)ADR-037, ADR-040
Relational storeSQLite — single database, one per corpusADR-008, ADR-009
Vector indexChroma — derived/disposable projection of SQLite vectorsADR-039
Vector storage (authoritative)SQLite, keyed by chunk hash + model identityADR-032, ADR-039
Document extractionDocling, out-of-process service, configurable serving engineADR-010, ADR-012
Extraction serving engine (default)Ollama, self-hostedADR-013
Extraction serving engine (reference model)One hosted model (OpenAI), configured for a separate run — a confirmation reference, never an in-pipeline fallback. There is no extraction-engine bake-offADR-012, ADR-013, ADR-072
ChunkingDocling HybridChunker (structure-first), tokenizer aligned to embedding model; LLM boundary-finding fallback for structureless (scanned) text, gated by measurement, currently disabledADR-029, ADR-044
Embedding modelNot yet chosen — candidates Qwen3-Embedding-0.6B, granite-embedding-278m, one hosted ceiling model; chosen by bake-offADR-033, ADR-034
Near-duplicate detectionMinHash with LSH banding (not SimHash)ADR-018
Containers / sidecarsApplication-managed (tool starts/stops its own dependencies)ADR-011
CLIpicocliADR-047
Publication targetNone — the run ends at the documents 6b generatesADR-101
Schema managementSpring schema.sql + manual version check; no Flyway/Liquibase yetADR-049

Explicitly removed from the pom (ADR-046, each citing the ADR that obviates it): camel-spring-boot-starter, spring-ai-vector-store-advisor, spring-boot-starter-batch-jdbc, the Spring AI jsoup/markdown/PDF document readers, spring-cloud-starter-contract-verifier, spring-modulith-observability-api/-core, spring-modulith-actuator. Rule: the pom carries what a recorded decision requires, not what current code happens to use.


3. Decision ledger#

Moved to docs/decision-ledger.md: the condensed one-line record of all 49 decisions, retained as the provenance witness for the reconstituted ADR files.

To read a decision, go to its own file in docs/adr/ — that folder is what to cite. Where a record says less than §1 or §2 does about the same decision, §1 and §2 are the fuller record.