← stitchkitDocumentation

Project declaration

Maturity: evolving. This surface is still finding its shape and may be redefined in any minor release — always with a ### ⚠️ Breaking changes entry and a migration section, never silently.

Declaring yourself is optional. A project with no project.json is a complete stitchkit project: nothing in stitchkit, stitchkit/server, stitchkit/node, stitchkit/tools or stitchkit/cli reads a declaration, and no build, test or start path looks for one. Read this page as an offer, not a requirement — it describes what you get if you decide to say these things in a machine-readable way instead of in a README.

stitchkit/declaration is the schema for what a repository says about itself: what it is, the roles it runs, what it builds, what has to exist before it starts, what must happen once on release, and the names of the environment variables a deployment has to supply.

import { parseProjectDeclaration } from 'stitchkit/declaration';
import declaration from '../project.json';

export const appDeclaration = parseProjectDeclaration(declaration);

parseProjectDeclaration checks schemaVersion before it reads any field. A reader that does not recognise the version refuses the repository instead of interpreting it partially — a half-understood declaration is the one failure mode that produces a running, wrong deployment rather than an error.

Why declare yourself

Identity is not product membership

The exported names ProjectDeclaration and ProjectIdentity describe the buildable source/artifact declared by a repository. A singular identity does not make a product project and a repository the same entity, nor does it identify a local checkout or a harness session.

Entity Meaning and owner
Product project A product boundary whose repository membership is explicitly maintained outside this declaration
Repository Versioned source; its declaration describes roles, build outputs and release requirements
Checkout A local working copy of a repository revision; local paths and credentials belong to its host
Harness workspace The host-selected working scope for a session, not an implied product or membership registry

Membership is many-to-many. For example, an external registry may declare product A includes repositories service-a and shared-library, while product B includes service-b and the same shared-library. Both products can read the library’s unchanged declaration. Installing that library as a dependency, placing a checkout beside another, or naming a harness workspace does not create membership. The embedding product/registry owns these explicit relationships and their access policy.

A private companion repository can be part of a product without becoming a separate product. Its relationship and working context stay in an authorized private registry, never in a potentially public library declaration. No registry or membership fields are required here; the declaration remains optional. Existing exports and schema version 1 are unchanged.

One statement, several readers

Because the statements exist either way, and without a schema they exist three times. A repository already says how many roles it runs (in a process file), which variables it needs (in a Zod schema), what it builds (in a script) and what has to happen on release (in a README paragraph). Those four copies drift independently, and nothing fails when they do.

What a declaration buys:

  • One reader can be outside the tree. Whatever builds a source into an artifact and binds that artifact into a deployment can validate the project with the same schema the project validates itself with, without being told anything by the author.
  • The tool is not the format. The schema ships from the framework and is plain published TypeScript plus JSON. Any side may read project.json through stitchkit/declaration — or generate a JSON Schema from it with z.toJSONSchema — and serve the project without asking the format’s author for anything. A repository that only one specific tool can bring up is a fork, not a dependency.
  • Generated instead of hand-kept. The starter renders its supervision files and its client-safe identity module from the declaration, so a role added in one place cannot be missing in another.

What it does not buy: nothing here starts, supervises or deploys anything. The declaration is a statement; acting on it belongs to whatever brings a deployment to a source.

The boundary rule

A declaration must be complete and meaningful when no machine exists. A field that cannot be filled in without knowing where the code will run is a binding supplied by the deployment, not a declaration made by the repository.

So ports, hosts, absolute URLs, connection strings, machine paths, routing shape, supervision policy and secrets are never values in a declaration. A binding is named — by the variable that will carry it — and never valued:

{
  "listener": { "portVariable": "API_PORT", "bindVariable": "BIND_HOST", "readinessPath": "/health" }
}

What is guaranteed is structure: there is nowhere in the declaration that a value of the place must go. A command is executable plus an args array — no shell string, no pipe — and no part may be an absolute path or carry an inline value, so --port=8080 must be written ['--port', '8080'], where it is refused as a port. A listener’s variables must exist in env.variables with the right shapes. Unknown keys are refused rather than stripped.

What is filtered is the rest: every remaining free string is checked against namesAMachine — a scheme, a protocol-relative host, an absolute or home-relative path, a Windows drive, a host:port pair, a bare IPv4 literal.

The second half is hygiene, not a proof, and it is worth knowing where it stops: a secret written as its own argument and a hostname written as a plain word look like any other argument. The schema will not catch them, and it is not trying to — the point is that a complete declaration can be written before any machine exists, not that nobody can type a value into one.

Three kinds of input, not two

The boundary rule separates code from the values of a place. There is a third kind that is neither: data read while building. Pages prerendered from a database depend on bytes that are not in the source and are not a binding, and moving environment variables around does not make such a build portable.

Decide it per route, not per project:

what the bytes depend on what is legitimate
only the source static — built once, and portable
the place (domain, port) never static; read it from the request
data render at runtime · a declared snapshot with a digest · generate it as a release step

The default is render at runtime: the build needs no database, and the page reads its data where the data lives. Choose a declared snapshot when you need static bytes — the build then reads a frozen export whose digest is a declared input, not a live database. Choose a release step when the bytes must be produced on the way to the deployment; that is the same kind of step as a migration, not a new concept.

A build that reads data it has not declared is not hermetic, and the fact is invisible: it succeeds on the machine that happens to have the database.

What it carries

key what it says
schemaVersion which format this is; checked before anything else
kind application or library
identity slug, name, version, description per locale
roles each with its own working directory, per-mode argv commands, optional listener and drainFloorMs
build the build command and the artifact paths it produces
requires what the code needs and does not provide, per phase (release, start)
release what must happen once before any role starts — migrations declared as bytes
env the variables a deployment must supply, by name and shape

Two details are load-bearing:

  • A role may declare no listener at all. A queue consumer, a bot or a scheduler is a role like any other; readiness belongs to a role rather than to the application.
  • Migrations are declared as bytes — engine, root, lockfile — not as a command to run. The side that can see the deployment decides what to do with them: exact contents, admission verdict, whether a preflight can be skipped because nothing touches the database.

Narrowing it for your project

A project that speaks exactly two locales narrows the shipped schema; it does not write its own copy of the rest.

import { z } from 'zod';
import { ProjectDeclarationSchema, parseProjectDeclaration } from 'stitchkit/declaration';

const Narrowed = ProjectDeclarationSchema.safeExtend({
  identity: ProjectIdentityNarrowedSchema,
});

safeExtend keeps the refinements the shipped schema carries. Extending the declaration itself is a breaking change to a published schema and moves the minor, with a migration section like any other.

  • ADR 0104 — why the schema ships from the framework rather than from each project.
  • API reference — every export.