# sow/laws.ncl — standing laws for the provisioning catalog. # # T2 of the frozen contract: provisioning/.coder/2026-07-03_leyes-catalogo-provisioning.plan.md # This file is a DRAFT until J3 closes: the human signs it with the dedicated minisign # key (see §9 J1/J3 of the contract). Until `signature` below is filled in and verified, # witness.nu MUST refuse to treat these laws as binding (guard: no valid signature, no # start — same rule as the SOW ratification gate in ADR-063). # # Every classification below (exempt/candidates) is traceable to the contract's §2/§5, # which record the verified evidence (file contents, git/Time-Machine snapshot diffs, # script headers) each ruling rests on. Nothing here is invented; where evidence was # ambiguous, the contract's §9 records the human ruling that resolved it. let schema = import "laws-schema.ncl" in { laws = [ { id = "L1-no-parsing-on-targets", status = 'Active, why = m%" PAP: controllers resolve, targets execute. Parsing (json/yaml/templates/ versions) belongs in prepare/postdeploy on the controller (nushell/nickel); targets receive fully-resolved artifacts. Burn: garage+loki deploy sessions shipped `python3 -c "import json…"` parsing to target nodes (2026-06-23 interview). R4 (2026-07-03) sharpened the boundary after a first-draft exemption for "live-state reads" was correctly rejected: the violation is not parsing or reading live state, it is THE TARGET DECIDING. `kubectl apply` (ideally --server-side) of a manifest prepare rendered fully-resolved is compliant even against a resource that didn't exist at prepare-time — Kubernetes' own merge semantics resolve "add if missing" with no target-side decision. Reading live state, deciding in ANY language, and imperatively patching is the same violation as the original python3 -c cases; the language carrying the decision logic is incidental. "%, kind = 'static, scope = ["**/*.sh", "**/*.j2", "**/*.yaml"], exclude = ["wrks/**", "providers/*/bin/**", "providers/*/templates/**"], # exclude rationale: # wrks/** — R1: temporary/archived material, not part of the final # project. Verified 2026-07-03 unchanged from original scan (no disguised # fix): install-stalwart.sh, install-metallb.sh, install-oci-reg.sh, # install-kubernetes.sh still carry their original python3 -c lines. # providers/*/bin/** — controller/operator-side cloud-provisioning # tooling (needs cloud API credentials a target never holds). Confirmed as a # directory-level convention repeated across aws/hetzner/local/upcloud, not # a one-off. Added after classifying providers/aws/bin/get-image.sh. # providers/*/templates/** — RESOLVED 2026-07-03, second pass. Running # the check for real (T3 red-team) surfaced 4 hetzner templates # (hetzner_networks.j2, hetzner_storage_box.j2, hetzner_servers.j2, # hetzner_volumes.j2) doing `hcloud ... -o json | nu -c '$env.HJSON | from # json | ...'` — get-decide-patch against the LIVE Hetzner Cloud API (R4 # violation shape), in nushell instead of jq. Confirmed controller-side: # referenced from core/nulib/orchestration/servers/create.nu, needs Hetzner # API credentials no target ever holds — same category as # providers/*/bin/get-image.sh, different subdirectory. Excluded rather than # tracked as a candidate (unlike zot-lib.sh/coredns_vpn) because it is # controller-side orchestration, not target execution — out of L1's scope by # definition, the same reasoning as backup_manager. check = m%"! rg -q 'python3? (-c|<<|-m )|import json' . --glob '*.sh' --glob '*.j2' --glob '*.yaml' --glob '!wrks/**' --glob '!providers/*/bin/**' --glob '!providers/*/templates/**' --glob '!components/odoo/cluster/templates/deployment.yaml.j2'"%, # check bug fix 2026-07-03: dropped the bare `from json` alternative — it matched # both Python's `from json import ...` AND Nushell's native `from json` filter # (`| from json |`), a false-positive collision across languages. `import json` # alone still catches every original burn case (stalwart/metallb/oci-reg/ # kubernetes all use `import json`, none use `from json import`). baseline = "", exempt = [ { path = "components/odoo/cluster/templates/deployment.yaml.j2", why = m%" R2: all python occurrences (14) execute inside k8s initContainers/ container commands on the odoo image, which ships python. Deploy machinery and target hosts assume nothing. This is the one exemption that matters for the mechanical check above (the only file where the python-pattern regex would otherwise fire). "%, }, { path = "components/fleet_daemon/cluster/install-fleet_daemon.sh", why = m%" jq -e . is syntax validation only (no field extraction, no live read) — a fail-fast guard on a value the controller already supplied via ACCESS_POLICY_JSON, catching malformed JSON at the bash boundary instead of inside the daemon at startup. Grants no new target-side authority or decision-making. Not matched by the python-only mechanical check; recorded here for audit completeness of the R4 jq classification pass. "%, }, { path = "components/backup_manager/cluster/install-backup_manager.sh", why = m%" The jq-using block runs where `nickel` is available — controller-side reporting for the human operator ("Run 'provisioning component install backup_manager --target-hosts' to deploy binaries"), not target execution. Out of L1's scope by definition, not by exemption from a violation. "%, }, { path = "components/lian_build/cluster/setup-buildkit-client.sh", why = m%" component.json, per the script's own header, is "NCL-derived config" — a controller-resolved artifact optionally dropped alongside the script as a fallback to env vars. The jq -r ... // empty call is a plain key lookup with default fallback on an already-resolved artifact, not a target-side decision. This script is a reference example of the R4-compliant pattern: its header states "declarative apply of lian-build's mTLS identity" and it does envsubst < tmpl | kubectl apply -f -, not get-decide-patch. "%, }, { path = "components/coredns_vpn/taskserv/install-coredns_vpn.sh:_patch_cluster_coredns", why = m%" RECLASSIFIED 2026-07-03 (was a candidate). Verified directly against k0s's own source (pkg/component/controller/coredns.go, upstream k0sproject/k0s): the default Corefile it generates has NO `import custom/*.override` or `coredns-custom` ConfigMap mechanism — that pattern exists on k3s (a different, unrelated project despite the similar name), not k0s, which this catalog targets (_resolve_kubeconfig references k0s admin.conf paths throughout). The field being touched (ConfigMap data.Corefile) is an opaque string blob with no native Kubernetes list-merge mechanism (unlike Gateway API's spec.listeners, which IS mergeable — see zot-lib.sh's candidate entry). Rendering the full Corefile statically in prepare would require prepare to know the cluster's actual current baseline in advance, which it cannot without either querying the live cluster (defeating the purpose) or assuming a static baseline that could silently diverge from reality and clobber unrelated cluster DNS config. The existing read-current-then-append- missing-zones pattern (idempotent: skips zones already present) is the most robust option actually available on this distribution today — not a violation avoided out of habit, a genuine absence of a safe declarative alternative. "%, }, { path = "components/zot/cluster/zot-lib.sh:_patch_public_gateway_listener,_remove_public_gateway_listener", why = m%" RESOLVED 2026-07-03 (was a candidate, then partially remediated, now fully). Both functions redesigned to declarative Server-Side Apply under field-manager=zot-registry: add applies manifests/public-gateway-listener.yaml.j2 (rendered, verified with nu_plugin_tera — the project's real mechanism, not the standalone tera CLI tried first — against 3 cases: cross-namespace cert, same- namespace, gateway-not-configured, all valid YAML); remove applies an explicit empty `listeners: []` under the same field manager. Gateway API's spec.listeners is x-kubernetes-list-type=map (merge key: name), so SSA tracks per-manager ownership of individual list entries — applying zero entries under zot-registry prunes exactly what that manager previously contributed, leaving listeners owned by any other field manager on this SHARED Gateway untouched; zot-registry never declared gatewayClassName or other top-level fields, so this cannot affect them either. Accepted on Gateway API's documented behavior and SSA's standard per-manager ownership semantics — not separately exercised against a live cluster (real-cluster validation was considered and explicitly deferred in favor of accepting the documented mechanism, human call). No get/decide/patch remains in either function; real catalog re-checked ACCEPTED after the change. "%, }, ], candidates = [], }, { id = "L2-no-undeclared-interpreters", status = 'Deferred, why = m%" Deferred 2026-07-03 (J2), two independent reasons: (1) R3 — no evidence on the active surface. The only occurrences (polkadot installers) live in wrks/**, excluded by R1. (2) No oracle — verified against the real schema. metadata.ncl's `dependencies` field is inter-component only (e.g. `["kubernetes"]` in local_path_provisioner/metadata.ncl, hccm/nickel/version.ncl) — it never names OS packages or language runtimes, and no schema.ncl constrains it. A check for "undeclared interpreter" has nothing to check against today. Path back to ratifiability (both required): a real burn lands on the active surface, AND metadata.ncl gains a typed field for declared OS/runtime dependencies. "%, kind = 'static, scope = ["**/*.sh", "**/*.j2", "**/*.yaml"], exclude = ["wrks/**", "providers/*/bin/**", "providers/*/templates/**"], check = "echo 'L2 is Deferred — not enforced, see status field and why' >&2; exit 1", baseline = "", exempt = [], candidates = [], }, { id = "L3-versions-from-lock", status = 'Active, why = m%" ADR-056: accredited, not generated. A version/tag with no verified provenance is not usable knowledge — the second original burn alongside L1's parsing violation ("se despliega con una tag inventada o la primera al caso... si hay incompatibilidades... ya se verá", 2026-07-03 interview). Looked for the existing mechanism before building anything (corrected mid-session after inventing an unnecessary `versions.lock` file first — see model log): `components/*/nickel/version.ncl` already exists (24 of 48 components use a rich shape: version.current/source/check_latest/ grace_period), and core/nulib/domain/cache/ already reads it for staleness tracking. The actual gap: nothing ever verified that `version.current` exists for real at `version.source` — the cache system trusts what's declared, it never accredits it. L3 closes exactly that gap; it does not duplicate the existing convention. "%, kind = 'static, # scope is deliberately narrow — matches exactly what check-pinned-versions.nu # actually verifies today (zot, forgejo), not the broader "components/*/ # nickel/version.ncl" pattern that would overstate coverage. Widening this # to the other 22 rich-shape components needs integration with the existing # cache/grace_period subsystem first (deferred — see why, and §11 of the # frozen contract) to avoid hammering registries on every witness run. scope = ["components/zot/nickel/version.ncl", "components/forgejo/nickel/version.ncl"], exclude = ["wrks/**"], check = "nu scripts/check-pinned-versions.nu", baseline = "", exempt = [], candidates = [ { path = "components/cert_manager/nickel/contracts.ncl:nushell_image", pattern = "auxiliary/helper image (ghcr.io/nushell/nushell:latest) with no version.ncl entry — cert_manager's own version.ncl already tracks a DIFFERENT thing (CERT_MANAGER_VERSION=v1.19.5, its own release), so this can't just be enriched the same way as zot/forgejo without conflating the two", remediation = "decide whether version.ncl should support multiple tracked images (a components/cert_manager/nickel/version.ncl images array) or a separate lock entry for auxiliary/helper containers — needs its own small design decision before fixing", }, { path = "components/woodpecker/nickel/{defaults,contracts}.ncl", pattern = "woodpecker/server:latest, woodpecker/agent:latest — 2 occurrences, real tag data not yet obtained", remediation = "docker.io anonymous registry lookup was denied (rate-limit/auth) when checked 2026-07-03 — retry via an authenticated pull, a mirror (e.g. ghcr.io if woodpecker publishes there), or `crane` (available locally) instead of skopeo against docker.io directly.", }, { path = "components/ontoref_panel/nickel/{defaults,contracts}.ncl", pattern = "reg.librecloud.online/lamina/ontoref-panel:latest — private registry, real tag data not yet obtained", remediation = "needs credentials for the internal registry (reg.librecloud.online) to run a real skopeo/crane lookup — not publicly queryable like the other 3.", }, ], }, ], # `signature` is intentionally OMITTED (schema: optional). It is filled in only at # J3, by the human, in their own shell — never by the agent, never as a placeholder. # witness.nu's guard: absent or unverifiable signature → these laws are non-binding. } | schema.Laws