Files
NODEDC_PLATFORM/infra/deploy-runner
..

NODE.DC deploy runner

This directory stores the versioned source for the Synology canonical deploy runner.

Live runner:

/usr/local/sbin/nodedc-deploy

Synology staging candidate:

/volume1/docker/nodedc-deploy/runner-install/nodedc-deploy

The runner accepts data-only app-overlay artifacts from:

/volume1/docker/nodedc-deploy/inbox

Dedicated Device Edge runner

The Debian Device Edge is a separate root-owned deployment domain. It does not use the Synology runner, inbox, state or backup tree. Its live runner and fixed roots are:

/usr/local/sbin/nodedc-edge-deploy
/home/ndcsudo/nodedc-device-edge/deploy/inbox
/home/ndcsudo/nodedc-device-edge/source
/var/lib/nodedc-edge-deploy

nodedc-edge-deploy accepts only component=device-edge, validates an exact five-entry payload and can build/recreate only device-edge-relay. The existing device-edge-backhaul and tailnet containers are identity-snapshotted before the transition and must remain byte-for-byte runtime-equivalent through apply or automatic rollback. It has no registry entry in the Synology runner.

The first ingress transition uses a Docker IPvlan L2 address on the Mini's single Ethernet parent. It publishes no Docker host port and preserves the Amnezia host full tunnel; only the relay container receives a LAN-routable address. The fixed IPv4 is a runner/Compose/descriptor constant: 192.168.71.253. Router evidence on 2026-08-04 proves the Deco DHCP pool is 192.168.68.50 through 192.168.71.250, so the address is explicitly outside the pool. Router port-forwarding/firewall remains a separate manual gate.

Build and test the transition source:

PYTHONDONTWRITEBYTECODE=1 \
  python3 infra/deploy-runner/test_device_edge_ingress_artifact.py
npm test --prefix device-plane

After the fixed address is approved, build the production artifact with a fresh transition id, stage it into the Edge inbox, then use only the canonical pair:

sudo /usr/local/sbin/nodedc-edge-deploy plan \
  /home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
sudo /usr/local/sbin/nodedc-edge-deploy apply \
  /home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz

The apply acceptance checks the exact IPvlan parent/subnet/gateway/address, absence of host port publication, internal relay health, private backhaul reachability, preserved VPN routes and unchanged backhaul/tailnet container identity. Gelios and Device Plane command transport are outside this domain.

Supported components in this source:

  • engine
  • launcher
  • platform
  • tasker
  • ops-agents
  • bim-viewer
  • n8n-private-extension
  • module-foundry
  • device-plane
  • gitea
  • proxy-contur
  • dc-amd-proxy

Fresh Gitea installation

gitea is a one-time, fresh-install-only component. Its artifact contains only the reviewed Compose file and strict deployment descriptor; it contains no database, repository, user, token, key, hook, runtime data or secret. Build the deterministic artifact locally with:

node infra/deploy-runner/build-gitea-fresh-install-artifact.mjs \
  gitea-fresh-install-YYYYMMDD-NNN

The runtime is fixed to Gitea 1.27.1-rootless by exact digest and linux/amd64. The runner never pulls it: the exact image must first be loaded through a separately reviewed trusted image-acquisition step. Compose is invoked with --pull never --no-deps, uses network_mode: none, publishes no ports, and serves only the host-mounted Unix socket /volume1/docker/nodedc-gitea/socket/gitea.sock. It exposes no SSH port, uses fresh SQLite, enables no LFS, and mounts two runner-generated secrets by URI. Registration and password Basic auth are disabled, while MFA is enforced; hooks, webhooks, migrations, packages, Actions, OAuth2/OpenID, federation and update checks are disabled. HTTP Git with PAT remains the intended trusted client path; PAT clone/push acceptance belongs to the later reviewed user/repository restoration transition, not this empty installation. LFS may only be enabled by that later transition after its secret and restart behavior has been accepted against the pinned runtime.

Plan and apply fail closed unless all live prerequisites are already true:

  • /volume1/docker/nodedc-gitea, the nodedc-gitea project, and any stale nodedc-gitea_internal network are absent; TCP/4022 is closed and Docker publishes neither TCP/3000 nor TCP/4022;
  • Docker Server is exactly 24.0.2, Compose is at least 2.20.1, and the exact pinned image ID and RepoDigest are local for linux/amd64 with image user 1000:1000;
  • the separate root-owned Nginx bridge /usr/local/etc/nginx/conf.d/http.nodedc-gitea-uds.conf matches its exact reviewed bytes, owner/mode and SHA-256; Nginx 1.23.1 configuration validates, and only its root/uid-1023 processes own 127.0.0.1:3000. The bridge proxies to the Unix socket, preserves Host/X-Real-IP/XFF/XFP, rate-limits login and bounds concurrent requests without limiting Git request bodies;
  • DSM reverse-proxy UUID 5bc46027-0307-4261-af7e-4f94a3c508c9 persistently and in generated nginx config routes git.dcserve.ru:443 to 127.0.0.1:3000; the generated vhost must also retain its exact host guard and canonical Host/X-Real-IP/XFF/XFP forwarding directives, so the UDS bridge never trusts a client-supplied X-Real-IP;
  • the emergency broad INPUT DROP for TCP/3000 has been removed, while the legacy 172.22.0.222:3000 OUTPUT DROP and TCP/4022 INPUT DROP remain;
  • any legacy container named gitea is stopped with restart policy no.

