Status: active
Quick-start for authoring, signing, publishing, and assigning a new NodeModule. Covers manifest.yaml schema, package_spec / file_spec / protected_spec semantics, Containerfile patterns, two-stage CI pipeline, and Cosign static-key signing (Gitea Actions isn't on Sigstore Fulcio's trusted-issuer list, so keyless signing never verifies server-side — see Phase 5).
Audience: module authors (internal + external open-source contributors), template designers composing fleet-wide assignments.
Added 2026-07-28 after a proposal to split five new modules out of dev-cell
was cut to one. Module sprawl is a real cost: every module is a manifest, a
build arm, a publish, a template assignment, a version to bump, and another
entry in every composing node's LKG manifest. Answer this BEFORE Phase 1.
First, check whether the capability already exists. Search the catalog by
PURPOSE, not by guessed name — the MCP actions system_discover_modules and
system_discover_templates rank existing modules/templates by semantic
similarity to a free-text intent ("reverse proxy with TLS", "metrics
scraper"):
system_discover_modules intent="reverse proxy terminating TLS"
system_discover_templates intent="public web serving stack"
Read the coverage field in the response before trusting an empty result. It
reports how much of the searched catalog actually carries an embedding; if
unembedded is non-zero, an empty result means not indexed, not "nothing
exists" — run rails system:catalog:backfill_embeddings (and
rails system:catalog:embedding_coverage to confirm) before concluding the
capability is missing. Ranking is vector-only with no keyword fallback, so an
unavailable embedding provider fails loudly rather than returning noise.
A new module must satisfy at least one of:
- R1 — Two or more real consumers today, or a hard
requires: capability:<name>edge from another module's manifest. "Assumed co-assigned on the same template" prose does NOT count: the resolver cannot enforce it, so it protects nothing. - R2 — An independent third-party payload with its own version/CVE cadence.
The per-module CVE remediation pipeline (
package_module_refresh,rolling_module_upgrade) operates per module, so a vendored upstream binary needs its own boundary to be CVE-bumped without dragging an unrelated module's churn. This is why traefik, gitleaks, act_runner and Chrome are separate. - R3 — An opt-in heavy payload a node type must be able to EXCLUDE
(
dev-cell-browser,dev-cell-docker).
This gate is machine-enforced over MCP (IMP-a67be4fe9041, 2026-08-21.) It used to be
prose only, which meant an agent authoring a module read past it. system_create_module
(and system_update_module on the bare-create-then-update path) now REFUSE a
manifest_yaml that would add a new name to the build planner's buildable set unless the
call carries a reuse_check:
reuse_check: {
considered: [ { module: "traefik", rejected_because: "terminates TLS, does not scrape" } ],
justification: "R2",
justification_detail: "vendored upstream binary with its own CVE cadence"
}
justification must be R1, R2 or R3; justification_detail must be non-blank; every
considered[].module is looked up in the same buildable set that defines novelty, so an
invented candidate refuses the call by name. The accepted declaration is persisted to
node_module.config["reuse_check"] with a checked_at stamp. A bare-field create (no
manifest) and a re-import onto an already-buildable module (a CVE version bump) are not
gated — neither is authoring.
The gate has a precondition of its own (IMP-45bda04c6123, 2026-08-25). A reuse_check
summarises a survey, and a survey is only worth anything if it could have found something.
system_discover_modules ranks over embeddings, so a buildable module is searchable
only when it is enabled AND carries an embedding AND that embedding is not stale (the row
has not been edited since the vector was generated). The gate measures that directly:
- Zero searchable modules ⇒ REFUSED. The survey could not have returned a candidate for
any intent, so "no existing module covers this" is the absence of a finding, not a
finding. The refusal names
rake system:catalog:backfill_embeddings. This is the state the live catalog was actually in on 2026-08-25: 42 buildable modules, 0 embeddings, and every reuse check since had cleared vacuously. - Partial coverage ⇒ ALLOWED, but disclosed. The response carries a
reuse_surveyblock naming how many buildable modules the survey could not see. It is not refused: nothing embeds a module on save today, so every module authored lands unsearchable, and refusing on any gap would demand a backfill between any two authorings. - An empty catalog is not an unsearchable one — the first module in an account is
authorable with
considered: [], exactly as before. - Escape hatch. If the embedding provider is unreachable and the module must be
authored anyway, pass
reuse_check.unindexed_catalog_ack: "<why>"— a non-blank string, not a flag. The module is then recorded as carrying an explicitly UNVERIFIED reuse check.
The accepted declaration is persisted with a catalog_coverage stamp
({total, embedded, searchable}) alongside checked_at, and both are read back on
system_get_module under reuse_check — so an auditor can later tell a real survey from a
blind one, which is precisely what the un-stamped record could not express.
The gate covers the MCP authoring surface only. The REST import_manifest endpoint,
the CI publish path, and rails db:seed call System::ManifestImportService directly and
are deliberately ungated: those are the human/committed paths, and Phase 0 above is the
gate for them. This is a sprawl gate, not a security boundary.
Otherwise, bake it into the owning node-type module's own build arm and
file_spec. Four of the five candidates in that review failed all three prongs
and stayed in dev-cell; runtime-go passed on R2 alone (Go ships security
releases roughly monthly, while dev-cell's scripts churn constantly — baked in,
every script tweak would re-ship a ~150MB-heavier blob and every Go CVE would
ride dev-cell's release treadmill).
Note that "it completes a family" is the WEAKEST justification — a family label
can rationalise any new member. runtime-* is demand-driven and should stay
that way: a language runtime earns a module only when the distro package cannot
satisfy the floor (ruby 3.2.8, node 24, go 1.25 all can't), never by analogy.
Where functionality belongs, once you have decided it is not its own module:
| Kind | Home |
|---|---|
| Fleet-wide generic mechanism | base-os-* (model: the ssh-hostkeys generalisation) |
| Per-node-type policy | the node-type module — never base-os |
| Per-instance data | never in any blob: identity envelope / fw_cfg / /persist |
That last row is an invariant, not a preference: a non-instance/node-specific module must not contain instance/node-specific files, and no instance-specific file belongs in a base-os build.
| Concept | What it is | Backing model |
|---|---|---|
| NodeModule | A reusable userspace component (e.g., nginx, k3s-server). Has a category + variety. | NodeModule |
| NodeModuleCategory | Ordered grouping (network=60, container runtimes=70, userland=90+). A platform-side DB row, not a manifest key — see note below. | NodeModuleCategory |
| Module variety | subscription (always-on) / config (overrides another module's config) / instance (per-instance customization) |
enum on NodeModule |
| NodeModuleVersion | A specific build of a module. It carries no lifecycle state: what runs where is a fact about each environment — NodeModule#current_version_id for a FOLLOWING plane, a ModuleEnvironmentPin for a PINNED one |
NodeModuleVersion |
| manifest.yaml | Authoring-time spec describing module identity + composition rules | YAML at the root of module-repo |
| package_spec | Debian packages installed into the module's rootfs by the builder stage (mmdebstrap for platform modules, apt in the Containerfile for third-party repos) |
YAML field |
| file_spec | rsync-glob patterns determining which files from the rootfs/ tree end up in the module artifact | YAML field |
| protected_spec | Files this module owns — overrides from higher-priority modules are forbidden | YAML field |
| dependency_spec | Other modules this one requires (resolved by DependencyResolutionService) |
YAML field |
| Containerfile | Dockerfile-style recipe for the module's builder image (used by Gitea Actions to produce the rootfs) | Dockerfile syntax |
| erofs digest | fs-verity hash committed to the OCI artifact. The agent never refuses a mount over it: under the default module-signing mode (off) it is not checked, and under any other mode it is measured and reported (verify:module_fsverity_audit), never enforced. The blob's sha256 is the only enforced check unless signature enforcement is on. See agent/internal/verify/doc.go |
sha256 |
The canonical layout lives at templates/module-repo/ — copy it as a starting point:
my-module/
├── manifest.yaml # the spec (this runbook focuses on it)
├── Containerfile # builder image, third-party path (apt → rootfs)
├── rootfs/ # files copied into the module artifact
│ └── etc/
│ └── nginx/
│ └── nginx.conf
└── .gitea/
└── workflows/
└── build.yaml # two-stage CI: builder → composer
Create a Gitea repository under registry.example.com/<account>/modules/my-module (private by default; public is allowed for community modules). Push the skeleton.
Minimum viable manifest:
schema_version: 1
# Identity — flat top-level keys (no `identity:` wrapper).
name: my-nginx
display_name: "nginx 1.26 with TLS hardening"
description: "nginx 1.26 with TLS hardening + /healthz endpoint"
license: "BSD-2-Clause"
# Packages installed into the rootfs by the builder stage (mmdebstrap).
# These end up in /var/lib/dpkg/status of the resulting rootfs.
package_spec:
- nginx
- nginx-extras
# Paths this module OWNS in the artifact (rsync-include, flat glob list).
file_spec:
- "/etc/nginx/**"
- "/var/www/healthz/**"
# Paths to EXCLUDE from this module's blob (rsync-style mask, local-only).
mask:
- "/etc/nginx/sites-enabled/default" # don't ship the default vhost
# Files this module owns — no neighbor may ship these. Folded into every
# neighbor's effective_mask in both priority directions.
protected_spec:
- "/etc/nginx/conf.d/00-security.conf"
# Other modules this one requires/provides. Resolved transitively by
# DependencyResolutionService. Two `requires` forms — see below.
dependencies:
requires:
- "powernode/system-base@^1.0" # name pin
- "powernode/security-hardening@^1.0"
- "powernode/chrony@^1.0" # NTP for cert validation
- "capability:database.postgres@>= 16" # capability requirement
provides:
- "http-server"
- "http-server@1.26" # versioned — see belowName pin — <owner>/<module>@<constraint>, or a bare module name. Binds to one
specific module by its Gitea repo (gitea_repo_full_name) or name. The constraint is
recorded on the dependency edge but is not enforced: the platform has no module
semver to check it against (NodeModuleVersion#version_number is an integer counter,
not a semver string). That is why every pin in this repo is the conventional @^1.0.
Capability requirement — capability:<tag>[@<constraint>]. Says what you need
rather than who provides it: the importer picks the highest-priority module on the
account whose provides advertises <tag>. Any future module providing that tag
satisfies the requirement, with no manifest edit here.
requires:
- "capability:os.userland" # any provider of the tag
- "capability:database.postgres@>= 16" # provider must advertise a satisfying versionRules worth knowing before you write one:
- The constraint is
Gem::Requirementsyntax —>= 16,~> 1.0,1.0. The npm caret form (^16) is not valid here, even though name pins use it by convention. A malformed constraint is now rejected at import with a manifest error; it is also rejected at PR time bymodule-validate.yamlagainstmodule-manifest.schema.json. (Historically it was silently reported to the operator as a missing provider, which sent people off to author a module that already existed.) - A bare
providestag does not satisfy a versioned requirement. If you want to answercapability:http-server@>= 1.26, advertisehttp-server@1.26, nothttp-server. This is deliberate: it forces providers to state their version rather than being matched optimistically. - Unsatisfied capabilities are not a build failure. The import defers them, and
CapabilityGapSensorreports the standing gap to the operator each tick. It clears itself when a provider publishes. - Drift is advisory. The constraint is matched once at import; if the provider is
later re-imported at a version that no longer satisfies it,
DependencyResolutionServiceemits a:constraint_driftwarning but still resolves the module into the closure.
Field semantics:
name— globally unique within the account (platform appends a hash to disambiguate across accounts). The manifest'snameis the stable identifier; downstream tooling looks up theNodeModulerow by it.package_spec— apt packages installed by the builder stage. Applied via mmdebstrap to the platform rootfs (Phase 5 Stage 1).file_spec— flat array of rsync-style glob strings identifying paths this module owns. The artifact ships these.mask— paths to EXCLUDE from this module's blob at build time. Local-only — does NOT affect neighbor modules' blobs.protected_spec— files this module owns that NO neighbor module may ship. The build pipeline folds these into every neighbor's effective_mask in both priority directions, so a sensitive lower-module file (e.g./etc/shadowfrom system-base) cannot be overridden by a service module's overlay layer.dependencies.requires— modules pulled in transitively. Either a name pin (<owner>/<module>@<version-constraint>) or a capability requirement (capability:<tag>[@<constraint>]) — see The tworequiresforms above.dependencies.provides— capability tags this module advertises, bare (http-server) or versioned (http-server@1.26). Denormalized into thecapabilitiescolumn at import so capability requirements can be resolved without scanning every manifest. Only the versioned form can satisfy a versioned requirement.
Important — these are NOT in the manifest: category, variety, cosign_identity_regexp, cosign_issuer_regexp live on the platform-side NodeModule DB row (set at registration time via the operator UI's Catalog ▸ Modules page — /app/system/catalog/modules — using its "Create Module" action, which opens a form modal; there is no separate /new route — or as the category_id: argument to system_create_module_from_package). They are not validated by the manifest schema. The NodeModuleCategory a module belongs to is a platform-side DB row selected at registration — the seeded slugs (system-base, network-overlay, container-runtimes, security-hardening, userland) are operator-facing taxonomy on that row, never a manifest field. variety accepts subscription (turn it on; always present once assigned — e.g. nginx, k3s-server), config (modifies another module's config without rebuilding it — e.g. daemon-json-override for slice 10), or instance (per-NodeInstance customisation — higher effective_priority than subscription).
For the authoritative shape see extensions/system/templates/module-repo/manifest.yaml and extensions/system/modules/.schema/module-manifest.schema.json. The MODULE_MANIFEST_COMPLETE_SCHEMA.md doc (in the parent docs/ directory) is the operator-facing prose reference.
The Containerfile produces the builder image for the third-party per-repo build (templates/module-repo/.gitea/workflows/build.yaml): it installs package_spec with apt and layers your rootfs/ on top. The composer stage then carves the module out of that image. (Platform modules under modules/*/ take a different Stage 1 — see Phase 5.)
# templates/module-repo/Containerfile
ARG UBUNTU_DIGEST=sha256:cdb5fd928fced577cfecf12c8966e830fcdf42ee481fb0b91904eeddc2fe5eff
FROM docker.io/library/ubuntu@${UBUNTU_DIGEST}
# Packages from the dispatched package_spec — the composer stage writes the
# spec to /workspace/package_spec.txt before invoking the build.
COPY package_spec.txt /tmp/package_spec.txt
RUN xargs -a /tmp/package_spec.txt apt-get install -y --no-install-recommends
# Your rootfs/ tree lands at the filesystem ROOT, on top of the package
# install — this is how a module overrides its package's defaults.
COPY rootfs/ /The base is docker.io/library/ubuntu pinned to an immutable digest, not a floating tag (SLSA L3+ reproducibility); override with --build-arg UBUNTU_DIGEST=sha256:... to rebuild against an older base. There is no entrypoint and no artifact emitted here: the builder image is the input to the composer stage, which rsyncs out only what file_spec matches.
rootfs/ tree:
rootfs/
└── etc/
└── nginx/
├── conf.d/
│ ├── 00-security.conf # in protected_spec — owned by this module
│ └── 10-app.conf # composable; lower-priority modules can override
└── nginx.conf
The platform's authority on file paths trumps your repo: if a higher-priority module owns /etc/nginx/nginx.conf via its protected_spec, your file_spec for it is silently dropped during composition.
There is no local build dry-run — the two-stage mmdebstrap → mkfs.erofs
pipeline (Phase 5) only runs in Gitea Actions. (An earlier local --dry-run
flow against a published builder image was described here and has been
retired; no such image is built or published by this pipeline.) Two real
local/pre-push checks exist instead:
Schema-validate against the JSON schema (works before the module is even
registered — no NodeModule row required):
# From your module-repo working tree
yq -o=json '.' manifest.yaml > /tmp/manifest.json
npx --yes ajv-cli@5 validate \
-s <path-to-checkout>/modules/.schema/module-manifest.schema.json \
-d /tmp/manifest.json \
--spec=draft2020 --all-errorsThis is exactly what module-validate.yaml
runs at PR time — running it locally first just fails faster. Catches typos
(fil_spec: vs file_spec:), missing required fields, bad enum values, and
malformed capability:<tag>[@<constraint>] constraints.
Verify against the platform's compatibility check (no upload, but
requires an existing NodeModule — register the module first via the
operator UI's Catalog ▸ Modules page (/app/system/catalog/modules) using
its "Create Module" action, or via system_create_module_from_package,
then use this to validate subsequent manifest revisions against it):
platform.system_validate_module_manifest({
module_id: "<existing NodeModule id — manifest name must match its name>",
manifest_yaml: "<contents of manifest.yaml>"
})
// → { valid: true, validation_errors: [] } | { valid: false, error: "...", validation_errors: [...] }This catches protected_spec collisions with existing modules in your account before you push.
Push your repo. The .gitea/workflows/build.yaml triggers on push:
# Two-stage build pipeline
on: [push]
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build module artifact
run: |
# Canonical workflow bootstraps a rootfs directly, not docker build.
# See templates/module-repo/.gitea/workflows/build.yaml for the
# authoritative two-stage pipeline (rootfs bootstrap → rsync filter →
# mkfs.erofs → fs-verity → syft + grype → cosign sign → oras push).
bash scripts/module-build/stage1-rootfs.sh --module my-nginx
# ... composer stage runs mkfs.erofs + emits the artifact bundle ...
- name: Push to OCI registry
run: |
oras push registry.example.com/<account>/modules/my-nginx:${{ github.sha }} \
./dist/module.tar:application/vnd.powernode.module.v1+tar
- name: Sign with Cosign (static key)
run: |
cosign sign --key env://COSIGN_PRIVATE_KEY --yes registry.example.com/<account>/modules/my-nginx:${{ github.sha }}
env:
COSIGN_PRIVATE_KEY: ${{ secrets.POWERNODE_COSIGN_PRIVATE_KEY }}Do not copy the workflow inline — use the canonical version at templates/module-repo/.gitea/workflows/build.yaml. The example above sketches the shape; the canonical workflow handles the full two-stage build, SBOM/VEX generation, in-toto provenance attestations, and OCI referrers. Diverging from the canonical workflow risks composing modules that the platform's ModuleOciIngestService rejects.
What happens behind the scenes:
- Builder stage: mmdebstrap installs packages from
package_specinto a clean Debian rootfs - Composer stage: rsync applies your
rootfs/tree perfile_specrules; mkfs.erofs computes the fs-verity digest - Artifact emission: tar of the erofs lower layer + manifest.json (parsed) + erofs digest
- OCI push:
orasuploads the artifact toregistry.example.com - Cosign signing: static-key signing — Gitea Actions isn't on Sigstore Fulcio's trusted-issuer list, so keyless certs would never verify server-side. The
assemblejob signs withPOWERNODE_COSIGN_PRIVATE_KEY, a Gitea Actions secret you add to your repo (ask your platform operator for the value — it's the private half of the platform'sPOWERNODE_COSIGN_PUBLIC_KEY). Keyless/Fulcio signing only applies to modules actually built on a Fulcio-trusted CI (e.g. GitHub Actions), not this Gitea template.
The Gitea webhook (POST /api/v1/system/webhooks/gitea/module) is what triggers ingestion — there is no polling timer. GiteaModuleController verifies the HMAC and calls System::ModulePublicationProcessor.process!, either inline (dev) or via the worker's System::ProcessModulePublicationJob calling back to the worker API (production default, POWERNODE_WEBHOOK_INGEST_MODE=async). The processor calls ModuleOciIngestService.ingest!, which verifies the Cosign signature and creates a NodeModuleVersion row. By default that means cosign verify against the platform's static POWERNODE_COSIGN_PUBLIC_KEY; the NodeModule's cosign_identity_regexp / cosign_issuer_regexp (set on the DB row, not the manifest) only come into play on the keyless fallback path, for modules signed by a genuinely Fulcio-trusted issuer.
// Every module verb takes the module's UUID, never its name. Resolve it once —
// system_list_modules returns { id, name, ... } for the account's catalog.
platform.system_list_module_versions({ module_id: "<module-id>" })
// → { versions: [{ id, module_id, version_number, current, pinned_in: [], oci_digest, ... }] }current: true marks the version the FOLLOWING planes serve (dev, ci, ops by default — they take whatever is published); pinned_in names the PINNED planes serving it (staging, prod by default — they serve only what was promoted into them, and nothing at all of a module never promoted there).
Walk the ladder of PLANES, one rung at a time. The rung below is the nearest lower pinned plane; when none sits below, the version must be what the following planes already run.
// Into the bottom pinned rung. Consults PromotionCriteria (N healthy instances
// on the rung below ran this digest for the dwell) and WARNS without refusing —
// read promotion_criteria / promotion_criteria_warning in the response.
platform.system_promote_module_version({ module_id: "<module-id>", environment: "staging" })
// Onward. prod is seeded supervised, so this PARKS for an operator: the reply
// carries { pending: true } and a deferred_operation_id, and nothing is
// promoted until someone approves. Do not retry it.
platform.system_promote_module_version({ module_id: "<module-id>", environment: "prod", version_id: "<version-id>" })A promotion moves that one plane's pin and arms RestartAfterUpdate for the version, so that plane's nodes restart onto it once they have materialised it. It does NOT move current_version_id — publishing does that, and system_rollback_module_version without environment: repoints it. There is no batched alternative for the pointer: it is per-module, so a fleet-global move is FLEET-ATOMIC (rolling_module_upgrade only SIZES it and executes nothing). See tutorials/06-rolling-upgrade.md.
The module_promotion_sensor raises system.module_promotion_ready when a pinned plane falls behind the rung below it AND the criteria pass, so an eligible promotion is offered rather than waited for.
Templates compose modules into reusable bundles:
platform.system_assign_module_to_template({
template_id: "<template-id>",
module_id: "<module-id>",
// Optional per-template config stored on the TemplateModule join. The key is
// `config` — `metadata` is not a declared parameter and is dropped WITHOUT an
// error (the tool validates required params, not unknown ones).
// Note this is not "config the agent sees at boot": TemplateApplyService
// creates each NodeModuleAssignment without copying the join's config, and
// NodeModuleAssignment#merged_config merges the module's config with the
// ASSIGNMENT's own, never the join's.
config: {
"purpose": "edge-cdn-tokyo"
}
})
// → { assignment: { id, template_id, module_id, priority, ... } }Priorities are determined by the module's category position + variety. The system_update_module_assignment MCP action toggles an assignment's enabled state, but not its priority — to override effective_priority (e.g., for a per-node config module that should win over a base subscription module), edit the assignment over REST:
# Bump effective_priority above userland (90) so a per-node config wins.
PATCH /api/v1/system/node_module_assignments/<assignment-id>
{ "effective_priority": 95 }To change which modules a template carries, use the assign/unassign MCP actions (system_assign_module_to_template / system_unassign_module_from_template) rather than an update call. Unassigning is irreversible: it destroys the join and, in the same transaction, purges every NodeModuleAssignment the join produced, so the module leaves those nodes on their next module sync (a row another join on the template still needs is re-pointed, and hand-authored assignments are untouched). To take a module out reversibly, set enabled: false with system_update_template_module instead.
Once assigned, every NodeInstance built from this template will pull the module on its next reconcile tick. Use system_drift_report to verify.
variety and parent_module are platform-side NodeModule fields, not
manifest keys — set them when you register the module (UI or
system_create_module_from_package). The manifest itself stays flat and only
contributes the override file:
schema_version: 1
name: nginx-tokyo-config
display_name: "nginx Tokyo overrides"
# This module *only* contributes file_spec — no packages, no erofs lower.
file_spec:
- "/etc/nginx/conf.d/99-tokyo.conf"Register this module with variety: instance on its platform-side row (higher
effective_priority than a subscription module). The manifest stays flat:
schema_version: 1
name: hostname-override
display_name: "Per-instance hostname"
# Templates evaluated per-NodeInstance with metadata bindings.
file_spec:
- "/etc/hostname"
- "/etc/hosts"
# The module-builder substitutes ${instance.hostname} from NodeInstance metadata.Register with variety: config + parent_module: chrony on the platform-side
row. file_spec and mask are flat top-level arrays in the manifest:
schema_version: 1
name: chrony-no-pool
display_name: "chrony without pool directives"
file_spec:
- "/etc/chrony/chrony.conf"
mask:
- "/etc/chrony/chrony.conf" # carve out parent's protected_spec ownershipThe mask directive is a deliberate escape hatch — use sparingly; it inverts the safety guarantee of protected_spec.
| Symptom | Cause | Fix |
|---|---|---|
ModuleManifestSchemaError on push |
YAML doesn't match schema_version | Run platform.system_validate_module_manifest locally first |
| Cosign signature rejected | Static-key mismatch (default path) — repo's POWERNODE_COSIGN_PRIVATE_KEY doesn't correspond to the platform's POWERNODE_COSIGN_PUBLIC_KEY; only the keyless fallback checks cosign_identity_regexp/cosign_issuer_regexp |
Confirm the repo's cosign key secret with your platform operator; for the keyless fallback, verify the signing CI's OIDC issuer matches your regexp |
Module shows in registry but no NodeModuleVersion row |
Ingestion is webhook-triggered, not polled — the Gitea webhook delivery never reached the platform, or (async mode) the worker callback failed | Check the repo's Gitea webhook deliveries (repo → Settings → Webhooks → Recent Deliveries) and redeliver the failed one; check journalctl -u 'powernode-*-rails.service' | grep GiteaModule for HMAC/lookup errors, and — when POWERNODE_WEBHOOK_INGEST_MODE=async (production default) — the worker's System::ProcessModulePublicationJob logs for ingest failures |
protected_spec collision on assignment |
Another module owns one of your protected files | Rename your file or use mask in a config-variety override |
| Assignment to template succeeds but agent doesn't pull | The version the node's ENVIRONMENT is served does not carry a mountable artifact: current_version_id for a following environment, the environment's pin for a pinned one. That — not promotion_state — is what the node-facing download resolves (NodeApi::ModulesController#download reads @module.served_version_for(current_instance.environment)&.artifact) |
Why the agent isn't pulling, below. Promoting is not the fix |
| fs-verity digest mismatch on agent | Module artifact corrupted during transit | Re-run CI build; the new tag's webhook delivery re-triggers ingestion — no need to wait |
What an agent receives depends on ITS PLANE, and on nothing carried by the version row
(the decorative promotion_state ladder was deleted in the Environment campaign,
increment 4b). NodeModule#current_version_id is what a node in a FOLLOWING environment
(auto_promote_on_publish true — dev, ci, ops by default) is served. A node in a PINNED
environment (staging, prod by default) is served that environment's pin instead
(NodeModule#served_version_for(environment)) — and NOTHING of a module that has never been
promoted into it. Flipping an environment to pinned (environment_update
auto_promote_on_publish: false) freezes every module there at its current version. That
write is human-only: from any MCP door, a dev cell's included, it parks until a person
approves it in their own session (Autonomy → approval queue), then runs as that person. A
publish never touches a pinned environment; only
system_promote_module_version with environment: moves a pin, one pinned rung at a time
(a following environment is not a rung). Start by finding out what the pointer points at —
system_list_module_versions marks the served row current: true and lists the pinned
environments under pinned_in — then match the cause:
1. The pointer was never moved onto your build. Publishing writes it by default, but withholds it in several cases, and the two publish paths report differently:
-
The Gitea module webhook (
gitea_module#handle→System::ModulePublicationProcessor) withholds when the module setsauto_promotefalse, when the erofs layer is under the non-empty floor, or whenSystem::CoreProvenanceGaterefuses the build's core provenance (that gate is inert unless the publish carries anative_build). Each emits a high-severitysystem.module_promotion_withheldevent naming the reason — read it first. -
The REST/worker publish (
ModulePublicationsController#create) withholds onauto_promotefalse, on the same non-empty floor, and on a batch-atomic deferral when a sibling module in the same build batch is still building. Only the deferral emits an event (system.module_promotion_deferred); the other two are log-only. So the absence of a withheld event does not mean the pointer moved — checkcurrent: truerather than inferring from events.Fix: repoint with
system_rollback_module_version({ module_id, version_id }). Despite the name it movescurrent_version_idforward as well as back, and it is the writer that armsRestartAfterUpdate. It refuses a target whose artifact is missing, whoseoci_digestis blank, or whose recorded size is below the same non-empty floor (NodeModuleVersion#rollback_usable?) — which is precisely the floor case above, so for that one repointing is not available: lower the floor (SiteSetting system.module_publish.min_artifact_bytes) and republish, or fix the build so the layer carries real content. A deferral needs no action — the orchestrator promotes the whole batch when its siblings finish.
2. The pointer was moved onto a spec-only version. A version row with no build behind
it became current_version_id, so download returns "Module has no published artifact"
and the agent has nothing to mount.
An ordinary spec edit is no longer a cause. Editing a module's versioned attributes
(file_spec, config, dependency_spec, protected_spec, …) still fires
NodeModule#auto_create_version, but since IMP-b7abf6c777da that records a version row and
leaves the pointer where it was — promotion is a separate, explicit decision. (Note the
edit still reaches attached instances: the node API serves mask/file_spec/package_spec/
dependency_spec/protected_spec/config off the module ROW, not off the version.)
Two writers still repoint without a build check, and they are what to look at here:
- Manifest import with
create_version: true— the REST routePOST /api/v1/system/node_modules/:id/import_manifest(the flag comes from params), and MCPsystem_create_modulewithmanifest_yaml, where it defaults to true.system_update_moduledefaults it to false. Neither CI publish path passes the flag. - The package build webhook, on a non-default
system.package_builds.mode.
Fix: republish so a build artifact lands on the new version, or repoint to the last version
that has one with system_rollback_module_version. The executable list of everything that
writes the column is server/spec/lint/node_module_current_version_write_seam_spec.rb.
Whether the promotion ladder should gate what the fleet serves is an open lifecycle-gating question tracked separately (IMP-c7d618b0b72f); this runbook describes current behaviour only.
When an operator chats "I need a new module for X" / "compose a template for nginx + TLS":
- Use
module_composeskill — semantically ranks existing modules (embedding cosine, keyword fallback) + drafts a Template - If a custom module is needed, surface this runbook + the
templates/module-repo/skeleton - For assignment workflows, use
system_assign_module_to_templatewithrequest_confirmation
templates/module-repo/README.md— skeleton this runbook expands ontemplates/example-modules/— 5 working examples (apache, chrony, nginx, rpi4-firmware, security-hardening)USE_CASE_MATRIX.md— composition use cases (long-lived edge, multi-tenant, per-tenant config)SKILL_EXECUTORS.md—module_composeskill for AI-assisted compositionrunbooks/cve-response.md— module updates triggered by CVE responseDISK_IMAGE_CI.md— companion pipeline for base disk images (vs. modules)
Last verified: 2026-08-04