Objective

Pivot CLANKERNET’s configuration model to the open Dev Containers standard (USER decision, 2026-09-17):

  1. 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 under customizations.clankernet — the spec’s sanctioned tool-extension point (VS Code uses customizations.vscode the same way). The repo stays openable in VS Code / Codespaces / DevPod unchanged.
  2. Per host — factory.yml becomes the ORCHESTRATOR (fleet) file: the host spec plus repos: [{url, ref, …}]. factory sync re-reads every listed repo’s devcontainer.json at 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.jsonFactory todayRule
imageimage.refrequired; 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
featuresfeatures.innerDocker, image-provided toolingallow-listed only: ghcr.io/devcontainers/features/docker-in-dockerinnerDocker, …/sshd → no-op (base image ships it); any other feature → refused (“features install at build time; bake it into image”)
forwardPorts + portsAttributes[p].labelports[].name/portlabel = port name; the first entry is the default port unless customizations.clankernet.ports.default says otherwise
portsAttributes[p].protocol/onAutoForwardaccepted, ignored (editor concerns)
onCreateCommandhooks.postClonestring / array / {name: cmd} object; object form gives the named entries the engine reports
postCreateCommandhooks.provisionsame; per-name fatal:false via customizations.clankernet.lifecycle.bestEffort: [names]
updateContentCommandhooks.syncsame
postStartCommandservices.procfile / factory-startif a Procfile path is given under customizations.clankernet.procfile the engine uses it; otherwise postStartCommand runs in the shell window
containerEnvenv.staticverbatim
remoteEnvenv.static (login shells)verbatim; ${localEnv:X}refused (there is no local machine)
hostRequirementschecked against the host spec’s server type at validate time; exceeding → refused
remoteUserdevelopermust 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, waitForrefused 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, today z.output<typeof FactoryManifestSchema>) into packages/engine/src/schema/resolved.ts as ResolvedManifest, with a golden JSON fixture captured from clankerengineer’s current factory.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 test green with zero behavioural change; the golden byte-matches what validate --json produced before the change; the entrypoint golden tests unchanged.

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:generate emits schema/devcontainer-customizations.v1.json and the site publishes site/public/schema/… (copied at build; a deploy-workflow smoke probes the URL). Editors get validation by referencing it from customizations.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.yml smoke line, test/customizations.test.ts
    • Acceptance: npm run schema:check up to date; curl https://clanker.net/schema/devcontainer-customizations.v1.json 200 after the next main deploy; 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, run customizations.clankernet through T202, produce ResolvedManifest. Lifecycle command forms (string / array / object) normalised to the engine’s named-hook list; bestEffort names must exist. Unsupported fields → the refusal set from T202; unknown top-level fields → warning (the spec evolves).
    • factory validate <path-to-devcontainer.json> and --json work on it; factory plan renders 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 a ResolvedManifest byte-identical to the T201 golden; VS Code’s own devcontainer schema still validates the same file (npx @devcontainers/cli read-configuration exits 0).