Changing DSM reverse proxy/firewall rules, acquiring evidence from the old root, restoring trusted users/repositories, and loading the pinned image are separate reviewed transitions. This component never reads or mounts /volume1/docker/gitea and never starts the legacy container. Failed apply stops/removes only the nodedc-gitea candidate, proves that it is absent, and quarantines the complete newly created root (including data/config/socket/secrets) for evidence, and restores the absent source/root state without deleting runtime evidence. If candidate absence cannot be proven, rollback preserves the root in place and records reconciliation-required instead of disconnecting live bind mounts.

Run the policy suite before publishing:

PYTHONDONTWRITEBYTECODE=1 \
  python3 infra/deploy-runner/test_gitea_fresh_install.py -v

Gitea incident salvage (clean-state recovery)

The additive gitea-incident-salvage slice is the reviewed recovery design for incident gitea-20260814. It does not upgrade or mount the compromised state. The candidate starts with a new SQLite database, new config, new runtime secrets, new email addresses and new password hashes. The exact v2 decision bundle is embedded as inert, digest-bound data:

  • 2 new active identities (dctouch, admin; SILVER, non-admin), each with a new root-contained one-time password and mandatory password change;
  • 8 new locked identities with random discarded passwords, inactive, restricted and prohibited from login;
  • 45 exact repositories: 32 private dctouch repositories and 13 public SILVER repositories. The other 962 legacy users and 2013 legacy repository rows are not imported into the clean database.

The successor v3 descriptor embeds the owner-confirmed, canonical confirmed-disposition-v1.json and the additive confirmed-closure-disposition-v1.json. Both are bound to the exact snapshot, database, identity decision, reference manifest, unsupported-state report, schema catalog and semantic-topics hashes. The original disposition's 105 reference decisions are explicit rows, not namespace wildcards: 85 normal heads, 4 tags and 4 wiki heads are eligible for later live reconstruction; the exact 5 pull refs and 7 remote refs are sealed archive-only evidence and can never be auto-promoted. The closure disposition authorizes only bounded plan-time inventory and records the target policy for access, collaboration, issue/PR metadata, attachments, releases, labels, projects, units, Packages and Actions. Observed hashes do not become activation authority, and the new closure-report digest is deliberately unpinned until separate review.

Build the deterministic review artifact locally with:

node infra/deploy-runner/build-gitea-incident-salvage-artifact.mjs \
  gitea-incident-salvage-subrelation-closure-20260814-006

The original v1 descriptor and ...salvage-20260814-001.tgz remain immutable. The exact ...salvage-disposition-20260814-002.tgz also remains immutable and must not be retried: its plan failed safely because it assumed every nonempty repository.topics TEXT value was a JSON array. A bounded immutable-database classification proved all 45 kept rows are instead exact four-byte canonical JSON null, Gitea's semantic empty representation. The ...topics-...-003 artifact is the forward fix with a new patch id and digest. That exact ...topics-...-003 artifact is now also an immutable predecessor. The local pre-audit ...closure-...-004 draft must not be staged: review found orphan-join and nullable-Actions gaps. The local ...closure-reviewed-...-005 artifact is also stale and must not be staged: its primary closure was fail-closed, but it retained a broad polymorphic- subrelation blocker. The additive ...subrelation-closure-...-006 successor closes the concrete comment/history/merger relations in the pinned snapshot schema and is the only artifact produced by the current builder. The successor builder refuses to overwrite an existing artifact basename and publishes from an exclusively created same-directory temporary file using an atomic no-clobber hard link. A failed build removes only its private temporary file. The disposition is sorted, compact ASCII JSON with one terminal LF.

The runtime pin is docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49a…3abd2c, with exact linux/amd64 OCI image ID sha256:272085a8…bc5c4c6. It runs as 1000:1000, uses the direct Gitea binary, network_mode: none, no Docker ports, no SSH and no LFS. Candidate restart policy is no. The existing reviewed Nginx bridge cannot reach the candidate while the socket parent is mode 0700; publication is an explicit post-acceptance change to 0750, followed by public TLS and smart-HTTP checks. Only after those checks may restart policy become unless-stopped. The transition never changes or enforces two-factor authentication.

The snapshot boundary is exact: UUID f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f, immutable SQLite SHA-256 8db9e74a…1a13052, and the 972/2058 decisions must recompute from the database row by row. The runner enumerates only each v2 repo_relative_path and wiki_relative_path. It rejects symlinks, special files, hard links, NOCOW, alternates, shallow/replace/grafts/worktrees, promisor state, unknown object material and incomplete pack/index pairs. One preloaded mount inventory also guards the objects and refs roots plus every traversed directory and file: all must remain on the trusted snapshot device, outside nested mountpoints and, for directories, outside nested Btrfs subvolumes. Traversal errors fail closed. Forbidden-path probes (objects/info/alternates, grafts, replace refs and the other listed sentinels) lstat each path component without following links and validate every ancestor before probing its child; a symlink or mount boundary therefore stops the plan before any lookup can escape the snapshot tree.

