Add an on/off switch for the MAS facility.

MAS-WP-0007 is finished. power.sh scales the MX and statute together
and records the power annotation. apply.sh keeps an off facility off.
The live facility was annotated power=on and was not restarted.

Assistant: grok
Assistant-Session: 01a198fa-fd27-7e41-a0a2-57355c3e4efd
This commit is contained in:
tegwick 2026-11-01 22:46:46 +01:00
parent b108e89af4
commit f18eb0b8df
12 changed files with 344 additions and 36 deletions

View file

@ -687,7 +687,8 @@ multimodal framework itself are out too.
3. v0.2 is declared final, or is withdrawn with recorded reasons;
4. the handoff reading of P2 is recorded in the v0.2 spec text, and the
address spec points to the mode contract;
5. the live test environment is retired, with its evidence archived;
5. the MAS facility can be turned off and back on, keeping its evidence,
credentials, and DNS;
6. mode contract v0.1 is published with JSON schemas and fixtures;
7. the email mode reference passes them in both roles: as master against
the callback stub, and as slave under the stub outside master;

View file

@ -58,9 +58,11 @@ delivery stays external.
outside master that issues case ids), a scripted callback stub, a stub
outside master and the flow runner. Email is the only real channel. See
`docs/reuse-surface.md`.
- **Live test environment.** `deploy/railiance/`: the MX for
- **MAS facility.** `deploy/railiance/`: the MX and statute for
`mas.coulomb.social`, with replies through Scaleway TEM capped at 300.
It is temporary; MAS-WP-0007 retires it.
`power.sh` turns the two Deployments on and off. Off keeps the
namespace, evidence volume, credentials, DNS, and LoadBalancer.
MAS-WP-0007.
## Out of Scope
@ -85,23 +87,22 @@ delivery stays external.
- Status: active. The reference experiment (MAS-WP-0002) passed its
decision gate on 2026-10-18; the findings are in
`docs/experiment-review.md`.
- Finished: MAS-WP-0001 to 0006 and 0008 to 0014. That covers the
Postfix profile, the v0.1.1 errata, v0.2 (final), the address composer,
the mode contract v0.1 (final) with authentication, one outbound path
and bundle-only flows, and the second implementation (`impl/js/`, both
specs, both contract sides).
- Open: MAS-WP-0007 (retire the live MX; backlog, kept by decision).
- Finished: MAS-WP-0001 to 0015. That covers the Postfix profile, the
v0.1.1 errata, v0.2 (final), the address composer, the mode contract
v0.1 (final) with authentication, one outbound path and bundle-only
flows, the second implementation (`impl/js/`, both specs, both contract
sides), and the switchable MAS facility.
- v0.2 is final (founder, 2026-10-19, after MAS-WP-0009). Mode contract
v0.1 is final too (founder, 2026-10-19, after MAS-WP-0014: independent
JS email side; MCP-1 to MCP-6).
- P2 is decided: `!MODE` requests a handoff (founder, 2026-10-19; SCP-8,
v0.2 §7.1). The mode contract and its reference implementation are
done (MAS-WP-0010); `mas test` runs the contract corpus.
- The live MX runs image `mas-live:v0.2-480e69a` (redeployed 2026-10-19)
and is kept up by founder decision (MAS-WP-0007 stays in backlog).
The deployment with MAS-WP-0012 (one outbound path) is recorded in
MAS-WP-0007.
- Tests: `.venv/bin/pytest` runs 274 tests, offline apart from loopback
- The MAS facility runs image `mas-live:v0.2-480e69a` (redeployed
2026-10-19). Founder decision 2026-11-01: keep it and power it on or
off (MAS-WP-0007). It was left on. The 300-reply cap, the side-loaded
image, and snakeoil SMTP TLS stay until a later decision.
- Tests: `.venv/bin/pytest` runs 280 tests, offline apart from loopback
fixtures, the Docker probe and Node for `impl/js/`. `mas test` runs
four corpora: parser 64, statute 16, v0.2 statute-aware 33, mode
contract 195. `cd impl/js && node --test` runs 85 more.

View file

