Skip to content

Mirrored from the repository

This page is CHANGELOG.md, rendered here. It is generated on every build, so edit the source rather than this copy — the pencil above already points there.

Changelog

Every notable change to lattice, newest first. The format is Keep a Changelog, and the versions are semantic.

Write your changes under ## [Unreleased] as you make them, not at release time — by then nobody remembers what changed, and a release with no notes is a release nobody can review. The Prepare release workflow moves that section under the new version number and stamps it with the date, leaving [Unreleased] empty for the next change, and opens a pull request for you to review. A release whose [Unreleased] section is empty is refused before any of that happens.

Unreleased

1.4.0 - 2026-07-26

Added

  • Maven and Python projects install themselves too. Only the npm stacks ever did. A scaffolded spring-boot or fastapi printed its install commands and left the user to run them, which meant the promise the whole tool is built on — the project you land in boots — held for JavaScript and was a to-do list everywhere else. ./mvnw -DskipTests test-compile and a project-local .venv now run at scaffold time, so ./mvnw spring-boot:run and uvicorn app.main:app --reload work immediately. Verified end to end: a scaffolded FastAPI project's own suite passes from its venv without a single manual step.
  • A runtime check that reads the project rather than a constant. lattice installs dependencies; it does not install runtimes, and src/toolchain.js is what lets it tell the difference. It detects the installed JDK or Python, compares it against the floor the template itself declares — <java.version> in the pom, requires-python in the pyproject — and if the runtime is missing or too old it says so, naming both versions, and leaves the project complete. Downloading a JDK would change the machine rather than the directory, and would need a system package manager lattice would have to guess at.

  • A styling choice for the React templates — --styling plain|scss|bootstrap|tailwind. The frontends shipped one hand-written stylesheet and no way to ask for anything else, so wanting Tailwind meant installing it, wiring the build and rewriting the components — most of the work scaffolding exists to save. Styling is now to a frontend what storage is to a backend: the template carries every variant and the scaffold keeps exactly one. What makes them interchangeable is a class-name contract (.app__header, .app__nav, .table, .pager, .muted, .error) that the components are written against and every variant implements, so the choice changes the stylesheet and nothing else. Plain and SCSS compile to byte-identical CSS; Bootstrap and Tailwind are themed to match rather than left at their defaults. Only Tailwind touches vite.config.js, and Sass and Tailwind land in devDependencies — they are compile-time tools, not runtime ones.

  • A documentation site, at wolfi-owo.github.io/lattice. package.json had pointed homepage at that URL for some time and nothing was served from it. Content lives in documentation/, one folder per section, and the navigation is generated from the folder tree rather than listed in a config — so adding a page is adding a markdown file and adding a section is adding a directory. Every page carries an edit link that opens it in GitHub's editor, which makes a typo fix a two-minute pull request instead of a clone.

The long-form documents stay at the repository root where they are actually read — in a diff, in a pull request, in an editor — and the site borrows them at build time (scripts/docs-mirror.js). Their links are rewritten to resolve on the site and their edit links point back at the real file rather than at the generated copy.

MkDocs is not an npm dependency and will not become one: ADR-0001 covers dev dependencies too, and a docs toolchain in package.json would be that rule broken with a different label on it. It is pinned in mkdocs-requirements.txt and installed only inside the CI job. tests/docs.test.js checks the wiring — that the workflow rebuilds for every file the site mirrors, that no navigation entry names a page that does not exist, that a pull request builds but never deploys — none of which needs MkDocs installed to run.

Changed

  • Python installs into .venv, and an existing one is reused. The old refusal reasoned that Python has no one obvious package manager — pip, pipx, poetry, uv and conda are all normal — and that is still right about the machine. It was never right about .venv: a virtual environment inside the project is a directory in the project, made with the standard library's own venv, deleted by deleting the project. A .venv that is already there is reused rather than rebuilt.
  • The next steps now describe the project that exists. A template can declare two sequences and nextSteps() picks between them, so a project whose venv lattice just built is not told to build one. Both the CLI and scripts/print-next-steps.js call that one function — CI runs exactly what the user is shown, and a second copy of the rule could not have been kept honest.
  • Android is deliberately still not auto-installed. Its build needs the Android SDK and accepted licences, and a ./gradlew that fails on a missing SDK is a worse first impression than one that was never run.

1.3.0 - 2026-07-24

