Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913f00770f | ||
|
|
58ada98d2b | ||
|
|
d66e91b1f2 | ||
|
|
df705828a4 | ||
|
|
f33bd0da96 | ||
|
|
0e2eef1c12 | ||
|
|
4c4d16131a | ||
|
|
ff3f0d29f1 | ||
|
|
378bc1afe3 | ||
|
|
e5d5c8495a | ||
|
|
3edde464b3 | ||
|
|
99e28d4100 | ||
|
|
7c4a4a4a3a | ||
|
|
049982d30e | ||
|
|
4904d4553c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 | ||
|
|
f4fd5967fc | ||
|
|
f65e9ea656 | ||
|
|
f58b3699a6 | ||
|
|
01e966f36d | ||
|
|
524146055d | ||
|
|
06e0d40352 | ||
|
|
166ee8c90f | ||
|
|
826a8b3b26 | ||
|
|
a4280b9c98 | ||
|
|
4fb44f6345 | ||
|
|
089615f63b |
@@ -8,6 +8,7 @@ coverage
|
|||||||
.env.local
|
.env.local
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
|
__pycache__/
|
||||||
docs/reports/
|
docs/reports/
|
||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
pnpm typecheck && pnpm lint && pnpm format:check
|
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||||
# instead of repopulating a fresh one.
|
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||||
store-dir=/root/.local/share/pnpm/store
|
store-dir=${HOME}/.local/share/pnpm/store
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ pnpm-lock.yaml
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
|
# Python build/test artifacts — same category as node_modules/dist/.next above.
|
||||||
|
# Prettier must never scan generated trees; without these a local venv poisons
|
||||||
|
# `pnpm format:check` with thousands of third-party files.
|
||||||
|
**/venv
|
||||||
|
**/__pycache__
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.pytest_cache
|
||||||
|
**/htmlcov
|
||||||
.claude/
|
.claude/
|
||||||
docs/tess/TASKS.md
|
docs/tess/TASKS.md
|
||||||
docs/scratchpads/
|
docs/scratchpads/
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ steps:
|
|||||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||||
|
# Test-membership guard (#1017): also first link of test:framework-shell.
|
||||||
|
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
|
||||||
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# C1 detector gate. The fixture itself is intentionally RED; CI is green only
|
||||||
|
# when its exact phase verdicts/reasons match the versioned expected-RED manifest.
|
||||||
|
when:
|
||||||
|
- event: [pull_request, manual]
|
||||||
|
- event: push
|
||||||
|
branch: [next, main]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
greenfield-case-denominator-init:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh init cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run"
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh init arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run"
|
||||||
|
|
||||||
|
greenfield-git-present:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source checkout --git present \
|
||||||
|
> /tmp/greenfield-git-present.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-git-present.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
next-git-present /tmp/greenfield-git-present.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run" next-git-present
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-git-present
|
||||||
|
|
||||||
|
greenfield-main-git-present:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane main --source checkout --git present \
|
||||||
|
> /tmp/greenfield-main-git-present.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-main-git-present.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
main-git-present /tmp/greenfield-main-git-present.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run" main-git-present
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-main-git-present
|
||||||
|
|
||||||
|
greenfield-remote-installer-contract:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
expected="$(awk 'NF {print $1; exit}' tools/install.sh.sha256)"
|
||||||
|
actual="$(sha256sum tools/install.sh | awk '{print $1}')"
|
||||||
|
test "$actual" = "$expected"
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
MOSAIC_FIXTURE_INSTALLER_URL="https://git.mosaicstack.dev/mosaicstack/stack/raw/commit/${CI_COMMIT_SHA}/tools/install.sh" \
|
||||||
|
MOSAIC_FIXTURE_INSTALLER_SHA256="$expected" \
|
||||||
|
MOSAIC_FIXTURE_SOURCE_COMMIT="${CI_COMMIT_SHA}" \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source remote --git present \
|
||||||
|
> /tmp/greenfield-remote.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-remote.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
next-git-present /tmp/greenfield-remote.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-remote-installer-contract
|
||||||
|
|
||||||
|
greenfield-git-absent:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source checkout --git absent \
|
||||||
|
> /tmp/greenfield-git-absent.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-git-absent.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
next-git-absent /tmp/greenfield-git-absent.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run" next-git-absent
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-git-absent
|
||||||
|
|
||||||
|
greenfield-case-denominator:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
# Publish exact execution coverage after the full matrix, even if a case failed.
|
||||||
|
depends_on:
|
||||||
|
- greenfield-git-present
|
||||||
|
- greenfield-main-git-present
|
||||||
|
- greenfield-remote-installer-contract
|
||||||
|
- greenfield-git-absent
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage-gate.sh \
|
||||||
|
tools/verify-greenfield-execution-coverage.sh \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt \
|
||||||
|
"$coverage_root" "$coverage_run"
|
||||||
@@ -7,20 +7,21 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
d="$(mktemp -d)" && trap 'rm -rf "$d"' EXIT && curl -fsSL -o "$d/install.sh" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh -o "$d/install.sh.sha256" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh.sha256 && (cd "$d" && test -s install.sh && sha256sum -c install.sh.sha256 && bash install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
The published installer body must be non-empty and match its versioned SHA-256
|
||||||
|
sidecar before it executes. A failed fetch, HTTP-200 empty body, or digest
|
||||||
```bash
|
mismatch is fatal. Because both files come from the same repository and trust
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
domain, this detects corruption or inconsistent publication—not repository or
|
||||||
```
|
server compromise. Independently signed release provenance is explicitly
|
||||||
|
deferred by the greenfield-install PRD.
|
||||||
|
|
||||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
(cd "$d" && bash install.sh --yes) # Accept all defaults
|
||||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
(cd "$d" && bash install.sh --yes --no-auto-launch) # Install only, skip wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs both components:
|
This installs both components:
|
||||||
@@ -30,6 +31,16 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
|
### Install lanes
|
||||||
|
|
||||||
|
| Lane | Command | Use when | Source |
|
||||||
|
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||||
|
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback |
|
||||||
|
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||||
|
|
||||||
|
`--next` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode.
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,10 +49,14 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported)
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- Node.js ≥ 20 and npm ≥ 9
|
||||||
|
- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`)
|
||||||
|
- At least 256 MiB free disk and 1,000 free inodes at the npm prefix
|
||||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||||
|
|
||||||
|
The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Launching Agent Sessions
|
### Launching Agent Sessions
|
||||||
@@ -201,8 +216,21 @@ git clone [email protected]:mosaicstack/stack.git
|
|||||||
cd stack
|
cd stack
|
||||||
|
|
||||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||||
|
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||||
|
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
|
# Verify dependencies and generated state before running source-quality gates.
|
||||||
|
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||||
|
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||||
|
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||||
|
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||||
|
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||||
|
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||||
|
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||||
|
# trust anchor outside worktree authority.
|
||||||
|
pnpm preflight
|
||||||
|
|
||||||
# Optional local queue service only. This does not start PostgreSQL.
|
# Optional local queue service only. This does not start PostgreSQL.
|
||||||
docker compose up -d valkey
|
docker compose up -d valkey
|
||||||
|
|
||||||
@@ -230,6 +258,7 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
|||||||
### Quality Gates
|
### Quality Gates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pnpm preflight # Checkout/dependency/generated-state validation
|
||||||
pnpm typecheck # TypeScript type checking (all packages)
|
pnpm typecheck # TypeScript type checking (all packages)
|
||||||
pnpm lint # ESLint (all packages)
|
pnpm lint # ESLint (all packages)
|
||||||
pnpm test # Vitest (all packages)
|
pnpm test # Vitest (all packages)
|
||||||
@@ -320,16 +349,10 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
Run the installer again — it handles upgrades automatically:
|
Run the same verified installer flow again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
d="$(mktemp -d)" && trap 'rm -rf "$d"' EXIT && curl -fsSL -o "$d/install.sh" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh -o "$d/install.sh.sha256" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh.sha256 && (cd "$d" && test -s install.sh && sha256sum -c install.sh.sha256 && bash install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
@@ -344,15 +367,17 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
### Installer Flags
|
### Installer Flags
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback
|
||||||
|
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||||
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "node ../../scripts/build-web.mjs",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
+40
@@ -1368,3 +1368,43 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||||
|
|
||||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Greenfield install correctness — C1 (#1050)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits.
|
||||||
|
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
||||||
|
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
||||||
|
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
||||||
|
5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment.
|
||||||
|
6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration.
|
||||||
|
7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8.
|
||||||
|
8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress.
|
||||||
|
9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass. The repository's installer tests SHALL nevertheless run in the canonical Alpine CI image by explicitly modeling a supported non-root/glibc target and using portable filesystem enumeration.
|
||||||
|
10. P0 SHALL bind the effective uid and username to the authoritative passwd HOME and shell and state/reject unsafe root or sudo-with-inherited-HOME privilege contexts.
|
||||||
|
11. Created paths SHALL satisfy phase-specific target owner/group and mode policy: P3 executables are not group/world writable, framework/runtime trees are not group/world writable, and identity/credential material is private.
|
||||||
|
12. The expected-RED comparator SHALL validate the complete manifest before selecting a case: exact case population, one exit and P0–P9 disposition per case, pinned require/forbid classes, and no malformed, duplicate, or unknown rows.
|
||||||
|
13. The published installer contract SHALL reject failed fetches, HTTP-success empty bodies, and digest mismatch, then execute the exact digest-verified body. The remote CI arm SHALL enumerate every payload-acquisition path and report a bound/found denominator. It SHALL bind both the installer body and the downstream stack framework/source it consumes to the same immutable CI commit, while retaining `--next` as the lane selector. Source resolution/acquisition SHALL fail closed, and the arm SHALL verify the realised source commit and archive digest after installation. Any out-of-scope unpinned sibling SHALL be named and counted rather than silently included in a broader pinning claim. `ASSUMPTION:` the configured repository's authenticated exact-commit endpoint is trusted to map that commit ID to the returned archive bytes; independent signed provenance/authenticity against repository or TLS trust-root compromise remains excluded by canonical greenfield-install PRD v2 §3.
|
||||||
|
14. Phase diagnostics SHALL be redacted before terminal or durable-log output. A seeded positive-control canary SHALL remain absent from observed argv, output, command logs, npm configuration, generated files, and shell history.
|
||||||
|
15. The CI fixture SHALL publish pipeline-level execution coverage for both the unconditional case set derived from the expected-RED manifest and the explicitly declared required pipeline-arm set, including the immutable remote-installer contract. Per-case and per-arm success markers SHALL be run-scoped, stored beneath an existing checkout-archive exclusion, and written only after that arm's verifier passes. A final step SHALL depend on the complete fixture matrix, run after prior success or failure, emit both `cases_defined=N cases_executed=M` and `arms_defined=N arms_executed=M`, and invoke the same testable aggregation helper whose complete case/arm PASS/FAIL truth table is covered. It SHALL fail unless both expected/executed name sets are exactly equal; missing, unexpected, stale, newly added unexecuted, or checkout-contaminating state SHALL fail closed. The archive-purity control SHALL bind to the production checkout-archive selector so deleting the production exclusion makes the control RED. Coverage tooling SHALL execute under both the canonical Alpine/BusyBox CI image and the Debian greenfield image; a green result from either runtime alone is insufficient portability evidence. Expected-set membership SHALL consume a fully materialized producer result so an early-closing consumer cannot turn a valid marker into a `pipefail` rejection.
|
||||||
|
|
||||||
|
### C1 acceptance criteria
|
||||||
|
|
||||||
|
1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent.
|
||||||
|
2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail.
|
||||||
|
3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation.
|
||||||
|
4. Woodpecker executes and validates the expected RED fixture plus the immutable remote-installer contract; its pipeline-level coverage gate reports exact equality for the manifest-derived case set and the declared arm set, while skipped-case and skipped-remote-arm controls prove non-execution is red. C1 does not repair P4/P5/P8 or activate #869.
|
||||||
|
5. Negative controls prove manifest shrink/duplicates/unknown rows fail, unsafe P0/P3/P4/P5 contexts fail, the P2–P8 fault seam enters real actions rather than synthetic writes, empty/mismatched fetched bodies fail, and a deliberately emitted secret canary is redacted from every persisted/output population.
|
||||||
|
|
||||||
|
### Explicit exclusions and dependencies
|
||||||
|
|
||||||
|
- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills.
|
||||||
|
- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric.
|
||||||
|
- RM-02 and #869 activation are out of scope.
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||||
|
|
||||||
|
## Installation and upgrades
|
||||||
|
|
||||||
|
- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate.
|
||||||
|
- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration.
|
||||||
|
|
||||||
## CLI and skill management
|
## CLI and skill management
|
||||||
|
|
||||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Installer State Machine and Recovery
|
||||||
|
|
||||||
|
The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8.
|
||||||
|
|
||||||
|
## Canonical phases
|
||||||
|
|
||||||
|
| Phase | Responsibility | Failure disposition |
|
||||||
|
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| P0 Resolve context | Bind uid/username to the authoritative passwd HOME/shell, state privilege mode, architecture, libc, Node, and npm | Fail before mutation |
|
||||||
|
| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation |
|
||||||
|
| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work |
|
||||||
|
| P3 Install CLI | Install at the configured absolute prefix; require exact version plus target owner/group and non-writable executable mode | Restore the prior prefix/npmrc snapshot |
|
||||||
|
| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees |
|
||||||
|
| P5 Identity | Validate SOUL/USER content and private modes; require private credential storage and target owner/group | Restore generated identity/credential binding |
|
||||||
|
| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files |
|
||||||
|
| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources |
|
||||||
|
| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles |
|
||||||
|
| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot |
|
||||||
|
|
||||||
|
The phase numbers are a cross-workstream contract and must not be renumbered.
|
||||||
|
|
||||||
|
## Side-effect-free check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tools/install.sh --check # stable/latest lane
|
||||||
|
bash tools/install.sh --check --next # prerelease lane
|
||||||
|
```
|
||||||
|
|
||||||
|
`--check`:
|
||||||
|
|
||||||
|
- emits exactly one `[P0]` through `[P8]` PASS/FAIL row;
|
||||||
|
- exits non-zero if any predicate fails;
|
||||||
|
- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file;
|
||||||
|
- uses temporary npm observation storage outside the target HOME and removes it before exit.
|
||||||
|
|
||||||
|
P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills/<name>/SKILL.md` with matching loadable frontmatter.
|
||||||
|
|
||||||
|
## Durable journal
|
||||||
|
|
||||||
|
Each mutating run creates a private transaction directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${XDG_STATE_HOME:-~/.local/state}/mosaic/install/
|
||||||
|
active.json
|
||||||
|
<UTC-run-id>/
|
||||||
|
journal.ndjson
|
||||||
|
journal.ndjson.sha256 # committed runs only
|
||||||
|
commands.log
|
||||||
|
snapshot/
|
||||||
|
```
|
||||||
|
|
||||||
|
Before each mutation scope is touched, `journal.ndjson` records:
|
||||||
|
|
||||||
|
- phase and path;
|
||||||
|
- whether prior state existed and where its snapshot lives;
|
||||||
|
- the reversal action;
|
||||||
|
- the captured command-output location and command status.
|
||||||
|
|
||||||
|
Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Command diagnostics are redacted before terminal output or durable logging; credential-shaped environment values, bearer values, auth tokens, and credentialed URLs are never deliberately persisted. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass.
|
||||||
|
|
||||||
|
Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state.
|
||||||
|
|
||||||
|
`active.json` is the current projection:
|
||||||
|
|
||||||
|
- `in-progress`: incomplete/open transaction;
|
||||||
|
- `rolled-back`: a fault restored the snapshot;
|
||||||
|
- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery;
|
||||||
|
- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation;
|
||||||
|
- `committed`: P9 passed and the journal is sealed.
|
||||||
|
|
||||||
|
## Failure recovery
|
||||||
|
|
||||||
|
1. Read the named phase and remediation line from installer stderr.
|
||||||
|
2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away.
|
||||||
|
3. For `rolled-back`, verify the target paths match their pre-install state before retrying.
|
||||||
|
4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer.
|
||||||
|
5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state.
|
||||||
|
|
||||||
|
## Greenfield CI gate
|
||||||
|
|
||||||
|
`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch.
|
||||||
|
|
||||||
|
The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, seeded secret-canary scan, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. The comparator validates the complete three-case schema before selecting a case: exactly one exit and P0–P9 disposition per case, pinned require/forbid populations, and no duplicate or unknown rows. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest:
|
||||||
|
|
||||||
|
- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success.
|
||||||
|
- `git` absent: P1 fails before target mutation and the installer emits no `Done.`.
|
||||||
|
|
||||||
|
The fixture is lane-parametric:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tools/e2e-install-test.sh --lane next --git present
|
||||||
|
bash tools/e2e-install-test.sh --lane main --git present
|
||||||
|
```
|
||||||
|
|
||||||
|
CI exercises both lane parameters as expected-RED structural checks. A separate remote-contract arm fetches the installer at the immutable CI commit, rejects failed or empty HTTP-success bodies, compares it to the reviewed `tools/install.sh.sha256`, and executes that exact fetched artifact. The P2–P8 fault matrix runs the real phase actions (including the P3 npm path, P4 framework path, and wizard path) rather than synthetic representative writes, then compares the complete target tree to its pre-install fingerprint. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior.
|
||||||
|
|
||||||
|
## Source trust boundary
|
||||||
|
|
||||||
|
Remote installer mode requires a non-empty body and an expected SHA-256 before execution. Remote source-archive mode separately pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. These controls provide immutable run provenance and archive safety, not an independent signing root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout and remote CI seams verify reviewed digests before executing their artifacts.
|
||||||
@@ -12,6 +12,20 @@ with no snapshot to fall back to.
|
|||||||
Protection is layered. Each layer is independent; a later layer catches what an
|
Protection is layered. Each layer is independent; a later layer catches what an
|
||||||
earlier one misses.
|
earlier one misses.
|
||||||
|
|
||||||
|
## Layer 0 — Transaction journal (install-wide recovery)
|
||||||
|
|
||||||
|
The unified installer opens a private journal under
|
||||||
|
`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target
|
||||||
|
mutation. Every mutation scope records its path, prior snapshot, and reversal
|
||||||
|
instructions before it is touched. Journal write/sync failure is fatal, and P9
|
||||||
|
seals successful journals with a SHA-256 sidecar. See
|
||||||
|
[Installer state machine and recovery](./installer-state-machine.md).
|
||||||
|
|
||||||
|
This transaction journal is distinct from the retained operator-only backup
|
||||||
|
below. The transaction journal is required for correctness and rollback;
|
||||||
|
Layer 2's durable backup remains a separately stated, fail-open recovery bonus
|
||||||
|
for a manifest bug that the normal transaction did not detect.
|
||||||
|
|
||||||
## Layer 1 — Manifest-owned sync (prevention)
|
## Layer 1 — Manifest-owned sync (prevention)
|
||||||
|
|
||||||
The single source of truth for ownership is
|
The single source of truth for ownership is
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!-- board-roll: 1 entry rolled from BOARD.md -->
|
||||||
|
|
||||||
|
### **D-1 / P-ACTIVATION + hygiene — committed `.npmrc` hard-pins `store-dir=/root/.local/share/pnpm/store`.**
|
||||||
|
|
||||||
|
Correct for the CI container (runs as root), fatal for EVERY non-root local checkout: `EACCES` on `/root/.local/share/pnpm/store/v10/server/server.json`. A committed config that only works on one runtime is exactly the activation-skew class. Fix candidate: make store-dir env-overridable, not hardcoded.
|
||||||
|
|
||||||
|
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||||
|
|
||||||
|
### **D-3 / P-FLEET-001 — the seats running this mission are UNMANAGED.** `mos-remediation`, `rev-974`,
|
||||||
|
|
||||||
|
`planner-opus`, `planner-sol` appear in NO roster (`~/.config/mosaic/fleet/roster.yaml`, `agents/`). Planners run on socket `default`; the roster declares `mosaic-fleet`. This is the exact "one roster-owned socket/host + quarantine unmanaged + stale GC" failure P-FLEET-001 indicts — observed on the remediation mission's own fleet. Prerequisite for INBOX identity-addressing.
|
||||||
|
|
||||||
|
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
||||||
|
|
||||||
|
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
||||||
|
|
||||||
|
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||||
|
|
||||||
|
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
||||||
|
|
||||||
|
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
||||||
|
|
||||||
|
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
|
||||||
|
|
||||||
|
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||||
|
|
||||||
|
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||||
|
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||||
|
|
||||||
|
## Head
|
||||||
|
|
||||||
|
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
||||||
|
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
||||||
|
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
||||||
|
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
|
||||||
|
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
|
||||||
|
(58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
|
||||||
|
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
|
||||||
|
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
|
||||||
|
|
||||||
|
## In-flight
|
||||||
|
|
||||||
|
| Task | Owner | State |
|
||||||
|
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||||
|
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||||
|
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||||
|
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||||
|
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||||
|
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||||
|
|
||||||
|
## Fleet seats
|
||||||
|
|
||||||
|
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||||
|
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||||
|
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||||
|
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||||
|
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||||
|
|
||||||
|
## Gate status
|
||||||
|
|
||||||
|
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
||||||
|
- Freeze: LIFTED for this workstream only.
|
||||||
|
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||||
|
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||||
|
- Capability check (D-11b): before dispatching seat X to provider Y, verify
|
||||||
|
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
|
||||||
|
capability registry. Mos owns provisioning; escalate missing pairs to him.
|
||||||
|
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||||
|
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||||
|
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
|
||||||
|
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
|
||||||
|
`-m`; heavy artifacts off shared `/tmp`.
|
||||||
|
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
||||||
|
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||||
|
|
||||||
|
## Sequencing (from MISSION.md)
|
||||||
|
|
||||||
|
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
||||||
|
⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
|
||||||
|
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
|
||||||
|
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
|
||||||
|
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
||||||
|
3. Comms service (envelope→service→PG/Redis→adapters)
|
||||||
|
4. Hygiene + conformance harness
|
||||||
|
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
||||||
|
|
||||||
|
## Dogfood evidence — live failure classes, not hypotheticals
|
||||||
|
|
||||||
|
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
|
||||||
|
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
|
||||||
|
|
||||||
|
<!-- BOARD-ROLL:START -->
|
||||||
|
|
||||||
|
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||||
|
|
||||||
|
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||||
|
|
||||||
|
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||||
|
|
||||||
|
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||||
|
|
||||||
|
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||||
|
|
||||||
|
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||||
|
|
||||||
|
<!-- BOARD-ROLL:END -->
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
|
||||||
|
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
||||||
|
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
||||||
|
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
||||||
|
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
||||||
|
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
||||||
|
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
||||||
|
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
||||||
|
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
|||||||
|
# Adversarial Decomposition — Pragmatic / Shortest-Path Side
|
||||||
|
|
||||||
|
**Planner:** `planner-sol`
|
||||||
|
**Bias:** make one real fleet task pass through one enforced path as early as possible; reuse before building.
|
||||||
|
**Scope source:** `MISSION.md`, `MACP-WIRING-SCOUT.md`, `BOARD.md`, existing `@mosaicstack/macp`, `packages/coord`, PG/Valkey, Tess durable inbox/outbox, and the Mission Control PRD.
|
||||||
|
|
||||||
|
## Executive position
|
||||||
|
|
||||||
|
The first useful milestone is **not** “complete Builds 1 and 2.” It is this narrow vertical slice:
|
||||||
|
|
||||||
|
> A DB-backed mission task is atomically claimed by `packages/coord`, executed by one Node `@mosaicstack/macp` TaskExecutor, gated, and terminally recorded with identity-bound events and a tri-state mutation result. No `docs/TASKS.md`, `mission.json`, `tasks.json`, Python gate loop, or NDJSON ledger participates.
|
||||||
|
|
||||||
|
That slice is **SOL-03 → SOL-04 → SOL-05 → SOL-06 → SOL-07 → SOL-08**, estimated at **72K tokens**, mostly Codex. PG polling is acceptable for this first proof. Redis acceleration follows only after correctness is observable. This is the shortest path that is both dogfoodable and not throwaway work.
|
||||||
|
|
||||||
|
### Cost posture
|
||||||
|
|
||||||
|
- **25 PR tasks, ~294K tokens total:** ~164K Codex, ~130K Sonnet, **0K Opus**.
|
||||||
|
- First live choke-point dogfood: ~72K on the hard path; SOL-01 and SOL-02 can run beside it.
|
||||||
|
- Opus is not justified for planned implementation. Escalate only if an independent security review finds an unresolved architecture-level authority flaw.
|
||||||
|
- Every row is one PR. Estimates include implementation, focused tests, docs affected by that PR, and one remediation pass—not orchestration/reviewer overhead.
|
||||||
|
|
||||||
|
## Gates and critical path
|
||||||
|
|
||||||
|
| gate | opens when | proof required before downstream work |
|
||||||
|
| ------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **G0 — trustworthy launch gates** | SOL-01, SOL-02 | non-root checkout works; queue status cannot become false-green |
|
||||||
|
| **G1 — first dogfood / minimum viable cut** | SOL-08 | one live fleet task completes DB → MACP executor → gates → DB with no flat-file state |
|
||||||
|
| **G2 — Builds 1+2 closed** | SOL-09..SOL-12 | all producers use the executor; duplicate islands retired; Redis loss is recoverable from PG |
|
||||||
|
| **G3 — rotation real** | SOL-13..SOL-16 | stale generation cannot mutate; fresh session resumes typed state; Pi-brick recovery works without broker |
|
||||||
|
| **G4 — sole-path comms real** | SOL-17..SOL-22 | roster identity is stable; bounced/stale messages converge through PG/Redis and adapters |
|
||||||
|
| **G5 — mission proof** | SOL-23..SOL-25 | workflow sweep cannot capture unknown files; fault bank passes, including 100 rotations |
|
||||||
|
|
||||||
|
**Critical path:** `03 → 04 → 05 → 06 → 08 → 10 → 12 → 13 → 14 → 15 → 16 → 17 → 18 → 19 → 20 → 21 → 22 → 24 → 25`.
|
||||||
|
|
||||||
|
## Ordered task list
|
||||||
|
|
||||||
|
### SOL-01 — Repair activation coherence and non-root checkout hygiene
|
||||||
|
|
||||||
|
- **build:** 5 (hygiene; pulled forward)
|
||||||
|
- **depends_on:** —
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. A clean non-root checkout can run dependency/bootstrap preparation without accessing `/root`.
|
||||||
|
2. A root CI checkout still uses an isolated writable pnpm store.
|
||||||
|
3. Activation installs CLI, hooks, broker/runtime assets, and version manifest transactionally: induced failure leaves the prior complete generation active.
|
||||||
|
4. Launch with a deliberately skewed component version is rejected with the exact repair command; diagnostics remain usable.
|
||||||
|
5. No test uses `--no-verify` or suppresses hooks.
|
||||||
|
- **dogfood seed:** BOARD D-1 root-pinned `.npmrc` and D-2 root-owned Husky path.
|
||||||
|
- **est. tokens:** 6K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-02 — Make queue guard fail-safe with exit-asserting tests
|
||||||
|
|
||||||
|
- **build:** 1
|
||||||
|
- **depends_on:** —
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Fixture responses `pending`, `success`, `failure`, no-status, malformed JSON, provider error, and unknown status produce explicitly asserted process exits.
|
||||||
|
2. Only terminal success/no-active-queue returns 0; unknown, malformed, and transport failure return non-zero with actionable output.
|
||||||
|
3. A payload larger than 150 KiB is consumed without argv expansion or truncation.
|
||||||
|
4. At least one mutant changes unknown→success and is killed by the test suite.
|
||||||
|
- **dogfood seed:** inert gate-6 and recursive #1019 failure (unknown→exit 0; ARG_MAX).
|
||||||
|
- **est. tokens:** 8K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-03 — Complete the canonical MACP contract, not another protocol
|
||||||
|
|
||||||
|
- **build:** 1
|
||||||
|
- **depends_on:** —
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. `@mosaicstack/macp` validates typed Task, TaskResult, lifecycle Event, state Claim, and `verified | written-unverified | failed` mutation outcome records.
|
||||||
|
2. Claims require source, confidence, issued-at, TTL/expiry, refresh instruction, and HMAC integrity; tamper/expiry returns a typed refusal, never partial data.
|
||||||
|
3. Lifecycle events include launch, mission generation, checkpoint, rotation, recovery, inbox receipt, and terminal disposition while preserving existing task events.
|
||||||
|
4. `MOSAIC_AGENT_NAME` is required for mutating execution and appears in credential/actor binding; missing identity fails closed.
|
||||||
|
5. Target metadata requires repository identity, task/record ID, and head or generation where applicable.
|
||||||
|
- **dogfood seed:** rev-974 identity drift plus the three observed write outcomes.
|
||||||
|
- **est. tokens:** 8K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-04 — Add the narrow PG orchestration spine schema
|
||||||
|
|
||||||
|
- **build:** 2
|
||||||
|
- **depends_on:** SOL-03
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Migration up creates mission, task, dependency, task-claim, MACP event, typed state-claim, session-generation, and dispatch-outbox records with tenant/mission keys and uniqueness constraints.
|
||||||
|
2. The database rejects a task without a mission, a dependency outside its mission, duplicate idempotency keys, and terminal→running regression.
|
||||||
|
3. Event and claim records reference canonical mission/task/generation identities; claims store integrity metadata.
|
||||||
|
4. Migration rollback on an empty test DB succeeds; rerunning migration is safe.
|
||||||
|
5. No comms-specific “universal message” schema is invented here; Build 4 reuses/extends existing interaction inbox/outbox tables.
|
||||||
|
- **dogfood seed:** mission convention existed but a lane could act with no mechanically valid mission/task.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-05 — Implement atomic PG task claims, transitions, ledger, and outbox
|
||||||
|
|
||||||
|
- **build:** 2
|
||||||
|
- **depends_on:** SOL-04
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Two concurrent claimers for one runnable task yield exactly one lease owner.
|
||||||
|
2. Dependencies are evaluated transactionally; an unmet dependency can never be claimed.
|
||||||
|
3. Claim, state transition, MACP event, and dispatch-outbox append commit atomically or all roll back.
|
||||||
|
4. Expired leases are reclaimable with a higher fencing generation; stale owners cannot complete or mutate.
|
||||||
|
5. Querying mission status is derived solely from PG and returns the next runnable task deterministically.
|
||||||
|
- **dogfood seed:** model-maintained live board and stale claims surviving session changes.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-06 — Build the one production Node MACP TaskExecutor
|
||||||
|
|
||||||
|
- **build:** 1
|
||||||
|
- **depends_on:** SOL-03, SOL-05
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. A public Node executor accepts only a claimed canonical MACP Task, resolves credentials/identity, runs the worker, runs structured gates, and persists terminal result/events through SOL-05.
|
||||||
|
2. Worker exit 0 plus a failed gate cannot produce `completed`; worker failure cannot skip terminal ledger emission.
|
||||||
|
3. Claude, Codex, and Pi fixture backends emit the same runtime-neutral lifecycle sequence.
|
||||||
|
4. Every mutation returns one mandatory tri-state outcome; callers cannot compile while discarding it.
|
||||||
|
5. Crash after worker success but before terminal commit leaves a recoverable fenced claim and no false completion.
|
||||||
|
- **dogfood seed:** stranded MACP, gate-6 inert completion, and `written-unverified` being treated as success.
|
||||||
|
- **est. tokens:** 16K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-07 — Provide one-shot flat-file import and cutover readiness audit
|
||||||
|
|
||||||
|
- **build:** 2
|
||||||
|
- **depends_on:** SOL-05
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. A dry-run parses existing mission/TASKS artifacts, reports unsupported/ambiguous rows, and performs zero writes.
|
||||||
|
2. Apply is idempotent and records source digests; repeated apply creates no duplicates.
|
||||||
|
3. Unknown status, dangling dependency, duplicate task ID, and malformed table block import with row-level diagnostics.
|
||||||
|
4. Readiness reports “cutover-ready” only when imported PG projections exactly match source counts/dependencies/statuses.
|
||||||
|
5. This command is migration-only; it exposes no dual-write or ongoing sync mode.
|
||||||
|
- **dogfood seed:** current remediation board/TASKS state needs a clean DB landing without silently losing tasks.
|
||||||
|
- **est. tokens:** 8K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-08 — Hard-cut `packages/coord` to PG and dogfood one live task
|
||||||
|
|
||||||
|
- **build:** 1
|
||||||
|
- **depends_on:** SOL-06, SOL-07
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. `mosaic coord run/status/continue` reads and mutates PG only; absent/unmigrated DB state fails with the SOL-07 repair path.
|
||||||
|
2. No fallback reads/writes `docs/TASKS.md`, mission JSON, task JSON, state JSON, results JSON, or events NDJSON.
|
||||||
|
3. A live canary task assigned to a fleet seat travels PG claim → TaskExecutor → worker → gate → terminal PG result/event and closes only after gate success.
|
||||||
|
4. Killing the coordinator after claim and restarting it neither duplicates execution nor allows the stale lease to close the task.
|
||||||
|
5. Evidence query shows actor seat, mission/task, target metadata, gate results, and tri-state outcome.
|
||||||
|
- **dogfood seed:** this remediation mission itself; reproduce a gate-6-style non-null task and identity-bound write.
|
||||||
|
- **est. tokens:** 16K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
> **G1 FIRST-DOGFOOD:** stop and validate here before broadening. If SOL-08 cannot carry a real task, do not build Redis, rotation, comms, or UI.
|
||||||
|
|
||||||
|
### SOL-09 — Route Forge and OpenClaw/MACP producers through TaskExecutor
|
||||||
|
|
||||||
|
- **build:** 1
|
||||||
|
- **depends_on:** SOL-08
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Forge and the OpenClaw MACP runtime submit the canonical Task type to SOL-06; neither executes a worker or gate itself.
|
||||||
|
2. Their success callbacks are derived from canonical terminal results, not local/stub completion.
|
||||||
|
3. A failed canonical gate is observed identically from Coord, Forge, and OpenClaw fixtures.
|
||||||
|
4. Repository search plus an executable import boundary test finds no production-local redefinition of Task/TaskResult/GateResult on these paths.
|
||||||
|
- **dogfood seed:** Forge’s immediate empty-gate completion and the plugin’s redefined MACP-shaped result.
|
||||||
|
- **est. tokens:** 10K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-10 — Retire flat-file orchestration and the disabled duplicate rail
|
||||||
|
|
||||||
|
- **build:** 1
|
||||||
|
- **depends_on:** SOL-09
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. The Python controller execution/gate/event path, `tasks_md_sync`, plugin-local protocol types, orphaned context loader, and production flat-file orchestration writers/readers are absent from shipped assets.
|
||||||
|
2. Framework guides/templates/startup context point to DB mission commands, not `docs/TASKS.md` as orchestration SoR.
|
||||||
|
3. A regression scan fails CI if production code reintroduces `events.ndjson`, `tasks.json`, `mission.json`, or `docs/TASKS.md` orchestration mutation.
|
||||||
|
4. jarvis-brain PDA flat files and unrelated project docs remain untouched.
|
||||||
|
5. Upgrade removes/quarantines obsolete generated rail files without deleting user source/docs.
|
||||||
|
- **dogfood seed:** three parallel islands and stale `.mosaic/orchestrator/mission.json` 0/0 residue.
|
||||||
|
- **est. tokens:** 14K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-11 — Add Redis hot dispatch as a derived outbox consumer
|
||||||
|
|
||||||
|
- **build:** 2
|
||||||
|
- **depends_on:** SOL-08
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Task creation commits mission/task/outbox in PG before any Redis enqueue.
|
||||||
|
2. Induced Redis failure leaves the task durable and pending; a sweeper later enqueues it exactly once logically.
|
||||||
|
3. Deleting the Redis queue and rebuilding from PG restores all non-terminal dispatches without reviving terminal tasks.
|
||||||
|
4. Duplicate delivery is neutralized by PG claim fencing/idempotency.
|
||||||
|
5. Existing `packages/queue` adapter/config is reused; no second broker API is introduced.
|
||||||
|
- **dogfood seed:** inert/unknown queue transport and broker outage during task dispatch.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-12 — Lock Builds 1+2 with black-box failure cases
|
||||||
|
|
||||||
|
- **build:** 2
|
||||||
|
- **depends_on:** SOL-02, SOL-10, SOL-11
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. A black-box suite proves: unknown queue status blocks; malformed task blocks; gate failure blocks completion; dropped identity blocks mutation; HMAC corruption forces refresh; Redis loss recovers from PG; stale lease cannot close.
|
||||||
|
2. The suite invokes shipped CLI/service boundaries, not internal mocks.
|
||||||
|
3. Every asserted failure checks process/result status and durable terminal/non-terminal state.
|
||||||
|
4. The same canary task succeeds under Claude, Codex, and Pi adapters or a documented unavailable-runtime fixture fails explicitly.
|
||||||
|
- **dogfood seed:** gate-6/#1019, identity drift, and built-but-unwired MACP.
|
||||||
|
- **est. tokens:** 8K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-13 — Bind session authority to contract hash and generation
|
||||||
|
|
||||||
|
- **build:** 3
|
||||||
|
- **depends_on:** SOL-12
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Launch computes a stable hash over the effective Constitution/AGENTS/runtime/skills set and stores it with session generation.
|
||||||
|
2. Policy change or compaction detection marks the generation stale before any subsequent mutation.
|
||||||
|
3. A stale/mismatched generation can read diagnostics but cannot claim, write task state, acknowledge comms, merge, or close.
|
||||||
|
4. Re-attestation creates a new generation; old credentials/leases remain fenced.
|
||||||
|
5. Hash input order/path normalization is deterministic across two clean launches.
|
||||||
|
- **dogfood seed:** compacted orchestrator losing directives and D-4 ignoring an in-message reset.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-14 — Persist compact typed rotation checkpoints using Coord primitives
|
||||||
|
|
||||||
|
- **build:** 3
|
||||||
|
- **depends_on:** SOL-13
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Checkpoint contains mission/task, completed/blocked state, next three actions, constraints, claims, contract hash/generation, and cursors—never transcript text.
|
||||||
|
2. Checkpoint is HMAC-verified before rehydration; corrupt/expired/missing required claims refuse resume and request deterministic refresh.
|
||||||
|
3. Writing checkpoint and rotation-intent event is atomic in PG.
|
||||||
|
4. Existing Coord continuation capsule semantics are reused; no competing handoff schema/file is created.
|
||||||
|
- **dogfood seed:** manual MOS-ORCHESTRATION-BOARD checkpoint and incomplete-rehydration risk.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-15 — Finish the deterministic coordinator rotation daemon
|
||||||
|
|
||||||
|
- **build:** 3
|
||||||
|
- **depends_on:** SOL-14
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Configured token threshold triggers checkpoint → revoke old authority → terminate → launch fresh → verify rehydration in that order.
|
||||||
|
2. Compaction-detected is a backstop that forces the same rotation path; it never requests recursive compaction.
|
||||||
|
3. A launch failure leaves the mission recoverable and visibly paused, not assigned to two active generations.
|
||||||
|
4. Ephemeral seats die/respawn without mission checkpoint; persistent/orchestrator seats rotate.
|
||||||
|
5. The implementation extends `packages/coord`; untracked `apps/coordinator` residue is not revived.
|
||||||
|
- **dogfood seed:** planner-sol dirty-context dispatch and the old coordinator’s log-only `_check_context()` behavior.
|
||||||
|
- **est. tokens:** 16K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-16 — Add broker-independent recovery and remove silent MOSAIC BYPASS
|
||||||
|
|
||||||
|
- **build:** 3
|
||||||
|
- **depends_on:** SOL-15
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. With Redis/broker unavailable, a diagnostic/bootstrap command can inspect PG mission state, repair broker configuration, and resume without traversing the broker gate.
|
||||||
|
2. Normal recovery remains broker-gated and is labeled as such.
|
||||||
|
3. Break-glass requires explicit scope and expiry, emits a durable event, displays a loud banner, and auto-expires; permanent/silent bypass text or behavior is absent.
|
||||||
|
4. The Pi-brick fixture recovers the broker, then returns to normal gated operation without editing source/config by hand.
|
||||||
|
5. Orchestrator guidance removes “/compact and continue” only after the rotation command is available; ephemeral guidance remains explicit.
|
||||||
|
- **dogfood seed:** Pi brick and silent `MOSAIC BYPASS 2026-07-22`.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-17 — Converge each host on one roster-owned lifecycle domain
|
||||||
|
|
||||||
|
- **build:** 5 (hygiene; hard prerequisite for addressed comms)
|
||||||
|
- **depends_on:** SOL-16
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Reconcile establishes exactly one roster-declared tmux socket/lifecycle domain per host.
|
||||||
|
2. Unknown sessions are reported and quarantined; they are never killed without positive unmanaged classification.
|
||||||
|
3. Max-age/max-context stale sessions invoke SOL-15 rotation for persistent seats or reap for ephemerals.
|
||||||
|
4. Seat identity survives respawn and equals the roster/MOSAIC_AGENT_NAME binding.
|
||||||
|
5. A fixture matching the current four unmanaged remediation seats converges them or produces explicit quarantine actions.
|
||||||
|
- **dogfood seed:** scout-bounce and BOARD D-3 seats split between default and `mosaic-fleet` sockets.
|
||||||
|
- **est. tokens:** 14K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-18 — Publish authenticated `comms/v1` envelope and compatibility rules
|
||||||
|
|
||||||
|
- **build:** 4
|
||||||
|
- **depends_on:** SOL-13, SOL-17
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Envelope validates protocol version, message/idempotency ID, sender/recipient seat identity, class, ordering/coalesce key, creation/expiry, correlation, payload digest, and authentication.
|
||||||
|
2. Current and immediately previous supported protocol versions are accepted; unsupported versions are rejected loudly with supported range.
|
||||||
|
3. Framework/runtime version is diagnostic metadata and never the compatibility key.
|
||||||
|
4. Forged sender, changed recipient/payload, expired envelope, and replay with conflicting content fail closed.
|
||||||
|
- **dogfood seed:** wrong-socket bare tmux message with no authoritative sender/recipient receipt.
|
||||||
|
- **est. tokens:** 8K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-19 — Build the logical PG-first comms service with tmux adapter
|
||||||
|
|
||||||
|
- **build:** 4
|
||||||
|
- **depends_on:** SOL-18
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Sending commits envelope/payload and PENDING state in PG before adapter delivery.
|
||||||
|
2. State machine enforces PENDING → RECEIVED → CONSUMED or DEAD-LETTER; illegal regressions are rejected.
|
||||||
|
3. Recipient-filtered claims and append/coalesce policy are deterministic by message class.
|
||||||
|
4. tmux is a dumb adapter: delivery failure changes no PG authority state and is retryable.
|
||||||
|
5. Existing Tess durable repository/state-machine patterns are extended or generalized; no new deployable microservice or second inbox framework appears.
|
||||||
|
- **dogfood seed:** MACP scout bounce that was discovered only by manual liveness check.
|
||||||
|
- **est. tokens:** 16K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-20 — Make `agent-send` use the sole path and prove stale-message handling
|
||||||
|
|
||||||
|
- **build:** 4
|
||||||
|
- **depends_on:** SOL-19
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Normal `agent-send` creates a comms/v1 record and observes RECEIVED/CONSUMED; it cannot directly invoke tmux.
|
||||||
|
2. A wrong/missing socket leaves PENDING with retry diagnostics, then reaches RECEIVED after roster repair without resending.
|
||||||
|
3. A stale coalescible message arriving after a newer terminal message is marked superseded/consumed and is not surfaced as live work.
|
||||||
|
4. Duplicate identical send is idempotent; same ID with changed content is rejected.
|
||||||
|
5. Inbox receipt/terminal disposition emits canonical MACP lifecycle events.
|
||||||
|
- **dogfood seed:** scout-bounce and #1018 stale-consumed message arriving after merge.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-21 — Add Redis Streams hot delivery and PG reconciliation
|
||||||
|
|
||||||
|
- **build:** 4
|
||||||
|
- **depends_on:** SOL-11, SOL-20
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. PG commit precedes XADD; induced XADD failure is repaired by sweeper.
|
||||||
|
2. Consumer uses a PEL; ack sequence is PG CONSUMED commit before XACK.
|
||||||
|
3. Redis flush/restart rebuilds pending delivery from PG without duplicating consumed messages.
|
||||||
|
4. Pending, abandoned, and dead-letter transitions are observable with bounded retry/backoff.
|
||||||
|
5. Existing Redis/queue connection/configuration is reused.
|
||||||
|
- **dogfood seed:** delivery bounce plus broker loss between durable write and hot enqueue.
|
||||||
|
- **est. tokens:** 12K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-22 — Prove adapter pluggability with the existing Matrix connector
|
||||||
|
|
||||||
|
- **build:** 4
|
||||||
|
- **depends_on:** SOL-21
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Existing Matrix connector consumes/produces comms/v1 through SOL-19 without owning authority state.
|
||||||
|
2. The same envelope can fail tmux and later deliver through Matrix while producing one logical message lifecycle.
|
||||||
|
3. Matrix retry/reconnect cannot regress PG state or duplicate CONSUMED work.
|
||||||
|
4. Removing Matrix availability leaves PG/Redis/tmux behavior intact.
|
||||||
|
- **dogfood seed:** cross-socket scout notification bounce; alternate reach must not become alternate authority.
|
||||||
|
- **est. tokens:** 8K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-23 — Constrain auto-sync and agent writes by allowlist and lease
|
||||||
|
|
||||||
|
- **build:** 5
|
||||||
|
- **depends_on:** SOL-10
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Auto-sync stages only an explicit allowlist; an unknown modified/untracked docs/source file remains unstaged and is reported.
|
||||||
|
2. Agent source/docs writes require the correct worktree/lease; two seats cannot acquire the same mutable target concurrently.
|
||||||
|
3. Generated files are positively identified, not inferred by denylist.
|
||||||
|
4. The measured annotation/index mid-write fixture cannot be swept into an unrelated commit.
|
||||||
|
5. DB orchestration state is absent from repository staging concerns.
|
||||||
|
- **dogfood seed:** auto-sync sweep commit `517bd5c26` capturing agent-authored docs mid-write.
|
||||||
|
- **est. tokens:** 8K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
### SOL-24 — Build the real-artifact lifecycle conformance harness
|
||||||
|
|
||||||
|
- **build:** 5
|
||||||
|
- **depends_on:** SOL-16, SOL-17, SOL-22, SOL-23
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Harness launches shipped CLI/runtime artifacts and fault-injects compaction, broker outage, delivery bounce, identity drop, stale contract hash, queue unknown/malformed, Redis loss, and auto-sync collision.
|
||||||
|
2. One deterministic test executes 100 sequential rotations with no lost/duplicated task, claim, receipt, or terminal disposition.
|
||||||
|
3. Tests assert DB state/event order and process exits, not log substrings alone.
|
||||||
|
4. Harness runs against isolated PG/Redis namespaces and cleans only resources it created.
|
||||||
|
5. Every banked dogfood seed has a named case and evidence output suitable for CI/release attachment.
|
||||||
|
- **dogfood seed:** the complete failure bank: Pi brick, scout-bounce, gate-6/#1019, identity drift, auto-sync, #1018 stale-consumed, D-4 dirty context.
|
||||||
|
- **est. tokens:** 18K
|
||||||
|
- **suggested runtime tier:** sonnet
|
||||||
|
|
||||||
|
### SOL-25 — Complete operator cutover docs and activation proof
|
||||||
|
|
||||||
|
- **build:** 5
|
||||||
|
- **depends_on:** SOL-01, SOL-02, SOL-10, SOL-16, SOL-22, SOL-24
|
||||||
|
- **acceptance criteria (diff-blind testable):**
|
||||||
|
1. Operator docs give exact DB import/cutover, rollback-before-cutover, recovery, break-glass expiry, rotation, comms, quarantine, and conformance commands.
|
||||||
|
2. Link/command checks find no orchestrator instruction to mutate flat-file mission/tasks, use silent bypass, direct-tmux normal comms, or “compact and continue” a persistent seat.
|
||||||
|
3. A clean non-root install activates one coherent version and runs the conformance smoke subset.
|
||||||
|
4. Release evidence maps all 15 decisions and every live seed to a passing check or an explicit deferred item below.
|
||||||
|
- **dogfood seed:** activation skew plus the tendency to leave built fixes unwired or undocumented.
|
||||||
|
- **est. tokens:** 6K
|
||||||
|
- **suggested runtime tier:** codex
|
||||||
|
|
||||||
|
## Explicit DEFER list (10)
|
||||||
|
|
||||||
|
These are not rejected; they are **past first dogfood** and should not delay G1/G2. Each is gold-plating unless a live failure makes it necessary.
|
||||||
|
|
||||||
|
1. **DEFER — Mission dashboard/TUI views.** CLI/DB queries are enough to operate and prove the spine.
|
||||||
|
2. **DEFER — PRD-to-board automatic decomposition.** This is LLM/judgment-heavy and unrelated to enforcing already-decided tasks.
|
||||||
|
3. **DEFER — General heuristic churn scoring.** Implement token threshold + compaction sensor first; repeated-tool-loop inference can follow measured need.
|
||||||
|
4. **DEFER — Discord comms adapter.** Existing plugin reach remains; migrate only after tmux+Matrix prove the service contract.
|
||||||
|
5. **DEFER — Slack comms adapter.** No current dogfood dependency.
|
||||||
|
6. **DEFER — Telegram comms adapter.** No current dogfood dependency.
|
||||||
|
7. **DEFER — Public MCP comms surface.** `agent-send` and service API are sufficient for the mission proof.
|
||||||
|
8. **DEFER — Protocol-v2 features/general negotiation framework.** Ship v1 with a bounded current/previous acceptance window; do not predict v2.
|
||||||
|
9. **DEFER — Multi-region/HA PG or Redis.** Existing in-stack PG+Redis and rebuildability satisfy current failure classes.
|
||||||
|
10. **DEFER — Event analytics/search UI and long-term warehouse.** Indexed PG evidence plus CLI queries is enough for audit/conformance.
|
||||||
|
|
||||||
|
## Suspect abstractions register
|
||||||
|
|
||||||
|
| proposed thing | verdict |
|
||||||
|
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Canonical Node `TaskExecutor` | **JUSTIFIED:** explicitly required single choke point; wraps existing MACP functions rather than replacing them. |
|
||||||
|
| PG repository methods | **JUSTIFIED but narrow:** ordinary adapters around existing Drizzle/DB patterns, not a new “state platform.” |
|
||||||
|
| Rotation daemon | **JUSTIFIED:** finishes `packages/coord`; do not revive `apps/coordinator` or create another service. |
|
||||||
|
| Comms service | **JUSTIFIED only as a logical in-process boundary:** reuse Tess durable inbox/outbox and existing connectors; no new deployable microservice this cycle. |
|
||||||
|
| Universal queue/broker abstraction | **SUSPECT / DO NOT BUILD:** reuse `packages/queue`, PG outbox, and Redis Streams/BullMQ configuration already present. |
|
||||||
|
| Universal envelope/state framework | **SUSPECT / DO NOT BUILD:** MACP Task/Claim/Event and comms/v1 have different bounded purposes. |
|
||||||
|
| Generic compatibility-negotiation engine | **SUSPECT / DEFER:** a small supported-version check meets v1 needs. |
|
||||||
|
|
||||||
|
## Dissent (7)
|
||||||
|
|
||||||
|
1. **Do not wire new production behavior into `mosaic_orchestrator.py::run_single_task`.** The scout correctly identified the duplicated block, but BOARD’s later ruling says the disabled Python rail is residue and must be retired. Building a Node bridge only to delete it is throwaway. Put the Node TaskExecutor in `@mosaicstack/macp`, route the live Coord path to it at SOL-08, migrate remaining producers at SOL-09, then delete the Python block at SOL-10.
|
||||||
|
2. **Redis is not on the first-dogfood critical path.** PG claim/polling is sufficient for one real task and exposes correctness earlier. Add Redis only after G1; otherwise queue debugging obscures whether the choke point works.
|
||||||
|
3. **“One choke-point service” does not justify a new deployable service.** An exported executor plus Coord daemon is enough. A new Nest app, RPC protocol, deployment, auth layer, and health plane would be greenfield.
|
||||||
|
4. **The Mission Control PRD’s file-first and board-regeneration assumptions are superseded.** Keep its mission/rotation semantics, but obey the accepted hard DB cutover; do not implement its file-first milestones or PRD-to-board generator now.
|
||||||
|
5. **Do not gate every interactive runtime launch as if it were a mission task.** Enforce every tracked task/data mutation at the executor/DB authority boundary. Ephemeral interactive shells may launch, but receive no task mutation authority unless attached to a valid claim.
|
||||||
|
6. **Do not implement broad “churn intelligence.”** Token threshold and compaction-detected are deterministic sensors. Repeated-loop semantic detection is expensive, noisy, and premature until telemetry demonstrates a gap.
|
||||||
|
7. **The 100-rotation test is a final conformance bar, not an early unit-test tax.** First prove one rotation, then fault cases, then 100 repetitions in SOL-24. Requiring 100 before G3 would delay feedback without changing the design.
|
||||||
|
|
||||||
|
## Orchestrator reconciliation notes
|
||||||
|
|
||||||
|
- Pre-register each task’s acceptance checks from this document before showing implementation diffs to its reviewer. Author and reviewer remain different seats.
|
||||||
|
- SOL-07 permits a one-time import, **not** an interim store: no shadow writes, dual reads, or sync daemon.
|
||||||
|
- G1 is the budget escape hatch. If the 72K hard-path slice does not work, stop and remediate instead of spending the remaining ~222K.
|
||||||
|
- Docs belong in each behavior-changing PR where required; SOL-25 is cross-link/cutover validation, not permission to postpone essential docs.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# mos-remediation — Orchestrator Kickstart / Compaction-Survival Resume
|
||||||
|
|
||||||
|
**You are `mos-remediation`, the project orchestrator for the Mosaic Stack remediation, launched in `/src/mosaic-stack`.**
|
||||||
|
This file is your fail-closed resume procedure. Read it on EVERY fresh/cleared session and on the FIRST turn
|
||||||
|
after any compaction. This mission's whole point is that manual compaction-survival is fragile — so follow this
|
||||||
|
mechanically until Build 3 (rotation) makes it automatic.
|
||||||
|
|
||||||
|
## On resume (do in order, before any orchestration action)
|
||||||
|
|
||||||
|
1. `cd /src/mosaic-stack`, then **`git fetch origin remediation/state`**.
|
||||||
|
⚠ **The live board is on the rolling branch `remediation/state`, NOT on `main`.** `main` carries only
|
||||||
|
periodic snapshots, so reading the board from `main` will silently give you a STALE tick. Read the
|
||||||
|
live files at `origin/remediation/state` (e.g. `git show origin/remediation/state:docs/remediation/BOARD.md`),
|
||||||
|
or check that branch out. Every tick is pushed there immediately, so its HEAD is always the newest state.
|
||||||
|
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
||||||
|
3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase,
|
||||||
|
in-flight tasks, fleet seat assignments, gate status. Single source of in-flight truth (kept < 8 KB;
|
||||||
|
older entries roll to `BOARD-LEDGER.md` via `board-roll.sh`).
|
||||||
|
4. Read the discussion checkpoint for full rationale if needed:
|
||||||
|
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
||||||
|
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
||||||
|
current build/phase, (c) the BOARD head (in-flight tasks + who owns them). If you cannot, HALT and re-read.
|
||||||
|
Do NOT act on memory alone; a compaction may have dropped context silently.
|
||||||
|
|
||||||
|
## Standing invariants (never violate)
|
||||||
|
|
||||||
|
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||||
|
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||||
|
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||||
|
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
||||||
|
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
||||||
|
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||||
|
|
||||||
|
## After every significant event
|
||||||
|
|
||||||
|
Overwrite stale lines in `BOARD.md`, keep it < 8 KB, commit + push. The board IS your checkpoint until the
|
||||||
|
DB-backed rotation daemon (Build 3) exists. Persist typed state (phase, tasks, owners, gates) — never the transcript.
|
||||||
|
|
||||||
|
## Fleet
|
||||||
|
|
||||||
|
- Adversarial planners: `planner-opus` (robustness), `planner-sol` (pragmatic) — dispatch for task decomposition; reconcile their oppositional decomps.
|
||||||
|
- Coders/reviewers: dispatch per roster + delivery gates. Comms: `~/.config/mosaic/tools/tmux/agent-send.sh`
|
||||||
|
(`-L <socket> -s <dst> -S <yourhost>:<yourseat> --class <class>`); always pass `-S`.
|
||||||
|
- Lead coordinator: Mos (`mos-claude`). Escalate only on the Constitution's escalation triggers.
|
||||||
|
|
||||||
|
## Remote control
|
||||||
|
|
||||||
|
On first startup, activate remote control for this session (`/remote-control`) so Jason can reach/drive you while
|
||||||
|
away. If the command is unavailable in this runtime, report it to Mos and continue — it is not a blocker.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# MACP wiring investigation
|
||||||
|
|
||||||
|
**Scope:** `/src/mosaic-stack` inspected at HEAD `b79336a8c11e2a4646a47ff8d295a226e0c71404`; read-only. Existing dirty/untracked state was not touched.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**(c) STRANDED.** `packages/macp` is exported, unit-tested, and registered as a CLI command group, but no production dispatch/execution code invokes its credential resolver, gate runner, or event emitter.
|
||||||
|
A separate MACP-named OpenClaw/orchestrator rail exists, but it redefines task/result types and gate/event logic instead of importing `@mosaicstack/macp`; direct `mosaic yolo|claude|codex|opencode|pi` also bypasses it.
|
||||||
|
|
||||||
|
## 1. Production call sites vs tests
|
||||||
|
|
||||||
|
### Production references to `@mosaicstack/macp`
|
||||||
|
|
||||||
|
| Surface | Evidence | Actual use |
|
||||||
|
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Unified CLI | `packages/mosaic/src/cli.ts:8,385` | Imports and registers `registerMacpCommand`; no task/gate/event execution. |
|
||||||
|
| Forge | `packages/forge/src/types.ts:1,17,68,79` | **Type-only** imports of `GateEntry` and `TaskResult`. Pipeline calls an injected abstract executor at `packages/forge/src/pipeline-runner.ts:189-190,299-300`, not MACP. |
|
||||||
|
| Mosaic package metadata | `packages/mosaic/package.json:36`; `packages/mosaic/src/runtime/update-checker.ts:172` | Dependency/update inventory only. |
|
||||||
|
| Agent | No match under production `packages/agent/src/**` | No MACP import/call. |
|
||||||
|
| Coord | No match under production `packages/coord/src/**`; dependency list is only `@mosaicstack/types` at `packages/coord/package.json:25-27` | No MACP import/call. |
|
||||||
|
| Plugins | No `@mosaicstack/macp` import under `plugins/**` | No package use; the MACP-named plugin is an independent implementation (below). |
|
||||||
|
|
||||||
|
**Repository-wide production call-site search result:** excluding `packages/macp/**`, tests, worktrees, and build output, there are **zero** calls to `runGate`, `runGates`, `emitEvent`, `appendEvent`, or `resolveCredentials`.
|
||||||
|
|
||||||
|
### `packages/macp` implementation is internally connected only
|
||||||
|
|
||||||
|
- Public exports: `packages/macp/src/index.ts:1-48` exports Task/GateEntry/MACPEvent/TaskResult, credential resolution, `runGate(s)`, risk-floor, and event emission.
|
||||||
|
- Gate runner calls its own event emitter: `packages/macp/src/gate-runner.ts:187-236`.
|
||||||
|
- Event persistence implementation appends NDJSON to a caller-supplied path: `packages/macp/src/event-emitter.ts:11-27`.
|
||||||
|
- There is **no exported programmatic `submit` implementation** in `packages/macp/src/index.ts:1-48`; only the CLI placeholder named `submit`.
|
||||||
|
|
||||||
|
### Test-only invocations
|
||||||
|
|
||||||
|
- Gate runner: `packages/macp/__tests__/gate-runner.test.ts:96-242` invokes `runGate/runGates`.
|
||||||
|
- Event ledger: `packages/macp/__tests__/event-emitter.test.ts:46-133` invokes `appendEvent/emitEvent` against temporary `events.ndjson` files.
|
||||||
|
- Credential resolver: `packages/macp/__tests__/credential-resolver.test.ts` exercises resolver behavior.
|
||||||
|
- CLI tests only verify command registration: `packages/macp/src/cli.spec.ts:37-73`; `packages/mosaic/src/cli-smoke.spec.ts:8` imports registration.
|
||||||
|
|
||||||
|
## 2. Gate on the live dispatch path
|
||||||
|
|
||||||
|
### Direct Mosaic runtime launch bypasses MACP
|
||||||
|
|
||||||
|
- Runtime commands dispatch directly to harness launch: `packages/mosaic/src/commands/launch.ts:730-801`.
|
||||||
|
- Claude/Pi go through the lease broker, then spawn the runtime: `packages/mosaic/src/commands/launch.ts:817-843`.
|
||||||
|
- Commander wiring sends `mosaic yolo <runtime>` and direct runtime commands to `launchRuntime`: `packages/mosaic/src/commands/launch.ts:1102-1157,1165-1167`.
|
||||||
|
- None of those ranges imports/calls `@mosaicstack/macp`, `runGates`, or `emitEvent`.
|
||||||
|
|
||||||
|
**Result:** a direct `mosaic yolo`, `mosaic claude/codex/opencode/pi`, or underlying exec does not create a typed MACP Task, run the package gate-runner, or append a package MACPEvent.
|
||||||
|
|
||||||
|
### Coord bypasses MACP
|
||||||
|
|
||||||
|
- Coord reads/updates `docs/TASKS.md`: `packages/coord/src/runner.ts:6,306-386`; parser/writer is `packages/coord/src/tasks-file.ts:326-377`.
|
||||||
|
- Coord launches a child process directly: `packages/coord/src/runner.ts:397-427`.
|
||||||
|
- Mission state is its own `.mosaic/orchestrator/mission.json`/`next-task.json`: `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:15-16,355-384`.
|
||||||
|
|
||||||
|
**Result:** Coord task execution has no MACP Task validation, package gate runner, or event append.
|
||||||
|
|
||||||
|
### Forge bypasses MACP execution
|
||||||
|
|
||||||
|
- Forge defines its own `ForgeTask` and abstract `TaskExecutor`: `packages/forge/src/types.ts:48-80`.
|
||||||
|
- The production CLI injects a **stub executor** that immediately reports completion with empty gates: `packages/forge/src/cli.ts:13-31,167,185`.
|
||||||
|
|
||||||
|
**Result:** even `mosaic forge run` does not execute MACP gates or persist MACP events.
|
||||||
|
|
||||||
|
### Separate MACP-named rail is not `packages/macp`
|
||||||
|
|
||||||
|
- OpenClaw plugin registers an ACP backend named `macp`: `plugins/macp/src/index.ts:1-18,72-102`.
|
||||||
|
- It locally redefines `OrchestratorTask`, `TaskResult`, and gate-result shapes instead of importing package types: `plugins/macp/src/macp-runtime.ts:43-77`.
|
||||||
|
- It appends directly to `.mosaic/orchestrator/tasks.json`, triggers an external controller, and polls `results/<task>.json`: `plugins/macp/src/macp-runtime.ts:290-329,437-483`.
|
||||||
|
- The controller independently implements `append_event`, `emit_event`, shell execution, gate execution, and results: `packages/mosaic/framework/tools/orchestrator-matrix/controller/mosaic_orchestrator.py:29-91,126-276`.
|
||||||
|
- Its gate loop runs raw string gates after worker success: `mosaic_orchestrator.py:213-235`; it does not support the package's structured `GateEntry`/AI-review behavior.
|
||||||
|
- Current checkout disables this controller: `.mosaic/orchestrator/config.json:2` (`"enabled": false`).
|
||||||
|
- Plugin references `tools/macp/dispatcher/pi_runner.ts` at `plugins/macp/src/macp-runtime.ts:85-91`, but `tools/macp/` does not exist in this checkout.
|
||||||
|
|
||||||
|
**Result:** there is a parallel, optionally enabled MACP-shaped rail, not package integration. It cannot make `packages/macp` the enforced path.
|
||||||
|
|
||||||
|
## 3. Event ledger status
|
||||||
|
|
||||||
|
- Package persistence exists only as a library primitive: `packages/macp/src/event-emitter.ts:11-27` appends JSON lines to an arbitrary `eventsPath`.
|
||||||
|
- Package event emission is reached only from package `runGates`: `packages/macp/src/gate-runner.ts:204-236`.
|
||||||
|
- No production caller invokes package `runGates/emitEvent/appendEvent`; therefore no runtime destination path is configured for the package ledger.
|
||||||
|
- Test-only ledgers use temp paths: `packages/macp/__tests__/event-emitter.test.ts:35-133`; gate tests use temp `events.ndjson`: `packages/macp/__tests__/gate-runner.test.ts:171-242`.
|
||||||
|
- The separate Python controller writes `.mosaic/orchestrator/events.ndjson`: `mosaic_orchestrator.py:129-133,159-161,219-235`; the Mosaic Framework plugin only **reads** that file for context at `plugins/mosaic-framework/src/index.ts:279-316,430-438`.
|
||||||
|
- In this checkout, `.mosaic/orchestrator/events.ndjson` is absent and the controller is disabled (`.mosaic/orchestrator/config.json:2`).
|
||||||
|
|
||||||
|
**Conclusion:** `MACPEvent` from `packages/macp` is defined/tested but not emitted or persisted by live production call sites. The similarly shaped Python ledger is a duplicate island.
|
||||||
|
|
||||||
|
## 4. Coord link
|
||||||
|
|
||||||
|
- `packages/coord` has no `@mosaicstack/macp` dependency/import: `packages/coord/package.json:25-27`; no matches in `packages/coord/src/**`.
|
||||||
|
- Coord's task model is Markdown `docs/TASKS.md` plus mission/session JSON: `packages/coord/src/tasks-file.ts:1-10,257-377`; `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:306-427`.
|
||||||
|
- It does not consume `.mosaic/orchestrator/events.ndjson`, MACP Task, MACPEvent, GateEntry, or TaskResult.
|
||||||
|
|
||||||
|
**Conclusion:** Coord and `packages/macp` are disconnected islands.
|
||||||
|
|
||||||
|
## Shortest wiring gap
|
||||||
|
|
||||||
|
**Single integration point:** replace the duplicated execution/gate/event block in `mosaic_orchestrator.py::run_single_task` (`:126-276`) with one production Node `TaskExecutor` backed by `@mosaicstack/macp` (typed Task validation + `resolveCredentials` + `runGates` + `emitEvent`), and make Coord/Forge/OpenClaw submit through that executor. This queue/controller choke point is where `yolo|acp|exec` worker outcomes can be gated and journaled before completion is recorded.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Mosaic Stack Remediation — Mission Charter
|
||||||
|
|
||||||
|
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
||||||
|
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** EXECUTING (planning complete; RM-01 in flight).
|
||||||
|
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Convert the 15 accepted postmortem remediation proposals into a working, **dogfooded** implementation.
|
||||||
|
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
||||||
|
gate/program; the LLM handles only genuine judgment.
|
||||||
|
|
||||||
|
### First-class principle — observe the property, not the exit code
|
||||||
|
|
||||||
|
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
||||||
|
>
|
||||||
|
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||||
|
> and why P-WRAPPER-001's tri-state (`verified` / `written-unverified` / `failed`) is not optional. The
|
||||||
|
> failure is not carelessness — a green is _engineered_ to be trusted, so trusting it is the default
|
||||||
|
> behaviour of a competent operator, not a lapse.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31) after the orchestrator committed this exact error: a
|
||||||
|
> `--draft` flag was silently dropped by a wrapper fallback that still exited 0, and the PR was reported
|
||||||
|
> as a draft on the strength of the exit code rather than an observed `draft: true` (D-12). Twelve
|
||||||
|
> failure instances were banked in that session; **three of them were the orchestrator's own.** That
|
||||||
|
> ratio is the point — the mechanism must catch the mechanic too, or it is not a mechanism.
|
||||||
|
>
|
||||||
|
> Operationally: after any write, read back the property you required. Applies to gates, wrappers, PR
|
||||||
|
> flags, commit authorship, file installs, and message delivery alike.
|
||||||
|
|
||||||
|
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||||
|
|
||||||
|
> **A pre-registered check set can fail in three distinct ways:**
|
||||||
|
>
|
||||||
|
> | mode | the set is… | found as |
|
||||||
|
> | --------------------------- | ------------------------------------------------- | -------- |
|
||||||
|
> | **WRONG** | a check does not test what it claims | D-8 |
|
||||||
|
> | **INCOMPLETE** | green while a criterion's requirement is untested | D-17 |
|
||||||
|
> | **INTERNALLY INCONSISTENT** | two criteria cannot both hold | D-18 |
|
||||||
|
>
|
||||||
|
> **Pre-registration protects against exactly one thing: retrofitting a check to fit the implementation
|
||||||
|
> it is supposed to judge.** It confers neither correctness, nor coverage, nor consistency. "We
|
||||||
|
> pre-registered the checks" has been treated as though it settled the question — it settles one of
|
||||||
|
> three.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31). All three modes were found on this mission's own **first
|
||||||
|
> delivery**, by the machinery applied to its own work — not by inspection, and not by looking for them.
|
||||||
|
>
|
||||||
|
> **Enforceable form — RM-02's four clauses.** The registry must establish that: (1) each check is
|
||||||
|
> **right** — proven red for its own stated reason before its green counts; (2) the set **covers** —
|
||||||
|
> every criterion bound to a case that actually exercises it; (3) no two criteria **conflict** —
|
||||||
|
> mutual unsatisfiability is a registry defect discoverable by construction; (4) when a criterion's
|
||||||
|
> meaning changes, the registry **retains original text, restatement, and reason**, so evolution stays
|
||||||
|
> auditable. A criterion with no case that can fail for its own reason is unregistered in substance,
|
||||||
|
> however it reads in the manifest.
|
||||||
|
|
||||||
|
### Corollary — never ship an integrity claim dressed as a property
|
||||||
|
|
||||||
|
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
||||||
|
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
|
||||||
|
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||||
|
> publish atomically, and carry a **tamper negative-control observed red** — otherwise its integrity is
|
||||||
|
> a _claim_, not a _property_.
|
||||||
|
>
|
||||||
|
> **If it cannot be made tamper-evident, say so and reconsider the approach.** Laundering foreign
|
||||||
|
> content as certified is the only unacceptable outcome; an honest "this cannot be verified" is always
|
||||||
|
> available and always preferable.
|
||||||
|
|
||||||
|
### First-class principle — when a property cannot exist at the layer it was specified
|
||||||
|
|
||||||
|
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
||||||
|
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
|
||||||
|
> there are exactly three honest moves, and all three are mandatory:
|
||||||
|
>
|
||||||
|
> 1. **Implement what the layer _can_ guarantee.** Partial protection against the class it was actually
|
||||||
|
> born from is worth having.
|
||||||
|
> 2. **State the boundary precisely, in BOTH directions.** What it does _not_ defend, **and** beside it
|
||||||
|
> what it _does_. A reader who sees only the negative dismisses the check as worthless; one who sees
|
||||||
|
> only the positive over-trusts it. **Both together is the honest artifact** — either alone misleads.
|
||||||
|
> 3. **Record where the real guarantee will come from — as a TRACKED DEPENDENCY, not prose.** It must
|
||||||
|
> name a task that someone must close. _A documented gap with no owner becomes a permanent gap that
|
||||||
|
> reads as intentional._
|
||||||
|
>
|
||||||
|
> **A written-down gap is acceptable engineering. An implied-fixed gap is this mission's core failure in
|
||||||
|
> a new costume** — a verification artifact that verifies nothing, with a green to prove it.
|
||||||
|
>
|
||||||
|
> Promoted to the charter by Mos (2026-07-31) from D-19. Origin: the RM-01 symlink manifest could not be
|
||||||
|
> made tamper-evident against a same-UID actor (CWE-345), because the manifest and its marker share one
|
||||||
|
> writable tree. The implementing seat **escalated rather than relabelling self-authentication as
|
||||||
|
> tamper-resistance** — the corollary above firing on its first real adversarial test, on the cheapest
|
||||||
|
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
|
||||||
|
> choke-point executor and spine verify from _outside_ the worktree's authority.
|
||||||
|
|
||||||
|
## Decision record (authoritative, immutable)
|
||||||
|
|
||||||
|
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||||
|
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
||||||
|
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
||||||
|
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
||||||
|
- MACP wiring scout (verdict c=STRANDED): [`MACP-WIRING-SCOUT.md`](./MACP-WIRING-SCOUT.md) (copied into this dir; TODO discharged). Its findings are sound; its _recommended wire-in point_ is superseded by DECISION-1.
|
||||||
|
|
||||||
|
## The plan — 15 proposals collapse to 4 builds + hygiene
|
||||||
|
|
||||||
|
| Build | Absorbs | What it is |
|
||||||
|
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. ⚠ **Target CORRECTED 2026-07-31 (DECISION-1, Mos):** a new production Node `TaskExecutor` on the **live** dispatch path (`packages/mosaic` launch + `packages/coord`), which Coord/Forge/live-dispatch submit through. **NOT** `mosaic_orchestrator.py::run_single_task` — that controller is `"enabled": false` and references a dispatcher absent from this checkout; wiring it would strand the executor, reproducing this mission's own disease. The Python rail is **deleted**, not ported. Both planners reached this independently. |
|
||||||
|
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
||||||
|
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
||||||
|
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
|
||||||
|
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
||||||
|
|
||||||
|
## The finding that sets the cost
|
||||||
|
|
||||||
|
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||||
|
exist; the Mission Control PRD exists; PG + Redis already run in-stack. Three duplicate MACP islands, an
|
||||||
|
orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retire, NOT greenfield. "Finish, don't re-spec."**
|
||||||
|
|
||||||
|
## Sequencing (skeleton — adversarial decomposition refines this)
|
||||||
|
|
||||||
|
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
||||||
|
(Per DECISION-1, a P0 phase of provable-gate + activation work precedes this; see `TASKS.md` §3.)
|
||||||
|
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
||||||
|
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
||||||
|
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
||||||
|
|
||||||
|
- **Cross-cutting retirements:** flat-file orchestration tracking (hard cutover to DB), the 3 duplicate MACP islands, the silent `MOSAIC BYPASS`.
|
||||||
|
|
||||||
|
## Standing directives (Jason, 2026-07-31)
|
||||||
|
|
||||||
|
- **Dogfooding:** validate EACH fix against the live fleet failure that motivated it. Seed acceptance tests:
|
||||||
|
Pi brick (RECOVERY), scout-bounce (INBOX/FLEET), gate-6 inert + #1019 recursion (QUEUE), identity drift
|
||||||
|
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
||||||
|
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
||||||
|
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
||||||
|
- ⚠ **QUALIFIED 2026-07-31 (DECISION-2, Mos):** the DB spine **must NOT be a single-point hard-stop.**
|
||||||
|
A broker-independent / degraded mode **and** a rehearsed rollback artifact are **design requirements**
|
||||||
|
(P-RECOVERY-001), binding now on RM-12, RM-13, RM-23, RM-36 and RM-53. This **supersedes** the earlier
|
||||||
|
orchestrator recommendation to pre-commit "no DB ⇒ the fleet stops" — that answer is _not_ on record.
|
||||||
|
Only the specific availability _target_ remains open, queued for Jason; it does **not** block current work.
|
||||||
|
|
||||||
|
## The 15 decisions (one-line; full rationale in the checkpoint)
|
||||||
|
|
||||||
|
1. **P-ACTIVATION-001** accept — transactional CLI+hooks+broker+version release; block launch on skew, fail-SAFE.
|
||||||
|
2. **P-AUTHORITY-001** MODIFY — structured authenticated inbox; envelope carries comms-PROTOCOL version; version the protocol not participants; N-version window.
|
||||||
|
3. **P-LIFECYCLE-001** accept — rotation not recursive compaction; pre-empt at token threshold; enforcer = deterministic coordinator; = finish Mission Control Plane.
|
||||||
|
4. **P-MISSION-001** accept — bind lanes to mission+task ledger; convention exists, ENFORCEMENT is the gap; mission+tasks → DB spine (hard cutover).
|
||||||
|
5. **P-QUEUE-001** accept — repair queue transport + exit-asserting non-null-case tests (gate-6 was INERT fleet-wide; #1019 fix recursed the same bug).
|
||||||
|
6. **P-STATE-001** accept — typed claims (source/confidence/TTL) not prose blob; MACP typed record; integrity fail-closed HMAC; don't fork a 4th island.
|
||||||
|
7. **P-AUDIT-001** accept — MACPEvent lifecycle ledger; EXTEND enum to lifecycle events; runtime-neutral (executor-emitted); retire duplicate Python ledger.
|
||||||
|
8. **P-WRAPPER-001** accept — identity derives from seat name + survives respawn; tri-state write outcomes MANDATORY; name safe target metadata.
|
||||||
|
9. **P-CONTRACT-001** accept — bind session to contract hash; re-anchor on policy-change OR compaction-detected; stale generation loses authority MECHANICALLY.
|
||||||
|
10. **P-INBOX-001** MODIFY — sole-path comms SERVICE; PG durable SoR + Redis hot queue (outbox, reconciliation sweeper); pluggable adapters; protocol-first, PG-first-then-Redis.
|
||||||
|
11. **P-RECOVERY-001** accept — broker-independent bootstrap recovery; honest capability labeling; break-glass LOUD+AUDITED+TEMPORARY not silent permanent bypass.
|
||||||
|
12. **P-GUIDE-001** accept — delete `/compact and continue` from orchestrator path (keep for ephemeral); removal = substitution (wire rotation trigger).
|
||||||
|
13. **P-FLEET-001** accept — one roster-owned socket/host; quarantine unmanaged; stale-session GC; prerequisite for INBOX identity-addressing.
|
||||||
|
14. **P-WORKFLOW-001** accept — auto-sync ALLOWLIST not denylist; worktree/lease isolation for agent docs/source; DB-tracking obviates the flat-file-sweep criterion.
|
||||||
|
15. **P-CONFORMANCE-001** accept — fleet lifecycle harness on REAL runtime artifacts + fault injection; the 100-rotations-lossless bar is a test; target the DB substrate.
|
||||||
|
|
||||||
|
## Fleet operating model
|
||||||
|
|
||||||
|
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||||
|
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||||
|
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||||
|
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||||
|
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||||
|
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||||
@@ -0,0 +1,894 @@
|
|||||||
|
# Remediation Backlog — Reconciled Execution Plan
|
||||||
|
|
||||||
|
**Owner:** `mos-remediation` (sole writer). Workers read; they never modify this file.
|
||||||
|
**Sources:** [`DECOMP-OPUS.md`](./DECOMP-OPUS.md) (robustness, 38 tasks / 8 dissents) and
|
||||||
|
[`DECOMP-SOL.md`](./DECOMP-SOL.md) (pragmatic, 25 tasks / 10 defers / 7 dissents), produced
|
||||||
|
**independently** — neither planner read the other. Charter: [`MISSION.md`](./MISSION.md).
|
||||||
|
**Status:** EXECUTING — all three blocking decisions RULED by Mos on 2026-07-31 (§5). **RM-01 is
|
||||||
|
dispatched.** RM-03 is held pending Jason's disposition of PR #1023; nothing else is blocked.
|
||||||
|
|
||||||
|
> **Provenance of the inputs (both clean).** `planner-opus` ran in a fresh session throughout.
|
||||||
|
> `planner-sol` initially began work at 64.3% dirty context despite a brief instructing it to reset;
|
||||||
|
> that run was **interrupted and discarded before it produced any output**, the seat was reset
|
||||||
|
> out-of-band to 0.0%, and the brief was re-dispatched. `DECOMP-SOL.md` is the product of the clean
|
||||||
|
> run only (it peaked at ~26% context). Both decompositions are therefore clean-context artifacts and
|
||||||
|
> are weighted equally here. The discarded dirty run is banked as dogfood seed D-4 and as task RM-58 —
|
||||||
|
> the failure it demonstrates is that _asking_ an agent to reset is not enforcement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What the two planners agreed on without collusion
|
||||||
|
|
||||||
|
Independent convergence is the strongest signal available here, because neither planner could see the
|
||||||
|
other's file. Where both arrived at the same conclusion from opposite biases, I treat it as settled.
|
||||||
|
|
||||||
|
| # | Convergent finding | OPUS | SOL |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------- |
|
||||||
|
| C1 | **The charter's wire-in point is wrong.** Do NOT wire the choke point into `mosaic_orchestrator.py::run_single_task`. | D2 (headline) | Dissent 1 |
|
||||||
|
| C2 | P0 hygiene/gate work must precede the spine, not follow it. | D1, phase P0 | G0, SOL-01/02 |
|
||||||
|
| C3 | No new deployable microservice; the executor is a library + the coord daemon. | implicit throughout | Dissent 3 |
|
||||||
|
| C4 | Comms adapters beyond tmux are out of scope for this mission. | D7 | DEFER 4/5/6 |
|
||||||
|
| C5 | The "100 rotations lossless" bar is a late conformance gate, not an early tax. | D4 | Dissent 7 |
|
||||||
|
| C6 | Redis is a derived hot path, never an authority; PG commits first. | R-013, R-054 | SOL-11, SOL-21 |
|
||||||
|
| C7 | Reuse `packages/coord`; do NOT revive the untracked `apps/coordinator` residue. | R-042 | SOL-15 AC5 |
|
||||||
|
|
||||||
|
**C1 is the single most consequential output of this exercise.** The charter (`MISSION.md`) and my
|
||||||
|
kickoff instruction both name `mosaic_orchestrator.py::run_single_task:126-276` as the integration
|
||||||
|
point. Both planners independently rejected it on the same evidence: that controller is
|
||||||
|
`"enabled": false` (`.mosaic/orchestrator/config.json:2`) and references a dispatcher path
|
||||||
|
(`tools/macp/dispatcher/pi_runner.ts`) that does not exist in this checkout. Wiring the new choke
|
||||||
|
point into a disabled rail produces **a stranded executor — the identical built-but-unwired disease,
|
||||||
|
one layer up, that would look "done" in a PR.** The live paths are
|
||||||
|
`packages/mosaic/src/commands/launch.ts` and `packages/coord/src/runner.ts`.
|
||||||
|
This contradicted the charter and was escalated as DECISION-1 — **now RULED in the planners' favour by
|
||||||
|
Mos (§5)**. The corrected target is a new production Node `TaskExecutor` on the live dispatch path
|
||||||
|
(`packages/mosaic` launch + `packages/coord`) that Coord/Forge/live dispatch submit through; the
|
||||||
|
Python rail is deleted, not ported.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1a. ★ KEYSTONE DOGFOOD CASE — an inert gate that erased its own evidence
|
||||||
|
|
||||||
|
**A merged commit shipped past `pnpm format:check` — and then the evidence quietly erased itself.**
|
||||||
|
|
||||||
|
Verified chain (blob-level, under the repo's own prettier config, at the file's real path):
|
||||||
|
|
||||||
|
| commit | state of `packages/mosaic/framework/tools/orchestrator/README.md` |
|
||||||
|
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| `b79336a8` — **merged PR #868** | blob `3ee7f104` — **FAILS** `pnpm format:check` |
|
||||||
|
| `48fd1df2` — merged PR #872 (unrelated: ci-queue-wait 404 handling) | blob `3d3bb132` — passes; incidentally reformatted by that PR's `lint-staged` |
|
||||||
|
| current `origin/main` (`06e0d403`) | passes — **the gate now looks green** |
|
||||||
|
|
||||||
|
So: PR #868 merged a file that fails a required gate ⇒ **the CI format gate did not block it.** The
|
||||||
|
gate was inert for that merge. Then an unrelated later PR's pre-commit hook reformatted the file as a
|
||||||
|
side effect, so `main` went green again **without anyone ever learning the gate had failed to fire.**
|
||||||
|
|
||||||
|
> **Correction on record:** my first report to Mos said "format:check is RED on main _now_." That was
|
||||||
|
> true of the `main` my checkout was pinned to (`b79336a8`) and is **no longer true of current `main`**,
|
||||||
|
> which advanced mid-session. The inert-gate finding itself is unchanged and verified; only its
|
||||||
|
> present-tense framing was wrong. The hygiene PR therefore carries the `.prettierignore` fix only —
|
||||||
|
> the README needs no fix today.
|
||||||
|
|
||||||
|
### Third live instance, same class — the queue guard, hit by this orchestrator
|
||||||
|
|
||||||
|
Running the **mandated** pre-push guard during TASK-0:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||||
|
[ci-queue-wait] platform=gitea purpose=push branch=main sha=06e0d403…
|
||||||
|
[ci-queue-wait] state=unknown purpose=push branch=main
|
||||||
|
$ echo $? → 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Two distinct defects in one tool**, both feeding RM-03:
|
||||||
|
|
||||||
|
1. **Wrong exit** — `state=unknown` ⇒ `exit 0`. The defect at `ci-queue-wait.sh:282-288` that OPUS
|
||||||
|
documented and that PR #1023 is parked on. A required gate returned PASS on an indeterminate result.
|
||||||
|
2. **Wrong branch** — it evaluated `branch=main`, not the branch actually being pushed. Even a
|
||||||
|
correctly-exiting guard would have been answering the wrong question.
|
||||||
|
|
||||||
|
**Standing doctrine (Mos):** until RM-03 lands, a green from this guard carries **zero information**
|
||||||
|
and must not be cited as merge evidence. Rely on reviewer clearance + real CI.
|
||||||
|
|
||||||
|
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
||||||
|
is the class confirmed, not anecdote.
|
||||||
|
|
||||||
|
### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically
|
||||||
|
|
||||||
|
`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at
|
||||||
|
D-18's entry, written by the orchestrator.
|
||||||
|
|
||||||
|
Two faults, both mine:
|
||||||
|
|
||||||
|
1. **D-18's AC2 restatement omitted the scope clause** that D-19 later established as mandatory
|
||||||
|
("within an accidental/independent-mutation threat model").
|
||||||
|
2. **D-18 asserted that the tampered-manifest control turns integrity "from a claim into a property."**
|
||||||
|
It does not, and _cannot_. That sentence was written **before** D-19 proved the property impossible
|
||||||
|
at this layer, and was never revised when D-19 landed.
|
||||||
|
|
||||||
|
**The reviewer did not merely read it — it disproved it.** It performed a **same-UID consistent
|
||||||
|
manifest + marker rewrite**, and **preflight passed**. My documented claim was falsified by experiment.
|
||||||
|
Code, README, scratchpad and PR body all stated both threat-model directions correctly; **this file was
|
||||||
|
the only place still overclaiming.**
|
||||||
|
|
||||||
|
**This is two banked findings firing on the orchestrator at once:**
|
||||||
|
|
||||||
|
- **The integrity-claim corollary** — I wrote an integrity _claim_ in the voice of an integrity
|
||||||
|
_property_, in the very document that defines the rule against doing so.
|
||||||
|
- **D-14 (propagation)** — D-19 superseded D-18's assertion. I propagated the consequence into the
|
||||||
|
charter and the delivery conditions, but **not back into D-18 itself.** A ruling that fails to
|
||||||
|
propagate _backwards_ into the finding it supersedes is the same defect as one that fails to
|
||||||
|
propagate forwards, and I did not audit for that direction.
|
||||||
|
|
||||||
|
**Corrected in place**, with the original wording quoted and the empirical disproof recorded, rather
|
||||||
|
than silently rewritten — the same standard demanded of any restated criterion.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (fifth clause).** Documentation asserting a _security or integrity_ property is
|
||||||
|
itself a claim requiring a negative control. Where a document states "X is guaranteed", the registry
|
||||||
|
must hold a case that **fails if X is not guaranteed** — and that case must have been observed red.
|
||||||
|
**Prose is not exempt from the mission's own evidentiary standard**, and prose in the _governing_
|
||||||
|
document least of all: it is the artifact most likely to be quoted as authority long after the code has
|
||||||
|
moved on.
|
||||||
|
|
||||||
|
**Reviewer credit.** rev-974 was briefed that its highest-priority check was "confirm the PR claims no
|
||||||
|
more than it can deliver, and a softened or omitted boundary is a finding even though the code works."
|
||||||
|
It applied that instruction **to the orchestrator's own governing document** and produced an experiment
|
||||||
|
to settle it. That is the review standard this mission is trying to make ordinary.
|
||||||
|
|
||||||
|
### D-19 — an integrity property that cannot exist at the layer it was specified
|
||||||
|
|
||||||
|
Implementing D-18's manifest, the seat + a Codex security review reached **CWE-345**: the symlink
|
||||||
|
manifest and the source-hash marker both live in the **same same-UID writable generated tree**, so an
|
||||||
|
actor with that UID can plant a rogue link, regenerate _both_, retain the fingerprint, and pass. **No
|
||||||
|
local cryptographic construction fixes self-authentication** without a key outside that actor's
|
||||||
|
authority; relocating the marker changes the path, not the authority.
|
||||||
|
|
||||||
|
The seat **escalated rather than describing self-authentication as tamper-resistant** — the explicit
|
||||||
|
failure mode the charter corollary demands. That is the corollary working, on its first real test.
|
||||||
|
|
||||||
|
**Ruling — Option A: scope AC2 to accidental / independent / stale mutation; retain the design.**
|
||||||
|
Rationale, recorded so it can be challenged:
|
||||||
|
|
||||||
|
1. **The undefendable boundary is not the weak link.** An actor with same-UID write can already edit the
|
||||||
|
source, the tests, `scripts/preflight.mjs` itself, and `.husky/*`. If they have that, _nothing_ in the
|
||||||
|
local checkout is trustworthy — hardening the manifest buys no real security while **implying
|
||||||
|
protection that does not exist**, which is worse than the gap.
|
||||||
|
2. **What AC2 is actually for.** These checks exist because a five-month-stale `.next` produced 19
|
||||||
|
phantom `TS2307` errors indistinguishable from real ones (**D-5**). That is staleness, drift and
|
||||||
|
foreign residue — and against that class the design demonstrably works.
|
||||||
|
3. **A real trust anchor arrives later, from this mission's own architecture.** An anchor must live
|
||||||
|
outside the actor's authority; for a fleet running as one user that means a separate service —
|
||||||
|
precisely the **choke-point executor + PG spine** of Builds 1–2, which verify outside the worktree's
|
||||||
|
authority. Hand-rolling key distribution for a local preflight now would duplicate that work badly.
|
||||||
|
4. Option C (structural policy, no manifest) is strictly worse — it cannot detect a **removed** expected link.
|
||||||
|
|
||||||
|
**Option A is acceptable only with honest labelling**, or it becomes the disease it is meant to cure.
|
||||||
|
Conditions (last two added/sharpened by Mos):
|
||||||
|
|
||||||
|
- Threat model stated verbatim in the code **and** the PR; the words _tamper-proof / tamper-evident /
|
||||||
|
secure_ **barred** from that context; the scope carried in AC2's restatement; every control kept
|
||||||
|
RED-first including manifest-only tamper.
|
||||||
|
- **State the boundary in BOTH directions.** Not only what it does _not_ defend (same-UID write; no
|
||||||
|
local construction can) but, beside it, what it **does** defend: accidental / independent / stale /
|
||||||
|
foreign-residue mutation — the **D-5** class it was born from (the five-month `.next` and its 19
|
||||||
|
phantom `TS2307`s). _A reader who sees only the negative dismisses the check as worthless; one who
|
||||||
|
sees only the positive over-trusts it. Both together is the honest artifact._
|
||||||
|
- **The residual risk is a HARD TRACKED DEPENDENCY EDGE, not a comment.** It is **RM-59**, owned by the
|
||||||
|
choke-point executor + spine work (`depends_on: RM-12, RM-21, RM-25`), and the AC2 scope note must
|
||||||
|
cite that id. _"Record where the real guarantee comes from" only holds if the record is a live
|
||||||
|
dependency someone must close._ **A documented gap with no owner becomes a permanent gap that reads
|
||||||
|
as intentional.**
|
||||||
|
|
||||||
|
**The generalizable rule.** When a required property **cannot exist at the layer where it was
|
||||||
|
specified**, the honest moves are: implement what the layer _can_ guarantee, **state the boundary
|
||||||
|
precisely**, and record where the real guarantee will come from. **A known gap that is written down is
|
||||||
|
acceptable; a gap that is implied fixed is not.** Silence here would have shipped a verification
|
||||||
|
artifact that verifies nothing — with a green to prove it.
|
||||||
|
|
||||||
|
### D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation
|
||||||
|
|
||||||
|
Implementing D-17's fix surfaced a conflict **between** pre-registered criteria:
|
||||||
|
|
||||||
|
- **AC2** (as written) — reject symlinked generated state.
|
||||||
|
- **AC4** — the canonical `pnpm -w build` succeeds and leaves no residue.
|
||||||
|
|
||||||
|
Verified independently rather than taken on report: `apps/web/next.config.ts:4` sets
|
||||||
|
`output: 'standalone'`, and the built tree contains **42 legitimate pnpm dependency symlinks** under
|
||||||
|
`.next/standalone/node_modules`. A blanket descendant-symlink rejection makes the canonical build fail
|
||||||
|
its own preflight with exit 43. **AC2 read literally is unsatisfiable alongside AC4 under this
|
||||||
|
configuration**, and nothing short of building the tree would have revealed it.
|
||||||
|
|
||||||
|
**Third distinct failure mode of a pre-registered check set**, completing the chain:
|
||||||
|
|
||||||
|
| finding | a pre-registered check set can be… |
|
||||||
|
| ------- | ------------------------------------------------------------------ |
|
||||||
|
| D-8 | **wrong** — a check that does not test what it claims |
|
||||||
|
| D-17 | **incomplete** — green while a criterion's requirement is untested |
|
||||||
|
| D-18 | **internally inconsistent** — two criteria that cannot both hold |
|
||||||
|
|
||||||
|
The implementing seat escalated instead of silently picking a winner. That matters: **quietly resolving
|
||||||
|
a conflict between pre-registered criteria destroys the point of pre-registering them** — the registration
|
||||||
|
exists so that changes of meaning are auditable rather than absorbed.
|
||||||
|
|
||||||
|
**Resolution (orchestrator ruling).** Approved a **build-certified symlink manifest**: `.next` itself is
|
||||||
|
still rejected as a symlink; descendants are rejected unless _exactly_ certified by a manifest the build
|
||||||
|
publishes atomically. Strictly **stronger** than blanket rejection — it also catches a **retargeted**
|
||||||
|
symlink, which blanket rejection cannot distinguish from a legitimate one.
|
||||||
|
|
||||||
|
**AC2 restated (recorded, not absorbed).** _Generated state must reject `.next` itself being a symlink
|
||||||
|
or non-directory, and must reject any descendant symlink not exactly certified by the build manifest —
|
||||||
|
added, removed, retargeted, or manifest-only-tampered all fail with exit 43 — **within an accidental /
|
||||||
|
independent-mutation threat model.**_
|
||||||
|
|
||||||
|
> ⚠ **This entry is superseded in part by [D-19](#d-19). Do not read D-18 standalone.** The scope clause
|
||||||
|
> above is load-bearing: the design **cannot** defend against a same-UID actor, which can rewrite the
|
||||||
|
> manifest and the marker consistently (CWE-345). D-18 was written **before** that impossibility was
|
||||||
|
> established.
|
||||||
|
|
||||||
|
**Hardening required before this counts.** The manifest is itself generated state, so **a manifest
|
||||||
|
writable by whoever plants a rogue symlink certifies the attack** — that is the one way this design
|
||||||
|
fails. It must sit inside the same ownership/fingerprint envelope, published atomically via the existing
|
||||||
|
marker mechanism, with negative controls **observed red first** for: added, removed, retargeted,
|
||||||
|
**manifest-only-tampered**, plus a positive control that the canonical build passes.
|
||||||
|
|
||||||
|
> ⚠ **CORRECTED (D-20).** This paragraph originally ended: _"without it, integrity is a claim rather
|
||||||
|
> than a property."_ **That overclaimed**, by implying the control makes integrity a _property_. It does
|
||||||
|
> not, and cannot. The manifest-only-tamper control detects **independent** mutation of the manifest;
|
||||||
|
> it confers **no authenticity** against an actor who rewrites manifest _and_ marker together.
|
||||||
|
> `rev-974` disproved the original wording empirically — a same-UID consistent manifest+marker rewrite
|
||||||
|
> **passed preflight**. Integrity here remains a scoped **drift-detection** property, never an
|
||||||
|
> authenticity one. See D-19 and the charter principle on properties that cannot exist at their
|
||||||
|
> specified layer.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (fourth clause).** The registry must detect **conflicts between registered
|
||||||
|
criteria**, not only wrongness and coverage. Two criteria that cannot simultaneously hold is a registry
|
||||||
|
defect discoverable by construction — and when a criterion is restated, the registry must retain the
|
||||||
|
original text, the restatement, and the reason, so the evolution stays auditable.
|
||||||
|
|
||||||
|
### D-17 — a pre-registered criterion passed a green suite without being satisfied
|
||||||
|
|
||||||
|
`rev-974` returned **CHANGES REQUESTED** on PR #1027 with one blocking finding, and it is the sharpest
|
||||||
|
instance of the session's theme because it occurred **inside our own verification machinery**.
|
||||||
|
|
||||||
|
**AC2** was pre-registered before any code was written, and explicitly required that **symlinked
|
||||||
|
generated state be rejected**. The implementation does not do it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -s /etc/hosts apps/web/.next/reviewer-symlink
|
||||||
|
pnpm preflight # → "checkout preflight passed", exit 0
|
||||||
|
# → required: generated-state exit 43
|
||||||
|
```
|
||||||
|
|
||||||
|
The acceptance suite was **21/21 green** throughout. Confirmed independently rather than relayed:
|
||||||
|
`scripts/preflight.mjs:82-92` rejects symlinks on the **source** path; `:28` merely _skips_ symlinked
|
||||||
|
directories rather than rejecting them; and the **generated-state** path at `:141-163` `lstat`s and
|
||||||
|
checks `uid` (ownership) but **never** calls `isSymbolicLink()`. The suite's only symlink cases
|
||||||
|
(`preflight.test.mjs:59`, `:115`) cover the turbo binary and a _source_ file. No generated-state case
|
||||||
|
exists anywhere.
|
||||||
|
|
||||||
|
**So: criterion pre-registered, suite green, requirement unmet.** Nobody was careless — the coverage gap
|
||||||
|
is _invisible from a green_, which is the entire problem.
|
||||||
|
|
||||||
|
**This sharpens D-8 rather than repeating it.** D-8 established that pre-registration does not confer
|
||||||
|
_correctness_ (a check can be wrong when written). D-17 establishes the adjacent failure:
|
||||||
|
**pre-registration does not confer _coverage_** — a suite can be green, and every registered criterion
|
||||||
|
can appear satisfied, while a criterion's actual requirement is untested. The two together mean a
|
||||||
|
registry of checks needs **two** properties, not one: each check must be _right_, and the set must
|
||||||
|
_actually exercise_ what it claims.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (third clause).** The registry must bind each acceptance criterion to the
|
||||||
|
**specific case that exercises it**, and prove that case red before trusting its green. A criterion with
|
||||||
|
no case that can fail for _that criterion's stated reason_ is unregistered in substance however it
|
||||||
|
appears in the manifest. This is mutation testing pointed at the **criterion-to-case mapping**, not
|
||||||
|
merely at the gate.
|
||||||
|
|
||||||
|
**Credit where due:** the reviewer also declined to re-run AC8, stating plainly that the PR carried it
|
||||||
|
forward with no runnable command rather than silently substituting a different boundary test. That is
|
||||||
|
the D-8 clause working a second time, in the same review that produced D-17.
|
||||||
|
|
||||||
|
### D-16 — the local test gate and the CI test gate disagree by environment
|
||||||
|
|
||||||
|
Mos flagged a shape worth chasing: if `pnpm test` exits non-zero on a _pre-existing_ guard, then either
|
||||||
|
`main` is red and merges step around it (the #868 shape again), or CI does not run that path. **Both
|
||||||
|
branches turned out wrong, and the truth is a third thing.** Established by running it, not by asking:
|
||||||
|
|
||||||
|
CI runs **exactly** `pnpm test` (`.woodpecker/ci.yml`, `test` step) — the same command. So the path _is_
|
||||||
|
exercised. Yet:
|
||||||
|
|
||||||
|
| environment | result |
|
||||||
|
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| CI container | `test` step **green** (#2158, #2167) |
|
||||||
|
| this host, clean worktree | **exit 97** — `WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash 5.2.15(1)-release … expected [3 4], probe reported [3 5] (#973)`, after `PASS=18 FAIL=0` |
|
||||||
|
| this host, main checkout | **exit 1** — a _different_, second defect (below) |
|
||||||
|
|
||||||
|
The guard is **environment-dependent**: it aborts on this host's bash and not in CI's container. `git
|
||||||
|
diff origin/main...` confirms PR #1027 touches **zero** files under `packages/mosaic`, so the guard is
|
||||||
|
genuinely pre-existing and unrelated — **f10-coder's report was accurate in every particular**, and
|
||||||
|
`main` is equally affected on this host.
|
||||||
|
|
||||||
|
**So it is not "merges step around a red" — it is worse in one specific way: the local gate and the CI
|
||||||
|
gate do not agree about what passing means.** No agent on this host can obtain a green `pnpm test` at
|
||||||
|
all, on any branch, including `main`. A gate an operator cannot run is a gate that only CI enforces,
|
||||||
|
and a gate only CI enforces cannot be a pre-push gate. This is the hermeticity/portability class
|
||||||
|
already live as #1007 (PR #1024).
|
||||||
|
|
||||||
|
**Second, independent defect found while establishing the above.** In the main checkout the same
|
||||||
|
package fails differently — exit 1 — because a test **scans the working tree** and asserts on files it
|
||||||
|
finds, picking up `apps/coordinator/venv/**` (third-party `site-packages`: `pi = math.pi` in `rich`,
|
||||||
|
`setuptools`, `mypy`). **A test whose result depends on untracked files present in the tree is not
|
||||||
|
hermetic.** This is the _same_ contamination source that made `pnpm format:check` unpassable (D-1/D-7
|
||||||
|
hygiene) — one untracked foreign tree silently breaking two independent gates.
|
||||||
|
|
||||||
|
**Requirements.** RM-01/RM-04: a gate must produce the same verdict on a developer host and in CI, or
|
||||||
|
declare loudly that it cannot run here — never diverge silently. RM-02 registers both as cases: the
|
||||||
|
environment-divergence guard, and a hermeticity control asserting a suite's verdict is unchanged by the
|
||||||
|
presence of untracked directories. Coordinate with #1007/#1024 rather than opening a third lane.
|
||||||
|
|
||||||
|
**Ownership (Mos, 2026-07-31).** The hermeticity fix **is** PR #1024, which sits in **Jason's parked
|
||||||
|
delivery stack** — so, like #1023, its disposition is Jason's. Marked `SUPERSEDED-PENDING-JASON`
|
||||||
|
alongside #1023. D-16 strengthens the urgency but does not transfer ownership: **we do not open a third
|
||||||
|
lane on a parked PR.** The one-line escalation for Jason: _two independent gates
|
||||||
|
(`format:check`, `pnpm test`) were broken by a single untracked directory, and a third
|
||||||
|
(`pnpm test`) disagrees between host and CI — non-hermetic gates make every green host-dependent._
|
||||||
|
|
||||||
|
**Sharpened statement of the class (Mos).** A pre-push gate an operator _cannot run locally_ is a gate
|
||||||
|
only CI enforces — so pointing `.husky/pre-push` at it **misrepresents where the gate lives**. Combined
|
||||||
|
with the shared root cause across two gates, the finding is: **non-hermetic gates make every green
|
||||||
|
host-dependent.** A gate that only appears to pass depending on which host runs it is this mission's
|
||||||
|
exact subject, one meta-level up.
|
||||||
|
|
||||||
|
**#1027 disposition (Mos):** proceeds on **CI-green**. CI is the authoritative gate; the local exit-97
|
||||||
|
is a known host-specific guard abort, irrelevant to the merge decision.
|
||||||
|
|
||||||
|
### D-15 — token scope is not repository permission (a THIRD capability layer)
|
||||||
|
|
||||||
|
`f10-coder` was provisioned with `gitea-mosaicstack-f10-coder.token`, scopes `write:repository` +
|
||||||
|
`write:issue`, and the mint was verified by "repo access returns 200". It then failed to push:
|
||||||
|
|
||||||
|
```
|
||||||
|
remote: error: User permission denied for writing.
|
||||||
|
remote: error: pre-receive hook declined
|
||||||
|
```
|
||||||
|
|
||||||
|
Verified objectively rather than inferred (per the charter principle):
|
||||||
|
|
||||||
|
| probe | result |
|
||||||
|
| ------------------------------------------------------ | ------------------------------------------- |
|
||||||
|
| `GET /repos/mosaicstack/stack/collaborators/f10-coder` | **404** — not a collaborator |
|
||||||
|
| repo permissions as seen by **its own token** | `admin: false`, `push: false`, `pull: true` |
|
||||||
|
|
||||||
|
**Capability has at least three independent layers, and satisfying two proves nothing about the third:**
|
||||||
|
|
||||||
|
1. **Token file exists** → raw-API authentication works (D-11b).
|
||||||
|
2. **`tea` login exists** → tea-dependent wrapper paths work (D-13).
|
||||||
|
3. **Repository permission granted** (collaborator/team membership) → _writes_ are actually authorised.
|
||||||
|
|
||||||
|
A token can carry `write:repository` scope and still be refused, because **scope bounds what a token
|
||||||
|
may attempt; repository permission decides what the user may do.** They are different systems.
|
||||||
|
|
||||||
|
**This is the charter principle failing on the very check meant to confirm capability.** The mint was
|
||||||
|
validated by an HTTP 200 on a _read_. A 200 proves reachability; it does not prove the property that
|
||||||
|
was required, which was **write**. Both the provisioner and I accepted it — the same
|
||||||
|
`written-unverified` treated as `verified` as D-12, one layer up, on a check whose entire purpose was
|
||||||
|
verification.
|
||||||
|
|
||||||
|
**Requirements.** RM-50's pre-dispatch capability check must probe the **effective permission for the
|
||||||
|
operation intended** — for push authority, assert `permissions.push == true` as that seat, not token
|
||||||
|
existence and not a 200 on a read. RM-04's registry reconciliation covers all three layers, with a
|
||||||
|
must-fail control for each. A capability check that cannot fail on a seat lacking write permission is
|
||||||
|
itself an inert gate.
|
||||||
|
|
||||||
|
### D-14 — a ruled decision did not propagate to the authoritative record
|
||||||
|
|
||||||
|
DECISION-1 (the corrected choke-point wire-in target) was ruled by the coordinator and applied to
|
||||||
|
`TASKS.md`. **`MISSION.md` — the charter, the document a cold-starting seat reads first — kept the
|
||||||
|
superseded target for hours.** It was flagged `CONTESTED` in a board note, then the ruling landed and
|
||||||
|
nobody edited the charter. A seat resuming from the charter would have read the _rejected_ target as
|
||||||
|
authoritative and wired the choke point into a disabled rail — the precise failure the ruling existed
|
||||||
|
to prevent.
|
||||||
|
|
||||||
|
Caught by hand, during an unrelated edit. Nothing would have caught it otherwise.
|
||||||
|
|
||||||
|
**This is P-MISSION-001 turned on ourselves.** The mission's own thesis is that convention exists and
|
||||||
|
_enforcement_ is the gap: a decision that lives in a chat ruling and a board note, but not in the
|
||||||
|
source of truth, has not actually been made — it has been _agreed_. The two are different, and the
|
||||||
|
difference is exactly what this mission is about.
|
||||||
|
|
||||||
|
> ⚠ **AMENDED by D-20 — propagation is BIDIRECTIONAL.** As first written, this requirement was read by
|
||||||
|
> both the orchestrator and the coordinator as _forward_ propagation only: a ruling reaches the
|
||||||
|
> documents that state the new rule. **D-20 proved that insufficient.** When D-19 superseded part of
|
||||||
|
> D-18, the consequence propagated forward into the charter and the delivery conditions but **never
|
||||||
|
> backward into D-18 itself**, which went on asserting a withdrawn claim — and a reviewer disproved it
|
||||||
|
> by experiment. **A supersession must update BOTH the documents that render the new rule AND the
|
||||||
|
> finding it retires, with the retired wording quoted rather than deleted.** Backward propagation is
|
||||||
|
> the same defect as forward; neither of us audited that direction until it bit.
|
||||||
|
|
||||||
|
**Requirement (not merely a fix).** A ruled decision must propagate **mechanically** to the
|
||||||
|
authoritative record; it must not depend on someone remembering to edit a second file. Concretely, once
|
||||||
|
mission state is DB-backed (RM-53 / the P-MISSION cutover):
|
||||||
|
|
||||||
|
- a decision is a **record**, not prose duplicated across documents;
|
||||||
|
- documents _render_ decisions rather than restating them, so there is one place to be wrong;
|
||||||
|
- and where duplication is unavoidable, a check asserts the authoritative record and the derived
|
||||||
|
document agree — with a must-fail control proving divergence is detected.
|
||||||
|
|
||||||
|
Until then, the interim rule: **the same commit that records a ruling updates every document that
|
||||||
|
states it — and every finding it supersedes.** Interim rules are exactly what the DB cutover exists to replace.
|
||||||
|
|
||||||
|
**The rule found a second instance within minutes of being written.** Auditing the charter against all
|
||||||
|
rulings to date (rather than waiting to be bitten again) surfaced that **DECISION-2 had also not
|
||||||
|
propagated**: `MISSION.md`'s standing directives still stated the DB hard-cutover with no mention of
|
||||||
|
Mos's binding qualification that _the spine must not be a single-point hard-stop_ (degraded mode +
|
||||||
|
rollback artifact required). A seat reading the charter would have designed toward an availability
|
||||||
|
posture the coordinator had explicitly rejected — and would have found the superseded
|
||||||
|
"no DB ⇒ the fleet stops" recommendation nowhere contradicted. Now corrected in place.
|
||||||
|
|
||||||
|
**Two un-propagated rulings out of two rulings that touched charter text.** The propagation gap is not
|
||||||
|
an oversight that happened once; without a mechanism it is the _default outcome_. That is the argument
|
||||||
|
for making this a requirement rather than a discipline.
|
||||||
|
|
||||||
|
### D-13 — two credential registries that can disagree (why the `--draft` fallback fired at all)
|
||||||
|
|
||||||
|
Diagnosing D-12's root cause surfaced a distinct defect. There are **two parallel credential
|
||||||
|
registries**, and capability in one does not imply capability in the other:
|
||||||
|
|
||||||
|
| registry | contents for identity `mos-dt-0` on `mosaicstack` |
|
||||||
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
|
| token files — `~/.config/mosaic/secrets/gitea-tokens/` | `gitea-mosaicstack-mos-dt-0.token` **EXISTS** |
|
||||||
|
| `tea login list` | **NO** `mosaicstack` login for `mos-dt-0` (only `mosaicstack-mos` and `mosaicstack-rev-974`) |
|
||||||
|
|
||||||
|
So `get_gitea_token` succeeds and every raw-API path works, while every **tea-dependent** wrapper path
|
||||||
|
fails its login validation and silently degrades to the API fallback — which is exactly what dropped
|
||||||
|
`--draft`. **tea is not "stale"; the login simply does not exist for that identity.**
|
||||||
|
|
||||||
|
This matters beyond one flag: capability was declared authoritative by the token-file set (D-11b), but
|
||||||
|
that registry does not govern the tea path. A seat can be _fully provisioned_ by the authoritative
|
||||||
|
registry and still lose functionality with no error — only a warning, and only on the degraded path.
|
||||||
|
|
||||||
|
**Requirements.** RM-04 (activation coherence): the two registries must be reconciled — one source of
|
||||||
|
truth, or a startup check asserting they agree, with a must-fail control proving disagreement is
|
||||||
|
detected. RM-50: the pre-dispatch capability check must verify capability for the **path actually
|
||||||
|
used**, not merely token-file presence.
|
||||||
|
|
||||||
|
**Confirmed working despite the gap** (so this is degradation, not outage): pushes, `pr-merge.sh`,
|
||||||
|
PR/issue creation via API fallback, comment posting, and all reads. Impact is confined to
|
||||||
|
tea-only features — `--draft`, `--labels`, `--milestone`.
|
||||||
|
|
||||||
|
**Reconciliation run by Mos (the manual form of RM-04's assert-agreement, done once by hand).** For
|
||||||
|
`git.mosaicstack.dev`, the token-file registry holds **six** seats; `tea` holds logins for **two**:
|
||||||
|
|
||||||
|
| state | seats |
|
||||||
|
| ------------------------------------------------ | -------------------------------------------------------- |
|
||||||
|
| token file present, **no** mosaicstack tea login | `f10-coder`, `jarvis`, `mos-admin`, `mos-dt-0`, `pepper` |
|
||||||
|
| token file present **and** tea login present | `rev-974` (only) |
|
||||||
|
|
||||||
|
**Five of six provisioned seats are silently degraded on tea-only features.** This is _systemic_, not
|
||||||
|
a one-off — which is why the fix is registry reconciliation (RM-04) and not a per-seat mint. Minting
|
||||||
|
one seat would clear a symptom and leave the class live.
|
||||||
|
|
||||||
|
Mos deliberately deferred the mint: it is not on RM-01's critical path, and additively editing shared
|
||||||
|
`tea` config underneath running work is a change he declined to make without cause. Full remediation —
|
||||||
|
mint the five missing logins **and** wire the startup must-fail assertion that _detects_ disagreement —
|
||||||
|
lands as RM-04 at a non-critical seam, or immediately if any seat needs a tea-only feature to progress.
|
||||||
|
|
||||||
|
**Correction of record:** this supersedes D-11(b)'s claim that the token-file set is _the_ authoritative
|
||||||
|
capability registry. It is **necessary but not sufficient**. Capability is **per-path**: the token file
|
||||||
|
governs the raw-API path, the tea login governs the tea path, and the two can disagree silently.
|
||||||
|
|
||||||
|
### D-12 — a requested SAFETY flag was silently degraded, and I did not check
|
||||||
|
|
||||||
|
I created PR #1027 with `pr-create.sh ... -d` (draft) because it carries **partial, unproven work**.
|
||||||
|
`tea` authentication was stale, so the wrapper fell back to its raw-API path — which cannot set draft —
|
||||||
|
and emitted:
|
||||||
|
|
||||||
|
```
|
||||||
|
Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup.
|
||||||
|
```
|
||||||
|
|
||||||
|
The PR was created **not-draft**. I read the success output, saw the PR number, and moved on. I then
|
||||||
|
reported to the coordinator that the PR was "opened as draft". **It was open, mergeable, and marked
|
||||||
|
ready for ~25 minutes**, protected only by the words "DRAFT" and "do not merge" in its title and body —
|
||||||
|
i.e. by prose a human might read, not by the platform control I asked for. Detected only because a
|
||||||
|
watcher polled `draft:` and the value disagreed with my belief. Corrected by setting the `WIP:` title
|
||||||
|
prefix (Gitea's draft mechanism); `draft: True` verified after.
|
||||||
|
|
||||||
|
**Three distinct failures, and the third is mine:**
|
||||||
|
|
||||||
|
1. **Silent degradation of a safety flag.** The fallback path dropped `--draft` and still exited 0. A
|
||||||
|
fallback that cannot honour a _safety_ argument must fail, not proceed — degrading `--labels` is a
|
||||||
|
nuisance; degrading `--draft` publishes unproven work as ready to merge.
|
||||||
|
2. **The warning went to stderr and nothing consumed it.** It was correct, specific, and ignored — a
|
||||||
|
warning nobody acts on is indistinguishable from no warning.
|
||||||
|
3. **I did not verify the flag took effect.** I checked that the PR existed, not that it had the
|
||||||
|
property I required. This is the mission's own thesis turned on me: **I trusted a success exit code
|
||||||
|
over an observed state**, on exactly the class of tool this mission exists to distrust.
|
||||||
|
|
||||||
|
**Requirements.** RM-02: a wrapper that cannot honour a safety-relevant argument must exit non-zero —
|
||||||
|
registered with a must-fail control asserting `--draft` on a degraded path fails rather than proceeds.
|
||||||
|
RM-24 (tri-state write outcomes): this is precisely `written-unverified` being treated as `verified` —
|
||||||
|
the PR write succeeded, the _requested property_ was never confirmed, and no one looked.
|
||||||
|
|
||||||
|
### D-11 — seat identity did not survive into git, and seat capability is invisible at dispatch
|
||||||
|
|
||||||
|
Two defects, one dispatch (RM-01 → `f10-coder`):
|
||||||
|
|
||||||
|
**(a) Identity drift — P-WRAPPER-001, reproduced on our own delivery.** The seat's commits are
|
||||||
|
authored `mosaic-coder <[email protected]>` — the generic fallback. **You cannot tell from
|
||||||
|
git history which seat did this work.** Recorded, not rewritten: the drift is the evidence.
|
||||||
|
|
||||||
|
> **Mechanism, corrected (Mos).** My original framing here was wrong, and the error was in the brief
|
||||||
|
> before it was in the finding. `MOSAIC_GIT_IDENTITY` resolves the **token** (which per-slot credential
|
||||||
|
> the wrappers act with). The **commit author** comes from `git config user.name` / `user.email`, which
|
||||||
|
> is a **separate setting** — it fell back to the generic value because nothing set it. Exporting the
|
||||||
|
> identity could never have fixed authorship. **My worker brief instructed only the export, so the
|
||||||
|
> seat did exactly what it was told and the commits were still mis-attributed.**
|
||||||
|
>
|
||||||
|
> **The requirement is coherence: token and authorship must agree.** A seat acting with
|
||||||
|
> `gitea-mosaicstack-f10-coder` must also commit as `f10-coder <[email protected]>`.
|
||||||
|
> Either half alone is identity drift — one produces the right credential with the wrong author, the
|
||||||
|
> other the reverse. That coherence _is_ P-WRAPPER-001, and it belongs in seat setup, not in prose
|
||||||
|
> instructions a seat may follow correctly and still end up wrong.
|
||||||
|
|
||||||
|
**(b) Capability opacity.** Nothing at dispatch time revealed that `f10-coder` had no credential for
|
||||||
|
the target provider. Per-slot tokens live at `~/.config/mosaic/secrets/gitea-tokens/`; the seat holds
|
||||||
|
`gitea-usc-f10-coder` but not `gitea-mosaicstack-f10-coder`. This surfaced only when the seat failed
|
||||||
|
**mid-task, after ~$9 and 69% of its context.** The orchestrator (me) selected a seat without any way
|
||||||
|
to check it could act on the target repo — and there was no way to check.
|
||||||
|
|
||||||
|
`get_gitea_token` behaved **correctly**: it refused to fall through and borrow another slot's token,
|
||||||
|
failing loud precisely to protect gate-16 attribution. The tooling was right; the _dispatch-time
|
||||||
|
information_ did not exist.
|
||||||
|
|
||||||
|
**This is P-RECOVERY-001's "honest capability labeling" applied to seats rather than services.** A seat
|
||||||
|
should declare what it can actually do — which providers, which repos, which credentials — and that
|
||||||
|
declaration must be **checkable before dispatch**, not discovered by failure after the budget is spent.
|
||||||
|
|
||||||
|
**Requirements.**
|
||||||
|
|
||||||
|
- **RM-04 (activation coherence)** gains the identity-binding half: seat setup must set **both** the
|
||||||
|
token identity **and** `git config user.name`/`user.email`, coherently. Verified by an
|
||||||
|
exit-asserting test that makes a commit and asserts its author — never assumed from an instruction
|
||||||
|
in a brief.
|
||||||
|
- **RM-50 (roster ownership)** gains per-seat capability declaration plus a **pre-dispatch capability
|
||||||
|
check**. Mos (who owns provisioning) confirms the check is mechanically trivial: **capability is
|
||||||
|
token-file existence.** Before dispatching seat `X` to provider `Y`, test that
|
||||||
|
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists; if absent, provision it or pick a
|
||||||
|
provisioned seat. **The token-file set is the authoritative capability registry.** A one-second check
|
||||||
|
would have replaced a mid-task failure that cost ~$9 and 69% of a seat's context.
|
||||||
|
|
||||||
|
### D-10 — the queue guard's failure modes are exactly backwards
|
||||||
|
|
||||||
|
`ci-queue-wait.sh` — a **required** pre-push/pre-merge gate — was observed this session doing both of
|
||||||
|
these:
|
||||||
|
|
||||||
|
- **Fails OPEN on an unknown result.** `state=unknown ⇒ exit 0`, five times, during real pushes and
|
||||||
|
real merges. It also evaluates `branch=main` rather than the branch being acted on.
|
||||||
|
- **Fails CLOSED on credential resolution.** In a worker seat it aborted with
|
||||||
|
`Gitea token not found`, hard-blocking a legitimate push of completed, tested work. The worker
|
||||||
|
correctly stopped (Constitution gate 8). The identical command run from that worker's _own worktree_
|
||||||
|
in another shell succeeded, so the checkout and remote were fine — the difference was the worker's
|
||||||
|
process environment.
|
||||||
|
|
||||||
|
**A gate that waves through work it never checked, and blocks work that is ready, has its failure
|
||||||
|
modes inverted.** Availability failures (cannot reach the provider, cannot resolve a credential)
|
||||||
|
should degrade to a loud, auditable _inability to assert_ — never to a hard stop on delivery, and
|
||||||
|
never to a silent pass. Correctness failures (unknown, malformed, terminal-failure) are what must
|
||||||
|
block.
|
||||||
|
|
||||||
|
This is also the **Pi-brick shape** (P-RECOVERY-001): a gate whose own unavailability prevents the
|
||||||
|
work needed to recover from it.
|
||||||
|
|
||||||
|
**Requirement on RM-03, extending its existing two defects:** the guard must distinguish
|
||||||
|
`CANNOT_ASSERT` (credential/transport/provider unavailable — loud, audited, does not silently pass and
|
||||||
|
does not permanently block) from `ASSERTED_NOT_READY` (a real non-green CI state — blocks). Both are
|
||||||
|
registered R-002 cases with must-fail controls; neither may exit 0 silently.
|
||||||
|
|
||||||
|
### D-9 — the comms path shell-interprets message bodies (injection-shaped, found by accident)
|
||||||
|
|
||||||
|
Sending a status message with `agent-send.sh -m "...`backticks`..."` caused bash to **execute** the
|
||||||
|
backticked text as command substitution. The recipient received a mangled body plus a
|
||||||
|
`No such file or directory` error; the intended sentence never arrived. The message was reported as
|
||||||
|
delivered.
|
||||||
|
|
||||||
|
This is the **same class** as the already-noted `pr-create.sh` backtick-quoting bug (M2 scratchpad):
|
||||||
|
**two tools in the comms path treat a message body as shell input.** A body that can execute on the
|
||||||
|
sender is a _correctness_ bug before it is ever a security one — and note the failure mode: the
|
||||||
|
send reported success while silently transmitting something other than what was written. Silent
|
||||||
|
corruption with a success receipt is precisely the pattern this mission exists to eliminate.
|
||||||
|
|
||||||
|
**Requirement on RM-40 / RM-42 (comms/v1), hardened by Mos.** The envelope must carry its payload
|
||||||
|
**verbatim** and must not be subject to shell interpretation at **any** hop — sender, transport, or
|
||||||
|
adapter. Concretely: **file/stdin transport, never argv interpolation.**
|
||||||
|
|
||||||
|
**Standing interim rule, effective now (Mos).** Until the envelope lands, use `agent-send.sh -f
|
||||||
|
<file>` for any message body containing special characters — **never `-m`**. Passing a file sidesteps
|
||||||
|
argv interpolation entirely. **This rule is mandatory in every worker brief this mission issues**,
|
||||||
|
alongside the D-8 "if a check is unrunnable, say so" clause. Round-trip fidelity (send a body containing backticks, `$(…)`, quotes, and newlines; assert
|
||||||
|
byte-identical receipt) is a required registered test case under RM-02, including a must-fail control
|
||||||
|
proving the assertion can detect corruption.
|
||||||
|
|
||||||
|
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
|
||||||
|
|
||||||
|
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
||||||
|
In bash, when no `venv` exists the glob is unmatched and passes through literally, creating a
|
||||||
|
directory named `apps/*/venv/lib` rather than one per workspace. The check as written did not test
|
||||||
|
what it claimed to test.
|
||||||
|
|
||||||
|
`rev-974` ran it **exactly as written**, observed the wrong behaviour, then re-ran the intended
|
||||||
|
assertion at an explicit path — **and said so in the review** rather than silently substituting a
|
||||||
|
working fixture and reporting PASS.
|
||||||
|
|
||||||
|
Two things this establishes:
|
||||||
|
|
||||||
|
1. **The instruction "do not adjust a check to fit the diff; if it is unrunnable, say so explicitly"
|
||||||
|
worked.** A silent substitution here would have produced a green AC2 that proved nothing, on the
|
||||||
|
exact task whose subject is gates that appear to work. The disclosure is what made the PASS
|
||||||
|
meaningful.
|
||||||
|
2. **Pre-registration does not confer correctness.** A pre-registered check is protected from being
|
||||||
|
retrofitted to the implementation; it is not protected from being _wrong when written_. This is a
|
||||||
|
small instance of the mission's own class — an unverified gate — occurring inside the mechanism
|
||||||
|
built to catch unverified gates.
|
||||||
|
|
||||||
|
**Requirement on RM-02 (non-negotiable, sharpened by Mos).** The registry must **self-verify** that
|
||||||
|
every registered case demonstrably **runs** and demonstrably **fails on a known-bad input**.
|
||||||
|
Presence in the registry is **not** evidence. **A check is not trusted until it has been shown to
|
||||||
|
fail.** This is mutation testing / negative control applied _at the registry level_ — meaning
|
||||||
|
**the conformance harness must itself be conformance-tested.** A registered case that cannot fail, or
|
||||||
|
cannot run, is exactly as inert as an unregistered one, and the registry check must detect that
|
||||||
|
itself rather than assume it.
|
||||||
|
|
||||||
|
**Requirement on RM-55.** The same recursion applies to the harness: it must be observed red before
|
||||||
|
its green is worth anything (OPUS R-063 AC1 already states this; D-8 is the empirical case for it).
|
||||||
|
|
||||||
|
**Second, equally load-bearing lesson — reviewer disclosure is what makes a review trustworthy.**
|
||||||
|
rev-974 could have silently swapped in a working fixture and reported `AC2 PASS`. Nothing in the
|
||||||
|
process would have caught it, and the resulting green would have certified nothing — on the very task
|
||||||
|
whose subject is gates that only appear to work. The brief's instruction — _"do not adjust a check to
|
||||||
|
fit the diff; if it is genuinely unrunnable as specified, say so explicitly and explain why rather
|
||||||
|
than silently substituting your own"_ — is therefore not boilerplate. It is the clause that makes a
|
||||||
|
PASS mean something, and it must appear in **every** reviewer brief this mission issues.
|
||||||
|
|
||||||
|
### D-7 — shared-tmpfs contention → cascading ENOSPC (live incident, 2026-07-31)
|
||||||
|
|
||||||
|
The shared 30 G `/tmp` hit **100% ENOSPC** mid-session. It broke tool calls in **two different seats**
|
||||||
|
(mine and Mos's) — a single full disk degrades every agent on the host at once. Recurring: prior
|
||||||
|
incidents 2026-06-18 and 2026-07-17.
|
||||||
|
|
||||||
|
Attribution matters, because the wrong owner cleans the wrong thing. Measured:
|
||||||
|
|
||||||
|
| path | size | last modified | owner |
|
||||||
|
| ---------------------------------------------- | --------- | ---------------------------- | ------------------------------------------------- |
|
||||||
|
| `…/-src-mosaic-stack/6d2faee6…` (this session) | **88 K** | live | mos-remediation |
|
||||||
|
| `…/-src-mosaic-stack/c743185d…` | **3.6 G** | **2026-07-22** (9 days dead) | abandoned session, same project path |
|
||||||
|
| `…/claude-1001/pnpm-store` | **1.6 G** | **2026-07-23** (8 days dead) | abandoned; the live store is correctly on `$HOME` |
|
||||||
|
|
||||||
|
So ~5.2 G — the bulk of the pressure — is **dead session scratch that nothing will ever read again**.
|
||||||
|
This is not a quota problem; it is **P-FLEET-001's stale-session GC, applied to disk instead of tmux
|
||||||
|
sessions.** The same missing capability (nothing owns reaping dead ephemeral state) produces both the
|
||||||
|
orphaned-session failure and this one. Reaping dead-session scratch belongs in RM-50 alongside stale
|
||||||
|
tmux-session GC.
|
||||||
|
|
||||||
|
**Added to RM-01 as acceptance criteria:** heavy build artifacts (node_modules, package stores, build
|
||||||
|
output) must land on the main disk in the worktree, never on the shared 30 G `/tmp`.
|
||||||
|
|
||||||
|
**Resolution, and the part that is actually the finding.** Mos verified the attribution independently
|
||||||
|
(mtimes, no process or `lsof` holding either path, no live session maps) and reaped both as lead
|
||||||
|
coordinator: `/tmp` went to 79%, 6.0 G free. But note _how_ it was resolved — **a human-authority seat
|
||||||
|
did it by hand, because the authority exists and the reaper does not.** That gap is the finding, not
|
||||||
|
the disk usage.
|
||||||
|
|
||||||
|
Two doctrine points fall out, both binding on RM-50:
|
||||||
|
|
||||||
|
1. **The fix is not "agents should tidy up."** Asking each seat to clean its own scratch is
|
||||||
|
`instructions are not enforcement` (D-4) wearing a different hat. A deterministic reaper must own
|
||||||
|
it — same conclusion the north star reaches for every other class in this mission.
|
||||||
|
2. **Refusing to unilaterally delete another session's scratch was correct, and the resolution is not
|
||||||
|
"be braver about deleting."** An agent guessing that someone else's state is garbage is exactly the
|
||||||
|
unreviewed destructive act the Constitution forbids. The resolution is that _ownership and liveness
|
||||||
|
become mechanically decidable_, so reaping is a determination rather than a judgement call.
|
||||||
|
|
||||||
|
**Reaper requirements for RM-50:** liveness determined mechanically (process/`lsof`/session-map, not
|
||||||
|
mtime alone); an age threshold; a dry-run that reports what it would reap and why; and an audit event
|
||||||
|
per reap. Never a heuristic sweep — that would reintroduce the P-WORKFLOW-001 auto-sync failure in a
|
||||||
|
more destructive form.
|
||||||
|
|
||||||
|
**The self-erasure is the important part.** An inert gate that is masked by unrelated downstream
|
||||||
|
commits produces no lasting artifact, which is precisely why this class survives for months. Detection
|
||||||
|
cannot rely on "is `main` currently red" — it must be per-merge-commit.
|
||||||
|
|
||||||
|
This matters more than the one-line fix:
|
||||||
|
|
||||||
|
- It is the **P-QUEUE-001 / P-CONFORMANCE-001 class** ("gate-6 was inert fleet-wide"), reproduced in
|
||||||
|
the repository this mission is remediating, discovered incidentally.
|
||||||
|
- It independently **validates OPUS premise A1** ("every gate is inert until proven otherwise") with
|
||||||
|
live evidence rather than argument — which is why RM-02 is adopted as the keystone (§2, X2).
|
||||||
|
- The file fix rides in its own hygiene PR. **The inert gate itself is NOT quiet-patched.** Per Mos:
|
||||||
|
it stays a first-class backlog item, because patching the symptom would destroy the signal.
|
||||||
|
|
||||||
|
**Binding requirement on RM-02 and RM-55:** the gate registry and the conformance harness must assert
|
||||||
|
**"every merged commit passed every required gate"** — evaluated **per merge commit, against that
|
||||||
|
commit's own tree**, not against current `main`. As the table above proves, a "is main green today"
|
||||||
|
check would have reported all-clear. A merged-commit-that-fails-a-required-gate is the exact detection
|
||||||
|
signal, and it must be a registered must-fail case. A gate that cannot prove it blocked something has
|
||||||
|
not been shown to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Where they genuinely disagree (not averaged — adjudicated)
|
||||||
|
|
||||||
|
| # | Axis | OPUS | SOL | My ruling |
|
||||||
|
| --- | ------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| X1 | **Total cost** | 38 tasks, ~5.3M tok | 25 tasks, ~294K tok | **~18× apart.** Not reconcilable by splitting. They measure different things: SOL explicitly excludes orchestration/review/iteration overhead and assumes one remediation pass; OPUS prices the full loop. Adopt **SOL's scope** with **OPUS's rigor**, and treat SOL's G1 as a hard budget checkpoint (§4). Re-estimate empirically after the first three merged PRs rather than trusting either number. |
|
||||||
|
| X2 | **Gate registry (OPUS R-002)** | Keystone; blocks all P2 | Absent; only a queue-guard fix | **ADOPT OPUS.** Empirically validated in this very session: I found `pnpm format:check` red on `main` via merged PR #868 — a required gate that did not block. OPUS's premise A1 ("every gate is inert until proven otherwise") is not theoretical; it reproduced today, unprompted. Scope it tighter than 120K. |
|
||||||
|
| X3 | **Drizzle PG first-install defect (R-010)** | Hidden blocker; everything downstream depends on it | Not mentioned | **ADOPT OPUS.** `packages/db/src/migrate.ts:30-38` carries a TODO admitting postgres-tier first-install fails today. The spine has only ever been proven on PGlite. Every later migration silently depends on this. SOL missed it. |
|
||||||
|
| X4 | **Rollback artifact for the hard cutover** | D3: hard cutover needs a rehearsed rollback snapshot | SOL-07: import-only, explicitly no dual-write | Both obey "no flat-file interim." OPUS wants a one-directional snapshot nothing reads as authority. I read that as compatible with the directive, but it is Jason's call → **DECISION-2** (§5). |
|
||||||
|
| X5 | **Where the queue guard sits** | P0, independent of spine | SOL-02, also early | Agree it is P0. But ownership collides with **parked PR #1023** → **DECISION-3** (§5). |
|
||||||
|
| X6 | **Report-only rollout** | D5: only with a hard expiry, else withdraw | not raised | **ADOPT OPUS.** A report-only gate is by definition inert; expiry is the mechanism that stops it becoming the new fail-open. |
|
||||||
|
| X7 | **Availability trade (FC-7/FC-11)** | D8: "no DB ⇒ fleet stops" must be pre-committed in writing | not raised | Genuine availability regression, correctly identified. Needs Jason → folded into **DECISION-2**. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Reconciled DAG
|
||||||
|
|
||||||
|
Phases run in order; `⛔` marks a hard barrier. `src` shows lineage (`O`=opus, `S`=sol, `O+S`=both).
|
||||||
|
Estimates are given as a **range** (SOL low / OPUS high) rather than a fabricated midpoint — the
|
||||||
|
spread is itself information, and X1 says we calibrate on real merged PRs.
|
||||||
|
|
||||||
|
### P0 — Make gates provable, and stop the fleet re-bricking
|
||||||
|
|
||||||
|
⛔ _No gate-introducing task in any later phase may merge before RM-02._
|
||||||
|
|
||||||
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
|
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------- | ------ |
|
||||||
|
| RM-01 | Reproducible non-root checkout; gate fails on **code, not env**; heavy artifacts OFF shared `/tmp` (banks D-1/D-2/D-5/D-7) | O+S+live | — | 6K / 60K | codex |
|
||||||
|
| RM-02 | **Gate registry + negative-control CI check** (anti-inert-gate harness) ★keystone | O | RM-01 | — / 120K | opus |
|
||||||
|
| RM-03 ⏸**HOLD** | Queue-guard: **two** defects — (a) `unknown`/`no-status`/malformed ⇒ ≠0, (b) guard evaluates `branch=main` instead of the branch being pushed | O+S+live | RM-02 | 8K / 100K | sonnet |
|
||||||
|
| RM-04 | Activation/version coherence; block launch on skew, fail SAFE; honest `doctor` labels | O+S | RM-01 | (in S-01) / 140K | sonnet |
|
||||||
|
| RM-05 | Break-glass replaces the three silent `MOSAIC BYPASS` fail-opens | O | RM-04, RM-02 | — / 120K | opus |
|
||||||
|
|
||||||
|
> ⚠ **RM-05 must not merge before RM-04.** The bypasses exist because the lease-broker daemon was
|
||||||
|
> never _deployed_ on this host — removing the fail-open before deployment coherence is real
|
||||||
|
> re-creates the 2026-07-22 bricking incident. Hard edge, from OPUS.
|
||||||
|
|
||||||
|
### P1 — Durable spine (PG)
|
||||||
|
|
||||||
|
⛔ _No migration may merge before RM-10._
|
||||||
|
|
||||||
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
|
| ----- | --------------------------------------------------------------------------------------------- | --- | ---------- | ---------- | ------ |
|
||||||
|
| RM-10 | **Fix the Drizzle postgres-tier first-install defect** ★hidden blocker | O | RM-01 | — / 90K | sonnet |
|
||||||
|
| RM-11 | Orchestration spine schema (tasks, attempts, gate_results, hash-chained ledger, typed claims) | O+S | RM-10 | 12K / 160K | opus |
|
||||||
|
| RM-12 | Spine client, fail-closed connection (no silent PGlite in prod) | O | RM-11 | — / 80K | sonnet |
|
||||||
|
| RM-13 | Atomic claims/transitions + transactional outbox + reconciliation sweeper | O+S | RM-12 | 12K / 140K | opus |
|
||||||
|
|
||||||
|
### P2 — The single choke point
|
||||||
|
|
||||||
|
⛔ _RM-25 (no-second-path) lands in the same milestone as RM-20, or the choke point is optional._
|
||||||
|
|
||||||
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
|
| ----- | ---------------------------------------------------------------------------------------------- | --- | ------------------- | ---------------- | ------ |
|
||||||
|
| RM-20 | Canonical MACP contract completion (Task/Result/Event/Claim/tri-state outcome) | S | — | 8K / (in R-020) | codex |
|
||||||
|
| RM-21 | **Production `TaskExecutor`** backed by `@mosaicstack/macp` ★keystone | O+S | RM-12, RM-02, RM-20 | 16K / 220K | opus |
|
||||||
|
| RM-22 | Gate-runner hardening: `fail_on`, timeouts, **empty gate set = failure** | O | RM-21 | — / 120K | sonnet |
|
||||||
|
| RM-23 | Hash-chained MACPEvent ledger in PG + lifecycle EventType extension | O+S | RM-21, RM-11 | — / 160K | opus |
|
||||||
|
| RM-24 | Seat identity from `MOSAIC_AGENT_NAME` + **mandatory** tri-state write outcomes | O+S | RM-21 | (in S-03) / 150K | opus |
|
||||||
|
| RM-25 | **No-second-path gate:** terminal status writable only by the executor | O | RM-21, RM-23 | — / 140K | opus |
|
||||||
|
| RM-26 | `packages/coord` submits through the executor (retire direct spawn) | O+S | RM-21 | 16K / 140K | sonnet |
|
||||||
|
| RM-27 | `mosaic yolo/claude/codex/pi` launch path records typed Task + events | O | RM-21, RM-23 | — / 160K | sonnet |
|
||||||
|
| RM-28 | Delete the Forge stub executor (empty-gate-list "success"); Forge submits through the real one | O+S | RM-21 | 10K / 90K | codex |
|
||||||
|
| RM-29 | One-shot flat-file import + cutover readiness audit (dry-run, idempotent, no dual-write) | S | RM-13 | 8K / (in R-062) | codex |
|
||||||
|
|
||||||
|
> **★ G1 — FIRST DOGFOOD. Stop here and prove it.** One live fleet task travels
|
||||||
|
> PG claim → TaskExecutor → worker → gates → terminal PG result/event, with **no** flat-file state.
|
||||||
|
> Adopted from SOL wholesale. If G1 cannot carry a real task, **do not build Redis, rotation, comms,
|
||||||
|
> or conformance** — remediate instead. This is the budget escape hatch (§4).
|
||||||
|
|
||||||
|
### P3 — Rotation lifecycle (finish the Mission Control Plane)
|
||||||
|
|
||||||
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
|
| ----- | -------------------------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
|
||||||
|
| RM-30 | Typed state claims (source/confidence/TTL) with HMAC integrity, fail-closed | O+S | RM-11, RM-21 | (in S-03) / 170K | opus |
|
||||||
|
| RM-31 | Contract-hash binding; stale generation loses mutation authority **mechanically** | O+S | RM-21, RM-30 | 12K / 180K | opus |
|
||||||
|
| RM-32 | Durable compaction/token sensor (per-runtime thresholds, PreCompact event) | O | RM-23, RM-31 | — / 130K | sonnet |
|
||||||
|
| RM-33 | Typed checkpoint writer (structured claims, never transcript) + digest | O+S | RM-30, RM-32 | 12K / 150K | opus |
|
||||||
|
| RM-34 | **Rotation daemon:** watch → checkpoint → revoke → kill → relaunch → rehydrate | O+S | RM-33, RM-26 | 16K / 240K | opus |
|
||||||
|
| RM-35 | Rehydration attestation gate: refuse to act on an incomplete claim set | O | RM-33 | — / 130K | opus |
|
||||||
|
| RM-36 | Broker-independent recovery; remove silent bypass; honest capability labels | S | RM-34 | 12K / (in R-004) | sonnet |
|
||||||
|
| RM-37 | Delete `/compact and continue` from the persistent-seat path (**substitution**, not removal) | O+S | RM-34, RM-44 | (in S-16) / 60K | codex |
|
||||||
|
|
||||||
|
### P4 — Comms service
|
||||||
|
|
||||||
|
⛔ _RM-50 (one roster-owned socket per host) precedes identity-addressed delivery._
|
||||||
|
|
||||||
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
|
| ----- | ---------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
|
||||||
|
| RM-40 | `comms/v1` envelope + protocol-version negotiation, LOUD reject | O+S | RM-11, RM-31 | 8K / 140K | opus |
|
||||||
|
| RM-41 | Comms service: PG state machine PENDING→RECEIVED→CONSUMED→DEAD-LETTER | O+S | RM-40, RM-13 | 16K / 200K | opus |
|
||||||
|
| RM-42 | tmux transport as a **dumb adapter**; durable retry before cursor advance | O+S | RM-41, RM-50 | (in S-19) / 160K | sonnet |
|
||||||
|
| RM-43 | Per-class coalescing + supersede (the stale-consumed-as-live fix) | O+S | RM-41 | 12K / 130K | sonnet |
|
||||||
|
| RM-44 | Redis Streams hot delivery + provenance guard (**Redis is never authority**) | O+S | RM-41, RM-13 | 12K / 170K | opus |
|
||||||
|
| RM-45 | Retire direct tmux sends; only the service may write a pane | O+S | RM-42, RM-43 | (in S-20) / 100K | codex |
|
||||||
|
|
||||||
|
### P5 — Retirements, hygiene, conformance
|
||||||
|
|
||||||
|
| id | task | src | depends_on | est (S/O) | tier |
|
||||||
|
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------- | ---------------- | ------ |
|
||||||
|
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||||
|
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||||
|
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||||
|
| RM-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus |
|
||||||
|
| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet |
|
||||||
|
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||||
|
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||||
|
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||||
|
| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus |
|
||||||
|
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||||
|
|
||||||
|
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Execution discipline
|
||||||
|
|
||||||
|
- **Every row is one PR.** Author ≠ reviewer; `rev-974` is the mosaicstack reviewer identity.
|
||||||
|
- **Pre-registered, diff-blind acceptance checks are committed BEFORE the reviewer reads the diff.**
|
||||||
|
Both decomps wrote their ACs in runnable `⇒0` / `⇒≠0` form specifically to make this possible.
|
||||||
|
- **Every gate-introducing task carries at least one registered must-fail negative control.** This is
|
||||||
|
RM-02's whole purpose; a gate with no proven failure path manufactures evidence.
|
||||||
|
- **Cost tiers:** codex for mechanical/unambiguous, sonnet for normal feature work, opus reserved for
|
||||||
|
security/integrity/cross-cutting-invariant tasks. SOL priced 0 opus tokens; OPUS priced 14 opus
|
||||||
|
tasks. I am keeping opus only where the failure is _integrity_, not merely complexity.
|
||||||
|
- **G1 is the budget checkpoint.** If the first-dogfood slice overruns SOL's estimate by >3×, stop and
|
||||||
|
re-plan rather than spending the remainder. X1 says neither estimate is trustworthy until calibrated.
|
||||||
|
- **Defer list adopted from SOL** (10 items): mission dashboard/TUI, PRD-to-board auto-decomposition,
|
||||||
|
heuristic churn scoring, Discord/Slack/Telegram adapters, public MCP comms surface, protocol-v2
|
||||||
|
negotiation, multi-region PG/Redis, event analytics UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Decisions — all three ruled by Mos, 2026-07-31
|
||||||
|
|
||||||
|
**DECISION-1 — the wire-in point. ✅ RULED: accept the planners (Mos, 2026-07-31).**
|
||||||
|
The charter's `mosaic_orchestrator.py::run_single_task` target is the **disabled Python controller
|
||||||
|
this mission retires**; wiring the new choke point into the rail we are deleting is wrong.
|
||||||
|
|
||||||
|
> **Corrected target (authoritative):** a **new production Node `TaskExecutor`** sitting on the
|
||||||
|
> **live dispatch path** — `packages/mosaic` launch + `packages/coord` — which Coord, Forge, and live
|
||||||
|
> dispatch all **submit through**. This is the MACP scout's _full_ recommendation ("replace the block
|
||||||
|
> **with** a Node executor **and** make Coord/Forge submit through it"), not a resurrection of the
|
||||||
|
> Python controller. RM-52 is therefore a **deletion** task, and Build 1's acceptance is measured on a
|
||||||
|
> live `mosaic yolo` invocation.
|
||||||
|
|
||||||
|
Mos ruled this resolvable from the already-accepted retire-the-Python-rail decision — his authority,
|
||||||
|
not a Jason escalation. RM-21/RM-26/RM-27/RM-52 all take the corrected target.
|
||||||
|
|
||||||
|
**DECISION-2 — rollback artifact + availability trade. ⏸ JASON-PENDING — NOT BLOCKING.**
|
||||||
|
The DB build is phases away, so this is queued for Jason's next session rather than escalated now.
|
||||||
|
**Binding requirement in the meantime (Mos, from P-RECOVERY-001):** the DB spine **must NOT be a
|
||||||
|
single-point hard-stop.** Design for a broker-independent / degraded mode **plus** a rollback
|
||||||
|
artifact. Jason finalises only the specific availability target. This reverses my earlier reading of
|
||||||
|
OPUS D8 ("the fallback is: the fleet stops") — that answer is **not** pre-committed; a degraded mode
|
||||||
|
is now a design requirement on RM-12, RM-13, RM-23, RM-36 and RM-53.
|
||||||
|
|
||||||
|
**DECISION-3 — RM-03 vs. parked PR #1023. ✅ RULED: HOLD RM-03 (Mos, 2026-07-31).**
|
||||||
|
Do **not** open a third gate-6 lane — that is the postmortem's own anti-pattern performed by the
|
||||||
|
remediation. PR #1023 sits in Jason's **parked delivery stack**; its disposition (close, or supersede
|
||||||
|
by RM-03) is Jason's at his next session.
|
||||||
|
|
||||||
|
- **PR #1023 → `SUPERSEDED-PENDING-JASON`.** RM-03 stays `HOLD`; when Jason rules, RM-03 proceeds as
|
||||||
|
the single correct lane.
|
||||||
|
- **RM-02 and RM-55 proceed independently and are NOT held.** The per-merge-commit gate-assertion
|
||||||
|
requirement is the _conformance_ capability, not the gate-6 fix itself — different scope, no
|
||||||
|
ownership collision.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Status
|
||||||
|
|
||||||
|
| phase | state |
|
||||||
|
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Decomposition | DONE — both planners delivered independently |
|
||||||
|
| Reconciliation | DONE — this document |
|
||||||
|
| Blocking decisions | **RULED** — all 3 closed by Mos 2026-07-31 (§5); D-2's availability target is Jason-pending but non-blocking |
|
||||||
|
| Dispatch | **RM-01 IN FLIGHT** — f10-coder (codex), worktree-isolated, AC1–AC8 pre-registered |
|
||||||
|
| Review | PR #1025 with rev-974; ACs pre-registered 22:12:26Z before diff exposure |
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
subject_head=3edde464b3891ad439019fcc19aad7728e4c2fb8
|
||||||
|
source=git show HEAD:tools/install-next-lane.test.sh
|
||||||
|
|
||||||
|
477 echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1
|
||||||
|
478 fi
|
||||||
|
479 [[ "$(grep -oF '[REDACTED]@' <<<"$OUTPUT" | wc -l | tr -d ' ')" -ge 5 ]] \
|
||||||
|
480 || { echo 'credentialed URL redaction controls were not all exercised' >&2; exit 1; }
|
||||||
|
481 secret_active="$TMP/secret-state/active.json"
|
||||||
|
--
|
||||||
|
525 echo 'framework nested capture leaked credential diagnostics' >&2; exit 1
|
||||||
|
526 fi
|
||||||
|
527 [[ "$(grep -oF '[REDACTED]@' "$framework_log" | wc -l | tr -d ' ')" -ge 5 ]] \
|
||||||
|
528 || { echo 'framework URL redaction controls were not exercised' >&2; exit 1; }
|
||||||
|
529
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
source=/tmp/c1-ci-next-x.log (exact failing canonical-image xtrace)
|
||||||
|
credential material is already replaced by the redactor token [REDACTED]; no live secret is reproduced
|
||||||
|
|
||||||
|
urls=https://[REDACTED]@example.com/a https://[REDACTED]@example.net/b https://[REDACTED]@example.org/c https://[REDACTED]@example.dev/d https://[REDACTED]@example.io/e
|
||||||
|
urls=https://[REDACTED]@example.com/a https://[REDACTED]@example.net/b https://[REDACTED]@example.org/c https://[REDACTED]@example.dev/d https://[REDACTED]@example.io/e
|
||||||
|
|
||||||
|
line_count=2
|
||||||
|
occurrence_count=10
|
||||||
|
observed_assertion_value=2 (from xtrace: [[ 2 -ge 5 ]])
|
||||||
|
|
||||||
|
canonical-image discriminator (same locally cached digest as failing run):
|
||||||
|
image_id=sha256:d40fb1a218b72d3dcbf8a427a5076facf2a6d958b6854e6bbd057f7264540841 repo_digests=["git.mosaicstack.dev/mosaicstack/stack/ci-base@sha256:0f1d996a6cfcc09e6dcf979ee66c872a1b0be4f1bfde852b4790f520ddd0d776"]
|
||||||
|
busybox=BusyBox v1.37.0 (2026-01-10 15:38:28 UTC)
|
||||||
|
regex_-o_single_line=5
|
||||||
|
fixed_-oF_single_line=1
|
||||||
|
fixed_-oF_two_lines=2
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
positive_control_exit=1
|
||||||
|
seeded_line=https://[MASKED-USERINFO]@example.io/e (actual synthetic userinfo intentionally omitted here)
|
||||||
|
expected_failure=credentialed URL redaction control missing for example.io
|
||||||
|
transcript_tail:
|
||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
credentialed URL redaction control missing for example.io
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
credentialed URL redaction control missing for example.io
|
||||||
+579
@@ -0,0 +1,579 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="/work"
|
||||||
|
# shellcheck source=tools/test-enumeration-assertions.sh
|
||||||
|
source "$ROOT/tools/test-enumeration-assertions.sh"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export TMPDIR="$TMP/runtime-tmp"
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
|
||||||
|
FAKE_BIN="$TMP/bin"
|
||||||
|
HOME_DIR="$TMP/home"
|
||||||
|
PREFIX="$HOME_DIR/prefix"
|
||||||
|
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||||
|
STATE="$TMP/state"
|
||||||
|
LOG="$TMP/npm.log"
|
||||||
|
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||||
|
|
||||||
|
# Model the supported non-root/glibc target explicitly even when this harness
|
||||||
|
# itself runs as root in Alpine/BusyBox CI.
|
||||||
|
cat > "$FAKE_BIN/id" <<'FAKE_ID'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "${1:-}" in
|
||||||
|
-u) echo 1001 ;;
|
||||||
|
-g) echo 1001 ;;
|
||||||
|
-un) echo fixture-user ;;
|
||||||
|
*) exec /bin/id "$@" ;;
|
||||||
|
esac
|
||||||
|
FAKE_ID
|
||||||
|
cat > "$FAKE_BIN/getent" <<FAKE_GETENT
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'fixture-user:x:1001:1001::%s:/bin/bash\n' '$HOME_DIR'
|
||||||
|
FAKE_GETENT
|
||||||
|
cat > "$FAKE_BIN/ldd" <<'FAKE_LDD'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'ldd (GNU libc) 2.36\n'
|
||||||
|
FAKE_LDD
|
||||||
|
cat > "$FAKE_BIN/stat" <<'FAKE_STAT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_OWNER_PATH:-__none__}" ]] && echo 9999 || echo 1001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %g' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_GROUP_PATH:-__none__}" ]] && echo 9999 || echo 1001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/stat "$@"
|
||||||
|
FAKE_STAT
|
||||||
|
cat > "$FAKE_BIN/realpath" <<'FAKE_REALPATH'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import os, sys
|
||||||
|
args=sys.argv[1:]
|
||||||
|
mode=args.pop(0) if args and args[0] in ('-e','-m') else '-m'
|
||||||
|
if args and args[0]=='--': args.pop(0)
|
||||||
|
if len(args)!=1 or (mode=='-e' and not os.path.exists(args[0])): raise SystemExit(1)
|
||||||
|
print(os.path.realpath(args[0]))
|
||||||
|
FAKE_REALPATH
|
||||||
|
chmod 0755 "$FAKE_BIN/id" "$FAKE_BIN/getent" "$FAKE_BIN/ldd" "$FAKE_BIN/stat" "$FAKE_BIN/realpath"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
STATE="${MOSAIC_TEST_STATE:?}"
|
||||||
|
echo "$*" >> "$LOG"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then
|
||||||
|
echo "10.6.2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_cli() {
|
||||||
|
local version="$1"
|
||||||
|
echo "$version" > "$STATE/mosaic"
|
||||||
|
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||||
|
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "\${1:-}" == "wizard" ]]; then
|
||||||
|
printf 'wizard\n' >> "\${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
mkdir -p "\${MOSAIC_HOME:?}" "\${HOME:?}/.config/mosaic-gateway"
|
||||||
|
printf '# Soul\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/SOUL.md"
|
||||||
|
printf '# User\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/USER.md"
|
||||||
|
chmod 0600 "\$MOSAIC_HOME/SOUL.md" "\$MOSAIC_HOME/USER.md"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s\\n' '$version'
|
||||||
|
CLI
|
||||||
|
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$1" == "view" ]]; then
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||||
|
echo "forced registry metadata failure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$2 $3" in
|
||||||
|
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||||
|
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||||
|
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||||
|
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "install" ]]; then
|
||||||
|
if [[ -n "${MOSAIC_INSTALL_SECRET_CANARY:-}" ]]; then
|
||||||
|
printf 'registry diagnostic authToken=%s\n' "$MOSAIC_INSTALL_SECRET_CANARY"
|
||||||
|
printf 'urls=https://alice:p@[email protected]/a https://bob:pa:[email protected]/b https://carol:p%%[email protected]/c https://[email protected]/d https://public.example/e\n'
|
||||||
|
printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n'
|
||||||
|
printf '%s\n' "$MOSAIC_INSTALL_SECRET_CANARY" > "${MOSAIC_TEST_CANARY_OBSERVATION:?}"
|
||||||
|
fi
|
||||||
|
case "$*" in
|
||||||
|
*"@mosaicstack/[email protected]"*)
|
||||||
|
install_cli "0.0.49-next.999"
|
||||||
|
;;
|
||||||
|
*"@mosaicstack/[email protected]"*)
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||||
|
echo "forced gateway install failure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||||
|
;;
|
||||||
|
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||||
|
install_cli "0.0.0-source"
|
||||||
|
;;
|
||||||
|
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||||
|
echo "0.0.0-source" > "$STATE/gateway"
|
||||||
|
;;
|
||||||
|
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "ls" ]]; then
|
||||||
|
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||||
|
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||||
|
node -e '
|
||||||
|
const cli = process.argv[1];
|
||||||
|
const gateway = process.argv[2];
|
||||||
|
const dependencies = {};
|
||||||
|
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||||
|
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||||
|
process.stdout.write(JSON.stringify({ dependencies }));
|
||||||
|
' "$cli" "$gateway"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "unexpected npm command: $*" >&2
|
||||||
|
exit 1
|
||||||
|
FAKE_NPM
|
||||||
|
chmod +x "$FAKE_BIN/npm"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
headers=""; output=""; url=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-D) headers="$2"; shift 2 ;;
|
||||||
|
-o) output="$2"; shift 2 ;;
|
||||||
|
--max-filesize) shift 2 ;;
|
||||||
|
-*) shift ;;
|
||||||
|
*) url="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
case "$url" in
|
||||||
|
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||||
|
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||||
|
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||||
|
;;
|
||||||
|
*/archive/*.tar.gz)
|
||||||
|
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||||
|
printf 'not-a-tarball\n' > "$output"
|
||||||
|
else
|
||||||
|
archive_root="$(mktemp -d)"
|
||||||
|
mkdir -p "$archive_root/stack"
|
||||||
|
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||||
|
/bin/tar czf "$output" -C "$archive_root" stack
|
||||||
|
rm -rf "$archive_root"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
FAKE_CURL
|
||||||
|
chmod +x "$FAKE_BIN/curl"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
dest=""; list=false
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-C) dest="$2"; shift 2 ;;
|
||||||
|
-*t*|t*) list=true; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ "$list" == true ]] && exit 0
|
||||||
|
if [[ -z "$dest" ]]; then
|
||||||
|
echo "fake tar missing -C destination" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$dest/stack/packages/mosaic/framework" "$dest/stack/apps/gateway"
|
||||||
|
cat > "$dest/stack/packages/mosaic/framework/install.sh" <<'FRAMEWORK'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
expected="${MOSAIC_PREFIX:?}/bin/mosaic"
|
||||||
|
[[ "${MOSAIC_CLI_PATH:-}" == "$expected" && -x "$MOSAIC_CLI_PATH" ]] || {
|
||||||
|
echo "framework did not receive P3 absolute CLI (got=${MOSAIC_CLI_PATH:-unset} expected=$expected)" >&2
|
||||||
|
exit 61
|
||||||
|
}
|
||||||
|
printf 'framework-cli=%s version=%s\n' "$MOSAIC_CLI_PATH" "$($MOSAIC_CLI_PATH --version)" >> "${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
mkdir -p "${MOSAIC_HOME:?}/credentials"
|
||||||
|
chmod 0700 "$MOSAIC_HOME/credentials"
|
||||||
|
printf '# framework fixture\n' > "$MOSAIC_HOME/AGENTS.md"
|
||||||
|
FRAMEWORK
|
||||||
|
chmod 0755 "$dest/stack/packages/mosaic/framework/install.sh"
|
||||||
|
FAKE_TAR
|
||||||
|
chmod +x "$FAKE_BIN/tar"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
echo "pnpm $*" >> "$LOG"
|
||||||
|
|
||||||
|
if [[ "$1" == "pack" ]]; then
|
||||||
|
out=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--pack-destination) out="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ -z "$out" ]]; then
|
||||||
|
echo "fake pnpm pack missing destination" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$out"
|
||||||
|
case "$PWD" in
|
||||||
|
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||||
|
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||||
|
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||||
|
echo "forced pnpm install failure" >&2
|
||||||
|
exit 42
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Other install/build commands are no-ops in this harness.
|
||||||
|
exit 0
|
||||||
|
FAKE_PNPM
|
||||||
|
chmod +x "$FAKE_BIN/pnpm"
|
||||||
|
|
||||||
|
reset_state() {
|
||||||
|
: > "$LOG"
|
||||||
|
rm -f "$STATE"/*
|
||||||
|
}
|
||||||
|
|
||||||
|
tree_fingerprint() {
|
||||||
|
local root="$1"
|
||||||
|
if [[ ! -d "$root" ]]; then printf 'ABSENT\n'; return; fi
|
||||||
|
python3 - "$root" <<'PY'
|
||||||
|
import hashlib, os, stat, sys
|
||||||
|
root=os.path.abspath(sys.argv[1]); rows=[]
|
||||||
|
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||||
|
for name in dirs + files:
|
||||||
|
path=os.path.join(current,name); meta=os.lstat(path)
|
||||||
|
rel=os.path.relpath(path,root)
|
||||||
|
target=os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ''
|
||||||
|
digest=''
|
||||||
|
if stat.S_ISREG(meta.st_mode):
|
||||||
|
with open(path,'rb') as handle: digest=hashlib.sha256(handle.read()).hexdigest()
|
||||||
|
rows.append((rel,stat.S_IFMT(meta.st_mode),stat.S_IMODE(meta.st_mode),target,digest))
|
||||||
|
payload='\n'.join('|'.join(map(str,row)) for row in sorted(rows)).encode()
|
||||||
|
print(hashlib.sha256(payload).hexdigest())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix_fingerprint() { tree_fingerprint "$PREFIX"; }
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --next fast path pins resolved package versions"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
|
||||||
|
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||||
|
echo "expected exact-version installs, found mutable @next install" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||||
|
echo "fast path unexpectedly fell back to source" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||||
|
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||||
|
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||||
|
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||||
|
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] fast path failure falls back to source build"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
|
||||||
|
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||||
|
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||||
|
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||||
|
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --dev source install does not require registry version resolution"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||||
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
|
if grep -qF '@next version' "$LOG"; then
|
||||||
|
echo "explicit ref should not query @next dist-tags" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||||
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||||
|
|
||||||
|
printf '[test] full framework path receives P3 absolute CLI without relying on PATH\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/full-state" MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FULL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FULL_STATUS" -ne 0 ]] # P4 remains intentionally undeclared until C5.
|
||||||
|
grep -qF "framework-cli=$PREFIX/bin/mosaic version=0.0.49-next.999" "$LOG"
|
||||||
|
if grep -q "CLI not found on PATH\|did not receive P3 absolute CLI" <<<"$OUTPUT"; then
|
||||||
|
echo "internal framework phase depended on PATH instead of P3 absolute CLI" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[test] captured diagnostics redact seeded credential canary everywhere\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state
|
||||||
|
canary='C1_SECRET_CANARY_7df4c2'
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/secret-state" MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_INSTALL_SECRET_CANARY="$canary" MOSAIC_TEST_CANARY_OBSERVATION="$TMP/canary-observed" \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
# Positive control: replace the removed redacted example.io source with one deliberately unredacted userinfo URL.
|
||||||
|
OUTPUT+=$'\nhttps://[email protected]/e'
|
||||||
|
if grep -qF "$canary" <<<"$OUTPUT"; then echo 'credential canary leaked to terminal output' >&2; exit 1; fi
|
||||||
|
if grep -Eq 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' <<<"$OUTPUT"; then
|
||||||
|
echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
for host in example.com example.net example.org example.dev example.io; do
|
||||||
|
grep -qF "https://[REDACTED]@$host" <<<"$OUTPUT" \
|
||||||
|
|| { echo "credentialed URL redaction control missing for $host" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
secret_active="$TMP/secret-state/active.json"
|
||||||
|
secret_journal="$(node -p "require('$secret_active').journal")"
|
||||||
|
secret_command_log="$(dirname "$secret_journal")/commands.log"
|
||||||
|
if grep -R -F "$canary" "$secret_command_log" "$HOME_DIR" 2>/dev/null; then
|
||||||
|
echo 'credential canary leaked to persistent installer output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
if grep -E 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' "$secret_command_log" >/dev/null; then
|
||||||
|
echo 'credentialed URL userinfo leaked to persistent installer output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$(cat "$TMP/canary-observed" 2>/dev/null || true)" != "$canary" ]]; then
|
||||||
|
echo 'credential canary positive control was not exercised' >&2; exit 1
|
||||||
|
fi
|
||||||
|
test_assert_find_empty 'redacted diagnostic staging files' \
|
||||||
|
"$TMPDIR" -maxdepth 1 -type f \
|
||||||
|
\( -name 'mosaic-phase-redacted.*' -o -name 'mosaic-post-redacted.*' \) || exit 1
|
||||||
|
|
||||||
|
printf '[test] framework nested capture redacts the same canary and URL variants\n'
|
||||||
|
framework_test_home="$TMP/framework-redact-home"
|
||||||
|
framework_target="$framework_test_home/.config/mosaic"
|
||||||
|
framework_cli="$TMP/framework-redact-cli"
|
||||||
|
framework_log="$TMP/framework-redact-commands.log"
|
||||||
|
framework_status="$TMP/framework-redact-status.tsv"
|
||||||
|
mkdir -p "$framework_test_home"; : > "$framework_log"; : > "$framework_status"
|
||||||
|
cat > "$framework_cli" <<'FRAMEWORK_CLI'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'nested authToken=%s\n' "${MOSAIC_INSTALL_SECRET_CANARY:?}"
|
||||||
|
printf 'nested=https://alice:p@[email protected]/a https://bob:pa:[email protected]/b https://carol:p%%[email protected]/c https://[email protected]/d https://user%%[email protected]/e\n'
|
||||||
|
printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n'
|
||||||
|
exit 1
|
||||||
|
FRAMEWORK_CLI
|
||||||
|
chmod 0755 "$framework_cli"
|
||||||
|
set +e
|
||||||
|
FRAMEWORK_OUTPUT="$(
|
||||||
|
HOME="$framework_test_home" MOSAIC_HOME="$framework_target" MOSAIC_INSTALL_MODE=overwrite \
|
||||||
|
MOSAIC_CLI_PATH="$framework_cli" MOSAIC_INSTALL_SECRET_CANARY="$canary" \
|
||||||
|
MOSAIC_INSTALL_COMMAND_LOG="$framework_log" MOSAIC_INSTALL_PHASE_STATUS_FILE="$framework_status" \
|
||||||
|
MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||||
|
bash "$ROOT/packages/mosaic/framework/install.sh" 2>&1
|
||||||
|
)"
|
||||||
|
framework_install_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$framework_install_status" -eq 0 ]]
|
||||||
|
if grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" <<<"$FRAMEWORK_OUTPUT" \
|
||||||
|
|| grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" "$framework_log"; then
|
||||||
|
echo 'framework nested capture leaked credential diagnostics' >&2; exit 1
|
||||||
|
fi
|
||||||
|
for host in example.com example.net example.org example.dev example.io; do
|
||||||
|
grep -qF "https://[REDACTED]@$host" "$framework_log" \
|
||||||
|
|| { echo "framework URL redaction control missing for $host" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] real P2-P8 actions run under fault injection and restore actual surfaces\n'
|
||||||
|
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||||
|
rm -rf "$HOME_DIR" "$STATE" "$TMP/fault-$phase"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/fault-$phase"
|
||||||
|
printf 'operator-sentinel\n' > "$HOME_DIR/operator.txt"
|
||||||
|
reset_state
|
||||||
|
before="$(tree_fingerprint "$HOME_DIR")"
|
||||||
|
set +e
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/fault-$phase" MOSAIC_INSTALL_FAULT_AFTER="$phase" \
|
||||||
|
MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes \
|
||||||
|
>"$TMP/fault-$phase.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -eq 97 ]] || { echo "$phase real fault expected 97, got $status" >&2; exit 1; }
|
||||||
|
[[ -s "$LOG" ]] || { echo "$phase fault never entered the real action path" >&2; exit 1; }
|
||||||
|
[[ "$(tree_fingerprint "$HOME_DIR")" == "$before" ]] || { echo "$phase real rollback mismatch" >&2; exit 1; }
|
||||||
|
grep -q "phase=$phase" "$TMP/fault-$phase.log"
|
||||||
|
test_assert_no_file_content_match "$phase fault-state" \
|
||||||
|
'"status"[[:space:]]*:[[:space:]]*"in-progress"' "$TMP/fault-$phase" || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] stale projection is preserved while the real fault path acquires a free OS lock\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE" "$TMP/stale-state"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/stale-state"
|
||||||
|
printf '{"status":"in-progress","journal":"%s"}\n' "$TMP/stale-state/dead-run/journal.ndjson" > "$TMP/stale-state/active.json"
|
||||||
|
reset_state
|
||||||
|
set +e
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/stale-state" MOSAIC_INSTALL_FAULT_AFTER=P2 \
|
||||||
|
MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes >"$TMP/stale.log" 2>&1
|
||||||
|
stale_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$stale_status" -eq 97 ]]
|
||||||
|
find "$TMP/stale-state" -name prior-active.json -type f -print -quit | grep -q .
|
||||||
|
[[ "$(node -p "require('$TMP/stale-state/active.json').status")" == rolled-back ]]
|
||||||
|
|
||||||
|
echo "[test] installer next lane tests passed"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] source archive with multiple extracted roots fails instead of selecting by find order
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
[test] framework nested capture redacts the same canary and URL variants
|
||||||
|
[test] real P2-P8 actions run under fault injection and restore actual surfaces
|
||||||
|
P2 left an in-progress transaction
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] source archive with multiple extracted roots fails instead of selecting by find order
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
[test] framework nested capture redacts the same canary and URL variants
|
||||||
|
[test] real P2-P8 actions run under fault injection and restore actual surfaces
|
||||||
|
[test] stale projection is preserved while the real fault path acquires a free OS lock
|
||||||
|
[test] installer next lane tests passed
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test] enumeration failure cannot mask a planted in-progress transaction
|
||||||
|
[test] FAIL: planted in-progress transaction plus failed enumeration passed the full suite
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] source archive with multiple extracted roots fails instead of selecting by find order
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
[test] framework nested capture redacts the same canary and URL variants
|
||||||
|
[test] real P2-P8 actions run under fault injection and restore actual surfaces
|
||||||
|
find: ‘/tmp/mosaic-next-install-test-hqL7U0/fault-P2/blocked’: Permission denied
|
||||||
|
[test] ERROR: P2 fault-state enumeration failed
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#1050 C1 ROUND 3 — REMOTE-ARM DOWNSTREAM SOURCE BINDING
|
||||||
|
|
||||||
|
IDENTITY / SCOPE
|
||||||
|
- branch: feat/1050-install-state-machine-red-fixture
|
||||||
|
- predecessor/provider lease pin: ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- status language: believed-fixed, pending jarvis validation
|
||||||
|
- in scope: remote arm's mosaicstack/stack framework/source archive (#7)
|
||||||
|
- unchanged/fenced: expected-RED manifest including P6=FAIL; #869; #1068 sequencing
|
||||||
|
- corrected reference citation: the container-image acquisition path in fa-install.sh only
|
||||||
|
|
||||||
|
ACQUISITION-PATH CENSUS (10/10 CODE-READ; COMPLETE RUNTIME PATH CENSUS NOT MEASURED)
|
||||||
|
1. Woodpecker checkout at CI commit — fully constrained by intended CI identity.
|
||||||
|
2. node:22-bookworm-slim image — unpinned sibling, out of scope.
|
||||||
|
3. apt package set — unpinned sibling, out of scope.
|
||||||
|
4. tools/install.sh at CI_COMMIT_SHA + verified SHA-256 — fully constrained.
|
||||||
|
5. @mosaicstack/mosaic@next — P2 CLI value is authoritative; realised disagreement is detected at P3 — fully constrained at the top-level package version.
|
||||||
|
6. @mosaicstack/gateway@next — later re-resolution wins and is verified against itself; drift from the first value can be silently accepted — partially constrained TOCTOU sibling, out of scope.
|
||||||
|
7. stack framework/source archive — pre-fix internally pinned to WRONG identity 4df478cdd150fdf8d52ea109f02ade5d85017acd; post-fix bound to intended CI commit.
|
||||||
|
8. unversioned sequential-thinking npx package — unpinned sibling, out of scope.
|
||||||
|
9. Excalidraw dependency graph — shipped lockfile exact versions + registry integrity hashes — fully constrained by lock/integrity.
|
||||||
|
10. agent-skills default-branch clone — unpinned sibling, out of scope.
|
||||||
|
|
||||||
|
Post-fix mechanism breakdown: fully constrained 5/10 (#1,#4,#5,#7,#9); partially constrained 1/10 (#6); unpinned 4/10 (#2,#3,#8,#10). Same-CI stack payload hops moved from 1/2 to 2/2. The fix does not increase the number of constrained paths; it corrects #7 from wrong identity to intended identity. This census is a LOWER BOUND on defects and an UPPER BOUND on assurance: questioning revised the claimed assurance downward each time (blended 6/10 -> mechanism split -> 5 full + 1 partial + 4 unpinned), never upward.
|
||||||
|
|
||||||
|
#5/#6 RESOLUTION-FAILURE / TOCTOU DISPOSITION (CODE-READ; DISAGREEMENT INJECTION NOT MEASURED)
|
||||||
|
- P1 metadata failure blocks before mutation.
|
||||||
|
- Failed package install falls back to the one immutable stack archive resolved in P2; source resolution/fetch failure is fatal.
|
||||||
|
- #5 CLI: P2 RESOLVED_CLI_VERSION is authoritative; a later installed CLI mismatch is detected by P3 and rolls back.
|
||||||
|
- #6 gateway: no authoritative P2 gateway value is persisted. The last resolution in install_next_cli_from_registry wins and is compared with itself, so successful same-suffix gateway drift can be silently accepted. Reported, not fixed.
|
||||||
|
|
||||||
|
RED-FIRST — STALE NEXT REJECTED BEFORE ACQUISITION FIX
|
||||||
|
Command shape: exact ff3f0d2 installer URL/digest + expected source commit/digest, remote next lane.
|
||||||
|
- expected source commit: ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- expected source SHA-256: 944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25
|
||||||
|
- fixture rc: 1
|
||||||
|
- realised source commit: 4df478cdd150fdf8d52ea109f02ade5d85017acd
|
||||||
|
- realised source SHA-256: 3e6d831efe13c3b2c0501507099d4a566af5abf877dacf85e5d7e4284d35e5c7
|
||||||
|
- source control: FAIL (realised != expected)
|
||||||
|
- P6 final fixture value: PASS (false pass from stale framework)
|
||||||
|
- forbidden evidence: CLI not found on PATH present
|
||||||
|
- comparator rc: 1; failures=8/checks=19
|
||||||
|
|
||||||
|
IMPLEMENTED PATH #7
|
||||||
|
- .woodpecker/greenfield-install.yml supplies MOSAIC_FIXTURE_SOURCE_COMMIT=${CI_COMMIT_SHA} while still invoking --lane next and --source remote.
|
||||||
|
- tools/e2e-install-test.sh validates 40-hex commit input, derives the exact commit archive URL, rejects failed/empty fetch, computes SHA-256 over that exact body, and passes the body/commit/digest through MOSAIC_INSTALL_LOCAL_SOURCE_{ARCHIVE,COMMIT,SHA256}.
|
||||||
|
- The existing installer local-source seam re-hashes the body and rejects mismatch.
|
||||||
|
- The fixture verifies realised .install-manifest.json sourceCommit and sourceSha256 against the exact fetched body after installation. Mismatch exits before the phase comparator; there is no fallback to next.
|
||||||
|
|
||||||
|
EXACT-SOURCE REPRODUCIBILITY RESULT — N=5 IDENTICAL FINAL-TREE EXECUTIONS
|
||||||
|
Invariant inputs on every run:
|
||||||
|
- fixture mode: --lane next --source remote --git present
|
||||||
|
- installer/source commit: ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- installer SHA-256: e59cb441a2f37ae9150f8eae470238e9d858a1816df93343d9784a6796676096
|
||||||
|
- realised source SHA-256: 944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25
|
||||||
|
- resolved/realised CLI: @mosaicstack/mosaic@next = 0.0.50-next.2207
|
||||||
|
- fixture rc=1, P3 PASS, SOURCE-CONTROL PASS, installer_exit=1, done_claims=0, P6 FAIL, P9 FAIL
|
||||||
|
|
||||||
|
Per-run outcomes:
|
||||||
|
run 1: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed/load NOT MEASURED
|
||||||
|
run 2: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed/load NOT MEASURED
|
||||||
|
run 3: comparator rc=1; checks=18/19; dead_hooks=2; P6 reason=broker absent but dead enforcement hooks are active (count=2); elapsed=912s; load before=7.79 7.51 8.93; load after=12.28 14.82 11.79
|
||||||
|
run 4: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed=938s; load before=12.28 14.82 11.79; load after=9.84 15.18 13.83
|
||||||
|
run 5: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed=954s; load before=9.84 15.18 13.83; load after=3.94 4.76 8.18
|
||||||
|
|
||||||
|
Rate, not verdict:
|
||||||
|
- comparator rc=0: 4/5
|
||||||
|
- comparator rc=1: 1/5
|
||||||
|
- action-failure P6 reason: 4/5
|
||||||
|
- dead-hooks-active count=2 P6 reason: 1/5
|
||||||
|
- source commit/digest property: PASS 5/5
|
||||||
|
- P6 outcome property: FAIL 5/5
|
||||||
|
No comparator verdict is claimed. Identical source, digest, and package version produced different comparator values; this gate is nondeterministic on the #869 probe path. Load does not explain the small sample monotonically: run 4 had the highest observed 5/15-minute load but produced rc=0. The 2000ms capability-probe timeout remains a code-read hypothesis, NOT MEASURED as causal.
|
||||||
|
|
||||||
|
CHECKOUT CONTROL (EXECUTED, NOT PART OF REMOTE N=5)
|
||||||
|
The checkout-source arm independently produced: fixture rc=1; @next CLI 0.0.50-next.2207; P3 PASS; P6 FAIL with dead hooks active count=2; comparator rc=1 with the same 1/19 required-reason miss. This showed that outcome can occur independently of the remote #7 binding; its reproducibility was not separately measured.
|
||||||
|
|
||||||
|
PROPERTY / SIGNAL FINDING (MANIFEST UNCHANGED)
|
||||||
|
P6=FAIL remains the correct property and is unchanged. The comparator additionally pins one reason signal: "runtime linking/activation action reported a required failure". Exact-source P6 failed on all 5/5 runs, while the reason signal varied. The reason assertion was not widened to fit either observation.
|
||||||
|
|
||||||
|
#869 FINDING (BEHAVIOR EXECUTED; ROOT-CAUSE ATTRIBUTION CODE-READ)
|
||||||
|
Current @next now carries the lease capability. defaultSupervisorProbe treats bundled launcher+daemon file presence plus a nonempty resolved socket PATH as supervisor presence; it does not require the socket itself to exist. One of five identical clean broker-absent runs wired two dead hooks and let the linker exit zero; four reported the action failure. The 2000ms defaultCapabilityProbe subprocess timeout is a code-read candidate for the variance, NOT MEASURED as causal. Fixing this is #869 scope and was not performed.
|
||||||
|
|
||||||
|
TRUST BOUNDARY / INDEPENDENT PROVENANCE
|
||||||
|
The exact-commit archive URL binds source identity under the configured repository provider's authenticated mapping of commit ID to response bytes. The computed SHA-256 proves that the bytes executed are the bytes that exact URL served and that they did not change between fetch, installer consumption, and manifest verify-after. That is the measured control against stale-next substitution: SOURCE-CONTROL passed 5/5 exact-source runs and failed when R7 deleted the binding. The digest does NOT prove that those bytes are what the repository's authors published if the repository service or TLS trust root is compromised. Provider + TLS are the trust root by design. Independent signed provenance/authenticity remains an inherited deferral explicitly sourced to canonical greenfield-install PRD v2 §3, matching the already documented install.sh sidecar boundary. No stronger supply-chain claim is made. Codex security review's initial HIGH/CWE-494 finding is retained as the named trust-root deferral rather than bypassed or silently reclassified.
|
||||||
|
|
||||||
|
R7 DELETE-THE-SUBJECT MUTANT (EXECUTED)
|
||||||
|
Deleted the three MOSAIC_INSTALL_LOCAL_SOURCE_{ARCHIVE,COMMIT,SHA256} binding exports from tools/e2e-install-test.sh temporarily while retaining the realised source assertion.
|
||||||
|
- fixture rc: 1
|
||||||
|
- realised source commit reverted to 4df478cdd150fdf8d52ea109f02ade5d85017acd
|
||||||
|
- expected source commit remained ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- realised source SHA-256: 3e6d831efe13c3b2c0501507099d4a566af5abf877dacf85e5d7e4284d35e5c7
|
||||||
|
- expected source SHA-256: 944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25
|
||||||
|
- SOURCE-CONTROL: FAIL
|
||||||
|
- comparator rc: 1; failures=8/checks=19
|
||||||
|
- elapsed: 171s; load before=4.13 4.71 8.05; load after=4.94 4.58 7.42
|
||||||
|
- subject file SHA-256 before mutant: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98
|
||||||
|
- subject file SHA-256 after restoration: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98
|
||||||
|
|
||||||
|
EVIDENCE LOGS (LOCAL, NOT COMMITTED RAW TRANSCRIPTS)
|
||||||
|
- /tmp/c1-round3-red.log and /tmp/c1-round3-red-summary.txt
|
||||||
|
- /tmp/c1-round3-green.log and /tmp/c1-round3-green-summary.txt
|
||||||
|
- /tmp/c1-round3-green-comparator.log
|
||||||
|
- /tmp/c1-round3-checkout-control.log and /tmp/c1-round3-checkout-summary.txt
|
||||||
|
- /tmp/c1-round3-repro-n5.tsv and /tmp/c1-round3-repro-{3,4,5}.log
|
||||||
|
- /tmp/c1-round3-final-r7.log and /tmp/c1-round3-final-r7-summary.txt
|
||||||
|
|
||||||
|
BASELINES / INDEPENDENT REVIEW
|
||||||
|
- bash -n tools/e2e-install-test.sh: PASS
|
||||||
|
- shellcheck tools/e2e-install-test.sh: PASS
|
||||||
|
- invalid remote source commit control: rc=2, named 40-hex requirement
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS, 45/45 tasks
|
||||||
|
- pnpm lint: PASS, 25/25 tasks
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
- Codex code review: APPROVE, confidence 0.92, 6 files, zero findings
|
||||||
|
- Initial Codex security review: HIGH/CWE-494 on independent provider-compromise provenance; retained and bounded explicitly in PRD/report as the canonical v2 §3 deferral
|
||||||
|
- Codex security re-review after trust-boundary documentation: risk NONE, confidence 0.96, 6 files, zero findings
|
||||||
|
|
||||||
|
PUSH / CI
|
||||||
|
Not yet recorded in this artifact. No CI polling is authorised after the single push.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#1050 C1 ROUND 4 — PIPELINE CASE-COVERAGE DENOMINATOR
|
||||||
|
|
||||||
|
IDENTITY / SCOPE
|
||||||
|
- predecessor head: 0e2eef1c1261444b83c089df6047a2a06592d5c6
|
||||||
|
- branch: feat/1050-install-state-machine-red-fixture
|
||||||
|
- only open requirement: pipeline-level expected-case/executed-case set equality
|
||||||
|
- unchanged/fenced: tools/fixtures/greenfield-expected-red.tsv, tools/verify-greenfield-expected-red.sh, #869, P6 detector/producer, absolute-P3 CLI linking, and per-case fixture dispositions
|
||||||
|
- status language: believed-fixed, pending jarvis validation
|
||||||
|
|
||||||
|
STALE-RULING WITHDRAWAL HONOURED
|
||||||
|
Requirements 1-3 were withdrawn after pipeline 2242 at 0e2eef1c reached terminal green and showed P6 failure propagation, absolute-P3 CLI use, and an executed git-absent arm. A temporary local P6-consumer edit made while the stale ruling was in flight was restored before this change set; tools/e2e-install-test.sh has no final-tree delta. Requirement 5 alone remains in this round.
|
||||||
|
|
||||||
|
RED-FIRST
|
||||||
|
Before the change, .woodpecker/greenfield-install.yml had no pipeline-level initialized marker store, no per-case success marker, and no final exact-set gate. A static assertion for a final run-on-failure case-coverage step failed. Pipeline 2242's green result proved that all three cases ran once, but no gate required that population on a later run.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- tools/verify-greenfield-case-coverage.sh is a new pipeline-level instrument; the existing per-case verifier is unchanged.
|
||||||
|
- Expected case names are derived from the first TSV field of tools/fixtures/greenfield-expected-red.tsv and sorted uniquely. There is no literal expected count.
|
||||||
|
- Marker storage is scoped by ${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}. The init action validates the manifest and empties only that run directory, so stale markers cannot satisfy a retry or later run.
|
||||||
|
- Every case/contract step explicitly depends on initialization. Each unconditional case step calls mark only after its exact tools/verify-greenfield-expected-red.sh invocation succeeds.
|
||||||
|
- The final greenfield-case-denominator step explicitly depends on all four case/contract steps, has status eligibility [success, failure], emits cases_defined=N cases_executed=M, and compares sorted expected and actual case-name sets. Missing or unexpected names fail even when the two counts are equal.
|
||||||
|
- A newly defined manifest case changes the expected set automatically and fails until a corresponding successful case step marks it.
|
||||||
|
|
||||||
|
MEASURED CONTROLS
|
||||||
|
1. Skipped expected arm:
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=2
|
||||||
|
[fixture-suite] missing_case=next-git-absent
|
||||||
|
2. Count-inflation control (one expected missing, one unexpected added):
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
[fixture-suite] missing_case=next-git-absent
|
||||||
|
[fixture-suite] unexpected_case=unexpected-case
|
||||||
|
3. Stale-marker initialization control (same run re-initialized after markers existed):
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=0
|
||||||
|
missing_case rows emitted for all three expected names
|
||||||
|
4. Exact-set positive control:
|
||||||
|
rc=0
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
5. Future manifest case control:
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=4 cases_executed=3
|
||||||
|
[fixture-suite] missing_case=future-case
|
||||||
|
|
||||||
|
WORKFLOW / FOCUSED VALIDATION
|
||||||
|
- bash -n new subject and test: PASS
|
||||||
|
- shellcheck new subject and test: PASS
|
||||||
|
- bash tools/verify-greenfield-case-coverage.test.sh: PASS
|
||||||
|
- test statically proves each mark follows its exact per-case verifier, every producer depends on initialization, the final gate depends on the complete matrix, and final status eligibility includes success+failure
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- Prettier check for workflow/package/PRD: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
CURRENT-HEAD FIXTURE MEASUREMENTS TAKEN BEFORE THE STALE RULING WAS WITHDRAWN
|
||||||
|
These measurements were run from 0e2eef1c before requirement 5 changed only pipeline instrumentation. They are retained as measured outcomes, not as justification for modifying requirements 1-3.
|
||||||
|
- next checkout/git-present: fixture rc=1; resolved @next=0.0.50-next.2207; P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8/P9 FAIL; installer_exit=1; done_claims=0; P6 reason was dead enforcement hooks active count=2.
|
||||||
|
- next checkout/git-absent: fixture rc=1; P0/P6/P7 PASS; P1/P2/P3/P4/P5/P8/P9 FAIL; installer_exit=1; done_claims=0.
|
||||||
|
- /home free before the first run: 8.5G; after concurrent authorized fleet reclaim completed: 11G; no be-coder-05 generated payload was deleted because active fixture work made that churn.
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW CYCLE
|
||||||
|
- Initial Codex code review: REQUEST_CHANGES, confidence 0.96; one blocker found that the first draft omitted depends_on ordering and could race initialization/final checking.
|
||||||
|
- Disposition: accepted. Added explicit init -> all case/contract steps -> final dependency graph and a regression assertion for every dependency edge.
|
||||||
|
- Initial Codex security review: risk NONE, confidence 0.94, zero findings. The sandbox could not create its own temp directory; local focused/full tests provide the dynamic evidence.
|
||||||
|
- First post-dependency code re-review: REQUEST_CHANGES, confidence 0.99; the case extractor treated the production manifest's comment lines as case names, while the synthetic test omitted comments.
|
||||||
|
- Disposition: accepted. Case derivation now excludes blank/comment lines; the focused test includes representative comments and executes init/mark/check against the repository's real manifest.
|
||||||
|
- Post-dependency security re-review: risk NONE, confidence 0.96, zero findings.
|
||||||
|
- Final Codex code re-review after the comment fix: APPROVE, confidence 0.94, 8 files, zero findings. It confirmed ordering, run scope, fail-closed set equality, PRD alignment, and negative controls.
|
||||||
|
- Final Codex security re-review: risk NONE, confidence 0.96, 8 files, zero findings.
|
||||||
|
|
||||||
|
FINAL LOCAL BASELINES
|
||||||
|
- pnpm test:installer: PASS, including the new coverage suite
|
||||||
|
- pnpm typecheck: PASS, 45/45
|
||||||
|
- pnpm lint: PASS, 25/25
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- bash -n + ShellCheck for new shell surfaces: PASS
|
||||||
|
- Woodpecker strict lint: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
- /home free before/after local baselines: 11G
|
||||||
|
|
||||||
|
PENDING BEFORE PUSH
|
||||||
|
- commit, queue guard, one push, provider attribution read-back
|
||||||
|
- announce the new full SHA to rev-security-02; do not merge or close
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#1050 C1 ROUND 5 — ARM COVERAGE + CHECKOUT PURITY
|
||||||
|
|
||||||
|
IDENTITY / SCOPE
|
||||||
|
- predecessor head: f33bd0da96bee3ce518744d6888d018bebb1c841
|
||||||
|
- branch: feat/1050-install-state-machine-red-fixture
|
||||||
|
- review inputs: be-coder-06 review 110; rev-security-02 review 111
|
||||||
|
- in scope: exact required pipeline-arm set including greenfield-remote-installer-contract; success-only arm marks; skipped-remote RED control; coverage-state exclusion from checkout source archives
|
||||||
|
- unchanged/fenced: tools/fixtures/greenfield-expected-red.tsv, tools/verify-greenfield-expected-red.sh, tools/e2e-install-test.sh, #869, and all per-case dispositions
|
||||||
|
- status: believed-fixed, pending jarvis validation
|
||||||
|
|
||||||
|
RED-FIRST — FOUR ARMS WERE REPRESENTED BY THREE CASES
|
||||||
|
The f33bd0d gate represented only the three expected-RED case identities. With all three case names marked and no remote-arm identity available, the production checker returned:
|
||||||
|
SKIPPED_REMOTE_ARM_CURRENT_GATE_RC=0
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
The fourth required arm, greenfield-remote-installer-contract, reused next-git-present and therefore could skip without changing case-set equality.
|
||||||
|
|
||||||
|
RED-FIRST — COVERAGE STATE CONTAMINATED CHECKOUT ARCHIVES
|
||||||
|
The f33bd0d workflow created .greenfield-case-state in the shared repository before the checkout-mode fixture arms. An independent reproduction using the exact e2e-install-test.sh tar exclusions created .greenfield-case-state/remote-arm.ran and measured:
|
||||||
|
OLD_STATE_ARCHIVE_MATCHES=1
|
||||||
|
The state instrument was therefore part of the checkout source payload it was intended only to observe.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- Renamed the checker to tools/verify-greenfield-execution-coverage.sh and generalized it over two validated dimensions: cases and arms.
|
||||||
|
- Added tools/fixtures/greenfield-expected-arms.txt as the explicit required arm set:
|
||||||
|
greenfield-git-present
|
||||||
|
greenfield-main-git-present
|
||||||
|
greenfield-remote-installer-contract
|
||||||
|
greenfield-git-absent
|
||||||
|
- Expected cases remain derived from the unchanged expected-RED TSV. Expected arms are derived from the new one-name-per-line arm declaration. Both inputs allow comments/blanks and validate every resulting name.
|
||||||
|
- Each of the four fixture arms writes its unique arm marker only after its exact per-case verifier succeeds. The three distinct expected-RED cases continue to write case markers after successful verification.
|
||||||
|
- The final step checks both dimensions even when the first check fails, emits both denominators, and fails unless both exact name sets match.
|
||||||
|
- Coverage state moved to .mosaic-test-work/greenfield-execution-coverage/${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}/{cases,arms}. The checkout archive already excludes every */.mosaic-test-work subtree.
|
||||||
|
- The expected-RED manifest, per-case verifier, and detector are untouched.
|
||||||
|
|
||||||
|
MEASURED SKIPPED-REMOTE CONTROL
|
||||||
|
With all three cases marked and only the three non-remote arms marked:
|
||||||
|
case gate rc=0
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
arm gate rc=1
|
||||||
|
[fixture-suite] arms_defined=4 arms_executed=3
|
||||||
|
[fixture-suite] missing_arm=greenfield-remote-installer-contract
|
||||||
|
This is the discriminating control: the case denominator remains green while the arm denominator catches the exact previously invisible skip.
|
||||||
|
|
||||||
|
OTHER DYNAMIC CONTROLS
|
||||||
|
- missing case: cases 3/2 => rc1
|
||||||
|
- case count inflation (one missing + one unexpected): cases 3/3 => rc1
|
||||||
|
- stale case markers after re-init: cases 3/0 => rc1
|
||||||
|
- exact case set: cases 3/3 => rc0
|
||||||
|
- future manifest case: cases 4/3 => rc1
|
||||||
|
- arm count inflation (remote missing + unexpected): arms 4/4 => rc1
|
||||||
|
- exact arm set: arms 4/4 => rc0
|
||||||
|
- production expected-RED manifest comments/blanks: accepted; exact set 3/3 => rc0
|
||||||
|
- archive selector firing control: a non-excluded root marker is present in the archive
|
||||||
|
- archive purity control: no .mosaic-test-work path is present in the archive
|
||||||
|
|
||||||
|
STATIC / STRUCTURAL CONTROLS
|
||||||
|
- every case mark follows that arm's successful per-case verifier
|
||||||
|
- every arm mark, including the remote contract, follows that arm's successful per-case verifier
|
||||||
|
- all four arms depend on coverage initialization
|
||||||
|
- final denominator depends on all four arms and is eligible after success or failure
|
||||||
|
- final denominator runs both checks and aggregates their statuses
|
||||||
|
- workflow contains the already-excluded .mosaic-test-work state root and no .greenfield-case-state reference
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- bash -n + ShellCheck on generalized checker/test: PASS
|
||||||
|
- pnpm test:installer: PASS, including execution-coverage controls
|
||||||
|
- pnpm typecheck: PASS, 45/45
|
||||||
|
- pnpm lint: PASS, 25/25
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
- /home free before/after: 11G
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.93, zero findings. It confirmed exact arm coverage, success-only marking, dual-status aggregation, excluded state, checkout-purity controls, and PRD alignment.
|
||||||
|
- Codex security review: risk NONE, confidence 0.96, zero findings. CI/path identifiers are constrained, expansions quoted, expected sets treated as data, and checks fail closed.
|
||||||
|
- Review sandboxes could not execute their own dynamic suite/ShellCheck because their filesystem is read-only; the local measured baselines above provide that evidence.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one push, attribution read-back
|
||||||
|
- announce the new full SHA; no merge or closure
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
# #1050 C1 fix round — Round 6 caller coupling and production archive binding
|
||||||
|
|
||||||
|
BASE HEAD
|
||||||
|
- df705828a439c6795cd47c938ed7f838b956ebf3
|
||||||
|
- Reviews 114 (rev-security-02) and 115 (be-coder-06) independently reproduced the same caller-level mutant: ignoring arms_status in the workflow left the focused helper suite green.
|
||||||
|
- Both reviewers confirmed the production aggregation was correct and requested test reachability, not a logic rewrite.
|
||||||
|
- rev-974 separately deleted only the production e2e-install-test.sh .mosaic-test-work tar exclusion; the copied-selector suite remained green.
|
||||||
|
|
||||||
|
RED
|
||||||
|
- After adding the four-cell caller truth-table expectations but before creating the workflow helper:
|
||||||
|
bash tools/verify-greenfield-execution-coverage.test.sh => rc=1
|
||||||
|
The expected helper did not exist, so the test could not reach a passing aggregation implementation.
|
||||||
|
- Prior independent controls:
|
||||||
|
ignore-arms workflow mutant => rc=0 before this round (reviews 114/115)
|
||||||
|
delete-production-archive-exclusion mutant => rc=0 before this round (rev-974 exact-head sandbox)
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- Extracted the existing set +e / two checks / status capture / final OR into tools/verify-greenfield-execution-coverage-gate.sh without changing its semantics.
|
||||||
|
- .woodpecker/greenfield-install.yml now invokes that helper directly; its inline duplicate was removed.
|
||||||
|
- The focused suite invokes the same helper used by the workflow across all four cells:
|
||||||
|
cases PASS / arms FAIL => RED
|
||||||
|
cases FAIL / arms PASS => RED
|
||||||
|
both FAIL => both diagnostic outputs and RED
|
||||||
|
both PASS => GREEN
|
||||||
|
- The archive-purity test now first locates the production checkout-archive command in tools/e2e-install-test.sh and requires its .mosaic-test-work exclusion, then proves the exclusion's archive effect.
|
||||||
|
- GREENFIELD_COVERAGE_GATE and GREENFIELD_E2E_INSTALLER are test-only subject injection seams used to prove the requested mutants; production paths remain the defaults.
|
||||||
|
|
||||||
|
MUTATION CONTROLS
|
||||||
|
- ignore-arms aggregation helper => focused suite rc=1
|
||||||
|
- ignore-cases aggregation helper => focused suite rc=1
|
||||||
|
- always-RED aggregation helper => focused suite rc=1
|
||||||
|
- delete only production .mosaic-test-work archive exclusion => focused suite rc=1
|
||||||
|
- restored production subjects => greenfield execution coverage tests passed
|
||||||
|
- final mutant workspace: /tmp/c1-round6-final-mutants.u0ZaL6
|
||||||
|
- logs: /tmp/c1-round6-final-gate-ignore-arms.log, /tmp/c1-round6-final-gate-ignore-cases.log, /tmp/c1-round6-final-gate-always-red.log, /tmp/c1-round6-final-archive-mutant.log
|
||||||
|
|
||||||
|
FENCED FILES
|
||||||
|
- tools/e2e-install-test.sh HEAD/worktree SHA-256: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98 (byte-identical)
|
||||||
|
- tools/fixtures/greenfield-expected-red.tsv HEAD/worktree SHA-256: baae06c664d0ab7ffe4c32ec106bfcb6f9dd74b46461d3c4440b73dc582af1a2 (byte-identical)
|
||||||
|
- No per-case disposition or #869 wiring changed.
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- /home free before build: 11G
|
||||||
|
- bash -n focused scripts: PASS
|
||||||
|
- ShellCheck focused scripts: PASS
|
||||||
|
- bash tools/verify-greenfield-execution-coverage.test.sh: PASS
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS (45/45)
|
||||||
|
- pnpm lint: PASS (25/25)
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.94, seven files reviewed, zero findings. It confirmed the workflow invokes the exact four-cell-tested helper, both diagnostics remain observable, and archive purity binds to the production selector.
|
||||||
|
- Codex security review: risk NONE, confidence 0.97, seven files reviewed, zero findings. It confirmed fail-closed behavior, quoted inputs, repository-controlled production arguments, and no new secret/injection/access-control exposure.
|
||||||
|
- The code-review sandbox could not execute the dynamic suite because its filesystem is read-only; the local measured baselines and mutation controls above provide dynamic evidence.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one lease-pinned push, provider attribution read-back
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# #1050 C1 fix round — Round 7 canonical Alpine/BusyBox portability
|
||||||
|
|
||||||
|
BASE HEAD
|
||||||
|
- d66e91b1f22489b911eef982aec3faa62c5edeb3
|
||||||
|
- Review 119 (rev-974) found tools/verify-greenfield-execution-coverage.sh used GNU-only find -printf.
|
||||||
|
- Pipeline 2246's canonical Alpine ci-base test failed while the Debian greenfield workflow passed; the stale pipeline is diagnostic evidence only and is not inherited as a ruling for the replacement head.
|
||||||
|
|
||||||
|
RED
|
||||||
|
- Before remediation:
|
||||||
|
docker run --rm -v "$PWD:/workspace" -w /workspace git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash tools/verify-greenfield-execution-coverage.test.sh
|
||||||
|
=> canonical_alpine_red_rc=1
|
||||||
|
- Independent review 119 captured BusyBox 1.37 rejecting `find: unrecognized: -printf`.
|
||||||
|
- Debian/GNU execution remained green, proving that one runtime's success did not establish portability.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- Removed the GNU find -printf inventory.
|
||||||
|
- Marker inventory now uses Bash nullglob/dotglob plus parameter expansion, all available in the script's declared Bash runtime.
|
||||||
|
- The checker verifies the run directory is readable/searchable before inventory and fails closed otherwise.
|
||||||
|
- Only direct regular, non-symlink `*.ran` files are included, preserving the prior find boundary; identities remain sorted and compared as exact sets.
|
||||||
|
- No production fixture disposition, expected-RED manifest, e2e installer, #869 wiring, or acquisition behavior changed.
|
||||||
|
|
||||||
|
GREEN
|
||||||
|
- Local focused suite: greenfield execution coverage tests passed.
|
||||||
|
- Canonical Alpine/BusyBox focused suite:
|
||||||
|
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash tools/verify-greenfield-execution-coverage.test.sh
|
||||||
|
=> greenfield execution coverage tests passed.
|
||||||
|
- Existing Round-6 controls retained:
|
||||||
|
ignore-arms aggregation helper => rc=1
|
||||||
|
ignore-cases aggregation helper => rc=1
|
||||||
|
always-RED aggregation helper => rc=1
|
||||||
|
delete production .mosaic-test-work archive exclusion => rc=1
|
||||||
|
- Mutant workspace: /tmp/c1-round7-mutants.LocpBH
|
||||||
|
|
||||||
|
FENCED FILES
|
||||||
|
- tools/e2e-install-test.sh HEAD/worktree SHA-256: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98 (byte-identical)
|
||||||
|
- tools/fixtures/greenfield-expected-red.tsv HEAD/worktree SHA-256: baae06c664d0ab7ffe4c32ec106bfcb6f9dd74b46461d3c4440b73dc582af1a2 (byte-identical)
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- /home free before and after validation: 11G
|
||||||
|
- Bash syntax focused scripts: PASS
|
||||||
|
- ShellCheck focused scripts: PASS
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS (45/45)
|
||||||
|
- pnpm lint: PASS (25/25)
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.96, zero findings. It confirmed hidden/empty/direct-regular/non-symlink semantics, exact sorting/comparison, dual-runtime execution, and PRD alignment.
|
||||||
|
- Codex security review: risk NONE, confidence 0.97, zero findings. It confirmed quoted paths, fail-closed exact-set behavior, and no new injection, traversal, authorization, secret, cryptographic, dependency, or data-integrity risk.
|
||||||
|
- The security-review sandbox could not execute the focused suite because its filesystem is read-only; the local and canonical-container measured runs above provide dynamic evidence.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one lease-pinned push, provider attribution read-back
|
||||||
|
- replacement-head canonical CI; no manual pipeline trigger or polling
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# #1050 C1 fix round — Round 8 pipefail-safe expected-set membership
|
||||||
|
|
||||||
|
BASE HEAD
|
||||||
|
- 58ada98d2b430da28c46cf92eebdb6d45b8b1846
|
||||||
|
- Review 122 (rev-security-02) found expected_names | grep -Fxq could falsely reject valid markers under pipefail when grep closed the pipe after an early match and the producer exited 141.
|
||||||
|
- Independent canonical-Alpine live-manifest stress reproduced intermittent false failures at the current four-arm set size. No small-manifest safe threshold is claimed.
|
||||||
|
|
||||||
|
DETERMINISTIC RED
|
||||||
|
- The focused suite now generates a 20,001-name manifest with a valid target sorted first and invokes the real mark path.
|
||||||
|
- Before remediation, in canonical Alpine ci-base:
|
||||||
|
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash tools/verify-greenfield-execution-coverage.test.sh
|
||||||
|
=> canonical_sigpipe_tdd_red_rc=1
|
||||||
|
=> [fixture-suite] case is not in the expected set: a-target
|
||||||
|
- This control exercises the production checker invocation rather than an approximated producer and makes the early-close race deterministic by exceeding pipe capacity.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- The mark path now fully materializes expected_names into expected_snapshot and fails closed if production fails.
|
||||||
|
- grep -Fxq reads the completed snapshot through a here-string; there is no producer/consumer pipeline to close early.
|
||||||
|
- Missing names still fail with the same attributable message. No `|| true` or other failure suppression was introduced.
|
||||||
|
- Exact-set checks, marker inventory, aggregation, archive binding, fixture dispositions, expected-RED manifest, e2e installer, #869 wiring, and acquisition behavior are unchanged.
|
||||||
|
|
||||||
|
GREEN
|
||||||
|
- Local focused suite: greenfield execution coverage tests passed.
|
||||||
|
- Canonical Alpine focused suite: greenfield execution coverage tests passed.
|
||||||
|
- Canonical Alpine live production-manifest stress: 1,000 valid greenfield-git-absent arm marks, failures=0.
|
||||||
|
- Canonical grep-q pipeline mutant through the real checker path => rc=1 with the attributable false rejection; restored subject => GREEN.
|
||||||
|
- Existing Round-6 controls retained:
|
||||||
|
ignore-arms aggregation helper => rc=1
|
||||||
|
ignore-cases aggregation helper => rc=1
|
||||||
|
always-RED aggregation helper => rc=1
|
||||||
|
delete production .mosaic-test-work archive exclusion => rc=1
|
||||||
|
- Round-8 mutant workspace: /tmp/c1-round8-mutants.aS1xTI
|
||||||
|
|
||||||
|
FENCED FILES
|
||||||
|
- tools/e2e-install-test.sh HEAD/worktree SHA-256: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98 (byte-identical)
|
||||||
|
- tools/fixtures/greenfield-expected-red.tsv HEAD/worktree SHA-256: baae06c664d0ab7ffe4c32ec106bfcb6f9dd74b46461d3c4440b73dc582af1a2 (byte-identical)
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- /home free before and after validation: 11G
|
||||||
|
- Bash syntax focused scripts: PASS
|
||||||
|
- ShellCheck focused scripts: PASS
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS (45/45)
|
||||||
|
- pnpm lint: PASS (25/25)
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.96, six files reviewed, zero findings. It confirmed full materialization removes the SIGPIPE race and the deterministic regression control exercises the real early-match mark path.
|
||||||
|
- Codex security review: risk NONE, confidence 0.98, six files reviewed, zero findings. It confirmed fail-closed producer handling, constrained/quoted names, and no new injection, traversal, secret, access-control, cryptographic, dependency, or logging risk.
|
||||||
|
- The code-review sandbox could not execute the suite or install ShellCheck because its filesystem is read-only; local and canonical-container dynamic evidence above supplies those gates.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one lease-pinned push, provider attribution read-back
|
||||||
|
- replacement-head canonical CI; no manual trigger or polling
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #1050 C1 fix-round verification
|
||||||
|
|
||||||
|
Frozen reviewed head before remediation: `378bc1afe3bc485adb8614897d66c5edccd4a527`.
|
||||||
|
|
||||||
|
Status: **believed-fixed, pending jarvis validation**. PR #1054 is not self-merged and issue #1050 remains open.
|
||||||
|
|
||||||
|
## Blocker B — fail-closed test enumeration
|
||||||
|
|
||||||
|
The RED-first control used a real filesystem permission failure, not binary shadowing or PATH interception.
|
||||||
|
|
||||||
|
1. A planted `{"status":"in-progress"}` file in a readable P2 fault tree made the complete real walk fail the frozen suite at `P2 left an in-progress transaction` (`01-pre-fix-positive-control.log`, exit 1).
|
||||||
|
2. The same planted defect beneath a target-owned mode-0100 directory made real `find` report a permission failure. The frozen suite erased the producer failure and exited 0 with `installer next lane tests passed` (`02-pre-fix-permission-failure-attack.log`).
|
||||||
|
3. The committed regression control initially failed because the child full-suite attack still exited 0 (`03-regression-test-red.log`).
|
||||||
|
4. After remediation, the same child full-suite input exits 1 and names `[test] ERROR: P2 fault-state enumeration failed` (`04-post-fix-permission-failure-attack.log`). The ordinary full suite remains green.
|
||||||
|
|
||||||
|
`tools/test-enumeration-assertions.sh` now captures each complete NUL-delimited population and checks the producer status before asserting absence. Content checks inspect the captured population and distinguish “no match” from a read error. The shared fail-closed implementation covers:
|
||||||
|
|
||||||
|
- `tools/install-next-lane.test.sh`: redacted staging-file cleanup and fault-state transaction scan;
|
||||||
|
- `tools/verified-installer-fetch.test.sh`: temporary-download cleanup;
|
||||||
|
- `tools/install-state-machine.test.sh`: symlink-target non-mutation;
|
||||||
|
- `docs/reports/verification/1050-b8-redaction-control/positive-control.test.sh`: both copied counterparts.
|
||||||
|
|
||||||
|
No assertion was loosened. A1, A2, upgrade-guard, source-root, the species-2 sweep, #869, and expected-RED verdict rows remain outside this remediation.
|
||||||
|
|
||||||
|
## Blocker A — installer digest
|
||||||
|
|
||||||
|
The stale sidecar value was replaced with the exact `sha256sum` record for `tools/install.sh`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
e59cb441a2f37ae9150f8eae470238e9d858a1816df93343d9784a6796676096 install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
RED-first documentation control from the repository root: `sha256sum -c tools/install.sh.sha256` exits 1 with `install.sh: FAILED open or read` because the sidecar records a path relative to `tools/`. The command actually executed, `(cd tools && sha256sum -c install.sh.sha256)`, exits 0 with `install.sh: OK`; the workflow's separate exact expected/actual equality also passes. The immutable provider-fetch arm at the new `${CI_COMMIT_SHA}` is recorded in the freeze artifact after push; local equality alone is not treated as sufficient evidence.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Documentation completion checklist — #1050 C1 fix round
|
||||||
|
|
||||||
|
## Required artifacts
|
||||||
|
|
||||||
|
- [x] `docs/PRD.md` exists; #1050 C1 requirement 13 binds the complete in-scope remote stack source chain, and requirement 15 binds exact case+arm execution coverage, executable aggregation, production-bound archive purity, dual-runtime portability, and pipefail-safe materialized membership.
|
||||||
|
- [x] User guide: not applicable; no user-facing production installer behavior changed.
|
||||||
|
- [x] Admin guide: not applicable; no operator procedure or deployment behavior changed.
|
||||||
|
- [x] Developer guide: existing `docs/guides/installer-state-machine.md` defines the fail-closed installer model; the C1-specific remote-arm contract and evidence live in the PRD, scratchpad, and scoped verification report.
|
||||||
|
- [x] OpenAPI and endpoint index: not applicable; no API changed.
|
||||||
|
- [x] Sitemap: not applicable; no navigation changed.
|
||||||
|
|
||||||
|
## API and structural coverage
|
||||||
|
|
||||||
|
- [x] API schema/auth/error coverage: not applicable; no endpoint changed.
|
||||||
|
- [x] Guide book indexes: not applicable; no guide page was added or moved.
|
||||||
|
- [x] Root hygiene preserved; all new artifacts are under `docs/reports/verification/1050-c1-fix-round/` and the active scratchpad remains under `docs/scratchpads/`.
|
||||||
|
|
||||||
|
## Review and publishing
|
||||||
|
|
||||||
|
- [x] Verification documentation is in the same logical change set as the shell-test remediation.
|
||||||
|
- [x] Trust boundary states both halves: the digest proves fetched bytes remain identical through execution/verify-after; it does not authenticate authorship against provider/TLS compromise. Independent provenance is the inherited PRD v2 §3 deferral.
|
||||||
|
- [x] Round-3 Codex code review APPROVE (confidence 0.92, zero findings) and security re-review risk NONE (confidence 0.96, zero findings). The initial HIGH trust-root finding remains documented as the canonical signed-provenance deferral, not hidden.
|
||||||
|
- [x] Round-4 review cycle closed: two blocking Codex findings (missing dependency ordering; production manifest comments misparsed) were accepted and fixed. Final code review APPROVE (confidence 0.94, zero findings) and security risk NONE (confidence 0.96, zero findings).
|
||||||
|
- [x] Round-5 exact arm coverage and archive-purity controls reviewed: Codex code APPROVE (confidence 0.93, zero findings) and security risk NONE (confidence 0.96, zero findings).
|
||||||
|
- [x] Round-6 executable aggregation and production archive-selector binding reviewed: Codex code APPROVE (confidence 0.94, zero findings) and security risk NONE (confidence 0.97, zero findings).
|
||||||
|
- [x] Round-7 BusyBox-safe marker inventory and dual-runtime evidence reviewed: Codex code APPROVE (confidence 0.96, zero findings) and security risk NONE (confidence 0.97, zero findings).
|
||||||
|
- [x] Round-8 materialized membership and deterministic canonical-Alpine control reviewed: Codex code APPROVE (confidence 0.96, zero findings) and security risk NONE (confidence 0.98, zero findings).
|
||||||
|
- [x] Canonical evidence remains in-repo. No external publishing action was requested or performed.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# #1050 successor remediation verification
|
||||||
|
|
||||||
|
Head under test before remediation: `e5d5c8495a070af2dcd393cace287fe74a8a819e`.
|
||||||
|
|
||||||
|
This change strengthens the expected-RED detector; it does not repair the intentionally failing greenfield rows. The #869 hooks remain unwired.
|
||||||
|
|
||||||
|
## A1 — P0 reason binding
|
||||||
|
|
||||||
|
RED first:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ bash tools/verify-greenfield-expected-red.test.sh
|
||||||
|
[test] FAIL: vacuous P0 PASS satisfied the expected-RED contract without identity/context evidence
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The pinned manifest now has an explicit `phase-reason` binding against the final P0 row: target `mosaic`, uid `1001`, equal `HOME` and passwd HOME, `/bin/bash`, `privilege=user`, `x86_64`, glibc, and version-shaped Node/npm evidence. All three cases structurally require exactly one P0 reason binding. The greenfield fixture's final P0 row now emits and validates the same complete identity/context evidence, so an unrelated earlier P0 line cannot satisfy the binding for a vacuous final row.
|
||||||
|
|
||||||
|
Pipeline 2224 and the successor's pre-change fixture run also exposed a stale next-lane P6 reason left behind by the already-closed B6 remediation: actual behavior is a fail-closed runtime-link action refusal with `#869` hooks left inactive and a persisted required P6 failure, while the manifest still expected dead hooks to be active. The pinned reason now matches the stronger measured refusal (`runtime linking/activation action reported a required failure`); no verdict changed and #869 remains unwired.
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[test] PASS: P0 PASS must bind target identity, HOME, shell, privilege, architecture, and runtime reason
|
||||||
|
```
|
||||||
|
|
||||||
|
## A2 — fail-closed P4 enumeration
|
||||||
|
|
||||||
|
RED first: a `find` control emitted only the safe root, omitted an unsafe mode-`0666` child, and exited `73`. The process-substitution consumer discarded that status:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[test] FAIL: P4 accepted a partial created-path inventory after find failed
|
||||||
|
[test] FAIL: P4 did not report failed created-path enumeration
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
P4 now captures the NUL-delimited walk into a temporary file, checks `find` to completion, and only then evaluates the complete inventory. A failed walk reports that enumeration failed and returns a P4 finding.
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[test] PASS: P4 rejects an incomplete created-path inventory
|
||||||
|
[test] PASS: P4 attributes the failed created-path enumeration
|
||||||
|
```
|
||||||
|
|
||||||
|
## B — deterministic TERM no-exit control
|
||||||
|
|
||||||
|
Woodpecker pipeline 2224 at the original head reported `32 passed, 2 failed`: the no-exit fixture did not exit zero or report sync success. The premise was not stale: the same fixture passed `34/34` on another filesystem.
|
||||||
|
|
||||||
|
A controlled reverse-sorted `find -print0` walk reproduced the pipeline result exactly (`32 passed, 2 failed`). Root cause: signal injection was tied to `guides/E2E-DELIVERY.md`; whether required `tools/` content remained after restore depended on filesystem enumeration order. The test was measuring path order as well as trap semantics.
|
||||||
|
|
||||||
|
The generated fixtures now damage a real target path after the snapshot is armed, self-signal immediately before the complete normal sync, and differ only in the explicit handler exit. Therefore a no-exit handler always restores, returns, runs the full sync, mutates the restored target again, and reports completion independent of walk order.
|
||||||
|
|
||||||
|
GREEN on both native and reverse-sorted enumeration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RESULT: 36 passed, 0 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutation sensitivity: restoring `exit 1` to the nominal no-exit fixture makes the control RED (`32 passed, 4 failed`), including failures of the zero-exit and resumed-success assertions. The control can still fail for its stated reason.
|
||||||
|
|
||||||
|
## Enumeration-class sweep
|
||||||
|
|
||||||
|
The sweep covered production enumeration in `tools/install.sh` and `packages/mosaic/framework/install.sh`, plus process-substitution consumers in the C1 shell-test surfaces. Framework installer file, operator, durable-snapshot, and pruning walks already capture and check their producer status. P4's created-path walk was the reviewed unchecked instance.
|
||||||
|
|
||||||
|
One additional order/completeness dependency was found in source acquisition: `find "$WORK_DIR" ... | head -1` hid `find` failure and selected arbitrarily when an archive produced multiple top-level directories. RED first, the extraction fake produced two roots and the lane test stopped at that new assertion with exit 1 because today's code selected one. Source acquisition now captures and checks the complete NUL-delimited walk and requires exactly one extracted root. The lane suite is green with the multiple-root rejection. No remaining production installer enumeration uses unchecked process substitution or first-row order as authority.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# RM-61 — CI contract exemption for #1000 teardown artifact
|
||||||
|
|
||||||
|
**Tracking:** RM-61 / issue #1000
|
||||||
|
|
||||||
|
**Branch:** `fix/rm-61-ci-contract-exemption`
|
||||||
|
**Owner:** `coder-mos1`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
|
||||||
|
|
||||||
|
## Pre-registered kill criterion
|
||||||
|
|
||||||
|
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Capture full `-f json` records for the 11 supplied observations and state counts.
|
||||||
|
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
|
||||||
|
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
|
||||||
|
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
|
||||||
|
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
|
||||||
|
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
|
||||||
|
|
||||||
|
## Initial evidence
|
||||||
|
|
||||||
|
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Requirements and kill criterion recorded before control implementation.
|
||||||
|
- [x] Historical full-JSON records captured.
|
||||||
|
- [x] Startup-failure control observed terminal.
|
||||||
|
- [x] Post-readiness crash control observed terminal.
|
||||||
|
- [x] Discrimination verdict recorded: Option B may proceed.
|
||||||
|
- [x] Conditional exemption implementation.
|
||||||
|
|
||||||
|
## Tests / evidence
|
||||||
|
|
||||||
|
### Control 1 — real startup failure
|
||||||
|
|
||||||
|
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
|
||||||
|
- Pipeline: #2189, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
|
||||||
|
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
|
||||||
|
- Pipeline/workflow: terminal `failure`.
|
||||||
|
|
||||||
|
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
|
||||||
|
|
||||||
|
### Control 2 — real post-readiness crash
|
||||||
|
|
||||||
|
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
|
||||||
|
- Pipeline: #2191, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
|
||||||
|
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
|
||||||
|
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
|
||||||
|
- `test`: `state=failure`, `exit_code=61`.
|
||||||
|
- Pipeline/workflow: terminal `failure`.
|
||||||
|
|
||||||
|
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
|
||||||
|
|
||||||
|
### Discrimination verdict
|
||||||
|
|
||||||
|
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
|
||||||
|
|
||||||
|
### Unit red-first checkpoint
|
||||||
|
|
||||||
|
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
|
||||||
|
|
||||||
|
### Control 2 setup attempt — invalid, excluded from evidence
|
||||||
|
|
||||||
|
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
|
||||||
|
- Pipeline: #2190, exact commit match.
|
||||||
|
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||||
|
- Service log: `/bin/sh: 0: -c requires an argument`.
|
||||||
|
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
|
||||||
|
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
|
||||||
|
|
||||||
|
## Implementation evidence
|
||||||
|
|
||||||
|
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
|
||||||
|
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
|
||||||
|
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
|
||||||
|
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
|
||||||
|
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
|
||||||
|
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
|
||||||
|
- Python compile: PASS.
|
||||||
|
- `pnpm typecheck`: PASS, 45/45 tasks.
|
||||||
|
- `pnpm lint`: PASS, 25/25 tasks.
|
||||||
|
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
|
||||||
|
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
|
||||||
|
|
||||||
|
## Independent review
|
||||||
|
|
||||||
|
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
|
||||||
|
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
|
||||||
|
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
|
||||||
|
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
|
||||||
|
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
|
||||||
|
|
||||||
|
## Documentation checklist
|
||||||
|
|
||||||
|
- [x] CI contract documented in the canonical framework CI/CD guide.
|
||||||
|
- [x] Operator command documented in the Woodpecker tool README.
|
||||||
|
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
|
||||||
|
- [x] Tracking and retirement cite issue #1000.
|
||||||
|
- [x] Both positive and negative guarantee boundaries are stated.
|
||||||
|
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# #1019 — Zero-timeout queue-guard harness race
|
||||||
|
|
||||||
|
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
|
||||||
|
- **Branch:** `fix/1019-ci-queue-timeout-harness`
|
||||||
|
- **Owner:** `be-coder-08`
|
||||||
|
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
|
||||||
|
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
|
||||||
|
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. RED deterministically reproduces deadline pre-emption before the provider call.
|
||||||
|
2. Every case that intends status classification positively proves provider observation.
|
||||||
|
3. Pending observes `pending` before deterministic virtual-time expiration.
|
||||||
|
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
|
||||||
|
5. The exact CI-base image passes the final harness repeatedly with zero failures.
|
||||||
|
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
|
||||||
|
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
|
||||||
|
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
|
||||||
|
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
|
||||||
|
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
|
||||||
|
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
|
||||||
|
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
|
||||||
|
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log` — **100 pass / 0 fail / 100 total**.
|
||||||
|
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
|
||||||
|
|
||||||
|
## Review remediation — semantic timeout vs. liveness bound
|
||||||
|
|
||||||
|
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
|
||||||
|
|
||||||
|
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
|
||||||
|
|
||||||
|
Remediation:
|
||||||
|
|
||||||
|
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
|
||||||
|
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
|
||||||
|
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
|
||||||
|
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
|
||||||
|
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
|
||||||
|
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
|
||||||
|
|
||||||
|
Post-review evidence:
|
||||||
|
|
||||||
|
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
|
||||||
|
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
|
||||||
|
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
|
||||||
|
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
|
||||||
|
|
||||||
|
## 60% context hold
|
||||||
|
|
||||||
|
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# #1050 — Installer P0–P9 state machine and red-first fixture
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS.
|
||||||
|
|
||||||
|
## Authority and scope
|
||||||
|
|
||||||
|
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies.
|
||||||
|
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
||||||
|
- Historical implementation base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. Delivery PR #1054 targets `main` under L0's trunk-only rule; `next` remains a non-merging integration lane.
|
||||||
|
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
||||||
|
- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer.
|
||||||
|
2. Commit the immutable red-first acceptance fixture before implementation.
|
||||||
|
3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms.
|
||||||
|
4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user.
|
||||||
|
5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- Working estimate: 32K reasoning/output tokens.
|
||||||
|
- Hard external cap: none stated.
|
||||||
|
- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it.
|
||||||
|
|
||||||
|
## Pre-registered acceptance checks
|
||||||
|
|
||||||
|
| ID | Exact case | Expected pre-fix result |
|
||||||
|
|---|---|---|
|
||||||
|
| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons |
|
||||||
|
| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts |
|
||||||
|
| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates |
|
||||||
|
| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state |
|
||||||
|
| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero |
|
||||||
|
| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version |
|
||||||
|
| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 |
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction.
|
||||||
|
- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering.
|
||||||
|
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
||||||
|
- [x] Issue #1050 created and provider author read back.
|
||||||
|
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
||||||
|
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
||||||
|
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
||||||
|
- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS.
|
||||||
|
- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery.
|
||||||
|
- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match.
|
||||||
|
- [ ] Reviews complete. Reviews 80 (`rev-security-02`) and 81 (`rev-974`) requested changes at `3934e03f`; their eight non-overlapping detector findings are being remediated red-first. Current remediation adds canonical-image portability, absolute P3 CLI propagation, exact expected-RED schema/cardinality, passwd-HOME binding, created-path owner/mode policy, real-action P2–P8 fault injection, verified non-empty remote installer execution, and seeded secret-canary/redacted diagnostics. Both old verdicts become void when the remediation head moves and require fresh independent review.
|
||||||
|
- [x] Successor remediation for review 90 is RED-first and recorded in `docs/reports/verification/1050-successor-remediation/`: the manifest now binds the complete supported final P0 reason; P4 rejects an incomplete created-path walk instead of discarding `find` failure; and the TERM no-exit control is independent of filesystem enumeration order while retaining a proven RED mutation. Pipeline 2224's 32/2 result was a path-order-sensitive control, not evidence that the resume bug's premise became stale. The enumeration-class sweep additionally replaced order-dependent `find | head -1` source-root selection with a checked complete inventory requiring exactly one extracted root.
|
||||||
|
- [x] C1 fix round for reviews 92/93: Blocker B was completed first. RED was reproduced before implementation: a planted `{"status":"in-progress"}` record makes the complete real walk fail the suite at P2, while the same planted defect beneath a target-owned mode-0100 directory makes real `find` fail and the frozen suite falsely exit 0 with `installer next lane tests passed`. One shared helper now captures and checks the complete NUL-delimited population before testing absence across the primary and copied harness sites; grep no-match is distinct from read failure. The same attack child drives the full suite RED with a named enumeration error, while ordinary native and root/container runs pass. Blocker A then regenerated the exact installer sidecar; provider-fetch validation remains the required post-push bar. Working estimate: 16K tokens; no external hard cap; 60% context is the stop/report gate.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
||||||
|
- `next` is a non-merging integration lane; PR #1054 targets `main`. The old “pending promotion to main” caution dissolved when the base moved. #1050 remains open after merge and closes only after Jarvis validates the greenfield behavior.
|
||||||
|
- #869 must remain staged and inactive.
|
||||||
|
- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5.
|
||||||
|
|
||||||
|
## Remediation review controls
|
||||||
|
|
||||||
|
- B1 RED: the next-lane harness failed immediately under `ci-base:latest` as root/musl; it now models uid 1001/glibc explicitly and uses Python tree fingerprints instead of GNU `find -printf`.
|
||||||
|
- B2 RED: framework/runtime linking consumed bare `mosaic` from PATH after P3 had committed an absolute path. The unified installer now exports/passes `MOSAIC_CLI_PATH`; the linker invokes that absolute artifact, and wizard auto-launch has no stale-PATH fallback.
|
||||||
|
- B3 RED: a one-row manifest (`exit=1`) certified any exit-1 log. Full-manifest validation now requires the exact three cases, one exit and P0–P9 row each, pinned require/forbid populations, and rejects malformed/duplicate/unknown rows; shrink is a negative control.
|
||||||
|
- B4 RED: uid 1001 with a passwd HOME different from ambient HOME produced P0 PASS. P0 now binds uid, username, passwd HOME and shell and explicitly rejects root and sudo-with-inherited-HOME controls.
|
||||||
|
- B5 RED: mode-0777 CLI, mode-0644 identity, and mode-0755 credential storage passed. P3/P4/P5 now apply target owner/group plus executable/shared/private policies; framework credential storage is created 0700.
|
||||||
|
- B6 RED: fault injection only wrote `.selftest-*` files. The synthetic path was removed; the P2–P8 matrix enters the normal action flow, proves an action observation occurred, injects after each real phase, and fingerprints rollback.
|
||||||
|
- B7 RED: an HTTP-200 empty body exits zero when piped to Bash. The fetched installer must now be non-empty, digest-equal to `tools/install.sh.sha256`, and that exact file is executed; failed/empty/mismatch controls are blocking and CI has a remote immutable-commit arm.
|
||||||
|
- B8 RED: raw combined command output was duplicated to terminal and `commands.log`. Both capture layers now redact before output/persistence; a seeded canary is positively emitted by the fake credential-capable registry and must remain absent from terminal, command log, npmrc, generated files and observed argv. The real greenfield fixture also scans those populations.
|
||||||
|
- Advisory code review findings are fixed: URL userinfo redaction now handles raw `@`, token-only and percent-encoded forms, repeated `:`, multiple URLs, Authorization/Basic, npm `_auth`, and Cookie headers in both capture layers; the real greenfield path positively emits its canary through `state_run_captured`; verified-fetch removes its temporary body after successful execution; and plaintext diagnostics exist only in process-substitution pipes rather than interruptible temporary files.
|
||||||
|
- Advisory security review's independent trust-root finding is **DEFERRED by canonical PRD v2 §3**, which explicitly excludes signed provenance. README now states precisely that the same-origin sidecar detects empty/corrupt/inconsistent publication but cannot authenticate against repository/server compromise; no stronger claim remains.
|
||||||
|
- The web1 no-manifest representativeness observation is recorded but intentionally not acted on: it is explicitly outside these eight blockers. This remediation does not weaken or otherwise change P9's manifest-presence assertion.
|
||||||
|
|
||||||
|
## Verification log
|
||||||
|
|
||||||
|
- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes.
|
||||||
|
- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, passwd-HOME/privilege discrimination, owner/group/mode attacks, persisted P4/P6 action failures, no synthetic fault implementation, unsafe/overlapping/symlink roots, and fatal journal initialization.
|
||||||
|
- `bash tools/install-next-lane.test.sh` passes inside `ci-base:latest`, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, prerelease suffix mismatch, absolute P3 CLI propagation, secret redaction, real-action P2–P8 rollback, and stale projection recovery.
|
||||||
|
- Comparator controls pass for verdict drift, unexpected exit, manifest shrink, missing phases, duplicate rows, unknown cases, and unknown kinds. Verified-fetch controls pass for successful execution and failed/empty/digest-mismatch rejection.
|
||||||
|
- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift.
|
||||||
|
- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full exact-remediation rerun is required before push.
|
||||||
|
- Review-93 RED evidence at frozen `378bc1a`: isolated positive-control full-suite exit `1` with `P2 left an in-progress transaction`; isolated permission-failure attack full-suite exit `0` with final `installer next lane tests passed`. No binary shadowing or PATH interception was used; the failure came from a real target-owned mode-0100 directory.
|
||||||
|
- Fix-round GREEN: `pnpm test:installer`, native next-lane, root/`ci-base:latest` next-lane, state-machine, verified-fetch, Bash syntax, ShellCheck, `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `git diff --check` pass. The direct attack child exits 1 and names `P2 fault-state enumeration failed`. All 81 Mosaic Vitest files / 1508 tests pass under an isolated npm prefix; the wider framework-shell chain reaches the pre-existing #973 Bash-line-number gate and exits 97, matching the known host-specific condition rather than this delta. Codex code review approved at 0.93 confidence with zero findings; Codex security review reported no risk at 0.96 confidence with zero findings.
|
||||||
|
- Sidecar RED-first documentation control from the repository root: `sha256sum -c tools/install.sh.sha256` exits 1 with `install.sh: FAILED open or read` because the sidecar path is relative to `tools/`. Sidecar GREEN with the command actually executed: `(cd tools && sha256sum -c install.sh.sha256)` exits 0 with `install.sh: OK`; the workflow's separate expected/actual comparison resolves `e59cb441a2f37ae9150f8eae470238e9d858a1816df93343d9784a6796676096`. This is not substituted for the required immutable provider-fetch arm at the pushed head.
|
||||||
|
|
||||||
|
## Round 3 — remote-arm downstream source binding
|
||||||
|
|
||||||
|
### Objective and constraints
|
||||||
|
|
||||||
|
- Tracking remains `mosaicstack/stack#1050`, delivery PR #1054, branch `feat/1050-install-state-machine-red-fixture`; `docs/TASKS.md` remains orchestrator-single-writer.
|
||||||
|
- Bind the remote arm's downstream stack framework/source archive to the same immutable `${CI_COMMIT_SHA}` as the digest-verified `install.sh`, while retaining `--next` and its exact resolved CLI/gateway lane-version assertion.
|
||||||
|
- RED first: the realised source commit/digest assertion must reject the existing stale `origin/next` substitution; GREEN must restore the exact expected `P6 FAIL` and pass the comparator. R7 then deletes the binding and requires RED again.
|
||||||
|
- Fenced out: the expected-RED manifest (including `P6=FAIL`), #869 activation, and #1068 digest-before-comparator sequencing.
|
||||||
|
- Push budget: one force-with-lease push pinned to provider head `ff3f0d29f1763bed44a60610d073036112e66b77`; run the queue guard first; do not poll CI after push.
|
||||||
|
- Working estimate: 14K reasoning/output tokens; no external hard token cap. Scope reduction order: reuse the existing local-source archive seam, add one realised-state assertion, avoid installer behavior changes.
|
||||||
|
|
||||||
|
### Corrected reference citation
|
||||||
|
|
||||||
|
The transferable reference is only **the container-image acquisition path in `fa-install.sh`**: resolve mutable input once to `Docker-Content-Digest`, fail closed if unresolved, render `@sha256`, then verify running images against the resolved digest. It is not a claim about USC's installer as a whole; the sibling root bundle extraction in `Install-FieldAgentOnPanel.ps1:82` is unpinned. The applicable structure is `RESOLVE-ONCE -> PIN -> FAIL-CLOSED ON RESOLUTION -> VERIFY-AFTER`, applied per acquisition path.
|
||||||
|
|
||||||
|
### Remote-arm acquisition-path census before the fix
|
||||||
|
|
||||||
|
Code-path enumeration only; execution of the complete list is **NOT MEASURED** in this round yet. Logical payload paths are counted once even when one command is invoked repeatedly or installs a dependency graph.
|
||||||
|
|
||||||
|
| # | Acquisition path taken by the arm | Pre-fix binding state | Scope disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Woodpecker checkout containing the workflow, fixture, sidecar, and comparator | CI commit checkout (commit-bound by runner contract) | existing arm substrate |
|
||||||
|
| 2 | `node:22-bookworm-slim` step image | mutable image tag | unpinned sibling; outside this fix fence |
|
||||||
|
| 3 | Debian package set acquired by `apt-get update/install` | repository-current, no package versions/snapshot | unpinned sibling; outside this fix fence |
|
||||||
|
| 4 | remote `tools/install.sh` | exact `${CI_COMMIT_SHA}` URL plus checked SHA-256 | in scope; already bound first hop |
|
||||||
|
| 5 | `@mosaicstack/mosaic@next` CLI package graph | mutable lane resolved to an exact top-level version, exact version installed, realised top-level version verified | lane assertion; must remain |
|
||||||
|
| 6 | `@mosaicstack/gateway@next` package graph | later re-resolution wins and is verified against itself; drift from the first value can be silently accepted | partially constrained TOCTOU sibling; outside this fix |
|
||||||
|
| 7 | `mosaicstack/stack` framework/source archive consumed by `ensure_monorepo` | `next` resolves once to `4df478cdd150fdf8d52ea109f02ade5d85017acd`, not the arm's CI commit | **in-scope defect; bind to CI commit** |
|
||||||
|
| 8 | sequential-thinking MCP package warmed via unversioned `npx -y @modelcontextprotocol/server-sequential-thinking` | mutable package resolution | unpinned sibling; outside this fix fence |
|
||||||
|
| 9 | Excalidraw npm dependency graph | shipped `package-lock.json` exact versions + registry integrity hashes; `npm install` consumes the lock | already lock/integrity-bound, separate from CI-source identity |
|
||||||
|
| 10 | canonical skills repo cloned by `mosaic-sync-skills` | mutable default branch of `mosaic/agent-skills.git` | unpinned sibling; outside this fix fence |
|
||||||
|
|
||||||
|
Pre-fix mechanism breakdown (do not blend unlike strengths): **2 / 10 intended-CI-commit-bound** (#1 checkout, #4 installer); **1 / 10 resolve-then-pin with comparison to the authoritative value** (#5 CLI); **1 / 10 partially constrained because verify-after compares #6 gateway to its later re-resolution rather than an authoritative pin**; **1 / 10 lockfile+integrity-bound** (#9 Excalidraw); **1 / 10 internally pinned to an immutable commit+digest but bound to the wrong arm identity** (#7 framework at `4df478cd`); **4 / 10 unpinned siblings** (#2, #3, #8, #10). The in-scope same-CI stack payload ratio is **1 / 2** (`install.sh` yes; framework/source no).
|
||||||
|
|
||||||
|
Resolution-failure behavior is separately stated rather than hidden in the counts. For #5/#6, a metadata-resolution failure during P1 blocks before mutation. Later package metadata/install failure enters the documented source fallback, but that fallback consumes the immutable stack archive already resolved in P2; failure to resolve or fetch that source is fatal. For #5, the P2 CLI value is authoritative and a later realised mismatch blocks P3 and rolls back. For #6, no authoritative P2 gateway value persists; a later same-suffix gateway drift can be silently accepted because the later value is compared with itself. Changing that TOCTOU path is outside this fix. The new #7 path accepts the 40-hex CI commit directly, fetches only its exact commit URL, rejects failed/empty acquisition, computes and passes the exact body's digest through the existing local-source seam, and rejects any realised manifest commit/digest mismatch without falling back to `next`.
|
||||||
|
|
||||||
|
Denominator corrections were sent to and accepted by the lane lead before implementation. Four expressly unpinned siblings make `7/10` impossible. Post-fix, the primary mechanism breakdown is **fully constrained 5 / 10** (#1, #4, #5, #7, #9), **partially constrained 1 / 10** (#6), and **unpinned 4 / 10** (#2, #3, #8, #10). The meaningful repaired ratio is **2 / 2 same-CI stack payload hops**. The fix does not increase coverage; it moves #7 from wrong identity to intended identity. Every census revision moved assurance downward under additional questioning, never upward, so the census is a lower bound on defects and an upper bound on assurance.
|
||||||
|
|
||||||
|
### Implementation plan
|
||||||
|
|
||||||
|
1. Add the realised source commit+archive-digest assertion and pass the expected immutable identity through the remote fixture; run the current stale-`next` mechanism and capture RED before changing acquisition.
|
||||||
|
2. Fetch the stack archive from the exact commit URL once, fail closed on failed/empty acquisition, compute its digest, pass that exact body through the installer's existing local-source archive seam, and verify the manifest's realised `sourceCommit` and `sourceSha256` against those values.
|
||||||
|
3. Run exact-source GREEN: require `P6 FAIL` values and a passing `next-git-present` comparator; run focused installer/baseline gates.
|
||||||
|
4. R7: delete the binding in a temporary mutant, run the same assertion to RED, then restore and re-run GREEN.
|
||||||
|
5. Independent code/security review, commit with command-scoped identity, queue guard, one force-with-lease push pinned to `ff3f0d29`; stop without CI polling and report `believed-fixed, pending jarvis validation`.
|
||||||
|
|
||||||
|
### Executed outcome
|
||||||
|
|
||||||
|
- RED-first stale substitution: fixture `rc=1`; realised source `4df478cdd150fdf8d52ea109f02ade5d85017acd` / `3e6d831efe13c3b2c0501507099d4a566af5abf877dacf85e5d7e4284d35e5c7` rejected against expected `ff3f0d29f1763bed44a60610d073036112e66b77` / `944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25`; comparator `rc=1`, 8/19 mismatches.
|
||||||
|
- Exact-source reproducibility, N=5 identical final-tree executions: every run had fixture `rc=1`, `@mosaicstack/mosaic@next=0.0.50-next.2207`, P3 PASS, realised source `ff3f0d29…` / `944c6db1…`, SOURCE-CONTROL PASS, `installer_exit=1`, `done_claims=0`, P6 FAIL, and P9 FAIL. Comparator `rc=0` occurred 4/5 (19/19 checks; action-failure reason); comparator `rc=1` occurred 1/5 (18/19; dead hooks active count `2`). The rate is the finding; no comparator verdict is claimed.
|
||||||
|
- Timings/load: runs 1–2 NOT MEASURED; run 3 rc1 elapsed 912s, load 7.79/7.51/8.93 -> 12.28/14.82/11.79; run 4 rc0 elapsed 938s, load 12.28/14.82/11.79 -> 9.84/15.18/13.83; run 5 rc0 elapsed 954s, load 9.84/15.18/13.83 -> 3.94/4.76/8.18. Load does not explain this sample monotonically.
|
||||||
|
- The `P6=FAIL` row remains untouched; P6 failed 5/5 while its reason signal varied. No comparator widening was performed. Checkout-source control independently exhibited the rc1/dead-hooks outcome, but its reproducibility was not separately measured.
|
||||||
|
- #869 out-of-scope finding: current `defaultSupervisorProbe` checks bundled supervisor artifact presence and a resolvable socket path, not socket existence. The 2000ms capability-probe timeout is a code-read hypothesis for variance, NOT MEASURED as causal.
|
||||||
|
- Final-tree R7 deleted the three local-source binding exports temporarily. The realised source reverted to `4df478cd…` / `3e6d831…`, SOURCE-CONTROL failed against `ff3f0d29…` / `944c6db…`, and comparator `rc=1` with 8/19 mismatches. The subject file SHA-256 was `a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98` before mutation and after restoration.
|
||||||
|
- Trust boundary: the exact-commit URL trusts the configured repository provider's authenticated commit-to-archive mapping. The computed digest pins transfer/consumption but does not authenticate against repository/TLS compromise; signed provenance remains the canonical PRD v2 §3 deferral. Initial Codex security review retained this as HIGH/CWE-494; no stronger claim or out-of-scope signing change was made.
|
||||||
|
- Final baselines: Bash syntax, ShellCheck, `pnpm test:installer`, `pnpm typecheck` (45/45), `pnpm lint` (25/25), `pnpm format:check`, and `git diff --check` pass. Codex code review APPROVE confidence 0.92 with zero findings; after explicit trust-boundary documentation, security re-review risk NONE confidence 0.96 with zero findings. The initial HIGH trust-root finding remains recorded as the signed-provenance deferral.
|
||||||
|
- Full evidence and named paths: `docs/reports/verification/1050-c1-fix-round/09-round3-source-binding.txt`.
|
||||||
|
|
||||||
|
## Round 4 — pipeline case-coverage denominator
|
||||||
|
|
||||||
|
- Pipeline 2242 at `0e2eef1c` superseded the lane lead's earlier pipeline-2229 ruling: requirements 1–3 were already satisfied. The temporary local P6-consumer edit started while that ruling was in flight was restored; `tools/e2e-install-test.sh`, the expected-RED manifest, the per-case verifier, and #869 remain unchanged in the final tree.
|
||||||
|
- Requirement 5 adds a pipeline-level instrument above the three per-case invocations. Expected names are derived from the manifest, markers are scoped by pipeline+workflow run, and each marker is written only after that exact per-case verifier succeeds. Every producer depends on initialization; the final step depends on the complete case/contract matrix, runs after success or failure, and requires exact expected/actual set equality rather than count equality.
|
||||||
|
- Measured firing controls: skipped arm `3/2` rc1; count inflation `3/3` rc1 with one missing and one unexpected name; stale re-initialized run `3/0` rc1; exact set `3/3` rc0; future manifest case `4/3` rc1. Codex review found and blocked two independent defects: first the missing `depends_on` graph, then comment lines being parsed as case names. Both were accepted; dependency edges are regression-asserted, blank/comments are excluded, and the focused test now consumes the production manifest directly. Final Codex code re-review APPROVE confidence 0.94 and security re-review risk NONE confidence 0.96, both with zero findings. Woodpecker strict lint, Bash syntax, ShellCheck, focused/full installer tests, typecheck, lint, Prettier, and diff check pass.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/10-round4-case-coverage.txt`.
|
||||||
|
|
||||||
|
## Round 5 — arm coverage and checkout purity
|
||||||
|
|
||||||
|
- Reviews 110/111 identified two blockers at `f33bd0da`: the three-case set could not represent the fourth `greenfield-remote-installer-contract` arm, and the root `.greenfield-case-state` directory was included in checkout fixture archives. The expected-RED manifest, per-case verifier, detector, and #869 remain fenced.
|
||||||
|
- RED controls: with all three cases complete and no remote-arm identity, the old gate returned rc0 at `cases_defined=3 cases_executed=3`; the exact fixture tar selector archived `.greenfield-case-state/remote-arm.ran` once.
|
||||||
|
- The checker is generalized across exact `cases` and `arms` dimensions. A new explicit four-arm declaration includes the remote contract; all four arms mark only after successful per-case verification. With the remote omitted, cases remain 3/3 rc0 while arms report 4/3 rc1 and name `greenfield-remote-installer-contract` as missing.
|
||||||
|
- State moved beneath `.mosaic-test-work/greenfield-execution-coverage`, which the existing checkout selector excludes. The regression control proves a non-excluded root marker is archived while no `.mosaic-test-work` path is archived.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.93 and security risk NONE confidence 0.96, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, and diff check pass with 11G free before/after.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/11-round5-arm-coverage-and-purity.txt`.
|
||||||
|
|
||||||
|
## Round 6 — executable aggregation and production archive binding
|
||||||
|
|
||||||
|
- Reviews 114/115 independently reproduced the same caller-level mutant at `df705828`: ignoring `arms_status` in the workflow still left the helper-bounded focused suite green. The production OR was correct and was not rewritten; its aggregation moved byte-for-byte into `verify-greenfield-execution-coverage-gate.sh`, which the workflow invokes directly.
|
||||||
|
- The focused suite now exercises that exact helper across the complete truth table: cases PASS/arms FAIL RED; cases FAIL/arms PASS RED; both FAIL emits both outputs and is RED; both PASS GREEN. Local ignore-arms, ignore-cases, and always-RED mutants each make the suite RED.
|
||||||
|
- Review `rev-974` separately proved that deleting only the production `e2e-install-test.sh` `.mosaic-test-work` tar exclusion left the copied-selector control green. The control now structurally binds its semantic archive test to the production checkout-archive command; the delete-production-binding mutant is RED. The fenced production installer remains byte-unchanged.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.94 and security risk NONE confidence 0.97, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, focused truth-table and four mutant controls, and diff check pass with 11G free before validation.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/12-round6-caller-coupling-and-archive-binding.txt`.
|
||||||
|
|
||||||
|
## Round 7 — canonical Alpine/BusyBox portability
|
||||||
|
|
||||||
|
- Review 119 found the exact-head focused suite RED in the canonical Alpine `ci-base`: GNU `find -printf` is unsupported by BusyBox 1.37. The same script remained green in the Debian greenfield workflow, so greenfield success alone did not prove canonical runtime compatibility.
|
||||||
|
- Local canonical-image RED was reproduced at rc1 before remediation. Marker inventory now uses Bash nullglob/dotglob and parameter expansion, checks state-directory readability, preserves the prior regular-file/non-symlink boundary, and retains sorted exact-set comparison without GNU-only flags.
|
||||||
|
- The focused suite is green both locally and inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`. Canonical pipeline evidence remains pending the replacement frozen-head push; no stale pipeline is inherited.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.96 and security risk NONE confidence 0.97, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, dual-runtime focused tests, retained Round-6 mutants, and diff check pass with 11G free.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/13-round7-busybox-portability.txt`.
|
||||||
|
|
||||||
|
## Round 8 — pipefail-safe expected-set membership
|
||||||
|
|
||||||
|
- Review 122 found `expected_names | grep -Fxq` could falsely reject a valid early-sorted marker: `grep -q` closes after its match, the upstream sorter can exit 141, and `pipefail` selects that producer failure. Independent canonical-Alpine stress measured failures at the live four-arm manifest size, so no safe small-set threshold is claimed.
|
||||||
|
- A deterministic 20,001-entry real-script mark control makes the unfixed canonical-Alpine path RED with `case is not in the expected set: a-target`. The mark path now materializes and validates the complete expected-name snapshot before applying `grep -q` via a here-string, eliminating the producer/consumer pipe while retaining fail-closed producer errors.
|
||||||
|
- The focused suite is green locally and in canonical Alpine after remediation. A separate 1,000-mark canonical-Alpine stress against the live four-arm production manifest recorded zero false failures.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.96 and security risk NONE confidence 0.98, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, canonical deterministic/stress controls, retained Round-6 mutants, and diff check pass with 11G free.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/14-round8-pipefail-membership.txt`.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# RM-01 — Reproducible checkout
|
||||||
|
|
||||||
|
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
|
||||||
|
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
|
||||||
|
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
|
||||||
|
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
|
||||||
|
- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum.
|
||||||
|
- Plan:
|
||||||
|
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
|
||||||
|
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
|
||||||
|
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
|
||||||
|
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
|
||||||
|
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
|
||||||
|
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
|
||||||
|
|
||||||
|
## Progress / evidence
|
||||||
|
|
||||||
|
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
|
||||||
|
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
|
||||||
|
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
|
||||||
|
|
||||||
|
## Checkpoint evidence (c45e5e19)
|
||||||
|
|
||||||
|
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
|
||||||
|
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
|
||||||
|
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
|
||||||
|
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
|
||||||
|
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
|
||||||
|
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
|
||||||
|
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
|
||||||
|
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
|
||||||
|
|
||||||
|
## Continuation evidence
|
||||||
|
|
||||||
|
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
|
||||||
|
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
|
||||||
|
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
|
||||||
|
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
|
||||||
|
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
|
||||||
|
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||||
|
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||||
|
|
||||||
|
## Review remediation — restated AC2
|
||||||
|
|
||||||
|
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||||
|
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||||
|
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||||
|
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||||
|
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
|
||||||
|
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||||
|
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
|
||||||
|
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
|
||||||
|
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
|
||||||
|
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# RM-03 — CI Queue Guard Repair
|
||||||
|
|
||||||
|
- **Task:** RM-03
|
||||||
|
- **Issue:** #1019
|
||||||
|
- **Branch:** `fix/rm-03-queue-guard`
|
||||||
|
- **Owner:** coder-mos1
|
||||||
|
- **Reviewer:** rev-974 (independent; author != reviewer)
|
||||||
|
- **Started:** 2026-08-01
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
||||||
|
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
||||||
|
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
||||||
|
- No bypass flags or hook suppression.
|
||||||
|
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
||||||
|
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
||||||
|
- No merge: coordinator holds the merge hand pending Jason.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
||||||
|
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
||||||
|
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
||||||
|
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
||||||
|
|
||||||
|
## Test matrix
|
||||||
|
|
||||||
|
| Case | Required outcome |
|
||||||
|
| --- | --- |
|
||||||
|
| success | exit 0; terminal-success |
|
||||||
|
| pending | nonzero after bounded timeout |
|
||||||
|
| failure | nonzero |
|
||||||
|
| no-status | nonzero |
|
||||||
|
| malformed | nonzero |
|
||||||
|
| >=150 KiB payload | unchanged classification; never rc126 |
|
||||||
|
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
||||||
|
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
||||||
|
| audit unavailable | nonzero |
|
||||||
|
| implicit push branch | provider URL uses checked-out feature branch |
|
||||||
|
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
||||||
|
|
||||||
|
## RED-first evidence
|
||||||
|
|
||||||
|
Observed against the unmodified `origin/main` implementation before source edits:
|
||||||
|
|
||||||
|
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
||||||
|
- Success payload was reported `state=unknown`.
|
||||||
|
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
||||||
|
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
||||||
|
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
||||||
|
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
||||||
|
- Audit-unavailable emitted no audit diagnostic.
|
||||||
|
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
||||||
|
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
||||||
|
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
||||||
|
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
||||||
|
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
||||||
|
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
||||||
|
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
||||||
|
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
||||||
|
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
||||||
|
|
||||||
|
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
||||||
|
- [x] Isolated worktree created and identity configured coherently.
|
||||||
|
- [x] Mutant tests authored and observed red.
|
||||||
|
- [x] Implementation green.
|
||||||
|
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
||||||
|
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
||||||
|
- [ ] PR CI terminal-green at exact head by full step scan.
|
||||||
|
- [ ] Merge-gate verdict issued against frozen head.
|
||||||
|
|
||||||
|
## Scope disposition
|
||||||
|
|
||||||
|
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
||||||
|
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
||||||
|
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
||||||
|
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
||||||
|
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
||||||
|
|
||||||
|
## Review remediation
|
||||||
|
|
||||||
|
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
||||||
|
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
||||||
|
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
||||||
|
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
||||||
|
|
||||||
|
## Risks / boundaries
|
||||||
|
|
||||||
|
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
||||||
|
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
||||||
|
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
||||||
|
|
||||||
|
## Test evidence
|
||||||
|
|
||||||
|
Fresh after rescue checkpoint `b7175012`:
|
||||||
|
|
||||||
|
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
||||||
|
- `bash -n` on the three production shell scripts passed.
|
||||||
|
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
||||||
|
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
||||||
|
- `pnpm lint` passed (25/25 Turbo tasks).
|
||||||
|
- `pnpm format:check` passed.
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
||||||
|
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
||||||
|
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
||||||
|
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
||||||
|
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
||||||
|
|
||||||
|
## Final evidence
|
||||||
|
|
||||||
|
Pending.
|
||||||
+7
-3
@@ -6,11 +6,15 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"preflight": "node scripts/preflight.mjs",
|
||||||
"test": "turbo run test",
|
"clean:generated": "node scripts/clean-generated.mjs",
|
||||||
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
|
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||||
|
"test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh && bash tools/verify-greenfield-execution-coverage.test.sh && bash tools/verified-installer-fetch.test.sh",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "husky"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ It is a **gate** role: the one and only merge path.
|
|||||||
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
||||||
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
||||||
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
||||||
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
CI before merging). Before issuing a verdict, scan the full JSON/API child-step
|
||||||
|
record (including `clone`) with **`verify-terminal-green.py --expect-commit
|
||||||
|
<current-provider-PR-head>`** and record the equal expected/observed full-40
|
||||||
|
commits, exact step count, anomalies, and named exemptions. Missing or mismatched
|
||||||
|
commit binding is a hard refusal. The verifier's sole interim
|
||||||
|
exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked
|
||||||
|
by #1000, and retires when #1000 is fixed. These scripts are the _only_
|
||||||
|
sanctioned merge path.
|
||||||
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
||||||
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||||
and `pr-ci-wait.sh`.
|
and `pr-ci-wait.sh`.
|
||||||
|
|||||||
@@ -868,6 +868,38 @@ steps:
|
|||||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
||||||
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||||
|
|
||||||
|
## Terminal-Green Full-Step Contract
|
||||||
|
|
||||||
|
A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR_HEAD=<full-40-hex-provider-head>
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
|
||||||
|
-r mosaicstack/stack -n <pipeline-number> -f json \
|
||||||
|
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \
|
||||||
|
--expect-commit "$PR_HEAD" -
|
||||||
|
```
|
||||||
|
|
||||||
|
`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record.
|
||||||
|
|
||||||
|
The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified.
|
||||||
|
|
||||||
|
### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN`
|
||||||
|
|
||||||
|
Only this exact conjunction is exempted:
|
||||||
|
|
||||||
|
- pipeline and workflow state are `success`;
|
||||||
|
- exactly one non-success child exists;
|
||||||
|
- its name is `ci-postgres` and type is `service`;
|
||||||
|
- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and
|
||||||
|
- its error exactly matches `pods "wp-svc-<ULID>-ci-postgres" not found`.
|
||||||
|
|
||||||
|
Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow.
|
||||||
|
|
||||||
|
**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red.
|
||||||
|
|
||||||
|
**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract.
|
||||||
|
|
||||||
## Post-Merge CI Monitoring (Hard Rule)
|
## Post-Merge CI Monitoring (Hard Rule)
|
||||||
|
|
||||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||||
@@ -893,14 +925,16 @@ Woodpecker note:
|
|||||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||||
|
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||||
|
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||||
|
|
||||||
## Gitea as Unified Platform
|
## Gitea as Unified Platform
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
- PR target for delivery is `main`.
|
- PR target for delivery is `main`.
|
||||||
- Direct pushes to `main` are prohibited.
|
- Direct pushes to `main` are prohibited.
|
||||||
- Merge to `main` MUST be squash-only.
|
- Merge to `main` MUST be squash-only.
|
||||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
|||||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. `push` - push immediately after queue guard passes.
|
9. `push` - push immediately after queue guard passes.
|
||||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
|||||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||||
> without asking.
|
> without asking.
|
||||||
|
|
||||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||||
|
|||||||
@@ -425,11 +425,11 @@ git push
|
|||||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||||
- Before merging, run queue guard:
|
- Before merging, run queue guard:
|
||||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||||
- Merge via wrapper:
|
- Merge via wrapper:
|
||||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||||
- Wait for terminal CI status:
|
- Wait for terminal CI status:
|
||||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- Close linked issue after merge + green CI:
|
- Close linked issue after merge + green CI:
|
||||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
|||||||
|
|
||||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||||
|
|
||||||
## Git Scripts
|
## Git Scripts
|
||||||
|
|
||||||
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
|||||||
|
|
||||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||||
|
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
|||||||
# Milestones
|
# Milestones
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
~/.config/mosaic/tools/git/milestone-create.sh
|
||||||
|
|
||||||
# CI queue guard (required before push/merge)
|
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||||
|
|
||||||
### Code Review (Codex)
|
### Code Review (Codex)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ done
|
|||||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||||
# shellcheck source=tools/_lib/manifest.sh
|
# shellcheck source=tools/_lib/manifest.sh
|
||||||
|
# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e.
|
||||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||||
|
|
||||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||||
@@ -222,12 +223,14 @@ prune_durable_snapshots() {
|
|||||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||||
list="$(mktemp)"
|
list="$(mktemp)"
|
||||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||||
|
warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot."
|
||||||
rm -f "$list"; return 0
|
rm -f "$list"; return 0
|
||||||
fi
|
fi
|
||||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||||
# than risk pruning in an undefined order.
|
# than risk pruning in an undefined order.
|
||||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||||
|
warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order."
|
||||||
rm -f "$list"; return 0
|
rm -f "$list"; return 0
|
||||||
fi
|
fi
|
||||||
while IFS= read -r d; do
|
while IFS= read -r d; do
|
||||||
@@ -266,7 +269,11 @@ make_durable_snapshot() {
|
|||||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
chmod 700 "$root" 2>/dev/null || true
|
if ! chmod 700 "$root"; then
|
||||||
|
umask "$old_umask"
|
||||||
|
warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
dir="$root/pre-update-$ts"
|
dir="$root/pre-update-$ts"
|
||||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||||
@@ -281,7 +288,10 @@ make_durable_snapshot() {
|
|||||||
if ! enumerate_operator_files "$list"; then
|
if ! enumerate_operator_files "$list"; then
|
||||||
umask "$old_umask"
|
umask "$old_umask"
|
||||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
rm -f "$list"
|
||||||
|
if ! rmdir "$dir"; then
|
||||||
|
warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively."
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
while IFS= read -r -d '' rel; do
|
while IFS= read -r -d '' rel; do
|
||||||
@@ -292,12 +302,18 @@ make_durable_snapshot() {
|
|||||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
chmod 600 "$dst" 2>/dev/null || true
|
if ! chmod 600 "$dst"; then
|
||||||
|
rm -f "$dst"
|
||||||
|
warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
count=$((count + 1))
|
count=$((count + 1))
|
||||||
done < "$list"
|
done < "$list"
|
||||||
rm -f "$list"
|
rm -f "$list"
|
||||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
# Tighten every dir the copy created (mkdir -p already honored umask 077).
|
||||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
if ! find "$dir" -type d -exec chmod 700 {} +; then
|
||||||
|
warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic."
|
||||||
|
fi
|
||||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||||
DURABLE_SNAPSHOT_DIR="$dir"
|
DURABLE_SNAPSHOT_DIR="$dir"
|
||||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||||
@@ -344,7 +360,9 @@ verify_operator_surface() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if cp "$snap" "$cur"; then
|
if cp "$snap" "$cur"; then
|
||||||
chmod 600 "$cur" 2>/dev/null || true
|
if ! chmod 600 "$cur"; then
|
||||||
|
warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair."
|
||||||
|
fi
|
||||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||||
healed=$((healed + 1))
|
healed=$((healed + 1))
|
||||||
else
|
else
|
||||||
@@ -535,7 +553,7 @@ sync_framework_keep() {
|
|||||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
if ! find "$dst/$root" -type d -empty -delete; then
|
||||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||||
fi
|
fi
|
||||||
done < <(manifest_subtree_roots)
|
done < <(manifest_subtree_roots)
|
||||||
@@ -581,7 +599,7 @@ run_migrations() {
|
|||||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||||
rm -rf "$TARGET_DIR/bin"
|
rm -rf "${TARGET_DIR:?}/bin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove old mosaic PATH entry from shell profiles
|
# Remove old mosaic PATH entry from shell profiles
|
||||||
@@ -692,9 +710,11 @@ trap 'restore_snapshot; exit 1' ERR INT TERM
|
|||||||
|
|
||||||
sync_framework
|
sync_framework
|
||||||
|
|
||||||
# Ensure persistent directories exist
|
# Ensure persistent directories exist. Credentials are private material and
|
||||||
|
# must never inherit a permissive umask/default mode.
|
||||||
mkdir -p "$TARGET_DIR/memory"
|
mkdir -p "$TARGET_DIR/memory"
|
||||||
mkdir -p "$TARGET_DIR/credentials"
|
mkdir -p "$TARGET_DIR/credentials"
|
||||||
|
chmod 0700 "$TARGET_DIR/credentials"
|
||||||
|
|
||||||
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
||||||
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
||||||
@@ -706,13 +726,23 @@ mkdir -p "$TARGET_DIR/credentials"
|
|||||||
# by `mosaic init` from templates with user-supplied values.
|
# by `mosaic init` from templates with user-supplied values.
|
||||||
reconcile_framework_files
|
reconcile_framework_files
|
||||||
|
|
||||||
# Ensure tool scripts are executable
|
# Ensure tool scripts are executable. These are P4 postconditions, not
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
# best-effort cleanup: a chmod failure leaves shipped tools unloadable.
|
||||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then
|
||||||
|
fail "Could not mark shipped shell tools executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then
|
||||||
|
fail "Could not mark shipped runtime scripts executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
# suffix — git resolves credential helpers by exact name/path, not extension.
|
||||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \
|
||||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
&& ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then
|
||||||
|
fail "Could not mark git-credential-mosaic executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
ok "Framework synced to $TARGET_DIR"
|
ok "Framework synced to $TARGET_DIR"
|
||||||
|
|
||||||
@@ -739,49 +769,162 @@ step "Post-install tasks"
|
|||||||
|
|
||||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||||
|
|
||||||
|
# Capture every fallible post-install command. A failure's text is surfaced and
|
||||||
|
# also appended to the parent transaction's private command log. Failure to
|
||||||
|
# write that log is fatal: continuing would recreate the false-clean diagnosis
|
||||||
|
# INV-C forbids.
|
||||||
|
record_phase_outcome() {
|
||||||
|
local phase="$1" status="$2" reason="$3"
|
||||||
|
[[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0
|
||||||
|
if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \
|
||||||
|
|| ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then
|
||||||
|
fail "Could not durably record $phase action outcome for the parent transaction."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
redact_install_stream() {
|
||||||
|
# Keep this bootstrap copy behaviorally identical to tools/install.sh's
|
||||||
|
# state_redact_stream; neither installer can assume the other is installed.
|
||||||
|
python3 /dev/fd/3 3<<'PY'
|
||||||
|
import os, re, sys
|
||||||
|
text = sys.stdin.read()
|
||||||
|
secret_name = re.compile(r"(?:TOKEN|PASSWORD|PASSWD|SECRET|API_KEY|AUTH|CREDENTIAL|CANARY)", re.I)
|
||||||
|
secrets = {value for name, value in os.environ.items() if secret_name.search(name) and len(value) >= 4}
|
||||||
|
for value in sorted(secrets, key=len, reverse=True):
|
||||||
|
text = text.replace(value, "[REDACTED]")
|
||||||
|
patterns = (
|
||||||
|
(re.compile(r"(?im)^(\s*(?:proxy-)?authorization\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?im)^(\s*(?:set-)?cookie\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)(Bearer\s+)[^\s'\"]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)((?:[_-]?auth(?:Token)?|token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)[^\s'\"]+"), r"\1[REDACTED]"),
|
||||||
|
)
|
||||||
|
for pattern, replacement in patterns:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
url_pattern = re.compile(r"https?://[^\s'\"<>]+", re.I)
|
||||||
|
def redact_url(match):
|
||||||
|
url = match.group(0)
|
||||||
|
scheme_end = url.find("://") + 3
|
||||||
|
authority_end = len(url)
|
||||||
|
for separator in "/?#":
|
||||||
|
position = url.find(separator, scheme_end)
|
||||||
|
if position != -1:
|
||||||
|
authority_end = min(authority_end, position)
|
||||||
|
authority = url[scheme_end:authority_end]
|
||||||
|
at = authority.rfind("@")
|
||||||
|
if at != -1:
|
||||||
|
return url[:scheme_end] + "[REDACTED]@" + authority[at + 1:] + url[authority_end:]
|
||||||
|
return url
|
||||||
|
sys.stdout.write(url_pattern.sub(redact_url, text))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
run_captured() {
|
||||||
|
local label="$1" redacted redactor_pid capture_fd status=0 redact_status=0
|
||||||
|
shift
|
||||||
|
redacted="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-redacted.XXXXXX")"
|
||||||
|
chmod 0600 "$redacted" || { rm -f "$redacted"; exit 1; }
|
||||||
|
# Preserve in-shell command behavior without ever staging plaintext output on
|
||||||
|
# disk. Process substitution carries raw bytes only through a pipe.
|
||||||
|
exec {capture_fd}> >(redact_install_stream > "$redacted")
|
||||||
|
redactor_pid=$!
|
||||||
|
set +e
|
||||||
|
"$@" >&"$capture_fd" 2>&1
|
||||||
|
status=$?
|
||||||
|
exec {capture_fd}>&-
|
||||||
|
wait "$redactor_pid"
|
||||||
|
redact_status=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$redact_status" -ne 0 ]]; then
|
||||||
|
rm -f "$redacted"
|
||||||
|
fail "Could not redact '$label' diagnostics; refusing to expose or persist raw output."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then
|
||||||
|
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$redacted"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \
|
||||||
|
|| ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then
|
||||||
|
cat "$redacted" >&2
|
||||||
|
rm -f "$redacted"
|
||||||
|
fail "Could not durably append '$label' diagnostics to the install command log."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$status" -ne 0 ]]; then cat "$redacted" >&2; fi
|
||||||
|
rm -f "$redacted"
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||||
link_args=()
|
link_args=()
|
||||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||||
# stdout is suppressed as before, but stderr is left connected: the
|
if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then
|
||||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
record_phase_outcome P6 committed "runtime asset linker exited zero"
|
||||||
# the operator, not be swallowed silently.
|
|
||||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
|
||||||
ok "Runtime assets linked"
|
ok "Runtime assets linked"
|
||||||
else
|
else
|
||||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
record_phase_outcome P6 failed "runtime asset linker exited non-zero"
|
||||||
|
warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking."
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
record_phase_outcome P6 failed "required runtime asset linker is missing or not executable"
|
||||||
|
warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
||||||
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
|
if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then
|
||||||
ok "sequential-thinking MCP configured"
|
ok "sequential-thinking MCP configured"
|
||||||
|
elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||||
|
record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode"
|
||||||
|
warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking."
|
||||||
else
|
else
|
||||||
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||||
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
|
exit 1
|
||||||
else
|
|
||||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
||||||
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
|
if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then
|
||||||
|
ok "excalidraw MCP configured"
|
||||||
|
else
|
||||||
|
warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
|
||||||
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
|
record_phase_outcome P4 failed "required skills sync explicitly skipped"
|
||||||
|
warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install."
|
||||||
|
elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||||
|
if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then
|
||||||
|
record_phase_outcome P4 committed "skills sync exited zero"
|
||||||
|
ok "Skills synced"
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "skills sync exited non-zero"
|
||||||
|
warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "required skills sync command is missing or not executable"
|
||||||
|
warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
||||||
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
|
if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then
|
||||||
|
ok "Local skills migrated"
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "local skills migration exited non-zero"
|
||||||
|
warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
||||||
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
|
if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then
|
||||||
|
ok "Health audit passed"
|
||||||
|
else
|
||||||
|
warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Write version stamp AFTER everything succeeds
|
# The version stamp records the successfully committed framework file sync.
|
||||||
|
# Post-install failures are carried separately into P4/P6 and cannot be erased
|
||||||
|
# by this stamp.
|
||||||
write_framework_version
|
write_framework_version
|
||||||
|
|
||||||
# ── Summary ──────────────────────────────────────────────────
|
# ── Summary ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -88,7 +88,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -97,7 +97,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|||||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -101,7 +101,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -87,7 +87,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -84,7 +84,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -85,7 +85,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -68,8 +68,15 @@ copy_claude_settings_guarded() {
|
|||||||
guard_args+=(--allow-inactive-enforcement)
|
guard_args+=(--allow-inactive-enforcement)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v mosaic >/dev/null 2>&1; then
|
local mosaic_cli="${MOSAIC_CLI_PATH:-}"
|
||||||
if mosaic "${guard_args[@]}"; then
|
# Unified install passes P3's committed absolute artifact. Standalone
|
||||||
|
# framework installs may resolve PATH once, but still invoke the resulting
|
||||||
|
# absolute path rather than a bare command.
|
||||||
|
if [[ -z "$mosaic_cli" ]]; then
|
||||||
|
mosaic_cli="$(command -v mosaic 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ "$mosaic_cli" == /* && -x "$mosaic_cli" ]]; then
|
||||||
|
if "$mosaic_cli" "${guard_args[@]}"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
||||||
@@ -77,7 +84,7 @@ copy_claude_settings_guarded() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2
|
echo "[mosaic-link] ERROR: P3 absolute mosaic CLI unavailable — cannot confirm lease-enforcement" >&2
|
||||||
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
||||||
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
||||||
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
BRANCH="main"
|
BRANCH=""
|
||||||
|
TARGET_REPO=""
|
||||||
|
HEAD_SHA=""
|
||||||
TIMEOUT_SEC=900
|
TIMEOUT_SEC=900
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
PURPOSE="merge"
|
PURPOSE="merge"
|
||||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||||
|
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
|
||||||
|
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
|
||||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||||
--purpose VALUE Log context: push|merge (default: merge)
|
--purpose VALUE Log context: push|merge (default: merge)
|
||||||
@@ -27,63 +31,65 @@ Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0")
|
$(basename "$0")
|
||||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
$(basename "$0") --purpose push -t 600 -i 10
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
get_state_from_status_json() {
|
get_state_from_status_json() {
|
||||||
python3 - <<'PY'
|
# Python source comes from -c so the provider payload remains on stdin.
|
||||||
|
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
|
||||||
|
python3 -c '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.load(sys.stdin)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("status payload is not an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
print("unknown")
|
print("malformed")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
statuses = payload.get("statuses") or []
|
raw_statuses = payload.get("statuses", [])
|
||||||
state = (payload.get("state") or "").lower()
|
raw_state = payload.get("state", "")
|
||||||
|
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||||
|
print("malformed")
|
||||||
|
raise SystemExit(0)
|
||||||
|
statuses = raw_statuses
|
||||||
|
state = raw_state.lower()
|
||||||
|
|
||||||
pending_values = {"pending", "queued", "running", "waiting"}
|
pending_values = {"pending", "queued", "running", "waiting"}
|
||||||
failure_values = {"failure", "error", "failed"}
|
failure_values = {"failure", "error", "failed"}
|
||||||
success_values = {"success"}
|
success_values = {"success"}
|
||||||
|
|
||||||
if state in pending_values:
|
|
||||||
print("pending")
|
|
||||||
raise SystemExit(0)
|
|
||||||
if state in failure_values:
|
|
||||||
print("terminal-failure")
|
|
||||||
raise SystemExit(0)
|
|
||||||
if state in success_values:
|
|
||||||
print("terminal-success")
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
values = []
|
values = []
|
||||||
for item in statuses:
|
for item in statuses:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
print("malformed")
|
||||||
value = (item.get("status") or item.get("state") or "").lower()
|
raise SystemExit(0)
|
||||||
if value:
|
raw_value = item.get("status") or item.get("state")
|
||||||
values.append(value)
|
if not isinstance(raw_value, str) or not raw_value:
|
||||||
|
print("malformed")
|
||||||
|
raise SystemExit(0)
|
||||||
|
values.append(raw_value.lower())
|
||||||
|
|
||||||
if not values and not state:
|
if any(value in pending_values for value in values) or state in pending_values:
|
||||||
print("no-status")
|
|
||||||
elif any(v in pending_values for v in values):
|
|
||||||
print("pending")
|
print("pending")
|
||||||
elif any(v in failure_values for v in values):
|
elif any(value in failure_values for value in values) or state in failure_values:
|
||||||
print("terminal-failure")
|
print("terminal-failure")
|
||||||
elif values and all(v in success_values for v in values):
|
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||||
print("terminal-success")
|
print("terminal-success")
|
||||||
|
elif not values:
|
||||||
|
print("no-status")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
PY
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
print_pending_contexts() {
|
print_pending_contexts() {
|
||||||
python3 - <<'PY'
|
python3 -c '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -104,17 +110,61 @@ for item in statuses:
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get("context") or item.get("name") or "unknown-context"
|
name = item.get("context") or item.get("name") or "unknown-context"
|
||||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||||
target = item.get("target_url") or item.get("url") or ""
|
target = item.get("target_url") or item.get("url") or ""
|
||||||
if value in pending_values:
|
if value in pending_values:
|
||||||
found = True
|
found = True
|
||||||
if target:
|
suffix = f" ({target})" if target else ""
|
||||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||||
else:
|
|
||||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
|
||||||
if not found:
|
if not found:
|
||||||
print("[ci-queue-wait] no pending contexts")
|
print("[ci-queue-wait] no pending contexts")
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
record_cannot_assert() {
|
||||||
|
local reason="$1"
|
||||||
|
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||||
|
|
||||||
|
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||||
|
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||||
|
record = {
|
||||||
|
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
"outcome": "CANNOT_ASSERT",
|
||||||
|
"reason": reason,
|
||||||
|
"platform": platform,
|
||||||
|
"purpose": purpose,
|
||||||
|
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||||
|
"branch": branch,
|
||||||
|
"repo": repo,
|
||||||
|
}
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||||
|
try:
|
||||||
|
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
PY
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PURPOSE" == "merge" ]]; then
|
||||||
|
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||||
|
return 75
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
github_get_branch_head_sha() {
|
github_get_branch_head_sha() {
|
||||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
|||||||
local owner="$1"
|
local owner="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
local sha="$3"
|
local sha="$3"
|
||||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
local work_root status_file checks_file
|
||||||
|
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
|
||||||
|
mkdir -p "$work_root" || return 1
|
||||||
|
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
|
||||||
|
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
|
||||||
|
rm -f "$status_file"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
|
||||||
|
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
|
||||||
|
rm -f "$status_file" "$checks_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$status_file" "$checks_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
status_pages = json.load(handle)
|
||||||
|
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||||
|
check_pages = json.load(handle)
|
||||||
|
|
||||||
|
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
# The statuses endpoint is newest-first and can contain retries for one context.
|
||||||
|
# Keep only the newest entry per context after flattening every page.
|
||||||
|
combined = []
|
||||||
|
seen_contexts = set()
|
||||||
|
for page in status_pages:
|
||||||
|
if not isinstance(page, list):
|
||||||
|
raise SystemExit(1)
|
||||||
|
for status in page:
|
||||||
|
if not isinstance(status, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
context = status.get("context")
|
||||||
|
if not isinstance(context, str) or not context or context in seen_contexts:
|
||||||
|
continue
|
||||||
|
seen_contexts.add(context)
|
||||||
|
combined.append(status)
|
||||||
|
|
||||||
|
check_runs = []
|
||||||
|
reported_total = 0
|
||||||
|
for page in check_pages:
|
||||||
|
if not isinstance(page, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
page_runs = page.get("check_runs") or []
|
||||||
|
total_count = page.get("total_count")
|
||||||
|
if not isinstance(page_runs, list) or not isinstance(total_count, int):
|
||||||
|
raise SystemExit(1)
|
||||||
|
reported_total = max(reported_total, total_count)
|
||||||
|
check_runs.extend(page_runs)
|
||||||
|
if len(check_runs) < reported_total:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
for run in check_runs:
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
status = run.get("status")
|
||||||
|
conclusion = run.get("conclusion")
|
||||||
|
if status != "completed":
|
||||||
|
value = "pending"
|
||||||
|
elif conclusion == "success":
|
||||||
|
value = "success"
|
||||||
|
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
|
||||||
|
value = "failure"
|
||||||
|
else:
|
||||||
|
value = "unknown"
|
||||||
|
combined.append({
|
||||||
|
"context": run.get("name") or "github-check",
|
||||||
|
"status": value,
|
||||||
|
"target_url": run.get("html_url") or run.get("details_url") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
json.dump({"state": "", "statuses": combined}, sys.stdout)
|
||||||
|
PY
|
||||||
|
local status=$?
|
||||||
|
rm -f "$status_file" "$checks_file"
|
||||||
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
gitea_get_branch_head_sha() {
|
gitea_get_branch_head_sha() {
|
||||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BRANCH="$2"
|
BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-R|--repo)
|
||||||
|
TARGET_REPO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--sha)
|
||||||
|
HEAD_SHA="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-t|--timeout)
|
-t|--timeout)
|
||||||
TIMEOUT_SEC="$2"
|
TIMEOUT_SEC="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
|||||||
echo "Error: timeout and interval must be integer seconds." >&2
|
echo "Error: timeout and interval must be integer seconds." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
|
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||||
|
echo "Error: --repo must be OWNER/REPO." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
OWNER=$(get_repo_owner)
|
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||||
REPO=$(get_repo_name)
|
echo "Error: --purpose must be push or merge." >&2
|
||||||
detect_platform > /dev/null
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OWNER="unknown"
|
||||||
|
REPO="unknown"
|
||||||
|
PLATFORM="unknown"
|
||||||
|
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||||
|
record_cannot_assert "repository-owner-unresolvable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||||
|
record_cannot_assert "repository-name-unresolvable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
if ! detect_platform > /dev/null; then
|
||||||
|
PLATFORM="${PLATFORM:-unknown}"
|
||||||
|
record_cannot_assert "unsupported-platform"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
PLATFORM="${PLATFORM:-unknown}"
|
PLATFORM="${PLATFORM:-unknown}"
|
||||||
|
|
||||||
|
if [[ -n "$TARGET_REPO" ]]; then
|
||||||
|
OWNER="${TARGET_REPO%%/*}"
|
||||||
|
REPO="${TARGET_REPO##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$BRANCH" ]]; then
|
||||||
|
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||||
|
record_cannot_assert "current-branch-unresolvable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
if ! command -v gh >/dev/null 2>&1; then
|
if ! command -v gh >/dev/null 2>&1; then
|
||||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
record_cannot_assert "github-cli-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||||
exit 1
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
HOST=$(get_remote_host) || {
|
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||||
echo "Error: Could not determine remote host." >&2
|
record_cannot_assert "remote-host-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
record_cannot_assert "credential-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
|
||||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
|
||||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
|
||||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
|
||||||
exit 0
|
|
||||||
fi
|
fi
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||||
exit 1
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||||
|
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
else
|
else
|
||||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
record_cannot_assert "unsupported-platform"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
|||||||
while true; do
|
while true; do
|
||||||
NOW_TS=$(date +%s)
|
NOW_TS=$(date +%s)
|
||||||
if (( NOW_TS > DEADLINE_TS )); then
|
if (( NOW_TS > DEADLINE_TS )); then
|
||||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||||
exit 124
|
exit 124
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||||
@@ -271,21 +459,24 @@ while true; do
|
|||||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||||
sleep "$INTERVAL_SEC"
|
sleep "$INTERVAL_SEC"
|
||||||
;;
|
;;
|
||||||
|
terminal-success)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
no-status)
|
no-status)
|
||||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
exit 1
|
else
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
exit 3
|
||||||
exit 0
|
|
||||||
;;
|
;;
|
||||||
terminal-success|terminal-failure|unknown)
|
terminal-failure|malformed|unknown)
|
||||||
# Queue guard only blocks on pending/running/queued states.
|
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
MERGE_METHOD="squash"
|
MERGE_METHOD="squash"
|
||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
SKIP_QUEUE_GUARD=false
|
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
EXPECT_HEAD=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -25,15 +25,14 @@ Options:
|
|||||||
-n, --number NUMBER PR number to merge (required)
|
-n, --number NUMBER PR number to merge (required)
|
||||||
-m, --method METHOD Merge method: squash only (default: squash)
|
-m, --method METHOD Merge method: squash only (default: squash)
|
||||||
-d, --delete-branch Delete the head branch after merge
|
-d, --delete-branch Delete the head branch after merge
|
||||||
--skip-queue-guard Skip CI queue guard wait before merge
|
|
||||||
--dry-run Run metadata/login preflight without merging
|
--dry-run Run metadata/login preflight without merging
|
||||||
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -n 42 # Merge PR #42
|
$(basename "$0") -n 42 # Merge PR #42
|
||||||
$(basename "$0") -n 42 -m squash # Squash merge
|
$(basename "$0") -n 42 -m squash # Squash merge
|
||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
DELETE_BRANCH=true
|
DELETE_BRANCH=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--skip-queue-guard)
|
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dry-run)
|
--dry-run)
|
||||||
DRY_RUN=true
|
DRY_RUN=true
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--expect-head)
|
||||||
|
EXPECT_HEAD="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|||||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
|
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||||
|
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||||
|
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||||
|
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
|
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||||
|
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" != true ]]; then
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$BASE_BRANCH" \
|
-B "$HEAD_BRANCH" \
|
||||||
|
-R "$HEAD_REPO" \
|
||||||
|
--sha "$HEAD_SHA" \
|
||||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||||
fi
|
fi
|
||||||
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
is_known_tea_empty_identity_failure() {
|
|
||||||
local error_file="$1"
|
|
||||||
|
|
||||||
python3 - "$error_file" <<'PY'
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
|
||||||
error = handle.read()
|
|
||||||
|
|
||||||
known_empty_identity = re.search(
|
|
||||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
|
||||||
error,
|
|
||||||
flags=re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
raise SystemExit(0 if known_empty_identity else 1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
merge_gitea_with_api() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||||
payload='{"Do":"squash"}'
|
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
head_sha, delete_branch = sys.argv[1:]
|
||||||
|
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||||
|
if delete_branch == "true":
|
||||||
|
payload["delete_branch_after_merge"] = True
|
||||||
|
print(json.dumps(payload, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
token=$(get_gitea_token "$host" || true)
|
token=$(get_gitea_token "$host" || true)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
@@ -202,7 +209,7 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||||
"${cmd[@]}"
|
"${cmd[@]}"
|
||||||
;;
|
;;
|
||||||
@@ -211,32 +218,9 @@ case "$PLATFORM" in
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||||
|
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
merge_gitea_with_api "$HOST"
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
|
||||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
|
||||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
|
||||||
rm -f "$TEA_ERROR_FILE"
|
|
||||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
|
||||||
cat "$TEA_ERROR_FILE" >&2
|
|
||||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
|
||||||
rm -f "$TEA_ERROR_FILE"
|
|
||||||
merge_gitea_with_api "$HOST"
|
|
||||||
else
|
|
||||||
cat "$TEA_ERROR_FILE" >&2
|
|
||||||
rm -f "$TEA_ERROR_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
|
||||||
merge_gitea_with_api "$HOST"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Delete branch after merge if requested
|
|
||||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
|
||||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ PY
|
|||||||
detect_platform > /dev/null
|
detect_platform > /dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||||
write_metadata "$METADATA"
|
write_metadata "$METADATA"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
|||||||
data.get('head_ref'),
|
data.get('head_ref'),
|
||||||
head_ref,
|
head_ref,
|
||||||
)
|
)
|
||||||
|
head_sha = first_non_empty(
|
||||||
|
nested(data, 'head', 'sha'),
|
||||||
|
nested(data, 'head', 'id'),
|
||||||
|
data.get('head_sha'),
|
||||||
|
)
|
||||||
|
head_repo = first_non_empty(
|
||||||
|
nested(data, 'head', 'repo', 'full_name'),
|
||||||
|
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||||
|
)
|
||||||
|
if not head_repo:
|
||||||
|
head_repo_owner = first_non_empty(
|
||||||
|
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||||
|
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||||
|
nested(data, 'head', 'repo', 'owner_name'),
|
||||||
|
)
|
||||||
|
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||||
|
if head_repo_owner and head_repo_name:
|
||||||
|
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||||
|
|
||||||
base_ref = first_non_empty(
|
base_ref = first_non_empty(
|
||||||
nested(data, 'base', 'ref'),
|
nested(data, 'base', 'ref'),
|
||||||
nested(data, 'base', 'name'),
|
nested(data, 'base', 'name'),
|
||||||
@@ -207,6 +226,8 @@ normalized = {
|
|||||||
'state': data.get('state'),
|
'state': data.get('state'),
|
||||||
'author': nested(data, 'user', 'login') or '',
|
'author': nested(data, 'user', 'login') or '',
|
||||||
'headRefName': head_ref,
|
'headRefName': head_ref,
|
||||||
|
'headRefOid': head_sha,
|
||||||
|
'headRepository': head_repo,
|
||||||
'baseRefName': base_ref,
|
'baseRefName': base_ref,
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||||
|
|||||||
@@ -109,6 +109,55 @@ else
|
|||||||
detect_platform >/dev/null
|
detect_platform >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Render the provider's own explanation for a failed request, for appending to
|
||||||
|
# an error message (#1004). Every HTTP arm in this file already has the response
|
||||||
|
# body on disk; without this it was discarded unread at exactly the moment the
|
||||||
|
# caller needed it, which pushes an operator toward re-issuing the request by
|
||||||
|
# hand to find out what the server said. Gitea returns {"message": "..."} on a
|
||||||
|
# refusal; anything unparseable falls back to a truncated raw first line so a
|
||||||
|
# proxy's HTML error page still says something. Prints "" when there is nothing
|
||||||
|
# to add, so callers can interpolate unconditionally.
|
||||||
|
#
|
||||||
|
# Args: $1 = path to the response body file.
|
||||||
|
gitea_error_detail() {
|
||||||
|
local body_file="$1"
|
||||||
|
[[ -s "$body_file" ]] || return 0
|
||||||
|
python3 - "$body_file" <<'PY' 2>/dev/null || true
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
LIMIT = 300
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8", errors="replace") as response:
|
||||||
|
raw = response.read().strip()
|
||||||
|
except OSError:
|
||||||
|
raise SystemExit(0)
|
||||||
|
if not raw:
|
||||||
|
raise SystemExit(0)
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
for key in ("message", "error", "errors"):
|
||||||
|
value = parsed.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
detail = value.strip()
|
||||||
|
break
|
||||||
|
if isinstance(value, list) and value:
|
||||||
|
detail = "; ".join(str(item) for item in value).strip()
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if not detail:
|
||||||
|
detail = raw.splitlines()[0].strip()
|
||||||
|
if not detail:
|
||||||
|
raise SystemExit(0)
|
||||||
|
if len(detail) > LIMIT:
|
||||||
|
detail = detail[:LIMIT] + "..."
|
||||||
|
print(f" — provider said: {detail}")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
||||||
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
||||||
# write is a direct POST that returns the created comment object, so we learn
|
# write is a direct POST that returns the created comment object, so we learn
|
||||||
@@ -150,7 +199,7 @@ print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$write_status" != "201" ]]; then
|
if [[ "$write_status" != "201" ]]; then
|
||||||
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
|
echo "Error: Gitea comment write failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -179,7 +228,7 @@ PY
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$readback_status" != "200" ]]; then
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
echo "Error: Gitea comment read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -370,7 +419,7 @@ gitea_authenticated_login() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$status" != "200" ]]; then
|
if [[ "$status" != "200" ]]; then
|
||||||
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
echo "Error: Gitea authenticated-identity read failed with HTTP $status$(gitea_error_detail "$response_file")" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -407,7 +456,7 @@ gitea_read_pr_head_into() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$status" != "200" ]]; then
|
if [[ "$status" != "200" ]]; then
|
||||||
echo "Error: Gitea PR head read failed with HTTP $status" >&2
|
echo "Error: Gitea PR head read failed with HTTP $status$(gitea_error_detail "$pr_file")" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
python3 - "$pr_file" <<'PY'
|
python3 - "$pr_file" <<'PY'
|
||||||
@@ -497,7 +546,7 @@ print(json.dumps({
|
|||||||
fi
|
fi
|
||||||
# Gitea returns 200 (occasionally 201) with the created review object.
|
# Gitea returns 200 (occasionally 201) with the created review object.
|
||||||
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
||||||
echo "Error: Gitea review submit failed with HTTP $write_status (#865: no durable review created)" >&2
|
echo "Error: Gitea review submit failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -526,7 +575,7 @@ PY
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$readback_status" != "200" ]]; then
|
if [[ "$readback_status" != "200" ]]; then
|
||||||
echo "Error: Gitea review read-back failed with HTTP $readback_status" >&2
|
echo "Error: Gitea review read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# Covers:
|
# Covers:
|
||||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ case "$mode" in
|
|||||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||||
500) code=500; body='{"message":"internal server error"}' ;;
|
500) code=500; body='{"message":"internal server error"}' ;;
|
||||||
no-status) code=200; body='{}' ;;
|
no-status) code=200; body='{}' ;;
|
||||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
*)
|
*)
|
||||||
echo "curl stub: unknown mode=$mode" >&2
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
|||||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
export GITEA_TOKEN="stub-token"
|
export GITEA_TOKEN="stub-token"
|
||||||
export GITEA_URL="https://git.example.test"
|
export GITEA_URL="https://git.example.test"
|
||||||
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
|||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||||
set +e
|
set +e
|
||||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||||
status_c=$?
|
status_c=$?
|
||||||
set -e
|
set -e
|
||||||
if [[ "$status_c" -eq 0 ]]; then
|
if [[ "$status_c" -ne 0 ]]; then
|
||||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||||
|
echo "$out_c" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||||
|
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
fail=1
|
||||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
fail=1
|
||||||
|
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||||
|
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||||
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
||||||
|
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/gh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
endpoint=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
||||||
|
done
|
||||||
|
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
||||||
|
case "$endpoint" in
|
||||||
|
repos/acme/widgets/branches/fix/github-checks)
|
||||||
|
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
||||||
|
;;
|
||||||
|
repos/acme/widgets/commits/*/statuses?per_page=100)
|
||||||
|
printf '%s\n' '[[]]'
|
||||||
|
;;
|
||||||
|
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
||||||
|
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
||||||
|
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
||||||
|
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
||||||
|
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
||||||
|
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/gh"
|
||||||
|
|
||||||
|
run_guard() {
|
||||||
|
local mode="$1"
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR" || exit
|
||||||
|
export PATH="$STUB_DIR:$PATH"
|
||||||
|
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||||
|
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||||
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||||
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
assert_case() {
|
||||||
|
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||||
|
set +e
|
||||||
|
output=$(run_guard "$mode" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$output" != *"state=$expected_state"* ]]; then
|
||||||
|
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
||||||
|
printf '%s\n' "$output" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set -e
|
||||||
|
: > "$WORK_DIR/gh-calls.log"
|
||||||
|
assert_case success zero terminal-success
|
||||||
|
assert_case pending nonzero pending
|
||||||
|
assert_case failure nonzero terminal-failure
|
||||||
|
assert_case late-failure nonzero terminal-failure
|
||||||
|
|
||||||
|
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||||
|
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
||||||
|
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||||
|
STATUS_OBSERVED="$WORK_DIR/status-observed"
|
||||||
|
CLOCK_LOG="$WORK_DIR/clock.log"
|
||||||
|
WATCHDOG_PYTHON="/usr/bin/python3"
|
||||||
|
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
|
||||||
|
WATCHDOG_TIMEOUT_SEC=5
|
||||||
|
WATCHDOG_EXIT=90
|
||||||
|
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||||
|
|
||||||
|
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
|
||||||
|
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||||
|
cat > "$WATCHDOG_SCRIPT" <<'PY'
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
timeout_seconds = float(sys.argv[1])
|
||||||
|
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
|
||||||
|
try:
|
||||||
|
return_code = process.wait(timeout=timeout_seconds)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
process.wait()
|
||||||
|
print(
|
||||||
|
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
|
||||||
|
"before completing its intended path",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(90)
|
||||||
|
|
||||||
|
if return_code < 0:
|
||||||
|
raise SystemExit(128 - return_code)
|
||||||
|
raise SystemExit(return_code)
|
||||||
|
PY
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
url=""
|
||||||
|
has_write_out=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-w) has_write_out=1 ;;
|
||||||
|
http://*|https://*) url="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||||
|
|
||||||
|
case "$url" in
|
||||||
|
*/branches/*)
|
||||||
|
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
|
||||||
|
while :; do :; done
|
||||||
|
fi
|
||||||
|
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||||
|
exit 7
|
||||||
|
fi
|
||||||
|
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||||
|
if [[ "$has_write_out" -eq 1 ]]; then
|
||||||
|
printf '%s\n200' "$body"
|
||||||
|
else
|
||||||
|
printf '%s' "$body"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/status)
|
||||||
|
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
|
||||||
|
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||||
|
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
|
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||||
|
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
||||||
|
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
||||||
|
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
||||||
|
malformed) printf '%s' 'not-json' ;;
|
||||||
|
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||||
|
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||||
|
large-success)
|
||||||
|
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||||
|
;;
|
||||||
|
unreachable) exit 7 ;;
|
||||||
|
*) echo "unknown status mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/date" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
|
||||||
|
echo "unexpected date invocation: $*" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
|
||||||
|
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
printf '1002\n'
|
||||||
|
else
|
||||||
|
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
printf '1000\n'
|
||||||
|
fi
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/sleep" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
|
||||||
|
|
||||||
|
run_guard() {
|
||||||
|
local status_mode="$1"
|
||||||
|
local audit_log="${2:-$AUDIT_LOG}"
|
||||||
|
shift 2 || true
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR" || exit
|
||||||
|
export PATH="$STUB_DIR:$PATH"
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||||
|
export HOME="$WORK_DIR/empty-home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
||||||
|
export MOSAIC_STUB_STATUS_MODE=success
|
||||||
|
else
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||||
|
fi
|
||||||
|
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
|
||||||
|
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||||
|
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
|
||||||
|
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
|
||||||
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||||
|
# Provider observation is the synchronization event. The one-second
|
||||||
|
# timeout is subject semantics under virtual time, never a wall wait.
|
||||||
|
# The absolute Python runtime uses an internal monotonic wait and kills
|
||||||
|
# the subject's isolated process group. Neither operation can resolve
|
||||||
|
# to the virtual date/sleep stubs at the front of PATH.
|
||||||
|
local subject_rc
|
||||||
|
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
|
||||||
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
|
||||||
|
subject_rc=0
|
||||||
|
else
|
||||||
|
subject_rc=$?
|
||||||
|
fi
|
||||||
|
return "$subject_rc"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
assert_provider_observed() {
|
||||||
|
local name="$1" require_expiration="${2:-0}"
|
||||||
|
if [[ ! -e "$STATUS_OBSERVED" ]]; then
|
||||||
|
echo "FAIL $name: status provider was not observed" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
|
||||||
|
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$require_expiration" -eq 1 ]]; then
|
||||||
|
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
|
||||||
|
echo "FAIL $name: pending path did not expire after provider observation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_assertion() {
|
||||||
|
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||||
|
local output rc
|
||||||
|
shift 4
|
||||||
|
set +e
|
||||||
|
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$expected_rc" in
|
||||||
|
zero)
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
nonzero)
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
not126)
|
||||||
|
if [[ "$rc" -eq 126 ]]; then
|
||||||
|
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$output" != *"$required_text"* ]]; then
|
||||||
|
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
||||||
|
printf '%s\n' "$output" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$status_mode" != "credential-unresolvable" ]]; then
|
||||||
|
if [[ "$status_mode" == "pending" ]]; then
|
||||||
|
assert_provider_observed "$name" 1
|
||||||
|
else
|
||||||
|
assert_provider_observed "$name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set -e
|
||||||
|
: > "$WORK_DIR/urls.log"
|
||||||
|
run_assertion success zero success 'state=terminal-success'
|
||||||
|
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||||
|
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||||
|
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||||
|
|
||||||
|
# Positive liveness control: a subject mutant hangs before the branch lookup
|
||||||
|
# can reach the status provider. Only the independent real-clock watchdog may
|
||||||
|
# terminate it, and its failure must be distinct from subject timeout rc=124.
|
||||||
|
set +e
|
||||||
|
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
|
||||||
|
watchdog_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
|
||||||
|
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
|
||||||
|
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
|
||||||
|
printf '%s\n' "$watchdog_output" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ -e "$STATUS_OBSERVED" ]]; then
|
||||||
|
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||||
|
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
||||||
|
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
||||||
|
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||||
|
set +e
|
||||||
|
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
||||||
|
merge_unreachable_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
||||||
|
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||||
|
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
assert_provider_observed merge-provider-unreachable
|
||||||
|
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||||
|
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||||
|
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
||||||
|
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
||||||
|
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
||||||
|
exact_sha=0123456789abcdef0123456789abcdef01234567
|
||||||
|
: > "$WORK_DIR/urls.log"
|
||||||
|
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
||||||
|
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
||||||
|
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
||||||
|
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
||||||
|
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
||||||
|
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
||||||
|
set +e
|
||||||
|
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
||||||
|
unsupported_rc=$?
|
||||||
|
set -e
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
||||||
|
if [[ "$unsupported_rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
||||||
|
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||||
|
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
||||||
|
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A degraded pass is forbidden if the audit receipt cannot be written.
|
||||||
|
mkdir -p "$WORK_DIR/not-a-directory"
|
||||||
|
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
||||||
|
set +e
|
||||||
|
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
||||||
|
audit_failure_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||||
|
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
assert_provider_observed audit-unavailable
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -79,15 +79,24 @@ emit_response() {
|
|||||||
printf '200'
|
printf '200'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||||
|
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
POST_DATA="$post_data" python3 - <<'PY'
|
||||||
echo "unexpected merge payload: $post_data" >&2
|
import json
|
||||||
exit 96
|
import os
|
||||||
fi
|
payload = json.loads(os.environ["POST_DATA"])
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
|||||||
export GITEA_TOKEN="redacted-test-token"
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
OUTPUT="$SANDBOX/output.log"
|
OUTPUT="$SANDBOX/output.log"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||||
echo "--- output ---" >&2
|
echo "--- output ---" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
echo "--- mock log ---" >&2
|
echo "--- mock log ---" >&2
|
||||||
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
|
||||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
|
||||||
if [[ "$*" == *"login list"* ]]; then
|
|
||||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "$*" == *"pr merge"* ]]; then
|
|
||||||
echo 'tea network timeout' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
EOF
|
|
||||||
chmod +x "$MOCK_BIN/tea"
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
|
||||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
|
||||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
|
||||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -177,8 +154,8 @@ EOF
|
|||||||
chmod +x "$MOCK_BIN/tea"
|
chmod +x "$MOCK_BIN/tea"
|
||||||
unset GITEA_LOGIN
|
unset GITEA_LOGIN
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
|
|||||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pr-merge.sh Gitea fallback regression passed"
|
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
||||||
|
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
||||||
|
SHA=0123456789abcdef0123456789abcdef01234567
|
||||||
|
|
||||||
|
make_fixture() {
|
||||||
|
local name="$1" remote="$2"
|
||||||
|
local root="$WORK_DIR/$name"
|
||||||
|
local tools="$root/tools/git"
|
||||||
|
mkdir -p "$tools" "$root/repo"
|
||||||
|
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||||
|
git -C "$root/repo" init -q
|
||||||
|
git -C "$root/repo" remote add origin "$remote"
|
||||||
|
cat > "$tools/pr-metadata.sh" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||||
|
SH
|
||||||
|
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$tools"/*.sh
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||||
|
make_fixture github https://github.com/acme/widgets.git
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
payload=""
|
||||||
|
for ((i=1; i<=$#; i++)); do
|
||||||
|
if [[ "${!i}" == "-d" ]]; then
|
||||||
|
j=$((i + 1))
|
||||||
|
payload="${!j}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||||
|
printf '200'
|
||||||
|
SH
|
||||||
|
chmod +x "$WORK_DIR/gitea/curl"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
|
||||||
|
) >"$WORK_DIR/gitea.out" 2>&1
|
||||||
|
gitea_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$gitea_rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
|
||||||
|
cat "$WORK_DIR/gitea.out" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
|
||||||
|
assert payload.get("Do") == "squash", payload
|
||||||
|
assert payload.get("head_commit_id") == sys.argv[2], payload
|
||||||
|
PY
|
||||||
|
|
||||||
|
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
|
||||||
|
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
|
||||||
|
rm -f "$WORK_DIR/gitea-payload-stale.json"
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
|
||||||
|
) >"$WORK_DIR/gitea-stale.out" 2>&1
|
||||||
|
stale_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
|
||||||
|
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
|
||||||
|
# --skip-queue-guard option must be rejected before any provider merge call.
|
||||||
|
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 99
|
||||||
|
SH
|
||||||
|
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
|
||||||
|
rm -f "$WORK_DIR/gitea-payload-bypass.json"
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
|
||||||
|
) >"$WORK_DIR/gitea-bypass.out" 2>&1
|
||||||
|
bypass_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
|
||||||
|
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dry-run is the only path that may omit the guard because it exits before the
|
||||||
|
# provider merge dispatch. Prove the exit and absence of a merge payload.
|
||||||
|
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
|
||||||
|
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
|
||||||
|
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
|
||||||
|
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/github/gh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
|
||||||
|
SH
|
||||||
|
chmod +x "$WORK_DIR/github/gh"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/github/repo"
|
||||||
|
export PATH="$WORK_DIR/github:$PATH"
|
||||||
|
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
|
||||||
|
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
|
||||||
|
) >"$WORK_DIR/github.out" 2>&1
|
||||||
|
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
|
||||||
|
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
|
||||||
|
cat "$WORK_DIR/github-call.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
||||||
|
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||||
|
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FIXTURE_DIR"
|
||||||
|
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||||
|
exit 42
|
||||||
|
SH
|
||||||
|
chmod +x "$FIXTURE_DIR"/*.sh
|
||||||
|
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||||
|
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
||||||
|
) >/dev/null 2>&1
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$rc" -ne 42 ]]; then
|
||||||
|
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$CALL_LOG" ]]; then
|
||||||
|
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q -- '-B main' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard still received the main base branch" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr-merge queue branch/repository/SHA regression passed"
|
||||||
@@ -58,6 +58,9 @@ CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
|||||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||||
|
# Sandboxed HOME so nothing under the real $HOME (notably the per-slot Gitea token
|
||||||
|
# store at ~/.config/mosaic/secrets/gitea-tokens/) is reachable from the wrapper.
|
||||||
|
HOME_DIR="$WORK_DIR/home"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$WORK_DIR"
|
rm -rf "$WORK_DIR"
|
||||||
@@ -78,9 +81,18 @@ OVERRIDE_TOKEN="override-token-placeholder"
|
|||||||
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
||||||
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
||||||
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH"
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
# HERMETICITY: get_gitea_token() step 0 resolves a per-agent identity from
|
||||||
|
# `git config --get mosaic.gitIdentity`, which on a provisioned agent seat is set
|
||||||
|
# GLOBALLY and therefore leaks into this fresh repo. It then reads a REAL per-slot
|
||||||
|
# token from $HOME and returns it WITHOUT ever consulting MOSAIC_CREDENTIALS_FILE,
|
||||||
|
# so the fixture credentials below are silently ignored and the suite runs against
|
||||||
|
# production credentials. An empty repo-local value shadows the global one and reads
|
||||||
|
# back as empty at rc=0, restoring the shared-credential path this suite intends to
|
||||||
|
# exercise. Paired with the sandboxed HOME in run_review().
|
||||||
|
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||||
|
|
||||||
# tea config: the override login carries its own token here. The default login
|
# tea config: the override login carries its own token here. The default login
|
||||||
# name ("mosaicstack") is deliberately absent, so the no-override default path
|
# name ("mosaicstack") is deliberately absent, so the no-override default path
|
||||||
@@ -266,6 +278,24 @@ submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
|
|||||||
with open(state_path, encoding="utf-8") as handle:
|
with open(state_path, encoding="utf-8") as handle:
|
||||||
reviews = json.load(handle)
|
reviews = json.load(handle)
|
||||||
|
|
||||||
|
# review-refused-422 (#1004): the server REFUSES the submit outright with a
|
||||||
|
# definite, correct, machine-readable reason in the body — the shape Gitea
|
||||||
|
# returns when the acting credential authored the PR. Nothing is created. The
|
||||||
|
# wrapper must surface what the server said and must NOT relabel this as the
|
||||||
|
# #865 silent-no-op defect class, which is precisely what it is not.
|
||||||
|
if mode == "review-refused-422":
|
||||||
|
print("422")
|
||||||
|
print(json.dumps({"message": "Cannot approve your own pull request"}))
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
# review-refused-html (#1004): a non-JSON error body, as a fronting proxy or
|
||||||
|
# gateway emits. The detail extraction must degrade to the first raw line rather
|
||||||
|
# than silently dropping the only explanation available.
|
||||||
|
if mode == "review-refused-html":
|
||||||
|
print("502")
|
||||||
|
print("<html><head><title>502 Bad Gateway</title></head>\n<body>nginx</body></html>")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
|
# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
|
||||||
# created object) even though a concurrent same-identity, same-state review at
|
# created object) even though a concurrent same-identity, same-state review at
|
||||||
# the same head already exists. Nothing is persisted; no created id to verify.
|
# the same head already exists. Nothing is persisted; no created id to verify.
|
||||||
@@ -517,6 +547,8 @@ run_review() {
|
|||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
PATH="$BIN_DIR:$PATH" \
|
PATH="$BIN_DIR:$PATH" \
|
||||||
TMPDIR="$TMP_SCRATCH" \
|
TMPDIR="$TMP_SCRATCH" \
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_GIT_IDENTITY="" \
|
||||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
||||||
@@ -940,4 +972,44 @@ if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
|
|||||||
fi
|
fi
|
||||||
assert_no_temp_leak "review-body-null"
|
assert_no_temp_leak "review-body-null"
|
||||||
|
|
||||||
|
# Case 19 (#1004): an outright server REFUSAL must report the provider's own
|
||||||
|
# reason and must NOT be relabelled as the #865 silent-no-op defect class. The
|
||||||
|
# old arm hardcoded "(#865: no durable review created)" for EVERY non-2xx, so a
|
||||||
|
# 422/403/404 — all of them definite, correct refusals the server explained in
|
||||||
|
# the discarded body — arrived at the caller wearing the name of the one defect
|
||||||
|
# they are not. That misdirection is what makes an operator re-issue the request
|
||||||
|
# by hand against the live object to find out what actually happened.
|
||||||
|
if run_review review-refused-422 approve; then
|
||||||
|
echo "FAIL: approve reported success when the server refused the submit" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'HTTP 422' "$OUTPUT_FILE"
|
||||||
|
if ! grep -q 'Cannot approve your own pull request' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: the provider's stated reason was discarded (#1004)" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q '#865: no durable review created' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: a server refusal was misattributed to the #865 defect class (#1004)" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_no_temp_leak "review-refused-422"
|
||||||
|
|
||||||
|
# Case 20 (#1004): a non-JSON error body (a fronting proxy's HTML page) must
|
||||||
|
# still yield something the caller can act on, rather than a bare status code.
|
||||||
|
if run_review review-refused-html approve; then
|
||||||
|
echo "FAIL: approve reported success on a 502" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -q 'HTTP 502' "$OUTPUT_FILE"
|
||||||
|
if ! grep -q '502 Bad Gateway' "$OUTPUT_FILE"; then
|
||||||
|
echo "FAIL: a non-JSON error body was dropped instead of degrading to its first line (#1004)" >&2
|
||||||
|
cat "$OUTPUT_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
assert_no_temp_leak "review-refused-html"
|
||||||
|
|
||||||
echo "pr-review.sh REST review + comment create/read-back regression passed"
|
echo "pr-review.sh REST review + comment create/read-back regression passed"
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-test-enumeration.sh — CI test-membership guard (#1017).
|
||||||
|
#
|
||||||
|
# CI reaches shell suites through two hand-enumerated surfaces:
|
||||||
|
# S1 packages/mosaic/package.json scripts."test:framework-shell"
|
||||||
|
# S2 .woodpecker/ci.yml direct `bash packages/mosaic/framework/tools/...` commands
|
||||||
|
#
|
||||||
|
# A hand-enumerated allowlist re-arms its own gap: a new suite never auto-joins,
|
||||||
|
# so the list silently under-runs the disk (17 of 39 suites were invisible when
|
||||||
|
# #1017 was filed). This guard makes that under-run impossible to do silently:
|
||||||
|
#
|
||||||
|
# FAIL when a suite-shaped file exists on disk and is neither enumerated on
|
||||||
|
# the UNION of both surfaces nor listed in the exclusions file.
|
||||||
|
# ("Enumerated", deliberately — F1/F2 on PR #1018 proved this guard sees
|
||||||
|
# NAMING, not reachability, and its words must not claim otherwise.)
|
||||||
|
# FAIL when either surface names a path that does not exist on disk
|
||||||
|
# (a rename manufactures a stale entry silently — checked BOTH directions).
|
||||||
|
# FAIL when an exclusion entry has no reason, names a path that is gone,
|
||||||
|
# names a path that is also enumerated (contradiction), or names a path
|
||||||
|
# outside the population (dead weight that looks like coverage).
|
||||||
|
#
|
||||||
|
# POPULATION PATTERN — a deliberate decision, stated per #1017's record:
|
||||||
|
# basename matches *test*.sh (contains "test", ends ".sh"). Deliberately BROAD:
|
||||||
|
# the strict `test-*.sh` prefix cannot even name three real boundary files
|
||||||
|
# (tmux/agent-send.test.sh — CI-run; orchestrator/smoke-test.sh;
|
||||||
|
# wake/validate-973/microtest-wake-assert.sh), and three independent censuses
|
||||||
|
# handled that last file three different ways with no trace of the judgement.
|
||||||
|
# The broad pattern makes such files MEMBERS, so their disposition must be a
|
||||||
|
# signed exclusion, not an accident of the glob. The SAME pattern is applied to
|
||||||
|
# both sides of the comparison (disk and enumeration) — a comparison globbed two
|
||||||
|
# ways runs on two different populations. Scripts outside the pattern on both
|
||||||
|
# sides symmetrically (e.g. check-resident-budget.sh, verify-sanitized.sh) are
|
||||||
|
# check-scripts, not suites; their existence is still verified via the
|
||||||
|
# both-directions rule because every surface-named path must exist on disk.
|
||||||
|
#
|
||||||
|
# The surfaces are PARSED, never line-ranged: three seats independently
|
||||||
|
# mis-scoped hand-written line ranges against these files (#1017 thread). S1 is
|
||||||
|
# read via JSON + command-chain tokenization; S2 by extracting every
|
||||||
|
# packages/mosaic/framework/tools/ token wherever it appears in the file.
|
||||||
|
#
|
||||||
|
# Exclusions file format (framework/tools/quality/test-enumeration-exclusions.txt):
|
||||||
|
# <repo-relative-path> | <non-empty reason>
|
||||||
|
# Lines starting with # and blank lines are ignored. An exclusion is a recorded
|
||||||
|
# decision someone signed, not an omission nobody made.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/../../../../../.." && pwd)"
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--root) ROOT="$(cd "$2" && pwd)"; shift 2 ;;
|
||||||
|
*) echo "usage: check-test-enumeration.sh [--root <repo-root>]" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
PKG_JSON="$ROOT/packages/mosaic/package.json"
|
||||||
|
CI_YML="$ROOT/.woodpecker/ci.yml"
|
||||||
|
TOOLS_DIR="$ROOT/packages/mosaic/framework/tools"
|
||||||
|
EXCLUSIONS="$TOOLS_DIR/quality/test-enumeration-exclusions.txt"
|
||||||
|
|
||||||
|
for f in "$PKG_JSON" "$CI_YML"; do
|
||||||
|
[[ -f "$f" ]] || { echo "FAIL: required surface file missing: $f" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
[[ -d "$TOOLS_DIR" ]] || { echo "FAIL: tools dir missing: $TOOLS_DIR" >&2; exit 2; }
|
||||||
|
|
||||||
|
fail_count=0
|
||||||
|
fail() { printf 'FAIL %s\n' "$1"; fail_count=$(( fail_count + 1 )); }
|
||||||
|
|
||||||
|
# in_population <repo-relative path> — the single pattern, used for BOTH sides.
|
||||||
|
in_population() {
|
||||||
|
local base; base="$(basename "$1")"
|
||||||
|
[[ "$base" == *test*.sh ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Surface 1: package.json test:framework-shell, parsed, repo-relative -----
|
||||||
|
# Tokens are script paths iff they contain "/" and end .sh/.py; interpreter
|
||||||
|
# names and flags are skipped. Paths are relative to packages/mosaic/.
|
||||||
|
mapfile -t S1 < <(python3 - "$PKG_JSON" <<'PY'
|
||||||
|
import json, shlex, sys
|
||||||
|
cmd = json.load(open(sys.argv[1]))["scripts"].get("test:framework-shell", "")
|
||||||
|
seen = []
|
||||||
|
for seg in cmd.split("&&"):
|
||||||
|
for tok in shlex.split(seg):
|
||||||
|
if "/" in tok and (tok.endswith(".sh") or tok.endswith(".py")):
|
||||||
|
path = "packages/mosaic/" + tok
|
||||||
|
if path not in seen:
|
||||||
|
seen.append(path)
|
||||||
|
print("\n".join(seen))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Surface 2: ci.yml, every framework/tools token wherever it appears ------
|
||||||
|
# Comment lines (first non-whitespace char is #) are skipped BEFORE matching:
|
||||||
|
# commenting an invocation out is the most common way a suite actually gets
|
||||||
|
# disabled, and a raw-text regex would keep calling it enumerated (F1, 20155 on
|
||||||
|
# PR #1018 — demonstrated, not argued). Known residual limit: a path named only
|
||||||
|
# in a TRAILING comment on a live line still matches; no such line exists today
|
||||||
|
# and full fidelity would need a YAML parser the CI image does not ship.
|
||||||
|
mapfile -t S2 < <(grep -vE '^[[:space:]]*#' "$CI_YML" \
|
||||||
|
| grep -oE 'packages/mosaic/framework/tools/[A-Za-z0-9_./-]+\.(sh|py)' | sort -u)
|
||||||
|
|
||||||
|
# --- Union, and its population-restricted view -------------------------------
|
||||||
|
declare -A ENUM=() ENUM_POP=()
|
||||||
|
for p in "${S1[@]:-}" "${S2[@]:-}"; do
|
||||||
|
[[ -n "$p" ]] || continue
|
||||||
|
ENUM["$p"]=1
|
||||||
|
in_population "$p" && ENUM_POP["$p"]=1
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Direction B: every surface-named path must exist on disk ----------------
|
||||||
|
for p in "${!ENUM[@]}"; do
|
||||||
|
[[ -f "$ROOT/$p" ]] || fail "STALE ENUMERATION: surfaces name '$p' but it does not exist on disk"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Exclusions: parsed with the same rigor the enumeration gets -------------
|
||||||
|
declare -A EXCLUDED=()
|
||||||
|
if [[ -f "$EXCLUSIONS" ]]; then
|
||||||
|
lineno=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
lineno=$(( lineno + 1 ))
|
||||||
|
[[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue
|
||||||
|
path="${line%%|*}"; reason="${line#*|}"
|
||||||
|
path="$(echo "$path" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||||
|
reason="$(echo "$reason" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||||
|
if [[ "$line" != *"|"* || -z "$reason" ]]; then
|
||||||
|
fail "EXCLUSION MISSING REASON: line $lineno ('$path') — an exclusion is a recorded decision someone signed"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$ROOT/$path" ]]; then
|
||||||
|
fail "STALE EXCLUSION: line $lineno excludes '$path' which does not exist on disk"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! in_population "$path"; then
|
||||||
|
fail "EXCLUSION OUTSIDE POPULATION: line $lineno excludes '$path' which the population pattern does not name — dead weight that reads as coverage"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ -n "${ENUM[$path]:-}" ]]; then
|
||||||
|
fail "CONTRADICTORY EXCLUSION: line $lineno excludes '$path' which the surfaces already enumerate"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
EXCLUDED["$path"]=1
|
||||||
|
done < "$EXCLUSIONS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Direction A: disk population must be enumerated or signed-excluded ------
|
||||||
|
disk_total=0
|
||||||
|
unlisted=0
|
||||||
|
while IFS= read -r f; do
|
||||||
|
rel="${f#"$ROOT"/}"
|
||||||
|
in_population "$rel" || continue
|
||||||
|
disk_total=$(( disk_total + 1 ))
|
||||||
|
if [[ -z "${ENUM_POP[$rel]:-}" && -z "${EXCLUDED[$rel]:-}" ]]; then
|
||||||
|
fail "UNENUMERATED: '$rel' exists on disk but is neither enumerated on any CI surface nor signed in the exclusions file"
|
||||||
|
unlisted=$(( unlisted + 1 ))
|
||||||
|
fi
|
||||||
|
done < <(find "$TOOLS_DIR" -type f -name '*.sh' | sort)
|
||||||
|
|
||||||
|
if (( fail_count > 0 )); then
|
||||||
|
printf 'enumeration guard: %d failure(s) — population %d, enumerated (in-population) %d, excluded %d\n' \
|
||||||
|
"$fail_count" "$disk_total" "${#ENUM_POP[@]}" "${#EXCLUDED[@]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'enumeration guard: OK — population %d, enumerated (in-population) %d, excluded (signed) %d, surfaces name %d path(s), all present on disk\n' \
|
||||||
|
"$disk_total" "${#ENUM_POP[@]}" "${#EXCLUDED[@]}" "${#ENUM[@]}"
|
||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test-check-test-enumeration.sh — needles for the enumeration guard (#1017).
|
||||||
|
#
|
||||||
|
# Every failure mode the guard promises gets BOTH polarities:
|
||||||
|
# NEEDLE a fixture that MUST trip the guard, asserted on the guard's OWN
|
||||||
|
# words (--out) — exit 1 alone cannot distinguish "caught the rogue
|
||||||
|
# file" from "choked on the fixture".
|
||||||
|
# CONTROL a fixture that MUST pass. A guard that failed unconditionally
|
||||||
|
# would satisfy every needle here — the null-case defect the guard's
|
||||||
|
# own subject matter (#1017) exists to make impossible.
|
||||||
|
#
|
||||||
|
# The needles encode the specific errors that produced #1017's thread:
|
||||||
|
# n6 is the 20124 boundary file (a suite the strict prefix cannot name);
|
||||||
|
# n2b proves surface 2 is PARSED, not line-ranged (three seats mis-scoped
|
||||||
|
# hand-written ranges against ci.yml);
|
||||||
|
# n5/n7 keep the exclusions file honest so it cannot become the next silent cap;
|
||||||
|
# n8/c4 are F1 (20155): a commented-out ci.yml line is NOT enumeration —
|
||||||
|
# commenting-out is the most common way a suite actually gets disabled,
|
||||||
|
# and it must fail loud in one direction without false-staling the other.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
GUARD="$HERE/check-test-enumeration.sh"
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
PASS=0; FAIL=0
|
||||||
|
|
||||||
|
# fixture <name> — a minimal repo root the guard accepts via --root:
|
||||||
|
# one suite enumerated on S1 (plus a naming-outlier suite, so the S1 parser's
|
||||||
|
# handling of non-prefix names is always exercised), one on S2, one check-script
|
||||||
|
# named on S2 that is outside the population, and an empty exclusions file.
|
||||||
|
fixture() {
|
||||||
|
local r="$TMP/$1"
|
||||||
|
mkdir -p "$r/packages/mosaic/framework/tools/git" \
|
||||||
|
"$r/packages/mosaic/framework/tools/tmux" \
|
||||||
|
"$r/packages/mosaic/framework/tools/quality/scripts" \
|
||||||
|
"$r/.woodpecker"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/git/test-a.sh"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/tmux/outlier.test.sh"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/quality/scripts/test-ci.sh"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$r/packages/mosaic/framework/tools/quality/scripts/verify-thing.sh"
|
||||||
|
cat > "$r/packages/mosaic/package.json" <<'JSON'
|
||||||
|
{"scripts": {"test:framework-shell": "bash framework/tools/git/test-a.sh && bash framework/tools/tmux/outlier.test.sh"}}
|
||||||
|
JSON
|
||||||
|
cat > "$r/.woodpecker/ci.yml" <<'YML'
|
||||||
|
steps:
|
||||||
|
sanitize:
|
||||||
|
commands:
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/verify-thing.sh
|
||||||
|
guard:
|
||||||
|
commands:
|
||||||
|
- bash packages/mosaic/framework/tools/quality/scripts/test-ci.sh
|
||||||
|
YML
|
||||||
|
: > "$r/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt"
|
||||||
|
printf '%s' "$r"
|
||||||
|
}
|
||||||
|
|
||||||
|
# expect <kind> <want-exit> <desc> [--out <substring>] -- <root>
|
||||||
|
expect() {
|
||||||
|
local kind="$1" want="$2" desc="$3"; shift 3
|
||||||
|
local need_out=""
|
||||||
|
while (( $# )); do
|
||||||
|
case "$1" in
|
||||||
|
--out) need_out="$2"; shift 2 ;;
|
||||||
|
--) shift; break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
local root="$1" got=0 out
|
||||||
|
out="$(bash "$GUARD" --root "$root" 2>&1)" || got=$?
|
||||||
|
local why=""
|
||||||
|
[[ "$got" == "$want" ]] || why="wanted exit $want, got $got"
|
||||||
|
if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then
|
||||||
|
why="exit $got as expected, but output never said: $need_out"
|
||||||
|
fi
|
||||||
|
if [[ -z "$why" ]]; then
|
||||||
|
printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got"
|
||||||
|
PASS=$(( PASS + 1 ))
|
||||||
|
else
|
||||||
|
printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why"
|
||||||
|
printf '%s\n' "$out" | sed 's/^/ | /'
|
||||||
|
FAIL=$(( FAIL + 1 ))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
excl() { printf '%s\n' "$2" >> "$1/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt"; }
|
||||||
|
|
||||||
|
echo "=== c1: a fully consistent fixture passes ==="
|
||||||
|
R="$(fixture c1)"
|
||||||
|
expect CONTROL 0 "consistent tree: both surfaces enumerated, nothing unlisted" \
|
||||||
|
--out "enumeration guard: OK" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n1: an on-disk suite reachable from no surface must fail ==="
|
||||||
|
R="$(fixture n1)"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||||
|
expect NEEDLE 1 "unlisted suite is named in the failure" \
|
||||||
|
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/test-rogue.sh'" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n6: the 20124 boundary file — a suite the strict prefix cannot name ==="
|
||||||
|
R="$(fixture n6)"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/rogue.test.sh"
|
||||||
|
expect NEEDLE 1 "naming-outlier suite (*.test.sh) is a population member, not invisible" \
|
||||||
|
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/rogue.test.sh'" -- "$R"
|
||||||
|
|
||||||
|
echo "=== c3: a non-suite script outside the pattern is outside it on BOTH sides ==="
|
||||||
|
R="$(fixture c3)"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/check-unrelated.sh"
|
||||||
|
expect CONTROL 0 "check-script on disk, unlisted, outside population: not the guard's business" \
|
||||||
|
--out "enumeration guard: OK" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n2/n2b: a surface naming a path absent from disk must fail — both surfaces ==="
|
||||||
|
R="$(fixture n2)"
|
||||||
|
rm "$R/packages/mosaic/framework/tools/git/test-a.sh"
|
||||||
|
expect NEEDLE 1 "S1 (package.json) stale entry" \
|
||||||
|
--out "STALE ENUMERATION: surfaces name 'packages/mosaic/framework/tools/git/test-a.sh'" -- "$R"
|
||||||
|
R="$(fixture n2b)"
|
||||||
|
rm "$R/packages/mosaic/framework/tools/quality/scripts/test-ci.sh"
|
||||||
|
expect NEEDLE 1 "S2 (ci.yml) stale entry — proves ci.yml is parsed, not line-ranged" \
|
||||||
|
--out "STALE ENUMERATION: surfaces name 'packages/mosaic/framework/tools/quality/scripts/test-ci.sh'" -- "$R"
|
||||||
|
|
||||||
|
echo "=== c2: a rogue suite with a SIGNED exclusion passes, and is counted ==="
|
||||||
|
R="$(fixture c2)"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||||
|
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh | non-hermetic pending fixture work (needle-suite specimen)"
|
||||||
|
expect CONTROL 0 "signed exclusion is honoured and visible in the summary" \
|
||||||
|
--out "excluded (signed) 1" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n3: an exclusion with no reason is not a decision ==="
|
||||||
|
R="$(fixture n3)"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||||
|
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh | "
|
||||||
|
expect NEEDLE 1 "empty reason rejected" --out "EXCLUSION MISSING REASON" -- "$R"
|
||||||
|
R="$(fixture n3b)"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||||
|
excl "$R" "packages/mosaic/framework/tools/git/test-rogue.sh"
|
||||||
|
expect NEEDLE 1 "missing separator rejected (the path alone is not a signature)" \
|
||||||
|
--out "EXCLUSION MISSING REASON" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n4: an exclusion whose path is gone is stale, not satisfied ==="
|
||||||
|
R="$(fixture n4)"
|
||||||
|
excl "$R" "packages/mosaic/framework/tools/git/test-vanished.sh | was excluded once, then deleted"
|
||||||
|
expect NEEDLE 1 "stale exclusion rejected" --out "STALE EXCLUSION" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n5: excluding an enumerated suite is a contradiction, not belt-and-braces ==="
|
||||||
|
R="$(fixture n5)"
|
||||||
|
excl "$R" "packages/mosaic/framework/tools/git/test-a.sh | already in CI but excluded anyway"
|
||||||
|
expect NEEDLE 1 "contradictory exclusion rejected" --out "CONTRADICTORY EXCLUSION" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n8/c4: a commented-out ci.yml line is not enumeration (F1, 20155) ==="
|
||||||
|
R="$(fixture n8)"
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$R/packages/mosaic/framework/tools/git/test-disabled.sh"
|
||||||
|
printf ' # - bash packages/mosaic/framework/tools/git/test-disabled.sh\n' >> "$R/.woodpecker/ci.yml"
|
||||||
|
expect NEEDLE 1 "suite named only in a commented-out invocation is UNENUMERATED" \
|
||||||
|
--out "UNENUMERATED: 'packages/mosaic/framework/tools/git/test-disabled.sh'" -- "$R"
|
||||||
|
R="$(fixture c4)"
|
||||||
|
printf ' # - bash packages/mosaic/framework/tools/git/test-vanished.sh\n' >> "$R/.woodpecker/ci.yml"
|
||||||
|
expect CONTROL 0 "comment naming an absent path raises no false stale-enumeration" \
|
||||||
|
--out "enumeration guard: OK" -- "$R"
|
||||||
|
|
||||||
|
echo "=== n7: excluding a file outside the population is dead weight, not coverage ==="
|
||||||
|
R="$(fixture n7)"
|
||||||
|
excl "$R" "packages/mosaic/framework/tools/quality/scripts/verify-thing.sh | not a suite but signing it anyway"
|
||||||
|
expect NEEDLE 1 "out-of-population exclusion rejected" --out "EXCLUSION OUTSIDE POPULATION" -- "$R"
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf 'enumeration-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||||
|
(( FAIL == 0 ))
|
||||||
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
|
|||||||
# loop — which would make the control a false negative. A root dotfile is
|
# loop — which would make the control a false negative. A root dotfile is
|
||||||
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
|
||||||
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
STRIPPED="$FW/.install-rollback-control.tmp.sh"
|
||||||
|
SIGNALED="$FW/.install-signal-control.tmp.sh"
|
||||||
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
NOEXIT="$FW/.install-noexit-control.tmp.sh"
|
||||||
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
|
||||||
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
|
||||||
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
|
||||||
|
|
||||||
pass=0; fail=0
|
pass=0; fail=0
|
||||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||||
@@ -179,58 +180,107 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
|||||||
|
|
||||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||||
# A bash signal trap that merely returns lets the script continue past the
|
# A bash signal trap that merely returns lets the script continue past the
|
||||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
# interrupt — restoring the snapshot, then resuming the install and reporting
|
||||||
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
|
# success. Generate two installer fixtures that first damage a real target path
|
||||||
# fires and ONLY the signal path governs), and assert the shipped installer
|
# after the snapshot is armed, then signal their own Bash process immediately
|
||||||
# restores AND exits without reporting success. The control strips `exit 1` from
|
# before the normal sync. This fixed injection point is independent of `find`
|
||||||
# the trap and shows the buggy resume-to-success.
|
# enumeration order: after a no-exit handler restores and returns, the complete
|
||||||
make_term_shim() {
|
# sync still remains to run, so the historical resume bug is deterministic on
|
||||||
local dir="$1"
|
# every filesystem. Their TERM handlers emit the same observable before
|
||||||
cat > "$dir/cp" <<SHIM
|
# diverging, so missing signal delivery fails BOTH arms rather than manufacturing
|
||||||
#!/usr/bin/env bash
|
# a pass. The only semantic difference between fixtures is the explicit `exit 1`
|
||||||
dest="\${@: -1}"
|
# whose load-bearing behavior this control proves.
|
||||||
case "\$dest" in
|
TERM_MARKER='[test-control] TERM handler entered'
|
||||||
*/$POISON_REL)
|
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||||
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
|
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@" ;;
|
|
||||||
esac
|
make_signal_installer() {
|
||||||
exec env PATH="$ORIG_PATH" cp "\$@"
|
local output="$1" handler="$2"
|
||||||
SHIM
|
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||||
chmod +x "$dir/cp"
|
local target_sync='sync_framework'
|
||||||
|
local inject_damage="printf '%s' '$GARBAGE' > \"\$TARGET_DIR/$POISON_REL\" # TEST-TERM-DAMAGE"
|
||||||
|
local inject_kill='kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||||
|
|
||||||
|
if ! awk \
|
||||||
|
-v target_trap="$target_trap" -v target_sync="$target_sync" \
|
||||||
|
-v handler="$handler" -v inject_damage="$inject_damage" \
|
||||||
|
-v inject_kill="$inject_kill" '
|
||||||
|
$0 == target_sync {
|
||||||
|
print inject_damage
|
||||||
|
print inject_kill
|
||||||
|
injection_sites++
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
$0 == target_trap {
|
||||||
|
print handler
|
||||||
|
handler_sites++
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (handler_sites != 1 || injection_sites != 1) exit 42
|
||||||
|
}
|
||||||
|
' "$INSTALL" > "$output"; then
|
||||||
|
rm -f "$output"
|
||||||
|
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$output"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
|
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
||||||
|
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||||
|
signal_fixture_ready() {
|
||||||
|
local fixture="$1" expected_handler="$2"
|
||||||
|
[[ "$(grep -cF '# TEST-TERM-DAMAGE' "$fixture")" -eq 1 ]] \
|
||||||
|
&& [[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||||
|
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||||
|
&& grep -Fqx "$expected_handler" "$fixture"
|
||||||
|
}
|
||||||
|
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
|
||||||
|
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
|
||||||
|
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
|
||||||
|
"signaled_fixture_ready"
|
||||||
|
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
|
||||||
|
"noexit_fixture_ready"
|
||||||
|
chk "[control] removing the explicit TERM exit changes the fixture" \
|
||||||
|
"! cmp -s '$SIGNALED' '$NOEXIT'"
|
||||||
|
|
||||||
|
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
|
||||||
|
# the selected copy. Echoes "<exit>\t<out>\t<home>".
|
||||||
run_signal_upgrade() {
|
run_signal_upgrade() {
|
||||||
local installer="$1" H OUT SHIM rc
|
local installer="$1" H OUT rc
|
||||||
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
|
H=$(mktemp -d); OUT=$(mktemp)
|
||||||
seed_home "$H"
|
seed_home "$H"
|
||||||
make_term_shim "$SHIM"
|
|
||||||
set +e
|
set +e
|
||||||
PATH="$SHIM:$ORIG_PATH" \
|
PATH="$ORIG_PATH" \
|
||||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
|
||||||
rc=$?
|
rc=$?
|
||||||
set -e 2>/dev/null || true
|
set -e 2>/dev/null || true
|
||||||
rm -rf "$SHIM"
|
|
||||||
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
|
||||||
}
|
}
|
||||||
|
|
||||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
|
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
chk "[signal] TERM handler observable fires exactly once" \
|
||||||
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||||
|
chk "[signal] SIGTERM after target mutation aborts non-zero (trap exits, does not resume)" \
|
||||||
"[ '$rcC' -ne 0 ]"
|
"[ '$rcC' -ne 0 ]"
|
||||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||||
"grep -q 'restoring previous state from snapshot' '$OUTC'"
|
"grep -q 'restoring previous state from snapshot' '$OUTC'"
|
||||||
|
chk "[signal] the deliberately damaged target is restored before termination" \
|
||||||
|
"[ \"\$(cat '$HC/$POISON_REL')\" = '$GOOD' ]"
|
||||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||||
"! grep -q 'file phase complete' '$OUTC'"
|
"! grep -q 'file phase complete' '$OUTC'"
|
||||||
|
|
||||||
# Control: strip `exit 1` from the signal trap → the handler returns, the script
|
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
||||||
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
|
chk "[control] TERM handler observable fires exactly once" \
|
||||||
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
|
||||||
"$INSTALL" > "$NOEXIT"
|
chk "[control] without 'exit 1' the handler restores before returning" \
|
||||||
chk "[control] the exit-strip actually changed the installer" \
|
"grep -q 'restoring previous state from snapshot' '$OUTD'"
|
||||||
"! cmp -s '$INSTALL' '$NOEXIT'"
|
chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
||||||
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
|
"[ '$rcD' -eq 0 ]"
|
||||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||||
"grep -q 'file phase complete' '$OUTD'"
|
"grep -q 'file phase complete' '$OUTD'"
|
||||||
|
chk "[control] the resumed full sync mutates the restored target again" \
|
||||||
|
"! grep -qxF '$GOOD' '$HD/$POISON_REL' && cmp -s '$FW/$POISON_REL' '$HD/$POISON_REL'"
|
||||||
|
|
||||||
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
|
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
|
||||||
# A `< <(find …)` process substitution discards find's exit status, so an
|
# A `< <(find …)` process substitution discards find's exit status, so an
|
||||||
@@ -309,10 +359,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
|
|||||||
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
|
||||||
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
|
||||||
|
|
||||||
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
|
# Cleanup (generated installer controls are also removed by the EXIT trap).
|
||||||
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
|
||||||
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
|
||||||
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# test-enumeration-exclusions.txt — signed exclusions for check-test-enumeration.sh (#1017).
|
||||||
|
#
|
||||||
|
# Every entry is a recorded decision: a suite-shaped file that exists on disk,
|
||||||
|
# is NOT reachable from any CI surface, and carries the reason someone signed
|
||||||
|
# for that. The guard FAILS on an entry with no reason, a stale path, or a path
|
||||||
|
# the surfaces already enumerate. Burning an entry down = making it CI-reachable
|
||||||
|
# (package.json test:framework-shell or a ci.yml step) and deleting its line.
|
||||||
|
#
|
||||||
|
# Format: <repo-relative path> | <reason>
|
||||||
|
# All entries below were signed at #1017's filing base (main 826a8b3b, 2026-07-31)
|
||||||
|
# by pepper (sb-it-1-dt); measurements cited are one-run assertions from that seat.
|
||||||
|
|
||||||
|
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
|
||||||
|
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
|
||||||
|
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||||
|
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
|
||||||
|
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
|
||||||
|
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
|
||||||
|
|
||||||
|
# --- tools/git: push guards — measured green locally, CI-image fitness unverified ---
|
||||||
|
packages/mosaic/framework/tools/git/test-push-guard.sh | measured green at 826a8b3b (46 passed / 0 failed, one run, 2026-07-31); CI-image fitness unverified; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/git/test-mutate-push-guard.sh | measured green at 826a8b3b (8/0, 13 mutants killed 0 survived, one run, 2026-07-31); requires setsid (util-linux), absent from the alpine base image; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh | hermeticity unaudited — the unprotected suite in #1007's protected/unprotected split; audit before CI; #1017 burndown
|
||||||
|
|
||||||
|
# --- tools/git: unmeasured ---
|
||||||
|
packages/mosaic/framework/tools/git/test-verify-clean-clone.sh | unmeasured in CI image; asserts git file-mode (100644/755) semantics that need verification on the CI filesystem first; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/git/test-help-exit-code.sh | unmeasured in CI image; stub-based (#701 regression harness), likely CI-fit; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh | unmeasured in CI image; fixture-based (#546/#547 regression harness), likely CI-fit; #1017 burndown
|
||||||
|
|
||||||
|
# --- tools/tmux: require a live tmux server ---
|
||||||
|
packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a real tmux server on a throwaway socket; CI image ships no tmux; #1017 burndown (needs tmux in image or a signed permanent exclusion)
|
||||||
|
packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling)
|
||||||
|
|
||||||
|
# --- single-suite directories: unmeasured in CI ---
|
||||||
|
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown
|
||||||
|
|
||||||
|
# --- naming-boundary files the strict test-*.sh prefix cannot even name ---
|
||||||
|
# (#1017: three independent censuses handled the microtest file three different
|
||||||
|
# ways — editorial drop, structural exclusion, accidental inclusion — with no
|
||||||
|
# recorded judgement. These lines ARE that judgement, signed.)
|
||||||
|
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
||||||
|
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
||||||
@@ -191,3 +191,177 @@ _wake_init_dir() {
|
|||||||
[ -f "$dir/pending.jsonl" ] || printf '' | _atomic_write "$dir/pending.jsonl"
|
[ -f "$dir/pending.jsonl" ] || printf '' | _atomic_write "$dir/pending.jsonl"
|
||||||
[ -f "$dir/ack-ledger.jsonl" ] || printf '' | _atomic_write "$dir/ack-ledger.jsonl"
|
[ -f "$dir/ack-ledger.jsonl" ] || printf '' | _atomic_write "$dir/ack-ledger.jsonl"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #973 — three-valued grep assertion helpers for the wake test suites.
|
||||||
|
#
|
||||||
|
# grep's exit contract is three-valued: 0 = match, 1 = no match, >1 = ERROR
|
||||||
|
# (bad file, bad pattern, resource failure). Every wake-suite assertion used
|
||||||
|
# to read all non-zero as "absent", so a grep that COULD NOT LOOK wore the
|
||||||
|
# colour of a verdict: OR-polarity sites (`|| fail`) went falsely red,
|
||||||
|
# AND-polarity sites (`&& fail` — including the credential canaries) went
|
||||||
|
# falsely green. The repair is to refuse to answer: rc 0 -> match, rc 1 -> no
|
||||||
|
# match, anything else -> loud abort naming the call site, the raw exit code,
|
||||||
|
# and the arguments. An error NEVER becomes a verdict.
|
||||||
|
#
|
||||||
|
# Production tools (store.sh, ack.sh) source this file but call none of the
|
||||||
|
# helpers below; they are inert outside the suites.
|
||||||
|
#
|
||||||
|
# Suite integration contract:
|
||||||
|
# - Call `wake_assert_init` ONCE at suite top level, right after sourcing.
|
||||||
|
# It dups the suite's real stderr to a saved fd BEFORE any call-site
|
||||||
|
# redirect exists, so an abort stays loud even at sites that append
|
||||||
|
# `2>/dev/null` (the preimage credential canaries pre-swallow stderr —
|
||||||
|
# exactly where a silent abort would recreate the defect being fixed).
|
||||||
|
# - Assertion sites live inside `( ... ) && ok` subshell blocks, pipelines,
|
||||||
|
# and `$(...)` substitutions, where a plain `exit` dies one layer deep and
|
||||||
|
# the suite would carry on to emit a verdict. The abort therefore signals
|
||||||
|
# the suite's MAIN shell ($$ is the main PID in every subshell) and then
|
||||||
|
# exits the current context: the suite dies by signal, non-zero, with NO
|
||||||
|
# verdict line emitted.
|
||||||
|
#
|
||||||
|
# Validation instrumentation (#973 evidence, not part of the assertion fix):
|
||||||
|
# - WAKE_ASSERT_LEDGER=<file>: every helper call appends
|
||||||
|
# "<helper> <caller-file>:<caller-line>" to <file>. That is the ONLY
|
||||||
|
# divergence from production behaviour — the suite otherwise runs its
|
||||||
|
# normal arms, so a validate run exercises exactly the shipped paths.
|
||||||
|
# - WAKE_ASSERT_FORCE_GREP_ERROR_AT=<caller-file>:<caller-line>: at exactly
|
||||||
|
# that call site, the invocation is routed through a REAL grep driven onto
|
||||||
|
# its real error path (unknown option -> rc 2) — a genuinely executed
|
||||||
|
# failing process, not a stubbed return — to prove per-site that the abort
|
||||||
|
# fires. Unset in production; matching no site is a no-op.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# wake_assert_init — dup the suite's real stderr once, for abort loudness.
|
||||||
|
# MUST be called at suite TOP LEVEL, immediately after sourcing and before any
|
||||||
|
# test block: a lazy (first-call) dup could capture an already-redirected
|
||||||
|
# stderr if the first executed helper call sat under a call-site 2>/dev/null,
|
||||||
|
# silencing every abort thereafter. The fd is allocated dynamically (>= 10),
|
||||||
|
# so it cannot collide with the wake lock fds (8) or the detector run-loop
|
||||||
|
# lock (9).
|
||||||
|
#
|
||||||
|
# Init also PINS the BASH_LINENO convention the site coordinates depend on:
|
||||||
|
# a helper call written across a backslash continuation must report at its
|
||||||
|
# FIRST physical line (the denominator artifact's convention). That was
|
||||||
|
# measured on a developer bash (5.3.x); CI runs whatever bash its base image
|
||||||
|
# baked in, and that version floats silently between image rebuilds. A bash
|
||||||
|
# that disagrees would shift every continuation-site coordinate by one line
|
||||||
|
# UNDER the validation instead of in front of it — so the convention is
|
||||||
|
# asserted at runtime, in the same bash binary that runs the suite, and a
|
||||||
|
# disagreeing bash aborts the suite loudly instead of skewing coordinates.
|
||||||
|
_wake_assert_lineno_pin() {
|
||||||
|
local _wa_pin_tmp _wa_pin_got
|
||||||
|
_wa_pin_tmp="$(mktemp)" || {
|
||||||
|
_wake_assert_err_note "WAKE-ASSERT INIT ABORT: mktemp failed; cannot pin the BASH_LINENO convention — a pin that silently does not run is not a pin (#973)"
|
||||||
|
exit 97
|
||||||
|
}
|
||||||
|
cat >"$_wa_pin_tmp" <<'WAKE_ASSERT_PIN'
|
||||||
|
_wap() { printf '%s\n' "${BASH_LINENO[0]}"; }
|
||||||
|
(
|
||||||
|
_wap simple
|
||||||
|
_wap \
|
||||||
|
continuation
|
||||||
|
)
|
||||||
|
WAKE_ASSERT_PIN
|
||||||
|
# WAKE_ASSERT_PIN_BASH: test-only interpreter override so the pin's abort
|
||||||
|
# arm can be PROVEN to fire (microtest C10) — bash resets $BASH at startup,
|
||||||
|
# so the real probe interpreter cannot be spoofed from the environment.
|
||||||
|
_wa_pin_got="$("${WAKE_ASSERT_PIN_BASH:-${BASH:-bash}}" "$_wa_pin_tmp" 2>/dev/null)"
|
||||||
|
rm -f "$_wa_pin_tmp"
|
||||||
|
if [ "$_wa_pin_got" != "$(printf '3\n4')" ]; then
|
||||||
|
_wake_assert_err_note "WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash ${BASH_VERSION}: probe reported [${_wa_pin_got:-<no output>}], expected [3 4] (simple call at own line, continuation call at FIRST physical line) — site coordinates are untrustworthy on this bash (#973)"
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wake_assert_init() {
|
||||||
|
if [ -z "${_wake_assert_err_fd:-}" ]; then
|
||||||
|
exec {_wake_assert_err_fd}>&2
|
||||||
|
_wake_assert_lineno_pin
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# _wake_assert_err_note MSG — write MSG to the saved real-stderr fd, falling
|
||||||
|
# back to the current stderr if init was never called.
|
||||||
|
_wake_assert_err_note() {
|
||||||
|
if [ -n "${_wake_assert_err_fd:-}" ]; then
|
||||||
|
printf '%s\n' "$1" >&"$_wake_assert_err_fd" 2>/dev/null ||
|
||||||
|
printf '%s\n' "$1" >&2
|
||||||
|
else
|
||||||
|
printf '%s\n' "$1" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# _wake_assert_abort HELPER SITE RC ARGS... — refuse to answer, loudly.
|
||||||
|
# Writes the named reason to the saved real-stderr fd (falling back to the
|
||||||
|
# current stderr), signals the suite's main shell, and exits this context.
|
||||||
|
_wake_assert_abort() {
|
||||||
|
local _wa_helper="$1" _wa_where="$2" _wa_code="$3"
|
||||||
|
shift 3
|
||||||
|
_wake_assert_err_note "WAKE-ASSERT ABORT: ${_wa_helper} at ${_wa_where}: grep exit ${_wa_code} is an error, not a verdict (args: $*) — refusing to answer (#973)"
|
||||||
|
if [ -n "${BASHPID:-}" ] && [ "$BASHPID" != "$$" ]; then
|
||||||
|
kill -TERM "$$" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
exit 97
|
||||||
|
}
|
||||||
|
|
||||||
|
# _wake_assert_armed SITE — true iff the forced-error arm targets SITE; on a
|
||||||
|
# match it emits a positive confirmation FIRST, so "site did not abort" can
|
||||||
|
# never conflate SITE NOT CONVERTED with ARM NEVER REACHED IT: an armed run
|
||||||
|
# with no ARMED line means the arm matched nothing (typo/renumber/drift), and
|
||||||
|
# an ARMED line with no abort means the site's error path is broken. The two
|
||||||
|
# defects are separable on stderr alone.
|
||||||
|
_wake_assert_armed() {
|
||||||
|
[ "${WAKE_ASSERT_FORCE_GREP_ERROR_AT:-}" = "$1" ] || return 1
|
||||||
|
_wake_assert_err_note "WAKE-ASSERT ARMED: forcing real grep error at $1 (#973)"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# has_match GREP_ARGS... — three-valued grep verdict.
|
||||||
|
# Drop-in for verdict-bearing `grep` calls (flags, files, stdin all pass
|
||||||
|
# through; stdout is not captured, so extract-form call sites may use it
|
||||||
|
# inside a substitution). Returns 0 on match, 1 on no-match; any other grep
|
||||||
|
# exit aborts the suite via _wake_assert_abort.
|
||||||
|
has_match() {
|
||||||
|
local _wa_site="${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]}" _wa_rc=0
|
||||||
|
if [ -n "${WAKE_ASSERT_LEDGER:-}" ]; then
|
||||||
|
printf 'has_match %s\n' "$_wa_site" >>"$WAKE_ASSERT_LEDGER"
|
||||||
|
fi
|
||||||
|
if _wake_assert_armed "$_wa_site"; then
|
||||||
|
command grep --wake-assert-forced-error -- /dev/null
|
||||||
|
_wa_rc=$?
|
||||||
|
else
|
||||||
|
command grep "$@"
|
||||||
|
_wa_rc=$?
|
||||||
|
fi
|
||||||
|
case "$_wa_rc" in
|
||||||
|
0) return 0 ;;
|
||||||
|
1) return 1 ;;
|
||||||
|
*) _wake_assert_abort has_match "$_wa_site" "$_wa_rc" "$@" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# count_lines GREP_ARGS... — `grep -c` with the same three-way discipline.
|
||||||
|
# Call sites drop their `-c` (the helper supplies it) and keep every other
|
||||||
|
# argument. Prints the count on rc 0 AND rc 1 (rc 1 is grep's "count is 0" —
|
||||||
|
# a valid measurement, not an error); any other exit aborts. The abort still
|
||||||
|
# kills the suite from inside a `$(...)` capture: the substitution subshell
|
||||||
|
# cannot exit the suite, but the signal to the main shell can — a count from
|
||||||
|
# a failed measurement is never printed.
|
||||||
|
count_lines() {
|
||||||
|
local _wa_site="${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]}" _wa_rc=0 _wa_out=""
|
||||||
|
if [ -n "${WAKE_ASSERT_LEDGER:-}" ]; then
|
||||||
|
printf 'count_lines %s\n' "$_wa_site" >>"$WAKE_ASSERT_LEDGER"
|
||||||
|
fi
|
||||||
|
if _wake_assert_armed "$_wa_site"; then
|
||||||
|
_wa_out="$(command grep --wake-assert-forced-error -c -- /dev/null)"
|
||||||
|
_wa_rc=$?
|
||||||
|
else
|
||||||
|
_wa_out="$(command grep -c "$@")"
|
||||||
|
_wa_rc=$?
|
||||||
|
fi
|
||||||
|
case "$_wa_rc" in
|
||||||
|
0 | 1) printf '%s\n' "$_wa_out" ;;
|
||||||
|
*) _wake_assert_abort count_lines "$_wa_site" "$_wa_rc" "$@" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|||||||
@@ -529,7 +529,19 @@ cmd_run() {
|
|||||||
echo "detector.sh: WARN — off-host liveness beacon emit failed (see beacon.sh); the off-host absence check remains the authoritative dead-man." >&2
|
echo "detector.sh: WARN — off-host liveness beacon emit failed (see beacon.sh); the off-host absence check remains the authoritative dead-man." >&2
|
||||||
fi
|
fi
|
||||||
[ "$once" -eq 1 ] && break
|
[ "$once" -eq 1 ] && break
|
||||||
sleep "$interval"
|
# Close the detector lock fd in the sleep child; otherwise an orphaned sleep
|
||||||
|
# keeps the single-instance flock (fd 9, taken at exec 9> above) alive after
|
||||||
|
# the detector parent dies. The lock is non-blocking (`flock -n`, above), so
|
||||||
|
# for as long as that sleep survives a replacement instance is REFUSED and
|
||||||
|
# exits rather than queueing. This particular hold is BOUNDED by one poll
|
||||||
|
# interval (WAKE_DETECTOR_INTERVAL, default 30s): when the orphaned sleep
|
||||||
|
# exits its copy of fd 9 closes, ending this bounded sleep-child hold. It
|
||||||
|
# does NOT follow that the next start succeeds — other inheritors of fd 9
|
||||||
|
# (the M1 adapter, M2 sink grandchildren) are outside this patch's scope and
|
||||||
|
# can keep holding the flock. The cost this removes is a restart window in
|
||||||
|
# which every supervisor retry fails on the sleep child's account.
|
||||||
|
# `9>&-` closes ONLY the child's copy — the parent's lock is unaffected.
|
||||||
|
sleep "$interval" 9>&-
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -465,8 +465,26 @@
|
|||||||
# never a hand-built flat dead-letter row, which would make the
|
# never a hand-built flat dead-letter row, which would make the
|
||||||
# audit's correct non-conviction look exactly like the defect
|
# audit's correct non-conviction look exactly like the defect
|
||||||
# under hunt (the #951 review's false-defect near-miss).
|
# under hunt (the #951 review's false-defect near-miss).
|
||||||
|
# 0.7.2 #973 three-valued grep verdicts across ALL TEN wake test suites.
|
||||||
|
# grep's exit contract is three-valued (0 match / 1 no-match /
|
||||||
|
# >=2 ERROR); every suite assertion read non-zero as "absent",
|
||||||
|
# so a grep that COULD NOT LOOK wore the colour of a verdict —
|
||||||
|
# OR-polarity sites failed falsely RED, AND-polarity sites
|
||||||
|
# (including all 19 credential canaries) failed falsely GREEN
|
||||||
|
# under load. _wake-common.sh gains has_match/count_lines
|
||||||
|
# (rc 0/1 pass through; anything else LOUDLY ABORTS the whole
|
||||||
|
# suite naming file:line + raw rc — an error is never a
|
||||||
|
# verdict), wake_assert_init (saved-fd abort loudness that
|
||||||
|
# survives call-site 2>/dev/null + a runtime pin of the
|
||||||
|
# BASH_LINENO coordinate convention against CI bash drift),
|
||||||
|
# and 261 call sites converted mechanically from a frozen
|
||||||
|
# denominator artifact. Production tools source but never call
|
||||||
|
# the helpers; suite verdict semantics on rc 0/1 are UNCHANGED.
|
||||||
|
# Evidence chain in validate-973/ (microtest C1-C11, expected/
|
||||||
|
# static/trace set arithmetic, 21 forced-error arms, residual
|
||||||
|
# sweep with per-form plants).
|
||||||
component=wake
|
component=wake
|
||||||
version=0.7.1
|
version=0.7.2
|
||||||
|
|
||||||
# Watch-list schema this component consumes, and the INCLUSIVE range of
|
# Watch-list schema this component consumes, and the INCLUSIVE range of
|
||||||
# schema_version values it supports. A wake-watch-list.json whose schema_version
|
# schema_version values it supports. A wake-watch-list.json whose schema_version
|
||||||
|
|||||||
@@ -31,6 +31,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
BEACON="$SCRIPT_DIR/beacon.sh"
|
BEACON="$SCRIPT_DIR/beacon.sh"
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || {
|
command -v jq >/dev/null 2>&1 || {
|
||||||
@@ -129,7 +136,7 @@ echo "== B2: beacon ABSENCE past SLO -> alarm FIRES + ROUTES =="
|
|||||||
out="$("$BEACON" check --slo-seconds 5 2>&1)"
|
out="$("$BEACON" check --slo-seconds 5 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 1 ] || fail_msg "B2: a stale-past-SLO beacon must FIRE the absence alarm (exit 1); got $rc [$out]"
|
[ "$rc" -eq 1 ] || fail_msg "B2: a stale-past-SLO beacon must FIRE the absence alarm (exit 1); got $rc [$out]"
|
||||||
echo "$out" | grep -qi 'ALARM FIRED' || fail_msg "B2: absence must announce the alarm fired [$out]"
|
echo "$out" | has_match -qi 'ALARM FIRED' || fail_msg "B2: absence must announce the alarm fired [$out]"
|
||||||
[ -s "$ALARM_OUT" ] || fail_msg "B2: the alarm must actually ROUTE to the sink (payload not written)"
|
[ -s "$ALARM_OUT" ] || fail_msg "B2: the alarm must actually ROUTE to the sink (payload not written)"
|
||||||
jq -e '.kind == "beacon-absence-alarm"' "$ALARM_OUT" >/dev/null 2>&1 || fail_msg "B2: routed payload must be a beacon-absence-alarm [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
jq -e '.kind == "beacon-absence-alarm"' "$ALARM_OUT" >/dev/null 2>&1 || fail_msg "B2: routed payload must be a beacon-absence-alarm [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
# NEVER-received is also an absence (depends on nothing the dying host does).
|
# NEVER-received is also an absence (depends on nothing the dying host does).
|
||||||
@@ -150,13 +157,13 @@ echo "== B3: unconfigured OR unreachable ALARM target -> FAIL LOUD =="
|
|||||||
out="$("$BEACON" check --slo-seconds 5 2>&1)"
|
out="$("$BEACON" check --slo-seconds 5 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 3 ] || fail_msg "B3a: unconfigured alarm target must FAIL LOUD (exit 3); got $rc [$out]"
|
[ "$rc" -eq 3 ] || fail_msg "B3a: unconfigured alarm target must FAIL LOUD (exit 3); got $rc [$out]"
|
||||||
echo "$out" | grep -qi 'silent no-alarm host' || fail_msg "B3a: the failure must name the silent-no-alarm-host hazard [$out]"
|
echo "$out" | has_match -qi 'silent no-alarm host' || fail_msg "B3a: the failure must name the silent-no-alarm-host hazard [$out]"
|
||||||
# (b) UNREACHABLE alarm sink (non-zero exit).
|
# (b) UNREACHABLE alarm sink (non-zero exit).
|
||||||
export WAKE_ALARM_SINK_CMD="false"
|
export WAKE_ALARM_SINK_CMD="false"
|
||||||
out2="$("$BEACON" check --slo-seconds 5 2>&1)"
|
out2="$("$BEACON" check --slo-seconds 5 2>&1)"
|
||||||
rc2=$?
|
rc2=$?
|
||||||
[ "$rc2" -eq 3 ] || fail_msg "B3b: unreachable alarm target must FAIL LOUD (exit 3); got $rc2 [$out2]"
|
[ "$rc2" -eq 3 ] || fail_msg "B3b: unreachable alarm target must FAIL LOUD (exit 3); got $rc2 [$out2]"
|
||||||
echo "$out2" | grep -qi 'UNREACHABLE' || fail_msg "B3b: the failure must name the unreachable target [$out2]"
|
echo "$out2" | has_match -qi 'UNREACHABLE' || fail_msg "B3b: the failure must name the unreachable target [$out2]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== B4: isolated host -> DEGRADED different-supervision-root beacon FLAGGED =="
|
echo "== B4: isolated host -> DEGRADED different-supervision-root beacon FLAGGED =="
|
||||||
@@ -169,8 +176,8 @@ echo "== B4: isolated host -> DEGRADED different-supervision-root beacon FLAGGED
|
|||||||
rc=$?
|
rc=$?
|
||||||
err="$(cat "$TMP_ROOT/b4.err")"
|
err="$(cat "$TMP_ROOT/b4.err")"
|
||||||
[ "$rc" -eq 0 ] || fail_msg "B4: a different-supervision-root emit must still succeed (exit 0); got $rc [$out][$err]"
|
[ "$rc" -eq 0 ] || fail_msg "B4: a different-supervision-root emit must still succeed (exit 0); got $rc [$out][$err]"
|
||||||
echo "$err" | grep -qi 'DEGRADED' || fail_msg "B4: a different-supervision-root beacon must be FLAGGED degraded on stderr [$err]"
|
echo "$err" | has_match -qi 'DEGRADED' || fail_msg "B4: a different-supervision-root beacon must be FLAGGED degraded on stderr [$err]"
|
||||||
echo "$out" | grep -q 'degraded=true' || fail_msg "B4: emit must NOT silently present a degraded beacon as healthy (degraded=true expected) [$out]"
|
echo "$out" | has_match -q 'degraded=true' || fail_msg "B4: emit must NOT silently present a degraded beacon as healthy (degraded=true expected) [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== B5: same-host-sibling -> REJECTED as non-independent, seq NOT advanced =="
|
echo "== B5: same-host-sibling -> REJECTED as non-independent, seq NOT advanced =="
|
||||||
@@ -182,7 +189,7 @@ echo "== B5: same-host-sibling -> REJECTED as non-independent, seq NOT advanced
|
|||||||
out="$("$BEACON" emit 2>&1)"
|
out="$("$BEACON" emit 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "B5: a same-host-sibling beacon must be REJECTED (non-zero exit) [$out]"
|
[ "$rc" -ne 0 ] || fail_msg "B5: a same-host-sibling beacon must be REJECTED (non-zero exit) [$out]"
|
||||||
echo "$out" | grep -qi 'not an independent' || fail_msg "B5: the rejection must explain it is NOT an independent leg [$out]"
|
echo "$out" | has_match -qi 'not an independent' || fail_msg "B5: the rejection must explain it is NOT an independent leg [$out]"
|
||||||
# A rejected emit must not have minted a seq (rejection precedes counter bump).
|
# A rejected emit must not have minted a seq (rejection precedes counter bump).
|
||||||
seq_after="$("$BEACON" status 2>/dev/null | sed -n 's/^beacon_emitter_seq=//p')"
|
seq_after="$("$BEACON" status 2>/dev/null | sed -n 's/^beacon_emitter_seq=//p')"
|
||||||
[ "${seq_after:-0}" -eq 0 ] || fail_msg "B5: a rejected emit must NOT advance the monotonic seq (got $seq_after)"
|
[ "${seq_after:-0}" -eq 0 ] || fail_msg "B5: a rejected emit must NOT advance the monotonic seq (got $seq_after)"
|
||||||
@@ -201,10 +208,10 @@ echo "== B6: alarm target resolved BY NAME; beacon.sh inlines no endpoint/secret
|
|||||||
out="$("$BEACON" check --slo-seconds 5 2>&1)"
|
out="$("$BEACON" check --slo-seconds 5 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 1 ] || fail_msg "B6: absence via a by-name alarm adapter must fire (exit 1); got $rc [$out]"
|
[ "$rc" -eq 1 ] || fail_msg "B6: absence via a by-name alarm adapter must fire (exit 1); got $rc [$out]"
|
||||||
head -n1 "$ALARM_OUT" 2>/dev/null | grep -qF "$SECRET_TARGET" || fail_msg "B6: the adapter must resolve+route to the BY-NAME target [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
head -n1 "$ALARM_OUT" 2>/dev/null | has_match -qF "$SECRET_TARGET" || fail_msg "B6: the adapter must resolve+route to the BY-NAME target [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
# The framework file must NOT inline the endpoint/secret: it only knows a NAME.
|
# The framework file must NOT inline the endpoint/secret: it only knows a NAME.
|
||||||
grep -qF "$SECRET_TARGET" "$BEACON" && fail_msg "B6: beacon.sh must NOT inline the target endpoint/secret"
|
has_match -qF "$SECRET_TARGET" "$BEACON" && fail_msg "B6: beacon.sh must NOT inline the target endpoint/secret"
|
||||||
grep -qF "$SECRET_TARGET" "$WAKE_ALARM_SINK_CMD" && fail_msg "B6: even the adapter must resolve by-name, not inline the secret"
|
has_match -qF "$SECRET_TARGET" "$WAKE_ALARM_SINK_CMD" && fail_msg "B6: even the adapter must resolve by-name, not inline the secret"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -218,7 +225,7 @@ echo "== B7: unconfigured OR unreachable BEACON sink on emit -> FAIL LOUD =="
|
|||||||
out="$("$BEACON" emit 2>&1)"
|
out="$("$BEACON" emit 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 3 ] || fail_msg "B7a: unconfigured beacon sink must FAIL LOUD (exit 3); got $rc [$out]"
|
[ "$rc" -eq 3 ] || fail_msg "B7a: unconfigured beacon sink must FAIL LOUD (exit 3); got $rc [$out]"
|
||||||
echo "$out" | grep -qi 'silent no-alarm host' || fail_msg "B7a: the failure must name the silent-no-alarm-host hazard [$out]"
|
echo "$out" | has_match -qi 'silent no-alarm host' || fail_msg "B7a: the failure must name the silent-no-alarm-host hazard [$out]"
|
||||||
# A refused emit (no sink) must not have minted a seq.
|
# A refused emit (no sink) must not have minted a seq.
|
||||||
seq_after="$("$BEACON" status 2>/dev/null | sed -n 's/^beacon_emitter_seq=//p')"
|
seq_after="$("$BEACON" status 2>/dev/null | sed -n 's/^beacon_emitter_seq=//p')"
|
||||||
[ "${seq_after:-0}" -eq 0 ] || fail_msg "B7a: an unconfigured-sink emit must NOT advance the seq (got $seq_after)"
|
[ "${seq_after:-0}" -eq 0 ] || fail_msg "B7a: an unconfigured-sink emit must NOT advance the seq (got $seq_after)"
|
||||||
@@ -227,7 +234,7 @@ echo "== B7: unconfigured OR unreachable BEACON sink on emit -> FAIL LOUD =="
|
|||||||
out2="$("$BEACON" emit 2>&1)"
|
out2="$("$BEACON" emit 2>&1)"
|
||||||
rc2=$?
|
rc2=$?
|
||||||
[ "$rc2" -eq 1 ] || fail_msg "B7b: unreachable beacon sink must FAIL LOUD (exit 1); got $rc2 [$out2]"
|
[ "$rc2" -eq 1 ] || fail_msg "B7b: unreachable beacon sink must FAIL LOUD (exit 1); got $rc2 [$out2]"
|
||||||
echo "$out2" | grep -qi 'UNREACHABLE' || fail_msg "B7b: the failure must name the unreachable sink [$out2]"
|
echo "$out2" | has_match -qi 'UNREACHABLE' || fail_msg "B7b: the failure must name the unreachable sink [$out2]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== B8: no invented SLO -> check --slo-seconds is REQUIRED (fail-loud) =="
|
echo "== B8: no invented SLO -> check --slo-seconds is REQUIRED (fail-loud) =="
|
||||||
@@ -238,7 +245,7 @@ echo "== B8: no invented SLO -> check --slo-seconds is REQUIRED (fail-loud) =="
|
|||||||
out="$("$BEACON" check 2>&1)"
|
out="$("$BEACON" check 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 3 ] || fail_msg "B8: check without an SLO must fail loud (exit 3); got $rc [$out]"
|
[ "$rc" -eq 3 ] || fail_msg "B8: check without an SLO must fail loud (exit 3); got $rc [$out]"
|
||||||
echo "$out" | grep -qi 'slo' || fail_msg "B8: the usage error must name the missing SLO [$out]"
|
echo "$out" | has_match -qi 'slo' || fail_msg "B8: the usage error must name the missing SLO [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== B9: capture-pane hint is a liveness HINT ONLY (does NOT suppress absence) =="
|
echo "== B9: capture-pane hint is a liveness HINT ONLY (does NOT suppress absence) =="
|
||||||
@@ -267,7 +274,7 @@ echo "== B10: fresh beacon within SLO -> ALIVE (exit 0, no alarm) =="
|
|||||||
out="$("$BEACON" check --slo-seconds 60 2>&1)"
|
out="$("$BEACON" check --slo-seconds 60 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "B10: a fresh beacon within SLO must be ALIVE (exit 0); got $rc [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "B10: a fresh beacon within SLO must be ALIVE (exit 0); got $rc [$out]"
|
||||||
echo "$out" | grep -qi 'ALIVE' || fail_msg "B10: a fresh beacon must report ALIVE [$out]"
|
echo "$out" | has_match -qi 'ALIVE' || fail_msg "B10: a fresh beacon must report ALIVE [$out]"
|
||||||
[ ! -s "$ALARM_OUT" ] || fail_msg "B10: a fresh beacon must NOT fire an alarm"
|
[ ! -s "$ALARM_OUT" ] || fail_msg "B10: a fresh beacon must NOT fire an alarm"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -291,7 +298,7 @@ echo "== B11: staleness from monitor ingested_ts -> a far-future emit_ts STILL g
|
|||||||
jq -c --argjson ing "$((now - 100))" '.ingested_ts = $ing' "$WAKE_BEACON_RECEIVED" >"$H/tmp" && mv "$H/tmp" "$WAKE_BEACON_RECEIVED"
|
jq -c --argjson ing "$((now - 100))" '.ingested_ts = $ing' "$WAKE_BEACON_RECEIVED" >"$H/tmp" && mv "$H/tmp" "$WAKE_BEACON_RECEIVED"
|
||||||
out="$("$BEACON" check --slo-seconds 5 2>&1)"; rc=$?
|
out="$("$BEACON" check --slo-seconds 5 2>&1)"; rc=$?
|
||||||
[ "$rc" -eq 1 ] || fail_msg "B11: a far-future emit_ts must NOT defer staleness — receive-time governs (expected absence exit 1); got $rc [$out]"
|
[ "$rc" -eq 1 ] || fail_msg "B11: a far-future emit_ts must NOT defer staleness — receive-time governs (expected absence exit 1); got $rc [$out]"
|
||||||
echo "$out" | grep -qi 'ALARM FIRED' || fail_msg "B11: the receive-time-stale beacon must fire the absence alarm [$out]"
|
echo "$out" | has_match -qi 'ALARM FIRED' || fail_msg "B11: the receive-time-stale beacon must fire the absence alarm [$out]"
|
||||||
jq -e '.age_seconds >= 100' "$ALARM_OUT" >/dev/null 2>&1 || fail_msg "B11: staleness age must be measured from ingested_ts (>=100s), not emit_ts [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
jq -e '.age_seconds >= 100' "$ALARM_OUT" >/dev/null 2>&1 || fail_msg "B11: staleness age must be measured from ingested_ts (>=100s), not emit_ts [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -329,7 +336,7 @@ else
|
|||||||
jq -c '.beacon_seq = 99999 | .host_id = "attacker"' "$SHIPPED" >"$spoof"
|
jq -c '.beacon_seq = 99999 | .host_id = "attacker"' "$SHIPPED" >"$spoof"
|
||||||
out="$("$BEACON" record <"$spoof" 2>&1)"; rc=$?
|
out="$("$BEACON" record <"$spoof" 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "B12: a spoofed beacon (altered fields) must be REJECTED at record; got rc=$rc [$out]"
|
[ "$rc" -ne 0 ] || fail_msg "B12: a spoofed beacon (altered fields) must be REJECTED at record; got rc=$rc [$out]"
|
||||||
echo "$out" | grep -qi 'spoofed beacon' || fail_msg "B12: the rejection must name the spoof [$out]"
|
echo "$out" | has_match -qi 'spoofed beacon' || fail_msg "B12: the rejection must name the spoof [$out]"
|
||||||
[ ! -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "B12: a rejected spoof must NOT be stored (dead-man clock not advanced)"
|
[ ! -s "$WAKE_BEACON_RECEIVED" ] || fail_msg "B12: a rejected spoof must NOT be stored (dead-man clock not advanced)"
|
||||||
# (c) an UNSIGNED beacon is rejected when signing is configured.
|
# (c) an UNSIGNED beacon is rejected when signing is configured.
|
||||||
unsigned="$H/unsigned.json"
|
unsigned="$H/unsigned.json"
|
||||||
@@ -337,14 +344,14 @@ else
|
|||||||
out2="$("$BEACON" record <"$unsigned" 2>&1)"; rc2=$?
|
out2="$("$BEACON" record <"$unsigned" 2>&1)"; rc2=$?
|
||||||
[ "$rc2" -ne 0 ] || fail_msg "B12: an unsigned beacon must be REJECTED when signing is configured; got rc=$rc2 [$out2]"
|
[ "$rc2" -ne 0 ] || fail_msg "B12: an unsigned beacon must be REJECTED when signing is configured; got rc=$rc2 [$out2]"
|
||||||
# beacon.sh must not inline the key material.
|
# beacon.sh must not inline the key material.
|
||||||
grep -qF "test-beacon-hmac-key-do-not-echo" "$BEACON" && fail_msg "B12: beacon.sh must NOT inline the HMAC key"
|
has_match -qF "test-beacon-hmac-key-do-not-echo" "$BEACON" && fail_msg "B12: beacon.sh must NOT inline the HMAC key"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake beacon harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake beacon harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake beacon harness: all invariants passed ($pass groups)"
|
echo "wake beacon harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
DET="$SCRIPT_DIR/detector.sh"
|
DET="$SCRIPT_DIR/detector.sh"
|
||||||
STORE="$SCRIPT_DIR/store.sh"
|
STORE="$SCRIPT_DIR/store.sh"
|
||||||
|
|
||||||
@@ -253,8 +260,8 @@ echo "== D5: watch-list schema_version out of manifest range -> FAIL LOUD =="
|
|||||||
err="$("$DET" poll-once 2>&1)"
|
err="$("$DET" poll-once 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "D5: an out-of-range schema_version must FAIL LOUD (non-zero exit)"
|
[ "$rc" -ne 0 ] || fail_msg "D5: an out-of-range schema_version must FAIL LOUD (non-zero exit)"
|
||||||
echo "$err" | grep -qi 'schema_version' || fail_msg "D5: the failure must name schema_version [$err]"
|
echo "$err" | has_match -qi 'schema_version' || fail_msg "D5: the failure must name schema_version [$err]"
|
||||||
echo "$err" | grep -qi 'range' || fail_msg "D5: the failure must state it is out of the supported range [$err]"
|
echo "$err" | has_match -qi 'range' || fail_msg "D5: the failure must state it is out of the supported range [$err]"
|
||||||
# And nothing was enqueued / no cursor advance under a rejected watch-list.
|
# And nothing was enqueued / no cursor advance under a rejected watch-list.
|
||||||
[ "$(depth)" = "0" ] || fail_msg "D5: a rejected watch-list must not enqueue, got depth $(depth)"
|
[ "$(depth)" = "0" ] || fail_msg "D5: a rejected watch-list must not enqueue, got depth $(depth)"
|
||||||
[ "$(det_seq)" = "0" ] || fail_msg "D5: a rejected watch-list must not advance observed_seq, got $(det_seq)"
|
[ "$(det_seq)" = "0" ] || fail_msg "D5: a rejected watch-list must not advance observed_seq, got $(det_seq)"
|
||||||
@@ -287,7 +294,7 @@ echo "== D6: source error / ambiguous-empty -> FAIL LOUD, observed_seq NOT advan
|
|||||||
err="$("$DET" poll-once 2>&1)"
|
err="$("$DET" poll-once 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "D6: a source error must FAIL LOUD (non-zero exit)"
|
[ "$rc" -ne 0 ] || fail_msg "D6: a source error must FAIL LOUD (non-zero exit)"
|
||||||
echo "$err" | grep -qi 'FAIL LOUD' || fail_msg "D6: the source error must be loud [$err]"
|
echo "$err" | has_match -qi 'FAIL LOUD' || fail_msg "D6: the source error must be loud [$err]"
|
||||||
[ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: a source error must NOT advance observed_seq (got $(det_seq), was $seq_before)"
|
[ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: a source error must NOT advance observed_seq (got $(det_seq), was $seq_before)"
|
||||||
[ "$(depth)" = "0" ] || fail_msg "D6: a source error must NOT enqueue, got depth $(depth)"
|
[ "$(depth)" = "0" ] || fail_msg "D6: a source error must NOT enqueue, got depth $(depth)"
|
||||||
|
|
||||||
@@ -298,7 +305,7 @@ echo "== D6: source error / ambiguous-empty -> FAIL LOUD, observed_seq NOT advan
|
|||||||
err="$("$DET" poll-once 2>&1)"
|
err="$("$DET" poll-once 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "D6: an ambiguous-empty response must FAIL LOUD (non-zero exit)"
|
[ "$rc" -ne 0 ] || fail_msg "D6: an ambiguous-empty response must FAIL LOUD (non-zero exit)"
|
||||||
echo "$err" | grep -qi 'AMBIGUOUS-EMPTY' || fail_msg "D6: ambiguous-empty must be named in the loud failure [$err]"
|
echo "$err" | has_match -qi 'AMBIGUOUS-EMPTY' || fail_msg "D6: ambiguous-empty must be named in the loud failure [$err]"
|
||||||
[ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: ambiguous-empty must NOT advance observed_seq (got $(det_seq))"
|
[ "$(det_seq)" = "$seq_before" ] || fail_msg "D6: ambiguous-empty must NOT advance observed_seq (got $(det_seq))"
|
||||||
[ "$(depth)" = "0" ] || fail_msg "D6: ambiguous-empty must NOT enqueue, got depth $(depth)"
|
[ "$(depth)" = "0" ] || fail_msg "D6: ambiguous-empty must NOT enqueue, got depth $(depth)"
|
||||||
|
|
||||||
@@ -442,7 +449,7 @@ EOF
|
|||||||
write_fc_watchlist "$wl" 999
|
write_fc_watchlist "$wl" 999
|
||||||
err="$("$DET" poll-once 2>&1)"; rc=$?
|
err="$("$DET" poll-once 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "D9: the new field must NOT weaken Gate B — an out-of-range schema_version must still FAIL LOUD (rc=$rc)"
|
[ "$rc" -ne 0 ] || fail_msg "D9: the new field must NOT weaken Gate B — an out-of-range schema_version must still FAIL LOUD (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'range' || fail_msg "D9: the out-of-range failure must still state it is out of the supported range [$err]"
|
echo "$err" | has_match -qi 'range' || fail_msg "D9: the out-of-range failure must still state it is out of the supported range [$err]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== D10: snapshot metadata (fd 3, #940) — adapter-attested sha/ts land in the enqueued locators =="
|
echo "== D10: snapshot metadata (fd 3, #940) — adapter-attested sha/ts land in the enqueued locators =="
|
||||||
@@ -513,7 +520,7 @@ EOF
|
|||||||
printf 'SHA-BBB\n' >"$stub/repo_r1"
|
printf 'SHA-BBB\n' >"$stub/repo_r1"
|
||||||
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D11: malformed metadata must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D11: malformed metadata must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'snapshot' || fail_msg "D11: dropping malformed metadata must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'snapshot' || fail_msg "D11: dropping malformed metadata must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
&& fail_msg "D11: malformed metadata must emit NO snapshot fields [$entry]"
|
&& fail_msg "D11: malformed metadata must emit NO snapshot fields [$entry]"
|
||||||
@@ -522,7 +529,7 @@ EOF
|
|||||||
printf 'SHA-CCC\n' >"$stub/repo_r1"
|
printf 'SHA-CCC\n' >"$stub/repo_r1"
|
||||||
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D11: rejected snapshot_sha must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D11: rejected snapshot_sha must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'snapshot' || fail_msg "D11: rejecting a bad snapshot_sha must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'snapshot' || fail_msg "D11: rejecting a bad snapshot_sha must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
&& fail_msg "D11: rejected metadata must emit NO snapshot fields [$entry]"
|
&& fail_msg "D11: rejected metadata must emit NO snapshot fields [$entry]"
|
||||||
@@ -560,7 +567,7 @@ EOF
|
|||||||
printf 'SHA-BBB\n' >"$stub/repo_r1"
|
printf 'SHA-BBB\n' >"$stub/repo_r1"
|
||||||
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D12: ts-without-sha must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D12: ts-without-sha must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'without a valid snapshot_sha' || fail_msg "D12: dropping ts-without-sha must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'without a valid snapshot_sha' || fail_msg "D12: dropping ts-without-sha must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e '.locators | has("snapshot_sha") or has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
&& fail_msg "D12: ts-without-sha must emit NO snapshot fields [$entry]"
|
&& fail_msg "D12: ts-without-sha must emit NO snapshot fields [$entry]"
|
||||||
@@ -571,7 +578,7 @@ EOF
|
|||||||
printf 'SHA-CCC\n' >"$stub/repo_r1"
|
printf 'SHA-CCC\n' >"$stub/repo_r1"
|
||||||
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D12: future ts must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D12: future ts must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'future-skew' || fail_msg "D12: dropping a future ts must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'future-skew' || fail_msg "D12: dropping a future ts must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D12: future ts must drop ts but KEEP the sha [$entry]"
|
|| fail_msg "D12: future ts must drop ts but KEEP the sha [$entry]"
|
||||||
@@ -581,7 +588,7 @@ EOF
|
|||||||
printf 'SHA-DDD\n' >"$stub/repo_r1"
|
printf 'SHA-DDD\n' >"$stub/repo_r1"
|
||||||
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$("$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D12: absurd ts must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D12: absurd ts must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'sane positive epoch' || fail_msg "D12: rejecting an absurd ts must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'sane positive epoch' || fail_msg "D12: rejecting an absurd ts must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D12: absurd ts must drop ts but KEEP the sha [$entry]"
|
|| fail_msg "D12: absurd ts must drop ts but KEEP the sha [$entry]"
|
||||||
@@ -621,7 +628,7 @@ EOF
|
|||||||
printf 'SHA-BBB\n' >"$stub/repo_r1"
|
printf 'SHA-BBB\n' >"$stub/repo_r1"
|
||||||
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='300s' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='300s' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D13: SLACK='300s' must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D13: SLACK='300s' must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'falling back to 300' || fail_msg "D13: malformed slack must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: malformed slack must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D13: valid metadata must SURVIVE a malformed knob (fallback, not drop) [$entry]"
|
|| fail_msg "D13: valid metadata must SURVIVE a malformed knob (fallback, not drop) [$entry]"
|
||||||
@@ -630,7 +637,7 @@ EOF
|
|||||||
printf 'SHA-CCC\n' >"$stub/repo_r1"
|
printf 'SHA-CCC\n' >"$stub/repo_r1"
|
||||||
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='abc' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='abc' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D13: SLACK='abc' must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D13: SLACK='abc' must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'falling back to 300' || fail_msg "D13: non-numeric slack must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: non-numeric slack must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D13: valid metadata must SURVIVE a non-numeric knob [$entry]"
|
|| fail_msg "D13: valid metadata must SURVIVE a non-numeric knob [$entry]"
|
||||||
@@ -640,7 +647,7 @@ EOF
|
|||||||
printf 'SHA-DDD\n' >"$stub/repo_r1"
|
printf 'SHA-DDD\n' >"$stub/repo_r1"
|
||||||
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='-99999999' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='-99999999' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D13: negative SLACK must NEVER fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D13: negative SLACK must NEVER fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'falling back to 300' || fail_msg "D13: negative slack must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: negative slack must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D13: negative slack must NOT invert the guard into deny-all [$entry]"
|
|| fail_msg "D13: negative slack must NOT invert the guard into deny-all [$entry]"
|
||||||
@@ -652,7 +659,7 @@ EOF
|
|||||||
printf 'SHA-CC2\n' >"$stub/repo_r1"
|
printf 'SHA-CC2\n' >"$stub/repo_r1"
|
||||||
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK=$'300\n8' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK=$'300\n8' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D13: multi-line SLACK (300\\n8) must NEVER fail the poll (rc=$rc) [$err]"
|
[ "$rc" -eq 0 ] || fail_msg "D13: multi-line SLACK (300\\n8) must NEVER fail the poll (rc=$rc) [$err]"
|
||||||
echo "$err" | grep -qi 'falling back to 300' || fail_msg "D13: multi-line slack must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: multi-line slack must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D13: valid metadata must SURVIVE a multi-line knob [$entry]"
|
|| fail_msg "D13: valid metadata must SURVIVE a multi-line knob [$entry]"
|
||||||
@@ -660,7 +667,7 @@ EOF
|
|||||||
printf 'SHA-CC3\n' >"$stub/repo_r1"
|
printf 'SHA-CC3\n' >"$stub/repo_r1"
|
||||||
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK=$'300\nabc' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK=$'300\nabc' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D13: multi-line SLACK (300\\nabc) must NEVER fail the poll (rc=$rc) [$err]"
|
[ "$rc" -eq 0 ] || fail_msg "D13: multi-line SLACK (300\\nabc) must NEVER fail the poll (rc=$rc) [$err]"
|
||||||
echo "$err" | grep -qi 'falling back to 300' || fail_msg "D13: multi-line non-numeric slack must be LOUD on stderr [$err]"
|
echo "$err" | has_match -qi 'falling back to 300' || fail_msg "D13: multi-line non-numeric slack must be LOUD on stderr [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts")' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D13: valid metadata must SURVIVE a multi-line non-numeric knob [$entry]"
|
|| fail_msg "D13: valid metadata must SURVIVE a multi-line non-numeric knob [$entry]"
|
||||||
@@ -687,7 +694,7 @@ EOF
|
|||||||
printf 'SHA-EEE\n' >"$stub/repo_r1"
|
printf 'SHA-EEE\n' >"$stub/repo_r1"
|
||||||
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='0' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
err="$(WAKE_SNAPSHOT_TS_FUTURE_SLACK='0' "$DET" poll-once 2>&1 >/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D13: valid SLACK=0 must not fail the poll (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "D13: valid SLACK=0 must not fail the poll (rc=$rc)"
|
||||||
echo "$err" | grep -qi 'future-skew' || fail_msg "D13: a valid tightened slack must still reject a future ts [$err]"
|
echo "$err" | has_match -qi 'future-skew' || fail_msg "D13: a valid tightened slack must still reject a future ts [$err]"
|
||||||
entry="$("$STORE" drain | tail -1)"
|
entry="$("$STORE" drain | tail -1)"
|
||||||
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
|
echo "$entry" | jq -e --arg s "$goodsha" 'select(.locators.snapshot_sha==$s) | .locators | has("snapshot_ts") | not' >/dev/null 2>&1 \
|
||||||
|| fail_msg "D13: valid SLACK=0 must drop the future ts but keep the sha [$entry]"
|
|| fail_msg "D13: valid SLACK=0 must drop the future ts but keep the sha [$entry]"
|
||||||
@@ -696,7 +703,7 @@ EOF
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake detector harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake detector harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake detector harness: all invariants passed ($pass groups)"
|
echo "wake detector harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -35,6 +35,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
STORE="$SCRIPT_DIR/store.sh"
|
STORE="$SCRIPT_DIR/store.sh"
|
||||||
DIGEST="$SCRIPT_DIR/digest.sh"
|
DIGEST="$SCRIPT_DIR/digest.sh"
|
||||||
SIGN="$SCRIPT_DIR/sign.sh"
|
SIGN="$SCRIPT_DIR/sign.sh"
|
||||||
@@ -102,13 +109,13 @@ echo "== D1: CUMULATIVE-STATE — two pending changes BOTH render (not just late
|
|||||||
"$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":11}' >/dev/null
|
"$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":11}' >/dev/null
|
||||||
"$STORE" enqueue --seq 2 --class actionable --locators '{"repo":"r","issue":22}' >/dev/null
|
"$STORE" enqueue --seq 2 --class actionable --locators '{"repo":"r","issue":22}' >/dev/null
|
||||||
out="$("$DIGEST" render)" || fail_msg "D1: render exited non-zero"
|
out="$("$DIGEST" render)" || fail_msg "D1: render exited non-zero"
|
||||||
echo "$out" | grep -q 'issue=#11' || fail_msg "D1: OLDER change (issue #11) dropped — digest is a delta, not cumulative state"
|
echo "$out" | has_match -q 'issue=#11' || fail_msg "D1: OLDER change (issue #11) dropped — digest is a delta, not cumulative state"
|
||||||
echo "$out" | grep -q 'issue=#22' || fail_msg "D1: newer change (issue #22) missing"
|
echo "$out" | has_match -q 'issue=#22' || fail_msg "D1: newer change (issue #22) missing"
|
||||||
echo "$out" | grep -q 'seq 1' || fail_msg "D1: seq 1 not listed in cumulative set"
|
echo "$out" | has_match -q 'seq 1' || fail_msg "D1: seq 1 not listed in cumulative set"
|
||||||
echo "$out" | grep -q 'seq 2' || fail_msg "D1: seq 2 not listed in cumulative set"
|
echo "$out" | has_match -q 'seq 2' || fail_msg "D1: seq 2 not listed in cumulative set"
|
||||||
# A coalescing digest-class entry is STATE (full), not a delta: a later digest
|
# A coalescing digest-class entry is STATE (full), not a delta: a later digest
|
||||||
# subsumes the earlier, but the cumulative unacked set (both actionables) stays.
|
# subsumes the earlier, but the cumulative unacked set (both actionables) stays.
|
||||||
echo "$out" | grep -q 'pending=2' || fail_msg "D1: cumulative pending count wrong (expected 2)"
|
echo "$out" | has_match -q 'pending=2' || fail_msg "D1: cumulative pending count wrong (expected 2)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== D2: HARD-LOCATOR enforcement — a malformed actionable claim is QUARANTINED (fail-loud PER-ENTRY, #920); never delivered as valid =="
|
echo "== D2: HARD-LOCATOR enforcement — a malformed actionable claim is QUARANTINED (fail-loud PER-ENTRY, #920); never delivered as valid =="
|
||||||
@@ -127,9 +134,9 @@ echo "== D2: HARD-LOCATOR enforcement — a malformed actionable claim is QUARAN
|
|||||||
# but the unlocated claim is NEVER delivered as a valid CLAIM (fail-loud is
|
# but the unlocated claim is NEVER delivered as a valid CLAIM (fail-loud is
|
||||||
# preserved, now per-entry). The old exit-4 wedged the entire drain (#920).
|
# preserved, now per-entry). The old exit-4 wedged the entire drain (#920).
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D2: a malformed actionable must be quarantined (render exit 0, #920), got $rc"
|
[ "$rc" -eq 0 ] || fail_msg "D2: a malformed actionable must be quarantined (render exit 0, #920), got $rc"
|
||||||
printf '%s' "$out" | grep -q 'mergeable=true' && fail_msg "D2: an unlocated actionable claim must NOT be delivered as a valid CLAIM"
|
printf '%s' "$out" | has_match -q 'mergeable=true' && fail_msg "D2: an unlocated actionable claim must NOT be delivered as a valid CLAIM"
|
||||||
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2: the malformed claim must be DEAD-LETTERED (fail-loud preserved, per-entry)"
|
has_match -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2: the malformed claim must be DEAD-LETTERED (fail-loud preserved, per-entry)"
|
||||||
grep -qi 'QUARANTINE' "$err" || fail_msg "D2: a LOUD per-entry alarm must fire for the malformed claim"
|
has_match -qi 'QUARANTINE' "$err" || fail_msg "D2: a LOUD per-entry alarm must fire for the malformed claim"
|
||||||
|
|
||||||
# A present-but-imprecise sha (not 40 hex) does NOT satisfy the hard locator ->
|
# A present-but-imprecise sha (not 40 hex) does NOT satisfy the hard locator ->
|
||||||
# quarantined, not delivered.
|
# quarantined, not delivered.
|
||||||
@@ -140,8 +147,8 @@ echo "== D2: HARD-LOCATOR enforcement — a malformed actionable claim is QUARAN
|
|||||||
rc=0
|
rc=0
|
||||||
out="$("$DIGEST" render 2>/dev/null)" || rc=$?
|
out="$("$DIGEST" render 2>/dev/null)" || rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D2: imprecise-sha entry must be quarantined (render exit 0), got $rc"
|
[ "$rc" -eq 0 ] || fail_msg "D2: imprecise-sha entry must be quarantined (render exit 0), got $rc"
|
||||||
printf '%s' "$out" | grep -q 'ci=green' && fail_msg "D2: imprecise sha (not 40-hex) must not satisfy the hard-locator gate (claim must not deliver)"
|
printf '%s' "$out" | has_match -q 'ci=green' && fail_msg "D2: imprecise sha (not 40-hex) must not satisfy the hard-locator gate (claim must not deliver)"
|
||||||
grep -q 'ci=green' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2: imprecise-sha claim must be dead-lettered"
|
has_match -q 'ci=green' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2: imprecise-sha claim must be dead-lettered"
|
||||||
|
|
||||||
# The SAME claim WITH a precise 40-hex sha renders fine (delivered, not quarantined).
|
# The SAME claim WITH a precise 40-hex sha renders fine (delivered, not quarantined).
|
||||||
h="$(fresh_state d2c)"
|
h="$(fresh_state d2c)"
|
||||||
@@ -149,7 +156,7 @@ echo "== D2: HARD-LOCATOR enforcement — a malformed actionable claim is QUARAN
|
|||||||
export WAKE_STATE_HOME
|
export WAKE_STATE_HOME
|
||||||
"$STORE" enqueue --seq 1 --class actionable --locators "$(jq -cn --arg s "$SHA40" '{claim:"ci=green",sha:$s}')" >/dev/null
|
"$STORE" enqueue --seq 1 --class actionable --locators "$(jq -cn --arg s "$SHA40" '{claim:"ci=green",sha:$s}')" >/dev/null
|
||||||
out="$("$DIGEST" render 2>/dev/null)" || fail_msg "D2: a well-located actionable claim must render"
|
out="$("$DIGEST" render 2>/dev/null)" || fail_msg "D2: a well-located actionable claim must render"
|
||||||
printf '%s' "$out" | grep -q "$SHA40" || fail_msg "D2: a well-located actionable claim must be DELIVERED"
|
printf '%s' "$out" | has_match -q "$SHA40" || fail_msg "D2: a well-located actionable claim must be DELIVERED"
|
||||||
[ -s "$h/default/dead-letter.jsonl" ] && fail_msg "D2: a well-located claim must NOT be quarantined"
|
[ -s "$h/default/dead-letter.jsonl" ] && fail_msg "D2: a well-located claim must NOT be quarantined"
|
||||||
# --- #905 bypass-prevention (STILL enforced, now via quarantine): a NON-
|
# --- #905 bypass-prevention (STILL enforced, now via quarantine): a NON-
|
||||||
# CANONICAL entry with a TOP-LEVEL `.claim` (store.sh never emits this shape;
|
# CANONICAL entry with a TOP-LEVEL `.claim` (store.sh never emits this shape;
|
||||||
@@ -164,8 +171,8 @@ echo "== D2: HARD-LOCATOR enforcement — a malformed actionable claim is QUARAN
|
|||||||
rc=0
|
rc=0
|
||||||
out="$(printf '%s\n' "$toplevel_claim_entry" | "$DIGEST" render --stdin 2>/dev/null)" || rc=$?
|
out="$(printf '%s\n' "$toplevel_claim_entry" | "$DIGEST" render --stdin 2>/dev/null)" || rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D2(#905): top-level .claim must be quarantined via --stdin (exit 0), got $rc"
|
[ "$rc" -eq 0 ] || fail_msg "D2(#905): top-level .claim must be quarantined via --stdin (exit 0), got $rc"
|
||||||
printf '%s' "$out" | grep -q 'mergeable=true' && fail_msg "D2(#905): --stdin top-level-.claim must NOT be delivered (gate not bypassed)"
|
printf '%s' "$out" | has_match -q 'mergeable=true' && fail_msg "D2(#905): --stdin top-level-.claim must NOT be delivered (gate not bypassed)"
|
||||||
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2(#905): --stdin top-level .claim must be dead-lettered (gate enforced)"
|
has_match -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2(#905): --stdin top-level .claim must be dead-lettered (gate enforced)"
|
||||||
ff="$TMP_ROOT/d2d-entry.jsonl"
|
ff="$TMP_ROOT/d2d-entry.jsonl"
|
||||||
printf '%s\n' "$toplevel_claim_entry" >"$ff"
|
printf '%s\n' "$toplevel_claim_entry" >"$ff"
|
||||||
h="$(fresh_state d2e)"
|
h="$(fresh_state d2e)"
|
||||||
@@ -174,8 +181,8 @@ echo "== D2: HARD-LOCATOR enforcement — a malformed actionable claim is QUARAN
|
|||||||
rc=0
|
rc=0
|
||||||
out2="$("$DIGEST" render --from-file "$ff" 2>/dev/null)" || rc=$?
|
out2="$("$DIGEST" render --from-file "$ff" 2>/dev/null)" || rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D2(#905): top-level .claim must be quarantined via --from-file (exit 0), got $rc"
|
[ "$rc" -eq 0 ] || fail_msg "D2(#905): top-level .claim must be quarantined via --from-file (exit 0), got $rc"
|
||||||
printf '%s' "$out2" | grep -q 'mergeable=true' && fail_msg "D2(#905): --from-file top-level-.claim must NOT be delivered (gate not bypassed)"
|
printf '%s' "$out2" | has_match -q 'mergeable=true' && fail_msg "D2(#905): --from-file top-level-.claim must NOT be delivered (gate not bypassed)"
|
||||||
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2(#905): --from-file top-level .claim must be dead-lettered (gate enforced)"
|
has_match -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D2(#905): --from-file top-level .claim must be dead-lettered (gate enforced)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== D3: TWO-TIER — orientation no-op with ZERO tool calls; actionable = claim-to-verify =="
|
echo "== D3: TWO-TIER — orientation no-op with ZERO tool calls; actionable = claim-to-verify =="
|
||||||
@@ -197,7 +204,7 @@ echo "== D3: TWO-TIER — orientation no-op with ZERO tool calls; actionable = c
|
|||||||
done
|
done
|
||||||
# No pending obligations => no-op common case.
|
# No pending obligations => no-op common case.
|
||||||
out="$(PATH="$bin:$PATH" "$DIGEST" render --lane build)" || fail_msg "D3: no-op render failed"
|
out="$(PATH="$bin:$PATH" "$DIGEST" render --lane build)" || fail_msg "D3: no-op render failed"
|
||||||
echo "$out" | grep -q 'NO-OP' || fail_msg "D3: empty inbox must render an explicit NO-OP orientation"
|
echo "$out" | has_match -q 'NO-OP' || fail_msg "D3: empty inbox must render an explicit NO-OP orientation"
|
||||||
[ -e "$WAKE_STATE_HOME/TOOL_CALLED" ] && fail_msg "D3: orientation no-op made a live tool call (must be ZERO)"
|
[ -e "$WAKE_STATE_HOME/TOOL_CALLED" ] && fail_msg "D3: orientation no-op made a live tool call (must be ZERO)"
|
||||||
# Now an actionable, consequential fact. It must be a CLAIM-TO-VERIFY, never
|
# Now an actionable, consequential fact. It must be a CLAIM-TO-VERIFY, never
|
||||||
# rendered as a trusted assertion or an auto-action.
|
# rendered as a trusted assertion or an auto-action.
|
||||||
@@ -206,14 +213,14 @@ echo "== D3: TWO-TIER — orientation no-op with ZERO tool calls; actionable = c
|
|||||||
out2="$(PATH="$bin:$PATH" "$DIGEST" render)" || fail_msg "D3: actionable render failed"
|
out2="$(PATH="$bin:$PATH" "$DIGEST" render)" || fail_msg "D3: actionable render failed"
|
||||||
# Rendering itself still makes ZERO live calls (it only lays out claims).
|
# Rendering itself still makes ZERO live calls (it only lays out claims).
|
||||||
[ -e "$WAKE_STATE_HOME/TOOL_CALLED" ] && fail_msg "D3: rendering an actionable claim made a live call (must defer to the consumer's gate)"
|
[ -e "$WAKE_STATE_HOME/TOOL_CALLED" ] && fail_msg "D3: rendering an actionable claim made a live call (must defer to the consumer's gate)"
|
||||||
echo "$out2" | grep -q 'CLAIM@seq' || fail_msg "D3: consequential fact not framed as CLAIM@seq"
|
echo "$out2" | has_match -q 'CLAIM@seq' || fail_msg "D3: consequential fact not framed as CLAIM@seq"
|
||||||
echo "$out2" | grep -qi 'VERIFY LIVE' || fail_msg "D3: claim not marked for live verification"
|
echo "$out2" | has_match -qi 'VERIFY LIVE' || fail_msg "D3: claim not marked for live verification"
|
||||||
echo "$out2" | grep -qi 'do NOT act on this line' || fail_msg "D3: claim missing do-not-auto-action framing"
|
echo "$out2" | has_match -qi 'do NOT act on this line' || fail_msg "D3: claim missing do-not-auto-action framing"
|
||||||
# The consequential fact must NOT appear as a bare trusted directive.
|
# The consequential fact must NOT appear as a bare trusted directive.
|
||||||
echo "$out2" | grep -qiE 'merge now|go ahead and (merge|deploy)|safe to merge' &&
|
echo "$out2" | has_match -qiE 'merge now|go ahead and (merge|deploy)|safe to merge' &&
|
||||||
fail_msg "D3: digest auto-actioned a consequential fact (imperative present)"
|
fail_msg "D3: digest auto-actioned a consequential fact (imperative present)"
|
||||||
# And its hard locator + one-call re-verify hint are present.
|
# And its hard locator + one-call re-verify hint are present.
|
||||||
echo "$out2" | grep -q "re-verify (ONE call)" || fail_msg "D3: actionable claim missing one-call re-verify locator"
|
echo "$out2" | has_match -q "re-verify (ONE call)" || fail_msg "D3: actionable claim missing one-call re-verify locator"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -229,7 +236,7 @@ echo "== D4: SCRUB — secret-canary + ANSI/bidi/zero-width in source free-text
|
|||||||
"$STORE" enqueue --seq 1 --class actionable --locators "$loc" >/dev/null
|
"$STORE" enqueue --seq 1 --class actionable --locators "$loc" >/dev/null
|
||||||
out="$("$DIGEST" render)" || fail_msg "D4: render failed"
|
out="$("$DIGEST" render)" || fail_msg "D4: render failed"
|
||||||
# ANSI escape / CSI must be gone.
|
# ANSI escape / CSI must be gone.
|
||||||
printf '%s' "$out" | LC_ALL=C grep -q "$(printf '\x1b')" && fail_msg "D4: ANSI ESC survived the scrub"
|
printf '%s' "$out" | LC_ALL=C has_match -q "$(printf '\x1b')" && fail_msg "D4: ANSI ESC survived the scrub"
|
||||||
# bidi/zero-width/BOM UTF-8 sequences must be gone.
|
# bidi/zero-width/BOM UTF-8 sequences must be gone.
|
||||||
# #912: patterns are LITERAL bytes + `grep -E`, NOT PCRE `grep -P`. BusyBox
|
# #912: patterns are LITERAL bytes + `grep -E`, NOT PCRE `grep -P`. BusyBox
|
||||||
# grep (Alpine/musl CI) has no `-P` — a `grep -qP` there errors
|
# grep (Alpine/musl CI) has no `-P` — a `grep -qP` there errors
|
||||||
@@ -244,21 +251,21 @@ echo "== D4: SCRUB — secret-canary + ANSI/bidi/zero-width in source free-text
|
|||||||
_b8b="$(printf '%b' '\x8b')"; _b8f="$(printf '%b' '\x8f')"
|
_b8b="$(printf '%b' '\x8b')"; _b8f="$(printf '%b' '\x8f')"
|
||||||
_baa="$(printf '%b' '\xaa')"; _bae="$(printf '%b' '\xae')"
|
_baa="$(printf '%b' '\xaa')"; _bae="$(printf '%b' '\xae')"
|
||||||
_bbom="$(printf '%b' '\xef\xbb\xbf')"
|
_bbom="$(printf '%b' '\xef\xbb\xbf')"
|
||||||
printf '%s' "$out" | LC_ALL=C grep -qE "${_b280}[${_b8b}-${_b8f}${_baa}-${_bae}]|${_bbom}" &&
|
printf '%s' "$out" | LC_ALL=C has_match -qE "${_b280}[${_b8b}-${_b8f}${_baa}-${_bae}]|${_bbom}" &&
|
||||||
fail_msg "D4: bidi/zero-width/BOM survived the scrub"
|
fail_msg "D4: bidi/zero-width/BOM survived the scrub"
|
||||||
# C0 control bytes (except tab/newline) must be gone.
|
# C0 control bytes (except tab/newline) must be gone.
|
||||||
_c00="$(printf '%b' '\x01')"; _c08="$(printf '%b' '\x08')"
|
_c00="$(printf '%b' '\x01')"; _c08="$(printf '%b' '\x08')"
|
||||||
_c0e="$(printf '%b' '\x0e')"; _c1f="$(printf '%b' '\x1f')"; _c7f="$(printf '%b' '\x7f')"
|
_c0e="$(printf '%b' '\x0e')"; _c1f="$(printf '%b' '\x1f')"; _c7f="$(printf '%b' '\x7f')"
|
||||||
printf '%s' "$out" | LC_ALL=C grep -qE "[${_c00}-${_c08}${_c0e}-${_c1f}${_c7f}]" &&
|
printf '%s' "$out" | LC_ALL=C has_match -qE "[${_c00}-${_c08}${_c0e}-${_c1f}${_c7f}]" &&
|
||||||
fail_msg "D4: a C0 control byte survived the scrub"
|
fail_msg "D4: a C0 control byte survived the scrub"
|
||||||
# Secret canaries must be redacted, never inlined.
|
# Secret canaries must be redacted, never inlined.
|
||||||
printf '%s' "$out" | grep -q 'ghp_0123456789' && fail_msg "D4: GitHub-token canary LEAKED into the digest"
|
printf '%s' "$out" | has_match -q 'ghp_0123456789' && fail_msg "D4: GitHub-token canary LEAKED into the digest"
|
||||||
printf '%s' "$out" | grep -q 'AKIAIOSFODNN7EXAMPLE' && fail_msg "D4: AWS-key canary LEAKED into the digest"
|
printf '%s' "$out" | has_match -q 'AKIAIOSFODNN7EXAMPLE' && fail_msg "D4: AWS-key canary LEAKED into the digest"
|
||||||
printf '%s' "$out" | grep -q 'REDACTED-SECRET' || fail_msg "D4: secret redaction marker absent — canary may not have been scrubbed"
|
printf '%s' "$out" | has_match -q 'REDACTED-SECRET' || fail_msg "D4: secret redaction marker absent — canary may not have been scrubbed"
|
||||||
# Free-text is quoted inside a DELIMITED untrusted block, framed as NOT instructions.
|
# Free-text is quoted inside a DELIMITED untrusted block, framed as NOT instructions.
|
||||||
printf '%s' "$out" | grep -q 'BEGIN UNTRUSTED DATA' || fail_msg "D4: source free-text not placed in a delimited untrusted block"
|
printf '%s' "$out" | has_match -q 'BEGIN UNTRUSTED DATA' || fail_msg "D4: source free-text not placed in a delimited untrusted block"
|
||||||
# The 40-hex git SHA locator must NOT be mangled by the secret scrubber.
|
# The 40-hex git SHA locator must NOT be mangled by the secret scrubber.
|
||||||
printf '%s' "$out" | grep -q "$SHA40" || fail_msg "D4: legitimate 40-hex SHA locator was wrongly scrubbed"
|
printf '%s' "$out" | has_match -q "$SHA40" || fail_msg "D4: legitimate 40-hex SHA locator was wrongly scrubbed"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -323,11 +330,11 @@ echo "== H2: KEY BY-NAME, never inline; no key leak; same-uid boundary documente
|
|||||||
fail_msg "H2: by-name key resolution failed"
|
fail_msg "H2: by-name key resolution failed"
|
||||||
echo "$env1" | jq -e .wake_mac >/dev/null || fail_msg "H2: no MAC produced from by-name key"
|
echo "$env1" | jq -e .wake_mac >/dev/null || fail_msg "H2: no MAC produced from by-name key"
|
||||||
# The key VALUE must never appear in the signed output.
|
# The key VALUE must never appear in the signed output.
|
||||||
echo "$env1" | grep -q 'SUPERSECRET-KEYVALUE-XYZ' && fail_msg "H2: key material LEAKED into the signed envelope"
|
echo "$env1" | has_match -q 'SUPERSECRET-KEYVALUE-XYZ' && fail_msg "H2: key material LEAKED into the signed envelope"
|
||||||
# A signed store-entry (hmac placeholder filled) must not leak the key either.
|
# A signed store-entry (hmac placeholder filled) must not leak the key either.
|
||||||
entry="$(printf '{"observed_seq":1,"locators":{"repo":"r","issue":1},"class":"actionable","emit_ts":1700000000,"hmac":""}' |
|
entry="$(printf '{"observed_seq":1,"locators":{"repo":"r","issue":1},"class":"actionable","emit_ts":1700000000,"hmac":""}' |
|
||||||
WAKE_HMAC_KEY_NAME=signing-key "$SIGN" sign-entry)"
|
WAKE_HMAC_KEY_NAME=signing-key "$SIGN" sign-entry)"
|
||||||
echo "$entry" | grep -q 'SUPERSECRET-KEYVALUE-XYZ' && fail_msg "H2: key material LEAKED into the signed entry"
|
echo "$entry" | has_match -q 'SUPERSECRET-KEYVALUE-XYZ' && fail_msg "H2: key material LEAKED into the signed entry"
|
||||||
echo "$entry" | jq -e '.hmac != "" and .hmac != null' >/dev/null || fail_msg "H2: sign-entry did not fill the hmac placeholder"
|
echo "$entry" | jq -e '.hmac != "" and .hmac != null' >/dev/null || fail_msg "H2: sign-entry did not fill the hmac placeholder"
|
||||||
# No flag may accept key MATERIAL inline — only a key NAME. An attempt to pass
|
# No flag may accept key MATERIAL inline — only a key NAME. An attempt to pass
|
||||||
# a literal key must be rejected as an unknown option (never silently honored).
|
# a literal key must be rejected as an unknown option (never silently honored).
|
||||||
@@ -341,8 +348,8 @@ echo "== H2: KEY BY-NAME, never inline; no key leak; same-uid boundary documente
|
|||||||
fail_msg "H2: signing with an unresolvable key name must FAIL LOUD"
|
fail_msg "H2: signing with an unresolvable key name must FAIL LOUD"
|
||||||
fi
|
fi
|
||||||
# The same-uid threat boundary + off-uid follow-up must be DOCUMENTED in the tool.
|
# The same-uid threat boundary + off-uid follow-up must be DOCUMENTED in the tool.
|
||||||
grep -qi 'same-uid' "$SIGN" || fail_msg "H2: same-uid threat boundary not documented in sign.sh"
|
has_match -qi 'same-uid' "$SIGN" || fail_msg "H2: same-uid threat boundary not documented in sign.sh"
|
||||||
grep -qi 'off-uid' "$SIGN" || fail_msg "H2: off-uid future signer not named in sign.sh"
|
has_match -qi 'off-uid' "$SIGN" || fail_msg "H2: off-uid future signer not named in sign.sh"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== D5 (#914a): EMBEDDED ACK NAMESPACE — env-less copy-run targets the RENDER-TIME agent, not 'default' =="
|
echo "== D5 (#914a): EMBEDDED ACK NAMESPACE — env-less copy-run targets the RENDER-TIME agent, not 'default' =="
|
||||||
@@ -355,7 +362,7 @@ echo "== D5 (#914a): EMBEDDED ACK NAMESPACE — env-less copy-run targets the RE
|
|||||||
out="$(WAKE_AGENT=someagent "$DIGEST" render)" || fail_msg "D5: render (WAKE_AGENT=someagent) failed"
|
out="$(WAKE_AGENT=someagent "$DIGEST" render)" || fail_msg "D5: render (WAKE_AGENT=someagent) failed"
|
||||||
ack_line="$(printf '%s\n' "$out" | awk '/^-- ACK/{f=1;next} f && NF {print; exit}')"
|
ack_line="$(printf '%s\n' "$out" | awk '/^-- ACK/{f=1;next} f && NF {print; exit}')"
|
||||||
[ -n "$ack_line" ] || fail_msg "D5: no embedded ack line extracted from the digest"
|
[ -n "$ack_line" ] || fail_msg "D5: no embedded ack line extracted from the digest"
|
||||||
printf '%s' "$ack_line" | grep -q 'WAKE_AGENT=someagent' ||
|
printf '%s' "$ack_line" | has_match -q 'WAKE_AGENT=someagent' ||
|
||||||
fail_msg "D5: embedded ack line has no explicit WAKE_AGENT=someagent prefix — an env-less copy-run silently resolves to 'default' [$ack_line]"
|
fail_msg "D5: embedded ack line has no explicit WAKE_AGENT=someagent prefix — an env-less copy-run silently resolves to 'default' [$ack_line]"
|
||||||
# Actually RUN it with NO WAKE_AGENT in the environment (the real failure
|
# Actually RUN it with NO WAKE_AGENT in the environment (the real failure
|
||||||
# mode: a consumer copy-pastes the line into a fresh shell).
|
# mode: a consumer copy-pastes the line into a fresh shell).
|
||||||
@@ -401,12 +408,12 @@ echo "== D6 (#914b): DIGEST-CLASS LOCATOR THREADING — ORIENTATION pointer carr
|
|||||||
loc="$(jq -cn '{kind:"repo", id:"r1", observed_hash:"deadbeefcafe0123456789abcdef0123456789abcdef0123456789abcdef01", remote:"example/repo"}')"
|
loc="$(jq -cn '{kind:"repo", id:"r1", observed_hash:"deadbeefcafe0123456789abcdef0123456789abcdef0123456789abcdef01", remote:"example/repo"}')"
|
||||||
"$STORE" enqueue --seq 1 --class digest --locators "$loc" >/dev/null
|
"$STORE" enqueue --seq 1 --class digest --locators "$loc" >/dev/null
|
||||||
out="$("$DIGEST" render)" || fail_msg "D6: render failed"
|
out="$("$DIGEST" render)" || fail_msg "D6: render failed"
|
||||||
orientline="$(printf '%s\n' "$out" | grep -E '^\s*\* seq 1 \[digest\]')"
|
orientline="$(printf '%s\n' "$out" | has_match -E '^\s*\* seq 1 \[digest\]')"
|
||||||
[ -n "$orientline" ] || fail_msg "D6: no ORIENTATION line found for seq 1"
|
[ -n "$orientline" ] || fail_msg "D6: no ORIENTATION line found for seq 1"
|
||||||
printf '%s' "$orientline" | grep -qE 'locator: *$' &&
|
printf '%s' "$orientline" | has_match -qE 'locator: *$' &&
|
||||||
fail_msg "D6: digest-class ORIENTATION pointer rendered an EMPTY locator despite a populated .locators field [$orientline]"
|
fail_msg "D6: digest-class ORIENTATION pointer rendered an EMPTY locator despite a populated .locators field [$orientline]"
|
||||||
# Must surface something a consumer can act on to re-verify.
|
# Must surface something a consumer can act on to re-verify.
|
||||||
printf '%s' "$orientline" | grep -qE 'remote=example/repo|id=r1|kind=repo' ||
|
printf '%s' "$orientline" | has_match -qE 'remote=example/repo|id=r1|kind=repo' ||
|
||||||
fail_msg "D6: digest-class ORIENTATION pointer does not carry a usable locator [$orientline]"
|
fail_msg "D6: digest-class ORIENTATION pointer does not carry a usable locator [$orientline]"
|
||||||
|
|
||||||
# --- ACTIONABLE-tier hard-locator FAIL-LOUD must be PRESERVED (now PER-ENTRY
|
# --- ACTIONABLE-tier hard-locator FAIL-LOUD must be PRESERVED (now PER-ENTRY
|
||||||
@@ -421,14 +428,14 @@ echo "== D6 (#914b): DIGEST-CLASS LOCATOR THREADING — ORIENTATION pointer carr
|
|||||||
rc=0
|
rc=0
|
||||||
out="$("$DIGEST" render 2>"$err")" || rc=$?
|
out="$("$DIGEST" render 2>"$err")" || rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "D6: a malformed actionable is now per-entry quarantined (render exit 0, #920), got $rc"
|
[ "$rc" -eq 0 ] || fail_msg "D6: a malformed actionable is now per-entry quarantined (render exit 0, #920), got $rc"
|
||||||
printf '%s' "$out" | grep -q 'mergeable=true' && fail_msg "D6: a malformed actionable claim must NOT be delivered as valid (fail-loud preserved)"
|
printf '%s' "$out" | has_match -q 'mergeable=true' && fail_msg "D6: a malformed actionable claim must NOT be delivered as valid (fail-loud preserved)"
|
||||||
grep -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D6: a malformed actionable must be DEAD-LETTERED (fail-loud preserved, per-entry)"
|
has_match -q 'mergeable=true' "$h/default/dead-letter.jsonl" 2>/dev/null || fail_msg "D6: a malformed actionable must be DEAD-LETTERED (fail-loud preserved, per-entry)"
|
||||||
grep -qi 'QUARANTINE' "$err" || fail_msg "D6: a malformed actionable must raise a loud per-entry alarm"
|
has_match -qi 'QUARANTINE' "$err" || fail_msg "D6: a malformed actionable must raise a loud per-entry alarm"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake digest/hmac harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake digest/hmac harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake digest/hmac harness: all invariants passed ($pass groups)"
|
echo "wake digest/hmac harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -117,6 +117,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
DIGEST="$SCRIPT_DIR/digest.sh"
|
DIGEST="$SCRIPT_DIR/digest.sh"
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || {
|
command -v jq >/dev/null 2>&1 || {
|
||||||
@@ -176,12 +183,12 @@ echo "== Q1 (a): malformed address-free {kind,id,observed_hash} QUARANTINES; cle
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q1: render must EXIT 0 (per-entry quarantine, not whole-digest exit-4), got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q1: render must EXIT 0 (per-entry quarantine, not whole-digest exit-4), got rc=$rc"
|
||||||
printf '%s' "$out" | grep -q "$SHA40" || fail_msg "Q1: the clean sibling (sha $SHA40) must STILL be delivered in the same drain [head-of-line block]"
|
printf '%s' "$out" | has_match -q "$SHA40" || fail_msg "Q1: the clean sibling (sha $SHA40) must STILL be delivered in the same drain [head-of-line block]"
|
||||||
printf '%s' "$out" | grep -q 'MALFORMED-Q' && fail_msg "Q1: the quarantined entry must be EXCLUDED from the rendered digest"
|
printf '%s' "$out" | has_match -q 'MALFORMED-Q' && fail_msg "Q1: the quarantined entry must be EXCLUDED from the rendered digest"
|
||||||
[ -f "$(dlq "$home")" ] || fail_msg "Q1: a durable dead-letter file must be written"
|
[ -f "$(dlq "$home")" ] || fail_msg "Q1: a durable dead-letter file must be written"
|
||||||
grep -q 'MALFORMED-Q' "$(dlq "$home")" 2>/dev/null || fail_msg "Q1: the malformed entry must be DEAD-LETTERED (accounted-for, not silently dropped)"
|
has_match -q 'MALFORMED-Q' "$(dlq "$home")" 2>/dev/null || fail_msg "Q1: the malformed entry must be DEAD-LETTERED (accounted-for, not silently dropped)"
|
||||||
grep -qi 'QUARANTINE' "$err" || fail_msg "Q1: a LOUD per-entry alarm must fire on stderr"
|
has_match -qi 'QUARANTINE' "$err" || fail_msg "Q1: a LOUD per-entry alarm must fire on stderr"
|
||||||
grep -q 'observed_seq=1' "$err" || fail_msg "Q1: the alarm must identify the offending entry (observed_seq=1)"
|
has_match -q 'observed_seq=1' "$err" || fail_msg "Q1: the alarm must identify the offending entry (observed_seq=1)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== Q2 (b): reconciler enumeration (reconciled:true) renders ORIENTATION-tier, NO exit-4 =="
|
echo "== Q2 (b): reconciler enumeration (reconciled:true) renders ORIENTATION-tier, NO exit-4 =="
|
||||||
@@ -200,8 +207,8 @@ echo "== Q2 (b): reconciler enumeration (reconciled:true) renders ORIENTATION-ti
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q2: a reconciler enumeration must NOT exit-4 (it is ORIENTATION-tier), got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q2: a reconciler enumeration must NOT exit-4 (it is ORIENTATION-tier), got rc=$rc"
|
||||||
printf '%s' "$out" | grep -q 'id=ENUM-B' || fail_msg "Q2: the enumeration must render as an ORIENTATION pointer (id=ENUM-B via _locator_line)"
|
printf '%s' "$out" | has_match -q 'id=ENUM-B' || fail_msg "Q2: the enumeration must render as an ORIENTATION pointer (id=ENUM-B via _locator_line)"
|
||||||
printf '%s' "$out" | grep -q 'CLAIM@seq' && fail_msg "Q2: an enumeration must NOT render as an ACTIONABLE CLAIM@seq"
|
printf '%s' "$out" | has_match -q 'CLAIM@seq' && fail_msg "Q2: an enumeration must NOT render as an ACTIONABLE CLAIM@seq"
|
||||||
[ -s "$(dlq "$home")" ] && fail_msg "Q2: an ORIENTATION-tier enumeration must NOT be quarantined/dead-lettered"
|
[ -s "$(dlq "$home")" ] && fail_msg "Q2: an ORIENTATION-tier enumeration must NOT be quarantined/dead-lettered"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
@@ -220,10 +227,10 @@ echo "== Q3 (c): TWO DISTINCT enumerations BOTH survive as SEPARATE orientation
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q3: two enumerations must render (exit 0), got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q3: two enumerations must render (exit 0), got rc=$rc"
|
||||||
n="$(printf '%s\n' "$out" | grep -c 'id=ENUM-C[12]' || true)"
|
n="$(printf '%s\n' "$out" | count_lines 'id=ENUM-C[12]' || true)"
|
||||||
[ "$n" = "2" ] || fail_msg "Q3: BOTH distinct enumerations must survive as SEPARATE orientation pointers (expected 2, got $n) — neither coalesced away"
|
[ "$n" = "2" ] || fail_msg "Q3: BOTH distinct enumerations must survive as SEPARATE orientation pointers (expected 2, got $n) — neither coalesced away"
|
||||||
printf '%s' "$out" | grep -q 'id=ENUM-C1' || fail_msg "Q3: enumeration ENUM-C1 must be present"
|
printf '%s' "$out" | has_match -q 'id=ENUM-C1' || fail_msg "Q3: enumeration ENUM-C1 must be present"
|
||||||
printf '%s' "$out" | grep -q 'id=ENUM-C2' || fail_msg "Q3: enumeration ENUM-C2 must be present"
|
printf '%s' "$out" | has_match -q 'id=ENUM-C2' || fail_msg "Q3: enumeration ENUM-C2 must be present"
|
||||||
[ -s "$(dlq "$home")" ] && fail_msg "Q3: enumerations must NOT be quarantined"
|
[ -s "$(dlq "$home")" ] && fail_msg "Q3: enumerations must NOT be quarantined"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
@@ -239,10 +246,10 @@ echo "== Q4: PRESERVED — a genuine malformed ACTIONABLE claim is STILL loud (d
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q4: render must exit 0 (per-entry quarantine), got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q4: render must exit 0 (per-entry quarantine), got rc=$rc"
|
||||||
printf '%s' "$out" | grep -q 'CLAIM-KEEP' && fail_msg "Q4: a malformed actionable must NOT be delivered as if valid"
|
printf '%s' "$out" | has_match -q 'CLAIM-KEEP' && fail_msg "Q4: a malformed actionable must NOT be delivered as if valid"
|
||||||
printf '%s' "$out" | grep -q '(none) — no consequential claims pending' || fail_msg "Q4: with the only claim quarantined, the ACTIONABLE section must show (none)"
|
printf '%s' "$out" | has_match -q '(none) — no consequential claims pending' || fail_msg "Q4: with the only claim quarantined, the ACTIONABLE section must show (none)"
|
||||||
grep -q 'CLAIM-KEEP' "$(dlq "$home")" 2>/dev/null || fail_msg "Q4: the malformed actionable must be DEAD-LETTERED (loudly surfaced, not silent)"
|
has_match -q 'CLAIM-KEEP' "$(dlq "$home")" 2>/dev/null || fail_msg "Q4: the malformed actionable must be DEAD-LETTERED (loudly surfaced, not silent)"
|
||||||
grep -qi 'QUARANTINE' "$err" || fail_msg "Q4: the malformed actionable must raise a LOUD alarm"
|
has_match -qi 'QUARANTINE' "$err" || fail_msg "Q4: the malformed actionable must raise a LOUD alarm"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== Q5: claim-precedence gated — a non-actionable-class entry carrying a claim (no hard locator) is STILL quarantined =="
|
echo "== Q5: claim-precedence gated — a non-actionable-class entry carrying a claim (no hard locator) is STILL quarantined =="
|
||||||
@@ -258,8 +265,8 @@ echo "== Q5: claim-precedence gated — a non-actionable-class entry carrying a
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q5: render must exit 0, got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q5: render must exit 0, got rc=$rc"
|
||||||
grep -q 'CLAIMY' "$(dlq "$home")" 2>/dev/null || fail_msg "Q5: a claim-carrying entry with no hard locator must be quarantined (claim precedence, §2.1)"
|
has_match -q 'CLAIMY' "$(dlq "$home")" 2>/dev/null || fail_msg "Q5: a claim-carrying entry with no hard locator must be quarantined (claim precedence, §2.1)"
|
||||||
printf '%s' "$out" | grep -q 'CI is green' && fail_msg "Q5: the un-verifiable claim must NOT be delivered"
|
printf '%s' "$out" | has_match -q 'CI is green' && fail_msg "Q5: the un-verifiable claim must NOT be delivered"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -278,11 +285,11 @@ echo "== Q6 (a): dead-lettered entry routes EXACTLY ONE off-host alarm (payload
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>"$err")"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q6: render must still EXIT 0 (per-entry quarantine, not whole-drain wedge), got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q6: render must still EXIT 0 (per-entry quarantine, not whole-drain wedge), got rc=$rc"
|
||||||
n="$(grep -c . "$ALARM_OUT" 2>/dev/null || true)"
|
n="$(count_lines . "$ALARM_OUT" 2>/dev/null || true)"
|
||||||
[ "$n" = "1" ] || fail_msg "Q6: EXACTLY ONE off-host alarm must route for the dead-lettered entry (got $n) [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
[ "$n" = "1" ] || fail_msg "Q6: EXACTLY ONE off-host alarm must route for the dead-lettered entry (got $n) [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
grep -q '"observed_seq":21' "$ALARM_OUT" 2>/dev/null || fail_msg "Q6: the routed alarm payload must name the entry's observed_seq (21)"
|
has_match -q '"observed_seq":21' "$ALARM_OUT" 2>/dev/null || fail_msg "Q6: the routed alarm payload must name the entry's observed_seq (21)"
|
||||||
grep -qi 'QUARANTINE' "$err" || fail_msg "Q6: the existing #920 stderr diagnostic must STILL fire (local + off-host, not either/or)"
|
has_match -qi 'QUARANTINE' "$err" || fail_msg "Q6: the existing #920 stderr diagnostic must STILL fire (local + off-host, not either/or)"
|
||||||
printf '%s' "$out" | grep -q 'DLQ-Q6' && fail_msg "Q6: the quarantined entry must still be EXCLUDED from the rendered digest"
|
printf '%s' "$out" | has_match -q 'DLQ-Q6' && fail_msg "Q6: the quarantined entry must still be EXCLUDED from the rendered digest"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -300,9 +307,9 @@ echo "== Q7 (b): re-draining the SAME still-dead-lettered entry N times routes Z
|
|||||||
for i in 1 2 3 4; do
|
for i in 1 2 3 4; do
|
||||||
"$DIGEST" render --from-file "$f" --agent default >/dev/null 2>"$TMP_ROOT/q7.err.$i"
|
"$DIGEST" render --from-file "$f" --agent default >/dev/null 2>"$TMP_ROOT/q7.err.$i"
|
||||||
done
|
done
|
||||||
n="$(grep -c . "$ALARM_OUT" 2>/dev/null || true)"
|
n="$(count_lines . "$ALARM_OUT" 2>/dev/null || true)"
|
||||||
[ "$n" = "1" ] || fail_msg "Q7: re-draining the SAME dead-lettered entry 4x must route ONLY ONE off-host alarm total (durable dedup by observed_seq); got $n [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
[ "$n" = "1" ] || fail_msg "Q7: re-draining the SAME dead-lettered entry 4x must route ONLY ONE off-host alarm total (durable dedup by observed_seq); got $n [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
grep -qi 'QUARANTINE' "$TMP_ROOT/q7.err.4" || fail_msg "Q7: the #920 stderr diagnostic must STILL fire on every re-drain (only the off-host route is deduped)"
|
has_match -qi 'QUARANTINE' "$TMP_ROOT/q7.err.4" || fail_msg "Q7: the #920 stderr diagnostic must STILL fire on every re-drain (only the off-host route is deduped)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== Q8 (c): a NEW distinct dead-lettered entry routes its OWN one alarm (dedup is per-entry, not a global latch) =="
|
echo "== Q8 (c): a NEW distinct dead-lettered entry routes its OWN one alarm (dedup is per-entry, not a global latch) =="
|
||||||
@@ -321,10 +328,10 @@ echo "== Q8 (c): a NEW distinct dead-lettered entry routes its OWN one alarm (de
|
|||||||
"$DIGEST" render --from-file "$f1" --agent default >/dev/null 2>/dev/null
|
"$DIGEST" render --from-file "$f1" --agent default >/dev/null 2>/dev/null
|
||||||
"$DIGEST" render --from-file "$f1" --agent default >/dev/null 2>/dev/null # re-drain seq 31 -> must NOT re-alarm
|
"$DIGEST" render --from-file "$f1" --agent default >/dev/null 2>/dev/null # re-drain seq 31 -> must NOT re-alarm
|
||||||
"$DIGEST" render --from-file "$f2" --agent default >/dev/null 2>/dev/null # NEW distinct seq 32 -> its own alarm
|
"$DIGEST" render --from-file "$f2" --agent default >/dev/null 2>/dev/null # NEW distinct seq 32 -> its own alarm
|
||||||
n="$(grep -c . "$ALARM_OUT" 2>/dev/null || true)"
|
n="$(count_lines . "$ALARM_OUT" 2>/dev/null || true)"
|
||||||
[ "$n" = "2" ] || fail_msg "Q8: two DISTINCT dead-lettered entries must together route exactly 2 off-host alarms total (got $n) [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
[ "$n" = "2" ] || fail_msg "Q8: two DISTINCT dead-lettered entries must together route exactly 2 off-host alarms total (got $n) [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
grep -q '"observed_seq":31' "$ALARM_OUT" 2>/dev/null || fail_msg "Q8: seq 31's alarm must be present"
|
has_match -q '"observed_seq":31' "$ALARM_OUT" 2>/dev/null || fail_msg "Q8: seq 31's alarm must be present"
|
||||||
grep -q '"observed_seq":32' "$ALARM_OUT" 2>/dev/null || fail_msg "Q8: seq 32's (the new distinct entry's) OWN alarm must be present"
|
has_match -q '"observed_seq":32' "$ALARM_OUT" 2>/dev/null || fail_msg "Q8: seq 32's (the new distinct entry's) OWN alarm must be present"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== Q9 (d): WAKE_ALARM_SINK_CMD unconfigured OR unreachable -> FAIL LOUD (never silent no-alarm); per-entry, render still exits 0 =="
|
echo "== Q9 (d): WAKE_ALARM_SINK_CMD unconfigured OR unreachable -> FAIL LOUD (never silent no-alarm); per-entry, render still exits 0 =="
|
||||||
@@ -340,9 +347,9 @@ echo "== Q9 (d): WAKE_ALARM_SINK_CMD unconfigured OR unreachable -> FAIL LOUD (n
|
|||||||
out_a="$("$DIGEST" render --from-file "$f" --agent default 2>"$err_a")"
|
out_a="$("$DIGEST" render --from-file "$f" --agent default 2>"$err_a")"
|
||||||
rc_a=$?
|
rc_a=$?
|
||||||
[ "$rc_a" -eq 0 ] || fail_msg "Q9a: per-entry quarantine must still exit 0 even when the off-host alarm sink is unconfigured (no whole-drain wedge), got rc=$rc_a"
|
[ "$rc_a" -eq 0 ] || fail_msg "Q9a: per-entry quarantine must still exit 0 even when the off-host alarm sink is unconfigured (no whole-drain wedge), got rc=$rc_a"
|
||||||
grep -qi 'FAIL LOUD' "$err_a" || fail_msg "Q9a: an unconfigured off-host alarm target must FAIL LOUD on stderr [$(cat "$err_a")]"
|
has_match -qi 'FAIL LOUD' "$err_a" || fail_msg "Q9a: an unconfigured off-host alarm target must FAIL LOUD on stderr [$(cat "$err_a")]"
|
||||||
grep -Eqi 'silent no-alarm|silent-miss|PERMANENTLY miss|permanent silent miss' "$err_a" || fail_msg "Q9a: the diagnostic must name the silent-miss hazard (G2a), mirroring beacon.sh's fail-closed wording [$(cat "$err_a")]"
|
has_match -Eqi 'silent no-alarm|silent-miss|PERMANENTLY miss|permanent silent miss' "$err_a" || fail_msg "Q9a: the diagnostic must name the silent-miss hazard (G2a), mirroring beacon.sh's fail-closed wording [$(cat "$err_a")]"
|
||||||
printf '%s' "$out_a" | grep -q 'DLQ-Q9' && fail_msg "Q9a: the quarantined entry must still be EXCLUDED from the rendered digest"
|
printf '%s' "$out_a" | has_match -q 'DLQ-Q9' && fail_msg "Q9a: the quarantined entry must still be EXCLUDED from the rendered digest"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
(
|
(
|
||||||
@@ -355,9 +362,9 @@ echo "== Q9 (d): WAKE_ALARM_SINK_CMD unconfigured OR unreachable -> FAIL LOUD (n
|
|||||||
out_b="$("$DIGEST" render --from-file "$f" --agent default 2>"$err_b")"
|
out_b="$("$DIGEST" render --from-file "$f" --agent default 2>"$err_b")"
|
||||||
rc_b=$?
|
rc_b=$?
|
||||||
[ "$rc_b" -eq 0 ] || fail_msg "Q9b: per-entry quarantine must still exit 0 even when the off-host alarm sink is unreachable, got rc=$rc_b"
|
[ "$rc_b" -eq 0 ] || fail_msg "Q9b: per-entry quarantine must still exit 0 even when the off-host alarm sink is unreachable, got rc=$rc_b"
|
||||||
grep -qi 'FAIL LOUD' "$err_b" || fail_msg "Q9b: an unreachable off-host alarm target must FAIL LOUD on stderr [$(cat "$err_b")]"
|
has_match -qi 'FAIL LOUD' "$err_b" || fail_msg "Q9b: an unreachable off-host alarm target must FAIL LOUD on stderr [$(cat "$err_b")]"
|
||||||
grep -qi 'UNREACHABLE' "$err_b" || fail_msg "Q9b: the diagnostic must name the unreachable target [$(cat "$err_b")]"
|
has_match -qi 'UNREACHABLE' "$err_b" || fail_msg "Q9b: the diagnostic must name the unreachable target [$(cat "$err_b")]"
|
||||||
printf '%s' "$out_b" | grep -q 'DLQ-Q9' && fail_msg "Q9b: the quarantined entry must still be EXCLUDED from the rendered digest"
|
printf '%s' "$out_b" | has_match -q 'DLQ-Q9' && fail_msg "Q9b: the quarantined entry must still be EXCLUDED from the rendered digest"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -383,16 +390,16 @@ echo "== Q10: snapshot metadata (#940) — snapshot_sha/snapshot_ts render on th
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>/dev/null)"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>/dev/null)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q10: render must succeed (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "Q10: render must succeed (rc=$rc)"
|
||||||
snapa_line="$(printf '%s\n' "$out" | grep 'id=SNAP-A' || true)"
|
snapa_line="$(printf '%s\n' "$out" | has_match 'id=SNAP-A' || true)"
|
||||||
printf '%s' "$snapa_line" | grep -q 'snapshot_sha=0123abc4567890def0123abc4567890def012345' \
|
printf '%s' "$snapa_line" | has_match -q 'snapshot_sha=0123abc4567890def0123abc4567890def012345' \
|
||||||
|| fail_msg "Q10: snapshot_sha must render on the ORIENTATION pointer line [$snapa_line]"
|
|| fail_msg "Q10: snapshot_sha must render on the ORIENTATION pointer line [$snapa_line]"
|
||||||
printf '%s' "$snapa_line" | grep -q 'snapshot_ts=1753850000' \
|
printf '%s' "$snapa_line" | has_match -q 'snapshot_ts=1753850000' \
|
||||||
|| fail_msg "Q10: snapshot_ts must render on the ORIENTATION pointer line (age = emit_ts - snapshot_ts, local arithmetic) [$snapa_line]"
|
|| fail_msg "Q10: snapshot_ts must render on the ORIENTATION pointer line (age = emit_ts - snapshot_ts, local arithmetic) [$snapa_line]"
|
||||||
printf '%s' "$out" | grep -q 'git show 0123abc4567890def0123abc4567890def012345:BOARD.md' \
|
printf '%s' "$out" | has_match -q 'git show 0123abc4567890def0123abc4567890def012345:BOARD.md' \
|
||||||
|| fail_msg "Q10: snapshot_sha+path must upgrade the actionable re-verify hint to a one-call git show"
|
|| fail_msg "Q10: snapshot_sha+path must upgrade the actionable re-verify hint to a one-call git show"
|
||||||
# The sibling without metadata must not grow empty snapshot_ fields.
|
# The sibling without metadata must not grow empty snapshot_ fields.
|
||||||
snapb_line="$(printf '%s\n' "$out" | grep 'id=SNAP-B' || true)"
|
snapb_line="$(printf '%s\n' "$out" | has_match 'id=SNAP-B' || true)"
|
||||||
printf '%s' "$snapb_line" | grep -q 'snapshot_' \
|
printf '%s' "$snapb_line" | has_match -q 'snapshot_' \
|
||||||
&& fail_msg "Q10: an entry without metadata must render NO snapshot_ fields [$snapb_line]"
|
&& fail_msg "Q10: an entry without metadata must render NO snapshot_ fields [$snapb_line]"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
@@ -434,28 +441,28 @@ echo "== Q11 (#944): REAL detector-shape actionable RENDERS as CLAIM@seq; addres
|
|||||||
[ "$rc" -eq 0 ] || fail_msg "Q11: render must exit 0, got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q11: render must exit 0, got rc=$rc"
|
||||||
# (a) POSITIVE: the live entry RENDERS as an actionable claim (not merely
|
# (a) POSITIVE: the live entry RENDERS as an actionable claim (not merely
|
||||||
# passes the predicate) with the one-call git-show re-verify hint.
|
# passes the predicate) with the one-call git-show re-verify hint.
|
||||||
printf '%s' "$out" | grep -q 'seq 68 — CLAIM@seq' || fail_msg "Q11a: the live seq-68 detector-shape entry must RENDER as CLAIM@seq (positive control)"
|
printf '%s' "$out" | has_match -q 'seq 68 — CLAIM@seq' || fail_msg "Q11a: the live seq-68 detector-shape entry must RENDER as CLAIM@seq (positive control)"
|
||||||
printf '%s' "$out" | grep -q 'git show 55d4909569d2b5fbccfec49ec9ca83db5049f3ce:docs/scratchpads/heartbeat-planning' \
|
printf '%s' "$out" | has_match -q 'git show 55d4909569d2b5fbccfec49ec9ca83db5049f3ce:docs/scratchpads/heartbeat-planning' \
|
||||||
|| fail_msg "Q11a: the rendered claim must carry the one-call re-verify hint git show <snapshot_sha>:<path>"
|
|| fail_msg "Q11a: the rendered claim must carry the one-call re-verify hint git show <snapshot_sha>:<path>"
|
||||||
# (b) POSITIVE: path arm alone suffices; hint degrades to one-call re-read.
|
# (b) POSITIVE: path arm alone suffices; hint degrades to one-call re-read.
|
||||||
printf '%s' "$out" | grep -q 'seq 69 — CLAIM@seq' || fail_msg "Q11b: a path-only detector-shape entry must RENDER as CLAIM@seq (path arm)"
|
printf '%s' "$out" | has_match -q 'seq 69 — CLAIM@seq' || fail_msg "Q11b: a path-only detector-shape entry must RENDER as CLAIM@seq (path arm)"
|
||||||
printf '%s' "$out" | grep -q 're-read BOARD.md' || fail_msg "Q11b: the path-only claim must carry the one-call re-read <path> hint"
|
printf '%s' "$out" | has_match -q 're-read BOARD.md' || fail_msg "Q11b: the path-only claim must carry the one-call re-read <path> hint"
|
||||||
n_claims="$(printf '%s\n' "$out" | grep -c 'CLAIM@seq' || true)"
|
n_claims="$(printf '%s\n' "$out" | count_lines 'CLAIM@seq' || true)"
|
||||||
[ "$n_claims" = "2" ] || fail_msg "Q11: EXACTLY the two valid entries must render as CLAIM@seq (got $n_claims)"
|
[ "$n_claims" = "2" ] || fail_msg "Q11: EXACTLY the two valid entries must render as CLAIM@seq (got $n_claims)"
|
||||||
# (c)+(d) NEGATIVES retained: both quarantine, each with its OWN alarm.
|
# (c)+(d) NEGATIVES retained: both quarantine, each with its OWN alarm.
|
||||||
printf '%s' "$out" | grep -q 'ADDR-FREE' && fail_msg "Q11c: the address-free entry must be EXCLUDED from the digest"
|
printf '%s' "$out" | has_match -q 'ADDR-FREE' && fail_msg "Q11c: the address-free entry must be EXCLUDED from the digest"
|
||||||
printf '%s' "$out" | grep -q 'SNAP-ONLY' && fail_msg "Q11d: no bare path-less snapshot_sha entry may appear in the digest (gate must not widen past board_file)"
|
printf '%s' "$out" | has_match -q 'SNAP-ONLY' && fail_msg "Q11d: no bare path-less snapshot_sha entry may appear in the digest (gate must not widen past board_file)"
|
||||||
grep -q 'ADDR-FREE' "$(dlq "$home")" 2>/dev/null || fail_msg "Q11c: the address-free entry must be DEAD-LETTERED"
|
has_match -q 'ADDR-FREE' "$(dlq "$home")" 2>/dev/null || fail_msg "Q11c: the address-free entry must be DEAD-LETTERED"
|
||||||
for snap_id in SNAP-ONLY-40 SNAP-ONLY-7 SNAP-ONLY-64; do
|
for snap_id in SNAP-ONLY-40 SNAP-ONLY-7 SNAP-ONLY-64; do
|
||||||
grep -q "$snap_id" "$(dlq "$home")" 2>/dev/null || fail_msg "Q11d: the bare snapshot_sha entry ($snap_id) must be DEAD-LETTERED"
|
has_match -q "$snap_id" "$(dlq "$home")" 2>/dev/null || fail_msg "Q11d: the bare snapshot_sha entry ($snap_id) must be DEAD-LETTERED"
|
||||||
done
|
done
|
||||||
grep -q 'heartbeat-planning' "$(dlq "$home")" 2>/dev/null && fail_msg "Q11a: the valid live entry must NOT be dead-lettered"
|
has_match -q 'heartbeat-planning' "$(dlq "$home")" 2>/dev/null && fail_msg "Q11a: the valid live entry must NOT be dead-lettered"
|
||||||
grep -q 'PILOT-LOCAL' "$(dlq "$home")" 2>/dev/null && fail_msg "Q11b: the valid path-only entry must NOT be dead-lettered"
|
has_match -q 'PILOT-LOCAL' "$(dlq "$home")" 2>/dev/null && fail_msg "Q11b: the valid path-only entry must NOT be dead-lettered"
|
||||||
n_alarms="$(grep -c . "$ALARM_OUT" 2>/dev/null || true)"
|
n_alarms="$(count_lines . "$ALARM_OUT" 2>/dev/null || true)"
|
||||||
[ "$n_alarms" = "4" ] || fail_msg "Q11: exactly the four invalid entries must alarm (got $n_alarms) [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
[ "$n_alarms" = "4" ] || fail_msg "Q11: exactly the four invalid entries must alarm (got $n_alarms) [$(cat "$ALARM_OUT" 2>/dev/null)]"
|
||||||
grep -q '"observed_seq":70' "$ALARM_OUT" 2>/dev/null || fail_msg "Q11c: seq 70's own alarm must be present"
|
has_match -q '"observed_seq":70' "$ALARM_OUT" 2>/dev/null || fail_msg "Q11c: seq 70's own alarm must be present"
|
||||||
for snap_seq in 71 72 73; do
|
for snap_seq in 71 72 73; do
|
||||||
grep -q "\"observed_seq\":$snap_seq" "$ALARM_OUT" 2>/dev/null || fail_msg "Q11d: seq $snap_seq's own alarm must be present"
|
has_match -q "\"observed_seq\":$snap_seq" "$ALARM_OUT" 2>/dev/null || fail_msg "Q11d: seq $snap_seq's own alarm must be present"
|
||||||
done
|
done
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
@@ -477,15 +484,15 @@ echo "== Q12 (#946): quarantined entries are DISCLOSED (by seq, content withheld
|
|||||||
[ "$rc" -eq 0 ] || fail_msg "Q12: render must exit 0, got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q12: render must exit 0, got rc=$rc"
|
||||||
# DISCLOSURE: a held entry must be VISIBLE in the digest it was held from —
|
# DISCLOSURE: a held entry must be VISIBLE in the digest it was held from —
|
||||||
# a silent hold is how five successive digests each stepped past seq 68...
|
# a silent hold is how five successive digests each stepped past seq 68...
|
||||||
printf '%s' "$out" | grep -q 'QUARANTINED' || fail_msg "Q12: the digest must carry a QUARANTINED disclosure section (no silent hold)"
|
printf '%s' "$out" | has_match -q 'QUARANTINED' || fail_msg "Q12: the digest must carry a QUARANTINED disclosure section (no silent hold)"
|
||||||
printf '%s' "$out" | grep -q 'seq 2 .*HELD' || fail_msg "Q12: the disclosure must name the held seq (2) as HELD"
|
printf '%s' "$out" | has_match -q 'seq 2 .*HELD' || fail_msg "Q12: the disclosure must name the held seq (2) as HELD"
|
||||||
# ...but WITHOUT re-injecting the refused content: disclosure is by seq only;
|
# ...but WITHOUT re-injecting the refused content: disclosure is by seq only;
|
||||||
# the Q1/Q4/Q5/Q6/Q9/Q11 exclusion property stands.
|
# the Q1/Q4/Q5/Q6/Q9/Q11 exclusion property stands.
|
||||||
printf '%s' "$out" | grep -q 'ADDR-Q12' && fail_msg "Q12: the quarantined entry's content/locators must stay EXCLUDED from the digest"
|
printf '%s' "$out" | has_match -q 'ADDR-Q12' && fail_msg "Q12: the quarantined entry's content/locators must stay EXCLUDED from the digest"
|
||||||
# CLAMP: the embedded ack stops BELOW the quarantined seq, and says so loudly.
|
# CLAMP: the embedded ack stops BELOW the quarantined seq, and says so loudly.
|
||||||
printf '%s' "$out" | grep -Eq 'consumed --upto 1$' || fail_msg "Q12: the embedded ack must be CLAMPED to --upto 1 (below quarantined seq 2)"
|
printf '%s' "$out" | has_match -Eq 'consumed --upto 1$' || fail_msg "Q12: the embedded ack must be CLAMPED to --upto 1 (below quarantined seq 2)"
|
||||||
printf '%s' "$out" | grep -Eq 'consumed --upto 2( |$)' && fail_msg "Q12: the raw observed cursor (2) must NOT be embedded while seq 2 is quarantined"
|
printf '%s' "$out" | has_match -Eq 'consumed --upto 2( |$)' && fail_msg "Q12: the raw observed cursor (2) must NOT be embedded while seq 2 is quarantined"
|
||||||
printf '%s' "$out" | grep -q 'ACK CLAMPED' || fail_msg "Q12: the clamp must be LOUDLY disclosed in the ACK section"
|
printf '%s' "$out" | has_match -q 'ACK CLAMPED' || fail_msg "Q12: the clamp must be LOUDLY disclosed in the ACK section"
|
||||||
# A foreign-data render must NOT rewrite the lane's quarantine truth.
|
# A foreign-data render must NOT rewrite the lane's quarantine truth.
|
||||||
[ -e "$home/default/quarantined.set" ] && fail_msg "Q12: a --from-file render must NOT write the store's quarantined.set (lane truth is store-mode only)"
|
[ -e "$home/default/quarantined.set" ] && fail_msg "Q12: a --from-file render must NOT write the store's quarantined.set (lane truth is store-mode only)"
|
||||||
true
|
true
|
||||||
@@ -503,9 +510,9 @@ echo "== Q13 (#946): nothing quarantined -> ack UNCLAMPED at the observed cursor
|
|||||||
out="$("$DIGEST" render --from-file "$f" --agent default 2>/dev/null)"
|
out="$("$DIGEST" render --from-file "$f" --agent default 2>/dev/null)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q13: render must exit 0, got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q13: render must exit 0, got rc=$rc"
|
||||||
printf '%s' "$out" | grep -Eq 'consumed --upto 1$' || fail_msg "Q13: with nothing quarantined the ack must embed the observed cursor (1) unchanged"
|
printf '%s' "$out" | has_match -Eq 'consumed --upto 1$' || fail_msg "Q13: with nothing quarantined the ack must embed the observed cursor (1) unchanged"
|
||||||
printf '%s' "$out" | grep -q 'QUARANTINED' && fail_msg "Q13: no disclosure section when nothing is quarantined"
|
printf '%s' "$out" | has_match -q 'QUARANTINED' && fail_msg "Q13: no disclosure section when nothing is quarantined"
|
||||||
printf '%s' "$out" | grep -q 'ACK CLAMPED' && fail_msg "Q13: no clamp note when nothing is quarantined"
|
printf '%s' "$out" | has_match -q 'ACK CLAMPED' && fail_msg "Q13: no clamp note when nothing is quarantined"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -520,8 +527,8 @@ echo "== Q14 (#946): store-mode render SYNCS quarantine truth into the store —
|
|||||||
out="$("$DIGEST" render --from-store --agent default 2>/dev/null)"
|
out="$("$DIGEST" render --from-store --agent default 2>/dev/null)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q14: render must exit 0, got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q14: render must exit 0, got rc=$rc"
|
||||||
printf '%s' "$out" | grep -q 'QUARANTINED' || fail_msg "Q14: the store-mode digest must disclose the held entry"
|
printf '%s' "$out" | has_match -q 'QUARANTINED' || fail_msg "Q14: the store-mode digest must disclose the held entry"
|
||||||
printf '%s' "$out" | grep -Eq 'consumed --upto 1$' || fail_msg "Q14: the embedded ack must clamp to 1 (below quarantined seq 2)"
|
printf '%s' "$out" | has_match -Eq 'consumed --upto 1$' || fail_msg "Q14: the embedded ack must clamp to 1 (below quarantined seq 2)"
|
||||||
qf="$home/default/quarantined.set"
|
qf="$home/default/quarantined.set"
|
||||||
[ "$(cat "$qf" 2>/dev/null)" = "2" ] || fail_msg "Q14: a store-mode render must sync quarantined.set to exactly {2}, got [$(cat "$qf" 2>/dev/null)]"
|
[ "$(cat "$qf" 2>/dev/null)" = "2" ] || fail_msg "Q14: a store-mode render must sync quarantined.set to exactly {2}, got [$(cat "$qf" 2>/dev/null)]"
|
||||||
# END-TO-END: even a hand-typed upto past the held seq is refused at the
|
# END-TO-END: even a hand-typed upto past the held seq is refused at the
|
||||||
@@ -545,8 +552,8 @@ echo "== Q15 (#946): gate-fix RECOVERY — a stale quarantined.set is REPLACED b
|
|||||||
out="$("$DIGEST" render --from-store --agent default 2>/dev/null)"
|
out="$("$DIGEST" render --from-store --agent default 2>/dev/null)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "Q15: render must exit 0, got rc=$rc"
|
[ "$rc" -eq 0 ] || fail_msg "Q15: render must exit 0, got rc=$rc"
|
||||||
printf '%s' "$out" | grep -q 'QUARANTINED' && fail_msg "Q15: nothing quarantines under the fixed gate — no disclosure section"
|
printf '%s' "$out" | has_match -q 'QUARANTINED' && fail_msg "Q15: nothing quarantines under the fixed gate — no disclosure section"
|
||||||
printf '%s' "$out" | grep -Eq 'consumed --upto 2$' || fail_msg "Q15: the ack must embed the full observed cursor (2) once the gate is fixed"
|
printf '%s' "$out" | has_match -Eq 'consumed --upto 2$' || fail_msg "Q15: the ack must embed the full observed cursor (2) once the gate is fixed"
|
||||||
[ -s "$home/default/quarantined.set" ] && fail_msg "Q15: the clean render must REPLACE (clear) the stale quarantined.set — a cumulative-forever set would block acks on entries that now render, got [$(cat "$home/default/quarantined.set")]"
|
[ -s "$home/default/quarantined.set" ] && fail_msg "Q15: the clean render must REPLACE (clear) the stale quarantined.set — a cumulative-forever set would block acks on entries that now render, got [$(cat "$home/default/quarantined.set")]"
|
||||||
"$STORE" consume --upto 2 >/dev/null 2>&1 || fail_msg "Q15: the ordinary consume must succeed after the clamp self-heals"
|
"$STORE" consume --upto 2 >/dev/null 2>&1 || fail_msg "Q15: the ordinary consume must succeed after the clamp self-heals"
|
||||||
) && ok
|
) && ok
|
||||||
@@ -557,7 +564,7 @@ echo "== Q16 (guard): Q2's ENUM-B fixture must STAY address-free — the reconci
|
|||||||
# Token concatenated so THIS guard's own source lines never contain the
|
# Token concatenated so THIS guard's own source lines never contain the
|
||||||
# literal fixture id and cannot self-match.
|
# literal fixture id and cannot self-match.
|
||||||
enum_id='ENUM''-B'
|
enum_id='ENUM''-B'
|
||||||
fixture_line="$(grep -F "\"id\":\"$enum_id\"" "$self" | grep -F '"observed_seq":5' | head -n1)"
|
fixture_line="$(has_match -F "\"id\":\"$enum_id\"" "$self" | has_match -F '"observed_seq":5' | head -n1)"
|
||||||
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
[ -n "$fixture_line" ] || fail_msg "Q16: could not locate Q2's $enum_id fixture line (renamed/renumbered? update this guard)"
|
||||||
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
fixture_json="$(printf '%s' "$fixture_line" | sed "s/.*'\({.*}\)'.*/\1/")"
|
||||||
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
# Positive controls FIRST (blind-instrument rule): the extraction must yield
|
||||||
@@ -578,7 +585,7 @@ echo "== Q16 (guard): Q2's ENUM-B fixture must STAY address-free — the reconci
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake digest-quarantine harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake digest-quarantine harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake digest-quarantine harness: all invariants passed ($pass groups)"
|
echo "wake digest-quarantine harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
ORACLE="$SCRIPT_DIR/fn-oracle.sh"
|
ORACLE="$SCRIPT_DIR/fn-oracle.sh"
|
||||||
DET="$SCRIPT_DIR/detector.sh"
|
DET="$SCRIPT_DIR/detector.sh"
|
||||||
|
|
||||||
@@ -62,8 +69,8 @@ echo "== O1: healthy pipeline -> synthetic-canary FN-rate = 0 =="
|
|||||||
out="$("$ORACLE" run --slo-seconds 120 --count 3 2>&1)"
|
out="$("$ORACLE" run --slo-seconds 120 --count 3 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "O1: a healthy pipeline must exit 0 (got $rc) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "O1: a healthy pipeline must exit 0 (got $rc) [$out]"
|
||||||
echo "$out" | grep -q 'FN-RATE = 0/3' || fail_msg "O1: FN-rate must be 0/3 on a healthy pipeline [$out]"
|
echo "$out" | has_match -q 'FN-RATE = 0/3' || fail_msg "O1: FN-rate must be 0/3 on a healthy pipeline [$out]"
|
||||||
echo "$out" | grep -q 'VERDICT = PASS' || fail_msg "O1: verdict must be PASS on a healthy pipeline [$out]"
|
echo "$out" | has_match -q 'VERDICT = PASS' || fail_msg "O1: verdict must be PASS on a healthy pipeline [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== O2: DISABLED detector (perfect no-op) -> FN-DETECTED (blindspot killer) =="
|
echo "== O2: DISABLED detector (perfect no-op) -> FN-DETECTED (blindspot killer) =="
|
||||||
@@ -76,9 +83,9 @@ echo "== O2: DISABLED detector (perfect no-op) -> FN-DETECTED (blindspot killer)
|
|||||||
out="$("$ORACLE" run --slo-seconds 120 --count 3 2>&1)"
|
out="$("$ORACLE" run --slo-seconds 120 --count 3 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "O2: a detector that drops a KNOWN delta MUST fail the oracle (non-zero exit), even at a perfect no-op rate"
|
[ "$rc" -ne 0 ] || fail_msg "O2: a detector that drops a KNOWN delta MUST fail the oracle (non-zero exit), even at a perfect no-op rate"
|
||||||
echo "$out" | grep -q 'FN-RATE = 3/3' || fail_msg "O2: every dropped canary must count as a false-negative (FN-RATE 3/3) [$out]"
|
echo "$out" | has_match -q 'FN-RATE = 3/3' || fail_msg "O2: every dropped canary must count as a false-negative (FN-RATE 3/3) [$out]"
|
||||||
echo "$out" | grep -q 'VERDICT = FN-DETECTED' || fail_msg "O2: verdict must be FN-DETECTED for a dropping detector [$out]"
|
echo "$out" | has_match -q 'VERDICT = FN-DETECTED' || fail_msg "O2: verdict must be FN-DETECTED for a dropping detector [$out]"
|
||||||
echo "$out" | grep -qi 'never OBSERVED' || fail_msg "O2: the failure must name the dropped (never-observed) delta [$out]"
|
echo "$out" | has_match -qi 'never OBSERVED' || fail_msg "O2: the failure must name the dropped (never-observed) delta [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== O3: reached CONSUMED but too slow -> FN-DETECTED (within-SLO clause) =="
|
echo "== O3: reached CONSUMED but too slow -> FN-DETECTED (within-SLO clause) =="
|
||||||
@@ -91,10 +98,10 @@ echo "== O3: reached CONSUMED but too slow -> FN-DETECTED (within-SLO clause) ==
|
|||||||
out="$("$ORACLE" run --slo-seconds 1 --count 1 2>&1)"
|
out="$("$ORACLE" run --slo-seconds 1 --count 1 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "O3: a canary that reaches CONSUMED past its SLO must be a false-negative (non-zero exit)"
|
[ "$rc" -ne 0 ] || fail_msg "O3: a canary that reaches CONSUMED past its SLO must be a false-negative (non-zero exit)"
|
||||||
echo "$out" | grep -qi 'per-class SLO' || fail_msg "O3: the failure must attribute to the per-class SLO, not a drop [$out]"
|
echo "$out" | has_match -qi 'per-class SLO' || fail_msg "O3: the failure must attribute to the per-class SLO, not a drop [$out]"
|
||||||
# It must NOT be the 'never observed' branch: the delta WAS observed/delivered,
|
# It must NOT be the 'never observed' branch: the delta WAS observed/delivered,
|
||||||
# just too slowly. This proves O3 exercises the SLO clause specifically.
|
# just too slowly. This proves O3 exercises the SLO clause specifically.
|
||||||
if echo "$out" | grep -qi 'never OBSERVED'; then
|
if echo "$out" | has_match -qi 'never OBSERVED'; then
|
||||||
fail_msg "O3: an SLO breach must NOT be misreported as a dropped delta [$out]"
|
fail_msg "O3: an SLO breach must NOT be misreported as a dropped delta [$out]"
|
||||||
fi
|
fi
|
||||||
) && ok
|
) && ok
|
||||||
@@ -110,7 +117,7 @@ echo "== O4: verdict is OFF-DOMAIN (from terminal consumed_seq, not detector sel
|
|||||||
out="$("$ORACLE" run --slo-seconds 120 --count 1 2>&1)"
|
out="$("$ORACLE" run --slo-seconds 120 --count 1 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "O4: a drive that exits 0 but delivers nothing must still be FN-DETECTED (verdict is off-domain)"
|
[ "$rc" -ne 0 ] || fail_msg "O4: a drive that exits 0 but delivers nothing must still be FN-DETECTED (verdict is off-domain)"
|
||||||
echo "$out" | grep -q 'VERDICT = FN-DETECTED' || fail_msg "O4: off-domain verdict must not be fooled by a clean exit code [$out]"
|
echo "$out" | has_match -q 'VERDICT = FN-DETECTED' || fail_msg "O4: off-domain verdict must not be fooled by a clean exit code [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== O5: no invented SLO -> --slo-seconds is REQUIRED (fail-loud) =="
|
echo "== O5: no invented SLO -> --slo-seconds is REQUIRED (fail-loud) =="
|
||||||
@@ -121,12 +128,12 @@ echo "== O5: no invented SLO -> --slo-seconds is REQUIRED (fail-loud) =="
|
|||||||
err="$("$ORACLE" run --count 1 2>&1)"
|
err="$("$ORACLE" run --count 1 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "O5: run without an SLO must fail loud (no invented default)"
|
[ "$rc" -ne 0 ] || fail_msg "O5: run without an SLO must fail loud (no invented default)"
|
||||||
echo "$err" | grep -qi 'slo' || fail_msg "O5: the usage error must name the missing SLO [$err]"
|
echo "$err" | has_match -qi 'slo' || fail_msg "O5: the usage error must name the missing SLO [$err]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake fn-oracle harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake fn-oracle harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake fn-oracle harness: all invariants passed ($pass groups)"
|
echo "wake fn-oracle harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -34,6 +34,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
WI="$SCRIPT_DIR/wake-install.sh"
|
WI="$SCRIPT_DIR/wake-install.sh"
|
||||||
BEACON="$SCRIPT_DIR/beacon.sh"
|
BEACON="$SCRIPT_DIR/beacon.sh"
|
||||||
FRAMEWORK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
FRAMEWORK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
@@ -94,7 +101,7 @@ EOF
|
|||||||
export WAKE_INSTALL_MANIFEST="$BAD_MANIFEST"
|
export WAKE_INSTALL_MANIFEST="$BAD_MANIFEST"
|
||||||
out="$(bash "$WI" install 2>&1)"; rc=$?
|
out="$(bash "$WI" install 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "I2: install must FAIL when a wake candidate is not framework-owned (got rc=$rc)"
|
[ "$rc" -ne 0 ] || fail_msg "I2: install must FAIL when a wake candidate is not framework-owned (got rc=$rc)"
|
||||||
echo "$out" | grep -qi 'Gate A VIOLATION' || fail_msg "I2: refusal must name the Gate A violation [$out]"
|
echo "$out" | has_match -qi 'Gate A VIOLATION' || fail_msg "I2: refusal must name the Gate A violation [$out]"
|
||||||
# No partial write: the target must have received NO wake tool file.
|
# No partial write: the target must have received NO wake tool file.
|
||||||
[ ! -e "$TGT/tools/wake/beacon.sh" ] || fail_msg "I2: a refused install must not have written any wake file (partial write leaked)"
|
[ ! -e "$TGT/tools/wake/beacon.sh" ] || fail_msg "I2: a refused install must not have written any wake file (partial write leaked)"
|
||||||
# And the positive control: with the REAL manifest, the same candidates install.
|
# And the positive control: with the REAL manifest, the same candidates install.
|
||||||
@@ -121,7 +128,7 @@ EOF
|
|||||||
|| fail_msg "I3: blank-reset drop-in write failed"
|
|| fail_msg "I3: blank-reset drop-in write failed"
|
||||||
out="$(bash "$WI" verify-single "$UNIT" 2>&1)"; rc=$?
|
out="$(bash "$WI" verify-single "$UNIT" 2>&1)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "I3: blank-reset must resolve exactly one OnUnitActiveUSec (rc=$rc) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "I3: blank-reset must resolve exactly one OnUnitActiveUSec (rc=$rc) [$out]"
|
||||||
echo "$out" | grep -qi 'EXACTLY ONE' || fail_msg "I3: verify must confirm exactly-one [$out]"
|
echo "$out" | has_match -qi 'EXACTLY ONE' || fail_msg "I3: verify must confirm exactly-one [$out]"
|
||||||
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> TWO values -> fail.
|
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> TWO values -> fail.
|
||||||
cat >"$UD/$UNIT.d/interval.conf" <<'EOF'
|
cat >"$UD/$UNIT.d/interval.conf" <<'EOF'
|
||||||
[Timer]
|
[Timer]
|
||||||
@@ -141,7 +148,7 @@ echo "== I4: snapshot-guard — reap without a snapshot is REFUSED; allowed afte
|
|||||||
# (a) reap WITHOUT snapshot -> refuse, unit still present.
|
# (a) reap WITHOUT snapshot -> refuse, unit still present.
|
||||||
out="$(bash "$WI" reap-unit "$UNIT" 2>&1)"; rc=$?
|
out="$(bash "$WI" reap-unit "$UNIT" 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "I4: reap without a snapshot must be REFUSED (rc=$rc)"
|
[ "$rc" -ne 0 ] || fail_msg "I4: reap without a snapshot must be REFUSED (rc=$rc)"
|
||||||
echo "$out" | grep -qi 'snapshot-guard' || fail_msg "I4: refusal must name the snapshot-guard [$out]"
|
echo "$out" | has_match -qi 'snapshot-guard' || fail_msg "I4: refusal must name the snapshot-guard [$out]"
|
||||||
[ -f "$UD/$UNIT" ] || fail_msg "I4: a refused reap must NOT delete the unit"
|
[ -f "$UD/$UNIT" ] || fail_msg "I4: a refused reap must NOT delete the unit"
|
||||||
# (b) snapshot, then reap -> allowed, unit gone, snapshot retained.
|
# (b) snapshot, then reap -> allowed, unit gone, snapshot retained.
|
||||||
bash "$WI" snapshot-unit "$UNIT" >/dev/null 2>&1 || fail_msg "I4: snapshot-unit failed"
|
bash "$WI" snapshot-unit "$UNIT" >/dev/null 2>&1 || fail_msg "I4: snapshot-unit failed"
|
||||||
@@ -161,28 +168,28 @@ echo "== I5: fail-closed install-validate — unconfigured HMAC/alarm FAIL LOUD;
|
|||||||
export WAKE_HMAC_KEY_NAME="primary"
|
export WAKE_HMAC_KEY_NAME="primary"
|
||||||
out="$(bash "$WI" validate-hmac-key 2>&1)"; rc=$?
|
out="$(bash "$WI" validate-hmac-key 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "I5: missing credential store must FAIL LOUD for the HMAC key (rc=$rc)"
|
[ "$rc" -ne 0 ] || fail_msg "I5: missing credential store must FAIL LOUD for the HMAC key (rc=$rc)"
|
||||||
echo "$out" | grep -qi 'UNSIGNED' || fail_msg "I5: HMAC failure must warn about unsigned wakes [$out]"
|
echo "$out" | has_match -qi 'UNSIGNED' || fail_msg "I5: HMAC failure must warn about unsigned wakes [$out]"
|
||||||
# Now provide the key by-name -> pass, and the value must NEVER be echoed.
|
# Now provide the key by-name -> pass, and the value must NEVER be echoed.
|
||||||
jq -cn --arg k "$SECRET_KEY" '{wake:{hmac_keys:{"primary":$k}}}' >"$CRED"
|
jq -cn --arg k "$SECRET_KEY" '{wake:{hmac_keys:{"primary":$k}}}' >"$CRED"
|
||||||
out2="$(bash "$WI" validate-hmac-key 2>&1)"; rc2=$?
|
out2="$(bash "$WI" validate-hmac-key 2>&1)"; rc2=$?
|
||||||
[ "$rc2" -eq 0 ] || fail_msg "I5: a by-name-resolvable HMAC key must pass (rc=$rc2) [$out2]"
|
[ "$rc2" -eq 0 ] || fail_msg "I5: a by-name-resolvable HMAC key must pass (rc=$rc2) [$out2]"
|
||||||
echo "$out2" | grep -qF "$SECRET_KEY" && fail_msg "I5: validate must NEVER echo the HMAC key material"
|
echo "$out2" | has_match -qF "$SECRET_KEY" && fail_msg "I5: validate must NEVER echo the HMAC key material"
|
||||||
# --- alarm target: unconfigured -> fail loud (silent no-alarm host).
|
# --- alarm target: unconfigured -> fail loud (silent no-alarm host).
|
||||||
unset WAKE_ALARM_SINK_CMD
|
unset WAKE_ALARM_SINK_CMD
|
||||||
out3="$(bash "$WI" validate-alarm-target 2>&1)"; rc3=$?
|
out3="$(bash "$WI" validate-alarm-target 2>&1)"; rc3=$?
|
||||||
[ "$rc3" -ne 0 ] || fail_msg "I5: unconfigured alarm target must FAIL LOUD (rc=$rc3)"
|
[ "$rc3" -ne 0 ] || fail_msg "I5: unconfigured alarm target must FAIL LOUD (rc=$rc3)"
|
||||||
echo "$out3" | grep -qi 'silent no-alarm host' || fail_msg "I5: alarm failure must name the silent-no-alarm hazard [$out3]"
|
echo "$out3" | has_match -qi 'silent no-alarm host' || fail_msg "I5: alarm failure must name the silent-no-alarm hazard [$out3]"
|
||||||
# unreachable -> fail loud.
|
# unreachable -> fail loud.
|
||||||
export WAKE_ALARM_SINK_CMD="false"
|
export WAKE_ALARM_SINK_CMD="false"
|
||||||
out4="$(bash "$WI" validate-alarm-target 2>&1)"; rc4=$?
|
out4="$(bash "$WI" validate-alarm-target 2>&1)"; rc4=$?
|
||||||
[ "$rc4" -ne 0 ] || fail_msg "I5: unreachable alarm sink must FAIL LOUD (rc=$rc4)"
|
[ "$rc4" -ne 0 ] || fail_msg "I5: unreachable alarm sink must FAIL LOUD (rc=$rc4)"
|
||||||
echo "$out4" | grep -qi 'UNREACHABLE' || fail_msg "I5: alarm failure must name the unreachable target [$out4]"
|
echo "$out4" | has_match -qi 'UNREACHABLE' || fail_msg "I5: alarm failure must name the unreachable target [$out4]"
|
||||||
# reachable -> pass.
|
# reachable -> pass.
|
||||||
export WAKE_ALARM_SINK_CMD="cat >/dev/null"
|
export WAKE_ALARM_SINK_CMD="cat >/dev/null"
|
||||||
bash "$WI" validate-alarm-target >/dev/null 2>&1 || fail_msg "I5: a configured+reachable alarm sink must pass"
|
bash "$WI" validate-alarm-target >/dev/null 2>&1 || fail_msg "I5: a configured+reachable alarm sink must pass"
|
||||||
# The installer file must inline NO secret/endpoint.
|
# The installer file must inline NO secret/endpoint.
|
||||||
grep -qF "$SECRET_ENDPOINT" "$WI" && fail_msg "I5: wake-install.sh must NOT inline any alarm endpoint"
|
has_match -qF "$SECRET_ENDPOINT" "$WI" && fail_msg "I5: wake-install.sh must NOT inline any alarm endpoint"
|
||||||
grep -qE 'hmac_keys[^A-Za-z_].*=[^=].*[A-Za-z0-9]{8,}' "$WI" && fail_msg "I5: wake-install.sh must NOT inline key material"
|
has_match -qE 'hmac_keys[^A-Za-z_].*=[^=].*[A-Za-z0-9]{8,}' "$WI" && fail_msg "I5: wake-install.sh must NOT inline key material"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -204,7 +211,7 @@ echo "== I6: reset->verify->retire — overlap keeps legacy running; only §4-ve
|
|||||||
[ "$rc" -eq 0 ] || fail_msg "I6: overlap reset-verify must succeed (rc=$rc) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "I6: overlap reset-verify must succeed (rc=$rc) [$out]"
|
||||||
[ -f "$UD/$TIMER" ] || fail_msg "I6: overlap phase must LEAVE the legacy timer running (retire withheld)"
|
[ -f "$UD/$TIMER" ] || fail_msg "I6: overlap phase must LEAVE the legacy timer running (retire withheld)"
|
||||||
[ -f "$SNAP/$TIMER" ] || fail_msg "I6: the legacy timer must be snapshotted before any retire"
|
[ -f "$SNAP/$TIMER" ] || fail_msg "I6: the legacy timer must be snapshotted before any retire"
|
||||||
echo "$out" | grep -qi 'LEFT RUNNING' || fail_msg "I6: overlap must announce retire is withheld [$out]"
|
echo "$out" | has_match -qi 'LEFT RUNNING' || fail_msg "I6: overlap must announce retire is withheld [$out]"
|
||||||
# (b) §4-vector pass: retire LAST, snapshot-guarded (fallback proven live above).
|
# (b) §4-vector pass: retire LAST, snapshot-guarded (fallback proven live above).
|
||||||
out2="$(bash "$WI" reset-verify-retire c --interval 30min --vector-passed 2>&1)"; rc2=$?
|
out2="$(bash "$WI" reset-verify-retire c --interval 30min --vector-passed 2>&1)"; rc2=$?
|
||||||
[ "$rc2" -eq 0 ] || fail_msg "I6: vector-passed retire must succeed (rc=$rc2) [$out2]"
|
[ "$rc2" -eq 0 ] || fail_msg "I6: vector-passed retire must succeed (rc=$rc2) [$out2]"
|
||||||
@@ -283,11 +290,11 @@ echo "== I9: dep-check — a host seed missing _lib/manifest.sh FAILS LOUD (name
|
|||||||
TGT="$(fresh i9-target)"
|
TGT="$(fresh i9-target)"
|
||||||
out="$(WAKE_INSTALL_SOURCE="$FAKE" WAKE_INSTALL_TARGET="$TGT" bash "$WI_FAKE" install 2>&1)"; rc=$?
|
out="$(WAKE_INSTALL_SOURCE="$FAKE" WAKE_INSTALL_TARGET="$TGT" bash "$WI_FAKE" install 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "I9: install MUST fail when _lib/manifest.sh is missing (rc=$rc)"
|
[ "$rc" -ne 0 ] || fail_msg "I9: install MUST fail when _lib/manifest.sh is missing (rc=$rc)"
|
||||||
echo "$out" | grep -qi 'manifest.sh' || fail_msg "I9: the failure must NAME the missing library (manifest.sh) [$out]"
|
echo "$out" | has_match -qi 'manifest.sh' || fail_msg "I9: the failure must NAME the missing library (manifest.sh) [$out]"
|
||||||
echo "$out" | grep -qiE 'REMEDY|mosaic update|re-seed|framework install' \
|
echo "$out" | has_match -qiE 'REMEDY|mosaic update|re-seed|framework install' \
|
||||||
|| fail_msg "I9: the failure must give an actionable REMEDY, not just an error [$out]"
|
|| fail_msg "I9: the failure must give an actionable REMEDY, not just an error [$out]"
|
||||||
# It must be a CLEAR fail-loud, NOT the obscure bare `source: No such file or directory`.
|
# It must be a CLEAR fail-loud, NOT the obscure bare `source: No such file or directory`.
|
||||||
echo "$out" | grep -qi 'No such file or directory' \
|
echo "$out" | has_match -qi 'No such file or directory' \
|
||||||
&& fail_msg "I9: must not fail with a bare source error (obscure) [$out]"
|
&& fail_msg "I9: must not fail with a bare source error (obscure) [$out]"
|
||||||
# Positive control: with the helper present (real framework root) install succeeds.
|
# Positive control: with the helper present (real framework root) install succeeds.
|
||||||
TGT_OK="$(fresh i9-target-ok)"
|
TGT_OK="$(fresh i9-target-ok)"
|
||||||
@@ -313,10 +320,10 @@ echo "== I10: systemd search-path — install links mosaic-wake.service into the
|
|||||||
rm -f "$UD/mosaic-wake.service"
|
rm -f "$UD/mosaic-wake.service"
|
||||||
out2="$(bash "$WI" validate-systemd-path 2>&1)"; rc2=$?
|
out2="$(bash "$WI" validate-systemd-path 2>&1)"; rc2=$?
|
||||||
[ "$rc2" -ne 0 ] || fail_msg "I10: validate must FAIL when the unit is not in the search path (rc=$rc2) [$out2]"
|
[ "$rc2" -ne 0 ] || fail_msg "I10: validate must FAIL when the unit is not in the search path (rc=$rc2) [$out2]"
|
||||||
echo "$out2" | grep -qi 'search path' || fail_msg "I10: validate failure must name the search-path miss [$out2]"
|
echo "$out2" | has_match -qi 'search path' || fail_msg "I10: validate failure must name the search-path miss [$out2]"
|
||||||
# (c) idempotent re-install: exactly one search-path entry, still resolvable.
|
# (c) idempotent re-install: exactly one search-path entry, still resolvable.
|
||||||
bash "$WI" install >/dev/null 2>&1 || fail_msg "I10: re-run install must succeed"
|
bash "$WI" install >/dev/null 2>&1 || fail_msg "I10: re-run install must succeed"
|
||||||
n="$(find "$UD" -maxdepth 1 -name 'mosaic-wake.service' | grep -c .)"
|
n="$(find "$UD" -maxdepth 1 -name 'mosaic-wake.service' | count_lines .)"
|
||||||
[ "$n" -eq 1 ] || fail_msg "I10: re-install must not duplicate the search-path entry (found $n)"
|
[ "$n" -eq 1 ] || fail_msg "I10: re-install must not duplicate the search-path entry (found $n)"
|
||||||
bash "$WI" validate-systemd-path >/dev/null 2>&1 || fail_msg "I10: re-install must keep the unit resolvable"
|
bash "$WI" validate-systemd-path >/dev/null 2>&1 || fail_msg "I10: re-install must keep the unit resolvable"
|
||||||
) && ok
|
) && ok
|
||||||
@@ -334,12 +341,12 @@ echo "== I11: F7 — the legacy reap REFUSES unless the canon fallback wake is p
|
|||||||
# it must REFUSE (schedulable floor not met).
|
# it must REFUSE (schedulable floor not met).
|
||||||
out0="$(bash "$WI" fallback-proven-live 2>&1)"; rc0=$?
|
out0="$(bash "$WI" fallback-proven-live 2>&1)"; rc0=$?
|
||||||
[ "$rc0" -ne 0 ] || fail_msg "I11: fallback-proven-live must FAIL when the fallback wake is not installed (rc=$rc0)"
|
[ "$rc0" -ne 0 ] || fail_msg "I11: fallback-proven-live must FAIL when the fallback wake is not installed (rc=$rc0)"
|
||||||
echo "$out0" | grep -qi 'F7' || fail_msg "I11: the refusal must name the F7 precondition [$out0]"
|
echo "$out0" | has_match -qi 'F7' || fail_msg "I11: the refusal must name the F7 precondition [$out0]"
|
||||||
# (b) NEGATIVE — vector-passed reap with NO live fallback must be REFUSED and the
|
# (b) NEGATIVE — vector-passed reap with NO live fallback must be REFUSED and the
|
||||||
# legacy timer LEFT RUNNING (never a coverage gap).
|
# legacy timer LEFT RUNNING (never a coverage gap).
|
||||||
out="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc=$?
|
out="$(bash "$WI" reset-verify-retire f --interval 30min --vector-passed 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "I11: reap must be REFUSED without a proven-live fallback (rc=$rc)"
|
[ "$rc" -ne 0 ] || fail_msg "I11: reap must be REFUSED without a proven-live fallback (rc=$rc)"
|
||||||
echo "$out" | grep -qi 'FALLBACK WAKE is not proven live' || fail_msg "I11: refusal must name the un-proven fallback [$out]"
|
echo "$out" | has_match -qi 'FALLBACK WAKE is not proven live' || fail_msg "I11: refusal must name the un-proven fallback [$out]"
|
||||||
[ -f "$UD/$TIMER" ] || fail_msg "I11: a refused reap must LEAVE the legacy timer running (F7 — no coverage gap)"
|
[ -f "$UD/$TIMER" ] || fail_msg "I11: a refused reap must LEAVE the legacy timer running (F7 — no coverage gap)"
|
||||||
# (c) POSITIVE — provision the fallback at the schedulable floor, then the reap
|
# (c) POSITIVE — provision the fallback at the schedulable floor, then the reap
|
||||||
# proceeds (F7 satisfied) and the legacy timer is retired.
|
# proceeds (F7 satisfied) and the legacy timer is retired.
|
||||||
@@ -367,11 +374,11 @@ echo "== I12: fallback drain — a STALLED detector still gets delivery via the
|
|||||||
# even with no detector running — no starvation of the drain.
|
# even with no detector running — no starvation of the drain.
|
||||||
out="$(bash "$DIGEST" render --from-store 2>/dev/null)"; rc=$?
|
out="$(bash "$DIGEST" render --from-store 2>/dev/null)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "I12: the canon fallback drain must exit 0 (rc=$rc)"
|
[ "$rc" -eq 0 ] || fail_msg "I12: the canon fallback drain must exit 0 (rc=$rc)"
|
||||||
echo "$out" | grep -q 'STALLED-DRAIN-MARK' \
|
echo "$out" | has_match -q 'STALLED-DRAIN-MARK' \
|
||||||
|| fail_msg "I12: the fallback drain must DELIVER the pending obligation a stalled detector left (no delivery starvation) [$out]"
|
|| fail_msg "I12: the fallback drain must DELIVER the pending obligation a stalled detector left (no delivery starvation) [$out]"
|
||||||
# Bind the invariant to the SHIPPED unit: its ExecStart must be this canon drain.
|
# Bind the invariant to the SHIPPED unit: its ExecStart must be this canon drain.
|
||||||
UNIT="$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service"
|
UNIT="$FRAMEWORK_ROOT/systemd/user/mosaic-wake-fallback.service"
|
||||||
grep -qE '^ExecStart=.*digest\.sh render --from-store' "$UNIT" \
|
has_match -qE '^ExecStart=.*digest\.sh render --from-store' "$UNIT" \
|
||||||
|| fail_msg "I12: mosaic-wake-fallback.service ExecStart must fire the canon drain (digest.sh render --from-store)"
|
|| fail_msg "I12: mosaic-wake-fallback.service ExecStart must fire the canon drain (digest.sh render --from-store)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -391,11 +398,11 @@ echo "== I13: install links + validates the canon fallback timer+service into th
|
|||||||
rm -f "$UD/mosaic-wake-fallback.timer"
|
rm -f "$UD/mosaic-wake-fallback.timer"
|
||||||
out="$(bash "$WI" validate-fallback-units 2>&1)"; rc=$?
|
out="$(bash "$WI" validate-fallback-units 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "I13: validate must FAIL when a fallback unit is not in the search path (rc=$rc) [$out]"
|
[ "$rc" -ne 0 ] || fail_msg "I13: validate must FAIL when a fallback unit is not in the search path (rc=$rc) [$out]"
|
||||||
echo "$out" | grep -qi 'search path' || fail_msg "I13: validate failure must name the search-path miss [$out]"
|
echo "$out" | has_match -qi 'search path' || fail_msg "I13: validate failure must name the search-path miss [$out]"
|
||||||
# (c) idempotent re-install: exactly one entry per unit, still resolvable.
|
# (c) idempotent re-install: exactly one entry per unit, still resolvable.
|
||||||
bash "$WI" install >/dev/null 2>&1 || fail_msg "I13: re-run install must succeed"
|
bash "$WI" install >/dev/null 2>&1 || fail_msg "I13: re-run install must succeed"
|
||||||
nt="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.timer' | grep -c .)"
|
nt="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.timer' | count_lines .)"
|
||||||
ns="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.service' | grep -c .)"
|
ns="$(find "$UD" -maxdepth 1 -name 'mosaic-wake-fallback.service' | count_lines .)"
|
||||||
[ "$nt" -eq 1 ] && [ "$ns" -eq 1 ] || fail_msg "I13: re-install must not duplicate the fallback entries (timer=$nt service=$ns)"
|
[ "$nt" -eq 1 ] && [ "$ns" -eq 1 ] || fail_msg "I13: re-install must not duplicate the fallback entries (timer=$nt service=$ns)"
|
||||||
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: re-install must keep the fallback units resolvable"
|
bash "$WI" validate-fallback-units >/dev/null 2>&1 || fail_msg "I13: re-install must keep the fallback units resolvable"
|
||||||
) && ok
|
) && ok
|
||||||
@@ -407,17 +414,17 @@ echo "== I14: per-class fallback cadence — blank-reset yields EXACTLY ONE OnUn
|
|||||||
TIMER="mosaic-wake-fallback.timer"
|
TIMER="mosaic-wake-fallback.timer"
|
||||||
# The shipped timer carries the base OnUnitActiveSec placeholder (=1h).
|
# The shipped timer carries the base OnUnitActiveSec placeholder (=1h).
|
||||||
cp "$FRAMEWORK_ROOT/systemd/user/$TIMER" "$UD/$TIMER"
|
cp "$FRAMEWORK_ROOT/systemd/user/$TIMER" "$UD/$TIMER"
|
||||||
grep -q '^OnUnitActiveSec=1h' "$UD/$TIMER" || fail_msg "I14: shipped fallback timer must carry a base OnUnitActiveSec placeholder"
|
has_match -q '^OnUnitActiveSec=1h' "$UD/$TIMER" || fail_msg "I14: shipped fallback timer must carry a base OnUnitActiveSec placeholder"
|
||||||
# write-fallback-cadence writes the per-class cadence in BLANK-RESET form.
|
# write-fallback-cadence writes the per-class cadence in BLANK-RESET form.
|
||||||
out="$(bash "$WI" write-fallback-cadence 30min 2>&1)"; rc=$?
|
out="$(bash "$WI" write-fallback-cadence 30min 2>&1)"; rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "I14: write-fallback-cadence must succeed + verify single (rc=$rc) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "I14: write-fallback-cadence must succeed + verify single (rc=$rc) [$out]"
|
||||||
echo "$out" | grep -qi 'exactly one' || fail_msg "I14: the cadence write must confirm exactly-one OnUnitActiveUSec [$out]"
|
echo "$out" | has_match -qi 'exactly one' || fail_msg "I14: the cadence write must confirm exactly-one OnUnitActiveUSec [$out]"
|
||||||
[ -f "$UD/$TIMER.d/cadence.conf" ] || fail_msg "I14: the per-class cadence drop-in must be written under <timer>.d/"
|
[ -f "$UD/$TIMER.d/cadence.conf" ] || fail_msg "I14: the per-class cadence drop-in must be written under <timer>.d/"
|
||||||
# Assert the blank-reset FORM: an empty `OnUnitActiveSec=` reset line IMMEDIATELY
|
# Assert the blank-reset FORM: an empty `OnUnitActiveSec=` reset line IMMEDIATELY
|
||||||
# FOLLOWED by the new value. Portable across GNU and BusyBox grep (Alpine CI) —
|
# FOLLOWED by the new value. Portable across GNU and BusyBox grep (Alpine CI) —
|
||||||
# `grep -z` (NUL-data) is a GNU-only extension BusyBox grep does NOT support, so
|
# `grep -z` (NUL-data) is a GNU-only extension BusyBox grep does NOT support, so
|
||||||
# match the reset line and require the value on the very next line (-A1) instead.
|
# match the reset line and require the value on the very next line (-A1) instead.
|
||||||
grep -A1 '^OnUnitActiveSec=$' "$UD/$TIMER.d/cadence.conf" | grep -qx 'OnUnitActiveSec=30min' \
|
has_match -A1 '^OnUnitActiveSec=$' "$UD/$TIMER.d/cadence.conf" | has_match -qx 'OnUnitActiveSec=30min' \
|
||||||
|| fail_msg "I14: the drop-in must use the blank-reset form (empty reset line, then the value)"
|
|| fail_msg "I14: the drop-in must use the blank-reset form (empty reset line, then the value)"
|
||||||
bash "$WI" verify-single "$TIMER" >/dev/null 2>&1 || fail_msg "I14: base(1h)+blank-reset(30min) must resolve exactly one OnUnitActiveUSec"
|
bash "$WI" verify-single "$TIMER" >/dev/null 2>&1 || fail_msg "I14: base(1h)+blank-reset(30min) must resolve exactly one OnUnitActiveUSec"
|
||||||
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> base 1h + 30min -> two -> fail.
|
# NEGATIVE CONTROL: a drop-in WITHOUT the reset line appends -> base 1h + 30min -> two -> fail.
|
||||||
@@ -428,7 +435,7 @@ echo "== I14: per-class fallback cadence — blank-reset yields EXACTLY ONE OnUn
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake install harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake install harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake install harness: all invariants passed ($pass groups)"
|
echo "wake install harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -46,6 +46,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
PRE="$SCRIPT_DIR/preimage.sh"
|
PRE="$SCRIPT_DIR/preimage.sh"
|
||||||
STORE="$SCRIPT_DIR/store.sh"
|
STORE="$SCRIPT_DIR/store.sh"
|
||||||
DET="$SCRIPT_DIR/detector.sh"
|
DET="$SCRIPT_DIR/detector.sh"
|
||||||
@@ -202,9 +209,9 @@ echo "== P4: credential-store PATH refused — bytes never captured (acceptance
|
|||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P4: row must record captured=false [$row]"
|
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P4: row must record captured=false [$row]"
|
||||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'path' || fail_msg "P4: refusal must name the path deny [$row]"
|
jq -r '.refused // ""' <<<"$row" | has_match -qi 'path' || fail_msg "P4: refusal must name the path deny [$row]"
|
||||||
# THE invariant: the secret bytes exist NOWHERE under the wake state dir.
|
# THE invariant: the secret bytes exist NOWHERE under the wake state dir.
|
||||||
if grep -rq "$secret" "$sd" 2>/dev/null; then
|
if has_match -rq "$secret" "$sd" 2>/dev/null; then
|
||||||
fail_msg "P4: credential bytes leaked into the wake state dir"
|
fail_msg "P4: credential bytes leaked into the wake state dir"
|
||||||
fi
|
fi
|
||||||
) && ok
|
) && ok
|
||||||
@@ -225,8 +232,8 @@ echo "== P5: secret-SHAPED content refused (refusal, not redaction) =="
|
|||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P5: row must record captured=false [$row]"
|
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P5: row must record captured=false [$row]"
|
||||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P5: refusal must name secret-shaped content [$row]"
|
jq -r '.refused // ""' <<<"$row" | has_match -qi 'secret' || fail_msg "P5: refusal must name secret-shaped content [$row]"
|
||||||
if grep -rq "$tok" "$sd" 2>/dev/null; then
|
if has_match -rq "$tok" "$sd" 2>/dev/null; then
|
||||||
fail_msg "P5: secret-shaped bytes leaked into the wake state dir"
|
fail_msg "P5: secret-shaped bytes leaked into the wake state dir"
|
||||||
fi
|
fi
|
||||||
) && ok
|
) && ok
|
||||||
@@ -275,7 +282,7 @@ echo "== P8: unresolvable adapter -> FAIL LOUD, never 'no change' (D2 class) =="
|
|||||||
unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA MOSAIC_HOME
|
unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA MOSAIC_HOME
|
||||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "P8: an unobservable preimage definition must FAIL LOUD (rc=0)"
|
[ "$rc" -ne 0 ] || fail_msg "P8: an unobservable preimage definition must FAIL LOUD (rc=0)"
|
||||||
echo "$out" | grep -qi 'does not resolve' || fail_msg "P8: the failure must name the unresolvable adapter [$out]"
|
echo "$out" | has_match -qi 'does not resolve' || fail_msg "P8: the failure must name the unresolvable adapter [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== P9: corrupt ledger -> REFUSE to compare/re-baseline =="
|
echo "== P9: corrupt ledger -> REFUSE to compare/re-baseline =="
|
||||||
@@ -294,7 +301,7 @@ echo "== P9: corrupt ledger -> REFUSE to compare/re-baseline =="
|
|||||||
printf 'v2 changed\n' >"$fx/f.env"
|
printf 'v2 changed\n' >"$fx/f.env"
|
||||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "P9: a corrupt ledger must FAIL LOUD (rc=0)"
|
[ "$rc" -ne 0 ] || fail_msg "P9: a corrupt ledger must FAIL LOUD (rc=0)"
|
||||||
echo "$out" | grep -qi 'unparseable' || fail_msg "P9: the failure must name the corrupt ledger [$out]"
|
echo "$out" | has_match -qi 'unparseable' || fail_msg "P9: the failure must name the corrupt ledger [$out]"
|
||||||
[ "$(wc -l <"$sd/preimage/preimage-ledger.jsonl")" = "$r1" ] || fail_msg "P9: nothing may be appended over corrupt history"
|
[ "$(wc -l <"$sd/preimage/preimage-ledger.jsonl")" = "$r1" ] || fail_msg "P9: nothing may be appended over corrupt history"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -314,7 +321,7 @@ echo "== P10: oversized file -> bytes refused, hash still recorded =="
|
|||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P10: row must record captured=false [$row]"
|
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P10: row must record captured=false [$row]"
|
||||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'MAX_BYTES' || fail_msg "P10: refusal must name the size cap [$row]"
|
jq -r '.refused // ""' <<<"$row" | has_match -qi 'MAX_BYTES' || fail_msg "P10: refusal must name the size cap [$row]"
|
||||||
sha="$(jq -r '.sha256' <<<"$row")"
|
sha="$(jq -r '.sha256' <<<"$row")"
|
||||||
[ "$sha" = "$(sha256sum "$fx/big.bin" | awk '{print $1}')" ] || fail_msg "P10: hash must still be recorded [$row]"
|
[ "$sha" = "$(sha256sum "$fx/big.bin" | awk '{print $1}')" ] || fail_msg "P10: hash must still be recorded [$row]"
|
||||||
[ ! -f "$sd/preimage/objects/$sha" ] || fail_msg "P10: oversized bytes must NOT be stored"
|
[ ! -f "$sd/preimage/objects/$sha" ] || fail_msg "P10: oversized bytes must NOT be stored"
|
||||||
@@ -362,7 +369,7 @@ echo "== P12: detector — preimage infra failure is LOUD but does not starve ob
|
|||||||
printf 'NOT-JSON-GARBAGE{{{\n' >"$sd/preimage/preimage-ledger.jsonl"
|
printf 'NOT-JSON-GARBAGE{{{\n' >"$sd/preimage/preimage-ledger.jsonl"
|
||||||
out="$("$DET" poll-once 2>&1)"; rc=$?
|
out="$("$DET" poll-once 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "P12: the pass must exit non-zero on preimage infra failure"
|
[ "$rc" -ne 0 ] || fail_msg "P12: the pass must exit non-zero on preimage infra failure"
|
||||||
echo "$out" | grep -qi 'preimage' || fail_msg "P12: the failure must name the preimage check [$out]"
|
echo "$out" | has_match -qi 'preimage' || fail_msg "P12: the failure must name the preimage check [$out]"
|
||||||
n="$(find "$sd/detector" -name 'watch-*.hash' 2>/dev/null | wc -l | tr -d '[:space:]')"
|
n="$(find "$sd/detector" -name 'watch-*.hash' 2>/dev/null | wc -l | tr -d '[:space:]')"
|
||||||
[ "$n" -ge 1 ] || fail_msg "P12: source observation must still proceed (no watch hash baselined)"
|
[ "$n" -ge 1 ] || fail_msg "P12: source observation must still proceed (no watch hash baselined)"
|
||||||
) && ok
|
) && ok
|
||||||
@@ -397,10 +404,10 @@ echo "== P13: symlinked/renamed credential store refused on BOTH path forms (#96
|
|||||||
n="$(jq -r 'select(.captured == false) | .path' "$sd/preimage/preimage-ledger.jsonl" | wc -l | tr -d '[:space:]')"
|
n="$(jq -r 'select(.captured == false) | .path' "$sd/preimage/preimage-ledger.jsonl" | wc -l | tr -d '[:space:]')"
|
||||||
[ "$n" = "2" ] || fail_msg "P13: both decoys must record captured=false (got $n)"
|
[ "$n" = "2" ] || fail_msg "P13: both decoys must record captured=false (got $n)"
|
||||||
# THE invariant (decoy method): the planted bytes exist NOWHERE in state.
|
# THE invariant (decoy method): the planted bytes exist NOWHERE in state.
|
||||||
if grep -rq "$s1" "$sd" 2>/dev/null; then
|
if has_match -rq "$s1" "$sd" 2>/dev/null; then
|
||||||
fail_msg "P13: renamed-target store bytes leaked into the wake state dir"
|
fail_msg "P13: renamed-target store bytes leaked into the wake state dir"
|
||||||
fi
|
fi
|
||||||
if grep -rq "$s2" "$sd" 2>/dev/null; then
|
if has_match -rq "$s2" "$sd" 2>/dev/null; then
|
||||||
fail_msg "P13: prefix-less key bytes leaked into the wake state dir"
|
fail_msg "P13: prefix-less key bytes leaked into the wake state dir"
|
||||||
fi
|
fi
|
||||||
) && ok
|
) && ok
|
||||||
@@ -423,7 +430,7 @@ echo "== P14: detector.env-class key — safe WITHOUT opt-in by polarity, refuse
|
|||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14a: extra must be record-only without opt-in [$row]"
|
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14a: extra must be record-only without opt-in [$row]"
|
||||||
if grep -rq "$hm" "$sd" 2>/dev/null; then
|
if has_match -rq "$hm" "$sd" 2>/dev/null; then
|
||||||
fail_msg "P14a: key bytes leaked without any opt-in"
|
fail_msg "P14a: key bytes leaked without any opt-in"
|
||||||
fi
|
fi
|
||||||
# (b) The operator opts the env file in (the exact error the old usage text
|
# (b) The operator opts the env file in (the exact error the old usage text
|
||||||
@@ -434,8 +441,8 @@ echo "== P14: detector.env-class key — safe WITHOUT opt-in by polarity, refuse
|
|||||||
[ "$rc" -eq 0 ] || fail_msg "P14b: refusal is not an infra failure (rc=$rc) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "P14b: refusal is not an infra failure (rc=$rc) [$out]"
|
||||||
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
|
||||||
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14b: opted-in key material must still refuse [$row]"
|
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14b: opted-in key material must still refuse [$row]"
|
||||||
jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P14b: refusal must name secret-shaped content [$row]"
|
jq -r '.refused // ""' <<<"$row" | has_match -qi 'secret' || fail_msg "P14b: refusal must name secret-shaped content [$row]"
|
||||||
if grep -rq "$hm" "$sd" 2>/dev/null; then
|
if has_match -rq "$hm" "$sd" 2>/dev/null; then
|
||||||
fail_msg "P14b: key bytes leaked despite refusal"
|
fail_msg "P14b: key bytes leaked despite refusal"
|
||||||
fi
|
fi
|
||||||
) && ok
|
) && ok
|
||||||
@@ -488,7 +495,7 @@ echo "== P16: deleted ledger over surviving objects/ — REFUSE to re-baseline (
|
|||||||
printf '# v3\n' >>"$fx/adapter.sh"
|
printf '# v3\n' >>"$fx/adapter.sh"
|
||||||
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "P16: absent ledger over prior objects must FAIL LOUD (rc=0) [$out]"
|
[ "$rc" -ne 0 ] || fail_msg "P16: absent ledger over prior objects must FAIL LOUD (rc=0) [$out]"
|
||||||
echo "$out" | grep -qi 'REFUSING to re-baseline' || fail_msg "P16: the failure must name the refusal [$out]"
|
echo "$out" | has_match -qi 'REFUSING to re-baseline' || fail_msg "P16: the failure must name the refusal [$out]"
|
||||||
[ ! -e "$sd/preimage/preimage-ledger.jsonl" ] || fail_msg "P16: the ledger must NOT be silently recreated over deleted history"
|
[ ! -e "$sd/preimage/preimage-ledger.jsonl" ] || fail_msg "P16: the ledger must NOT be silently recreated over deleted history"
|
||||||
[ "$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')" = "$nobj" ] || fail_msg "P16: prior objects must remain untouched"
|
[ "$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')" = "$nobj" ] || fail_msg "P16: prior objects must remain untouched"
|
||||||
[ "$(depth)" = "0" ] || fail_msg "P16: the v2->v3 change must NOT be absorbed or enqueued from an unverifiable baseline (depth=$(depth))"
|
[ "$(depth)" = "0" ] || fail_msg "P16: the v2->v3 change must NOT be absorbed or enqueued from an unverifiable baseline (depth=$(depth))"
|
||||||
@@ -520,7 +527,7 @@ echo "== P17: allowlist symmetry — symlink to a DENIED target refuses; symlink
|
|||||||
sd="$(state_dir)"
|
sd="$(state_dir)"
|
||||||
crow="$(jq -c --arg p "$(realpath "$fx/settings.json")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
|
crow="$(jq -c --arg p "$(realpath "$fx/settings.json")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
|
||||||
[ "$(jq -r '.captured' <<<"$crow")" = "false" ] || fail_msg "P17: allowlisted symlink to a denied target must refuse [$crow]"
|
[ "$(jq -r '.captured' <<<"$crow")" = "false" ] || fail_msg "P17: allowlisted symlink to a denied target must refuse [$crow]"
|
||||||
if grep -rq "$s" "$sd" 2>/dev/null; then
|
if has_match -rq "$s" "$sd" 2>/dev/null; then
|
||||||
fail_msg "P17: credential bytes leaked via an allowlisted alias"
|
fail_msg "P17: credential bytes leaked via an allowlisted alias"
|
||||||
fi
|
fi
|
||||||
brow="$(jq -c --arg p "$(realpath "$fx/alias.cfg")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
|
brow="$(jq -c --arg p "$(realpath "$fx/alias.cfg")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
|
||||||
|
|||||||
@@ -29,6 +29,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
RECON="$SCRIPT_DIR/reconcile.sh"
|
RECON="$SCRIPT_DIR/reconcile.sh"
|
||||||
STORE="$SCRIPT_DIR/store.sh"
|
STORE="$SCRIPT_DIR/store.sh"
|
||||||
DET="$SCRIPT_DIR/detector.sh"
|
DET="$SCRIPT_DIR/detector.sh"
|
||||||
@@ -87,7 +94,7 @@ EOF
|
|||||||
out="$("$RECON" inventory 2>&1)"
|
out="$("$RECON" inventory 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "R1: a complete inventory must PASS (exit 0) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "R1: a complete inventory must PASS (exit 0) [$out]"
|
||||||
echo "$out" | grep -qi 'COMPLETE' || fail_msg "R1: a complete inventory must report COMPLETE [$out]"
|
echo "$out" | has_match -qi 'COMPLETE' || fail_msg "R1: a complete inventory must report COMPLETE [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== R2: OMITTED source -> FLAG (vacuous-pass prevented, not silently green) =="
|
echo "== R2: OMITTED source -> FLAG (vacuous-pass prevented, not silently green) =="
|
||||||
@@ -106,8 +113,8 @@ EOF
|
|||||||
out="$("$RECON" inventory 2>&1)"
|
out="$("$RECON" inventory 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "R2: an omitted (declared-but-unwatched) source must FLAG (non-zero), not silently pass"
|
[ "$rc" -ne 0 ] || fail_msg "R2: an omitted (declared-but-unwatched) source must FLAG (non-zero), not silently pass"
|
||||||
echo "$out" | grep -qi 'r2' || fail_msg "R2: the flag must name the omitted source r2 [$out]"
|
echo "$out" | has_match -qi 'r2' || fail_msg "R2: the flag must name the omitted source r2 [$out]"
|
||||||
echo "$out" | grep -qi 'vacuous' || fail_msg "R2: the flag must state the vacuous-pass is prevented [$out]"
|
echo "$out" | has_match -qi 'vacuous' || fail_msg "R2: the flag must state the vacuous-pass is prevented [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== R3: required_sources omission -> FLAG =="
|
echo "== R3: required_sources omission -> FLAG =="
|
||||||
@@ -126,7 +133,7 @@ EOF
|
|||||||
out="$("$RECON" inventory 2>&1)"
|
out="$("$RECON" inventory 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "R3: a required_sources omission must FLAG (non-zero)"
|
[ "$rc" -ne 0 ] || fail_msg "R3: a required_sources omission must FLAG (non-zero)"
|
||||||
echo "$out" | grep -qi "requires source 'r2'" || fail_msg "R3: the flag must name the required-but-omitted source r2 [$out]"
|
echo "$out" | has_match -qi "requires source 'r2'" || fail_msg "R3: the flag must name the required-but-omitted source r2 [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== R4: dangling watch reference -> FLAG =="
|
echo "== R4: dangling watch reference -> FLAG =="
|
||||||
@@ -144,7 +151,7 @@ EOF
|
|||||||
out="$("$RECON" inventory 2>&1)"
|
out="$("$RECON" inventory 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "R4: a dangling reference must FLAG (non-zero)"
|
[ "$rc" -ne 0 ] || fail_msg "R4: a dangling reference must FLAG (non-zero)"
|
||||||
echo "$out" | grep -qi 'dangling' || fail_msg "R4: the flag must state it is dangling [$out]"
|
echo "$out" | has_match -qi 'dangling' || fail_msg "R4: the flag must state it is dangling [$out]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== R5/R6: pre-existing state -> UNACCOUNTED flag + enumerated into store; re-run 0 =="
|
echo "== R5/R6: pre-existing state -> UNACCOUNTED flag + enumerated into store; re-run 0 =="
|
||||||
@@ -173,14 +180,14 @@ EOF
|
|||||||
out="$("$RECON" reconcile 2>&1)"
|
out="$("$RECON" reconcile 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "R5: pre-existing unaccounted state must FLAG (non-zero) on first reconcile"
|
[ "$rc" -ne 0 ] || fail_msg "R5: pre-existing unaccounted state must FLAG (non-zero) on first reconcile"
|
||||||
echo "$out" | grep -qi 'UNACCOUNTED=2' || fail_msg "R5: both pre-existing sources must be UNACCOUNTED [$out]"
|
echo "$out" | has_match -qi 'UNACCOUNTED=2' || fail_msg "R5: both pre-existing sources must be UNACCOUNTED [$out]"
|
||||||
# R6: enumerated into the durable store — one entry per pre-existing source.
|
# R6: enumerated into the durable store — one entry per pre-existing source.
|
||||||
[ "$(depth)" = "2" ] || fail_msg "R6: pre-existing state must be ENUMERATED into the store (depth 2), got $(depth)"
|
[ "$(depth)" = "2" ] || fail_msg "R6: pre-existing state must be ENUMERATED into the store (depth 2), got $(depth)"
|
||||||
# A follow-up reconcile now finds everything accounted -> 0 unaccounted.
|
# A follow-up reconcile now finds everything accounted -> 0 unaccounted.
|
||||||
out2="$("$RECON" reconcile 2>&1)"
|
out2="$("$RECON" reconcile 2>&1)"
|
||||||
rc2=$?
|
rc2=$?
|
||||||
[ "$rc2" -eq 0 ] || fail_msg "R6: a second reconcile must report 0 unaccounted (exit 0) [$out2]"
|
[ "$rc2" -eq 0 ] || fail_msg "R6: a second reconcile must report 0 unaccounted (exit 0) [$out2]"
|
||||||
echo "$out2" | grep -qi 'UNACCOUNTED=0' || fail_msg "R6: second reconcile must be 0 unaccounted [$out2]"
|
echo "$out2" | has_match -qi 'UNACCOUNTED=0' || fail_msg "R6: second reconcile must be 0 unaccounted [$out2]"
|
||||||
[ "$(depth)" = "2" ] || fail_msg "R6: a clean reconcile must NOT re-enumerate (depth still 2), got $(depth)"
|
[ "$(depth)" = "2" ] || fail_msg "R6: a clean reconcile must NOT re-enumerate (depth still 2), got $(depth)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -211,7 +218,7 @@ EOF
|
|||||||
out="$("$RECON" reconcile 2>&1)"
|
out="$("$RECON" reconcile 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "R7: state reflected in the inbox must be ACCOUNTED (exit 0) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "R7: state reflected in the inbox must be ACCOUNTED (exit 0) [$out]"
|
||||||
echo "$out" | grep -qi 'UNACCOUNTED=0' || fail_msg "R7: inbox-reflected state must be 0 unaccounted [$out]"
|
echo "$out" | has_match -qi 'UNACCOUNTED=0' || fail_msg "R7: inbox-reflected state must be 0 unaccounted [$out]"
|
||||||
[ "$(depth)" = "1" ] || fail_msg "R7: reconciler must NOT double-enumerate inbox-reflected state (depth still 1), got $(depth)"
|
[ "$(depth)" = "1" ] || fail_msg "R7: reconciler must NOT double-enumerate inbox-reflected state (depth still 1), got $(depth)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -236,7 +243,7 @@ EOF
|
|||||||
out="$("$RECON" reconcile 2>&1)"
|
out="$("$RECON" reconcile 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "R8: a source error must FAIL LOUD (non-zero exit)"
|
[ "$rc" -ne 0 ] || fail_msg "R8: a source error must FAIL LOUD (non-zero exit)"
|
||||||
echo "$out" | grep -qi 'FAIL LOUD' || fail_msg "R8: the source error must be loud [$out]"
|
echo "$out" | has_match -qi 'FAIL LOUD' || fail_msg "R8: the source error must be loud [$out]"
|
||||||
[ "$(depth)" = "0" ] || fail_msg "R8: a failed observation must NOT enumerate anything, got depth $(depth)"
|
[ "$(depth)" = "0" ] || fail_msg "R8: a failed observation must NOT enumerate anything, got depth $(depth)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -278,7 +285,7 @@ EOF
|
|||||||
out="$("$RECON" reconcile 2>&1)"
|
out="$("$RECON" reconcile 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -ne 0 ] || fail_msg "R9: pre-existing beta is unaccounted on this pass -> must FLAG (non-zero) [$out]"
|
[ "$rc" -ne 0 ] || fail_msg "R9: pre-existing beta is unaccounted on this pass -> must FLAG (non-zero) [$out]"
|
||||||
echo "$out" | grep -qi 'UNACCOUNTED=1' || fail_msg "R9: only beta should be unaccounted (alpha is inbox-accounted) [$out]"
|
echo "$out" | has_match -qi 'UNACCOUNTED=1' || fail_msg "R9: only beta should be unaccounted (alpha is inbox-accounted) [$out]"
|
||||||
[ "$(depth)" = "2" ] || fail_msg "R9: reconciler must ENUMERATE beta into the co-fed store (depth 2), got $(depth)"
|
[ "$(depth)" = "2" ] || fail_msg "R9: reconciler must ENUMERATE beta into the co-fed store (depth 2), got $(depth)"
|
||||||
|
|
||||||
# A further detector delta on beta must allocate 3 — the unified allocator
|
# A further detector delta on beta must allocate 3 — the unified allocator
|
||||||
@@ -292,8 +299,8 @@ EOF
|
|||||||
# already used, so this set would contain a duplicate (alias).
|
# already used, so this set would contain a duplicate (alias).
|
||||||
seqs="$("$STORE" drain | jq -r '.observed_seq' | sort -n | tr '\n' ' ')"
|
seqs="$("$STORE" drain | jq -r '.observed_seq' | sort -n | tr '\n' ' ')"
|
||||||
[ "$seqs" = "1 2 3 " ] || fail_msg "R9: co-fed observed_seqs must be distinct+gapless '1 2 3', got '$seqs' (a duplicate = the aliasing #908 dissolved)"
|
[ "$seqs" = "1 2 3 " ] || fail_msg "R9: co-fed observed_seqs must be distinct+gapless '1 2 3', got '$seqs' (a duplicate = the aliasing #908 dissolved)"
|
||||||
ndistinct="$("$STORE" drain | jq -r '.observed_seq' | sort -nu | grep -c .)"
|
ndistinct="$("$STORE" drain | jq -r '.observed_seq' | sort -nu | count_lines .)"
|
||||||
ntotal="$("$STORE" drain | jq -r '.observed_seq' | grep -c .)"
|
ntotal="$("$STORE" drain | jq -r '.observed_seq' | count_lines .)"
|
||||||
[ "$ndistinct" = "$ntotal" ] || fail_msg "R9: aliasing detected — $ntotal entries but only $ndistinct distinct observed_seq"
|
[ "$ndistinct" = "$ntotal" ] || fail_msg "R9: aliasing detected — $ntotal entries but only $ndistinct distinct observed_seq"
|
||||||
# The contiguous-prefix CONSUMED contract holds over the co-fed seqs.
|
# The contiguous-prefix CONSUMED contract holds over the co-fed seqs.
|
||||||
"$STORE" consume --upto 3 >/dev/null 2>&1 || fail_msg "R9: CONSUMED 3 must succeed over the gapless co-fed prefix"
|
"$STORE" consume --upto 3 >/dev/null 2>&1 || fail_msg "R9: CONSUMED 3 must succeed over the gapless co-fed prefix"
|
||||||
@@ -333,7 +340,7 @@ EOF
|
|||||||
out="$("$RECON" reconcile 2>&1)"
|
out="$("$RECON" reconcile 2>&1)"
|
||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "R10: a consumed state must be ACCOUNTED (exit 0, no spurious rc=1 CRITICAL) [$out]"
|
[ "$rc" -eq 0 ] || fail_msg "R10: a consumed state must be ACCOUNTED (exit 0, no spurious rc=1 CRITICAL) [$out]"
|
||||||
echo "$out" | grep -qi 'UNACCOUNTED=0' || fail_msg "R10: a consumed state must be 0 unaccounted (no re-enumeration) [$out]"
|
echo "$out" | has_match -qi 'UNACCOUNTED=0' || fail_msg "R10: a consumed state must be 0 unaccounted (no re-enumeration) [$out]"
|
||||||
[ "$(depth)" = "0" ] || fail_msg "R10: reconciler must NOT re-enumerate a consumed state (depth still 0 = no duplicate wake), got $(depth)"
|
[ "$(depth)" = "0" ] || fail_msg "R10: reconciler must NOT re-enumerate a consumed state (depth still 0 = no duplicate wake), got $(depth)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -372,7 +379,7 @@ EOF
|
|||||||
[ "$rc" -ne 0 ] || fail_msg "R11: a genuine gap (r2) must still FLAG (non-zero) [$out]"
|
[ "$rc" -ne 0 ] || fail_msg "R11: a genuine gap (r2) must still FLAG (non-zero) [$out]"
|
||||||
# After #932: ONLY r2 is unaccounted (r1 suppressed by the store record). On
|
# After #932: ONLY r2 is unaccounted (r1 suppressed by the store record). On
|
||||||
# BASE both r1 and r2 re-enumerate (UNACCOUNTED=2) -> this assertion is red-first.
|
# BASE both r1 and r2 re-enumerate (UNACCOUNTED=2) -> this assertion is red-first.
|
||||||
echo "$out" | grep -qi 'UNACCOUNTED=1' || fail_msg "R11: exactly ONE source (the gap r2) must be unaccounted; the consumed r1 must be suppressed [$out]"
|
echo "$out" | has_match -qi 'UNACCOUNTED=1' || fail_msg "R11: exactly ONE source (the gap r2) must be unaccounted; the consumed r1 must be suppressed [$out]"
|
||||||
# Prove precisely WHICH state re-enumerated: the gap r2 IS enumerated, the
|
# Prove precisely WHICH state re-enumerated: the gap r2 IS enumerated, the
|
||||||
# consumed r1 is NOT. (base re-enumerates r1 too -> the r1-absent assertion is
|
# consumed r1 is NOT. (base re-enumerates r1 too -> the r1-absent assertion is
|
||||||
# red-first; the r2-present assertion holds both before and after = no over-suppression.)
|
# red-first; the r2-present assertion holds both before and after = no over-suppression.)
|
||||||
@@ -383,7 +390,7 @@ EOF
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake reconcile harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake reconcile harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake reconcile harness: all invariants passed ($pass groups)"
|
echo "wake reconcile harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -42,6 +42,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
STORE="$SCRIPT_DIR/store.sh"
|
STORE="$SCRIPT_DIR/store.sh"
|
||||||
ACK="$SCRIPT_DIR/ack.sh"
|
ACK="$SCRIPT_DIR/ack.sh"
|
||||||
|
|
||||||
@@ -96,14 +103,14 @@ echo "== T1: three-cursor advancement =="
|
|||||||
"$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":1}' >/dev/null
|
"$STORE" enqueue --seq 1 --class actionable --locators '{"repo":"r","issue":1}' >/dev/null
|
||||||
"$STORE" enqueue --seq 2 --class actionable --locators '{"repo":"r","issue":2}' >/dev/null
|
"$STORE" enqueue --seq 2 --class actionable --locators '{"repo":"r","issue":2}' >/dev/null
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'observed_seq=2' || fail_msg "T1: observed_seq should be 2 after enqueue 1,2 [$cur]"
|
echo "$cur" | has_match -q 'observed_seq=2' || fail_msg "T1: observed_seq should be 2 after enqueue 1,2 [$cur]"
|
||||||
echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T1: consumed_seq must NOT advance on enqueue (only on CONSUMED ack) [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=0' || fail_msg "T1: consumed_seq must NOT advance on enqueue (only on CONSUMED ack) [$cur]"
|
||||||
echo "$cur" | grep -q 'pending_depth=2' || fail_msg "T1: pending depth should be 2 [$cur]"
|
echo "$cur" | has_match -q 'pending_depth=2' || fail_msg "T1: pending depth should be 2 [$cur]"
|
||||||
# consumed_seq advances only on CONSUMED.
|
# consumed_seq advances only on CONSUMED.
|
||||||
"$STORE" consume --upto 2 >/dev/null
|
"$STORE" consume --upto 2 >/dev/null
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'consumed_seq=2' || fail_msg "T1: consumed_seq should be 2 after CONSUMED 2 [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=2' || fail_msg "T1: consumed_seq should be 2 after CONSUMED 2 [$cur]"
|
||||||
echo "$cur" | grep -q 'pending_depth=0' || fail_msg "T1: pending drained after CONSUMED [$cur]"
|
echo "$cur" | has_match -q 'pending_depth=0' || fail_msg "T1: pending drained after CONSUMED [$cur]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== T2: digest coalesce-REPLACE vs actionable APPEND =="
|
echo "== T2: digest coalesce-REPLACE vs actionable APPEND =="
|
||||||
@@ -116,16 +123,16 @@ echo "== T2: digest coalesce-REPLACE vs actionable APPEND =="
|
|||||||
"$STORE" enqueue --seq 3 --class digest --locators '{"head":"bbb"}' >/dev/null
|
"$STORE" enqueue --seq 3 --class digest --locators '{"head":"bbb"}' >/dev/null
|
||||||
"$STORE" enqueue --seq 4 --class human --locators '{"from":"peer"}' >/dev/null
|
"$STORE" enqueue --seq 4 --class human --locators '{"from":"peer"}' >/dev/null
|
||||||
out="$("$STORE" drain)"
|
out="$("$STORE" drain)"
|
||||||
ndigest="$(printf '%s\n' "$out" | jq -c 'select(.class=="digest")' | grep -c . || true)"
|
ndigest="$(printf '%s\n' "$out" | jq -c 'select(.class=="digest")' | count_lines . || true)"
|
||||||
[ "$ndigest" = "1" ] || fail_msg "T2: digest must COALESCE to a single pending entry, got $ndigest"
|
[ "$ndigest" = "1" ] || fail_msg "T2: digest must COALESCE to a single pending entry, got $ndigest"
|
||||||
head="$(printf '%s\n' "$out" | jq -r 'select(.class=="digest") | .locators.head')"
|
head="$(printf '%s\n' "$out" | jq -r 'select(.class=="digest") | .locators.head')"
|
||||||
[ "$head" = "bbb" ] || fail_msg "T2: newest digest must REPLACE prior (expected bbb, got $head)"
|
[ "$head" = "bbb" ] || fail_msg "T2: newest digest must REPLACE prior (expected bbb, got $head)"
|
||||||
nactionable="$(printf '%s\n' "$out" | jq -c 'select(.class=="actionable")' | grep -c . || true)"
|
nactionable="$(printf '%s\n' "$out" | jq -c 'select(.class=="actionable")' | count_lines . || true)"
|
||||||
nhuman="$(printf '%s\n' "$out" | jq -c 'select(.class=="human")' | grep -c . || true)"
|
nhuman="$(printf '%s\n' "$out" | jq -c 'select(.class=="human")' | count_lines . || true)"
|
||||||
[ "$nactionable" = "1" ] || fail_msg "T2: actionable must APPEND (never replaced), got $nactionable"
|
[ "$nactionable" = "1" ] || fail_msg "T2: actionable must APPEND (never replaced), got $nactionable"
|
||||||
[ "$nhuman" = "1" ] || fail_msg "T2: human must APPEND / stay durable, got $nhuman"
|
[ "$nhuman" = "1" ] || fail_msg "T2: human must APPEND / stay durable, got $nhuman"
|
||||||
# Durability never bypassed: all classes present in the durable store.
|
# Durability never bypassed: all classes present in the durable store.
|
||||||
total="$(printf '%s\n' "$out" | grep -c . || true)"
|
total="$(printf '%s\n' "$out" | count_lines . || true)"
|
||||||
[ "$total" = "3" ] || fail_msg "T2: durable store should hold digest+actionable+human = 3, got $total"
|
[ "$total" = "3" ] || fail_msg "T2: durable store should hold digest+actionable+human = 3, got $total"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -141,7 +148,7 @@ echo "== T3: contiguous-prefix CONSUMED (reject ack N while N-1 unconsumed) =="
|
|||||||
fail_msg "T3: CONSUMED 3 must be REJECTED while seq 2 is a gap (unconsumed)"
|
fail_msg "T3: CONSUMED 3 must be REJECTED while seq 2 is a gap (unconsumed)"
|
||||||
fi
|
fi
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T3: rejected CONSUMED must NOT advance the cursor [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=0' || fail_msg "T3: rejected CONSUMED must NOT advance the cursor [$cur]"
|
||||||
# Also reject via the ack wrapper.
|
# Also reject via the ack wrapper.
|
||||||
if "$ACK" consumed --upto 3 --no-sync >/dev/null 2>&1; then
|
if "$ACK" consumed --upto 3 --no-sync >/dev/null 2>&1; then
|
||||||
fail_msg "T3: ack.sh CONSUMED 3 must be REJECTED over a gap"
|
fail_msg "T3: ack.sh CONSUMED 3 must be REJECTED over a gap"
|
||||||
@@ -150,11 +157,11 @@ echo "== T3: contiguous-prefix CONSUMED (reject ack N while N-1 unconsumed) =="
|
|||||||
"$STORE" enqueue --seq 2 --class actionable --locators '{}' >/dev/null
|
"$STORE" enqueue --seq 2 --class actionable --locators '{}' >/dev/null
|
||||||
"$ACK" consumed --upto 3 --no-sync >/dev/null 2>&1 || fail_msg "T3: CONSUMED 3 should succeed once gap at 2 is filled"
|
"$ACK" consumed --upto 3 --no-sync >/dev/null 2>&1 || fail_msg "T3: CONSUMED 3 should succeed once gap at 2 is filled"
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'consumed_seq=3' || fail_msg "T3: consumed_seq should be 3 after contiguous prefix filled [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=3' || fail_msg "T3: consumed_seq should be 3 after contiguous prefix filled [$cur]"
|
||||||
# Cumulative + can't regress: CONSUMED 2 after 3 is an idempotent no-op.
|
# Cumulative + can't regress: CONSUMED 2 after 3 is an idempotent no-op.
|
||||||
"$ACK" consumed --upto 2 --no-sync >/dev/null 2>&1 || fail_msg "T3: cumulative CONSUMED 2 (<=3) should be an idempotent success"
|
"$ACK" consumed --upto 2 --no-sync >/dev/null 2>&1 || fail_msg "T3: cumulative CONSUMED 2 (<=3) should be an idempotent success"
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'consumed_seq=3' || fail_msg "T3: cursor must not regress below 3 [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=3' || fail_msg "T3: cursor must not regress below 3 [$cur]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== T4: wake_id DELIVERY-dedup (dup delivery = re-RECEIVE, never re-action) =="
|
echo "== T4: wake_id DELIVERY-dedup (dup delivery = re-RECEIVE, never re-action) =="
|
||||||
@@ -169,7 +176,7 @@ echo "== T4: wake_id DELIVERY-dedup (dup delivery = re-RECEIVE, never re-action)
|
|||||||
[ "$r2" = "DUP" ] || fail_msg "T4: duplicate delivery of same wake_id should be DUP (re-RECEIVE), got '$r2'"
|
[ "$r2" = "DUP" ] || fail_msg "T4: duplicate delivery of same wake_id should be DUP (re-RECEIVE), got '$r2'"
|
||||||
# RECEIVED (delivery) must NEVER advance consumed_seq (delivery != consumption).
|
# RECEIVED (delivery) must NEVER advance consumed_seq (delivery != consumption).
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T4: RECEIVED must not advance consumed_seq [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=0' || fail_msg "T4: RECEIVED must not advance consumed_seq [$cur]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== T5: atomic write-tmp+rename survives crash mid-write =="
|
echo "== T5: atomic write-tmp+rename survives crash mid-write =="
|
||||||
@@ -189,7 +196,7 @@ echo "== T5: atomic write-tmp+rename survives crash mid-write =="
|
|||||||
drained="$("$STORE" drain)"
|
drained="$("$STORE" drain)"
|
||||||
printf '%s\n' "$drained" | jq -e . >/dev/null 2>&1 || fail_msg "T5: drain returned non-JSON — garbage temp corrupted the store"
|
printf '%s\n' "$drained" | jq -e . >/dev/null 2>&1 || fail_msg "T5: drain returned non-JSON — garbage temp corrupted the store"
|
||||||
printf '%s\n' "$drained" | stream_any '.observed_seq==1' || fail_msg "T5: committed entry (seq 1) lost after simulated crash"
|
printf '%s\n' "$drained" | stream_any '.observed_seq==1' || fail_msg "T5: committed entry (seq 1) lost after simulated crash"
|
||||||
printf '%s\n' "$drained" | grep -q 999 && fail_msg "T5: uncommitted garbage (seq 999) leaked into the live store"
|
printf '%s\n' "$drained" | has_match -q 999 && fail_msg "T5: uncommitted garbage (seq 999) leaked into the live store"
|
||||||
# A subsequent real mutation must succeed DESPITE the stale temp present.
|
# A subsequent real mutation must succeed DESPITE the stale temp present.
|
||||||
"$STORE" enqueue --seq 2 --class actionable --locators '{"issue":2}' >/dev/null || fail_msg "T5: enqueue after crash temp failed"
|
"$STORE" enqueue --seq 2 --class actionable --locators '{"issue":2}' >/dev/null || fail_msg "T5: enqueue after crash temp failed"
|
||||||
# #927: the enqueue HOT PATH must NOT reap tmp files — an unconditional delete
|
# #927: the enqueue HOT PATH must NOT reap tmp files — an unconditional delete
|
||||||
@@ -198,7 +205,7 @@ echo "== T5: atomic write-tmp+rename survives crash mid-write =="
|
|||||||
# untouched by an enqueue; reaping it on the hot path is exactly the bug.
|
# untouched by an enqueue; reaping it on the hot path is exactly the bug.
|
||||||
[ -e "$STATE_DIR/.wake.tmp.crash12" ] || fail_msg "T5: the enqueue hot path must NOT delete tmp files off its own write (that hot-path reap was the #927 clobber)"
|
[ -e "$STATE_DIR/.wake.tmp.crash12" ] || fail_msg "T5: the enqueue hot path must NOT delete tmp files off its own write (that hot-path reap was the #927 clobber)"
|
||||||
d2="$("$STORE" drain)"
|
d2="$("$STORE" drain)"
|
||||||
[ "$(printf '%s\n' "$d2" | grep -c .)" = "2" ] || fail_msg "T5: store not healthy after crash+recovery (expected 2 entries)"
|
[ "$(printf '%s\n' "$d2" | has_match -c .)" = "2" ] || fail_msg "T5: store not healthy after crash+recovery (expected 2 entries)"
|
||||||
# Bounded accumulation is preserved via a MAINTENANCE reap (store.sh init /
|
# Bounded accumulation is preserved via a MAINTENANCE reap (store.sh init /
|
||||||
# detector tick), age-scoped so it only removes DEMONSTRABLY-orphaned tmps
|
# detector tick), age-scoped so it only removes DEMONSTRABLY-orphaned tmps
|
||||||
# (older than any plausible in-flight write) and never a live one. Age the
|
# (older than any plausible in-flight write) and never a live one. Age the
|
||||||
@@ -209,7 +216,7 @@ echo "== T5: atomic write-tmp+rename survives crash mid-write =="
|
|||||||
"$STORE" init >/dev/null 2>&1 || fail_msg "T5: store.sh init (maintenance) failed"
|
"$STORE" init >/dev/null 2>&1 || fail_msg "T5: store.sh init (maintenance) failed"
|
||||||
[ -e "$STATE_DIR/.wake.tmp.crash12" ] && fail_msg "T5: maintenance reap (store.sh init) must remove a demonstrably-orphaned stale temp (bounded accumulation)"
|
[ -e "$STATE_DIR/.wake.tmp.crash12" ] && fail_msg "T5: maintenance reap (store.sh init) must remove a demonstrably-orphaned stale temp (bounded accumulation)"
|
||||||
d3="$("$STORE" drain)"
|
d3="$("$STORE" drain)"
|
||||||
[ "$(printf '%s\n' "$d3" | grep -c .)" = "2" ] || fail_msg "T5: store not healthy after maintenance reap (expected 2 entries)"
|
[ "$(printf '%s\n' "$d3" | has_match -c .)" = "2" ] || fail_msg "T5: store not healthy after maintenance reap (expected 2 entries)"
|
||||||
printf '%s\n' "$d3" | jq -e . >/dev/null 2>&1 || fail_msg "T5: drain returned non-JSON after maintenance reap"
|
printf '%s\n' "$d3" | jq -e . >/dev/null 2>&1 || fail_msg "T5: drain returned non-JSON after maintenance reap"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -254,12 +261,12 @@ echo "== T6: durability survives restart (retain until CONSUMED) =="
|
|||||||
# "Session 2": a brand-new process (this subshell invocation of store.sh) must
|
# "Session 2": a brand-new process (this subshell invocation of store.sh) must
|
||||||
# see the persisted entries — nothing was held in memory.
|
# see the persisted entries — nothing was held in memory.
|
||||||
d="$("$STORE" drain)"
|
d="$("$STORE" drain)"
|
||||||
[ "$(printf '%s\n' "$d" | grep -c .)" = "2" ] || fail_msg "T6: entries not retained across restart (expected 2)"
|
[ "$(printf '%s\n' "$d" | has_match -c .)" = "2" ] || fail_msg "T6: entries not retained across restart (expected 2)"
|
||||||
printf '%s\n' "$d" | stream_any '.class=="human"' || fail_msg "T6: a human message was lost across restart (durability bypassed)"
|
printf '%s\n' "$d" | stream_any '.class=="human"' || fail_msg "T6: a human message was lost across restart (durability bypassed)"
|
||||||
# Retained UNTIL consumed: after CONSUMED they are released.
|
# Retained UNTIL consumed: after CONSUMED they are released.
|
||||||
"$STORE" consume --upto 2 >/dev/null
|
"$STORE" consume --upto 2 >/dev/null
|
||||||
d2="$("$STORE" drain)"
|
d2="$("$STORE" drain)"
|
||||||
n2="$(printf '%s\n' "$d2" | grep -c . || true)"
|
n2="$(printf '%s\n' "$d2" | count_lines . || true)"
|
||||||
[ "$n2" = "0" ] || fail_msg "T6: entries should be released only AFTER CONSUMED (still $n2 pending)"
|
[ "$n2" = "0" ] || fail_msg "T6: entries should be released only AFTER CONSUMED (still $n2 pending)"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
@@ -296,7 +303,7 @@ EOF
|
|||||||
end="$(date +%s.%N)"
|
end="$(date +%s.%N)"
|
||||||
elapsed_ms="$(awk -v s="$start" -v e="$end" 'BEGIN { printf "%d", (e - s) * 1000 }')"
|
elapsed_ms="$(awk -v s="$start" -v e="$end" 'BEGIN { printf "%d", (e - s) * 1000 }')"
|
||||||
[ "$elapsed_ms" -lt 1500 ] || fail_msg "T7: ack path blocked ${elapsed_ms}ms — must return without waiting on the background sync"
|
[ "$elapsed_ms" -lt 1500 ] || fail_msg "T7: ack path blocked ${elapsed_ms}ms — must return without waiting on the background sync"
|
||||||
echo "$out" | grep -q 'CONSUMED 1' || fail_msg "T7: CONSUMED not reported [$out]"
|
echo "$out" | has_match -q 'CONSUMED 1' || fail_msg "T7: CONSUMED not reported [$out]"
|
||||||
# Local write is durable IMMEDIATELY (no network needed to record the ack).
|
# Local write is durable IMMEDIATELY (no network needed to record the ack).
|
||||||
STATE_DIR="$WAKE_STATE_HOME/default"
|
STATE_DIR="$WAKE_STATE_HOME/default"
|
||||||
jq_any "$STATE_DIR/ack-ledger.jsonl" '.type=="CONSUMED" and .upto==1' ||
|
jq_any "$STATE_DIR/ack-ledger.jsonl" '.type=="CONSUMED" and .upto==1' ||
|
||||||
@@ -321,8 +328,8 @@ echo "== T8: SOLE store-side allocator — enqueue (no --seq) allocates contiguo
|
|||||||
[ "$s1" = "1" ] || fail_msg "T8: first store-allocated observed_seq must be 1, got '$s1'"
|
[ "$s1" = "1" ] || fail_msg "T8: first store-allocated observed_seq must be 1, got '$s1'"
|
||||||
[ "$s2" = "2" ] || fail_msg "T8: second store-allocated observed_seq must be 2 (contiguous), got '$s2'"
|
[ "$s2" = "2" ] || fail_msg "T8: second store-allocated observed_seq must be 2 (contiguous), got '$s2'"
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'observed_seq=2' || fail_msg "T8: observed_seq cursor should be 2 [$cur]"
|
echo "$cur" | has_match -q 'observed_seq=2' || fail_msg "T8: observed_seq cursor should be 2 [$cur]"
|
||||||
echo "$cur" | grep -q 'pending_depth=2' || fail_msg "T8: both allocations must be durably enqueued [$cur]"
|
echo "$cur" | has_match -q 'pending_depth=2' || fail_msg "T8: both allocations must be durably enqueued [$cur]"
|
||||||
# ANTI-SWALLOW: consume the prefix, then a LEGACY explicit --seq inside the
|
# ANTI-SWALLOW: consume the prefix, then a LEGACY explicit --seq inside the
|
||||||
# consumed prefix must FAIL LOUD (never the old silent seq<=consumed no-op).
|
# consumed prefix must FAIL LOUD (never the old silent seq<=consumed no-op).
|
||||||
"$STORE" consume --upto 2 >/dev/null
|
"$STORE" consume --upto 2 >/dev/null
|
||||||
@@ -331,7 +338,7 @@ echo "== T8: SOLE store-side allocator — enqueue (no --seq) allocates contiguo
|
|||||||
fail_msg "T8: explicit --seq 1 <= consumed_seq 2 must FAIL LOUD (anti-swallow), not be a silent no-op"
|
fail_msg "T8: explicit --seq 1 <= consumed_seq 2 must FAIL LOUD (anti-swallow), not be a silent no-op"
|
||||||
fi
|
fi
|
||||||
err="$("$STORE" enqueue --seq 1 --class actionable --locators '{}' 2>&1 || true)"
|
err="$("$STORE" enqueue --seq 1 --class actionable --locators '{}' 2>&1 || true)"
|
||||||
echo "$err" | grep -qi 'anti-swallow' || fail_msg "T8: the refusal must name the anti-swallow guarantee [$err]"
|
echo "$err" | has_match -qi 'anti-swallow' || fail_msg "T8: the refusal must name the anti-swallow guarantee [$err]"
|
||||||
after_depth="$("$STORE" cursors | sed -n 's/pending_depth=//p')"
|
after_depth="$("$STORE" cursors | sed -n 's/pending_depth=//p')"
|
||||||
[ "$before_depth" = "$after_depth" ] || fail_msg "T8: a refused enqueue must not mutate the store (depth $before_depth->$after_depth)"
|
[ "$before_depth" = "$after_depth" ] || fail_msg "T8: a refused enqueue must not mutate the store (depth $before_depth->$after_depth)"
|
||||||
# And the NEXT real allocation is > consumed (2) — never inside the prefix.
|
# And the NEXT real allocation is > consumed (2) — never inside the prefix.
|
||||||
@@ -443,8 +450,8 @@ if command -v flock >/dev/null 2>&1; then
|
|||||||
[ -n "$a" ] && [ -n "$b" ] || fail_msg "T10: both concurrent enqueues must print an allocated seq (got '$a','$b')"
|
[ -n "$a" ] && [ -n "$b" ] || fail_msg "T10: both concurrent enqueues must print an allocated seq (got '$a','$b')"
|
||||||
[ "$a" != "$b" ] || fail_msg "T10: two concurrent enqueues must get DISTINCT seqs, both got '$a' (aliasing / lost write without the lock)"
|
[ "$a" != "$b" ] || fail_msg "T10: two concurrent enqueues must get DISTINCT seqs, both got '$a' (aliasing / lost write without the lock)"
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'observed_seq=2' || fail_msg "T10: observed_seq must reach 2 after two enqueues [$cur]"
|
echo "$cur" | has_match -q 'observed_seq=2' || fail_msg "T10: observed_seq must reach 2 after two enqueues [$cur]"
|
||||||
echo "$cur" | grep -q 'pending_depth=2' || fail_msg "T10: both entries must be durably stored (no lost write) [$cur]"
|
echo "$cur" | has_match -q 'pending_depth=2' || fail_msg "T10: both entries must be durably stored (no lost write) [$cur]"
|
||||||
# The two allocated seqs are exactly {1,2} (distinct, contiguous, gapless).
|
# The two allocated seqs are exactly {1,2} (distinct, contiguous, gapless).
|
||||||
lo="$a"; hi="$b"; [ "$a" -gt "$b" ] && { lo="$b"; hi="$a"; }
|
lo="$a"; hi="$b"; [ "$a" -gt "$b" ] && { lo="$b"; hi="$a"; }
|
||||||
{ [ "$lo" = "1" ] && [ "$hi" = "2" ]; } || fail_msg "T10: concurrent seqs must be {1,2}, got {$lo,$hi}"
|
{ [ "$lo" = "1" ] && [ "$hi" = "2" ]; } || fail_msg "T10: concurrent seqs must be {1,2}, got {$lo,$hi}"
|
||||||
@@ -492,7 +499,7 @@ echo "== T11: final observed_seq cursor write FAILURE — fail loud + observed.s
|
|||||||
# the _atomic_write failure and returns 0).
|
# the _atomic_write failure and returns 0).
|
||||||
[ "$rc" -ne 0 ] || fail_msg "T11: an enqueue whose FINAL cursor write fails must EXIT NON-ZERO (fail loud), got rc=$rc"
|
[ "$rc" -ne 0 ] || fail_msg "T11: an enqueue whose FINAL cursor write fails must EXIT NON-ZERO (fail loud), got rc=$rc"
|
||||||
# The loud diagnostic names the cursor write (not a generic error).
|
# The loud diagnostic names the cursor write (not a generic error).
|
||||||
grep -qi 'cursor' "$errfile" || fail_msg "T11: the failure diagnostic must name the observed_seq cursor write [$(cat "$errfile")]"
|
has_match -qi 'cursor' "$errfile" || fail_msg "T11: the failure diagnostic must name the observed_seq cursor write [$(cat "$errfile")]"
|
||||||
|
|
||||||
# (2) The cursor did NOT advance — the allocation is NOT committed.
|
# (2) The cursor did NOT advance — the allocation is NOT committed.
|
||||||
obs_after="$("$STORE" cursors | sed -n 's/^observed_seq=//p')"
|
obs_after="$("$STORE" cursors | sed -n 's/^observed_seq=//p')"
|
||||||
@@ -536,7 +543,7 @@ echo "== T12: #932 — consume records the last-consumed observed_hash per (kind
|
|||||||
r2h="$(jq -sr '[ .[] | select(.kind=="repo" and .id=="r2") ] | .[0].observed_hash' "$rec" 2>/dev/null)"
|
r2h="$(jq -sr '[ .[] | select(.kind=="repo" and .id=="r2") ] | .[0].observed_hash' "$rec" 2>/dev/null)"
|
||||||
[ "$r2h" = "HASH-C" ] || fail_msg "T12: repo/r2 must record HASH-C, got '$r2h'"
|
[ "$r2h" = "HASH-C" ] || fail_msg "T12: repo/r2 must record HASH-C, got '$r2h'"
|
||||||
# ADDITIVE: existing on-disk state files are unchanged/consistent post-consume.
|
# ADDITIVE: existing on-disk state files are unchanged/consistent post-consume.
|
||||||
"$STORE" cursors | grep -q 'consumed_seq=3' || fail_msg "T12: consumed_seq must be 3 after CONSUMED 3"
|
"$STORE" cursors | has_match -q 'consumed_seq=3' || fail_msg "T12: consumed_seq must be 3 after CONSUMED 3"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== T13: #946 — ordinary consume REFUSES to advance past a quarantined seq (store + ack paths) =="
|
echo "== T13: #946 — ordinary consume REFUSES to advance past a quarantined seq (store + ack paths) =="
|
||||||
@@ -555,10 +562,10 @@ echo "== T13: #946 — ordinary consume REFUSES to advance past a quarantined se
|
|||||||
fail_msg "T13: ordinary consume --upto 3 must be REFUSED while seq 2 is quarantined"
|
fail_msg "T13: ordinary consume --upto 3 must be REFUSED while seq 2 is quarantined"
|
||||||
fi
|
fi
|
||||||
err="$("$STORE" consume --upto 3 2>&1 >/dev/null || true)"
|
err="$("$STORE" consume --upto 3 2>&1 >/dev/null || true)"
|
||||||
echo "$err" | grep -q 'quarantined seq(s): 2' || fail_msg "T13: the refusal must NAME the quarantined seq [$err]"
|
echo "$err" | has_match -q 'quarantined seq(s): 2' || fail_msg "T13: the refusal must NAME the quarantined seq [$err]"
|
||||||
echo "$err" | grep -q -- '--force-past-quarantine' || fail_msg "T13: the refusal must NAME the force flag [$err]"
|
echo "$err" | has_match -q -- '--force-past-quarantine' || fail_msg "T13: the refusal must NAME the force flag [$err]"
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T13: a refused consume must NOT advance the cursor [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=0' || fail_msg "T13: a refused consume must NOT advance the cursor [$cur]"
|
||||||
# BELOW the quarantined seq the ordinary path is unaffected.
|
# BELOW the quarantined seq the ordinary path is unaffected.
|
||||||
"$STORE" consume --upto 1 >/dev/null 2>&1 || fail_msg "T13: consume --upto 1 (below the quarantined seq) must succeed"
|
"$STORE" consume --upto 1 >/dev/null 2>&1 || fail_msg "T13: consume --upto 1 (below the quarantined seq) must succeed"
|
||||||
# The ack wrapper propagates the refusal — no ordinary-path bypass exists.
|
# The ack wrapper propagates the refusal — no ordinary-path bypass exists.
|
||||||
@@ -566,7 +573,7 @@ echo "== T13: #946 — ordinary consume REFUSES to advance past a quarantined se
|
|||||||
fail_msg "T13: ack.sh consumed --upto 3 must be REFUSED while seq 2 is quarantined (ordinary-path bypass)"
|
fail_msg "T13: ack.sh consumed --upto 3 must be REFUSED while seq 2 is quarantined (ordinary-path bypass)"
|
||||||
fi
|
fi
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'consumed_seq=1' || fail_msg "T13: cursor must still be 1 after the refused ack [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=1' || fail_msg "T13: cursor must still be 1 after the refused ack [$cur]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== T14: #946 — FORCED step-over is LOUD, prunes the set, and NEVER fabricates a consumed-hash row for the quarantined entry =="
|
echo "== T14: #946 — FORCED step-over is LOUD, prunes the set, and NEVER fabricates a consumed-hash row for the quarantined entry =="
|
||||||
@@ -585,9 +592,9 @@ echo "== T14: #946 — FORCED step-over is LOUD, prunes the set, and NEVER fabri
|
|||||||
rc=$?
|
rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail_msg "T14: forced consume must succeed (rc=$rc) [$(cat "$errf")]"
|
[ "$rc" -eq 0 ] || fail_msg "T14: forced consume must succeed (rc=$rc) [$(cat "$errf")]"
|
||||||
[ "$out" = "3" ] || fail_msg "T14: forced consume must print the new cursor 3, got '$out'"
|
[ "$out" = "3" ] || fail_msg "T14: forced consume must print the new cursor 3, got '$out'"
|
||||||
grep -q 'FORCED PAST QUARANTINE' "$errf" || fail_msg "T14: the forced path must be LOUD on stderr [$(cat "$errf")]"
|
has_match -q 'FORCED PAST QUARANTINE' "$errf" || fail_msg "T14: the forced path must be LOUD on stderr [$(cat "$errf")]"
|
||||||
grep -q 'seq 2' "$errf" || fail_msg "T14: the forced-path diagnostic must name the stepped-over seq 2 [$(cat "$errf")]"
|
has_match -q 'seq 2' "$errf" || fail_msg "T14: the forced-path diagnostic must name the stepped-over seq 2 [$(cat "$errf")]"
|
||||||
"$STORE" cursors | grep -q 'consumed_seq=3' || fail_msg "T14: forced consume must advance the cursor to 3"
|
"$STORE" cursors | has_match -q 'consumed_seq=3' || fail_msg "T14: forced consume must advance the cursor to 3"
|
||||||
# NO FALSE WITNESS: the quarantined entry (repo/b) was NEVER delivered, so no
|
# NO FALSE WITNESS: the quarantined entry (repo/b) was NEVER delivered, so no
|
||||||
# consumed-hash row may exist for it — even on the forced path (the reconciler
|
# consumed-hash row may exist for it — even on the forced path (the reconciler
|
||||||
# re-enumerating it once is safe-but-noisy; a false witness silences it
|
# re-enumerating it once is safe-but-noisy; a false witness silences it
|
||||||
@@ -597,7 +604,7 @@ echo "== T14: #946 — FORCED step-over is LOUD, prunes the set, and NEVER fabri
|
|||||||
jq_any "$rec" '.kind=="repo" and .id=="b"' && fail_msg "T14: the quarantined entry repo/b must have NO consumed-hash row (a row would witness a delivery that never happened)"
|
jq_any "$rec" '.kind=="repo" and .id=="b"' && fail_msg "T14: the quarantined entry repo/b must have NO consumed-hash row (a row would witness a delivery that never happened)"
|
||||||
# The stepped-over seq is PRUNED from the set (it is consumed now; a stale
|
# The stepped-over seq is PRUNED from the set (it is consumed now; a stale
|
||||||
# entry would re-refuse forever).
|
# entry would re-refuse forever).
|
||||||
grep -qxF '2' "$qf" 2>/dev/null && fail_msg "T14: seq 2 must be PRUNED from quarantined.set after the forced step-over"
|
has_match -qxF '2' "$qf" 2>/dev/null && fail_msg "T14: seq 2 must be PRUNED from quarantined.set after the forced step-over"
|
||||||
# The ack wrapper's force flag passes through, stays LOUD on stderr, and
|
# The ack wrapper's force flag passes through, stays LOUD on stderr, and
|
||||||
# still reports a CLEAN cursor line on stdout.
|
# still reports a CLEAN cursor line on stdout.
|
||||||
"$STORE" enqueue --class actionable --locators '{"kind":"repo","id":"d","observed_hash":"HD"}' >/dev/null
|
"$STORE" enqueue --class actionable --locators '{"kind":"repo","id":"d","observed_hash":"HD"}' >/dev/null
|
||||||
@@ -607,8 +614,8 @@ echo "== T14: #946 — FORCED step-over is LOUD, prunes the set, and NEVER fabri
|
|||||||
out2="$("$ACK" consumed --upto 5 --no-sync --force-past-quarantine 2>"$errf2")"
|
out2="$("$ACK" consumed --upto 5 --no-sync --force-past-quarantine 2>"$errf2")"
|
||||||
rc2=$?
|
rc2=$?
|
||||||
[ "$rc2" -eq 0 ] || fail_msg "T14: forced ack must succeed (rc=$rc2) [$(cat "$errf2")]"
|
[ "$rc2" -eq 0 ] || fail_msg "T14: forced ack must succeed (rc=$rc2) [$(cat "$errf2")]"
|
||||||
echo "$out2" | grep -q '^CONSUMED 5$' || fail_msg "T14: forced ack must report a CLEAN cursor line 'CONSUMED 5', got '$out2'"
|
echo "$out2" | has_match -q '^CONSUMED 5$' || fail_msg "T14: forced ack must report a CLEAN cursor line 'CONSUMED 5', got '$out2'"
|
||||||
grep -q 'FORCED PAST QUARANTINE' "$errf2" || fail_msg "T14: the forced-path loudness must survive the ack wrapper (stderr) [$(cat "$errf2")]"
|
has_match -q 'FORCED PAST QUARANTINE' "$errf2" || fail_msg "T14: the forced-path loudness must survive the ack wrapper (stderr) [$(cat "$errf2")]"
|
||||||
jq_any "$rec" '.kind=="repo" and .id=="e"' && fail_msg "T14: the quarantined repo/e must have NO consumed-hash row via the forced ack path either"
|
jq_any "$rec" '.kind=="repo" and .id=="e"' && fail_msg "T14: the quarantined repo/e must have NO consumed-hash row via the forced ack path either"
|
||||||
true
|
true
|
||||||
) && ok
|
) && ok
|
||||||
@@ -671,10 +678,10 @@ echo "== T16: #946 — quarantine-audit: a consumed-hash row matching a dead-let
|
|||||||
if "$STORE" quarantine-audit >"$rep" 2>&1; then
|
if "$STORE" quarantine-audit >"$rep" 2>&1; then
|
||||||
fail_msg "T16: report-mode audit must exit NON-ZERO when false rows exist"
|
fail_msg "T16: report-mode audit must exit NON-ZERO when false rows exist"
|
||||||
fi
|
fi
|
||||||
grep -q 'FALSE WITNESS' "$rep" || fail_msg "T16: the audit must name the false row loudly [$(cat "$rep")]"
|
has_match -q 'FALSE WITNESS' "$rep" || fail_msg "T16: the audit must name the false row loudly [$(cat "$rep")]"
|
||||||
grep -q '"id":"X"' "$rep" || fail_msg "T16: the audit must identify the false row (repo/X@1) [$(cat "$rep")]"
|
has_match -q '"id":"X"' "$rep" || fail_msg "T16: the audit must identify the false row (repo/X@1) [$(cat "$rep")]"
|
||||||
grep -q '"id":"Y"' "$rep" && fail_msg "T16: the clean row repo/Y must NOT be flagged"
|
has_match -q '"id":"Y"' "$rep" && fail_msg "T16: the clean row repo/Y must NOT be flagged"
|
||||||
grep -q '"id":"Z"' "$rep" && fail_msg "T16: the HEALED row repo/Z@4 must NOT be flagged (its dead-letter evidence is seq 3 with a different hash)"
|
has_match -q '"id":"Z"' "$rep" && fail_msg "T16: the HEALED row repo/Z@4 must NOT be flagged (its dead-letter evidence is seq 3 with a different hash)"
|
||||||
# Report mode modifies nothing.
|
# Report mode modifies nothing.
|
||||||
jq_any "$rec" '.id=="X"' || fail_msg "T16: report mode must not modify the record"
|
jq_any "$rec" '.id=="X"' || fail_msg "T16: report mode must not modify the record"
|
||||||
# REPAIR: exactly the false row is removed; the dead-letter LEDGER is history
|
# REPAIR: exactly the false row is removed; the dead-letter LEDGER is history
|
||||||
@@ -683,10 +690,10 @@ echo "== T16: #946 — quarantine-audit: a consumed-hash row matching a dead-let
|
|||||||
jq_any "$rec" '.id=="X"' && fail_msg "T16: --repair must REMOVE the provably-false X row"
|
jq_any "$rec" '.id=="X"' && fail_msg "T16: --repair must REMOVE the provably-false X row"
|
||||||
jq_any "$rec" '.id=="Y" and .observed_hash=="HY"' || fail_msg "T16: --repair must keep the clean Y row"
|
jq_any "$rec" '.id=="Y" and .observed_hash=="HY"' || fail_msg "T16: --repair must keep the clean Y row"
|
||||||
jq_any "$rec" '.id=="Z" and .observed_hash=="HZ-NEW" and .observed_seq==4' || fail_msg "T16: --repair must keep the healed Z@4 row"
|
jq_any "$rec" '.id=="Z" and .observed_hash=="HZ-NEW" and .observed_seq==4' || fail_msg "T16: --repair must keep the healed Z@4 row"
|
||||||
[ "$(grep -c . "$dl")" = "2" ] || fail_msg "T16: the dead-letter LEDGER must be untouched by --repair"
|
[ "$(has_match -c . "$dl")" = "2" ] || fail_msg "T16: the dead-letter LEDGER must be untouched by --repair"
|
||||||
# Clean re-audit: OK, exit 0.
|
# Clean re-audit: OK, exit 0.
|
||||||
"$STORE" quarantine-audit >"$TMP_ROOT/t16.ok" 2>&1 || fail_msg "T16: a clean audit must exit 0 [$(cat "$TMP_ROOT/t16.ok")]"
|
"$STORE" quarantine-audit >"$TMP_ROOT/t16.ok" 2>&1 || fail_msg "T16: a clean audit must exit 0 [$(cat "$TMP_ROOT/t16.ok")]"
|
||||||
grep -qi 'OK' "$TMP_ROOT/t16.ok" || fail_msg "T16: a clean audit must say OK [$(cat "$TMP_ROOT/t16.ok")]"
|
has_match -qi 'OK' "$TMP_ROOT/t16.ok" || fail_msg "T16: a clean audit must say OK [$(cat "$TMP_ROOT/t16.ok")]"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo "== T17: #952 — the clean-sweep message names BOTH unprovable residual classes; a surviving-but-empty-hash dead-letter row is correctly NOT convicted =="
|
echo "== T17: #952 — the clean-sweep message names BOTH unprovable residual classes; a surviving-but-empty-hash dead-letter row is correctly NOT convicted =="
|
||||||
@@ -722,20 +729,20 @@ echo "== T17: #952 — the clean-sweep message names BOTH unprovable residual cl
|
|||||||
# match can never fire: this is unprovable class 2, NOT a false witness.
|
# match can never fire: this is unprovable class 2, NOT a false witness.
|
||||||
rep="$TMP_ROOT/t17.rep"
|
rep="$TMP_ROOT/t17.rep"
|
||||||
"$STORE" quarantine-audit >"$rep" 2>&1 || fail_msg "T17: the audit must exit 0 — nothing here is provable [$(cat "$rep")]"
|
"$STORE" quarantine-audit >"$rep" 2>&1 || fail_msg "T17: the audit must exit 0 — nothing here is provable [$(cat "$rep")]"
|
||||||
grep -q 'FALSE WITNESS' "$rep" && fail_msg "T17: the empty-hash evidence must NOT convict (the predicate is correct and must not change) [$(cat "$rep")]"
|
has_match -q 'FALSE WITNESS' "$rep" && fail_msg "T17: the empty-hash evidence must NOT convict (the predicate is correct and must not change) [$(cat "$rep")]"
|
||||||
# The wording under test (#952): BOTH residual classes, named.
|
# The wording under test (#952): BOTH residual classes, named.
|
||||||
grep -qi 'pruned' "$rep" || fail_msg "T17: clean sweep must name residual class 1 — evidence pruned away [$(cat "$rep")]"
|
has_match -qi 'pruned' "$rep" || fail_msg "T17: clean sweep must name residual class 1 — evidence pruned away [$(cat "$rep")]"
|
||||||
grep -qi 'empty observed_hash' "$rep" || fail_msg "T17: clean sweep must name residual class 2 — surviving evidence with an empty observed_hash [$(cat "$rep")]"
|
has_match -qi 'empty observed_hash' "$rep" || fail_msg "T17: clean sweep must name residual class 2 — surviving evidence with an empty observed_hash [$(cat "$rep")]"
|
||||||
# --repair on a clean sweep removes nothing: the unprovable row survives.
|
# --repair on a clean sweep removes nothing: the unprovable row survives.
|
||||||
"$STORE" quarantine-audit --repair >"$TMP_ROOT/t17.fix" 2>&1 || fail_msg "T17: --repair on a clean sweep must exit 0 [$(cat "$TMP_ROOT/t17.fix")]"
|
"$STORE" quarantine-audit --repair >"$TMP_ROOT/t17.fix" 2>&1 || fail_msg "T17: --repair on a clean sweep must exit 0 [$(cat "$TMP_ROOT/t17.fix")]"
|
||||||
jq_any "$rec" '.kind=="bench" and .observed_seq==13 and .observed_hash=="H-REAL-CONSUMED"' ||
|
jq_any "$rec" '.kind=="bench" and .observed_seq==13 and .observed_hash=="H-REAL-CONSUMED"' ||
|
||||||
fail_msg "T17: --repair must NOT remove the unprovable bench@13 row — the audit only removes what the ledger can convict"
|
fail_msg "T17: --repair must NOT remove the unprovable bench@13 row — the audit only removes what the ledger can convict"
|
||||||
[ "$(grep -c . "$dl")" = "1" ] || fail_msg "T17: the dead-letter LEDGER must be untouched"
|
[ "$(count_lines . "$dl")" = "1" ] || fail_msg "T17: the dead-letter LEDGER must be untouched"
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake store/ack harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
|
echo "wake store/ack harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake store/ack harness: all invariants passed ($pass groups)"
|
echo "wake store/ack harness: all invariants passed ($pass groups)"
|
||||||
|
|||||||
@@ -32,6 +32,13 @@
|
|||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
if ! . "$SCRIPT_DIR/_wake-common.sh"; then
|
||||||
|
echo "WAKE-ASSERT INIT ABORT: cannot source _wake-common.sh — suite ran ZERO wake assertions (#984)" >&2
|
||||||
|
exit 97
|
||||||
|
fi
|
||||||
|
wake_assert_init
|
||||||
STORE="$SCRIPT_DIR/store.sh"
|
STORE="$SCRIPT_DIR/store.sh"
|
||||||
|
|
||||||
command -v jq >/dev/null 2>&1 || {
|
command -v jq >/dev/null 2>&1 || {
|
||||||
@@ -147,7 +154,7 @@ EOF
|
|||||||
wait "$pa" 2>/dev/null || true
|
wait "$pa" 2>/dev/null || true
|
||||||
else
|
else
|
||||||
# Confirm the window is genuinely open: A holds a live in-flight tmp now.
|
# Confirm the window is genuinely open: A holds a live in-flight tmp now.
|
||||||
n_tmp="$(find "$STATE_DIR" -maxdepth 1 -name '.wake.tmp.*' -type f | grep -c . || true)"
|
n_tmp="$(find "$STATE_DIR" -maxdepth 1 -name '.wake.tmp.*' -type f | count_lines . || true)"
|
||||||
[ "$n_tmp" -ge 1 ] || fail_msg "T-RACE: expected A's live in-flight tmp to exist while A holds the lock (got $n_tmp)"
|
[ "$n_tmp" -ge 1 ] || fail_msg "T-RACE: expected A's live in-flight tmp to exist while A holds the lock (got $n_tmp)"
|
||||||
|
|
||||||
# B: its PRE-LOCK _wake_init_dir runs now (pre-fix: deletes A's live tmp),
|
# B: its PRE-LOCK _wake_init_dir runs now (pre-fix: deletes A's live tmp),
|
||||||
@@ -173,7 +180,7 @@ EOF
|
|||||||
if [ "$ra" -ne 0 ]; then
|
if [ "$ra" -ne 0 ]; then
|
||||||
fail_msg "T-RACE: enqueue A was SPURIOUSLY ABORTED (rc=$ra) — its live in-flight tmp was deleted by B's pre-lock cleanup [#927]. stderr: $(tr '\n' ' ' <"$a_err")"
|
fail_msg "T-RACE: enqueue A was SPURIOUSLY ABORTED (rc=$ra) — its live in-flight tmp was deleted by B's pre-lock cleanup [#927]. stderr: $(tr '\n' ' ' <"$a_err")"
|
||||||
fi
|
fi
|
||||||
if grep -q 'durable pending write FAILED' "$a_err" 2>/dev/null; then
|
if has_match -q 'durable pending write FAILED' "$a_err" 2>/dev/null; then
|
||||||
fail_msg "T-RACE: enqueue A reported 'durable pending write FAILED' — the exact #927 spurious abort (its tmp was clobbered mid-write by a concurrent pre-lock cleanup)."
|
fail_msg "T-RACE: enqueue A reported 'durable pending write FAILED' — the exact #927 spurious abort (its tmp was clobbered mid-write by a concurrent pre-lock cleanup)."
|
||||||
fi
|
fi
|
||||||
[ "$rb" -eq 0 ] || fail_msg "T-RACE: enqueue B should also succeed (rc=$rb). stderr: $(tr '\n' ' ' <"$b_err")"
|
[ "$rb" -eq 0 ] || fail_msg "T-RACE: enqueue B should also succeed (rc=$rb). stderr: $(tr '\n' ' ' <"$b_err")"
|
||||||
@@ -189,20 +196,20 @@ EOF
|
|||||||
|
|
||||||
# #908 store invariants: both durably landed, cursor reached 2, no burned seq.
|
# #908 store invariants: both durably landed, cursor reached 2, no burned seq.
|
||||||
cur="$("$STORE" cursors)"
|
cur="$("$STORE" cursors)"
|
||||||
echo "$cur" | grep -q 'observed_seq=2' || fail_msg "T-RACE: observed_seq must reach 2 (both allocations committed) [$cur]"
|
echo "$cur" | has_match -q 'observed_seq=2' || fail_msg "T-RACE: observed_seq must reach 2 (both allocations committed) [$cur]"
|
||||||
echo "$cur" | grep -q 'pending_depth=2' || fail_msg "T-RACE: both entries must be durably stored — no lost/aborted write [$cur]"
|
echo "$cur" | has_match -q 'pending_depth=2' || fail_msg "T-RACE: both entries must be durably stored — no lost/aborted write [$cur]"
|
||||||
echo "$cur" | grep -q 'consumed_seq=0' || fail_msg "T-RACE: enqueue must never advance consumed_seq [$cur]"
|
echo "$cur" | has_match -q 'consumed_seq=0' || fail_msg "T-RACE: enqueue must never advance consumed_seq [$cur]"
|
||||||
# Gapless: the contiguous prefix 1..2 is consumable (no interior gap/burn).
|
# Gapless: the contiguous prefix 1..2 is consumable (no interior gap/burn).
|
||||||
"$STORE" consume --upto 2 >/dev/null 2>&1 || fail_msg "T-RACE: CONSUMED 2 must succeed — seqs {1,2} are gapless (no burned seq)"
|
"$STORE" consume --upto 2 >/dev/null 2>&1 || fail_msg "T-RACE: CONSUMED 2 must succeed — seqs {1,2} are gapless (no burned seq)"
|
||||||
# No stale tmp left leaking either.
|
# No stale tmp left leaking either.
|
||||||
n_leak="$(find "$STATE_DIR" -maxdepth 1 -name '.wake.tmp.*' -type f | grep -c . || true)"
|
n_leak="$(find "$STATE_DIR" -maxdepth 1 -name '.wake.tmp.*' -type f | count_lines . || true)"
|
||||||
[ "$n_leak" = "0" ] || fail_msg "T-RACE: $n_leak stale tmp file(s) leaked after both enqueues committed"
|
[ "$n_leak" = "0" ] || fail_msg "T-RACE: $n_leak stale tmp file(s) leaked after both enqueues committed"
|
||||||
fi
|
fi
|
||||||
) && ok
|
) && ok
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ -s "$FAILFILE" ]; then
|
if [ -s "$FAILFILE" ]; then
|
||||||
echo "wake store enqueue-race harness: FAILED ($(grep -c . "$FAILFILE") assertion(s)) — #927 TOCTOU reproduced (RED)" >&2
|
echo "wake store enqueue-race harness: FAILED ($(count_lines . "$FAILFILE") assertion(s)) — #927 TOCTOU reproduced (RED)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "wake store enqueue-race harness: all invariants passed ($pass group) — #927 race closed (GREEN)"
|
echo "wake store enqueue-race harness: all invariants passed ($pass group) — #927 race closed (GREEN)"
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""check-973.py — computation backend for the #973 validation harness.
|
||||||
|
|
||||||
|
Everything here derives from exactly two inputs: the frozen denominator
|
||||||
|
artifact (denominator-089615f.json) and the SOURCE TEXT of the ten converted
|
||||||
|
suites at the current tree. It never reads the ledger — set arithmetic against
|
||||||
|
the runtime trace belongs to validate-973.sh, so the two legs of the
|
||||||
|
comparison come from independent code paths.
|
||||||
|
|
||||||
|
Subcommands (all print sorted, stable output; non-zero exit on any failure):
|
||||||
|
|
||||||
|
expected The expected coordinate set from the ARTIFACT: one
|
||||||
|
"<helper> <file>:<line+7>" row per denominator row (+7 = 3
|
||||||
|
converter header lines + 4 lines from the #984 source guard,
|
||||||
|
uniform across all ten suites).
|
||||||
|
Multi-grep lines stay ONE coordinate.
|
||||||
|
|
||||||
|
static The converted-site inventory from the SOURCE TEXT at the current
|
||||||
|
tree: every non-comment line bearing a has_match/count_lines
|
||||||
|
token, as "<helper> <file>:<line>". Independent of the artifact
|
||||||
|
row list, so `expected == static` is a real check on the
|
||||||
|
conversion, not a tautology. (Amendment ONE, leg 1: the ledger is
|
||||||
|
an execution trace, not an inventory — the inventory must come
|
||||||
|
from the text.)
|
||||||
|
|
||||||
|
arms The forced-error arm list: the 19 denominator canaries plus one
|
||||||
|
E-form arm (store-ack:733→740, a $(count_lines) capture compared
|
||||||
|
afterward — the A6 shape) plus one F-form arm (quarantine:560→567,
|
||||||
|
the multi-grep pipeline capture), as "<helper> <file>:<line+7>
|
||||||
|
<form>". Both extras are asserted to exist in the artifact with
|
||||||
|
the expected form — a renumber that moved them fails here, not
|
||||||
|
silently downstream.
|
||||||
|
|
||||||
|
sweep Residual sweep: the denominator's own classifier (ported from the
|
||||||
|
frozen derivation) over the ten suites at the current tree must
|
||||||
|
find ZERO unconverted verdict-form grep sites; and, IN THE SAME
|
||||||
|
RUN, eight specimens (six per-form + two absorb-branch probes, #985)
|
||||||
|
planted into a temp copy of a real
|
||||||
|
suite must ALL be found with their correct forms — an instrument
|
||||||
|
that reports zero must first be seen finding what it claims to
|
||||||
|
find (A5).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
WAKE = HERE.parent
|
||||||
|
ART = HERE / "denominator-089615f.json"
|
||||||
|
|
||||||
|
HEADER_SHIFT = 7 # 3 converter header lines after SCRIPT_DIR + 4 lines from the
|
||||||
|
# #984 source guard (1-line `. _wake-common.sh && wake_assert_init` became a 5-line
|
||||||
|
# guarded block) — both uniform across all ten suites, both above every site.
|
||||||
|
|
||||||
|
# The two hand-picked extra arms (base coordinates; forms asserted at load).
|
||||||
|
EXTRA_ARMS = [
|
||||||
|
("test-wake-store-ack.sh", 733, "E-count-capture"),
|
||||||
|
("test-wake-digest-quarantine.sh", 560, "F-extract-capture"),
|
||||||
|
]
|
||||||
|
|
||||||
|
RX_HELPER = re.compile(r"(^|[^A-Za-z0-9_.-])(has_match|count_lines)([^A-Za-z0-9_.-]|$)")
|
||||||
|
|
||||||
|
# ---- classifier, ported verbatim in logic from the frozen denominator
|
||||||
|
# ---- derivation (docs/journal/fleet/drift-derive-089615f__pepper.py)
|
||||||
|
RX_FAIL_SAME = re.compile(r"(\|\||&&)\s*fail")
|
||||||
|
RX_COUNT_SUB = re.compile(r"\$\(.*grep\s+[^)]*-c|\$\(\s*grep\s+-c")
|
||||||
|
RX_ASSIGN_SUB = re.compile(r'=\s*"?\$\(.*grep')
|
||||||
|
RX_IF = re.compile(r"^\s*(el)?if\s+.*grep")
|
||||||
|
RX_GREP = re.compile(r"(^|[^A-Za-z0-9_.-])grep([^A-Za-z0-9_.-]|$)")
|
||||||
|
|
||||||
|
# grep in COMMAND position: at line start or after a command separator / subshell
|
||||||
|
# opener / shell keyword / `!`. Quote-unaware by design — a quoted "grep" after a
|
||||||
|
# separator reads as a command and lands the line in residual, which fails LOUD;
|
||||||
|
# the absorb direction (note) is the one that must never fire on a real verdict.
|
||||||
|
RX_GREP_CMD = re.compile(
|
||||||
|
r"(?:^|[;|&(`]|\$\(|\bif\b|\belif\b|\bthen\b|\belse\b|\bdo\b|\bwhile\b|\buntil\b|!)"
|
||||||
|
r"\s*grep(?:\s|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(lines):
|
||||||
|
"""Return (sites, dispo). Every line containing the word grep gets a row."""
|
||||||
|
sites, dispo = [], []
|
||||||
|
n = len(lines)
|
||||||
|
for i, raw in enumerate(lines):
|
||||||
|
line = raw
|
||||||
|
ln = i + 1
|
||||||
|
if not RX_GREP.search(line):
|
||||||
|
continue
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
dispo.append((ln, "comment", stripped))
|
||||||
|
continue
|
||||||
|
nxt = ""
|
||||||
|
for j in range(i + 1, min(i + 3, n)):
|
||||||
|
if lines[j].strip():
|
||||||
|
nxt = lines[j].strip()
|
||||||
|
break
|
||||||
|
if "$(" in line and RX_COUNT_SUB.search(line):
|
||||||
|
sites.append((ln, "E-count-capture", stripped))
|
||||||
|
continue
|
||||||
|
if RX_ASSIGN_SUB.search(line) and "grep -c" not in line:
|
||||||
|
sites.append((ln, "F-extract-capture", stripped))
|
||||||
|
continue
|
||||||
|
if RX_FAIL_SAME.search(line):
|
||||||
|
sites.append((ln, "A-same-line", stripped))
|
||||||
|
continue
|
||||||
|
if stripped.endswith("\\"):
|
||||||
|
k = i + 1
|
||||||
|
joined = stripped[:-1]
|
||||||
|
while k < n:
|
||||||
|
cont = lines[k].strip()
|
||||||
|
joined += " " + (cont[:-1] if cont.endswith("\\") else cont)
|
||||||
|
if not cont.endswith("\\"):
|
||||||
|
break
|
||||||
|
k += 1
|
||||||
|
if re.search(r"(\|\||&&)\s*fail", joined) or (
|
||||||
|
joined.rstrip().endswith(("||", "&&"))
|
||||||
|
and k + 1 < n
|
||||||
|
and lines[k + 1].strip().startswith("fail")
|
||||||
|
):
|
||||||
|
sites.append((ln, "C-cont-backslash", stripped))
|
||||||
|
continue
|
||||||
|
dispo.append((ln, "backslash-no-fail-continuation", stripped))
|
||||||
|
continue
|
||||||
|
if stripped.endswith(("||", "&&")) and nxt.startswith("fail"):
|
||||||
|
sites.append((ln, "B-cont-operator", stripped))
|
||||||
|
continue
|
||||||
|
if RX_IF.search(line):
|
||||||
|
window = " ".join(lines[j] for j in range(i, min(i + 5, n)))
|
||||||
|
if "fail" in window:
|
||||||
|
sites.append((ln, "D-if-form", stripped))
|
||||||
|
continue
|
||||||
|
dispo.append((ln, "if-grep-no-fail-window", stripped))
|
||||||
|
continue
|
||||||
|
win = " ".join(lines[j] for j in range(max(0, i - 2), min(i + 3, n)))
|
||||||
|
if re.search(r"fail", win, re.I):
|
||||||
|
dispo.append((ln, "BACKSTOP-HAND-REVIEW", stripped))
|
||||||
|
else:
|
||||||
|
dispo.append((ln, "no-verdict-context", stripped))
|
||||||
|
return sites, dispo
|
||||||
|
|
||||||
|
|
||||||
|
def residual_sites(lines):
|
||||||
|
"""classify() plus the absorb decision — the ONE path both sweep legs share.
|
||||||
|
|
||||||
|
A classified site is absorbed as a note only when its line carries a wake
|
||||||
|
helper token AND the line shows no grep in command position: a converted
|
||||||
|
line whose PATTERN argument merely contains the word grep. A helper line
|
||||||
|
that also runs a real grep verdict (has_match ... && grep -q SECRET ... &&
|
||||||
|
fail) stays residual (#985). Multi-line forms anchor the site at the line
|
||||||
|
containing grep, so a command-position grep on a continuation line never
|
||||||
|
shares its line with the helper token and stays residual by construction.
|
||||||
|
"""
|
||||||
|
sites, dispo = classify(lines)
|
||||||
|
residual, notes = [], []
|
||||||
|
for ln, form, text in sites:
|
||||||
|
line = lines[ln - 1]
|
||||||
|
if RX_HELPER.search(line) and not RX_GREP_CMD.search(line):
|
||||||
|
notes.append((ln, form, text))
|
||||||
|
else:
|
||||||
|
residual.append((ln, form, text))
|
||||||
|
return residual, notes, dispo
|
||||||
|
|
||||||
|
|
||||||
|
def load_art():
|
||||||
|
art = json.loads(ART.read_text())
|
||||||
|
assert art["total"] == 261 == len(art["rows"]), "artifact self-consistency"
|
||||||
|
return art
|
||||||
|
|
||||||
|
|
||||||
|
def helper_for(row):
|
||||||
|
return "count_lines" if row["form"].startswith("E") else "has_match"
|
||||||
|
|
||||||
|
|
||||||
|
def suite_files(art):
|
||||||
|
return sorted({r["file"] for r in art["rows"]})
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_expected():
|
||||||
|
art = load_art()
|
||||||
|
out = sorted(
|
||||||
|
f"{helper_for(r)} {r['file']}:{r['line'] + HEADER_SHIFT}" for r in art["rows"]
|
||||||
|
)
|
||||||
|
assert len(out) == len(set(out)) == 261, "expected set must be 261 distinct rows"
|
||||||
|
print("\n".join(out))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_static():
|
||||||
|
art = load_art()
|
||||||
|
rows = []
|
||||||
|
for f in suite_files(art):
|
||||||
|
for i, line in enumerate((WAKE / f).read_text().split("\n"), start=1):
|
||||||
|
if line.strip().startswith("#"):
|
||||||
|
continue
|
||||||
|
m = RX_HELPER.search(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
helper = (
|
||||||
|
"count_lines"
|
||||||
|
if RX_HELPER.search(line).group(2) == "count_lines"
|
||||||
|
else "has_match"
|
||||||
|
)
|
||||||
|
rows.append(f"{helper} {f}:{i}")
|
||||||
|
print("\n".join(sorted(rows)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_arms():
|
||||||
|
art = load_art()
|
||||||
|
by_key = {(r["file"], r["line"]): r for r in art["rows"]}
|
||||||
|
rows = []
|
||||||
|
canaries = [r for r in art["rows"] if r.get("canary")]
|
||||||
|
assert len(canaries) == 19, f"expected 19 canaries, artifact has {len(canaries)}"
|
||||||
|
for r in canaries:
|
||||||
|
rows.append(f"{helper_for(r)} {r['file']}:{r['line'] + HEADER_SHIFT} {r['form']}")
|
||||||
|
for f, ln, want_form in EXTRA_ARMS:
|
||||||
|
r = by_key.get((f, ln))
|
||||||
|
assert r is not None, f"extra arm {f}:{ln} not in artifact — renumbered?"
|
||||||
|
assert r["form"] == want_form, f"extra arm {f}:{ln} form {r['form']} != {want_form}"
|
||||||
|
rows.append(f"{helper_for(r)} {f}:{ln + HEADER_SHIFT} {r['form']}")
|
||||||
|
assert len(rows) == 21
|
||||||
|
print("\n".join(rows))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# (expected classify form, expected disposition through residual_sites, snippet)
|
||||||
|
PLANTS = [
|
||||||
|
("A-same-line", "residual", ['grep -q needle haystack || fail "plant-A"']),
|
||||||
|
("B-cont-operator", "residual", ["grep -q needle haystack ||", ' fail "plant-B"']),
|
||||||
|
("C-cont-backslash", "residual", ["grep -q needle \\", ' haystack || fail "plant-C"']),
|
||||||
|
("D-if-form", "residual", ["if ! grep -q needle haystack; then", ' fail "plant-D"', "fi"]),
|
||||||
|
("E-count-capture", "residual", ['[ "$(grep -c needle haystack)" = "1" ] || fail "plant-E"']),
|
||||||
|
("F-extract-capture", "residual", ['val="$(grep needle haystack)"']),
|
||||||
|
# G: a converted line that ALSO runs a raw grep verdict — the helper token
|
||||||
|
# must not absorb it (#985)
|
||||||
|
("A-same-line", "residual", ['has_match -q needle "$F" && grep -q SECRET "$F" && fail "plant-G"']),
|
||||||
|
# H: negative control — helper whose PATTERN argument is the word grep;
|
||||||
|
# must be absorbed as a note, never residual
|
||||||
|
("A-same-line", "note", ['has_match -q "grep" haystack || fail "plant-H"']),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sweep():
|
||||||
|
art = load_art()
|
||||||
|
bad = 0
|
||||||
|
|
||||||
|
# leg 1: real suites at the current tree must be residual-free
|
||||||
|
for f in suite_files(art):
|
||||||
|
residual, notes, _dispo = residual_sites((WAKE / f).read_text().split("\n"))
|
||||||
|
for ln, form, text in notes:
|
||||||
|
# converted line whose PATTERN argument contains the word grep:
|
||||||
|
# not an unconverted site, but never silently absorbed either
|
||||||
|
print(f"SWEEP-NOTE {f}:{ln} converted line matches grep-token ({form}): {text[:80]}")
|
||||||
|
for ln, form, text in residual:
|
||||||
|
print(f"SWEEP-RESIDUAL {f}:{ln} {form}: {text[:100]}")
|
||||||
|
bad += 1
|
||||||
|
print(f"SWEEP {f}: {len(residual)} residual verdict site(s)")
|
||||||
|
|
||||||
|
# leg 2, SAME RUN, SAME PATH as leg 1: the instrument must find every plant
|
||||||
|
# with the right form AND the right absorb disposition — plants G/H exercise
|
||||||
|
# the absorb branch itself, so this leg must go through residual_sites(),
|
||||||
|
# not raw classify()
|
||||||
|
donor = suite_files(art)[0]
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
planted = Path(td) / donor
|
||||||
|
shutil.copy(WAKE / donor, planted)
|
||||||
|
base_lines = planted.read_text().split("\n")
|
||||||
|
offset = len(base_lines)
|
||||||
|
expect = {}
|
||||||
|
for form, dispo, snippet in PLANTS:
|
||||||
|
expect[offset + 1] = (form, dispo) # first physical line of each plant
|
||||||
|
base_lines.extend(snippet)
|
||||||
|
offset = len(base_lines)
|
||||||
|
planted.write_text("\n".join(base_lines))
|
||||||
|
residual, notes, _ = residual_sites(planted.read_text().split("\n"))
|
||||||
|
found = {ln: (form, "residual") for ln, form, _t in residual}
|
||||||
|
found.update({ln: (form, "note") for ln, form, _t in notes})
|
||||||
|
unexpected = [(ln, form) for ln, form, _t in residual if ln not in expect]
|
||||||
|
hits = sum(1 for ln, want in expect.items() if found.get(ln) == want)
|
||||||
|
n_plants = len(PLANTS)
|
||||||
|
print(f"SWEEP-PLANTS found={hits}/{n_plants} in planted copy of {donor}")
|
||||||
|
if hits != n_plants:
|
||||||
|
for ln, want in sorted(expect.items()):
|
||||||
|
got = found.get(ln, ("<missed>", "<missed>"))
|
||||||
|
if got != want:
|
||||||
|
print(f"SWEEP-PLANT-MISS line {ln}: expected {want}, got {got}")
|
||||||
|
bad += 1
|
||||||
|
if unexpected:
|
||||||
|
# the donor is a converted suite: any non-plant RESIDUAL site in the
|
||||||
|
# copy contradicts the zero leg 1 just reported on the original
|
||||||
|
# (non-plant notes mirror leg 1's treatment: printed there, not bad)
|
||||||
|
for ln, form in unexpected:
|
||||||
|
print(f"SWEEP-PLANT-UNEXPECTED {donor}(copy):{ln} {form}")
|
||||||
|
bad += 1
|
||||||
|
|
||||||
|
return 1 if bad else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cmds = {
|
||||||
|
"expected": cmd_expected,
|
||||||
|
"static": cmd_static,
|
||||||
|
"arms": cmd_arms,
|
||||||
|
"sweep": cmd_sweep,
|
||||||
|
}
|
||||||
|
if len(sys.argv) != 2 or sys.argv[1] not in cmds:
|
||||||
|
sys.exit(f"usage: check-973.py {{{'|'.join(cmds)}}}")
|
||||||
|
sys.exit(cmds[sys.argv[1]]())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""convert-973.py — mechanical #973 site conversion, driven by the frozen
|
||||||
|
denominator artifact (denominator-089615f.json), never by ad-hoc grepping.
|
||||||
|
|
||||||
|
For every row in the artifact it FIRST verifies the worktree line still says
|
||||||
|
what the artifact froze (exact match after whitespace strip, artifact-side
|
||||||
|
truncation as prefix match, or the normalized suite-summary template), and
|
||||||
|
aborts before touching anything on the first verification failure — a
|
||||||
|
conversion applied to a line the denominator did not measure would be the
|
||||||
|
umbrella defect wearing the converter's clothes.
|
||||||
|
|
||||||
|
Transforms (behaviour-preserving; verdict semantics unchanged on grep rc 0/1):
|
||||||
|
E-count-capture `grep -c ARGS` -> `count_lines ARGS` (helper adds -c)
|
||||||
|
all other forms `grep ARGS` -> `has_match ARGS` (drop-in)
|
||||||
|
env prefixes (`LC_ALL=C grep`) are kept — the prefix reaches the grep child
|
||||||
|
through the function (microtest C8).
|
||||||
|
|
||||||
|
The two multi-grep pipeline sites (digest-quarantine:560, install:420) convert
|
||||||
|
BOTH greps: each is measurement-bearing, and under pipefail an rc=2 in the
|
||||||
|
left element is masked by an rc=1 in the right — the same defect one pipe
|
||||||
|
deeper. They stay ONE denominator site each (one coordinate); the ledger
|
||||||
|
records helper calls, so those coordinates appear twice per execution and the
|
||||||
|
equality check compares SETS of coordinates.
|
||||||
|
|
||||||
|
Finally each suite gains three header lines directly after its SCRIPT_DIR
|
||||||
|
assignment (source + wake_assert_init + comment), shifting every site by +3
|
||||||
|
lines exactly; the validation harness maps base coordinates accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
WAKE = HERE.parent
|
||||||
|
ART = HERE / "denominator-089615f.json"
|
||||||
|
|
||||||
|
RX_GREP_TOKEN = re.compile(r"(^|[^A-Za-z0-9_.-])grep([^A-Za-z0-9_.-]|$)")
|
||||||
|
|
||||||
|
HEADER = [
|
||||||
|
"# #973: three-valued grep assertion helpers (has_match/count_lines); init saves real stderr for abort loudness.\n",
|
||||||
|
"# shellcheck disable=SC1091\n",
|
||||||
|
'. "$SCRIPT_DIR/_wake-common.sh" && wake_assert_init\n',
|
||||||
|
]
|
||||||
|
|
||||||
|
MULTI_GREP_CONVERT_BOTH = {
|
||||||
|
("test-wake-digest-quarantine.sh", 560),
|
||||||
|
("test-wake-install.sh", 420),
|
||||||
|
}
|
||||||
|
|
||||||
|
SUMMARY_TEMPLATE_MARK = '$(grep -c . "$FAILFILE")'
|
||||||
|
|
||||||
|
|
||||||
|
def verify(row, actual):
|
||||||
|
a = actual.strip()
|
||||||
|
t = row["text"].strip()
|
||||||
|
if a == t:
|
||||||
|
return True
|
||||||
|
if t and a.startswith(t): # artifact-side truncation
|
||||||
|
return True
|
||||||
|
# normalized suite-summary template rows
|
||||||
|
if t.startswith('echo "wake ') and SUMMARY_TEMPLATE_MARK in actual and a.startswith('echo "wake '):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def convert_line(row, line):
|
||||||
|
key = (row["file"], row["line"])
|
||||||
|
n_grep = len(RX_GREP_TOKEN.findall(line))
|
||||||
|
if key in MULTI_GREP_CONVERT_BOTH:
|
||||||
|
assert n_grep == 2, f"{key}: expected 2 grep tokens, found {n_grep}"
|
||||||
|
else:
|
||||||
|
assert n_grep == 1, f"{key}: expected 1 grep token, found {n_grep}: {line!r}"
|
||||||
|
|
||||||
|
if row["form"].startswith("E"):
|
||||||
|
assert line.count("grep -c ") == 1, f"{key}: E row without single 'grep -c ': {line!r}"
|
||||||
|
return line.replace("grep -c ", "count_lines ", 1)
|
||||||
|
|
||||||
|
def repl(m):
|
||||||
|
return m.group(1) + "has_match" + m.group(2)
|
||||||
|
|
||||||
|
count = 2 if key in MULTI_GREP_CONVERT_BOTH else 1
|
||||||
|
return RX_GREP_TOKEN.sub(repl, line, count=count)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
art = json.loads(ART.read_text())
|
||||||
|
rows = art["rows"]
|
||||||
|
by_file = {}
|
||||||
|
for r in rows:
|
||||||
|
by_file.setdefault(r["file"], []).append(r)
|
||||||
|
|
||||||
|
# pass 1: verify every row before touching any file
|
||||||
|
bad = 0
|
||||||
|
texts = {}
|
||||||
|
for f, frs in by_file.items():
|
||||||
|
lines = (WAKE / f).read_text().split("\n")
|
||||||
|
texts[f] = lines
|
||||||
|
for r in frs:
|
||||||
|
if not verify(r, lines[r["line"] - 1]):
|
||||||
|
bad += 1
|
||||||
|
print(f"VERIFY-FAIL {f}:{r['line']}\n artifact: {r['text']!r}\n worktree: {lines[r['line'] - 1]!r}")
|
||||||
|
if bad:
|
||||||
|
sys.exit(f"ABORT: {bad} row(s) failed verification; nothing was modified.")
|
||||||
|
|
||||||
|
# pass 2: convert + insert header
|
||||||
|
total = {"has_match": 0, "count_lines": 0}
|
||||||
|
for f, frs in sorted(by_file.items()):
|
||||||
|
lines = texts[f]
|
||||||
|
for r in frs:
|
||||||
|
i = r["line"] - 1
|
||||||
|
new = convert_line(r, lines[i])
|
||||||
|
assert new != lines[i], f"{f}:{r['line']}: no-op conversion"
|
||||||
|
lines[i] = new
|
||||||
|
total["count_lines" if r["form"].startswith("E") else "has_match"] += 1
|
||||||
|
# header insertion after the SCRIPT_DIR= line
|
||||||
|
sd = [i for i, ln in enumerate(lines) if ln.startswith('SCRIPT_DIR="$(')]
|
||||||
|
assert len(sd) == 1, f"{f}: expected exactly one SCRIPT_DIR line, found {len(sd)}"
|
||||||
|
first_site = min(r["line"] for r in frs) - 1
|
||||||
|
assert sd[0] < first_site, f"{f}: SCRIPT_DIR line {sd[0] + 1} not before first site {first_site + 1}"
|
||||||
|
lines[sd[0] + 1 : sd[0] + 1] = [h.rstrip("\n") for h in HEADER]
|
||||||
|
(WAKE / f).write_text("\n".join(lines))
|
||||||
|
print(f"{f}: {len(frs)} sites converted, header at line {sd[0] + 2}")
|
||||||
|
|
||||||
|
print(f"TOTAL: {total['has_match']} has_match + {total['count_lines']} count_lines = {sum(total.values())} sites in {len(by_file)} files")
|
||||||
|
assert sum(total.values()) == art["total"] == 261, "site count mismatch vs artifact"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
+299
@@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# microtest-wake-assert.sh — #973 instrument self-test. Run BEFORE trusting any
|
||||||
|
# validate-run evidence: it proves the counted ledger and the abort mechanics on
|
||||||
|
# two generated mini-suites, so a defect in the instrument cannot silently wear
|
||||||
|
# the colour of a clean validation.
|
||||||
|
#
|
||||||
|
# What it proves (each check named C1..C8 below):
|
||||||
|
# C1 green run: ledger set EQUALS a text-derived expected set spanning TWO
|
||||||
|
# files (file-field discrimination), row count > 1, both sentinels emitted,
|
||||||
|
# exit 0. Also pins the BASH_LINENO convention for backslash-continuation
|
||||||
|
# call sites against the first-physical-line convention the denominator
|
||||||
|
# artifact uses.
|
||||||
|
# C2 early-exit truncation: a suite that exits before its later site yields a
|
||||||
|
# SHORT ledger, and the expected-set comparison catches it — a counted
|
||||||
|
# ledger must report its own truncation, never a smaller total.
|
||||||
|
# C3 abort from inside a `( ... )` test subshell kills the WHOLE suite: no
|
||||||
|
# sentinel, non-zero exit, loud named reason (file:line + raw rc).
|
||||||
|
# C4 abort stays loud at a call site that appends 2>/dev/null (the preimage
|
||||||
|
# canary shape) — the saved-fd path.
|
||||||
|
# C5 abort escapes a `$( count_lines ... )` substitution (A6 shape): the
|
||||||
|
# count from a failed measurement is never compared and the suite dies.
|
||||||
|
# C6 abort escapes a pipeline tail (`printf | has_match`).
|
||||||
|
# C7 count_lines prints 0 on grep rc 1 (zero matches is a measurement, not an
|
||||||
|
# error) — implicit in C1's green run via the delta-count site.
|
||||||
|
# C8 an env-prefix on the helper (`LC_ALL=C has_match ...`) reaches the grep
|
||||||
|
# child — pins the conversion shape for the digest-hmac LC_ALL site.
|
||||||
|
# C9 an arm that matches NO site is loud about it by omission: green run,
|
||||||
|
# sentinel present, and NO "WAKE-ASSERT ARMED" line — so "did not abort"
|
||||||
|
# is separable into arm-never-matched (no ARMED line) vs error-path-
|
||||||
|
# broken (ARMED line, no abort). C3..C6 require the ARMED line AND the
|
||||||
|
# aborting site's ledger row (append lands BEFORE the grep runs, so an
|
||||||
|
# abort can never shorten the count it is part of).
|
||||||
|
# C10 the BASH_LINENO pin's abort arm fires: under a probe interpreter that
|
||||||
|
# misreports the continuation line, wake_assert_init aborts loudly and
|
||||||
|
# nothing past init executes — a pin whose failure arm was never seen
|
||||||
|
# firing is an undertaking, not a control.
|
||||||
|
# C11 the FAILED-summary template executes on the red path: a mini-suite
|
||||||
|
# driven deterministically red emits the exact converted summary shape
|
||||||
|
# (`FAILED ($(count_lines . "$FAILFILE") assertion(s))`) with the right
|
||||||
|
# count, exits 1, and the summary site's ledger row lands. The nine
|
||||||
|
# real-suite summary sites are structurally unreachable in a green run
|
||||||
|
# (guarded by [ -s "$FAILFILE" ]); their dispositions cite THIS check as
|
||||||
|
# the measured execution of the same template, so "unexecuted in the
|
||||||
|
# green run" never silently means "never executed anywhere".
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
export WAKE_COMMON="$HERE/../_wake-common.sh"
|
||||||
|
[ -f "$WAKE_COMMON" ] || {
|
||||||
|
echo "microtest: _wake-common.sh not found at $WAKE_COMMON" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
fails=0
|
||||||
|
check() { # check NAME COND-DESCRIPTION (pass/fail already decided by caller: $1=name $2=0|1 $3=detail)
|
||||||
|
if [ "$2" -eq 0 ]; then
|
||||||
|
echo " PASS $1"
|
||||||
|
else
|
||||||
|
echo " FAIL $1 — $3"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- fixture data ----------------------------------------------------------
|
||||||
|
printf 'alpha\nbeta\nbeta\ngamma-unused\n' >"$TMP/data.txt"
|
||||||
|
|
||||||
|
# --- mini-suite A: six helper sites across every converted form ------------
|
||||||
|
cat >"$TMP/mini-a.sh" <<'MINI_A'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
. "$WAKE_COMMON"
|
||||||
|
wake_assert_init
|
||||||
|
TMP="$1"
|
||||||
|
FAILFILE="$TMP/failures-a"
|
||||||
|
: >"$FAILFILE"
|
||||||
|
fail_msg() { echo " FAIL: $*" >&2; echo x >>"$FAILFILE"; }
|
||||||
|
ok() { :; }
|
||||||
|
(
|
||||||
|
has_match -q alpha "$TMP/data.txt" || fail_msg "alpha missing" # SITE:or-subshell
|
||||||
|
) && ok
|
||||||
|
(
|
||||||
|
has_match -q FORBIDDEN "$TMP/data.txt" 2>/dev/null && fail_msg "forbidden present" # SITE:and-swallow
|
||||||
|
) && ok
|
||||||
|
(
|
||||||
|
[ "$(count_lines beta "$TMP/data.txt")" = "2" ] || fail_msg "beta count" # SITE:count-capture
|
||||||
|
) && ok
|
||||||
|
(
|
||||||
|
printf 'gamma\n' | has_match -q gamma || fail_msg "gamma pipeline" # SITE:pipeline
|
||||||
|
) && ok
|
||||||
|
(
|
||||||
|
has_match -q \
|
||||||
|
alpha "$TMP/data.txt" || fail_msg "continuation" # SITE:continuation
|
||||||
|
) && ok
|
||||||
|
(
|
||||||
|
[ "$(count_lines delta "$TMP/data.txt")" = "0" ] || fail_msg "delta zero" # SITE:count-zero
|
||||||
|
) && ok
|
||||||
|
if [ -s "$FAILFILE" ]; then
|
||||||
|
echo "mini-a: FAILED" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "mini-a: OK" >&2
|
||||||
|
MINI_A
|
||||||
|
|
||||||
|
# --- mini-suite B: second file, one site behind an early exit --------------
|
||||||
|
cat >"$TMP/mini-b.sh" <<'MINI_B'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
. "$WAKE_COMMON"
|
||||||
|
wake_assert_init
|
||||||
|
TMP="$1"
|
||||||
|
(
|
||||||
|
has_match -q alpha "$TMP/data.txt" || echo "b1 missing" >&2 # SITE:b-first
|
||||||
|
)
|
||||||
|
if [ "${MINI_B_EARLY_EXIT:-}" = "1" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
(
|
||||||
|
has_match -q beta "$TMP/data.txt" || echo "b2 missing" >&2 # SITE:b-second
|
||||||
|
)
|
||||||
|
echo "mini-b: OK" >&2
|
||||||
|
MINI_B
|
||||||
|
chmod +x "$TMP/mini-a.sh" "$TMP/mini-b.sh"
|
||||||
|
|
||||||
|
# Text-derived expected set: helper-name + basename:line for every SITE-marked
|
||||||
|
# call, taken from the generated files' TEXT (independent of BASH_LINENO), with
|
||||||
|
# the continuation site expected at its FIRST physical line — the denominator
|
||||||
|
# artifact's convention.
|
||||||
|
expected_set() { # expected_set FILE
|
||||||
|
local f="$1" base
|
||||||
|
base="$(basename "$f")"
|
||||||
|
awk '
|
||||||
|
/# SITE:/ {
|
||||||
|
line = NR
|
||||||
|
if ($0 !~ /has_match|count_lines/) line = NR - 1 # marker on the continuation tail
|
||||||
|
print line
|
||||||
|
}
|
||||||
|
' "$f" | while read -r ln; do
|
||||||
|
txt="$(sed -n "${ln}p" "$f")"
|
||||||
|
case "$txt" in
|
||||||
|
*count_lines*) printf 'count_lines %s:%s\n' "$base" "$ln" ;;
|
||||||
|
*) printf 'has_match %s:%s\n' "$base" "$ln" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
site_line() { # site_line FILE MARKER -> first physical line of that call
|
||||||
|
local f="$1" marker="$2" ln
|
||||||
|
ln="$(grep -n "# SITE:${marker}\$" "$f" | cut -d: -f1)"
|
||||||
|
# continuation marker sits on the tail line; the call starts one line up
|
||||||
|
if ! sed -n "${ln}p" "$f" | grep -Eq 'has_match|count_lines'; then
|
||||||
|
ln=$((ln - 1))
|
||||||
|
fi
|
||||||
|
printf '%s' "$ln"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- C1: green run, two files, set equality --------------------------------
|
||||||
|
LEDGER="$TMP/ledger-c1"
|
||||||
|
: >"$LEDGER"
|
||||||
|
outA="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
|
rcA=$?
|
||||||
|
outB="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-b.sh" "$TMP" 2>&1)"
|
||||||
|
rcB=$?
|
||||||
|
{ expected_set "$TMP/mini-a.sh"; expected_set "$TMP/mini-b.sh"; } | sort >"$TMP/expected-c1"
|
||||||
|
sort "$LEDGER" >"$TMP/got-c1"
|
||||||
|
n_expected="$(grep -c . "$TMP/expected-c1")"
|
||||||
|
if [ "$rcA" -eq 0 ] && [ "$rcB" -eq 0 ] &&
|
||||||
|
printf '%s' "$outA" | grep -q 'mini-a: OK' &&
|
||||||
|
printf '%s' "$outB" | grep -q 'mini-b: OK' &&
|
||||||
|
[ "$n_expected" -gt 1 ] &&
|
||||||
|
cmp -s "$TMP/expected-c1" "$TMP/got-c1"; then
|
||||||
|
check C1 0 ""
|
||||||
|
else
|
||||||
|
check C1 1 "rcA=$rcA rcB=$rcB expected($n_expected)/got diff: $(diff "$TMP/expected-c1" "$TMP/got-c1" 2>&1 | head -n 10 | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- C2: early exit -> short ledger, comparison catches it -----------------
|
||||||
|
LEDGER="$TMP/ledger-c2"
|
||||||
|
: >"$LEDGER"
|
||||||
|
WAKE_ASSERT_LEDGER="$LEDGER" MINI_B_EARLY_EXIT=1 bash "$TMP/mini-b.sh" "$TMP" >/dev/null 2>&1
|
||||||
|
expected_set "$TMP/mini-b.sh" | sort >"$TMP/expected-c2"
|
||||||
|
sort "$LEDGER" >"$TMP/got-c2"
|
||||||
|
if ! cmp -s "$TMP/expected-c2" "$TMP/got-c2" &&
|
||||||
|
grep -q "has_match mini-b.sh:$(site_line "$TMP/mini-b.sh" b-first)" "$TMP/got-c2" &&
|
||||||
|
! grep -q "mini-b.sh:$(site_line "$TMP/mini-b.sh" b-second)" "$TMP/got-c2"; then
|
||||||
|
check C2 0 ""
|
||||||
|
else
|
||||||
|
check C2 1 "truncated ledger was not detected as short"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- C3..C6: per-shape abort proofs ----------------------------------------
|
||||||
|
abort_case() { # abort_case NAME MARKER HELPER
|
||||||
|
local name="$1" marker="$2" helper="$3" ln site out rc ledger
|
||||||
|
ln="$(site_line "$TMP/mini-a.sh" "$marker")"
|
||||||
|
site="mini-a.sh:${ln}"
|
||||||
|
ledger="$TMP/ledger-${name}"
|
||||||
|
: >"$ledger"
|
||||||
|
out="$(WAKE_ASSERT_LEDGER="$ledger" WAKE_ASSERT_FORCE_GREP_ERROR_AT="$site" \
|
||||||
|
bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ] &&
|
||||||
|
! printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||||
|
! printf '%s' "$out" | grep -q 'mini-a: FAILED' &&
|
||||||
|
printf '%s' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" &&
|
||||||
|
printf '%s' "$out" | grep -q "WAKE-ASSERT ABORT" &&
|
||||||
|
printf '%s' "$out" | grep -q "$site" &&
|
||||||
|
printf '%s' "$out" | grep -q "grep exit 2" &&
|
||||||
|
grep -q "^${helper} ${site}\$" "$ledger"; then
|
||||||
|
check "$name" 0 ""
|
||||||
|
else
|
||||||
|
check "$name" 1 "rc=$rc site=$site ledger=$(grep -c . "$ledger") out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
abort_case C3 or-subshell has_match
|
||||||
|
abort_case C4 and-swallow has_match
|
||||||
|
abort_case C5 count-capture count_lines
|
||||||
|
abort_case C6 pipeline has_match
|
||||||
|
|
||||||
|
# --- C7: covered by C1 (delta-count site prints 0 on grep rc 1) ------------
|
||||||
|
check C7 0 ""
|
||||||
|
|
||||||
|
# --- C8: env-prefix on a function reaches the grep child -------------------
|
||||||
|
envprobe() { command env | command grep -c '^LC_ALL=xx_wake_test$'; }
|
||||||
|
got="$(LC_ALL=xx_wake_test envprobe 2>/dev/null)" # bash's setlocale warning about the fake locale is itself proof the prefix landed
|
||||||
|
if [ "$got" = "1" ]; then check C8 0 ""; else check C8 1 "env-prefix did not reach child (got=$got)"; fi
|
||||||
|
|
||||||
|
# --- C9: arm matching NO site -> green run, no ARMED line ------------------
|
||||||
|
out="$(WAKE_ASSERT_FORCE_GREP_ERROR_AT="mini-a.sh:9999" bash "$TMP/mini-a.sh" "$TMP" 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 0 ] &&
|
||||||
|
printf '%s' "$out" | grep -q 'mini-a: OK' &&
|
||||||
|
! printf '%s' "$out" | grep -q 'WAKE-ASSERT ARMED'; then
|
||||||
|
check C9 0 ""
|
||||||
|
else
|
||||||
|
check C9 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- C10: lineno pin aborts under an interpreter that breaks the convention -
|
||||||
|
cat >"$TMP/fake-bash" <<'FAKE'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# stand-in for a bash whose BASH_LINENO convention differs: misreports the
|
||||||
|
# continuation call one line low (the exact skew the pin exists to catch)
|
||||||
|
printf '3\n5\n'
|
||||||
|
FAKE
|
||||||
|
chmod +x "$TMP/fake-bash"
|
||||||
|
out="$(WAKE_ASSERT_PIN_BASH="$TMP/fake-bash" bash -c '. "$WAKE_COMMON" && wake_assert_init && echo REACHED-PAST-INIT' 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ] &&
|
||||||
|
! printf '%s' "$out" | grep -q 'REACHED-PAST-INIT' &&
|
||||||
|
printf '%s' "$out" | grep -q 'WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated'; then
|
||||||
|
check C10 0 ""
|
||||||
|
else
|
||||||
|
check C10 1 "rc=$rc out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- C11: red path executes the converted FAILED-summary template -----------
|
||||||
|
cat >"$TMP/mini-c.sh" <<'MINI_C'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
. "$WAKE_COMMON"
|
||||||
|
wake_assert_init
|
||||||
|
TMP="$1"
|
||||||
|
FAILFILE="$TMP/failures-c"
|
||||||
|
: >"$FAILFILE"
|
||||||
|
fail_msg() { echo " FAIL: $*" >&2; echo x >>"$FAILFILE"; }
|
||||||
|
ok() { :; }
|
||||||
|
(
|
||||||
|
has_match -q alpha "$TMP/data.txt" && fail_msg "alpha present" # SITE:c-inverted (deterministically red: alpha IS in the fixture)
|
||||||
|
) && ok
|
||||||
|
echo
|
||||||
|
if [ -s "$FAILFILE" ]; then
|
||||||
|
echo "wake mini-c harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2 # SITE:c-summary
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "wake mini-c harness: all invariants passed (1 group)"
|
||||||
|
MINI_C
|
||||||
|
chmod +x "$TMP/mini-c.sh"
|
||||||
|
LEDGER="$TMP/ledger-c11"
|
||||||
|
: >"$LEDGER"
|
||||||
|
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$TMP/mini-c.sh" "$TMP" 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
summary_ln="$(site_line "$TMP/mini-c.sh" c-summary)"
|
||||||
|
if [ "$rc" -eq 1 ] &&
|
||||||
|
printf '%s' "$out" | grep -q 'wake mini-c harness: FAILED (1 assertion(s))' &&
|
||||||
|
! printf '%s' "$out" | grep -q 'all invariants passed' &&
|
||||||
|
grep -q "^count_lines mini-c.sh:${summary_ln}\$" "$LEDGER"; then
|
||||||
|
check C11 0 ""
|
||||||
|
else
|
||||||
|
check C11 1 "rc=$rc summary_ln=$summary_ln ledger=$(tr '\n' ' ' <"$LEDGER") out=$(printf '%s' "$out" | tail -n 2 | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$fails" -gt 0 ]; then
|
||||||
|
echo "microtest-wake-assert: FAILED ($fails check(s))" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "microtest-wake-assert: OK (all checks passed)"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# unexecuted-sites-dispositions.txt — #973 validation, amendment ONE leg 3.
|
||||||
|
#
|
||||||
|
# The ledger is an execution trace, not an inventory: a green instrumented run
|
||||||
|
# cannot execute a site that only lives on a suite's red path. Every converted
|
||||||
|
# site that did NOT appear in the green-run trace is enumerated here with an
|
||||||
|
# individual disposition; validate-973.sh fails if any unexecuted site lacks a
|
||||||
|
# row here, and ALSO fails if a row here names a site that DID execute (stale
|
||||||
|
# disposition). Key = first two whitespace-separated fields; text after "—" is
|
||||||
|
# the adjudication.
|
||||||
|
#
|
||||||
|
# All nine sites below are the same structural shape, adjudicated one by one
|
||||||
|
# from source text: the suite's FAILED-branch summary line,
|
||||||
|
# echo "wake <name> harness: FAILED ($(count_lines . "$FAILFILE") assertion(s))" >&2
|
||||||
|
# guarded by `if [ -s "$FAILFILE" ]` — structurally unreachable while every
|
||||||
|
# assertion passes, which is precisely the state a green validation run is
|
||||||
|
# required to be in. (The tenth suite, test-wake-preimage.sh, uses its own
|
||||||
|
# X/Y summary format with no grep in the red branch, so it has no row here.)
|
||||||
|
#
|
||||||
|
# The disposition is NOT "it would work": the exact template is EXECUTED red
|
||||||
|
# in microtest C11 (deterministically failed mini-suite, same
|
||||||
|
# count_lines-in-substitution summary shape → right count, exit 1, ledger row
|
||||||
|
# at the summary coordinate), and the E-in-substitution abort path is proven
|
||||||
|
# by microtest C5 plus the forced-error arm at test-wake-store-ack.sh:736.
|
||||||
|
# Each site's conversion text is independently verified by the static
|
||||||
|
# inventory (check-973.py static == expected, all 261 rows).
|
||||||
|
#
|
||||||
|
# Verified guard per site (line numbers at branch tip, +3 header shift):
|
||||||
|
|
||||||
|
count_lines test-wake-beacon.sh:354 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 353; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-detector.sh:706 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 705; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-digest-hmac.sh:438 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 437; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-digest-quarantine.sh:588 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 587; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-fn-oracle.sh:136 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 135; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-install.sh:438 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 437; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-reconcile.sh:393 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 392; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-store-ack.sh:745 — red-path summary; guard `[ -s "$FAILFILE" ]` at line 744; template execution measured by microtest C11; text verified by static inventory
|
||||||
|
count_lines test-wake-store-enqueue-race.sh:212 — red-path summary (with "#927 TOCTOU reproduced (RED)" tail); guard `[ -s "$FAILFILE" ]` at line 211; template execution measured by microtest C11; text verified by static inventory
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# validate-973.sh — #973 validation driver. One run produces the complete
|
||||||
|
# evidence chain for the 261-site conversion:
|
||||||
|
#
|
||||||
|
# 0. instrument self-test (microtest) — no validate evidence is trusted
|
||||||
|
# before the instrument itself has been proven, including its abort arms.
|
||||||
|
# 1. expected set: 261 coordinates from the FROZEN artifact (+7 header
|
||||||
|
# shift: 3 converter lines + 4 #984 guard lines), count asserted against the number declared below BEFORE any
|
||||||
|
# suite runs.
|
||||||
|
# 2. static inventory: converted call sites re-derived from SOURCE TEXT,
|
||||||
|
# must equal the expected set exactly (amendment ONE, leg 1 — the
|
||||||
|
# inventory comes from the text, never from the ledger).
|
||||||
|
# 3. green instrumented run: all ten suites with WAKE_ASSERT_LEDGER; each
|
||||||
|
# must exit 0 AND emit its own sentinel (per-suite formats differ and are
|
||||||
|
# pinned here — a suite that died early must never pass on another
|
||||||
|
# suite's output).
|
||||||
|
# 4. trace arithmetic on coordinate SETS (loops re-execute sites and the
|
||||||
|
# multi-grep lines append twice per pass, so counts are meaningless;
|
||||||
|
# sets are not):
|
||||||
|
# trace − expected MUST be empty (a helper ran at a coordinate the
|
||||||
|
# denominator never measured);
|
||||||
|
# expected − trace = converted-but-never-executed: enumerated, and
|
||||||
|
# every entry must carry a disposition in the
|
||||||
|
# committed unexecuted-sites-dispositions.txt, with
|
||||||
|
# no stale dispositions the other way (amendment
|
||||||
|
# ONE, legs 2+3 — the ledger is an execution trace,
|
||||||
|
# not an inventory; the difference is enumerated and
|
||||||
|
# individually dispositioned, never silently absent).
|
||||||
|
# 5. forced-error arms: the 19 denominator canaries plus one E-form and one
|
||||||
|
# F-form site, each run with WAKE_ASSERT_FORCE_GREP_ERROR_AT: the suite
|
||||||
|
# must emit the ARMED line (the arm proved it fired), the ABORT line
|
||||||
|
# naming the site, exit non-zero, emit NO sentinel, and the aborting
|
||||||
|
# site's ledger row must already be present (the append lands before the
|
||||||
|
# grep).
|
||||||
|
# 6. residual sweep: the denominator's own classifier finds zero unconverted
|
||||||
|
# verdict greps in the suites — and eight plants (six per-form + two
|
||||||
|
# absorb-branch probes, #985) in the same run.
|
||||||
|
#
|
||||||
|
# Output discipline (A10): every line that reports on a suite names the file
|
||||||
|
# under test; exit codes are reported before failure counts.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WAKE="$(cd "$HERE/.." && pwd)"
|
||||||
|
CHECK="$HERE/check-973.py"
|
||||||
|
DISPO="$HERE/unexecuted-sites-dispositions.txt"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
# Declared BEFORE any suite runs (A2): the run must produce THESE numbers,
|
||||||
|
# not be described by whatever numbers it produced.
|
||||||
|
EXPECTED_SUITES=10
|
||||||
|
EXPECTED_SITES=261
|
||||||
|
EXPECTED_ARMS=21
|
||||||
|
|
||||||
|
fails=0
|
||||||
|
flag() {
|
||||||
|
printf 'FAIL %s\n' "$*"
|
||||||
|
fails=$((fails + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
SUITES=(
|
||||||
|
test-wake-beacon.sh
|
||||||
|
test-wake-detector.sh
|
||||||
|
test-wake-digest-hmac.sh
|
||||||
|
test-wake-digest-quarantine.sh
|
||||||
|
test-wake-fn-oracle.sh
|
||||||
|
test-wake-install.sh
|
||||||
|
test-wake-preimage.sh
|
||||||
|
test-wake-reconcile.sh
|
||||||
|
test-wake-store-ack.sh
|
||||||
|
test-wake-store-enqueue-race.sh
|
||||||
|
)
|
||||||
|
[ "${#SUITES[@]}" -eq "$EXPECTED_SUITES" ] ||
|
||||||
|
flag "suite list has ${#SUITES[@]} entries, declared $EXPECTED_SUITES"
|
||||||
|
|
||||||
|
# Per-suite sentinel patterns, pinned: nine suites share the harness template
|
||||||
|
# (enqueue-race appends a tail after it); preimage uses its own format.
|
||||||
|
sentinel_for() {
|
||||||
|
case "$1" in
|
||||||
|
test-wake-preimage.sh) printf '%s' '^== test-wake-preimage: 17/17 passed ==$' ;;
|
||||||
|
*) printf '%s' 'harness: all invariants passed' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 0: instrument self-test ------------------------------------------------
|
||||||
|
if bash "$HERE/microtest-wake-assert.sh" >"$TMP/microtest.out" 2>&1; then
|
||||||
|
echo "MICROTEST microtest-wake-assert.sh exit=0 (instrument proven)"
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
echo "MICROTEST microtest-wake-assert.sh exit=$rc"
|
||||||
|
sed 's/^/ /' "$TMP/microtest.out" | tail -n 15
|
||||||
|
flag "instrument self-test failed — no validate evidence below is trustworthy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 1+2: expected set (artifact) vs static inventory (source text) ---------
|
||||||
|
python3 "$CHECK" expected | sort >"$TMP/expected.txt" ||
|
||||||
|
flag "check-973.py expected failed"
|
||||||
|
n_expected="$(grep -c . "$TMP/expected.txt")"
|
||||||
|
echo "EXPECTED-SET $n_expected coordinates (declared: $EXPECTED_SITES)"
|
||||||
|
[ "$n_expected" -eq "$EXPECTED_SITES" ] ||
|
||||||
|
flag "expected set has $n_expected coordinates, declared $EXPECTED_SITES"
|
||||||
|
|
||||||
|
python3 "$CHECK" static | sort >"$TMP/static.txt" ||
|
||||||
|
flag "check-973.py static failed"
|
||||||
|
if cmp -s "$TMP/expected.txt" "$TMP/static.txt"; then
|
||||||
|
echo "STATIC-INVENTORY equals expected set ($(grep -c . "$TMP/static.txt") rows from source text)"
|
||||||
|
else
|
||||||
|
flag "static inventory (source text) differs from expected set (artifact):"
|
||||||
|
diff "$TMP/expected.txt" "$TMP/static.txt" | head -n 20 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 3: green instrumented run ----------------------------------------------
|
||||||
|
LEDGER="$TMP/ledger"
|
||||||
|
: >"$LEDGER"
|
||||||
|
for s in "${SUITES[@]}"; do
|
||||||
|
out="$(WAKE_ASSERT_LEDGER="$LEDGER" bash "$WAKE/$s" 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
if printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$s")"; then
|
||||||
|
sent="present"
|
||||||
|
else
|
||||||
|
sent="ABSENT"
|
||||||
|
fi
|
||||||
|
echo "SUITE $s exit=$rc sentinel=$sent"
|
||||||
|
[ "$rc" -eq 0 ] || flag "$s exited $rc in the green instrumented run"
|
||||||
|
[ "$sent" = "present" ] || flag "$s did not emit its sentinel"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- 4: trace arithmetic on coordinate sets ---------------------------------
|
||||||
|
sort -u "$LEDGER" >"$TMP/trace.txt"
|
||||||
|
echo "TRACE $(grep -c . "$TMP/trace.txt") distinct coordinates from $(grep -c . "$LEDGER") ledger rows"
|
||||||
|
|
||||||
|
comm -13 "$TMP/expected.txt" "$TMP/trace.txt" >"$TMP/rogue.txt"
|
||||||
|
if [ -s "$TMP/rogue.txt" ]; then
|
||||||
|
flag "trace contains coordinates OUTSIDE the frozen denominator:"
|
||||||
|
sed 's/^/ ROGUE /' "$TMP/rogue.txt"
|
||||||
|
else
|
||||||
|
echo "TRACE-MINUS-EXPECTED empty (no helper ran at an unmeasured coordinate)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
comm -23 "$TMP/expected.txt" "$TMP/trace.txt" >"$TMP/unexec.txt"
|
||||||
|
n_unexec="$(grep -c . "$TMP/unexec.txt" || true)"
|
||||||
|
echo "UNEXECUTED $n_unexec of $EXPECTED_SITES converted sites did not execute in the green run"
|
||||||
|
if [ ! -f "$DISPO" ]; then
|
||||||
|
flag "disposition file missing: $DISPO — every unexecuted site must be individually dispositioned"
|
||||||
|
sed 's/^/ UNDISPOSITIONED /' "$TMP/unexec.txt"
|
||||||
|
else
|
||||||
|
awk '!/^#/ && NF >= 2 {print $1, $2}' "$DISPO" | sort -u >"$TMP/dispo-keys.txt"
|
||||||
|
comm -23 "$TMP/unexec.txt" "$TMP/dispo-keys.txt" >"$TMP/undispo.txt"
|
||||||
|
comm -13 "$TMP/unexec.txt" "$TMP/dispo-keys.txt" >"$TMP/stale-dispo.txt"
|
||||||
|
if [ -s "$TMP/undispo.txt" ]; then
|
||||||
|
flag "unexecuted sites WITHOUT a disposition:"
|
||||||
|
sed 's/^/ UNDISPOSITIONED /' "$TMP/undispo.txt"
|
||||||
|
fi
|
||||||
|
if [ -s "$TMP/stale-dispo.txt" ]; then
|
||||||
|
flag "dispositions for sites that DID execute (stale — the file no longer matches the run):"
|
||||||
|
sed 's/^/ STALE-DISPO /' "$TMP/stale-dispo.txt"
|
||||||
|
fi
|
||||||
|
if [ ! -s "$TMP/undispo.txt" ] && [ ! -s "$TMP/stale-dispo.txt" ]; then
|
||||||
|
echo "DISPOSITIONS all $n_unexec unexecuted sites individually dispositioned, none stale"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5: forced-error arms ---------------------------------------------------
|
||||||
|
python3 "$CHECK" arms >"$TMP/arms.txt" || flag "check-973.py arms failed"
|
||||||
|
n_arms="$(grep -c . "$TMP/arms.txt")"
|
||||||
|
echo "ARMS $n_arms forced-error arms (declared: $EXPECTED_ARMS)"
|
||||||
|
[ "$n_arms" -eq "$EXPECTED_ARMS" ] ||
|
||||||
|
flag "arm list has $n_arms entries, declared $EXPECTED_ARMS"
|
||||||
|
|
||||||
|
while read -r helper site form; do
|
||||||
|
f="${site%%:*}"
|
||||||
|
aled="$TMP/ledger-arm"
|
||||||
|
: >"$aled"
|
||||||
|
out="$(WAKE_ASSERT_LEDGER="$aled" WAKE_ASSERT_FORCE_GREP_ERROR_AT="$site" \
|
||||||
|
bash "$WAKE/$f" 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
bad=""
|
||||||
|
[ "$rc" -ne 0 ] || bad="$bad exit=0"
|
||||||
|
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ARMED: forcing real grep error at $site" ||
|
||||||
|
bad="$bad no-ARMED-line"
|
||||||
|
printf '%s\n' "$out" | grep -q "WAKE-ASSERT ABORT: ${helper} at ${site}: grep exit" ||
|
||||||
|
bad="$bad no-ABORT-line"
|
||||||
|
# AND-polarity check (a match is the defect): a grep error (rc>=2) must be
|
||||||
|
# its own loud arm — it cannot fall through as "no sentinel = pass".
|
||||||
|
rc_sent=0
|
||||||
|
printf '%s\n' "$out" | grep -Eq "$(sentinel_for "$f")" || rc_sent=$?
|
||||||
|
case "$rc_sent" in
|
||||||
|
0) bad="$bad sentinel-emitted" ;;
|
||||||
|
1) : ;;
|
||||||
|
*) bad="$bad sentinel-grep-error-rc=$rc_sent" ;;
|
||||||
|
esac
|
||||||
|
grep -q "^${helper} ${site}\$" "$aled" ||
|
||||||
|
bad="$bad no-ledger-row"
|
||||||
|
if [ -z "$bad" ]; then
|
||||||
|
echo "ARM $site ($form) exit=$rc armed+abort+no-sentinel+ledger-row"
|
||||||
|
else
|
||||||
|
echo "ARM $site ($form) exit=$rc DEFECTS:$bad"
|
||||||
|
flag "arm $site ($form) failed:$bad"
|
||||||
|
fi
|
||||||
|
done <"$TMP/arms.txt"
|
||||||
|
|
||||||
|
# --- 6: residual sweep ------------------------------------------------------
|
||||||
|
if python3 "$CHECK" sweep >"$TMP/sweep.out" 2>&1; then
|
||||||
|
echo "SWEEP exit=0"
|
||||||
|
else
|
||||||
|
echo "SWEEP exit=$?"
|
||||||
|
flag "residual sweep failed"
|
||||||
|
fi
|
||||||
|
sed 's/^/ /' "$TMP/sweep.out"
|
||||||
|
|
||||||
|
# --- summary (exit codes above, failure count last — A10) --------------------
|
||||||
|
echo
|
||||||
|
if [ "$fails" -gt 0 ]; then
|
||||||
|
echo "validate-973: FAILED ($fails failure(s))" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "validate-973: OK — $EXPECTED_SUITES suites, $EXPECTED_SITES sites, $EXPECTED_ARMS arms, sweep clean"
|
||||||
@@ -26,12 +26,13 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Script | Purpose |
|
| Script | Purpose |
|
||||||
| --------------------- | -------------------------------------------- |
|
| -------------------------- | -------------------------------------------------------------- |
|
||||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||||
|
| `verify-terminal-green.py` | Verify every JSON/API child step under the bounded CI contract |
|
||||||
|
|
||||||
## Common Options
|
## Common Options
|
||||||
|
|
||||||
@@ -59,4 +60,9 @@ A Woodpecker API token is required. To configure:
|
|||||||
|
|
||||||
# Block until one or more pipelines finish (event-driven CI wait)
|
# Block until one or more pipelines finish (event-driven CI wait)
|
||||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
||||||
|
|
||||||
|
# Verify the full JSON child-step record; do not use the text summary for this gate
|
||||||
|
PR_HEAD=<full-40-hex-provider-head>
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r mosaicstack/stack -n 2188 -f json \
|
||||||
|
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py --expect-commit "$PR_HEAD" -
|
||||||
```
|
```
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Red-first contract harness for RM-61 / #1000.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
VERIFIER="$SCRIPT_DIR/verify-terminal-green.py"
|
||||||
|
EXPECTED_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
write_fixture() {
|
||||||
|
local file="$1" pipeline_status="$2" postgres_state="$3" postgres_exit="$4" postgres_error="$5" test_state="$6"
|
||||||
|
python3 - "$file" "$pipeline_status" "$postgres_state" "$postgres_exit" "$postgres_error" "$test_state" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
path, pipeline_status, pg_state, pg_exit, pg_error, test_state = sys.argv[1:]
|
||||||
|
steps = [
|
||||||
|
{"name": "clone", "type": "clone", "state": "success", "exit_code": 0, "error": None},
|
||||||
|
{"name": "ci-postgres", "type": "service", "state": pg_state, "exit_code": int(pg_exit), "error": pg_error or None},
|
||||||
|
{"name": "test", "type": "commands", "state": test_state, "exit_code": 0 if test_state == "success" else 1, "error": None},
|
||||||
|
]
|
||||||
|
json.dump({
|
||||||
|
"number": 9999,
|
||||||
|
"status": pipeline_status,
|
||||||
|
"commit": "a" * 40,
|
||||||
|
"workflows": [{"name": "ci", "state": pipeline_status, "children": steps}],
|
||||||
|
}, open(path, "w"))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_exit() {
|
||||||
|
local expected_exit="$1" label="$2" file="$3" expected_commit="${4:-$EXPECTED_COMMIT}"
|
||||||
|
set +e
|
||||||
|
output=$(python3 "$VERIFIER" --expect-commit "$expected_commit" "$file" 2>&1)
|
||||||
|
actual=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$actual" -ne "$expected_exit" ]]; then
|
||||||
|
printf 'FAIL %s: expected exit %s, got %s\n%s\n' "$label" "$expected_exit" "$actual" "$output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'PASS %s\n' "$label"
|
||||||
|
printf '%s' "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ordinary terminal green.
|
||||||
|
write_fixture "$TMP/green.json" success success 0 '' success
|
||||||
|
out=$(expect_exit 0 green "$TMP/green.json")
|
||||||
|
grep -q '"total_steps": 3' <<<"$out"
|
||||||
|
grep -q '"exempted_steps": 0' <<<"$out"
|
||||||
|
|
||||||
|
# Exact, named #1000 teardown artifact: the only permitted non-success child.
|
||||||
|
artifact='pods "wp-svc-01kyxzjhdf6w81swsnbfzh85z9-ci-postgres" not found'
|
||||||
|
write_fixture "$TMP/artifact.json" success failure 0 "$artifact" success
|
||||||
|
out=$(expect_exit 0 exact-artifact "$TMP/artifact.json")
|
||||||
|
grep -q '"exemption_id": "WP-K8S-1000-CI-POSTGRES-TEARDOWN"' <<<"$out"
|
||||||
|
grep -q '"exempted_steps": 1' <<<"$out"
|
||||||
|
|
||||||
|
# Negative controls: both real PostgreSQL failures must remain red.
|
||||||
|
write_fixture "$TMP/startup.json" failure failure 1 '' failure
|
||||||
|
expect_exit 1 startup-failure "$TMP/startup.json" >/dev/null
|
||||||
|
write_fixture "$TMP/crash.json" failure failure 137 '' failure
|
||||||
|
expect_exit 1 post-readiness-crash "$TMP/crash.json" >/dev/null
|
||||||
|
|
||||||
|
# The exemption is signature-scoped, not step-scoped.
|
||||||
|
write_fixture "$TMP/wrong-error.json" success failure 0 'connection refused' success
|
||||||
|
expect_exit 1 other-postgres-error "$TMP/wrong-error.json" >/dev/null
|
||||||
|
write_fixture "$TMP/wrong-pod.json" success failure 0 'pods "other-ci-postgres" not found' success
|
||||||
|
expect_exit 1 wrong-pod-signature "$TMP/wrong-pod.json" >/dev/null
|
||||||
|
write_fixture "$TMP/nonzero-artifact.json" success failure 137 "$artifact" success
|
||||||
|
expect_exit 1 nonzero-with-artifact-text "$TMP/nonzero-artifact.json" >/dev/null
|
||||||
|
|
||||||
|
# JSON booleans and non-integer zero look equal to 0 in Python but are not exit codes.
|
||||||
|
python3 - "$TMP/artifact.json" "$TMP" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
record = json.load(open(sys.argv[1]))
|
||||||
|
for label, value in (("false", False), ("true", True), ("float", 0.0), ("string", "0"), ("null", None)):
|
||||||
|
changed = json.loads(json.dumps(record))
|
||||||
|
changed["workflows"][0]["children"][1]["exit_code"] = value
|
||||||
|
json.dump(changed, open(os.path.join(sys.argv[2], f"exit-{label}.json"), "w"))
|
||||||
|
PY
|
||||||
|
for label in false true float string null; do
|
||||||
|
expect_exit 1 "non-integer-exit-$label" "$TMP/exit-$label.json" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
# Exact artifact cannot mask any independent failure or non-success pipeline.
|
||||||
|
write_fixture "$TMP/artifact-plus-failure.json" failure failure 0 "$artifact" failure
|
||||||
|
expect_exit 1 artifact-plus-real-failure "$TMP/artifact-plus-failure.json" >/dev/null
|
||||||
|
write_fixture "$TMP/skipped.json" success success 0 '' skipped
|
||||||
|
expect_exit 1 skipped-step "$TMP/skipped.json" >/dev/null
|
||||||
|
|
||||||
|
# The scanned pipeline must be bound to an explicit, full PR-head commit.
|
||||||
|
set +e
|
||||||
|
missing_output=$(python3 "$VERIFIER" "$TMP/artifact.json" 2>&1)
|
||||||
|
missing_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$missing_rc" -ne 2 ]] || ! grep -q -- '--expect-commit' <<<"$missing_output"; then
|
||||||
|
printf 'FAIL missing-expected-commit: expected usage exit 2\n%s\n' "$missing_output" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
expect_exit 1 mismatched-expected-commit "$TMP/artifact.json" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb >/dev/null
|
||||||
|
|
||||||
|
python3 - "$TMP/artifact.json" "$TMP/missing-record-commit.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
record = json.load(open(sys.argv[1]))
|
||||||
|
record.pop("commit")
|
||||||
|
json.dump(record, open(sys.argv[2], "w"))
|
||||||
|
PY
|
||||||
|
expect_exit 1 missing-record-commit "$TMP/missing-record-commit.json" >/dev/null
|
||||||
|
|
||||||
|
printf 'terminal-green contract harness: PASS (17 cases)\n'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user