@ -14,7 +14,7 @@
| workplan | MAS-WP-0004 | finished | — | workplans/MAS-WP-0004-postfix-integration-profile.md |
| workplan | MAS-WP-0005 | finished | — | workplans/MAS-WP-0005-spec-errata-v0-1-1.md |
| workplan | MAS-WP-0006 | finished | — | workplans/MAS-WP-0006-address-composer.md |
| workplan | MAS-WP-0007 | active | — | workplans/MAS-WP-0007-switchable-facility.md |
| workplan | MAS-WP-0007 | finished | — | workplans/MAS-WP-0007-switchable-facility.md |
| workplan | MAS-WP-0008 | finished | — | workplans/MAS-WP-0008-finalize-v0-2.md |
| workplan | MAS-WP-0009 | finished | — | workplans/MAS-WP-0009-declare-v0-2-final.md |
| workplan | MAS-WP-0010 | finished | — | workplans/MAS-WP-0010-mode-reference-implementation.md |
@ -61,9 +61,9 @@
| task | MAS-WP-0006-T01 | done | — | workplans/MAS-WP-0006-address-composer.md |
| task | MAS-WP-0006-T02 | done | — | workplans/MAS-WP-0006-address-composer.md |
| task | MAS-WP-0007-T01 | cancel | — | workplans/MAS-WP-0007-switchable-facility.md |
| task | MAS-WP-0007-T02 | todo | — | workplans/MAS-WP-0007-switchable-facility.md |
| task | MAS-WP-0007-T03 | todo | — | workplans/MAS-WP-0007-switchable-facility.md |
| task | MAS-WP-0007-T04 | todo | — | workplans/MAS-WP-0007-switchable-facility.md |
| task | MAS-WP-0007-T02 | done | — | workplans/MAS-WP-0007-switchable-facility.md |
| task | MAS-WP-0007-T03 | done | — | workplans/MAS-WP-0007-switchable-facility.md |
| task | MAS-WP-0007-T04 | done | — | workplans/MAS-WP-0007-switchable-facility.md |
| task | MAS-WP-0008-T01 | done | — | workplans/MAS-WP-0008-finalize-v0-2.md |
| task | MAS-WP-0009-T01 | done | — | workplans/MAS-WP-0009-declare-v0-2-final.md |
| task | MAS-WP-0009-T02 | done | — | workplans/MAS-WP-0009-declare-v0-2-final.md |

View file

@ -1,4 +1,4 @@
# MAS live-test MX for mas.coulomb.social (MAS-WP-0003).
# MAS facility image for mas.coulomb.social (MAS-WP-0007).
# Build from the repo root: docker build -f deploy/railiance/Dockerfile .
FROM debian:bookworm-slim
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \

View file

@ -0,0 +1,37 @@
# MAS facility
The MX and the statute for `mas.coulomb.social`. Replies go out through
Scaleway TEM and stay capped at 300. MAS-WP-0007.
## On and off
`power.sh` scales `deploy/mas-mx` and `deploy/mas-statute` together. It
writes annotation `mas.coulomb.social/power` on namespace `mas`.
| Power | What runs | What stays |
|---|---|---|
| `on` | both Deployments at 1 replica | evidence PVC, Services, Ingress, ExternalSecret, DNS, OpenBao role |
| `off` | both Deployments at 0 | the same durable parts; port 25 has no ready pod |
```bash
KUBECONFIG=~/.kube/config-railiance01 deploy/railiance/power.sh status
KUBECONFIG=~/.kube/config-railiance01 deploy/railiance/power.sh off
KUBECONFIG=~/.kube/config-railiance01 deploy/railiance/power.sh on
```
The workstation's default kubeconfig is `ops-mason-builder`. That identity
can see Services and cannot scale these Deployments. Use the railiance01
admin kubeconfig for `power.sh` and `apply.sh`.
`apply.sh IMAGE` renders `manifests.yaml` and applies it. When the
annotation is `off`, both Deployments are rendered at 0 replicas, so a
later apply does not turn the facility back on. A missing annotation means
on. The script stops if it cannot read the namespace.
## Limits
One replica. The image is side-loaded into k3s containerd, not pulled from
a registry. SMTP TLS is the image's snakeoil certificate. The running image
is `docker.io/library/mas-live:v0.2-480e69a`, whose banner still says
`MAS live test`. These stay until a later decision. This switch does not
roll a new image.