Added

  • Every backend template now ships the products domain, not just Express. 1.2.0 claimed the repository seam was domain-agnostic and proved it across six storage adapters — but only inside one template. Fastify, Spring Boot and FastAPI still shipped a single domain each, so the claim held for one language. All four now carry products: money as integer cents, stock moved by a signed delta rather than set to an absolute, and SKU as a natural key so seeding is idempotent. The three latent bugs below are what porting it found; none were reachable before, because the code that exposes them did not exist.
  • src/models/ in the Fastify template, matching the Express layout: one flat file per domain, describing the record in storage-neutral terms, with each dialect's DDL derived from it rather than written out six times.
  • requireRole in the Fastify template. It shipped requireAuth alone, so a route could ask whether a caller was authenticated but not whether they were allowed — every admin-only operation was open to any signed-in user, and the template had no way to express otherwise.
  • The Android template builds. It was the last template whose correctness rested on review rather than execution, because Gradle's wrapper has no script-only form and the no-binaries rule forbade committing gradle-wrapper.jar — so the template shipped a build definition nobody could run. The wrapper now ships under a single documented exception, and a CI job builds the debug APK on a runner with no Gradle installed, which is the path a user cloning the generated project takes. The jar is not trusted on faith: its SHA-256 is pinned in the unit suite, and CI runs gradle/actions/wrapper-validation, which fails unless it is a byte-identical published Gradle wrapper.

Fixed

  • Fastify accepted writes it had been told to reject, and reported success. Fastify defaults ajv to removeAdditional: true, which quietly turns additionalProperties: false from "refuse these" into "delete these". A caller PATCHing a read-only field was told the write succeeded while the value was dropped on the floor — the request returned 200 where it should have returned 400, and no log recorded that anything had been discarded. Now removeAdditional: false.
  • Spring Boot answered every authorization denial with 500. @PreAuthorize throws inside the dispatcher, past the filter chain, so AccessDeniedException reached @RestControllerAdvice as an unhandled exception rather than being turned into a response by ExceptionTranslationFilter. A forbidden request looked like a server crash to any client, and to any log-based alert.
  • Spring Boot answered unauthenticated requests with 403 instead of 401. Without an explicit authenticationEntryPoint the container's default page shape won, so a caller who had sent no credentials was told their credentials were insufficient — the one response that tells a client not to bother retrying with a token.
  • The scaffolder copied build output into new projects. copyTemplate walked the template tree indiscriminately, so anything a local build had left behind — target/, build/, node_modules/, __pycache__/, .gradle/ — was copied into the generated project. A stale class file from a previous build was enough to make spring-boot:repackage fail with an IllegalArgumentException in a project the user had just created and never built. Those directories are now skipped by name.
  • The Android template was missing a dependency its own source needed. UsersScreen imports androidx.lifecycle.compose.collectAsStateWithLifecycle, which lives in lifecycle-runtime-compose — a dependency the build never declared. The screen could not compile, and every other error in the build cascaded from that one unresolved import. It went unnoticed for exactly one reason: without a wrapper the template had never once been built.

Changed

  • The Express products domain has tests. It shipped in 1.2.0 with none — the suite people saw pass covered health and users, so the domain added to prove the storage seam was itself unverified. 26 tests now cover it, including the cases that differ per adapter: SKU case-insensitivity, a refused stock movement leaving the row untouched, PATCH refusing to set stock directly, and a float price being rejected.
  • Test counts across the templates, as a result: Express 5 → 32, Fastify 0 → 28, Spring Boot 3 → 25, FastAPI 4 → 29.

Documentation

  • STRUCTURE.md carried a "no binaries in the repository" rule with no exceptions and a verification section written before any of the above existed — it still described Android as unbuildable and understated every test count. Both are now accurate, and the one committed binary is documented as an exception with its justification and its digest rather than left to be discovered.

1.2.0 - 2026-07-22

Added

  • A products domain, in every storage adapter. Until now every template shipped exactly one domain, so the claim that the repository seam is domain-agnostic had never been tested by anything. Products tests it across all six adapters.
  • A generated project whose layout would silently not work is now rejected. The gate runs on the staged tree, as the last thing before the transaction commits, so a violation rolls the whole generation back instead of leaving a rejected project on disk. Every rule describes a placement that is silently wrong: a .java outside src/main/java is not on Maven's source path, so the class does not exist at runtime and no compiler error points at it; a test under src/main is packaged into the shipped artifact along with its test-only dependencies. Anything that merely offends taste stays in lattice doctor, which scores rather than refuses.
  • The Java templates ship a Maven wrapper. A scaffolded Spring Boot project had no way to build: its README and the CLI both say mvn spring-boot:run, and the template shipped no wrapper, so the project only ran for someone who already had a compatible Maven installed. CI never saw it, because actions/setup-java puts Maven on PATH. Both Java templates now ship mvnw, mvnw.cmd and the wrapper properties — Maven's script-only distribution has no maven-wrapper.jar, so no binary is committed.