T204: Fleet factory.yml (v2) schema + reader

  • Blocked By: [T201]
  • Details:
    • packages/engine/src/schema/fleet.ts (the shape above), version: 2 discriminates from the legacy per-repo manifest (version: 1), which the engine keeps reading through T208’s deprecation window with a warning.
    • Repo fetch: contents API at ref (via FACTORY_GITHUB_TOKEN for private repos — the same value checkout_token_from already resolves), cached per run; ref may 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 at ref, converge (the existing provision-over-kept-volumes path — D5), ff the working tree to ref; containers whose repo LEFT the fleet → teardown (preserveVolumes per the repo’s last known policy); repos ADDED get no container until a user runs provision (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.yml gains action: sync on the fleet’s sync.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 from postCreateCommand; factory.yml is deleted from clankerengineer; server/__tests__/factory-manifest.test.tsdevcontainer.test.ts (validates against the CN reader through the same sibling-checkout seam); the CE caller .github/workflows/factory-deploy.yml passes repo: only.
    • Files (CE): .devcontainer/devcontainer.json, delete factory.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; CE npx jest green.

T207: CLANKERNET’s own fleet + dogfood

  • Blocked By: [T204, T205]
  • Details:
    • Root factory.yml becomes the v2 fleet for dev-host (repos: clankerengineer + CLANKERNET); CLANKERNET’s own container config moves to .devcontainer/devcontainer.json (the current root manifest’s content).
    • examples/ becomes examples/devcontainer.*.json + examples/factory.fleet.yml; dogfood-manifest.test.ts follows.
    • Files: factory.yml, .devcontainer/devcontainer.json, examples/*, tests
    • Acceptance: factory validate factory.yml → fleet OK, 2 repos; the dogfood dispatch provision still works end to end (post-cutover).

T208: Deprecate the v1 per-repo manifest

  • Blocked By: [T206, T207]
  • Details:
    • v1 factory.yml files still load for one release (v1.x) with a deprecation warning naming factory migrate; v2.0.0 removes schema/factory.ts and docs/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.

T209: Docs and site

  • Blocked By: [T202, T204]
  • Details:
    • docs/quickstart.md rewritten around devcontainer.json (a 15-line file + the fleet entry + the caller workflow); docs/factory-yml.mddocs/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.ts executes the new snippets; no provider names on the landing (existing grep).

T210: Guard tests

  • Blocked By: [T203, T205]
  • Details: tests/repo-standards pins that CLANKERNET’s own .devcontainer/devcontainer.json validates with both our reader and the Dev Containers reference CLI; tests/no-committed-blobs unchanged; a new test/devcontainer-spec-drift.test.ts pins 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 test green; 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’s features run at build time; our image is prebuilt).
  • features are the sharpest edge: a user pasting a Codespaces devcontainer.json with five features gets five refusals. The message must say “bake it into image” and link the docs; consider a v1.1 “feature bake” workflow that builds a repo image from features on the host.
  • build is 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. access therefore 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: develop in 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 CLANKERNET entry has no access:. access is REQUIRED per entry (and access.github inside it) — the schema refuses the example as written. The reason is the “Two files, two owners” note: an entry without access would be default-open, not “inherit”. examples/factory.fleet.yml is the corrected form.
  • hostref.ts needed no “fleet-relative” change: resolveHostRef already takes the referencing file’s directory as baseDir; 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.
  • access moved out of schema/factory.ts into schema/access.ts so that T208’s deletion of factory.ts does not take the fleet’s access block with it; HostRef moved to schema/refs.ts for the same reason. schema/factory.schema.json is byte-identical after the move.
  • The v1 deprecation warning (V1_DEPRECATION_WARNING, validate.ts) fires NOW on every v1 read, ahead of T208 — factory migrate is 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, not read.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} or image.base/image.pull, and CE’s manifest carries all of them. The fleet supplies url (and ref) at sync time, but a standalone factory validate <devcontainer.json> has no fleet, and a byte-identical round-trip needs image.base (ghcr.io/clankerlabs/factory-base:1) and defaultBranch: develop. customizations.clankernet therefore carries repo: {name?, url?, defaultBranch?} (the fleet entry wins when both are set; the name defaults to the URL’s last segment) and image: {base?, pull?}pull is a secret NAME, exactly the class the Risks section says must stay in the block. image.base defaults; it is informational once image names what runs.
  • workspaceFolder “must be absent or equal /home/developer/<repo>” refuses CE’s own file: CE’s repo.name is clankerengineer and its path is /home/developer/clanker. The reader maps workspaceFolder to repo.path VERBATIM (any absolute path; the default is /home/developer/<repo>). Renaming CE’s repo to clanker would change FACTORY_REPO and the concurrency key — not byte-identical.
  • access is refused in the block (USER 2026-09-17), which moves CE’s access.ssh.keySource: sshid and access.tailscale.hostname: dev-{user} into the fleet entry. T204’s FleetAccess already 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 (github in the fleet, ssh/tailscale in the block) would be a schema change on both sides.
  • postStartCommand without a Procfile is REFUSED, not “run in the shell window”: the resolved contract (frozen, T201) has no slot for an inline start command — factory-start reads a Procfile from the clone. Honouring the table’s rule needs a contract change (a services.start the entrypoint turns into a window) and is a follow-up, not something the reader can do alone. With customizations.clankernet.procfile set, postStartCommand is 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 as remoteUser), workspaceMount and runServices (refused with mounts/service), secrets (refused — point at customizations.clankernet.env.secrets), otherPortsAttributes / updateRemoteUserUID / overrideFeatureInstallOrder (ignored). The full disposition table is SPEC_FIELDS in schema/customizations.ts; T210’s drift test pins it.
  • hostRequirements needs a size table: the host spec names a Hetzner type, not a size. schema/host-requirements.ts carries the catalogue; an unknown type WARNS (a stale table must never refuse a fit), a known type that is exceeded refuses. gpu: true is refused (no GPU hosts); "optional" is ignored.
  • The port default flag is per port, not ports.default: <name>: a record keyed by label with a default: <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 first forwardPorts entry.
  • containerEnv and remoteEnv are 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.ts must 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 the version: 1 literal and the YAML entry point), not remove it.
  • The reference CLI (@devcontainers/cli 0.89.0) reads both fixtures (CE-shaped and JSONC) with exit 0 and customizations.clankernet intact. read-configuration shells out to docker ps, so the test puts a stub docker on PATH; the suite skips by name when no devcontainer binary is present (T210 decides how CI gets one).