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)¶
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.tomlrecords that. - Each
moon.ymlstates itsdependsOn, since moon infers no edge from a requirement with a range.tests/test_moon.pyholds it to the siblings the project'spyproject.tomlrequires, and checks that each comes from the workspace; for a web package, to the workspace packages itspackage.jsondepends 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:
buildrunsuv build --no-sources --out-dir distfrom the package's directory, so the wheel and sdist are built as they are published, into the project's owndist/. It runs after its dependencies'build, so a package's siblings are built however far down they are.standalonedepends on the package'sbuild.scripts/standalone.pyinstalls 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.deptryruns oversrc/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 |
checkis one task per project that runs nothing itself and depends on the project's checks, so the list lives in one place.make checkrunsmoon run :check, and CI and Nightly run the same tasks.- artifactr's and reflexr's contributor tasks,
schema,dashboards,pg-up,pg-down,app-upandtest-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,smokeandsmoke-app. - artifactr's and reflexr's
apitasks, which theapitag gives them, are their acceptance tests (ADR-0015). Each starts its reference implementation from the package'scompose.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'sschemathesis.tomlconfigures Schemathesis. portalr'sclienthas anapitask 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'
imagetasks build each image from lattice's root, since it installs from the root's lock. The image's ownDockerfile.dockerignorelets 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 besidepg-upandapp-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):
- It asks moon which projects the change touches, by their files and by their tasks' inputs, which include
uv.lock, the rootpyproject.tomland.moon/. - It adds every project that depends on one of them, all the way down, from the project graph.
- It runs the task,
check,test,image,api, or stackr'stemplate,smokeorsmoke-app, in each of those projects that has it.bddruns 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,
CIandTitle. Branches need not be up to date withmain, 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
queueis tested in a batch with the others waiting, in a draft pull request, and squash-merged when the batch passesCI(ADR-0016, ADR-0024). mainis Nightly's. CI runs on pull requests only, andnightly.ymlruns everything on each push tomain.- 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'sworkflowstask. - 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
devgroup, 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~/.protowhen 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 withbun 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.mdexactly as release-please writes it, anduv.lockandbun.lockout 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, throughmoon run :typecheck, whose cache skips what a change doesn't touch. .prekignoreleaves outpackages/stackr/template/, whose hook configuration is the generated applications'..git-blame-ignore-revsnames the setup's commit that only sorted imports, andmake installpointsgit blameat it.
Dependencies and advisories¶
- Renovate keeps the dependencies current (ADR-0005), from lattice's own Actions, as lattice's GitHub App (ADR-0004).
renovate.jsonextendsconfig: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 withbun.lock. A release waits three days, on PyPI as on npm. PostgreSQL stays at major 17, the version local Supabase runs. Itsprotomanager moves.prototools, and itspre-commitmanager the hooks; Bun's version, in.prototoolsand inpackage.json'spackageManager, moves in one pull request. - Advisories. GitHub's dependency graph doesn't read
uv.lockorbun.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.tomlrecords 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 itspyproject.tomland inuv.lock, and itsCHANGELOG.md, and creates the package's GitHub release. Each changelog keeps what came before lattice under "Before lattice". release.ymlthen 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-aifirst, then the others, the steps in its amendment. Every library has been on PyPI since 2026-10-17. A change onmainbefore its release installs from lattice, in its package's subdirectory, such asgit+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/latticetakesHEAD. The rootcopier.ymlincludes stackr's and points_subdirectoryat 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 atdocs/<package>/. Theawesome-navplugin 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:docsis the build: strict, so a broken link or anchor fails it, thenscripts/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'sdocstask, anddocs.ymldeploys it frommain, 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.pyturns 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 underdocs/project/, each package'sCHANGELOG.mdand schemas, and the files stackr's reference pages describe, whichstackr:referencechecks 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.andstackr.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.