Fixed

  • A project is only installed if its storage driver actually works. --database sqlite produced a project that died on its first command with Error: Could not locate the bindings file. better-sqlite3 compiles a native binding during install, and when lattice spawned npm that install script was silently skipped: npm exited 0, the package was present, lattice printed "Installed dependencies", and nothing worked. Deterministic, three runs out of three.

Documentation

  • Rule 5 said "adding a domain is adding a file". Shipping the products domain proved that false — fifteen files, of which one was the seed data. The rest was a repository in all six adapters, the DOMAINS registration, and four API files. The scope is now explicit and the real number is written down.
  • Where models and data-transfer objects live is now stated rather than implied.

[1.0.1] - 2026-07-19

Changed

  • Renumbered from 0.0.1. The code is identical; only the version differs. 0.0.1 is published and correct, but npm will not apply the latest tag to a version below one already published, and 1.0.0 cannot be removed — it is outside npm's 72-hour unpublish window and above its 300-downloads-a-week exemption. Moving the tag afterwards is not available either: trusted publishing mints a credential scoped to publishing, and a dist-tag change is refused with E401.
  • 1.1.0, 2.0.0 and 2.1.0 were unpublished earlier and npm retires those numbers permanently, so the first free version above 1.0.0 is 1.0.1. Numbering above the old release is what lets latest apply on its own — no tag juggling, and nothing left pointing at pre-restart code.

0.0.1 - 2026-07-19

Added

  • Generation is all-or-nothing. Every file a scaffold writes goes to a staging directory first, and the project appears at its real path only once generation has fully succeeded. A failure — unreadable template, full disk, Ctrl-C — leaves the working directory exactly as it was. Previously a failure partway through left a half-written project that then blocked its own retry, because isEmptyDir correctly saw a non-empty directory and demanded --force.

Installing dependencies and starting the database stay outside the transaction: both are recoverable by design, and rolling back a valid project because a registry blipped would destroy work that is entirely fine.

Fixed

  • The Python templates told you to activate a virtualenv they never created. lattice my-app --stack ml-project printed source .venv/bin/activate into a directory with no .venv, because lattice deliberately does not auto-install for Python. Both Python templates now print the two lines that actually create it — the same ones their READMEs always had.
  • better-sqlite3 could not install on current Node. The pinned ^11 ships no prebuilt binary for Node 24's ABI, so --database sqlite fell back to a node-gyp source build and failed on any machine without a C++ toolchain. Now ^12, which supports through Node 26.
  • node-cli never linted. Its ESLint config declared ecmaVersion: 2023, which cannot parse the import attributes its own entrypoint uses; every run was a parse error rather than a lint result.
  • setTimeout was undefined to ESLint in the Express and Fastify templates, so the graceful-shutdown path — the code that matters most at deploy time — was a no-undef error nobody saw.
  • The one line that keeps password hashes out of responses was itself a lint error. const { passwordHash, ...safe } = user is CONVENTIONS.md rule 3; the omitted key is unused on purpose, which is what ignoreRestSiblings is for.
  • baseUrl in the TypeScript template, deprecated in TypeScript 6 and removed in 7, and unnecessary since TypeScript 5. The template now uses create-vite's current shape — a solution tsconfig.json referencing tsconfig.app.json and tsconfig.node.json — which also typechecks vite.config.ts for the first time.
  • Neither React template shipped public/, and index.html linked no icon, so every scaffolded app 404'd on its favicon.
  • 23 files across five templates were unformatted by their own Prettier config.
  • GitHub Packages published releases that npm had refused. The job depended on verify rather than publish, so it inherited none of the checks guarding the npm publish — not the tag/package.json agreement, not the human approval. It now depends on publish, and skips rather than fails when a version is already present.

