Objective
Pivot CLANKERNET’s configuration model to the open Dev Containers standard (USER decision, 2026-09-17):
- Per repo —
.devcontainer/devcontainer.json. The standard fields describe the container (image, forwarded ports and their attributes, lifecycle commands, env, host requirements); everything the factory needs that the spec cannot express lives undercustomizations.clankernet— the spec’s sanctioned tool-extension point (VS Code usescustomizations.vscodethe same way). The repo stays openable in VS Code / Codespaces / DevPod unchanged. - Per host —
factory.ymlbecomes the ORCHESTRATOR (fleet) file: the host spec plusrepos: [{url, ref, …}].factory syncre-reads every listed repo’sdevcontainer.jsonat its pinned ref and converges the host; adding or removing a repo from a host is one edit to one file.
Today (after Waves 1–3): each repo carries a bespoke factory.yml manifest
(packages/engine/src/schema/factory.ts, sections repo, host, image, access, hostnames, ports, services, resources, env, hooks, agents, features, coordination, sync, teardown), CLANKERNET carries hosts/<id>.yml, the
container engine (packages/engine/src/container/**) renders compose +
Traefik routes + env from the manifest, and images/entrypoint.sh consumes
FACTORY_MANIFEST (resolved JSON) + FACTORY_ENV_FILE + FACTORY_USER.
clankerengineer’s factory.yml is 299 lines and has no .devcontainer/.
Non-negotiables carried over: the compose volume-key contract, tagged
DNS with exact-name Tailscale matching, the stack ownership guard, secrets as
Pulumi stack config (no new secrets, no ESC for ours), the hooks.env
allow-list, the dev-host naming (D24 rename deferred), no provider names in
public copy (D25), the image’s resolved-JSON contract UNCHANGED (the engine
translates; the image does not know which file the config came from).
Design principle: the engine gains a reader that turns
devcontainer.json + customizations.clankernet into the SAME resolved
manifest shape the container engine already consumes. Everything downstream
of FactoryManifest (compose, routes, env, verify, entrypoint) is untouched.
Mapping (standard field → factory concept)
| devcontainer.json | Factory today | Rule |
|---|---|---|
image | image.ref | required; a prebuilt image the repo’s own CI publishes |
build.* | — | refused in v1 (“images are prebuilt; point image at your registry”) — v1.1 may build on the host |
features | features.innerDocker, image-provided tooling | allow-listed only: ghcr.io/devcontainers/features/docker-in-docker → innerDocker, …/sshd → no-op (base image ships it); any other feature → refused (“features install at build time; bake it into image”) |
forwardPorts + portsAttributes[p].label | ports[].name/port | label = port name; the first entry is the default port unless customizations.clankernet.ports.default says otherwise |
portsAttributes[p].protocol/onAutoForward | — | accepted, ignored (editor concerns) |
onCreateCommand | hooks.postClone | string / array / {name: cmd} object; object form gives the named entries the engine reports |
postCreateCommand | hooks.provision | same; per-name fatal:false via customizations.clankernet.lifecycle.bestEffort: [names] |
updateContentCommand | hooks.sync | same |
postStartCommand | services.procfile / factory-start | if a Procfile path is given under customizations.clankernet.procfile the engine uses it; otherwise postStartCommand runs in the shell window |
containerEnv | env.static | verbatim |
remoteEnv | env.static (login shells) | verbatim; ${localEnv:X} → refused (there is no local machine) |
hostRequirements | — | checked against the host spec’s server type at validate time; exceeding → refused |
remoteUser | developer | must be absent or developer |
workspaceFolder | /home/developer/<repo> | must be absent or equal |
mounts, runArgs, privileged, capAdd, securityOpt, init, overrideCommand, dockerComposeFile, service, appPort, shutdownAction, userEnvProbe, waitFor | — | refused with a one-line reason each (the factory owns the runtime; these would bypass the compose contract) |
customizations.vscode, other customizations.* | — | ignored (other tools’ business) |
customizations.clankernet (v1) carries: hostnames, ports (per-port
public, health, paths, default), access, services.postgres,
resources, env.templated/secrets/computed, hooks.env,
lifecycle.bestEffort, procfile, agents, coordination, sync,
teardown, features.mosh. Everything in it is exactly what the spec cannot
express (see Risks).
The fleet factory.yml (CLANKERNET, per host):
version: 2
host: ./hosts/dev-host.yml # or repo:/stack: ref forms (unchanged)
repos:
- url: https://github.com/clankerlabs/clankerengineer
ref: develop # branch or sha; sync fast-forwards to it
devcontainer: .devcontainer/devcontainer.json # default
access: { github: { org: clankerlabs } } # who may hold a container
- url: https://github.com/clankerlabs/CLANKERNET
ref: main
sync:
schedule: "0 6 * * *" # the dogfood workflow's cron, optional
Tasks
T201: Freeze the resolved-manifest contract
- Blocked By: []
- Details:
- Extract the type the container engine + entrypoint actually consume
(
FactoryManifest, todayz.output<typeof FactoryManifestSchema>) intopackages/engine/src/schema/resolved.tsasResolvedManifest, with a golden JSON fixture captured from clankerengineer’s currentfactory.yml(packages/engine/test/fixtures/resolved/clankerengineer.json). - Every consumer (
container/**,entrypoint.golden.test.ts,images/README.md) imports the resolved type, not the manifest schema. - Files:
packages/engine/src/schema/resolved.ts,container/*.ts(imports only),images/README.md,test/fixtures/resolved/*,test/resolved-contract.test.ts - Acceptance:
npm testgreen with zero behavioural change; the golden byte-matches whatvalidate --jsonproduced before the change; the entrypoint golden tests unchanged.
- Extract the type the container engine + entrypoint actually consume
(
T202: customizations.clankernet schema + published JSON Schema
- Blocked By: [T201]
- Details:
packages/engine/src/schema/customizations.ts(zod v1 of the table above;$id: https://clanker.net/schema/devcontainer-customizations.v1.json);schema:generateemitsschema/devcontainer-customizations.v1.jsonand the site publishessite/public/schema/…(copied at build; adeploy-workflowsmoke probes the URL). Editors get validation by referencing it fromcustomizations.clankernet.$schema.- Refusal fixtures for every “refused” row of the mapping table, one per field, each naming the reason the user will read.
- Files:
schema/customizations.ts,json-schema.ts,schema/devcontainer-customizations.v1.json,site/public/schema/,deploy.ymlsmoke line,test/customizations.test.ts - Acceptance:
npm run schema:checkup to date;curl https://clanker.net/schema/devcontainer-customizations.v1.json200 after the nextmaindeploy; every refusal fixture fails with its message.
T203: devcontainer.json reader → ResolvedManifest
- Blocked By: [T201, T202]
- Details:
packages/engine/src/devcontainer/read.ts: parse JSONC (the spec allows comments), apply the mapping table, runcustomizations.clankernetthrough T202, produceResolvedManifest. Lifecycle command forms (string / array / object) normalised to the engine’s named-hook list;bestEffortnames must exist. Unsupported fields → the refusal set from T202; unknown top-level fields → warning (the spec evolves).factory validate <path-to-devcontainer.json>and--jsonwork on it;factory planrenders routes/env from it.- Files:
devcontainer/read.ts,devcontainer/lifecycle.ts,cli.ts,validate.ts,test/devcontainer-read.test.ts(+ fixtures: minimal, clankerengineer-shaped, every refusal) - Acceptance: reading a devcontainer.json generated from clankerengineer’s
factory.yml(T206) yields aResolvedManifestbyte-identical to the T201 golden; VS Code’s own devcontainer schema still validates the same file (npx @devcontainers/cli read-configurationexits 0).
T204: Fleet factory.yml (v2) schema + reader
- Blocked By: [T201]
- Details:
packages/engine/src/schema/fleet.ts(the shape above),version: 2discriminates from the legacy per-repo manifest (version: 1), which the engine keeps reading through T208’s deprecation window with a warning.- Repo fetch:
contentsAPI atref(viaFACTORY_GITHUB_TOKENfor private repos — the same valuecheckout_token_fromalready resolves), cached per run;refmay be a branch (resolved to a sha and recorded in the run summary) or a sha. - Files:
schema/fleet.ts,fleet/read.ts,hostref.ts(fleet-relative host refs),test/fleet.test.ts - Acceptance: CLANKERNET’s own fleet file validates; a fleet entry pointing at a repo with no devcontainer.json fails with the path it looked for.
T205: factory sync --host <id> and the container engine on the fleet
- Blocked By: [T203, T204]
- Details:
container/fleet-sync.ts: for each fleet repo × each existing container of that repo on the host (discovered from the tagged DNS / compose project list), re-read the repo’s devcontainer.json atref, converge (the existingprovision-over-kept-volumes path — D5), ff the working tree toref; containers whose repo LEFT the fleet →teardown(preserveVolumesper the repo’s last known policy); repos ADDED get no container until a user runsprovision(containers are per user).container provision --repo <url>now reads the fleet to authorise (access) and to find the ref; the manifest arg is gone.dogfood.ymlgainsaction: syncon the fleet’ssync.schedule.- Files:
container/fleet-sync.ts,container/lifecycle.ts,cli.ts,.github/workflows/{factory-deploy,dogfood}.yml,tests/factory-deploy-workflow.test.ts,test/container/fleet-sync.test.ts - Acceptance: with a fake host (the existing injectable seams), a fleet of two repos and three containers converges: one updated, one unchanged, one torn down; the summary lists each with its resolved sha.
T206: clankerengineer migration
- Blocked By: [T203]
- Details:
factory migrate factory.yml(one-shot verb, v1 → devcontainer.json + the diff of what could not be expressed) generates/home/developer/clanker/.devcontainer/devcontainer.json; review it by hand;scripts/factory/*hooks stay and are referenced frompostCreateCommand;factory.ymlis deleted from clankerengineer;server/__tests__/factory-manifest.test.ts→devcontainer.test.ts(validates against the CN reader through the same sibling-checkout seam); the CE caller.github/workflows/factory-deploy.ymlpassesrepo:only.- Files (CE):
.devcontainer/devcontainer.json, deletefactory.yml,server/__tests__/devcontainer.test.ts,.github/workflows/factory-deploy.yml,CLAUDE.md,.claude/skills/*mentions - Acceptance:
code ./ Codespaces opens the repo with that file (manual check); T203’s byte-identity acceptance holds; CEnpx jestgreen.
T207: CLANKERNET’s own fleet + dogfood
- Blocked By: [T204, T205]
- Details:
- Root
factory.ymlbecomes the v2 fleet fordev-host(repos:clankerengineer + CLANKERNET); CLANKERNET’s own container config moves to.devcontainer/devcontainer.json(the current root manifest’s content). examples/becomesexamples/devcontainer.*.json+examples/factory.fleet.yml;dogfood-manifest.test.tsfollows.- Files:
factory.yml,.devcontainer/devcontainer.json,examples/*, tests - Acceptance:
factory validate factory.yml→ fleet OK, 2 repos; the dogfood dispatchprovisionstill works end to end (post-cutover).
- Root
T208: Deprecate the v1 per-repo manifest
- Blocked By: [T206, T207]
- Details:
- v1
factory.ymlfiles still load for one release (v1.x) with a deprecation warning namingfactory migrate;v2.0.0removesschema/factory.tsanddocs/factory-yml.md. - Files:
validate.ts,docs/releasing.md(the deprecation line),CHANGELOG - Acceptance: the warning fires on the T201 fixture; release notes state it.
- v1
T209: Docs and site
- Blocked By: [T202, T204]
- Details:
docs/quickstart.mdrewritten arounddevcontainer.json(a 15-line file + the fleet entry + the caller workflow);docs/factory-yml.md→docs/fleet.md+docs/devcontainer.md(the mapping table and every refusal with its reason);docs/hosts.md,docs/byoc.md,docs/operator/cutover-2026-09.md(post-cutover steps use the fleet),CLAUDE.md, skills; the landing’s “describe your product in one file” line stays true — the file is now the standard one (say “a standard dev-container file”, never a vendor name — D25).- Files:
docs/**,CLAUDE.md,.claude/skills/factory-engine/SKILL.md,site/src/html/home.html(one line) - Acceptance:
quickstart-doc.test.tsexecutes the new snippets; no provider names on the landing (existing grep).
T210: Guard tests
- Blocked By: [T203, T205]
- Details:
tests/repo-standardspins that CLANKERNET’s own.devcontainer/devcontainer.jsonvalidates with both our reader and the Dev Containers reference CLI;tests/no-committed-blobsunchanged; a newtest/devcontainer-spec-drift.test.tspins the exact set of top-level spec fields we accept/ignore/refuse so a spec addition shows up as a failing test rather than a silent ignore. - Acceptance:
npm testgreen; deleting a refusal makes the drift test fail.
Dependency order
T201 → {T202, T204} → T203 → {T205, T206} → T207 → T208; T209 after T202+T204 (docs can lead); T210 last. T201–T204 are engine-internal and can run before the cutover; T205–T207 touch the live host and belong AFTER T023 (there is no host to sync until then).
Risks and errata-style notes
- What the spec cannot express, and must stay in
customizations.clankernet: public hostnames and per-port routing (hostnames,paths,health), secret NAMES and their provider, template variables ({hostname},{port:name}— the spec’s${localEnv:}/${containerEnv:}are different semantics), per-command fatality, multi-window process management (Procfile), sync/teardown policy, coordination, image-provided Postgres (the spec’sfeaturesrun at build time; our image is prebuilt). featuresare the sharpest edge: a user pasting a Codespaces devcontainer.json with five features gets five refusals. The message must say “bake it intoimage” and link the docs; consider a v1.1 “feature bake” workflow that builds a repo image fromfeatureson the host.buildis refused in v1: images are published by the repo’s own CI (clankerengineer’s:factory). Building on the factory host is a v1.1 feature with its own security review (untrusted Dockerfiles on a shared host).- Lifecycle command objects run in parallel per the spec; the engine runs named hooks sequentially. Document the difference; parallelism is not promised.
- Two files, two owners: a repo’s devcontainer.json is the REPO’s; the
fleet file is the HOST OWNER’s.
accesstherefore lives in the fleet (who may hold a container on this host), not in the repo — a repo cannot grant itself a host. - Ref drift:
ref: developin the fleet means “whatever develop is at sync time”; the summary records the resolved sha. Adopters wanting reproducibility pin a sha. - Byte-identity is the migration’s safety net (T203/T206): if the generated devcontainer.json does not resolve to the same manifest as today’s factory.yml, the migration is wrong, not the engine.
- Editor validation:
customizations.*is free-form in the upstream schema, so editors will not validate ours unless the file references our$schema(T202) — document the one line that enables it.
Errata — T204 (fleet schema + reader, 2026-09-17)
- The fleet example above is wrong on one line: the
CLANKERNETentry has noaccess:.accessis REQUIRED per entry (andaccess.githubinside it) — the schema refuses the example as written. The reason is the “Two files, two owners” note: an entry withoutaccesswould be default-open, not “inherit”.examples/factory.fleet.ymlis the corrected form. hostref.tsneeded no “fleet-relative” change:resolveHostRefalready takes the referencing file’s directory asbaseDir; the fleet reader passes the fleet file’s. The one edit there is exporting the default fetcher so the reader’s two GitHub calls share the seam.- The branch → sha lookup is the commits API with
Accept: application/vnd.github.sha(bare sha as text), not the JSON form — one seam (fetchText) serves both calls. accessmoved out ofschema/factory.tsintoschema/access.tsso that T208’s deletion offactory.tsdoes not take the fleet’saccessblock with it;HostRefmoved toschema/refs.tsfor the same reason.schema/factory.schema.jsonis byte-identical after the move.- The v1 deprecation warning (
V1_DEPRECATION_WARNING,validate.ts) fires NOW on every v1 read, ahead of T208 —factory migrateis a NOT_YET verb (→ T206) so the warning names a verb that fails by wave rather than an unknown one. T208 keeps the removal. - T203’s seam is
src/fleet/parse-devcontainer.ts, notread.ts: wire the devcontainer reader there (one file, no common line with the fleet reader).
Errata — T201–T203 (resolved contract, customizations, reader; 2026-09-17)
- The mapping table has no row for
repo.{name,url,defaultBranch}orimage.base/image.pull, and CE’s manifest carries all of them. The fleet suppliesurl(andref) at sync time, but a standalonefactory validate <devcontainer.json>has no fleet, and a byte-identical round-trip needsimage.base(ghcr.io/clankerlabs/factory-base:1) anddefaultBranch: develop.customizations.clankernettherefore carriesrepo: {name?, url?, defaultBranch?}(the fleet entry wins when both are set; the name defaults to the URL’s last segment) andimage: {base?, pull?}—pullis a secret NAME, exactly the class the Risks section says must stay in the block.image.basedefaults; it is informational onceimagenames what runs. workspaceFolder“must be absent or equal/home/developer/<repo>” refuses CE’s own file: CE’srepo.nameisclankerengineerand its path is/home/developer/clanker. The reader mapsworkspaceFoldertorepo.pathVERBATIM (any absolute path; the default is/home/developer/<repo>). Renaming CE’s repo toclankerwould changeFACTORY_REPOand the concurrency key — not byte-identical.accessis refused in the block (USER 2026-09-17), which moves CE’saccess.ssh.keySource: sshidandaccess.tailscale.hostname: dev-{user}into the fleet entry. T204’sFleetAccessalready takes the full v1 shape, so nothing is lost — but note that those two keys are the REPO’s engineers’ key source and device-name template, not “who may hold a container”; a future split (githubin the fleet,ssh/tailscalein the block) would be a schema change on both sides.postStartCommandwithout a Procfile is REFUSED, not “run in the shell window”: the resolved contract (frozen, T201) has no slot for an inline start command —factory-startreads a Procfile from the clone. Honouring the table’s rule needs a contract change (aservices.startthe entrypoint turns into a window) and is a follow-up, not something the reader can do alone. Withcustomizations.clankernet.procfileset,postStartCommandis ignored with a warning.- The spec has top-level fields the table omitted:
initializeCommand(refused — runs on the local machine),postAttachCommand(ignored with a warning),containerUser(same rule asremoteUser),workspaceMountandrunServices(refused withmounts/service),secrets(refused — point atcustomizations.clankernet.env.secrets),otherPortsAttributes/updateRemoteUserUID/overrideFeatureInstallOrder(ignored). The full disposition table isSPEC_FIELDSinschema/customizations.ts; T210’s drift test pins it. hostRequirementsneeds a size table: the host spec names a Hetzner type, not a size.schema/host-requirements.tscarries the catalogue; an unknown type WARNS (a stale table must never refuse a fit), a known type that is exceeded refuses.gpu: trueis refused (no GPU hosts);"optional"is ignored.- The port default flag is per port, not
ports.default: <name>: a record keyed by label with adefault: <string>sibling does not type or JSON-Schema cleanly (one key of a different shape). The block mirrors v1:ports.<label>.default: true; none set → the firstforwardPortsentry. containerEnvandremoteEnvare ONE set here (both →env.static; the same name in both is refused): every process sources the env file, so the spec’s “container process env” vs “editor process env” split has no runtime to land on.schema/factory.tsmust not be DELETED in T208 as written: the reader’s validation layer IS that schema (it builds a v1-shaped input and parses it). T208 should rename it to the resolved-manifest schema (drop theversion: 1literal and the YAML entry point), not remove it.- The reference CLI (
@devcontainers/cli0.89.0) reads both fixtures (CE-shaped and JSONC) with exit 0 andcustomizations.clankernetintact.read-configurationshells out todocker ps, so the test puts a stubdockeron PATH; the suite skips by name when nodevcontainerbinary is present (T210 decides how CI gets one).