Skip to content

Architecture

Status: lattice has held the packages since 2026-09-30, as stackr RFC-0003 designed; its phases 1 and 2 are done. This document is evergreen: it describes lattice as it is, and is updated in the same pull request as what it describes. lattice's decisions are recorded in adr/, and each package's in its own section. RFC-0003 is the design, and its Tracking section lists where lattice departed from it.

What lattice is

lattice is one repository: eight packages, two reference implementations, and one set of tools that checks, releases and documents them. It holds the packages, and changes nothing about what they are downstream. Each keeps its name, pyproject.toml, version, dependencies, extras, tests, 100% coverage gate, ADRs, RFCs, changelog and releases, and each installs on its own.

Goals

  • Every package stays installable on its own, and CI proves it on every change.
  • The libraries stay independent (reflexr ADR-0003, as amended by ADR-0018), and checks enforce it, where repository walls used to.
  • A change that spans packages lands in one pull request, tested with every package it can break.
  • One of each: lock per language, CI, set of hooks, documentation site, dev container and release process.

Non-goals (for now)

  • Publishing to PyPI (#7). Every library has been on PyPI since 2026-10-17 (Releases).

Layout

lattice/
├── packages/
│   ├── artifactr/       artifactr-ai: src/, tests/, schemas/ (its JSON Schema and OpenAPI document), bruno/ (its collection), deploy/ (its dashboards), scripts/, compose.yaml, compose.stackr.yaml, moon.yml, pyproject.toml, README.md, CHANGELOG.md, LICENSE
│   ├── reflexr/         the same shape
│   ├── evalr/
│   ├── relayr/          relayr-ai
│   ├── grantr/          grantr: src/, tests/, schemas/, compose.yaml, moon.yml, pyproject.toml, README.md, CHANGELOG.md, LICENSE
│   ├── stackr/          the stack and the application template: compose.yaml, supabase/, deploy/, scripts/, template/, copier.yml, versions.env, Makefile
│   ├── portalr/         the portal: its Python package, and web/ (client, ui, app, admin), the Bun workspace's packages
│   └── lattice-core/    lattice-core-ai: what the libraries share and must keep identical (ADR-0018)
├── examples/
│   ├── docplan/         artifactr's reference implementation, and its image
│   └── oncall/          reflexr's
├── docs/                the site: lattice's pages, and docs/<package>/ for each package
├── scripts/             affected.py, api (the acceptance tests), breaks.py, standalone.py, check_site.py, griffe_sphinx_roles.py
├── tests/               lattice's tests: the quality gates over all Python, and moon's edges
├── .devcontainer/       the dev container
├── .moon/               workspace.yml, and the tasks projects inherit
├── .github/             the workflows, issue forms and pull request template
├── pyproject.toml       the uv workspace's root: its members, lattice's tools, and the shared ruff configuration
├── uv.lock              one lock for every package
├── package.json         the Bun workspace's root: its members, and the shared web tools
├── bun.lock             one lock for every web package
├── biome.json, tsconfig.base.json, knip.json   the web packages' shared configuration
├── moon.yml             the root project, lattice
├── mkdocs.yml           the site's one configuration
├── copier.yml           points Copier at stackr's template
├── Makefile             install, and check
├── .prototools, .python-version, .pre-commit-config.yaml, .prekignore, .git-blame-ignore-revs
├── renovate.json, release-please-config.json, .release-please-manifest.json, osv-scanner.toml
└── README.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, LICENSE
  • A package's directory holds what it ships and what builds it: code, tests, schemas, dashboards, changelog and license. Its wheel and sdist ship its LICENSE; the root has the same one.
  • Its pages are at docs/<package>/, its ADRs and RFCs included, so one build makes one site (The docs site).
  • The reference implementations are in examples/, since uv refuses a workspace member inside another member. Each is a project of its own, with its own 100% gate.
  • lattice's documents are at the root of docs/: this one, lattice's ADRs, and the project pages. Outside its package, an ADR or RFC is named with it, as in "reflexr ADR-0003", and lattice's as "lattice ADR-0002".

The projects

moon knows fifteen projects: the fourteen below, and lattice, the root, which holds the site and lattice's own tests and scripts.

Project Path What it is Kind Depends on
lattice-core packages/lattice-core What the libraries share and must keep identical; distributed as lattice-core-ai (ADR-0018) Library
evalr packages/evalr Typed evaluation of agent systems Library
artifactr packages/artifactr Chat applications where people and agents share versioned artifacts; distributed as artifactr-ai Library evalr, lattice-core
reflexr packages/reflexr Rules over event streams that run LLM workflows Library evalr, lattice-core
relayr packages/relayr The bridge between artifactr and reflexr; distributed as relayr-ai Library artifactr, reflexr
grantr packages/grantr Identity and access for artifactr and reflexr, in stackr's template (its plan) Library lattice-core
stackr packages/stackr The stack the libraries run on, and the application template Tool (shell), ships no wheel artifactr, reflexr, evalr, grantr
portalr packages/portalr The portal: the router that serves the app and admin UIs, and reads the stack's tools for one tenant (its architecture) Library grantr, lattice-core
client packages/portalr/web/client @portalr/client, the only code in the UIs that talks to the server Library (TypeScript)
ui packages/portalr/web/ui @portalr/ui, the kit app and admin build from Library (TypeScript)
app packages/portalr/web/app The people's UI, a single-page app Application (TypeScript) ui
admin packages/portalr/web/admin The admin UI, a single-page app Application (TypeScript) ui
docplan examples/docplan artifactr's reference implementation Application artifactr, evalr
oncall examples/oncall reflexr's reference implementation Application reflexr
graph TD
    relayr --> artifactr
    relayr --> reflexr
    docplan --> artifactr
    docplan --> evalr
    oncall --> reflexr
    stackr --> artifactr
    stackr --> reflexr
    stackr --> evalr
    stackr --> grantr
    artifactr --> evalr
    reflexr --> evalr
    portalr --> grantr
    app --> ui
    admin --> ui
  • An edge is a requirement. The dependent names its dependency in [project] and takes it from the workspace (The uv workspace). The libraries name their siblings with a range, which their wheels carry; docplan and oncall ship no wheel, and name theirs without one. docplan depends on evalr through its [dspy] extra.
  • stackr's edges are its template's: it generates applications on the three libraries and grantr, and no pyproject.toml records that.
  • Each moon.yml states its dependsOn, since moon infers no edge from a requirement with a range. tests/test_moon.py holds it to the siblings the project's pyproject.toml requires, and checks that each comes from the workspace; for a web package, to the workspace packages its package.json depends on.
  • Independence is checked, not assumed. In one environment every member is importable, so an undeclared import that would once have failed passes silently. artifactr's and reflexr's layering tests list what each layer may import, evalr's forbid artifactr and reflexr, and deptry fails on an import that a package's shipped code doesn't declare.

The uv workspace

The root pyproject.toml is a virtual project (package = false) whose members are packages/* and examples/*. It holds lattice's tools, in its dev and docs dependency groups, and their shared configuration.

Where it lives
The lock One uv.lock, one resolution for every package, extra and group. uv sync --all-packages --all-groups --all-extras installs them all into one .venv, and CI adds --locked
A sibling A range in [project], such as evalr>=0.1.0, and { workspace = true } in [tool.uv.sources], which only development sees. A built wheel carries the range and no path
ruff One configuration, at the root; stackr extends it for its scripts
pyright Per package; the root's covers scripts/ and tests/. It stays until ty can replace it (ADR-0013)
pytest and coverage Per project, since neither tool reads a parent directory's configuration
The quality test One, at the root (tests/test_quality.py), over every package, examples/ and the root's Python, and portalr's TypeScript: no inline suppressions anywhere

One resolution means every package agrees on every third-party version. Today that costs one hold: litellm stays at 1.83.0, and osv-scanner.toml records the advisories that don't apply (ADR-0008).

Standalone packages

Every package that ships a wheel is tagged distribution in its moon.yml, and inherits three tasks that prove it installs outside lattice:

  • build runs uv build --no-sources --out-dir dist from the package's directory, so the wheel and sdist are built as they are published, into the project's own dist/. It runs after its dependencies' build, so a package's siblings are built however far down they are.
  • standalone depends on the package's build. scripts/standalone.py installs the wheel with every extra, and the siblings it needs as wheels by path, into a fresh environment outside the workspace; imports every module; and checks the version. uv checks every range between them, so a range that a sibling's version doesn't satisfy fails to resolve.
  • deptry runs over src/ from the package's directory. An undeclared import (DEP001, DEP003) or a development dependency used in shipped code (DEP004) fails. DEP002, unused dependencies, stays off, since extras such as [postgres] name drivers the library never imports.

check includes standalone and deptry.

The Bun workspace

portalr's web packages are a Bun workspace rooted at lattice's root, so TypeScript has one lock, as Python has one uv.lock (portalr ADR-0008).

Where it lives
The members packages/portalr/web/* in the root package.json's workspaces: client, ui, app and admin, each private, named @portalr/<name>, and without a version of its own: portalr's is its pyproject.toml's
The lock One bun.lock, the text lockfile. bun install installs it into node_modules/, and CI adds --frozen-lockfile
The shared tools The root package.json's devDependencies: TypeScript 7, Biome, Vitest and knip. A package's own tools, such as Vite, are its own. Every version is exact
TypeScript tsconfig.base.json, strict, which each package's tsconfig.json extends. tsc is TypeScript 7.0, the native compiler; no tool in the workspace needs TypeScript's library API yet (portalr ADR-0007)
Biome biome.json: formatting and the recommended lint rules, for the web packages and the workspace's own configuration
knip knip.json: one run over the workspace, from the root project, which reads moon's tasks for the tools they run
A sibling "@portalr/ui": "workspace:*". client and ui are consumed as source: their exports point at src/index.ts, and Vite builds them into app and admin
ui's styles portalr's tokens as CSS custom properties, each a light-dark() pair, in tokens.css: its inks as --portalr-*, and brand-neutral roles, such as --accent, which styles.css makes Tailwind 4's whole colour theme. The primitives are Base UI's, styled with them and merged with shadcn's cn()
Generated types client's types are generated from each package's committed OpenAPI document and WebSocket JSON Schema into src/generated/, with a drift test; moon run client:types regenerates them. openapi-typescript loads TypeScript 6, client's own typescript, and client is still type-checked with the root's TypeScript 7 (portalr ADR-0010)
Browser tests ui's tests run in Chromium, through Vitest's browser mode and Playwright, twice: with the system's scheme light, and dark (portalr ADR-0009). make install, the dev container and CI install Playwright's headless Chromium

Bun runs every tool, in its own runtime. Each task runs bun x --bun --no-install <tool>, so a tool's Node.js script runs in Bun, and one that bun.lock doesn't hold fails rather than being fetched. lattice pins no Node.js, and no task runs one.

moon

moon runs every task, and only those a change affects; uv and Bun manage the dependencies, and moon never edits a manifest or a lock. moon's Python toolchain stays off until it is stable (RFC-0003, D3), so every Python task is a plain uv run command.

File Holds
.moon/workspace.yml The fifteen projects, by path; main as the default branch; moon's version constraint
.moon/tasks/python.yml For every Python project: lint, typecheck, test, format and check. The root's pyproject.toml and uv.lock are inputs of every task
.moon/tasks/distribution.yml For every project tagged distribution: build, standalone and deptry
.moon/toolchains.yml JavaScript, with Bun as its package manager, Bun, and TypeScript. Every setting that would install dependencies or rewrite a package.json or tsconfig.json is off, and moon installs no Bun of its own: proto does, from .prototools
.moon/tasks/typescript.yml For every web package: lint (Biome), typecheck (tsc), test (Vitest), build (Vite), format and check. The root's package.json, bun.lock, biome.json and tsconfig.base.json are inputs of every task. client and ui have no build, and app and admin no test until they have code to test
<project>/moon.yml The project's language, layer, tags and dependsOn, and tasks of its own
moon.yml The root project, lattice: lint, types and tests for the root's Python and lattice's tests; docs and docs-serve; workflows, which runs actionlint and zizmor; knip, over the Bun workspace; and check. It inherits no task, which would check every package again
.prototools The versions of proto, moon, uv and Bun, which CI's .github/actions/toolchain installs, and proto anywhere else. detect-strategy = "only-prototools" leaves Python to uv, from .python-version
  • check is one task per project that runs nothing itself and depends on the project's checks, so the list lives in one place. make check runs moon run :check, and CI and Nightly run the same tasks.
  • artifactr's and reflexr's contributor tasks, schema, dashboards, pg-up, pg-down, app-up and test-pg, are ones CI doesn't run and moon doesn't cache (RFC-0003, D2). stackr keeps its Makefile for the stack, and its checks are tasks: validate, reference, template, smoke and smoke-app.
  • artifactr's and reflexr's api tasks, which the api tag gives them, are their acceptance tests (ADR-0015). Each starts its reference implementation from the package's compose.yaml, on pydantic-ai's test model, runs the package's Bruno collection against it, then Schemathesis over the package's committed OpenAPI document, and stops it. A change to the reference implementation runs them, through their inputs. Each runs as a compose project of its own, on ports the system picks, so they run side by side. The root's schemathesis.toml configures Schemathesis. portalr's client has an api task of its own, without the tag: it starts docplan and oncall the same way, and runs the client's stores against both (portalr ADR-0010).
  • The reference implementations' image tasks build each image from lattice's root, since it installs from the root's lock. The image's own Dockerfile.dockerignore lets in only the workspace's manifests, the lock, and the packages it installs, with the README and license their metadata names. Each starts its image on its library's PostgreSQL as a compose project of its own (scripts/image), on host ports the system picks, so the two run side by side, and beside pg-up and app-up.

What a change affects

moon ci --downstream deep doesn't reach a dependent's tasks, and moon's affected projects miss changes to the root's files. So CI runs scripts/affected.py (ADR-0002):

  1. It asks moon which projects the change touches, by their files and by their tasks' inputs, which include uv.lock, the root pyproject.toml and .moon/.
  2. It adds every project that depends on one of them, all the way down, from the project graph.
  3. It runs the task, check, test, image, api, or stackr's template, smoke or smoke-app, in each of those projects that has it. bdd runs in one project after another, since each starts and stops a compose stack.

A change to evalr runs the checks of evalr and of every project above it; a change to uv.lock runs every package's. A change to stackr or to a library runs the Template and Smoke jobs, whose applications install the pull request's libraries (stackr ADR-0018).

CI

Workflow Runs Jobs
ci.yml On pull requests Check (Python 3.12): check in every affected project and its dependents, with PostgreSQL 17 and a database per library. Tests (Python 3.13 and 3.14): test, the same way. Images: image, the same way. Acceptance: api, the same way: each library's Bruno collection and Schemathesis against its reference implementation, and portalr's client against both. Template: the application template's seven variants, each generated and checked as its own CI would. Smoke: the stack, started three ways, with telemetry sent through it, and in one of them an application from the template beside it. Dependency review. OSV: the advisories a change adds to uv.lock or bun.lock. CI: passes only when every other job does
title.yml When a pull request is opened, edited, pushed to or reopened Title: the title is a Conventional Commit, and it, or the description, marks every break the pull request adds to a package's public API against its last release (ADR-0012)
nightly.yml On every push to main, every night, and by hand Every project's check, whatever changed; the images; osv-scanner over uv.lock and bun.lock; lychee over every Markdown file's links
docs.yml On every push to main, and by hand Builds the site and deploys it to GitHub Pages
release.yml On every push to main release-please; then each released package's wheel, built, attested and attached
renovate.yml Every day, and by hand Renovate
codeql.yml On pull requests, on every push to main, and every week Analyze: CodeQL over the Python and the TypeScript, with its alerts in code scanning (ADR-0011)
scorecard.yml Every week, and by hand Scorecard: OpenSSF Scorecard, published to the OpenSSF and uploaded to code scanning (ADR-0011)
  • Two checks are required, CI and Title. Branches need not be up to date with main, since the merge queue tests each batch of pull requests on top of it (ADR-0024). main's ruleset allows no deletion and no force push, requires linear history, and takes squash-only pull requests (ADR-0003).
  • Mergify queues the merges. A reviewed pull request labelled queue is tested in a batch with the others waiting, in a draft pull request, and squash-merged when the batch passes CI (ADR-0016, ADR-0024).
  • main is Nightly's. CI runs on pull requests only, and nightly.yml runs everything on each push to main.
  • Every workflow is hardened the same way: each action pinned to a commit, with its version beside it; permissions: {} at the top, and each job granted what it needs; and no checkout keeps its credentials. actionlint and zizmor check the workflows, in the hooks and in the root's workflows task.
  • Every test and every job has a timeout: a test fails after a minute, printing every thread's stack, and each job has a limit of a few times its usual length (ADR-0007).
  • Every project's tests run in a random order, through pytest-randomly in the root's dev group, and each run prints the seed that reproduces it (ADR-0010).
  • Every job that runs moon, uv or Bun installs them through .github/actions/toolchain, at the versions in .prototools: from main's cache of ~/.proto when it can, and otherwise with each download retried and proto checked against pinned digests (ADR-0017). The jobs that run checks and tests install the web packages with bun install --frozen-lockfile.

Hooks

prek runs the hooks in .pre-commit-config.yaml, and make install installs them. The Python tools run from uv.lock through uv run, and Biome from bun.lock through bun x, so the hooks run the versions CI does.

  • The hygiene hooks, leaving each CHANGELOG.md exactly as release-please writes it, and uv.lock and bun.lock out of the large-file check; JSON, shebangs and private keys, for every file.
  • Conventional Commits, at commit-msg.
  • ruff; Biome for the web packages; yamllint and shellcheck for stackr's files; actionlint and zizmor for the workflows; and pyright and tsc, through moon run :typecheck, whose cache skips what a change doesn't touch.
  • .prekignore leaves out packages/stackr/template/, whose hook configuration is the generated applications'.
  • .git-blame-ignore-revs names the setup's commit that only sorted imports, and make install points git blame at it.

Dependencies and advisories

  • Renovate keeps the dependencies current (ADR-0005), from lattice's own Actions, as lattice's GitHub App (ADR-0004). renovate.json extends config:best-practices, which pins actions by commit and maintains the lock weekly. Python ranges stay as written and the lock moves within them, so a package's floors move by hand, with the code that needs them. The web packages' versions are exact, and move with bun.lock. A release waits three days, on PyPI as on npm. PostgreSQL stays at major 17, the version local Supabase runs. Its proto manager moves .prototools, and its pre-commit manager the hooks; Bun's version, in .prototools and in package.json's packageManager, moves in one pull request.
  • Advisories. GitHub's dependency graph doesn't read uv.lock or bun.lock, so osv-scanner does, over every lockfile git doesn't ignore: on each pull request for what it adds, and every night in full. osv-scanner.toml records what doesn't apply, each with its reason and a date by which it is reviewed again, and the matching Dependabot alerts are dismissed with the same reasons (ADR-0008). Dependabot's security updates are off.

Releases

  • Versions are per package, in each pyproject.toml.
  • release-please keeps a release pull request open for each package with unreleased changes (ADR-0023's amendment). Only features, fixes, performance changes, reverts and breaking changes cut a release; a package never released starts at 0.1.0; and a breaking change bumps the minor version before 1.0 (ADR-0006).
  • Merging one releases its package: it tags <package>-v<version>, updates the package's version, in its pyproject.toml and in uv.lock, and its CHANGELOG.md, and creates the package's GitHub release. Each changelog keeps what came before lattice under "Before lattice".
  • release.yml then builds each released package that ships a wheel, alone and as it is published (uv build --no-sources), attests its provenance, and attaches the wheel and sdist to its release. stackr is tagged and released, and ships no wheel.
  • PyPI is by dispatch on each release's tag, one package a dispatch, with the maintainer's approval (ADR-0023). The first release was staged: lattice-core-ai first, then the others, the steps in its amendment. Every library has been on PyPI since 2026-10-17. A change on main before its release installs from lattice, in its package's subdirectory, such as git+https://github.com/alexnodeland/lattice#subdirectory=packages/artifactr; uv takes the package's workspace siblings from the same repository and commit. portalr's UIs are built into its wheel, not kept in git, so its wheel is built in a checkout (portalr's getting started).
  • Copier sees no template release. The tags are prefixed, and none parses as a version, so uvx copier copy gh:alexnodeland/lattice takes HEAD. The root copier.yml includes stackr's and points _subdirectory at its template.

The docs site

One site, one build, one mkdocs.yml, at lattice.alexnodeland.com.

  • Zensical builds it from docs/. lattice's pages are at its root, and each package's at docs/<package>/. The awesome-nav plugin orders each folder by its .nav.yml, so each package's section keeps its own order. Each section starts at its package's home page.
  • The site is styled in lattice's brand (ADR-0001, docs/assets/brand/), with the dark mark in the header in the dark scheme, and the site's home and each package's open with a lockup.
  • moon run lattice:docs is the build: strict, so a broken link or anchor fails it, then scripts/check_site.py, which fails on a list that rendered as text. CI runs it on a pull request that changes the site's inputs, as the root project's docs task, and docs.yml deploys it from main, one run at a time, never cancelled.
  • One build resolves the references between packages. mkdocstrings reads every package from the workspace's one environment, so no package needs another's inventory. scripts/griffe_sphinx_roles.py turns artifactr's and reflexr's Sphinx roles into cross-references; evalr's and relayr's docstrings are Markdown.
  • Pages include files instead of copying them, by their path from the root: lattice's CONTRIBUTING.md, code of conduct, security policy and license under docs/project/, each package's CHANGELOG.md and schemas, and the files stackr's reference pages describe, which stackr:reference checks the pages against.
  • Each package's README is also its PyPI page, so its links are absolute: the site's pages, and files on GitHub.
  • The old sites, at artifactr., reflexr., evalr. and stackr.alexnodeland.com, keep serving the old pages until they are retired separately (RFC-0003, D1).

The dev container

One dev container, at the root, for every package (ADR-0009). Docker runs inside it, for stackr's stack, the reference implementations and the PostgreSQL tests. It installs proto, which installs moon, uv and Bun from .prototools; Python 3.12 to 3.14 through uv; the two workspaces; and the hooks. The egress firewall, the prebuilt image and Codespaces come in phase 7.

Decisions

Every decision of lattice's own is an ADR in adr/, whose index lists them with their status, and RFC-0003's own are in its Decisions section.

What comes next

What remains of the move is RFC-0003's phases 4 to 7, which #22 tracks.