Changed

  • CI runs every check a template declares, not one chosen for it. Every JavaScript template declared a lint script and none had ever been linted; typecheck and prettier --check had never run at all. Checks are now derived from each template's own package.json, and a script that cannot be a pass/fail check must be listed as exempt with a reason — a test fails on one that is neither. Fifteen checks now run where five did.
  • The Python workflow runs the steps the CLI actually prints, read from the registry rather than retyped. It claimed to do this before and did not, which is why it passed while the printed steps were broken.
  • A native job covers Node 22 and 24. better-sqlite3 is the only dependency with a compiled binary, and the storage matrix pins Node 20, so its ABI was only ever exercised against one runtime.
  • mongoose to ^9, verified against a real MongoDB container.
  • React 19, Vite 8, TypeScript 6, React Router 7. The frontend templates were a full generation behind create-vite on every axis — not broken, which is why it needed a deliberate change rather than waiting for a failure. No template source had to change: the components, router, hooks and API client were already written against APIs that survived all four majors.

TypeScript 6 rather than 7, because create-vite pins ~6 and the official generator is what decides here. ESLint stays on 9: eslint-plugin-react caps its peer range at ^9.7, so 10 cannot install alongside it, and a template that cannot npm install is worse than one a major behind.

Added

  • .github/workflows/template-drift.yml — runs the official generators weekly and reports what the built-in templates are missing or behind on. Both frontend bugs above were exactly this drift, found by a person scaffolding a project rather than by CI. It reports rather than merges: some of the difference is deliberate, and a job that adopted upstream's output wholesale would delete the router, API client and layout conventions that make these templates worth having.

Added

  • --generator <id> — scaffold with a framework's own official tool instead of a built-in stack. 28 of them: every create-vite template, Next, Nuxt, SvelteKit, Astro, React Router, Vue, Expo, Angular, three .NET project types, Cargo, Go, Laravel, Rails, Dart, Flutter and Swift. Opt-in on purpose — it needs the network, it needs that toolchain installed, and its output is whatever upstream ships today rather than something lattice verified. The built-in stacks remain the default and the thing that is promised to boot. --list shows them; ADR-0003 explains the tiers.
  • --enterprise — overlay the scaffolding a repository needs once more than one person works on it: docs/adr/, todo/, organizational/, community-health files, a Trivy security scan, a release workflow, and CI. The project's own README is kept and composed under a badge header rather than replaced, and a directory table is generated from the tree — including a column for what must never go in each directory, which is the half that stops a layout from rotting.
  • CI matched to the project's build tool. --enterprise detects what a project is built with — package.json, go.mod, pom.xml, Cargo.toml, build.gradle, pyproject.toml, composer.json, Gemfile, Package.swift, pubspec.yaml or a .csproj — and writes the matching ci.yml and dependabot.yml. Eleven toolchains. A Go module gets go test, a Maven project mvn -B verify, a Rails app bin/rails test. A project whose build tool is unrecognised gets everything except the CI, which is better than a workflow that cannot pass.
  • --owner <name> — the GitHub owner or organisation that fills the badge and link slots in the enterprise overlay.
  • .github/workflows/generators.yml — scaffolds all 28 delegations, installs and builds each, and asserts every one received the CI of its own build tool. The argv for these are claims about other people's CLIs, and nothing but running them can tell you when one goes stale. It earned this on its first run: Remix v2 had been upstreamed into React Router and create-remix no longer created anything.

Changed

  • Releasing is now two steps, and nothing writes to main during one. The version bump and changelog cut happen in a reviewed pull request opened by the new Prepare release workflow; publishing then only verifies and publishes, with contents: read. Previously the version commit landed after npm publish, so a failure in that final step would leave a version on a registry that never forgets and no record of it here. npm publish also now waits for a human approval on the production environment. See Releasing in the README.

  • lattice doctor [path] — score a project's structure and hygiene, offline. It grades a tree 0–100 across structure, hygiene, testing, CI/CD, containerization, observability and security, and lists what to fix most-load-bearing-first with the why and the fix. Deterministic and local by design — no network, no model — so it stays a scaffolder, not a service. The checks encode the bar these repositories already hold themselves to. --strict exits non-zero below 60 so it can gate CI.

  • A leveled logger (src/logger.js) modeled on the Winston format used across the author's services: level from LATTICE_LOG_LEVEL / LOG_LEVEL, colourized timestamp level: message, stacks on errors. Diagnostics go to stderr (so --verbose never contaminates stdout); the user-facing progress stays as it was.
  • --verbose turns on debug diagnostics for a run.
  • Project hygiene to match the rest of the author's repositoriesARCHITECTURE.md, CONTRIBUTING.md, SECURITY.md, .editorconfig, .github/CODEOWNERS, and docs/adr/ with the first two decision records (zero-dependency core; why lattice is a library, not an enterprise-layered app). lattice doctor scores itself 100 now — it did not before, and it said so.