Only three exact derived-file classes observed in the bounded 45-repository inventory are excluded: dumb-HTTP objects/info/packs, objects/pack/pack-<oid>.bitmap with its exact complete pack/index pair, and monolithic objects/info/commit-graph. Each must be a bounded regular, single-link, non-NOCOW file. The reference manifest records its exact path, class and byte size, but the runner neither reads nor copies the excluded bytes. Unobserved .rev, MIDX and split commit-graph layouts stay fail-closed; .promisor, .mtimes, .keep, unknown and temporary lookalikes are not classified as disposable accelerators.

The same inventory found receive-pack quarantine directories matching only an exact direct child objects/tmp_objdir-incoming-[A-Za-z0-9]{6}. A match must retain the reviewed uid/gid 1000:1000, mode 0755, single-link and non-NOCOW directory metadata. Its exact path and lstat fields are recorded, then the subtree is pruned without reading, hashing, copying or reflinking contents; the sealed read-only incident snapshot retains the forensic copy. Any name, metadata, mount or subvolume drift remains a hard stop. A reviewed reference/OID manifest is required; raw HEAD/refs files are never copied. The intended data path is same-filesystem per-file --reflink=always, with no byte-copy fallback and a bounded exclusive-allocation gate.

This slice is intentionally NO-GO for apply at the current review point. plan inventories and prints the exact ref-manifest digest, byte length and canonical compact JSON. The JSON is the reviewable, hash-bound reference/OID artifact: it contains approved repository identities, ref names/OIDs, object totals and stat-bound derived/quarantine exclusions, but no Git object bytes, hooks or config. The same plan prints the canonical unsupported-state report and a separate incident-closure report, each with its SHA-256, byte length and compact ASCII JSON. The closure report is evidence only: its disposition keeps expectedSha256 and expectedBytes null and retains an explicit review-pin blocker.

The reviewed plan evidence bound by the disposition is reference manifest 9cddaf0e…a311 (36,010 bytes), unsupported-state report 4b2cecf8…106ac (119,468 bytes), and schema catalog b5e3b677…b6db. The report has exact 45 repository rows with empty schema_missing, schema_mismatch, and anomalies arrays. These facts close only the owner's material-state decision; fsck/reachability, physical blob inventory, sanitized archive creation and candidate acceptance remain separate verifiers.

The unsupported-state report has one deterministic row for every one of the 45 kept repository IDs, including explicit zeroes. It separates direct repository-relation counts, directional base/head PR counts, repo-unit type counts, text-metadata presence booleans and denormalized numeric hints; it does not add overlapping relations into a misleading grand total. LFS rows are reported per repository with validated logical byte sums and distinct-OID aggregates, including size conflicts and sharing with non-kept repositories. Every association sharing a kept OID must also have an integer repository ID that resolves to the exact snapshot repository table; invalid and orphan owners make shared/unique byte aggregates unavailable and add a data-anomaly blocker. Attachments are reported per repository with validated logical byte sums and explicit, overlapping issue/comment/release/unlinked link splits. Neither category claims that a physical blob exists; no title, body, note, comment, config, URL, token, key, secret, hash payload or blob content is selected or exported.

The report also embeds the exact PRAGMA table_xinfo catalog and explicit label-to-table-to-repository-column map for a code-owned allowlist of relevant tables. sqlite_master must prove an ordinary, non-virtual table before any row query; SQLite 3.37+ adds PRAGMA table_list ordinary-main-table proof. On an older compatible SQLite, the weaker object-kind capability is recorded and adds a coverage blocker. query_only=ON and trusted_schema=OFF are read back before the snapshot connection is accepted. Only column identifiers and structural metadata are exported, never DDL, defaults or row values. The v3 closure inventory follows code-owned, schema-attested primary joins for the issue/PR dependency tables (comments, assignees, content histories, labels, issue users/watches, reactions, reviews/state, dependencies, notifications, stopwatches, tracked time and auto-merge), plus projects/boards, releases, attachments, Packages and Actions. It emits only IDs required to prove joins, actor classes, counts, declared sizes and SQLite byte lengths; it never selects or prints text bodies, names, URLs, package metadata, workflow payloads, credentials, tokens, secrets or logs. Package and Actions target features stay disabled and no legacy row is importable. The successor additionally closes every concrete FK-like comment field in the pinned schema (labels, projects, milestones, tracked time, assignees, dependency, review and cross-reference), content-history-to-comment, review-team and pull-merger provenance. Target repository and local actor IDs are classified through the exact 2058/972 decisions. Migrated external-author IDs are never treated as local users and are represented only by presence counts and author-name byte lengths. A nonzero comment-assignee or review-reviewer team must resolve to an exact team and organization; because no organization/team allowlist exists, its row, team and organization IDs enter a sealed HOLD inventory and add the narrowly scoped issue-pr-team-mapping-hold blocker. Orphans, conflicting user/team identities, invalid cross-reference pairs and schema/type drift fail closed. The report is still not a physical-file or sanitized-archive verifier, so those separate blockers remain explicit. The schema-catalog digest is recorded as observed evidence but deliberately remains unpinned as reviewed activation authority. Schema absence/type drift, invalid numeric/OID state or aggregate overflow is a hard error. apply fails before creating /volume1/docker/nodedc-gitea until all of the following are registered:

  • root-owned Btrfs proof that the named snapshot UUID is read-only;
  • exact stopped/restart-no legacy container image ID, image ref and sole /volume1/docker/gitea:/data bind inspect;
  • a separately verified exact reference/OID manifest digest, fsck, reachability and selected-object reconstruction;
  • immutable sanitized archives for issues, PRs, comments, releases, labels, projects and repository descriptions, with no legacy row import;
  • physical attachment inventory and reachable LFS pointer/OID/size/SHA proof;
  • kept-user mapping before any collaboration recreation; and
  • package/Actions schema plus physical zero closure and target unit-policy acceptance.