23
deploy/railiance/apply.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
# Apply the facility manifest without undoing an off switch (MAS-WP-0007).
# A missing power annotation is on. Refuses to apply when the namespace
# annotation cannot be read.
set -eu
cd "$(dirname "$0")"
if [ "$#" -ne 1 ]; then
echo "usage: apply.sh IMAGE" >&2
exit 2
fi
image=$1
if ! power=$(kubectl get namespace mas \
-o jsonpath='{.metadata.annotations.mas\.coulomb\.social/power}'); then
echo "cannot read namespace mas; refusing to apply" >&2
exit 1
fi
if [ -z "$power" ]; then
power=on
fi
python3 ./facility.py render "$image" "$power" < manifests.yaml | kubectl apply -f -

View file

@ -0,0 +1,81 @@
"""Render the MAS facility manifest for a power state.
This file does not talk to the cluster. power.sh and apply.sh do.
"""
from __future__ import annotations
import sys
SWITCHABLE = ("mas-mx", "mas-statute")
def render(manifest: str, image: str, power: str) -> str:
"""Return the manifest with IMAGE filled in and replicas set from power.
`on` keeps one replica of each switchable Deployment. `off` sets both
to zero. Services, the evidence volume, the Ingress, and the
ExternalSecret are copied through unchanged.
"""
if power not in ("on", "off"):
raise ValueError(f"power must be on or off, got {power!r}")
if not image or any(ch.isspace() for ch in image):
raise ValueError("image must be a single token")
if "image: IMAGE" not in manifest:
raise ValueError("manifest has no image: IMAGE placeholder")
rendered = manifest.replace("image: IMAGE", f"image: {image}")
docs = rendered.split("\n---\n")
seen: set[str] = set()
out: list[str] = []
for doc in docs:
name = _deployment_name(doc)
if name in SWITCHABLE:
seen.add(name)
doc = _set_replicas(doc, 0 if power == "off" else 1)
out.append(doc)
missing = [name for name in SWITCHABLE if name not in seen]
if missing:
raise ValueError("manifest is missing Deployments: " + ", ".join(missing))
return "\n---\n".join(out)
def _deployment_name(doc: str) -> str | None:
kind = None
name = None
for line in doc.splitlines():
if line.startswith("kind:"):
kind = line.split(":", 1)[1].strip()
elif line.startswith(" name:") and name is None:
name = line.split(":", 1)[1].strip()
if kind == "Deployment":
return name
return None
def _set_replicas(doc: str, count: int) -> str:
lines = doc.splitlines(keepends=True)
found = 0
out = []
for line in lines:
if line.startswith(" replicas:"):
found += 1
newline = "\n" if line.endswith("\n") else ""
out.append(f" replicas: {count}{newline}")
else:
out.append(line)
if found != 1:
raise ValueError(f"expected one replicas field, found {found}")
return "".join(out)
def main(argv: list[str]) -> int:
if len(argv) != 4 or argv[1] != "render":
print("usage: facility.py render IMAGE on|off", file=sys.stderr)
return 2
sys.stdout.write(render(sys.stdin.read(), argv[2], argv[3]))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View file

@ -1,9 +1,11 @@
# MAS live provider test (mail-address-semantics MAS-WP-0003).
# Founder go for A-D: 2026-10-08. Temporary: delete the namespace after the test.
# A namespace + MX pod B LoadBalancer :25 C statute-only ingress D no outbound mail
# D superseded 2026-10-18: operator chose replies via Scaleway TEM (Secret mas-relay,
# synced by ExternalSecret from OpenBao platform/suppliers/scaleway/mas-tem-relay).
# IMAGE is replaced with the pushed digest at apply time.
# MAS facility for mas.coulomb.social (MAS-WP-0007).
# Durable: this namespace, the evidence PVC, Services, Ingress, ExternalSecret.
# Switchable: Deployments mas-mx and mas-statute (replicas 1 on, 0 off).
# deploy/railiance/apply.sh reads annotation mas.coulomb.social/power and
# renders replicas 0 when the facility is off. A missing annotation means on.
# Replies use Scaleway TEM (Secret mas-relay, from OpenBao
# platform/suppliers/scaleway/mas-tem-relay), capped at 300.
# IMAGE is replaced at apply time. The running image is side-loaded.
apiVersion: v1
kind: Namespace
metadata:
@ -20,8 +22,8 @@ spec:
---
# TEM SMTP login from OpenBao (ops-mason MASON-WP-0014, CCR-2026-0051).
# OpenBao role external-secrets-mas-tem-relay and its read policy are
# cluster-scoped. Retire them at MAS-WP-0003 close; deleting the namespace
# does not remove them.
# cluster-scoped. They stay with the facility. Deleting the namespace would
# not remove them, and the power switch does not delete the namespace.
apiVersion: v1
kind: ServiceAccount
metadata: {name: mas-tem-relay-eso, namespace: mas}

