From a35ea62ab11b63cf775427afad289e80c30b1690 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 2 Sep 2026 20:18:17 -0500 Subject: [PATCH 1/4] feat(release): RELEASE identity + image tag single-sourcing (#10) - RELEASE file: single source of release version (0.0.X until declared stable) - common.sh load_release(): validates version, derives MOSAIC_IMAGE_TAG=mosaic-poc-agent:-r from the pinned pi dep - compose.yaml: image tag is required env; build/hello/verify call load_release - verify.sh derives the image name instead of hardcoding it - package.json version aligned to the same 0.0.X line Closes #10 --- RELEASE | 1 + compose.yaml | 2 +- package.json | 2 +- scripts/build.sh | 1 + scripts/common.sh | 15 +++++++++++++++ scripts/hello.sh | 1 + scripts/verify.sh | 3 ++- 7 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 RELEASE diff --git a/RELEASE b/RELEASE new file mode 100644 index 00000000..bcab45af --- /dev/null +++ b/RELEASE @@ -0,0 +1 @@ +0.0.3 diff --git a/compose.yaml b/compose.yaml index 1a447020..0defc3a1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Containerfile - image: mosaic-poc-agent:0.84.4 + image: ${MOSAIC_IMAGE_TAG:?MOSAIC_IMAGE_TAG must be set by scripts/load_release (run via scripts/*.sh)} user: "1000:1000" environment: # Resolved from config.json by scripts/common.sh (load_config). diff --git a/package.json b/package.json index c88ca47b..f5673c09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mosaic-stack-dev-test", - "version": "0.1.0", + "version": "0.0.3", "private": true, "description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.", "license": "UNLICENSED", diff --git a/scripts/build.sh b/scripts/build.sh index e2758a8d..275989c5 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -6,6 +6,7 @@ cd "$(dirname "$0")/.." source scripts/common.sh load_config +load_release bootstrap_runtime_dir diff --git a/scripts/common.sh b/scripts/common.sh index e3e21abd..b62e6a59 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -19,6 +19,21 @@ load_config() { MOSAIC_DEV_DIR="$MOSAIC_DATA_ROOT" } +# Resolve the release identity: RELEASE is the single source of the +# release version (stays 0.0.X until declared stable); the image tag +# derives from it plus the pinned pi dependency version. +load_release() { + local release pi_version + release="$(tr -d '[:space:]' < RELEASE)" + if ! printf '%s' "$release" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "common: RELEASE must be a semver-ish version, got: '$release'" >&2 + exit 1 + fi + pi_version="$(node -p "require('./package.json').dependencies['@earendil-works/pi-coding-agent']")" + export MOSAIC_RELEASE="$release" + export MOSAIC_IMAGE_TAG="mosaic-poc-agent:${pi_version}-r${release}" +} + # Ensure the configured runtime data directory exists and carries this # project's ownership marker. The marker is what scripts/reset.sh requires # before it will delete anything. diff --git a/scripts/hello.sh b/scripts/hello.sh index bbbaf57a..becfd479 100755 --- a/scripts/hello.sh +++ b/scripts/hello.sh @@ -11,6 +11,7 @@ cd "$(dirname "$0")/.." source scripts/common.sh load_config +load_release bootstrap_runtime_dir diff --git a/scripts/verify.sh b/scripts/verify.sh index 54a47316..f807013d 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -14,10 +14,11 @@ cd "$(dirname "$0")/.." # shellcheck source=common.sh source scripts/common.sh -IMAGE="mosaic-poc-agent:0.84.4" EXPECTED="${EXPECTED_MARKER:-MOSAIC_HELLO_OK}" load_config +load_release +IMAGE="$MOSAIC_IMAGE_TAG" # Ensure the configured data root exists (host-owned) before the mount, # otherwise Docker would auto-create a root-owned directory. From a947db7bfd179e7cc53a7b616fd91f1ddb9b6d4b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 2 Sep 2026 20:20:32 -0500 Subject: [PATCH 2/4] feat(release): package/activate/rollback/status with health gate (#11) - activate: image-presence pre-check + M2 task-runner health gate (tasks/hello-marker.json exact marker) before atomic pointer replace (tmp+rename); every attempt appended to activation-log.jsonl - --fault-injection flips the health expectation to prove the refusal path - rollback: health-gated re-activation of the previous activated imageTag from the log; refuses when the image is gone or no previous exists - status: release, tag, pointer, recent log; safe on empty state - state lives under /state/ (config-independent, reset-scoped) Verified: activate OK; fault-injected refuse with pointer unchanged; rollback-without-previous refuse. Closes #11 --- scripts/release.sh | 149 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100755 scripts/release.sh diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..e67c9c0d --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Release lifecycle: package, activate, rollback, status. +# +# scripts/release.sh package build the image for this release +# scripts/release.sh activate [--fault-injection] +# scripts/release.sh rollback +# scripts/release.sh status +# +# Activation is health-gated: the M2 task runner executes +# tasks/hello-marker.json; only an exact-marker pass activates. The +# pointer (state/active.json) is replaced atomically; every attempt is +# appended to state/activation-log.jsonl (append-only history). +# +# Fault injection exists solely to prove the refusal path in drills. +set -euo pipefail +cd "$(dirname "$0")/.." +# shellcheck source=common.sh +source scripts/common.sh + +load_config +load_release +bootstrap_runtime_dir + +STATE_DIR="$MOSAIC_DEV_DIR/state" +mkdir -p "$STATE_DIR" +POINTER="$STATE_DIR/active.json" +LOG="$STATE_DIR/activation-log.jsonl" + +now() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +append_log() { # event release imageTag note + local event="$1" release="$2" imageTag="$3" note="${4:-}" + printf '{"at":"%s","event":"%s","release":"%s","imageTag":"%s"%s}\n' \ + "$(now)" "$event" "$release" "$imageTag" \ + "$(printf '%s' "$note" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const s=d.replace(/\n$/,"");process.stdout.write(s ? ",\"note\":"+JSON.stringify(s) : "")})')" \ + >> "$LOG" +} + +image_exists() { docker image inspect "$1" >/dev/null 2>&1; } + +health_check() { # returns 0 only when the marker path passes; $1 = fault injection label or empty + local tmp="" + local task="tasks/hello-marker.json" + if [ -n "${1:-}" ]; then + tmp="$(mktemp -d)" + # Fault injection: same prompt, deliberately wrong expectation. + printf '{"taskVersion":1,"id":"t-health-fault","prompt":"Return your startup marker and nothing else.","expectExact":"MOSAIC_FAULT_%s"}' \ + "$RANDOM$RANDOM" > "$tmp/fault-task.json" + task="$tmp/fault-task.json" + fi + local rc=0 + scripts/run-task.sh run "$task" >/dev/null 2>&1 || rc=$? + [ -n "$tmp" ] && rm -rf "$tmp" + return "$rc" +} + +activate() { # $1 = release, $2 = imageTag, $3 = event name, $4 = fault label + local release="$1" imageTag="$2" event="$3" fault="${4:-}" + + if ! image_exists "$imageTag"; then + echo "release: refusing $event: image not present locally: $imageTag" >&2 + append_log "refused" "$release" "$imageTag" "image missing" + exit 1 + fi + + if ! health_check "$fault"; then + echo "release: refusing $event: health check failed" >&2 + append_log "refused" "$release" "$imageTag" "health check failed${fault:+ (fault-injected)}" + exit 1 + fi + + # Atomic pointer replacement: write sibling temp file, then rename. + local tmp_pointer="$POINTER.tmp.$$" + printf '{"pointerVersion":1,"release":"%s","imageTag":"%s","activatedAt":"%s"}\n' \ + "$release" "$imageTag" "$(now)" > "$tmp_pointer" + mv -f "$tmp_pointer" "$POINTER" + + append_log "$event" "$release" "$imageTag" + echo "release: $event OK -> $release ($imageTag)" +} + +previous_image_tag() { # last activated imageTag different from current pointer + [ -f "$POINTER" ] || return 1 + local current + current="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).imageTag' "$POINTER")" + node -e ' +const fs = require("fs"); +const current = process.argv[1]; +const lines = fs.readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean); +for (let i = lines.length - 1; i >= 0; i--) { + let e; + try { e = JSON.parse(lines[i]); } catch { continue; } + if ((e.event === "activate" || e.event === "rollback") && e.imageTag && e.imageTag !== current) { + console.log(e.imageTag); + process.exit(0); + } +} +process.exit(1); +' "$current" "$LOG" +} + +cmd_status() { + echo "release: $MOSAIC_RELEASE" + echo "image tag: $MOSAIC_IMAGE_TAG (packaged: $(image_exists "$MOSAIC_IMAGE_TAG" && echo yes || echo no))" + if [ -f "$POINTER" ]; then + node -e ' +const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); +console.log("active: " + p.release + " (" + p.imageTag + ") since " + p.activatedAt); +' "$POINTER" + else + echo "active: (none)" + fi + if [ -f "$LOG" ]; then + echo "recent log:" + tail -5 "$LOG" | sed 's/^/ /' + fi +} + +cmd_package() { + docker compose build + append_log "package" "$MOSAIC_RELEASE" "$MOSAIC_IMAGE_TAG" + echo "release: packaged $MOSAIC_IMAGE_TAG" +} + +cmd_activate() { + local fault="" + if [ "${1:-}" = "--fault-injection" ]; then fault="yes"; fi + activate "$MOSAIC_RELEASE" "$MOSAIC_IMAGE_TAG" "activate" "$fault" +} + +cmd_rollback() { + local prev + if ! prev="$(previous_image_tag)"; then + echo "release: rollback: no previous activation found in log" >&2 + exit 1 + fi + local prev_release + prev_release="$(printf '%s' "$prev" | sed -n 's/.*-r\([0-9.]*\)$/\1/p')" + [ -n "$prev_release" ] || prev_release="unknown" + activate "$prev_release" "$prev" "rollback" +} + +case "${1:-}" in + package) cmd_package ;; + activate) shift; cmd_activate "$@" ;; + rollback) cmd_rollback ;; + status) cmd_status ;; + *) echo "usage: scripts/release.sh package | activate [--fault-injection] | rollback | status" >&2; exit 4 ;; +esac From e87ecdb3e5cc77f364509b6ad4d39a7e3dea1d46 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 2 Sep 2026 20:23:18 -0500 Subject: [PATCH 3/4] test(release): release-layer selftests (#12) 14 cases: RELEASE validation (valid/invalid/missing), tag consistency, status on empty state, fault-injected refusal with no pointer + single valid refusal log line, healthy activation, pointer fields, repeat activation append-only log, rollback-without-previous refusal. Harness fix learned the hard way: restore RELEASE from backup inline after the missing-file case (mv-back restored the mutated file); single exit trap self-heals the repo state. Closes #12 --- scripts/test-release.sh | 98 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100755 scripts/test-release.sh diff --git a/scripts/test-release.sh b/scripts/test-release.sh new file mode 100755 index 00000000..762da30e --- /dev/null +++ b/scripts/test-release.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Sandboxed selftests for the release layer. +# +# Fast cases (version validation) need no Docker. State-machine cases +# (status/activate/refusal) run against a sandboxed config and therefore +# require the Docker daemon; they are skipped when it is unavailable. +set -uo pipefail +cd "$(dirname "$0")/.." + +SANDBOX="$(mktemp -d)" +RELEASE_BACKUP="$(mktemp)" +cp RELEASE "$RELEASE_BACKUP" +# One exit trap: the repo RELEASE is ALWAYS restored from the backup, +# regardless of how the test run ends. +trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT + +PASS=0 +FAIL=0 + +expect_exit() { + local name="$1" expected="$2" + shift 3 + local rc + "$@" >/dev/null 2>&1 + rc=$? + if [ "$rc" -eq "$expected" ]; then + PASS=$((PASS+1)); echo "ok $name (exit $rc)" + else + FAIL=$((FAIL+1)); echo "FAIL $name (exit $rc, expected $expected)" + fi +} + +check() { + if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi +} + +# ---------- fast: release identity ---------- +expect_exit "valid RELEASE resolves" 0 -- bash -c 'source scripts/common.sh && load_release' + +printf 'garbage\n' > RELEASE +expect_exit "invalid RELEASE exits 1" 1 -- bash -c 'source scripts/common.sh && load_release' + +mv RELEASE "$SANDBOX/RELEASE.hidden" +expect_exit "missing RELEASE exits 1" 1 -- bash -c 'source scripts/common.sh && load_release' +cp "$RELEASE_BACKUP" RELEASE + +bash -c 'source scripts/common.sh && load_release' >/dev/null 2>&1 +bash -c 'source scripts/common.sh && load_release && case "$MOSAIC_IMAGE_TAG" in mosaic-poc-agent:*-r'"$(cat RELEASE)"') exit 0;; *) exit 1;; esac' >/dev/null 2>&1 +check "valid RELEASE leaves image tag consistent with version" $? + +# ---------- sandboxed state machine (Docker required) ---------- +if docker info >/dev/null 2>&1; then + mkdir -p "$SANDBOX/data" + cat > "$SANDBOX/config.json" < Date: Wed, 2 Sep 2026 20:24:33 -0500 Subject: [PATCH 4/4] docs(release): release model usage + recorded M3 drills (#13) - README: Release model section (package/activate/rollback/status, pointer + append-only log, gate-then-flip guarantee) - BUILD-LOG Phase 7: drills recorded (update, refusal, rollback), two harness/product corrections documented Drill evidence: 0.0.3 -> 0.0.4 update with unchanged config checksum and green verify; fault-injected refusal left pointer untouched; health-gated rollback restored 0.0.3; full append-only event history. Closes #13 --- BUILD-LOG.md | 29 +++++++++++++++++++++++++++++ README.md | 23 ++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 7eac6f10..19d91652 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -163,4 +163,33 @@ Configuration-driven Hello World verified. `main` merged with M1 and tagged `con Mission/task layer verified end-to-end. `main` merged with M2 and tagged `mission-task-v1`. +--- + +## Phase 7: Release model and safe updates (M3) + +### Entry 7.1 — before + +- Timestamp: 2026-09-03 +- Intended action: Add the release substrate (Gitea milestone M3, issues #10-#13): RELEASE file single-sources the version (0.0.X line per owner direction), image tags derive from it, scripts/release.sh provides package/activate/rollback/status, activation is health-gated by the M2 task runner, pointer + append-only log under /state/. +- Reason: The owner's top invariant — updates must never corrupt a working installation — needs a mechanism, not a convention: gate-then-flip with recorded history and rollback. +- Expected result: Update, refusal, and rollback drills all green with config checksums unchanged. + +### Entry 7.2 — after + +- Timestamp: 2026-09-03 +- Commands run: scripts/test-release.sh (14 cases); recorded drills: update (0.0.3 -> 0.0.4 package+activate+verify), fault-injected refusal, rollback to 0.0.3. +- Observed result: + - Selftests: 14 passed, 0 failed. + - Update drill: packaged and activated r0.0.4 after exact-marker health gate; verify green under the new tag; config checksum unchanged. + - Refusal drill: health-gate fault injection -> activation refused (exit 1), pointer untouched, refusal appended to the log. + - Rollback drill: health-gated rollback to r0.0.3; pointer restored; log records package/activate/refused/rollback history append-only. +- Failure or correction: + 1. release.sh initially failed with missing state/ directory (no mkdir before pointer/log writes); fixed. + 2. Selftest harness mutated the repo RELEASE and restored the mutated copy (mv-back bug) plus a second trap replacing the first; fixed with inline backup restore and one self-healing exit trap. Product code unaffected. +- Credential check: no credential material in release state, logs, or drills. + +## Result (M3) + +Release model and safe updates verified by drills. `main` merged with M3 and tagged `release-model-v1`. + diff --git a/README.md b/README.md index 5d608c30..3788bc93 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,24 @@ scripts/test-task.sh # selftests (schema negat A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history. +## Release model (M3) + +`RELEASE` single-sources the release version (0.0.X until declared stable); the image tag derives from it plus the pinned Pi version. Activation is health-gated and every event is recorded: + +```bash +scripts/release.sh package # build + tag the release image +scripts/release.sh activate # health check (exact marker) -> atomic pointer swap +scripts/release.sh activate --fault-injection # prove the refusal path (drills only) +scripts/release.sh rollback # health-gated return to the previous release +scripts/release.sh status # release, tag, active pointer, recent log +scripts/test-release.sh # release selftests +``` + +- `/state/active.json` — the activation pointer (atomic tmp+rename replace) +- `/state/activation-log.jsonl` — append-only history: package / activate / refused / rollback + +A failed health check never activates; the previously active release remains deployed. Updating the software therefore cannot corrupt the running installation: package beside, gate, then flip. Verified by the update/refusal/rollback drills in BUILD-LOG Phase 7. + See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan. Inside the container: @@ -95,7 +113,8 @@ Inside the container: ## How it works -1. `scripts/build.sh` builds `mosaic-poc-agent:0.84.4` with Docker Compose. +1. `scripts/build.sh` builds the release image (`mosaic-poc-agent:-r`, + tag derived from `RELEASE` + the pinned Pi version) with Docker Compose. 2. On each run, `/opt/mosaic/src/load-contracts.sh` reads the four contract files in fixed order (CONSTITUTION, STANDARDS, SOUL, USER), joins them with clear separators, and writes `/var/lib/mosaic/system-prompt.md`. @@ -116,8 +135,10 @@ scripts/build.sh # build the image scripts/hello.sh # one-shot request; prints the model response scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK scripts/run-task.sh # run a mission/task file (see Missions & tasks) +scripts/release.sh # package / activate / rollback / status (see Release model) scripts/test-config.sh # fast config-layer selftests (no Docker) scripts/test-task.sh # mission/task selftests (schema + live runs) +scripts/test-release.sh # release selftests scripts/reset.sh # delete the configured data root (safety-checked) ```