All 25 direct repository relations have explicit dispositions, including the 15 exact zero-and-drop categories. Pull base/head counts remain directional views of the same five PRs. All repository numeric hints are dropped and recomputed. Repo-unit types 110 are total: clean types 15 and 8 are recreated once per repository; external wiki/tracker are forbidden; Packages (9) and Actions (10) are absent in the target and remain globally disabled. Legacy unit config is never imported. The topics verifier accepts exact JSON null as semantic empty state, or a canonical JSON array of sorted unique lowercase names matching ^[a-z0-9][-.a-z0-9]*$ with at most 35 UTF-8 bytes. SQL NULL, whitespace/case variants, quoted "null", objects, numbers, booleans and noncanonical arrays fail closed. The current snapshot must prove exactly 45 serialized JSON nulls, zero serialized arrays, zero material topics and zero repo_topic rows; target cache is rebuilt from relations.

The activation hook remains frozen until these blockers are closed and the full clean-database recreation, reflink reconstruction, doctor/fsck, credential-table zeroing, rollback and public acceptance path has its own runtime smoke. The current reviewed source identities are:

  • runner nodedc-deploy SHA-256 c766985aa02fe911fa5a873717276f3503d9cecded448e41dc6945030dcbce9e;
  • deterministic builder SHA-256 b92076583aeab854015a05deb9d942c8dfcf3ca475fd3d52115d402d6f61acca;
  • salvage policy test SHA-256 99b96b6179286a78e7d53ee6a923fc092e3471a8cece382a472449d455ad212c;
  • fresh-install policy test SHA-256 20653c640142c25e7d2ef712048acc7889c56e1d519e2d03b5cad4127e48947c;
  • confirmed disposition SHA-256 0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c;
  • confirmed closure disposition SHA-256 7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243;
  • successor v3 descriptor SHA-256 9b98eb1a1640fd5569cf051a621837379b167eff4527313a43a0a851e7cc181a;
  • deterministic local review artifact nodedc-gitea-gitea-incident-salvage-subrelation-closure-20260814-006.tgz SHA-256 d3e598cf892b1371912dcd9cef64caa991dd23613e48fe979d0f95d493c23d53.

Only after that exact runner is separately reviewed, promoted to the root-owned runner path and its installed SHA-256 is re-attested may the exact artifact be staged into the canonical inbox for a canonical plan command. Staging is evidence collection only; it grants no authority to run apply. The legacy identity is now pinned to the observed ref gitea/gitea:latest and immutable image ID sha256:bf95d9a45ce4fe38b027d051cdc4a4bc531513489fa6244af4074efbb1c376d6. The mutable tag is provenance only. Every plan re-inspects exact name /gitea, stopped state, restart no, and the sole RW bind /volume1/docker/gitea:/data; drift is a hard stop.

The successor plan must report these exact remaining blockers:

  • attachment-physical-verifier-pending;
  • candidate-root-activation-hard-frozen;
  • closure-report-review-pin-pending;
  • collaboration-kept-user-mapping-verifier-pending;
  • forensic-ref-archive-verifier-pending;
  • issue-pr-metadata-sanitized-archive-verifier-pending;
  • lfs-reachable-pointer-physical-verifier-pending;
  • package-action-physical-closure-verifier-pending;
  • reference-manifest-fsck-reachability-verifier-pending;
  • repository-object-reconstruction-verifier-pending;
  • target-unit-policy-acceptance-pending;
  • unsupported-schema-catalog-verifier-pending.

Any other blocker or identity drift is also a stop. apply remains prohibited and hard-frozen before root creation until a subsequent reviewed runner revision closes every blocker and implements and smoke-tests the complete activation/rollback path. The successor files must be independently reviewed before any runner promotion or inbox staging.

Run the bounded policy suite with:

PYTHONDONTWRITEBYTECODE=1 \
  python3 infra/deploy-runner/test_gitea_incident_salvage.py -v

The frozen local cutoff passes 54 incident-salvage tests and 26 fresh-install tests, plus Python compilation and git diff --check.

n8n-private-extension is a staging-only trust boundary for reviewed offline n8n private-node releases. Its artifact may contain exactly one digest-bound n8n-nodes-ndc release with package.tgz, release.json and rollback.json. The runner validates the inner npm tarball, rejects lifecycle scripts and runtime dependencies, refuses to overwrite an existing release, and seals the installed release root-owned/read-only under:

/volume1/docker/nodedc-platform/n8n-private-extensions/releases/n8n-nodes-ndc/<version>-<sha256-prefix>

This component has no Compose file, service, container mutation or activation side effect. In particular, staging does not make the node visible to n8n. Activation remains an Engine-owned change: mount the reviewed immutable release at /home/node/.n8n/nodes/node_modules/n8n-nodes-ndc, atomically switch between verified releases, restart every n8n process, and accept only after MCP exposes the package-qualified n8n-nodes-ndc.* schemas. The Platform runner cannot cross that boundary and never runs npm install in a live container.

