AGENTS.md
Agent/developer operating rules for the start-technologies monorepo root. This repo is the monorepo for all Start9 products. CLAUDE.md is a one-line @AGENTS.md import — do not edit it.
See ARCHITECTURE.md for the layout and CONTRIBUTING.md for the build/test/format workflow.
Keep these docs current. Every scope carries AGENTS.md / ARCHITECTURE.md / README.md (and CLAUDE.md, a one-line @AGENTS.md import). When a change alters structure, conventions, the build/test/release flow, or product context, update the matching doc(s) in the same change — never defer. These docs are hierarchical: each scope's docs cover only what is specific to it and must not repeat anything already stated at a higher scope (e.g. commit/PR conventions live only in this root CONTRIBUTING.md).
Anything an agent must follow belongs in AGENTS.md, not behind a link from it. The AGENTS.md standard is plain Markdown with no import syntax — unlike CLAUDE.md, whose @file imports expand into context at launch. So a pointer in AGENTS.md ("see X for the release process") is just a suggestion that the agent spend a tool call, and agents routinely don't: that is exactly how start-sdk 2.0.4 and 2.0.5 shipped untagged. Inline the rule; link only to reference material an agent can safely skip (ARCHITECTURE.md, a product book). CLAUDE.md stays a one-line @AGENTS.md import — Claude Code does not read AGENTS.md natively.
So CONTRIBUTING.md is being folded into AGENTS.md — in every scope but the root. A sub-scope CONTRIBUTING.md earns nothing (GitHub gives it no special treatment) and costs an agent a hop, so its contents belong in that scope's AGENTS.md. When you next do substantial work in a scope that hasn't migrated, fold its CONTRIBUTING.md into its AGENTS.md, delete it, and repoint every inbound link in the same change — don't leave dangling cross-references, and don't migrate scopes you aren't otherwise touching. The root CONTRIBUTING.md stays: GitHub surfaces it (the contributing prompt on new issues/PRs, the community profile), so it remains the human-facing front door — the social layer, and a pointer to AGENTS.md for the mechanics. That pointer runs one way; a human will follow a link, an agent won't.
A product's user docs and changelog ship with the code. Any change that alters user-visible behavior must update that product's user-facing documentation book (its docs/ directory — e.g. projects/start-os/docs/, projects/start-tunnel/docs/, projects/start-sdk/docs/) in the same change, and must add a CHANGELOG.md entry for that product (a version bump always pairs with its changelog). Don't land code and defer its docs or changelog to a follow-up. The conventions for authoring any of those books — mdBook versions, admonitions, tabs, SUMMARY.md, the shared theme/ — live in projects/start-docs/AGENTS.md and its CONTRIBUTING.md. That project is a sibling, not an ancestor, so nothing loads it for you: read it before editing book pages anywhere in the repo.
The changelog's top heading is the prospective next version, and git tags decide released-vs-unreleased. Before deciding where a changelog entry goes, freshly pull tags from origin first — run git fetch --tags origin (or query origin live: git ls-remote --tags origin '<product>/v*', gh release list), every time; never trust stale local tags, and never infer release state from the changelog file, a ## [x.y.z] heading, or a manifest constant. Those origin tags (<product>/v<version>) are the only source of truth for what has shipped. Keep the top CHANGELOG.md heading set to the actual prospective next version (e.g. ## [1.1.1]) matching the product manifest — not a bare ## [Unreleased] — because the number itself signals the tier of change accumulated (patch/minor/major). When that top version has no matching origin tag it is unreleased: add your entry under it (in the right ### Added/### Changed/### Fixed/### Security subsection), and raise both the heading and the manifest a tier only if your change warrants it (a fix leaves an accumulating 1.1.1 alone; a breaking change bumps it to 2.0.0). If your change fixes or refines a feature that was added in that same still-unreleased version, edit that feature's existing entry (only where its wording needs it) rather than adding a separate ### Fixed line — to the user the feature simply ships correct, so there is no fix to a thing they never received. Only cut a new heading — and bump the manifest — once the current top heading is a cut origin tag (that line is fully released). The release tooling turns that prospective heading into the shipped one when it cuts the tag.
An entry says what changed; the commit says why. It is read by someone deciding whether an upgrade affects them and wanting a high-level sense of what the release did — so an entry runs as long as it takes to convey that, and stops there. A breaking change that needs a migration instruction gets one; a fix nobody could observe does not need its mechanism explained. What never belongs is the material that explains the change to whoever reviewed it: the root cause, the implementation detail, the alternatives you rejected, the story of how you found it. That goes in the commit message and the PR body, where it stays attached to the diff.
Read CONTRIBUTING.md before making any code changes. It carries the build/test/format workflow and the commit/PR conventions every change must follow — read it first, before you touch code. This is hierarchical like AGENTS.md: if a subdirectory you touch carries its own CONTRIBUTING.md, read that one too — and any further nested CONTRIBUTING.md on the way down to the files you're editing — before changing anything there.
Read down into what you touch. When you work in a subdirectory, first read its AGENTS.md — and any further nested AGENTS.md on the way down to the files you're editing — before changing anything. Each scope's docs assume you've read the scopes above it, so a subdir's AGENTS.md adds only its own rules on top of this root.
Layout
Each product lives under projects/ as a thin wrapper; the bulk of the code lives in the top-level shared libs (shared-libs/).
projects/start-os/— OS product. Rust binsstartbox+start-container(src/bin/*.rs),web/(Angular UI + setup-wizard),container-runtime/(Node LXC service runtime), bin build scripts + OS image build (build/), Debian control (debian/), VM-setupassets/,backup-fs/,docs/,*.service.projects/start-cli/—start-clibin (src/main.rs); thin wrapper overstart-core.projects/start-registry/—registryboxbin; registry server, serves the shared marketplace UI lib.projects/start-tunnel/—tunnelboxbin +web/(StartTunnel UI).projects/start-wrt/— StartWRT, an OpenWrt-based router OS. Rust backend (startwrtbin: RPC daemon + CLI, cratesctrl/uciedit/uciedit_macros) building on sharedstart-core; an Angularweb/UI (a project in the root Angular workspace) embedded into the binary; a build-managedopenwrt/workspace (pinned upstream OpenWrt tarball + the Start9 delta inopenwrt-patches/+openwrt-overlay/); flashable image for the SpaceMiT K1.projects/start-sdk/—@start9labs/start-sdk(source inlib/; imports the shared@start9labs/start-corelib and bundles it into its publisheddist/) +Makefile/s9pk.mk+docs/(packaging mdbook).projects/brochure-marketplace/— public marketplace/landing Angular app (deploys to marketplace.start9.com).projects/start-docs/— the documentation website (build infra + landing + Bitcoin guides; each product's own book lives in itsdocs/).shared-libs/crates/start-core/— the entire Rust backend lib (packagestart-core, lib namestart_core). All six bins depend on it.shared-libs/ts-modules/— shared TypeScript modules (the common thread is just that they are TS — not Angular-specific). These are the Angular libsshared/(@start9labs/shared) andmarketplace/(@start9labs/marketplace), plus the non-Angularstart-core/(@start9labs/start-core: SDK core types/ABI/effects/OS bindings, the TS projection of thestart-corecrate, consumed by web and bundled into the SDK; versionless, not published separately). The Angular workspace is rooted at the repo root (angular.json/package.json). Product apps reference the libs by package name.- Top level also holds the shared build infra (
build/,Makefile),apt/, the shareddebian/build.sh,scripts/(maintainer release tooling —manage-release.sh <subcommand> <project>drives a product through pre-check/tag/release/sign/publish;deploy-migration-payload.shpublishes a 0.3.5.1 → 0.4.0 migration OTA payload to a legacy registry),rfcs/(protocol drafts), andshared-libs/crates/patch-db/(first-party crate, consumed bystart-coreand web).
Build & test (run from the repo root)
- Use
makerecipes when they exist rather than re-deriving the underlying commands. The rootMakefileis a thin orchestrator thatincludesbuild/common.mk(shared vars/macros) and one<project>/build.mkper product (projects/<name>/build.mk,shared-libs/*/build.mk) — run everything from the repo root (make start-os,make start-registry, etc.); a product's targets live in itsbuild.mk. There is no default target — baremakeprintshelp. - Build a single product with
cargo build -p <crate> --bin <bin>(bins:startbox/start-containerin packagestart-os;start-cli;registryboxinstart-registry;tunnelboxinstart-tunnel;startwrtin packagestartwrt-coreforstart-wrt). - Tests:
make test(all),make start-core-test/make start-sdk-test/make backup-fs-test(all backup-fs library tests except the mount-based/dev/fusesuite) /make container-runtime-test(scoped). A single Rust test:cd shared-libs/crates/start-core && cargo test <test_name> --features=test. - Format:
make format(rustfmt in a pinned-nightly container + prettier + taplo, both native); CI runsmake format-check. See CONTRIBUTING.md for the full build/test/format workflow.
Branches and channels
-
masteris always shippable, and every push to it publishes. Only merge work that is finished and tested: a master push indexes StartOS images into the alpha registry, publishes.debs into thealphaapt suite, and — once theSTARTWRT_ALPHA_REGISTRYrepo variable is set — StartWRT images into its alpha registry. There is no staging step between merging and publishing. The SDK is the deliberate exception: an npm version can never be republished, so it publishes only on release. -
Work that can't land piecemeal goes on
integration/<topic>. Land the pieces there as ordinary PRs, soak the branch as a unit, and delete it on merge. The PR merging it into master is the last gate before it ships, so CI builds the full flashable image matrix on that PR specifically — the bypass lives instartos-iso.yaml'schangesjob and start-wrt'simagejob, both keyed onhead_refstarting withintegration/andbase_refbeingmaster. -
Re-indexing a version into a registry requires removing it first.
registry os asset addupserts the platform slot but rejects a write whose blake3 differs from the commitment already there (registry/os/asset/add.rs,registry.os.asset.commitment-mismatch) — so publishing a second build under a version number that already holds one fails, even thoughregistry os version additself is a harmless upsert. Alpha rolls the same version on every master push, so both deploy jobs runregistry os version removefirst. Anything that re-publishes a version in place must do the same. -
Alpha is the source a release is promoted from, so a master push must build release-equivalent artifacts.
os promotecopies each asset's URL and signed commitment through verbatim (registry/os/promote.rs), so the bytes a master push uploads are the bytes production eventually serves — from the same S3 key. Two consequences:ENVIRONMENTis empty on master (only PRs andintegration/*default todev), and alpha images must not be given a separate S3 prefix, or promoted production assets would point inside it. It also means an S3 key, once published, is immutable: both deploy jobs refuse to overwrite an object that already exists and fail listing every conflict, since replacing those bytes would leave an already-promoted signature validating against content that no longer matches. Re-running a deploy for a commit that was published before therefore needs a dispatch withforce-overwrite. -
The debs promote out of
alphatoo.release start-cli/start-tunnel/start-registrystage from thealphasuite (pull-alpha), not from a CI run, so the packages testers have been running are the ones that reachstable. The whole chain is verified against the suite's signature:InReleaseagainstapt/start9-alpha.gpg,Packagesagainst the hash the signed Release commits to, and each.debagainst the hash that index commits to. Nothing about the promotion trusts plain HTTPS — the pool key is stable across builds, so an unverified download could be swapped by a republish mid-promotion. It also fetches from the S3 origin, not the*.cdn.*host inapt/*.list, and sends no-cache headers: a cachedInReleaseis still a validly signed one, so every check would pass while promoting whatever build the edge was holding. Signatures prove authenticity, not freshness — don't align those two URLs. That leaves replayed signed metadata theoretically promotable, and the answer to that is not aValid-Untilon the suite: expiring a rolling channel on a timer unrelated to release cadence breaksapt updatefor everyone during any quiet period. What actually guards it is that the operator is shown the commit being tagged, where a replay appears as an unexpectedly old hash. -
A deb release tags the commit alpha built, which is often not
HEAD. The build is identified by theGit-Hashcontrol fielddebian/build.shwrites (Versioncannot identify it — every master build of a version publishes under the same one). Each product's workflow is path-filtered, so master advances on changes elsewhere without rebuilding that product, andalphalegitimately holds a build of an older commit while no build ofHEADexists or ever will. Rather than demanding one,pull-alphaadopts alpha's commit and tags there — the tag has to point at the commit that produced the artifact. It says so on the way past, and prints thegit checkoutto put a tree on that commit;./scripts/manage-release.sh alpha-commit <project>prints the same hash for scripting. Adoption is confined to a commit already in the current branch's history, and an explicitCOMMIT=is never overridden — it fails instead, because that is someone asserting a different intent. start-cli's per-triple binaries are the one thing that cannot be promoted — they are only ever GitHub release assets, so that half still needs the run. To put a CI build on a server without building locally:make start-os-update-from-gha REMOTE=start9@<ip>(latest master build) orRUN_ID=<id|url>for a specific run. -
live-docsis what every product has published, and is not a development branch. docs.start9.com serves it, andstart-cli s9pk init-workspaceclones it — a packaging workspace reads its guide, package template, and SDK source from that branch, so what is on it must be true of shipped software. master'sdocs/books describe the version that has not shipped yet. Never merge a docs change intolive-docsthat isn't already true of the published software. -
A pull request to
live-docsmay only change published book files. The code on that branch is written bydocs-sync-on-tag.ymlalone, on a release — a PR that carried code would put a tree there that no release produced, anddocs-backport.ymlwould then push it onto master unattended, unreviewed as a code change.live-docs-guard.ymlfails any PR touching a path outsideprojects/<product>/docs/andprojects/start-docs/, and rejectsprojects/start-sdk/docs/package-template/as well. That lives underdocs/but is the codes9pk init-packagescaffolds from, read out of the workspace's checkout at scaffold time rather than built intostart-cli— so a merge there changes what every new package is built from at once, without facing themanage-release.sh pre-check start-sdkgate on its SDK pin. -
A book change ships with its code on
master; a book-only fix goes tolive-docs. This routing governs the published books only —projects/<product>/docs/**andprojects/start-docs/**, the pathsdocs-deploy.ymltriggers on. Every other markdown in the repo —AGENTS.md,ARCHITECTURE.md,CONTRIBUTING.md,README.md, at any scope — is ordinary repo content that nothing publishes, and lands on master like code. For the books, the test is whether the change accompanies code. A book edit describing a change you are making belongs in the same PR as that change, on master, and reaches the site when that product is tagged. A change that only corrects a book — stale text, a dead link, a wrong path, anything already wrong on the published site — belongs onlive-docs: it deploys on merge, anddocs-backport.ymlthen pushes the same commit to master unattended. A book-only PR opened against master instead sits unpublished until the next tag. Where part of such a change touches book text that exists only on master (a section describing unshipped behavior), split that part into its own master PR — never write the same fix in both places, because one of the two will conflict and the conflict falls to a human. -
A green
Conflict Markerscheck does not mean a backport is clean. Where git cannot merge a file line by line — a page deleted on one side, an image changed on both — the backport keeps one side and leaves no marker, so compare the PR againstlive-docsbefore merging it.
Releases
- Cutting
<project>/v<version>is what advanceslive-docsfor that product..github/workflows/docs-sync-on-tag.ymltakes the tagged tree whole, then puts every otherprojects/*back on the release it is on. So a tag moves its own project, the shared libraries beneath it (shared-libs/), and the repo root — and nothing another product owns. The sharedprojects/start-docs/site tree rides along only with a product that ships a book,versions.confincluded, soversions.confmust already name the version you are about to publish before you tag; it is hand-maintained and the sync will not fix it for you. A bookless release (start-cli, start-registry) still advances its own project and the shared tree; it just leaves the site alone, so no deploy fires. Nothing moves backwards: a tag behind a release already synced — a deb release adopts the commit alpha built, often older than a tag cut before it — still advances its own project but leaves the shared trees where the newer release put them, and a tag behind its own product's last release publishes its book alone. - Cut every release with
scripts/manage-release.sh—./scripts/manage-release.sh release <project>(start-os,start-cli,start-tunnel,start-registry,start-sdk,start-wrt);--helplists the individual subcommands and env vars. A product's version is read from its manifest (Cargo.toml, orpackage.jsonfor the SDK), and its git tag / GitHub release is<project>/v<version>. StartOS is the exception: its version carries a revision segment SemVer cannot express (0.4.0.1), so the rootpackage.jsonholds it andprojects/start-os/Cargo.tomlcarries only a0.4.0-rev.1label — never a comparand, since a SemVer prerelease sorts below its release. Read it viabuild/env/version.sh; seeshared-libs/crates/start-core/VERSION_BUMP.md. - A release's curated notes live at
projects/<product>/release-notes/<version>.md, and that file is their only home.manage-release.shcomposes the GitHub release body from it — appending the full-changelog link to its## Highlightssection — and both deploy workflows register it as the version'sreleaseNotes, which is what a server shows before it updates.pre-checkfails without it. Structure: a lede, an optional## ⚠️ Before You Update,## Highlights, an optional## Important. The CHANGELOG carries every change; these notes carry the ones a user would decide on. - A notes-only master change must publish matching channel metadata.
pull-alphatags the commit that builtstart-cli,start-tunnel, orstart-registryand refuses to publish notes that differ from that commit; StartWRT's alpha registry records the notes from its build commit. Their workflowpush.pathstherefore re-include each product'srelease-notes/**after the global Markdown exclusion. Keep this exception out ofpull_request.paths: these notes are not binary inputs and the PR build does not consume them. - Never invoke a product's publish step directly (
make publish, an upload, a registry index). The pipelines differ per product — npm for the SDK, apt + GitHub release for the debs, S3 + registry promotion for the OS and StartWRT — but all of them run the idempotent steps (tag, GitHub release) before the irreversible one. Skip the pipeline and you strand a released version with no tag and no GitHub release, which for npm cannot be undone (pre-checkthen refuses the version and npm won't republish it). This is exactly how start-sdk 2.0.4 and 2.0.5 shipped, and they had to be backfilled. Reach for individual subcommands only to repair a partial release. - Release from a merged, up-to-date
master. The tag is a claim that a commit onmasterproduced the artifact, so cut it where that's true. Nothing enforces this — publishing out of band from an unmerged branch is deliberately still possible, and sometimes the right call — but it is a debt, not a shortcut: the commit you published from will be squashed or orphaned when the branch merges, leaving the tag nowhere honest to point. If you take it, you owe the follow-up in the same sitting — merge the branch, then tag and release at the resultingmastercommit, having checked that its shipped subtree still matches the artifact you published. start-sdk 2.0.5 went out this way and had to be reconstructed after the fact. - Per-product prerequisites and specifics live in that product's scope — e.g.
projects/start-sdk/AGENTS.md,projects/start-wrt/CONTRIBUTING.md.
Filing issues
- The issue forms are the human path, and you will never see them.
.github/ISSUE_TEMPLATE/*.ymlbinds the web UI only —gh issue createdoes not consult a template, so nothing sets the type or the label on your behalf. Pass both yourself (gh issue create --type Bug --label StartOS) and write the body from the spec below; don't go open a form to find out what it wants. An issue filed without a type and label is untyped and unlabeled, and drops out of every triage view. - Type is a GitHub issue type, not a label.
Bug,Feature, orTask, defined at the org level and passed as--type. There is nobugorenhancementlabel — don't invent one. - Take the project label from this exact set. The casing is inconsistent and is matched literally:
StartOS,start-cli,StartSDK,StartWRT,StartTunnel,start-registry,start-docs,brochure, andrepo(build, CI, and release tooling). One label is the norm — reach for a second only when a defect genuinely spans two products. The shared libraries —start-core,patch-db,exver,ts-modules— have none of their own, so label them with the product the defect is most visible in. - The two status labels are the maintainer's, not the filer's.
Approvedmeans signed off and ready for a PR;Known Solutionmeans the fix is identified but unimplemented. Never apply either when filing. - Don't pass
--assignee..github/workflows/issue-triage.ymlroutes the issue to its owner from the project label you set, and features to the maintainer regardless of project. Getting the label right is therefore what gets the issue in front of the right person: an issue it can't route falls back toDEFAULT_OWNERso nothing goes unowned, but the fallback is not a substitute for a label — it lands the issue on one maintainer's plate no matter whose product it is, and an unlabeled issue still drops out of every per-project triage view. - Title the issue as the finding, not the symptom. Once you have traced the cause,
<what breaks> — <why> (<file:line>)beats a bare description of what you saw. - A bug in a packaged service is not a bug in this repo. Defects in Bitcoin Core, LND, Nextcloud and the rest belong in that package's own
*-startosrepo. File here only when the fault is in StartOS, the SDK, or another product in this monorepo.
Write the body with these headings verbatim. Omit a section you have nothing for rather than filling it with "N/A":
### Environment
<the facts listed for this product below>
### What happens
### What should happen instead
### Steps to reproduce
<omit when you found this by reading code — say so under Root cause instead>
### Root cause / code pointers
<`path/to/file:line` and the mechanism, only once you have verified it. Omit rather than guess: a confident wrong cause costs triage more than an empty section. Human filers almost never supply this, so it is where you add the most — and where you do the most damage by bluffing.>
### Logs & evidence
What ### Environment carries, by project:
StartOS— version and git hash (start-cli -H <host> git-info), architecture and image variant, server hardware, and the area (web UI, service runtime, networking, backup/restore, update)start-cli—start-cli --versionandstart-cli git-info, platform, install method, the exact failing invocation, and the target server's version for a remote callStartSDK— the@start9labs/start-sdkversion, the package being built, Node version, and the StartOS version for a runtime failurestart-registry— which registry,registryboxversion if self-hosted, and the client used (marketplace tab, brochure,start-cli registry, direct RPC)StartTunnel—start-tunnel --versionanddpkg -l start-tunnel, VPS provider and OS, whether the host is behind NAT, and host firewall stateStartWRT— the image release or git hash, the board, and the relevantuci show <package>outputbrochure— page URL, browser and OS, and the registry being browsedstart-docs— page URL, which book, and the source file underprojects/<product>/docs/
Opening PRs
- Label every PR with the project(s) it modifies. Nothing labels it for you:
issue-triage.ymlis bound toissues:alone, and no workflow reads a PR's diff. Pass them when you open it —gh pr create --label StartOS --label StartSDK— or add them after withgh pr edit <n> --add-label repo. Take them from the same set as an issue, listed under Filing issues;ghfails on a label the repo doesn't have, and the casing is matched literally. - The diff decides, so a PR takes as many labels as it needs. An issue carries the one product a defect surfaces in; a PR carries every project whose files it changes, because that is what tells a reviewer and a release what a merge can break. Build, CI, and release tooling —
.github/,build/,scripts/,Makefile,debian/,apt/, and the repo-root docs — isrepo. Ashared-libs/change has no label of its own: label the products whose behavior it changes.
Code style
- Default to zero comments. A comment must protect a caller or reader from a mistake the code cannot — a wrong input, a misread return, a hazard that bites later. If nobody would go wrong without it, there is no comment, on a self-explanatory helper most of all. Try the rename or the restructure that would make the comment unnecessary first.
- A comment is a predicate, not a paragraph.
Saturates at 255 rather than wrapping.One clause, one restriction, stated bare — twelve words is a budget, not a target. Never enumerate the cases that trigger the rule:Bad input zeroes the accumulator., not a list of the inputs that are bad. Neveronly X counts; state what counts. An error path that degrades into the default is the default — don't narrate it. Where the declaration supplies the subject, a bare clause is complete. - Leave the signature out of the prose. The parameter names sit on the screen next to the comment; echoing them repeats the declaration. Say what an argument means by its role, not its identifier. A formal term — a checksum, a timestamp, a
Hostheader — is not re-explained; the reader is owed only what the term does not imply. - State the fact, don't argue it. No
so,because, orwhich means; no clause about what other code is thereby spared. Keep the precise noun, drop the explanation of it:Filters the application protocols the client asks for. Unset filters none of them. - Say what is true, not when you wrote it. No
currently,new,for now, ortemporary until X lands; no// was: …changelog; no pointer at a PR, a ticket, or a caller. Git holds the history, and a// TODObelongs in an issue (see Filing issues). Write nothing about what the code or the product does not do — a capability that turned out to be unnecessary, a behaviour that was removed, a check that is skipped. That holds in user-facing copy exactly as it holds in code. - Doc comments on a public surface are documentation, and they stay. A
///on an exported item, a JSDoc on an SDK type a package author reads in their editor — write those, keep them accurate, and hold them to the predicate rule above rather than to the book's voice. A///on a#[ts(export)]type ships to package authors as a generated binding; member docs are glosses, and the mdbook is where a concept gets taught. - The comments around you are not a style reference. Most of the doc comments in this repo were written by an agent, including many that blame attributes to a maintainer. Follow these rules, not the neighbours.
- Fix the comments you pass, only in files you are already changing. Delete one the code now says, and cut a three-idea one to its one idea. Read the code first — some carry a constraint the code can't show. Don't open files to hunt, and don't let the cleanup outgrow the change it rides on.
Gotchas
- Polyglot repo. Per-component gotchas live in component-level
AGENTS.mdfiles — read the relevant one before operating on that component (see Sub-scopes). - Verify cross-layer changes in order. Rust → start-core-ts-bindings → SDK rebuild → web/container-runtime type checks. See ARCHITECTURE.md. Editing
shared-libs/ts-modules/start-core/lib/osBindings/*.tsalone is NOT sufficient — start-core (and the SDK bundle, for container-runtime) must be rebuilt before web/container-runtime will see the change. - Ask before destructive
makerecipes. Image flashing, deploy targets (update*,reflash,wormhole*), andmake clean*consume hours and disk — confirm with the user first. - No git submodules.
projects/start-wrt/openwrt/looks like vendored source but is a disposable, gitignored build workspace (no git repo inside — thinknode_modules/):make start-wrt-openwrt-setuprebuilds it from the sha256-pinned upstream OpenWrt release tarball (projects/start-wrt/build/openwrt-version) plus the Start9 delta fromopenwrt-patches/(modified upstream files) +openwrt-overlay/(added files). Never keep work inside it — every run rebuilds it; change the patch/overlay dirs instead (seeprojects/start-wrt/CONTRIBUTING.md"OpenWrt tree"). Only start-wrt's full image build needs it — every other product, and start-wrt's own binary build, does not. - Stale-path watch. Old docs referenced
core/,web/,sdk/,container-runtime/,patch-db/at the repo root, and the products +shared/directly at the root. Those are gone — products now live underprojects/, the shared libs undershared-libs/; use the locations above.
Coupled changes (keep in sync)
Some pairs of files mirror each other by hand — nothing enforces them, so a change to one half is incomplete until you update the other. Update both in the same commit:
- A product's CI
paths:filter ↔ itsbuild.mkprerequisites. Each.github/workflows/<product>.yamlonly triggers on the paths that product's build actually depends on. Thosepaths:allowlists are a hand-maintained mirror of the prerequisites inprojects/<product>/build.mk(the project dir,shared-libs/**or the specific crates it pulls in,Cargo.*,build/**,debian/**, the web config for products with a UI, …). When you add or drop a build input in abuild.mk, update that product's workflowpaths:(both thepush:andpull_request:blocks) — otherwise CI will silently stop running on changes that affect the build. Affected pairs:start-cli,start-registry,start-tunnel,start-wrt,startos-iso. Additionally,startos-iso.yaml'schangesjob carries a finer mirror: on PRs it gates the expensive image matrix on a regex of image-assembly paths (packaging, image-recipe, systemd units,apt/**, sharedbuild/**) — the inputs the image target pulls in beyond the compiled binary. When you change what feeds the image target inprojects/start-os/build.mk(vs. the binary, which thecompilejob always covers), update that regex too, or image-affecting PRs will skip image validation. - Pull request retargets run through
.github/workflows/pr-retarget.yml. A base change is aneditedevent, but trigger-levelbranches:andpaths:filters evaluate the new base and diff before a workflow starts. The unfiltered listener therefore calls the explicit base-sensitive set:test,start-cli,start-registry,start-tunnel,start-wrt, andstartos-iso; title and body edits finish in its classifier without replacing existing check conclusions. Keep each called target reusable throughworkflow_call, give its concurrency group a workflow-specific literal prefix, and leaveeditedout of its directpull_request:types.conflict-markersremains a direct unfilteredpull_requestlistener and rescans every edit, because itsConflict Markersjob must itself replace any stale verdict after a retarget.live-docs-guardis the other direct listener: it keeps itslive-docsbranch filter but takeseditedtoo, since the filter sees the new base and a PR retargeted ontolive-docsmust be checked before its next push. Grant called jobs their minimum permissions:packages: writebelongs only on thestart-registrycall and remains confined inside it tocreate-image; its compile job explicitly downgrades tocontents: read..github/workflows/pr-retarget-conflict.ymlreports GitHub's native conflict verdict through its PR-numberedMergeabilityjob; thepull_request_targetrun itself supplies the pending and final check result without writing a commit status. Base changes use mergeability-specific concurrency and poll until GitHub resolves mergeability; other edits finish under a PR-numberedMetadata editjob with separate concurrency. When a base-change event's head or base SHA is stale, it cancels its own run so the obsolete check finishes neutral. It never replaces the separateConflict Markerscheck, whose real head/base scan clears stale marker failures after a clean retarget. The privileged listener must never check out or execute pull request code..github/scripts/check-pr-retarget-workflows.pyenforces independent audited target sets, permissions, concurrency, event routing, and conflict-listener isolation. - start-wrt's CI publish constants ↔
scripts/manage-release.sh's wrt config. Thedeployjob in.github/workflows/start-wrt.yamlregisters builds into the beta registry with values (registry URL, S3 CDN, platform, compat floor) and register/index commands that hand-mirrormanage-release.sh'sSTARTWRT_*vars andcmd_register(the manual fallback). Change one side, change the other. projects/start-os/release-notes/<version>.md↔ the StartOS image.projects/start-os/build.mkresolves the file forVERSION.txt, packages it as/usr/lib/startos/release-notes.md, and makes it an image input without making it a Rust binary input.version/release_notes.rsreads the packaged file after an update, so a version bump without its notes file fails the build rather than shipping a server that cannot say what changed. Renaming or moving the source directory requires updatingbuild.mkplusstartos-iso.yaml's trigger, image-change filter, and deploysparse-checkout.- A deploy job's
sparse-checkout↔ the pathsmanage-release.sh notesreads. Thedeployjobs instartos-iso.yamlandstart-wrt.yamlcheck out only what they need and call the script to compose the registry's release notes, so their checkout must coverscripts/, that product'srelease-notes/, and the manifest its version is read from. Teach the script to read something else and widen both, or the register step warns and registers a version with no notes. startos-iso.yaml'sPRUNE_REGISTRIES↔ every registry that can references3://startos-images. The deploy job'sPrune superseded imagesstep reclaims an image once it isMIN_AGE_DAYSold, its version is still published by some registry in that list, and no registry in that list references its commit — so a registry left out of it is a registry whose images get deleted, for every version another registry still publishes. It is deliberately wider than the job's ownREGISTRYmap — production is in it even though this workflow never deploys there, becauseos promotecopies asset URLs through verbatim and production ends up pointing at objects alpha has stopped referencing. Stand up a new channel, or point a registry at this bucket by hand, and it belongs in that list before the next deploy runs.- The reusable service-package CI ↔ the SDK package-template ↔ the packaging docs.
.github/workflows/{build,release,syncNext,tagAndRelease}.yml(theworkflow_callCI that external*-startosservice repos consume) are mirrored by the copies underprojects/start-sdk/docs/package-template/.github/workflows/and the examples inprojects/start-sdk/docs/src/project-structure.md. Change the reusable-workflow surface (inputs, action names, file layout) in all three. - The docs bot's environment policy ↔ the workflow that uses it.
masterandlive-docsare PR-protected with no bypass forgithub-actions[bot], sodocs-backport.ymlanddocs-sync-on-tag.ymlmint a token from a GitHub App that is a ruleset bypass actor. Granting an App bypass would be a repo-wide escalation — any workflow on any branch could then write to master — so the App's private key is an environment secret, and each environment's deployment policy admits exactly the ref its workflow triggers on (docs-backport→ thelive-docsbranch;docs-publish→*/v*tags). Change a trigger and you must change that environment's policy, or the job silently loses the secret and every run fails at token minting. Never move those secrets to repo scope, and never add a second workflow to an existing environment without checking its ref policy still describes the whole set. - A book's source dir ↔
docs-deploy.yml'spaths:↔projects/start-docs/versions.conf.versions.confalone drives the build, nginx routing, and llms.txt — but the deploy only triggers on the paths listed in.github/workflows/docs-deploy.yml, a hand-maintained mirror of the set of productdocs/trees. Add a book without adding its path there and edits to it publish nothing. apt-publish-alpha.yml'sworkflow_run.workflows↔ the deb products' workflowname:fields. One workflow publishes the wholealphasuite, because the three products share a pool and each publish uploads with--delete-removed— concurrent writers prune each other, and serializing them is not enough (GitHub keeps only one pending run per concurrency group, so a third publisher supersedes a queued one). It therefore collects each product's latest successful master build rather than the artifacts of the run that triggered it, which is what makes a superseded run harmless. The trigger matches on workflow name, not filename: rename a product workflow without updating that list and alpha silently stops receiving that product's packages. Adding a deb product means adding it in both places.debian/build.sh'sGit-Hashcontrol field ↔manage-release.sh'spromote_alpha_debs. That field is the only thing tying a published.debback to the commit that built it — the pool filename loses the hash (dpkg-namerewrites it to<package>_<version>_<arch>.deb) andVersionrepeats across every master build. Drop or rename it and deb releases stop being able to verify what they are promoting.debian/publish.sh's suites ↔apt/*.list↔ the signing key. Each suite needs a sources.list line for the people who consume it:stableisapt/start9.list(installed onto StartTunnel hosts byprojects/start-tunnel/build.mk),alphaisapt/start9-alpha.list(added by hand by testers). A new suite is a new file. Each suite is signed independently — the trust chain is that suite's owndists/<suite>/InRelease, so a suite CI publishes must use a CI-only key, and its.listline must name the matchingsigned-by=keyring. Never point a CI-published suite atstart9.gpg: that would mean handing CI the production key.- Adding a product or crate. A new crate must be added to the root
Cargo.tomlmembers; a new product also needs itsprojects/<product>/build.mkincluded in the rootMakefile, a path-gated.github/workflows/<product>.yaml, and — if it ships a UI — anangular.jsonproject pluspackage.jsonscripts. It also needs an intake path: a GitHub label, a bug form, and a dropdown option (see the next bullet). - The feature form's project dropdown ↔
OWNERSinissue-triage.yml↔ the labels that exist on the repo. An issue form'slabels:is a static array, so a dropdown cannot drive it. The eight bug forms sidestep this by being per-product and carrying a static label;9-feature-request.ymlis the one form that can't, so.github/workflows/issue-triage.ymlreads its Project selection and applies the matching label. The dropdown'soptions, the workflow'sOWNERSkeys, and the repo's actual labels must all agree — an option missing fromOWNERSsilently applies no label and leaves the issue on the fallback owner rather than its own, and a label renamed on GitHub breaks both. The workflow only ever adds, never removes, so a deliberate second project label survives an edit. - Assignment lives only in that workflow's
OWNERSmap — not on the forms. A form'sassignees:fires in the web UI alone, so it would leave everygh issue createissue unowned; the forms deliberately carry none. The workflow maps project label → owner, and issue typeFeature→FEATURE_OWNERregardless of project; anything it can't route that way — an issue filed with neither a label nor a type, which is how most agent-filed issues arrive — goes toDEFAULT_OWNER. It assigns only when the issue has no assignee, so a manual reassignment is never overwritten. That cuts both ways: the fallback is sticky, so labelling a fallback-assigned issue later will not re-route it to that project's owner — reassign by hand when it belongs to someone else. Adding a product means a newOWNERSentry, not anassignees:block. - The server hostname limit ↔ every surface that states or repairs it.
MAX_LENinshared-libs/crates/start-core/src/hostname.rsis 32 and governs new input plus migration, restore, and transfer repair. The number is restated by hand inMAX_LENGTHinshared-libs/ts-modules/shared/src/util/hostname.ts, theMust be N characters or lessstring in all five web dictionaries,help.arg.hostnamein all five locales oflocales/i18n.yaml, the///docs onSetupExecuteCliParams::hostnameandSetServerHostnameParams::hostname, their generated man pages and TS binding,projects/start-os/docs/src/server-name.md, and — until each release is cut — the unreleased changelog entries stating it inprojects/start-os/CHANGELOG.mdandprojects/start-cli/CHANGELOG.md. Move one and you must move all of them, or the form accepts or preserves a name the server rejects. - Each bug form's environment fields ↔ its
### Environmentline under Filing issues. Agents never see the forms, so that section restates what each product's form asks for — a hand-maintained mirror, deliberately duplicated because a pointer would not be followed. Add, drop, or rename an environment field on a bug form and you must update its line there in the same change, or agent-filed and human-filed reports of the same bug stop carrying the same facts.
Already enforced or checked elsewhere (listed here for completeness; documented at their own scope):
- Exported Rust types →
make start-core-ts-bindings→ SDK rebuild → web/container-runtime. See ARCHITECTURE.md; editingosBindings/*.tsalone is not enough. - CLI subcommands and their help strings →
make manpages. The committed pages underprojects/*/man/are generated from the clap definitions instart-core;Generated Artifactsregenerates both these and the TS bindings and fails on any drift. - User-facing strings ↔ all five locale dictionaries (
en_US/de_DE/es_ES/fr_FR/pl_PL) — compile-checked forstart-core;npm run check:i18nfor the web libs. patchdb-ui-seed.json↔patchdb-ui-seed.beta.json— keep both seeds in sync (seeprojects/start-os/AGENTS.md).- A crate's
versionbump ↔ itsCHANGELOG.md— versions are read from each manifest; bump the changelog in the same change. - Root
package.json↔projects/start-os/Cargo.toml's label ↔version::Current— the three spellings of the StartOS version.manage-release.sh pre-check start-osfails on a stale label, andversion::tests::current_matches_manifestfails ifCurrentdrifts frompackage.json. - StartOS install/update docs' GitHub release link ↔ the OS release.
projects/start-os/docs/src/installing-startos.mdandupdate-040.mdpin the release URL to the shipping version — a repo-widereleases/latestresolves to whichever product released most recently (e.g. StartTunnel), not StartOS.manage-release.sh pre-check start-osfails if a doc still links toreleases/latestor pins a stale version, so bump these links with the release like the changelog. - The package template's SDK pin ↔ the prospective SDK version.
projects/start-sdk/docs/package-template/package.jsonpins the@start9labs/start-sdkversion scaffolded packages build against. Bump it withmake -C projects/start-sdk sync-templatein the same change asprojects/start-sdk/package.json; packaging workspaces tracklive-docs, so changingmasterdoes not update their template. The SDK tag advances it.manage-release.sh pre-check start-sdkfails if it pins a different version than the release being cut, or if the template commits apackage-lock.json(a generated artifact that only rots against the pin). - User-facing changes ↔ that product's
docs/— docs are part of the change (see each product's AGENTS/CONTRIBUTING).
Sub-scopes
projects/start-os/AGENTS.md— OS productprojects/start-os/container-runtime/AGENTS.md— Node.js LXC service runtimeprojects/start-cli/AGENTS.md— CLI wrapper overstart-coreprojects/start-registry/AGENTS.md— registry server wrapperprojects/start-tunnel/AGENTS.md— tunnel server + UIprojects/start-wrt/AGENTS.md— OpenWrt-based router OS (Rust backend + Angular UI in the root workspace + pinned-upstream OpenWrt image build)projects/start-sdk/AGENTS.md— TypeScript service-packaging SDK, plus the packaging mdbook indocs/projects/brochure-marketplace/AGENTS.md— public marketplace siteprojects/start-docs/AGENTS.md— documentation website; also the authoring conventions for every product bookshared-libs/AGENTS.md— shared libs container:crates/start-core(Rust backend),web(Angular workspace + UI/setup-wizard/shared libs)shared-libs/crates/patch-db/— first-party crate (maintained in-tree)