73
deploy/railiance/power.sh Executable file
View file

@ -0,0 +1,73 @@
#!/bin/bash
# Turn the MAS facility on or off (MAS-WP-0007).
# Scales only deploy/mas-mx and deploy/mas-statute. The namespace, evidence
# PVC, Services, Ingress, and ExternalSecret stay.
# Records the desired power on namespace mas:
# mas.coulomb.social/power=on|off
set -eu
set -o pipefail
ns=mas
annotation=mas.coulomb.social/power
deployments="mas-mx mas-statute"
usage() {
echo "usage: power.sh on|off|status" >&2
exit 2
}
power_now() {
kubectl get namespace "$ns" \
-o jsonpath='{.metadata.annotations.mas\.coulomb\.social/power}'
}
wait_ready() {
name=$1
kubectl -n "$ns" rollout status "deploy/$name" --timeout=180s
}
wait_stopped() {
name=$1
i=0
while [ "$i" -lt 90 ]; do
left=$(kubectl -n "$ns" get pods -l "app.kubernetes.io/name=$name" --no-headers 2>/dev/null | wc -l)
left=$(echo "$left" | tr -d ' ')
if [ "$left" -eq 0 ]; then
return 0
fi
i=$((i + 1))
sleep 2
done
echo "pods for $name are still running" >&2
exit 1
}
cmd=${1:-}
case "$cmd" in
on|off)
kubectl annotate namespace "$ns" "$annotation=$cmd" --overwrite
if [ "$cmd" = on ]; then
replicas=1
else
replicas=0
fi
for name in $deployments; do
kubectl -n "$ns" scale "deploy/$name" --replicas="$replicas"
done
for name in $deployments; do
if [ "$cmd" = on ]; then
wait_ready "$name"
else
wait_stopped "$name"
fi
done
;;
status)
echo "power=$(power_now)"
kubectl -n "$ns" get deploy $deployments
kubectl -n "$ns" get endpointslice -l 'kubernetes.io/service-name in (mas-smtp,mas-statute)'
;;
*)
usage
;;
esac

View file

@ -1,6 +1,7 @@
# No owner classification file. Recorded confidential/medium for this
# validation MX: it holds relay credentials and message evidence, and it is
# capped rather than a production mail service.
# No owner classification file. Recorded confidential/medium: this facility
# holds relay credentials and message evidence. It is the operated MAS
# facility for mas.coulomb.social and can be powered off without removing
# that state. Replies stay capped at 300.
kind: release-unit
unit_id: mas
owner: mail-address-semantics
@ -8,7 +9,7 @@ contract_version: 1.0.0
data_classification: confidential
criticality: medium
composition:
purpose: Covers the live MAS reference MX and statute service.
purpose: Covers the MAS facility (MX and statute), powered on or off as a pair.
member_repos:
- repo: mail-address-semantics
role: rollout owner

View file