Build a verified offline release artifact:

node infra/deploy-runner/build-n8n-private-extension-artifact.mjs \
  n8n-nodes-ndc-release-YYYYMMDD-NNN

The builder is byte-reproducible and accepts exactly the three reviewed NDC runtime types:

  • n8n-nodes-ndc.ndcDataProductPublish
  • n8n-nodes-ndc.ndcDataProductRead
  • n8n-nodes-ndc.ndcFoundryBinding

Their three opaque capability credential schemas are ndcDataProductWriterApi, ndcDataProductReaderApi and ndcFoundryBindingApi. A node description containing usableAsTool is rejected because n8n 2.3.2 would synthesize an additional *Tool runtime type and violate the exact-three activation contract. Run the positive and negative release-policy suite before publishing an artifact:

PYTHONDONTWRITEBYTECODE=1 \
  python3 infra/deploy-runner/test_n8n_private_extension.py

Release/rollback manifests use schema v2. Before a first activation, the Engine-owned activator must verify and record the current inactive state. That verified_inactive state is an allowed rollback baseline when no previous verified immutable release exists; later upgrades prefer the previous verified release. Rollback never deletes or mutates a staged release.

The historical 0.1.0 release remains immutable and must not be overwritten. Release 0.1.1-994756958861518e is retained as rejected/inactive: its three node descriptions used usableAsTool, so n8n 2.3.2 exposed six NDC runtime types instead of the required three. It must not be activated, overwritten or deleted.

The active predecessor is package version 0.1.4 at immutable release 0.1.4-59dc9f7882721d6a. Package 0.1.5 adds the provider-neutral complete snapshot replace mode used by map.zones.current.v2; its Platform staging remains inert until a separately reviewed Engine-owned transition selects the exact digest and accepts the updated MCP node schema.

The paired Engine activation is built by build-engine-n8n-private-extension-artifact.mjs. It deliberately does not copy from or write generated files into the dirty Engine worktree, and it does not build an image. A fresh transition id is mandatory and previously issued ids are rejected:

node infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs 20260721-005

The builder emits a narrowly scoped Compose override plus a strict transition descriptor. On apply, the runner validates the staged release again, verifies that the running n8n container and the NAS-local 2.3.2 tag resolve to the same immutable image ID, and extracts the package into the root-owned, read-only Engine release tree:

/volume2/nodedc-demo/n8n-private-extensions/releases/n8n-nodes-ndc/0.1.5-3c8ae53f010d7c88/package

The override sets N8N_USER_FOLDER=/home/node, which is required because the actual Engine service runs as root while the canonical community package path is below /home/node/.n8n. The live runtime contract remains the successfully deployed generation-003 contract and does not set NODE_PATH. Only the runner's isolated node -e package-loader probe temporarily initializes the dependency tree bundled inside the exact n8n base image; this reproduces n8n's own loader without redefining the live Compose state. The override enables loading but disables reinstall, mounts only the exact release read-only, and uses both Compose pull_policy: never and docker compose up --pull never. No registry access, lifecycle script, database installed_packages row or custom-extension loader is involved.

The runner pins the exact Engine service topology observed in source and rejects an added worker/webhook generation. Only the single actual n8n service is force-recreated with --no-deps; the Postgres service, .n8n data, encryption key and credentials remain intact. The apply gate verifies readiness, the running image/version, sealed mount, loader environment, package-loader node/credential sets, scoped loader logs, restart stability and content-exact pinned Engine MCP catalogs. The runner pins the complete 434/385 inactive baseline and a digest registry for every reviewed 437/388 active release, so a same-count substitution of any built-in or private schema is rejected. Upgrade 0.1.2 -> 0.1.3 is accepted only when the verified live predecessor, descriptor, package mount and catalog all agree. Any gate failure after mutation automatically restores the pre-apply catalogs/descriptor and force-recreates the previous verified runtime. Staged, sealed and failed releases are retained. The paired rollback artifact returns 0.1.3 to the verified immutable 0.1.2 release and its exact catalogs; it does not invent an inactive baseline for an already-active upgrade.

Run both policy suites before publishing the Engine pair:

PYTHONDONTWRITEBYTECODE=1 \
  python3 infra/deploy-runner/test_n8n_private_extension.py
PYTHONDONTWRITEBYTECODE=1 \
  python3 infra/deploy-runner/test_engine_n8n_private_extension.py

The source-less Engine MCP control-plane update is a separate exact slice:

node infra/deploy-runner/build-engine-mcp-control-plane-artifact.mjs 20260717-001

Its six-entry registry contains only the Engine Agent gateway, verified graph patch route, Codex installer source/package and the active node-intelligence descriptor with the new gateway digest. It force-recreates only the existing nodedc-backend. The node-intelligence image/service, n8n, L1, credentials, databases and volumes are outside the slice. The runner proves the installed predecessor digests, exact installer archive/source equality, bounded change session policy, no-effect/post-write graph barriers, active immutable backend runtime and descriptor equality. The ordinary overlay backup is the automatic rollback source; rollback restores the predecessor gateway and descriptor together before recreating the same backend service.