Added

  • Fastify backend template. The same six storage adapters and the same repository seam as Express, so --stack fastify --database postgres gets you a schema-first API whose generated suite is green on all six.
  • database/ demo data in every backend. One file per domain, an idempotent loader that goes through the repository rather than a driver, and a database:seed script that means the same thing in every language.
  • CONVENTIONS.md — the rules every template obeys, and the reason the templates read like one product instead of twenty personal styles.
  • The conventions are tests now, not prose. tests/registry.test.js asserts the user shape, the demo-data layout, the frontend dependency rules and the storage spelling, so a template that drifts fails the build instead of being noticed a year later.

Changed

  • The storage seam is spelled database everywheresrc/database/, not src/db/. One word for one concept, in every language and every template.
  • The health probes are spelled out: /api/health/liveness and /api/health/readiness, replacing /live and /ready. Rule 1 applies to URLs too, and Spring Boot's Actuator already served the long names — the other three backends were the ones out of step.
  • Fastify drains through terminus, like Express, instead of a hand-rolled process.once('SIGTERM') handler that flipped a flag a route read. Readiness now lives on the http.Server underneath Fastify, which is the only place it can answer 503 from the instant a signal lands while the app above goes on serving the requests already in flight.
  • Every backend now drains the same way, in every language. Terminus was only ever the JavaScript spelling of it; the behaviour is the contract:
Mechanism
Express, Fastify @godaddy/terminus — readiness on the http.Server, below the framework
Spring Boot ReadinessDrainLifecycle, a SmartLifecycle that stops before the web server does
FastAPI app/core/lifecycle.py, which intercepts uvicorn's signal handler

server.shutdown: graceful alone was not enough for Spring, which is worth writing down because the name suggests it is: it stops accepting new connections immediately, so readiness does not go to 503 — it becomes unreachable, and a load balancer that is still routing to this instance gets connections refused rather than drained. The new lifecycle bean buys the window back.

All three were drained under a real SIGTERM against a real booted server, not asserted: readiness flips to 503 while liveness stays 200, in-flight requests finish, then the process exits. - CI scaffolds real projects and runs their suites, across six storages and two backends. A scaffolder cannot be tested by testing the scaffolder.

Fixed

  • Two Postgres races that only a parallel test runner could find. node:test runs test files in a process each, so two processes booted the app against a fresh database at once, and both CREATE DATABASE and CREATE TABLE IF NOT EXISTS turned out to be check-then-act races — the second one despite its name. The losers died on pg_database_datname_index and pg_type_typname_nsp_index, neither of which mentions the thing that actually collided. Creating the database now tries and forgives, and the schema is created under an advisory lock, which are the only forms of each that are atomic. It failed roughly one CI run in two; it now survives a fresh database six times out of six. Fixed in Express as well as Fastify: Mocha's sequential files hid it there, but the adapter is what has to be right, not the runner that happens to hide it.
  • The Spring Boot template could not start. Flyway 10 (which Spring Boot 3.3 manages) moved per-database support out of flyway-core into separate modules, so with only flyway-core on the classpath it connects, throws Unsupported Database: PostgreSQL, and dies during context initialisation. mvn spring-boot:run — the command the CLI prints as your next step — crashed, while mvn test passed, because the test profile runs H2 with flyway.enabled=false and never executes a migration at all. A green suite on top of an application that does not boot is the exact failure this project exists to prevent, so CI now boots the template against a real PostgreSQL and probes it, rather than trusting the tests.
  • SQLite could not be opened by two processes at once. busy_timeout was missing, and adding it was necessary but not sufficient: PRAGMA journal_mode = WAL needs an exclusive lock and SQLite does not run the busy handler for it, so it fails instantly regardless. It is set once now, by whoever gets there first.
  • A failed before hook in the Fastify template reported Cannot read properties of undefined (reading 'close') from its teardown, burying the error that actually caused it.

  • The CLI told you to run docker compose up -d db, but the service it generates is named database — the command it printed errored out. It also pointed at src/db/ after that directory had been renamed. Both were user-facing, and both shipped green, because the test guarding the spelling only looked at the templates and never at the scaffolder itself. It looks at both now.