@ -0,0 +1,74 @@
"""MAS facility power render (MAS-WP-0007). No cluster."""
import importlib.util
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location(
"facility", ROOT / "deploy/railiance/facility.py"
)
_facility = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_facility)
render = _facility.render
MANIFEST = (ROOT / "deploy/railiance/manifests.yaml").read_text()
POWER = (ROOT / "deploy/railiance/power.sh").read_text()
APPLY = (ROOT / "deploy/railiance/apply.sh").read_text()
IMAGE = "docker.io/library/mas-live:v0.2-480e69a"
DURABLE = (
"kind: PersistentVolumeClaim",
"name: mas-evidence",
"name: mas-smtp",
"kind: Ingress",
"kind: ExternalSecret",
"name: mas-relay",
)
def docs(text):
return text.split("\n---\n")
def deployment(text, name):
for doc in docs(text):
if doc.startswith("apiVersion: apps/v1") and f"\n name: {name}\n" in doc:
return doc
raise AssertionError(name)
def test_off_stops_both_deployments_and_keeps_durable_objects():
rendered = render(MANIFEST, IMAGE, "off")
assert rendered.count("\n replicas: 0\n") == 2
assert "replicas: 1" not in rendered
for name in ("mas-mx", "mas-statute"):
assert "\n replicas: 0\n" in deployment(rendered, name)
for marker in DURABLE:
assert marker in rendered
assert f"image: {IMAGE}" in rendered
assert "image: IMAGE" not in rendered
def test_on_leaves_one_replica_each():
rendered = render(MANIFEST, IMAGE, "on")
assert rendered.count("\n replicas: 1\n") == 2
assert "replicas: 0" not in rendered
for marker in DURABLE:
assert marker in rendered
def test_render_rejects_unknown_power_and_a_blank_image():
with pytest.raises(ValueError):
render(MANIFEST, IMAGE, "standby")
with pytest.raises(ValueError):
render(MANIFEST, "mas live", "on")
def test_operator_command_does_not_delete_durable_objects():
assert POWER.startswith("#!/bin/bash\n")
for script in (POWER, APPLY):
assert "kubectl delete" not in script
assert "delete namespace" not in script
assert "mas-mx" in POWER and "mas-statute" in POWER
assert "mas.coulomb.social/power" in POWER
assert "facility.py render" in APPLY

View file

@ -4,7 +4,7 @@ type: workplan
title: "Operate MAS as a switchable facility"
domain: communication
repo: mail-address-semantics
status: active
status: finished
owner: claude
topic_slug: coulomb-social
created: "2026-10-18"
@ -20,7 +20,7 @@ Residual of MAS-WP-0003. Namespace `mas` is the MAS facility for
`mas.coulomb.social`, with replies through Scaleway TEM capped at 300.
It stays. The 2026-10-19 retirement backlog is withdrawn.
**Founder decision (2026-11-01): keep it, and make it switchable.** Do not
**Founder decision (2026-11-01, `4d38ed47-8ab1-419e-839c-92921f7eeef1`): keep it, and make it switchable.** Do not
delete the namespace, revoke the TEM key, remove DNS, or tear down the
OpenBao role. Operate the existing facility so mail and the statute can
be turned off and back on without discarding evidence, credentials, or
@ -91,7 +91,7 @@ images.
```task
id: MAS-WP-0007-T02
status: todo
status: done
priority: high
state_hub_task_id: "18d8898e-52c9-572d-aa7c-014fb1f7751e"
```
@ -103,11 +103,16 @@ switchable parts and the limits above. The release-unit note stops
calling this something other than the operated facility. Historical
workplans and `history/` stay as they were.
Done 2026-11-01. `SCOPE.md`, INTENT.md criterion 5, `deploy/railiance/README.md`,
the manifest header, and `registry/release-units/mas.yaml` describe the
switchable facility. The 300-reply cap, side-loaded image, and snakeoil
SMTP TLS are recorded as limits.
## Add the power switch
```task
id: MAS-WP-0007-T03
status: todo
status: done
priority: high
state_hub_task_id: "78a11c89-7cda-5ff7-bd0a-b7958b6c9fe7"
```
@ -124,11 +129,19 @@ ingress.
On the cluster, record `power=on` to match the running facility. Do not
scale it to 0 and do not roll a new image.
Done 2026-11-01. `deploy/railiance/power.sh` and `apply.sh`, with
`facility.py` doing the render. Live: `power.sh on` with the railiance01
admin kubeconfig annotated namespace `mas` as `power=on`, scaled both
Deployments to 1, and both were already ready. Pod start times stayed
`mas-mx` 2026-10-21T22:18:00Z and `mas-statute` 2026-10-19T11:36:30Z.
`power.sh status` shows `power=on`, both Deployments 1/1, and endpoints
for `mas-smtp` and `mas-statute`. No new image.
## Test the switch offline
```task
id: MAS-WP-0007-T04
status: todo
status: done
priority: high
state_hub_task_id: "3f458be1-a8c8-5b05-96e8-851294e36d60"
```
@ -137,3 +150,5 @@ Test the render without a cluster. Power `off` sets both Deployment
replicas to 0 and leaves the Service, PVC, Ingress, and ExternalSecret
in place. Power `on` leaves `replicas: 1`. The image placeholder is
replaced. The operator command has no delete of those durable objects.
Done 2026-11-01. `tests/test_facility_power.py`: 4 passed.