The successor autonomy/provider-v5 slice keeps MCP authority explicit without turning every write into a second permission dialogue. MCP tool availability is the capability boundary, while the user's current objective is the intent boundary; Engine L2 may act autonomously only inside their intersection. A graph plan remains a mandatory machine barrier whose target, diff, revision and blockers are inspected by the agent. It is not a repeated approval prompt after an implementation objective is already authorized. Retries must add new evidence or change the attempted variant, and three identical failures without new evidence or state change are a critical stop. The slice also advances the installer to 0.1.6 and adds gelios.provider.v5 -> fleet.positions.current.v4 beside the immutable v4/v3 rollback line:

node infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs \
  20260720-004

Its seven-entry allowlist recreates only nodedc-backend; n8n, L1, databases, credential values and the node-intelligence image remain outside the change. The runner accepts both the exact target and the exact predecessor after an automatic rollback, and rejects every mixed state.

For platform artifacts, the allowlist includes the versioned Ontology Core, the frozen legacy Gelios compatibility service, and the provider-neutral External Data Plane sources. The Gelios service remains reproducible only to protect its existing database/workflow; it is not a template for a provider integration. An External Data Plane artifact builds only its image and force-recreates only external-data-plane; the already healthy external-data-plane-postgres container and its Timescale volume are an independent deploy prerequisite and are never selected by an EDP application artifact. The reviewed Compose source pins the Timescale image, named volume and target, internal database-only network, absence of database host ports, healthy dependency, localhost-only EDP bind and the three read-only provisioner/trust mounts in the reviewed Compose source. The runner does not reinterpret version-dependent docker compose config JSON as a second deploy schema. Its canonical enforcement remains the artifact/path allowlist plus hard-coded build command, selected service set, runtime-secret preparation and health acceptance. Post-apply acceptance also requires database=ready; the managed Foundry slice additionally requires foundryReaderBindingProvisioning=digest+server-resolved-source. A first-rollout failure removes only the candidate EDP container without volumes and restores the source overlay. It never contains a provider credential, provider endpoint, collection schedule or command transport. Its contract payload is an exact provider-neutral runtime subset; providers/*, mappings, fixtures and tests are excluded and do not trigger an EDP rebuild. Database credentials remain root-owned live .env.synology configuration and must not reuse NODEDC_INTERNAL_ACCESS_TOKEN.

On the first relevant Platform apply, the root-owned runner creates /volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token atomically in a dedicated UID/GID 11006 directory (directory 0500, token 0400). It is never an .env value or an artifact member and is mounted read-only only into External Data Plane.

Manual one-time binding issuance and digest-only managed writer ensure are independently disabled by default. The target control-plane operation generates and stores the capability inside native NDC L2 Credentials and sends only its digest to EDP; users and MCP consumers receive only an opaque compatible reference/status. The runner-owned bearer above authenticates only legacy plaintext issuance and is intentionally rejected by managed ensure/revoke. Managed requests use a deployment Ed25519 Engine service key; EDP mounts only the public-key trust directory read-only. On every reviewed Engine apply and on an EDP runtime apply, this runner creates or validates one matching Ed25519 pair: the Engine-only private key is root:root 0400, while the EDP trust copy is root:11006 0440. Public-only crash state, key mismatch, a non-Ed25519 key, symlinks and permissive modes fail closed. plan discloses both paths without printing key material. The private key must not be broadened into an L2 graph, MCP surface, artifact or shared-token boundary.

Module Foundry has a separate Ed25519 managed-provisioner identity for target-scoped Data Product consumer grants. On a relevant platform or module-foundry apply, the runner creates or validates the Foundry-only private key at /volume1/docker/nodedc-platform/secrets/foundry-edp-managed-provisioner/private-key.pem as root:root 0400 and the matching EDP trust copy at /volume1/docker/nodedc-platform/trust/foundry-managed-provisioner/public-key.pem as root:11006 0440. Foundry generates the opaque reader token only inside its persistent private runtime; its signed EDP request contains only a SHA-256 digest and Data Product id. EDP resolves the unique active writer source scope server-side and fails closed on missing or ambiguous coverage. No provider, tenant, connection, token, private key or endpoint is admitted to the Foundry MCP plan, application state or browser response. The Engine signing identity, native n8n credentials and legacy issuance bearer cannot call this endpoint.

The reviewed Engine source candidate has dedicated server-derived MCP plan/apply handling for the exact ndcDataProductWriterApi + Data Product Publish tuple. It is separate from the generic HTTP safe-ref path and accepts no caller-provided provider/scope/credential identity, capability, generation or service URL. The production managed flag remains false during staging. In the explicitly confirmed activation window it is set to true immediately before the Platform EDP artifact; runner acceptance then requires EDP /healthz to report managed provisioning enabled. At that point the old Engine still has no signer mount, so the endpoint remains usable only after the separately accepted Engine artifact. Root/UI transfer is emergency self-hosted diagnostics only, not the user journey or acceptance path. A provider-specific daemon remains forbidden.

Build the narrow Engine source artifact with:

node infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs \
  engine-data-product-publish-grant-YYYYMMDD-NNN

The builder includes exactly the pinned provider-security catalog, the Publish-grant service, Engine Agent scope/gateway wiring, the existing n8n adapter and the reviewed additive backend runtime overlay. It deliberately excludes base Compose, frontend/dist, runtime data, tests, native credentials, the NDC L2 process and the legacy generic credential-sink route/core. The runner fixes the runtime action to nodedc-backend with --no-deps and --pull never; n8n, nginx app, database services and volumes are not selected. Acceptance requires backend health, the active immutable backend identity and the exact additive mount inventory. Any failure restores the touched source and recreates only the previous verified backend runtime.

Existing Engine Agents created before Publish-grant support store the original nine scopes as the complete developer profile. They must not require a new agent, setup command or device credential when the profile gains a server-owned capability. Build the compatibility artifact that introduces the durable named full-developer profile with:

node infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs \
  engine-agent-full-grant-migration-YYYYMMDD-NNN

This follow-up slice contains exactly nodedc-source/server/engineAgents/store.js. The runner pins its predecessor to the successfully applied Publish generation, requires the installed Publish overlay and active immutable backend, recreates only nodedc-backend, and proves the exact candidate SHA, named profile and current expanded runtime scope view. Store schema v1 is migrated atomically to v2 only when a grant contains the complete legacy nine-scope developer bundle. From then on the profile name is the authorization authority and its scope list is derived on every read, so capabilities deliberately added to full-developer immediately apply to existing full grants without token or store migrations. Partial grants become custom and remain exact; they are never elevated. The artifact does not touch UI, n8n, credentials, agent tokens, workflow graphs, databases or runtime payloads.

Engine L2 node-intelligence transition

The node-intelligence transition is an additive Engine-owned deployment domain. It does not merge Ontology, provider APIs or Ops into the Engine MCP. It pins the reviewed upstream n8n-mcp implementation at package 2.33.2, commit 974a9fb3492fe2c4984ee0549085d531cdc6242a, and exposes only the safe NDC L2 projection through the existing Engine Agent gateway. Upstream management and write tools are not forwarded.

Production never clones, pulls, installs or builds this dependency. The builder saves the reviewed linux/amd64 image once, embeds that exact archive in a data-only activation artifact and records both the archive and image-config digests. The runner validates every inner blob, revision label, entrypoint, command and platform before an offline docker image load; Compose then uses the fixed tag with pull_policy: never and --pull never.

Build a fresh activation/rollback pair:

node infra/deploy-runner/build-engine-node-intelligence-artifacts.mjs \
  YYYYMMDD-NNN

The activation exact set is:

  • nodedc-source/server/nodeIntelligence
  • nodedc-source/server/routes/engineAgentGateway.js
  • nodedc-source/services/node-intelligence

The runtime adds only nodedc-node-intelligence and recreates the existing nodedc-backend; n8n, UI, databases, L1, provider services and volumes are not selected. The sidecar has no host port, runs as 11007:11007, has a read-only root filesystem, drops all capabilities and receives no Engine or provider credential. Its independent MCP bearer is created by the root-owned runner as a read-only file and mounted only into the sidecar and backend. It never appears in an artifact, shared environment file, log or MCP response.

Acceptance requires the exact image/container/mount/security inventory, immutable backend identity and live authenticated get_node, validate_node and validate_workflow calls. Any apply failure restores source and runtime automatically. The separate rollback artifact first removes only the sidecar without volumes, restores the pinned inactive gateway, and recreates only the verified backend. Loaded images and failed candidate source are retained for audit rather than destructively deleted.

Stage the reviewed runner under a unique candidate name first:

/volume1/docker/nodedc-deploy/runner-install/candidates/
  nodedc-deploy.engine-node-intelligence-YYYYMMDD-NNN

Do not overwrite the canonical staging candidate while another deploy may be in flight. After the no-lock/no-process gate, promote the exact candidate in a standalone root step, run verify-install, then invoke a fresh process for activation plan and only then apply. The generated plan/apply runbook carries the runner, activation and rollback SHA-256 values and is staged beside the unique runner candidate.

module-foundry is an independent, authenticated application component. Its artifact contains source and compose infrastructure only; its live /volume1/docker/nodedc-platform/module-foundry/source/.env is root-owned and never enters an artifact. The component reuses the existing internal platform credential for Launcher handoff validation and requires that runtime configuration before its first apply.

When the managed reader-grant source is present, Module Foundry acceptance also requires /healthz to report the dedicated Ed25519 provisioner as configured. The check is source-aware: if an apply rolls back to the prior source, rollback acceptance uses that prior health contract instead of falsely requiring a feature which the restored generation does not contain.

The Foundry ↔ Map Gateway signing key is not an application or .env setting. On the first relevant platform or module-foundry apply, the root-owned runner creates /volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret atomically (root:gid 1000, mode 0640). Both containers receive that file only as a read-only mount. The value is never printed, backed up with source, included in an artifact, or administered through Foundry.

proxy-contur is the canonical VPN egress for selected Map Gateway provider hosts. Its existing root-owned PROXY_TOKEN is copied by the runner into /volume1/docker/nodedc-platform/secrets/map-egress-proxy-token with root:gid 1000, mode 0640, then mounted read-only only into Map Gateway. The value is neither printed nor contained in an artifact, Foundry setting, or browser response. Apply the proxy-contur artifact before the Platform Map Gateway artifact: it creates the private nodedc-map-egress Docker network.

dc-amd-proxy is the separate, staged connector for the neighbouring AMD VPN machine. Its active artifact attaches only to the private nodedc-map-egress network, exposes a narrow NAS-LAN pairing port, and has no direct provider egress. The runner preserves a 0700, service-user-owned runtime directory for the one-time paired connector credential and synchronizes the existing private Map Gateway egress credential as a read-only file. Neither value is ever placed in an artifact, .env, browser response, or runner output. The separate Platform switch is applied only after the connector and pairing are verified; it does not alter NAS routes, VPN, DNS, or Tailscale.

Install or update the root-owned live runner on Synology:

# First verify that no deploy process is active and state/deploy.lock is absent.
sudo install -o root -g root -m 0755 \
  /volume1/docker/nodedc-deploy/runner-install/nodedc-deploy \
  /usr/local/sbin/nodedc-deploy
sudo /usr/local/sbin/nodedc-deploy verify-install

Runner promotion is a standalone admin step, never an app-overlay artifact and never part of a running apply. A Python process already executing the old file keeps the old code in memory; always invoke a fresh verified process afterward.

Normal service deploys must still use explicit artifacts:

sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz
sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/<artifact>.tgz

Device Plane foundation

device-plane is an additive component rooted at:

/volume1/docker/nodedc-device-plane

Its fixed Compose project is nodedc-device-plane. Ordinary application artifacts may select only device-control-core and device-gateway, always with --no-deps. device-postgres and the named nodedc-device-plane-postgres-data volume are durable prerequisites and are never selected or recreated by an application overlay.

The sole exception is the exact one-time bootstrap artifact containing only the reviewed Compose file and deployment/device-postgres-bootstrap-v1.json. Its preflight requires both the Compose database container and named volume to be absent. It selects only device-postgres; a failed activation may remove that candidate container but never the volume. Any pre-existing container or volume is an ambiguity and fails closed.

The runner creates or validates three root-owned secret files outside the artifact: the PostgreSQL password, Gateway-to-Core token and restricted identifier pepper. The manifest cannot choose their paths or values.

The foundation publishes only loopback health endpoints on 18120 and 18121. Raw device ingress 9921, discovery ingest and outbound command transport remain disabled. The first application artifact must not be built or staged until this runner candidate is separately reviewed, promoted and proven by a fresh verify-install.

Build and test the deterministic data-only artifact contract locally:

python3 -m unittest -v \
  infra.deploy-runner.test_device_plane_registry \
  infra.deploy-runner.test_device_plane_artifact

node infra/deploy-runner/build-device-plane-artifact.mjs \
  device-plane-foundation-YYYYMMDD-NNN

Any failed first activation removes only candidate Core/Gateway containers, never volumes, restores the source overlay and retains PostgreSQL state. The rollback baseline is taken from an explicit pre-apply Docker service inventory stored as runtime-before.json in the backup. The presence of the shared Compose file never implies that Core or Gateway existed before apply.

The exact device-plane-foundation-20260725-001 failed activation is recovered only through the registered deployment/device-plane-foundation-recovery-v1.json transition. Its artifact must reproduce the failed foundation source byte-for-byte. The runner validates the exact failed archive, journal, backup partition, partial live source and healthy observed Core/Gateway/PostgreSQL generations. Recovery publishes source and performs read-only runtime acceptance; it does not build, recreate, restart or remove containers. A failed recovery restores source only and leaves runtime unchanged.

Build and test that incident-specific deterministic recovery artifact:

python3 -m unittest -v \
  infra.deploy-runner.test_device_plane_foundation_recovery_artifact

node infra/deploy-runner/build-device-plane-foundation-recovery-artifact.mjs \
  device-plane-foundation-recovery-20260725-002

The recovery device-plane-foundation-recovery-20260725-002 is also terminal failed. Docker Engine 24 does not materialize requested host port mappings when the container is attached only to an internal network. Its registered successor is the exact deployment/device-plane-foundation-network-publication-v1.json transition. It keeps PostgreSQL exclusively on the existing internal private network and adds a second non-internal bridge only to Control Core and Gateway. Masquerade is disabled on that bridge; the only published ports remain 127.0.0.1:18120 and 127.0.0.1:18121. Device TCP 9921, public ingress, discovery ingest and command transport remain disabled.

This successor has an empty build set and force-recreates only device-control-core and device-gateway with --no-deps from their exact existing pull_policy: never images. Its predecessor barrier validates the terminal failed archive/journal/backup, partial source, exact running generations, absent actual port mappings, internal private network and absence of the control network. Acceptance requires new stateless container generations, the unchanged PostgreSQL generation and volume, exact actual loopback mappings, healthy HTTP contracts and a closed 9921.

Rollback removes only the two stateless candidate containers and the newly created unused control network, restores the partial source predecessor and recreates the internal-only Core/Gateway runtime from the sealed predecessor Compose bytes. PostgreSQL and its volume are never selected.

Build and test the deterministic network-publication successor:

python3 -m unittest -v \
  infra.deploy-runner.test_device_plane_foundation_network_publication_artifact

node \
  infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs \
  device-plane-foundation-network-publication-20260725-003

After the runner is promoted and freshly verified, bootstrap the durable prerequisite with a separate artifact before planning the application:

node infra/deploy-runner/build-device-plane-postgres-bootstrap-artifact.mjs \
  device-plane-postgres-bootstrap-YYYYMMDD-NNN