apistockdocs
Decisions

Upgrading apps and adding features to them (v0.5)

ADR-0050Accepted

Status: Accepted (2026-09-15) · Amends: ADR-0014, ADR-0016, ADR-0021, ADR-0041, ADR-0048

Context#

v0.5 promises aps upgrade (a 3-way merge on a branch), aps add orgs (single-tenant to multi-tenant) and the Custom preset. Its definition of done: an app generated with an earlier release and edited by script upgrades with no lost edits.

ADR-0016 and ADR-0021 planned this on top of per-feature recipes: a fixed set of operations (createFile, insertLine@anchor, addRequire, copyMigration, appendEnv), recorded in apistock.lock and replayed at the old and new versions to rebuild the merge base. What shipped in v0.2 to v0.4 is different, for good reasons (ADR-0041):

FindingEvidenceConsequence
A preset is one whole template tree, generated from a golden appcli/internal/recipes/{minimal,full,full-multi}, byte-for-byte golden testsThere are no per-feature recipes to replay; the tested unit is the whole app
The lock can't identify the templates that wrote a fileapistock.lock records generator: aps v0.1.0-dev and recipe version v0.1.0 (the constant LibraryVersion, never bumped); no commit, no template inputsAn upgrade can't know what the old templates were
Organisations change shared files wholesale, not at anchorsinternal/archtest/examples_test.go lists app.go, commands.go, jobs.go, modules.go, permissions.go, settings.go, seed.go among the files full-multi changes"One line at one anchor per feature" doesn't describe organisations
Multi-tenant apps replace a migration rather than add onefull-single has 20260915000002_projects.sql (owner_id); full-multi has 20260916000002_projects.sql (org_id) insteadMoving a live app to organisations can't copy full-multi's migrations: released migrations never change (ADR-0016), and CREATE TABLE projects would fail on an existing database
aps add mail rewrites tracked files but doesn't record itcli/internal/cli/add.go never touches apistock.lockAfter aps add mail, a rebuilt base would still say Resend
Nothing is releasedNo tags; milestones merged at c830679 (v0.2), 83ae970 (v0.3), 0004f6c (v0.4)"Generated with v0.2" has no release to name yet

The merge spike (spikes/merge) holds: merges keep every edit, edits on neighbouring lines conflict, and a conflicted file doesn't compile, so upgrades run on a branch.

Constraints: developer code is never overwritten; generated apps work without aps (architecture principle 8); no code from outside the CLI runs during an upgrade and nothing is written outside the project (threats 2 to 4); the golden apps stay the single source of truth.

Options#

Where the merge base comes from#

1. Embed every release's templates in aps2. Fetch the recorded release's templates3. Keep a pristine copy in the app's git repository
ShapeEach release adds its trees to the binarygo mod download apistock.dev/cli@<version>, verified by the checksum database, rendered by the current apsA branch or ref holding exactly what aps wrote, committed at aps new and every aps add
Binary sizeGrows about 0.8 MB per release, foreverUnchangedUnchanged
Works offlineYesAfter the first download (module cache)Yes
Teammates and CIWorkWorkNeed the ref fetched and pushed, which git doesn't do by default for non-branch refs; a visible branch gets deleted or rebased
TrustOur binaryOur release, checked against the checksum database; templates are rendered as text, never runWhatever is in the repository
Survives Go or gofmt changesVerified by hashesVerified by hashesExact

What a recipe is#

A. Per-feature operations (ADR-0021 as written)B. Whole preset trees merged 3-way
ShapeSplit golden apps into feature fragments and anchor edits; replay the logA tracked state is a preset tree plus its inputs; upgrading or adding a feature merges old tree → new tree into the app
Fits what shippedNo: rewrite the generator and golden testsYes: the golden trees are the recipes
OrganisationsNeeds dozens of anchors in shared filesMerge base-fullbase-full-multi
ConflictsFewer in shared filesMore in shared files a developer edited; always on a branch, never lost
Custom presetArbitrary combinations, each needing testsOnly combinations with a golden tree

Decision#

Option 2 for the base, option B for recipes.

1. The lock records what's needed to rebuild the base#

apistock.lock moves to apiVersion: apistock.dev/v2:

json
{
  "apiVersion": "apistock.dev/v2",
  "aps": { "version": "v0.5.0", "revision": "0004f6c…" },
  "inputs": { "name": "acme-api", "module": "example.com/acme-api", "preset": "full", "tenancy": "single", "mail": "resend" },
  "files": [ { "path": "internal/app/app.go", "sha256": "…" } ]
}
  • aps is the release that rendered the tracked files; revision is the commit, read from the binary's build information, for development builds.
  • inputs are every value the templates read, apart from the machine-specific --local path.
  • files holds the hash of every tracked file exactly as aps wrote it. aps add mail, aps add orgs and aps upgrade rewrite the lock; aps gen resource, aps gen job and aps gen migration stay one-shot and untracked (ADR-0021).
  • apistock.dev/v1 locks are read too: they have no version to rebuild from, so aps upgrade --from <tag or commit> names it, and the rebuild must match every recorded hash or the upgrade stops.
  • go.mod and go.sum are rendered but never hashed: aps new runs go mod tidy straight after writing them, and upgrades update them with go get (section 3).
  • Recipe names (base-full, base-full-multi) are no longer recorded: inputs.preset and inputs.tenancy select the tree.

2. Rebuilding the base#

  1. Get the recorded release's recipe trees: from the module cache or the Go module proxy (go mod download, checked by the checksum database; refused when GOSUMDB=off, or GONOSUMDB, GOPRIVATE or GOINSECURE covers apistock.dev); for a development build, git archive <revision> cli/internal/recipes from the --local checkout.
  2. Render them with the recorded inputs using the current aps renderer. Fetched code is never compiled or run; templates are data. The template format (⟦ ⟧ delimiters, the recipes.Data fields) becomes a versioned contract: fields are only added.
  3. Compare each rendered file with its lock hash. A file that differs (for example gofmt changed between Go releases) has no trustworthy base: the upgrade treats it as changed on both sides, so the developer sees both versions in conflict markers instead of losing either.

3. Merge rules#

base is the rebuilt old tree, theirs the new tree, ours the working copy.

CaseResult
ours = baseTake theirs
base = theirsKeep ours
Both changedgit merge-file with labelled markers (yours / apistock vX)
New in theirsCreated; if a file already exists at that path, conflict
Removed in theirsDeleted if ours = base, otherwise kept and reported
Deleted by the developerStays deleted; reported if theirs changed it
go.mod, go.sumNever merged: go get each apistock.dev module at the new version plus requirements the new tree added, then go mod tidy
db/migrations/*.sqlNever merged: new migration files are copied with their released names; a release that changes a released migration fails CI
api/openapi.jsonNever merged: regenerated with go run ./cmd/api openapi once the app builds
apistock.yaml, apistock.lockRewritten from the new inputs and hashes

Hashes, not timestamps, decide "unchanged", so line-ending or permission changes don't count as edits.

4. aps upgrade#

  • Needs a git repository with a clean tree; creates branch aps-upgrade/<version> from HEAD.
  • Applies the merge, then go.mod and migrations. Without conflicts: go build ./..., go vet ./..., regenerate api/openapi.json, and commit Upgrade apistock to <version>; tests are left to the developer, since they need the database, and the next steps say so.
  • With conflicts: nothing is committed; it lists each conflicted file and prints the commands to finish (resolve, go build ./..., go run ./cmd/api openapi > api/openapi.json, commit).
  • --dry-run prints the plan per file; --json for scripts and agents; releases can be skipped, because the base is rebuilt from whatever release the lock names.
  • The upgrade notes of every release between the two are printed, so manual steps are visible.

5. aps add orgs#

  • The same merge, with base = the app's base-full tree and theirs = the base-full-multi tree at the same release. An app on an older release runs aps upgrade first; aps add orgs refuses otherwise.
  • Migrations can't come from full-multi: the recipe adds two new migrations stamped at the time of the command, like aps gen migration. The first is full-multi's organisations migration. The second converts the data: a personal workspace for every existing account, projects.org_id set to the owner's workspace, owner_id becoming created_by, and indexes replaced. The app's released projects migration stays.
  • A CI test proves the result: a database migrated as single-tenant with data, then converted, has the same columns (name, type, nullability, default), constraints and indexes as a new multi-tenant app, every project sits in its owner's workspace, and the converted app's tests pass. Column order differs and isn't compared: the conversion changes projects in place rather than recreating it, so columns and data developers added survive.
  • Resources the developer generated with --scope user are left as they are and listed: user-scoped resources are valid in multi-tenant apps. Converting one to organisations is a manual step the output explains.
  • Recorded in the lock: inputs.tenancy becomes multi.

6. aps add mail records its change#

It sets inputs.mail and the hashes of the files it rewrote, so the next upgrade rebuilds an SMTP base for an SMTP app.

7. ADR-0021's operation vocabulary is dropped#

The five-operation vocabulary and the replayed operation log are replaced by rendered trees, recorded inputs and hashes. What stays: writes through os.Root, gofmt validation, idempotence, a clean tree, --dry-run and --json, anchors for aps gen resource, and no code run at install time. "One wiring file per feature" stays a style goal for the golden apps: it keeps merges small, but correctness no longer depends on it.

8. The Custom preset leaves v0.5#

With whole trees, every Custom combination is another golden app to write and test. The only real choices in a Full app today are tenancy (asked) and the email provider (aps add mail); sign-in methods are off until their keys are set (ADR-0045). A checklist would mainly remove things that are cheap to leave in and hard to add back. Custom returns only when a feature is genuinely optional and costs something to ship, with its own ADR and a golden tree per offered combination.

9. Order of work#

  1. Lock v2 in aps new and aps add mail.
  2. Base rebuild and the merge engine, with a test that upgrades a scripted-edit app across a real template change.
  3. aps upgrade.
  4. aps add orgs with the conversion migration and schema test.
  5. The rest of v0.5 (ops endpoints, maintenance mode, aps doctor, Postman collection, llms.txt) in their own ADR. Those changes touch the golden apps, so they are the first real upgrade CI exercises.

10. Definition of done, restated#

Before the first release, "generated with v0.2" means: tag v0.2.0, v0.3.0 and v0.4.0 at their milestone commits (with cli/-prefixed tags too, since the CLI is a nested module); CI generates a Full app with the CLI at v0.4.0, applies scripted edits (a changed line in a tracked file, a new resource, a new migration, aps add mail smtp), runs aps upgrade --from v0.4.0 with the v0.5 CLI, and checks that every scripted edit survives, the template changes arrive, and the app builds and passes its tests. The same app converted with aps add orgs passes the multi-tenant suite.

Why#

  • The golden apps stay the only source of truth: the tree a developer reads in this repository is both what aps new writes and what aps upgrade merges.
  • Hashes make the base provable instead of assumed; a base that can't be proven becomes a visible conflict, never a silent overwrite.
  • Fetching through the module proxy and checksum database reuses how Go developers already trust code, keeps the binary small and works for teammates and CI.
  • One merge engine serves upgrades and organisations; aps add orgs isn't a separate patcher.

Trade-offs#

  • More conflicts in shared wiring files a developer has edited than anchor edits would give; they happen on a branch with both sides shown.
  • Upgrades need git and, the first time, network access to the module proxy.
  • The template format is now a contract across releases.
  • Dropping the operation vocabulary means third-party recipes (ADR-0021's "safe to run from third parties") need a new design when community modules arrive.
  • Custom is later, not v0.5.

Consequences#

  • ADR-0016: the base is rebuilt from recorded inputs and hashes, not replayed operations.
  • ADR-0021: sections "Engine" steps 1 and 7, "Operations" and the lock requirement are superseded by sections 1, 3 and 7 here.
  • ADR-0014 and roadmap: Custom leaves v0.5.
  • ADR-0015: apistock.lock v2 and the template format are versioned public contracts; the CLI reads v1 and v2.
  • ADR-0048: aps add orgs converts data with a new migration instead of copying full-multi's.
  • Threat model: new rows for tampered fetched recipes (checksum database, templates never run) and upgrades overwriting edits (hash-proven base, branch, conflicts).
  • Every template change to a golden app needs an upgrade note; CI fails when a released migration changes.

Implementation notes#

StepStateNotes
1. Lock v2Done (2026-09-15)cli/internal/cli/lock.go. aps new writes v2; aps add mail sets inputs.mail and rehashes the tracked files it rewrote when the lock is v2, and leaves a v1 lock alone (apistock.yaml holds the provider for those apps). revision is recorded only when the binary was built from a clean tree, since a modified tree's templates aren't that commit. Reading refuses unknown fields, unknown versions, and paths that are absolute, escape the app or repeat. Tests: TestLockRoundTrip, TestReadLockV1, TestReadLockRejects, TestLockRecordOnlyTrackedFiles, TestRevisionOf, TestNewCreatesApp (every hash matches the written file), TestAddMailRecordsTheProviderInTheLock
2. Base rebuild and merge engineDone (2026-09-15)Fetching: cli/internal/cli/release.go extracts cli/internal/recipes at a tag (preferring cli/<version>) or commit with git archive from the checkout (--local, the app's replace directive, or the checkout the command runs in), writing only regular files under the templates through os.Root; refs must look like a tag or commit, never an option or range. Without a checkout, go mod download -json apistock.dev/cli@<version>, refused when GOSUMDB=off, GONOSUMDB, GOPRIVATE or GOINSECURE covers the module, or GOFLAGS has -insecure. Merge: cli/internal/merge plans each file per section 3 with git merge-file (markers yours / before / apistock <version>); an unproven base is compared 2-way, so it can only conflict, never silently take the template; migrations are created, never merged or deleted, and a release that changes a released one is refused. Tests: TestPlan (13 cases, every line the developer added survives), TestPlanMigrations, TestReleaseFromCheckout (v0.4.0 and v0.2.0 templates), TestReleaseFromCheckoutRejectsRefs, TestExtractTemplatesStaysInside, TestChecksumPolicy. Rendering (2026-09-15): recipes.Release renders a whole app in memory from any release's cli/internal/recipes directory (Embedded() or ReleaseFS), preset tree then email provider, exactly as aps new and aps add mail write it; aps new now renders the same tree and writes it. The layout and template format are unchanged since v0.2.0, so every tagged release renders with the current code. Tests: TestTreeMatchesRender, TestTreeFromDirectory, TestTreeWithSMTP, TestTreeErrors, and every lock written by aps new or aps add mail rebuilds hash for hash (assertLockRebuilds). Next: fetching a release's templates (checkout git archive, module proxy) and the 3-way merge
3. aps upgradeDone (2026-09-15)cli/internal/cli/upgrade.go. The release comes from --from, else the lock's revision, else its version; a v1 lock or a development build without a commit needs --from, and v1 inputs come from apistock.yaml. A v1 lock is proven with Resend (what aps new wrote), then the base takes the provider apistock.yaml names, so apps that ran aps add mail before v0.5 upgrade without false conflicts. The new lock hashes the new templates, not the merged files, so the next upgrade's base is what aps wrote. Up to date (no file changes and an identical lock): no branch. Conflicts: exit 1, files and next steps listed, nothing committed. Otherwise go.mod gains the new templates' missing requirements (with replace directives for a local checkout) and apistock modules move to the new library version, then go mod tidy, go build ./..., go run ./cmd/api openapi > api/openapi.json and a commit Upgrade apistock to <version>; --skip-build stops before building. Tests: TestUpgradeMergesTemplateChanges (an older release with a changed README, an extra file and a missing migration against developer edits; dirty tree refused; dry run writes nothing), TestUpgradeConflictKeepsBothSides, TestUpgradeUpToDate, TestUpgradeFromV1Lock (wrong --from refused). End to end (APS_E2E=1, TestUpgradeFromV040): a Full app created by aps built at v0.4.0, with a generated resource and migration, aps add mail --provider smtp and an edit to routes.go, upgrades in about 7 s with every edit kept, a v2 lock recording SMTP, api/openapi.json regenerated and the upgrade committed; no test that passed before fails after. Found: aps add mail --provider smtp already leaves three of the app's own tests failing on v0.4.0 (they assume Resend); tracked as a separate fix
4. aps add orgsDone (2026-09-15)cli/internal/cli/add_orgs.go, sharing the branch, go.mod, build, api/openapi.json and commit steps with aps upgrade (applyMove). Requires a v2 lock at this release, the Full preset and a clean tree; an app already multi-tenant changes nothing. Merges the single-tenant tree into the multi-tenant tree on branch aps-add-orgs (commit Add organisations), without the multi-tenant tree's own migrations; writes <now>_orgs.sql (the multi-tenant tree's organisations migration) and <now+1>_orgs_convert.sql (cli/internal/recipes/orgs/convert.sql). The conversion creates a personal workspace and owner membership for every account without one, IDs made in SQL in the exact orgs.NewID format (128 random bits from gen_random_uuid without its fixed bits, lowercase base32); a deleted account's workspace is deleted with purge_after 30 days after the account's deletion, when its projects would have been purged; then, only if projects.owner_id exists, adds org_id and created_by, backfills them, adds UNIQUE (org_id, id), and swaps the owner indexes for the organisation ones. Modules the developer generated are listed as staying user-scoped. Tests: TestAddOrgs (every non-migration file equals the multi-tenant tree; the released projects migration and generated modules stay; lock and apistock.yaml say multi; running again is a no-op), TestAddOrgsRefuses. End to end with PostgreSQL (APS_E2E=1, TestAddOrgsConvertsADatabase, 34 s): a single-tenant database with three accounts (one deleted) and three projects, after aps add orgs and migrate, has a new multi-tenant app's columns, constraints and indexes; three personal workspaces with owners; every project in its owner's workspace; the deleted account's workspace deleted with a later purge time; every ID shaped as orgs.ParseID requires; and the converted app passes its own suite with APISTOCK_REQUIRE_DB=1

Maintainer's answers (2026-09-15)#

  1. The base is rebuilt from the recorded release through the Go module proxy (section 2), not kept as a pristine copy in git or embedded in aps.
  2. Whole preset trees merged 3-way replace ADR-0021's per-feature operations (sections 3 and 7).
  3. The Custom preset moves out of v0.5 (section 8).
  4. v0.2.0, v0.3.0 and v0.4.0 are tagged at the milestone commits, with cli/ tags, and pushed (section 10).
esc
↑↓ move↵ openesc close