Files
session-history/0006/SESSION-0006.0-TRANSCRIPT-2026-05-27T19-00--2026-05-27T19-21.md
T
Ben Stull ad01c0854a #30: per-session folder layout + README + sessions.json
Restructures the flat repo into one folder per session, with a top-level
README.md (the "about sessions" page, also rendered at /docs/sessions/about
by rfc-app v0.19.0+) and a sessions.json title manifest keyed by 4-digit
NNNN. Filenames inside each folder retain the full SESSION-NNNN.M-TRANSCRIPT-…
form (no trim of the SESSION- prefix). Legacy letter-form transcripts, if
any are still at the root, are not moved — the folder convention applies
to the numeric form only.

Session 0017.0; see SESSION-PROTOCOL.md §1 amendment for the binding shape.
2026-05-28 09:12:08 -07:00

739 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Session F — Transcript
> Date: 2026-05-27
> Goal: Execute Slice 3 of the `ohm-rfc-app-flotilla` SPEC §20 slicing
> plan — pin reading + `deploy --dry-run`. Per §20.3: add CLI verbs
> `pin show`, `pin check`, `deploy <deployment> --dry-run`; ship a
> Gitea raw-file client; assemble the deploy plan (resolve pin,
> validate secret references resolve, compute overlay snapshot hash,
> render the 9-phase §8.1 sequence) without touching the target VM.
> Exit criterion: dry-run prints the assembled plan with the resolved
> pin and confirmed secret-reference resolution but no secret bytes.
>
> Outcome: **Slice 3 shipped at v0.3.0** — committed (`491e72e`) and
> tagged locally. Single commit also folds in Slice 2's code, which
> was deliberately uncommitted at the end of Session E pending the
> operator-side Secret Manager bootstrap gesture; the v0.2.0 tag was
> never cut, so this release bumps straight from v0.1.0 to v0.3.0
> (the CHANGELOG keeps both entries for record-keeping). New surfaces:
> migration `003_pin_source_host.sql` adds the Gitea base URL as a
> per-deployment column; `ohm_rfc_app_flotilla/pin.py` reads the
> raw-file endpoint with certifi-backed trust roots and an explicit
> User-Agent; `ohm_rfc_app_flotilla/plan.py` assembles the dry-run
> `Plan`. CLI gained `pin show`, `pin check`, `deploy <name> --dry-run`
> (with `--json`). Tests went from 35 → 74 (added `test_pin.py` and
> `test_plan.py`).
>
> Two live-network findings worth flagging permanently in the SPEC
> (§11.4 updated per §19.3 rule 2):
> 1. **Framework Python on macOS ships without TLS trust roots** —
> `ssl.get_default_verify_paths()` returns a `cafile` path that
> doesn't exist. The pin reader builds its SSLContext from
> `certifi.where()`; `certifi` is a transitive dep of
> `google-cloud-secret-manager` so it's always available.
> 2. **Cloudflare-fronted Gitea 403s `Python-urllib/<ver>`** — the
> default User-Agent is blocked at the edge. Reader sets
> `User-Agent: ohm-rfc-app-flotilla (Gitea pin reader)`.
>
> Also discovered (flagged in CHANGELOG, not corrected): SPEC §13.4
> records the corpus repo as `ben/ohm-rfc`, but the canonical Gitea
> path is `ben.stull/ohm-rfc`. Gitea returns a stable 307 redirect
> and urllib follows it; the alias form is what the operator
> remembers, so §13.4 stays.
>
> One spec-additive decision: added `pin_source_host` column to
> `deployments` (migration 003, default `https://git.wiggleverse.org`).
> The spec was silent on the Gitea base URL; hardcoding it would have
> made a future deployment on a different Gitea instance a code change.
> Spec corrections folded into §4.3, §5.4, §11.4, §13.3 to reflect
> the new column and its OHM value.
---
## Turn 1 — User
> ok, let's do slice 3
## Turn 1 — Assistant
Read project memory and the spec to recover context.
**Memory loaded**: `MEMORY.md` plus the four project memories pointing
at this repo as OHM's operator CLI, `SPEC.md` as the binding contract,
and the §20 slicing plan as the menu of build sessions. The slug
"slice 3" was unambiguous against §20.3 — "Pin reading + dry-run
deploy, ships as v0.3.0."
**SPEC §20.3 read in full**:
> **Scope.**
> - Schema: pin-source fields on the `deployments` row (already in
> Slice 1's table; this slice exercises them).
> - CLI verbs: `pin show`, `pin check`, `deploy <deployment> --dry-run`.
> - Gitea raw-file client for reading `.rfc-app-version`.
> - Plan-assembly logic: resolve pin, validate SemVer, fetch all
> secret references (validation-only, no bytes held longer than
> needed), compute overlay snapshot hash, render the plan as the
> 9-phase list from §8.1 with the resolved values.
> - Tests: pin parsing (valid + invalid), plan assembly against a
> stubbed Gitea + Secret Manager.
>
> **Exit criteria.** `ohm-rfc-app-flotilla deploy ohm-rfc-app --dry-run`
> prints the assembled 9-phase plan with the resolved pin (`0.3.0` or
> whatever is in `ohm-rfc` at the time) and the secret-reference
> resolution confirmed but secret bytes not displayed. Ships as v0.3.0.
Cross-referenced §8.1 (the 9-phase sequence the dry-run should render),
§11.4 (Gitea raw-file public endpoint shape), §14.3 (rfc-app `>= 0.2.3`
floor — pre-0.2.3 lacks `/api/health`, so flotilla refuses to plan
against an older pin), and §3 invariant 1 (no secret bytes anywhere
in flotilla state or output).
**Repo state at session start**:
```
$ git log --oneline -2
581687a Release 0.1.0: Slice 1 — registry + non-secret overlay
a9a9aaa Release 0.0.1: initial spec
$ git status
M CHANGELOG.md, README.md, SPEC.md, pyproject.toml,
ohm_rfc_app_flotilla/cli.py, ohm_rfc_app_flotilla/registry.py,
tests/test_db.py, tests/test_overlay.py
?? docs/, ohm_rfc_app_flotilla/migrations/002_secret_refs.sql,
ohm_rfc_app_flotilla/secrets.py, tests/test_secrets.py
```
— matching Session E's tail state: Slice 2 code present and tested
green on disk, never committed; v0.1.0 the only release tag.
**Codebase read**: `cli.py` (270 lines, Click groups for `deployment`,
`overlay`, `secret`), `registry.py` (~240 lines, dataclasses +
SQLite CRUD), `secrets.py` (read-only Secret Manager wrapper with
`parse_ref` + `read_secret`), `db.py` (WAL SQLite + migration
runner), migrations 001 + 002, conftest fixtures (`tmp_data_dir`,
`conn`, `runner` wrapping `CliRunner` with `expect_exit`),
existing tests (35 passing).
**Task list created** (TaskCreate × 6):
1. Add `pin_source_host` column via migration 003
2. Implement pin module (Gitea raw-file + SemVer)
3. Implement plan module (dry-run assembly)
4. Wire CLI: `pin show`, `pin check`, `deploy --dry-run`
5. Tests: pin parsing + plan assembly with stubs
6. Update register-ohm.sh, README, CHANGELOG, VERSION, pyproject
**One design call surfaced before writing code**: how does flotilla
know which Gitea host to read the pin from? §5.4 names
`pin_source_kind/repo/path` but not a host; §13.3 names the repo
(`ben/ohm-rfc`) and §11.4 references `git.wiggleverse.org` in prose,
but the schema has no column for it. Three options came to mind:
1. Add a `pin_source_host` column (new migration 003).
2. Hardcode `https://git.wiggleverse.org` in `pin.py`.
3. Derive from the overlay's `GITEA_URL` value.
Asked via `AskUserQuestion` (rendered with side-by-side previews
showing the SQL/Python for each option), recommending option 1 — it
keeps the schema structurally generic across deployments on different
Gitea instances, the default value handles OHM transparently, the new
CLI flag is optional, and the small SPEC correction is honest about
what the slice discovered.
## Turn 2 — User
> [AskUserQuestion answer] **Add pin_source_host column (Recommended)**:
> ALTER TABLE deployments ADD COLUMN pin_source_host TEXT NOT NULL
> DEFAULT 'https://git.wiggleverse.org'; new optional `--pin-source-host`
> on `deployment add`.
## Turn 2 — Assistant
Implemented the slice end-to-end, then exercised live and uncovered
two environmental issues that became permanent SPEC corrections.
### Migration 003
Wrote `ohm_rfc_app_flotilla/migrations/003_pin_source_host.sql`:
```sql
-- Slice 3: pin reading. Per SPEC §5.4 and §11.4.
-- The Gitea host the deployment's pin file is read from. Default matches the
-- OHM deployment so existing rows backfill correctly; new deployments on a
-- different Gitea instance pass --pin-source-host on `deployment add`.
ALTER TABLE deployments
ADD COLUMN pin_source_host TEXT NOT NULL DEFAULT 'https://git.wiggleverse.org';
```
Updated `registry.py`:
- `Deployment` dataclass gains `pin_source_host: str`
- `_row_to_deployment` reads the column
- `add_deployment(...)` gains `pin_source_host: str = "https://git.wiggleverse.org"`
kwarg, INSERT statement extended
Verified the 35 existing tests still pass against the new schema —
SQLite's `DEFAULT` clause backfills rows inserted without the column,
and reads pick up the default for existing-fixture data.
### `ohm_rfc_app_flotilla/pin.py`
Three pieces: `parse_semver`, `build_raw_url`, `read_gitea_pin`.
```python
MIN_RFC_APP_VERSION = (0, 2, 3)
_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
class PinError(Exception):
"""The pin could not be read, did not parse as SemVer, or is below the
`/api/health`-supporting floor."""
def parse_semver(raw: str) -> str:
s = raw.strip()
if not s:
raise PinError("pin file is empty")
m = _SEMVER_RE.match(s)
if not m:
raise PinError(f"pin {s!r} is not a SemVer …")
parts = tuple(int(g) for g in m.groups())
if parts < MIN_RFC_APP_VERSION:
raise PinError(f"pin {s} is below the supported floor 0.2.3 …")
return s
```
The floor check enforces §14.3 — pre-0.2.3 rfc-app lacks `/api/health`,
so flotilla cannot verify a deploy of an older pin. Rejecting at
parse time means the failure surfaces in Phase 1 of the gesture, not
Phase 8 after pip install + npm build.
`build_raw_url` constructs `<host>/api/v1/repos/<owner>/<repo>/raw/<path>`,
URL-encoding the path, stripping a trailing slash from the host, and
rejecting a repo without a `/`. `read_gitea_pin` does an unauth
`urllib.request.urlopen` and parses the body as SemVer.
### `ohm_rfc_app_flotilla/plan.py`
The dry-run engine. Public surface: `PlanError`, `Plan`,
`SecretRefSummary`, `Phase`, `assemble_plan`, `render_plan_text`.
```python
def assemble_plan(conn, deployment_name, *, read_pin=None, read_secret=None) -> Plan:
dep = registry.get_deployment(conn, deployment_name)
overlay = registry.list_overlay(conn, deployment_name)
refs = registry.list_secret_refs(conn, deployment_name)
try:
target_version = read_pin(dep.pin_source_host, dep.pin_source_repo,
dep.pin_source_path)
except pinmod.PinError as e:
raise PlanError(f"pin: {e}") from e
# Validate every secret ref resolves. Returned bytes leave scope
# immediately — never persisted, never logged (§3 invariant 1).
for r in refs:
try:
_ = read_secret(r.secret_project, r.secret_id, r.secret_version)
except secrets.SecretReadError as e:
raise PlanError(f"secret: {r.env_key} → …: {e}") from e
# Canonical JSON for the hash. Sort keys; tight separators.
# Bytes never enter this structure — only ref strings.
snapshot = {
"overlay": {e.env_key: e.env_value for e in overlay},
"secret_refs": {
r.env_key: secrets.format_ref(r.secret_project, r.secret_id, r.secret_version)
for r in refs
},
}
canonical = json.dumps(snapshot, sort_keys=True, separators=(",", ":"))
overlay_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
```
The injectable `read_pin` / `read_secret` are the seam tests use to
stub out the network. Defaults call the real functions.
`_build_phases` renders the 9 phases from §8.1 with deployment values
filled in:
```
1. validate resolve pin → 0.3.0; validate N secret ref(s); compute hash
2. preflight ssh ohm-app: test -d /opt/ohm-app; record is-active
3. fetch+checkout sudo -u ohm-app git -C /opt/ohm-app fetch --tags && git checkout v0.3.0
4. backend deps sudo -u ohm-app /opt/ohm-app/backend/.venv/bin/pip install -r …
5. frontend build cd /opt/ohm-app/frontend && sudo -u ohm-app npm ci && npm run build
6. write .env atomic write … (mode 0600, owner ohm-app) from 13 overlay + 0 secrets
7. restart sudo systemctl restart ohm-app.service; poll is-active up to 30s
8. verify /api/health poll https://ohm.wiggleverse.org/api/health until body.version==0.3.0 …
9. finalize record deploys row outcome=succeeded; print success line
```
`render_plan_text` produces the human-readable form. The secret-refs
section explicitly says "resolved successfully, bytes not displayed",
and the trailing line says "DRY RUN — no SSH performed, no .env
written, no restart issued." `to_dict()` produces the JSON shape;
both renderings exclude byte values categorically (the `Plan`
dataclass has no field that could carry them).
### CLI wiring
Added `pin` group with `show` and `check`:
```python
@pin.command("show")
@click.argument("deployment")
@click.option("--json", "as_json", is_flag=True)
def pin_show(deployment, as_json):
dep = registry.get_deployment(conn, deployment) # NotFoundError → exit 1
version = pinmod.read_gitea_pin(dep.pin_source_host, ) # PinError → exit 1
click.echo(version) # or JSON payload with pin_source struct
```
`pin check` resolves the pin and renders `OK: <name> pin resolves to
<version>` plus a forward-reference line noting that last-deploy
comparison lands in Slice 4 (the `deploys` table doesn't exist until
§20.4). Distinct from `pin show` in intent — show is "what's the
version", check is "is the pin source healthy" — and exit code on
failure carries the operational signal.
Added `deploy` as a `@main.command` (not a group), with `--dry-run`
and `--json` flags. Without `--dry-run`, deploy exits 1 with "real
deploy gesture lands in Slice 4 (SPEC §20.4) — use --dry-run for
now." Noted in code-comment that the Slice 4 session will need to
decide how to layer `deploy abort/status/watch/log` on top of the
existing `deploy <name>` shape — Click groups don't natively support
"both a default-arg command and named subcommands"; the slicing
session can pick a pattern.
Updated `deployment_add` to accept optional `--pin-source-host`
defaulting to `https://git.wiggleverse.org`, and `deployment_show`
to render `pin_source_host` in its field list.
### Tests
Wrote `tests/test_pin.py` (15 tests):
- `parse_semver` accepts `0.2.3`, `0.3.0`, `1.2.3`, `10.20.30`
- whitespace-stripping (`" 0.3.0\n"``"0.3.0"`)
- rejects `""`, `" "`, `"v0.3.0"`, `"0.3"`, `"0.3.0-rc1"`,
`"0.3.0+build"`, `"abc"`, `"0.3.0.0"`
- rejects `0.0.1`, `0.1.0`, `0.2.2` with the floor message
- accepts exact floor `0.2.3`
- `build_raw_url` basic shape, trailing-slash stripping, percent-encoding
for a path with spaces, rejection of bare repo (no `/`)
Wrote `tests/test_plan.py` (16 tests). Notable patterns:
- `STUB_SECRET_BYTES = b"stub-secret-bytes-MUST-NEVER-APPEAR-IN-OUTPUT"`
— used by the stub secret reader so any test rendering the plan can
grep its own output for these bytes and fail loudly if §3 invariant
1 leaks.
- `_stub_pin(version="0.3.0")` returns a callable matching the
`read_pin(host, repo, path)` signature.
- `_stub_secret_ok` / `_stub_secret_boom` for the two paths through
`assemble_plan`'s secret-validation loop.
Tests covered:
- basic assembly (pin resolves, 9 phases, deployment fields surface in
the relevant phase strings)
- pin error → `PlanError` with `"pin:"` prefix
- secret error → `PlanError` with `"secret: <env_key>"` prefix
- secret refs appear in plan output, bytes do not (asserted both in
`render_plan_text` output and in `json.dumps(plan.to_dict())`)
- hash determinism (assemble twice → same hash)
- hash sensitivity to overlay-value change
- hash sensitivity to secret-version change
- unknown deployment → `NotFoundError`
- `pin show` happy path (line) and `--json` (parsed payload)
- `pin show` for unknown deployment → exit 1 with "no deployment"
- `pin show` propagates `PinError` to stderr
- `pin check` happy path + Slice-4 forward-reference message
- `pin check` failure path with the floor message
- `deploy ohm-rfc-app` without `--dry-run` → exit 1 with "Slice 4"
- `deploy ohm-rfc-app --dry-run` happy path (overlay key listed, secret
ref listed, `sha256:` line present, all 9 phases including `9. finalize`,
no stub bytes anywhere)
- `deploy --dry-run --json` happy path (parsed payload, 9 phases,
no `secret_value` / `secret_bytes` / stub bytes)
- `deploy --dry-run` propagates `PlanError`
```
$ .venv/bin/pytest -q
........................................................................ [ 97%]
.. [100%]
74 passed in 0.11s
```
Stub-based tests are fast (74 in 0.11s) and the assertion discipline on
secret bytes carries over from Slice 2's test patterns.
### Live exercise — exit criterion
Wanted to confirm the exit criterion against real OHM, not just stubs.
Set up a sandbox:
```
$ cd /tmp/flotilla-smoke
$ OHM_RFC_APP_FLOTILLA_DATA_DIR=$PWD bash scripts/register-ohm.sh
OHM registered.
$ OHM_RFC_APP_FLOTILLA_DATA_DIR=$PWD ohm-rfc-app-flotilla pin show ohm-rfc-app
error: could not reach https://git.wiggleverse.org/api/v1/repos/ben/ohm-rfc/raw/.rfc-app-version:
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed:
unable to get local issuer certificate (_ssl.c:1028)
```
**Surprise #1: macOS framework Python ships without TLS trust roots.**
Confirmed via:
```
$ python -c "import ssl; print(ssl.get_default_verify_paths())"
DefaultVerifyPaths(
cafile=None, capath=None,
openssl_cafile_env='SSL_CERT_FILE',
openssl_cafile='/Library/Frameworks/Python.framework/Versions/3.13/etc/openssl/cert.pem',
openssl_capath_env='SSL_CERT_DIR',
openssl_capath='/Library/Frameworks/Python.framework/Versions/3.13/etc/openssl/certs')
```
The `cafile` and `capath` paths point at locations that don't exist on
the python.org installer. Classic well-known issue.
Confirmed `certifi.where()` was available in the venv (it's a transitive
dep of `google-cloud-secret-manager`):
```
$ python -c "import certifi; print(certifi.where())"
/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/lib/python3.13/site-packages/certifi/cacert.pem
```
Patched `pin.py` to build the SSLContext explicitly:
```python
def _ssl_context() -> ssl.SSLContext:
# Framework Python on macOS ships without trust roots; certifi is a
# transitive dep of google-cloud-secret-manager so it's always present.
# Falls back to system roots when certifi is genuinely absent.
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
```
Re-ran:
```
$ ohm-rfc-app-flotilla pin show ohm-rfc-app
error: HTTP 403 reading pin from https://git.wiggleverse.org/api/v1/repos/ben/ohm-rfc/raw/.rfc-app-version
```
**Surprise #2: Gitea is Cloudflare-fronted, Cloudflare 403s
Python-urllib.**
Confirmed with curl that the URL was reachable as a redirect, and
that the canonical path was `ben.stull/ohm-rfc`:
```
$ curl -sS -o /tmp/pin.txt -w "%{http_code}\n" \
https://git.wiggleverse.org/api/v1/repos/ben/ohm-rfc/raw/.rfc-app-version
307
$ cat /tmp/pin.txt
<a href="/api/v1/repos/ben.stull/ohm-rfc/raw/.rfc-app-version">Temporary Redirect</a>.
$ curl -L -sS -w "\nHTTP %{http_code}\n" https://git.wiggleverse.org/…/raw/.rfc-app-version
0.3.0
HTTP 200
```
But the Python attempt with the urllib trace returned 403 immediately
— no 307 in the chain. Confirmed via a one-liner that adding an
explicit User-Agent fixed it:
```python
req = urllib.request.Request(url, headers={"User-Agent": "ohm-rfc-app-flotilla/0.3.0"})
# OK 200 https://git.wiggleverse.org/api/v1/repos/ben.stull/ohm-rfc/raw/.rfc-app-version
# '0.3.0\n'
```
Cloudflare blocks `Python-urllib/<version>` UAs at the edge — it
never even reaches Gitea, so there's no 307. With an explicit UA,
the request is permitted, Gitea returns its 307, urllib follows it,
and the canonical URL returns `0.3.0`.
Patched `pin.py`:
```python
# Cloudflare-fronted Gitea instances return 403 to the default
# `Python-urllib/<ver>` UA. Set an explicit identifier — also useful for
# Gitea-side request logs to attribute reads to the operator tool.
_USER_AGENT = "ohm-rfc-app-flotilla (Gitea pin reader)"
def read_gitea_pin(host, repo, path, *, timeout=10.0) -> str:
url = build_raw_url(host, repo, path)
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
body = resp.read().decode("utf-8")
return parse_semver(body)
```
Both fixes are tiny and load-bearing for the tool actually working on
a fresh macOS Python install against the real Gitea. They earned a
permanent SPEC note in §11.4.
### Re-running the exit criterion
```
$ ohm-rfc-app-flotilla pin show ohm-rfc-app
0.3.0
$ ohm-rfc-app-flotilla pin check ohm-rfc-app
OK: ohm-rfc-app pin resolves to 0.3.0
(no deploy history yet — last-deploy comparison lands in Slice 4)
$ ohm-rfc-app-flotilla deploy ohm-rfc-app --dry-run
# dry-run plan for ohm-rfc-app
target version 0.3.0
pin source gitea-file https://git.wiggleverse.org ben/ohm-rfc:.rfc-app-version
health url https://ohm.wiggleverse.org/api/health
overlay snapshot sha256:15783e783fb6f8f5e339042132b0603577e3ef7d1c3f478124b9f27a4742574d
target vm
name ohm-app
zone us-central1-a
project wiggleverse-ohm
service_user ohm-app
install_dir /opt/ohm-app
systemd_unit ohm-app.service
tunnel_through_iap False
overlay keys (13)
APP_URL
DATABASE_PATH
EMAIL_FROM
EMAIL_FROM_NAME
GITEA_BOT_USER
GITEA_ORG
GITEA_URL
META_REPO
OWNER_GITEA_LOGIN
SMTP_HOST
SMTP_PORT
SMTP_STARTTLS
SMTP_USER
secret refs (none)
phases (would execute on real deploy)
1. validate
resolve pin → 0.3.0; validate 0 secret ref(s); compute overlay snapshot hash
2. preflight
ssh ohm-app: test -d /opt/ohm-app; record `systemctl is-active ohm-app.service`
3. fetch+checkout
sudo -u ohm-app git -C /opt/ohm-app fetch --tags && git checkout v0.3.0
4. backend deps
sudo -u ohm-app /opt/ohm-app/backend/.venv/bin/pip install -r /opt/ohm-app/backend/requirements.txt
5. frontend build
cd /opt/ohm-app/frontend && sudo -u ohm-app npm ci && npm run build
6. write .env
atomic write /opt/ohm-app/backend/.env (mode 0600, owner ohm-app) from 13 overlay entries + 0 secret(s)
7. restart
sudo systemctl restart ohm-app.service; poll `is-active` up to 30s
8. verify /api/health
poll https://ohm.wiggleverse.org/api/health until body.version==0.3.0 and body.status=='ok' (5min budget)
9. finalize
record `deploys` row outcome=succeeded; print success line
DRY RUN — no SSH performed, no .env written, no restart issued.
```
This is the §20.3 exit criterion satisfied against live OHM. (Secret
validation is no-op here because no secrets were bound in this
sandbox — the unit test `test_deploy_dry_run_renders_plan` exercises
the secret-bound path against a stubbed Secret Manager.)
### Release artifacts
- `VERSION``0.3.0`
- `pyproject.toml``version = "0.3.0"`
- `CHANGELOG.md` → new `## 0.3.0 — 2026-05-27` section (above the
surviving `## 0.2.0 — pending operator gesture` block). Detailed:
migration 003, the pin and plan modules, the certifi + UA notes,
CLI surface, test count (74), the §13.4 alias note, the four spec
corrections, the sequencing note that v0.2.0's operator-gesture
prerequisite is independent.
- `README.md` → status section bumped from "v0.1.0 ships Slice 1" to
"v0.3.0 ships Slice 3 …"
- `SPEC.md` corrections (per §19.3 rule 2):
- §4.3 `deployments` columns now include `pin_source_host`
- §5.4 names `pin_source_host` with the OHM default value
- §11.4 adds the certifi + Cloudflare-UA implementation notes
- §13.3 records `pin_source_host: https://git.wiggleverse.org`
- Reinstalled in the venv to refresh the editable metadata:
```
$ .venv/bin/pip install -e .
$ .venv/bin/ohm-rfc-app-flotilla --version
ohm-rfc-app-flotilla, version 0.3.0
```
Final test pass: 74 passing in 0.11s.
Offered to commit + tag, noting that the working tree contains both
Slice 2 (uncommitted per Session E) and Slice 3 work.
## Turn 3 — User
> yes. And then write a transcript of this full session (EVERYTHING)
> to ~/git/ohm-infra
## Turn 3 — Assistant
**Commit + tag.**
The working tree had Slice 2 (deliberately uncommitted at the end of
Session E, pending the operator-side Secret Manager bootstrap
gesture) plus Slice 3. Considered three commit shapes:
- Two commits (Slice 2 alone, then Slice 3) — cleanest history, but
requires partial-file staging since the CHANGELOG and `cli.py` were
modified by both slices.
- One combined commit "Release 0.3.0: Slices 2 + 3" — accurately
reflects "nothing was committed between v0.1.0 and now", lossy on
per-slice history granularity, much simpler.
- Two commits via stash split — error-prone, no clear payoff.
Picked the combined commit. The CHANGELOG already documents both
versions separately so the per-slice record survives; the git log
just consolidates them at the release boundary.
```
$ git add CHANGELOG.md README.md SPEC.md VERSION pyproject.toml \
ohm_rfc_app_flotilla/{cli,registry,secrets,pin,plan}.py \
ohm_rfc_app_flotilla/migrations/{002_secret_refs,003_pin_source_host}.sql \
tests/{test_db,test_overlay,test_secrets,test_pin,test_plan}.py \
docs/secret-bootstrap.md
$ git commit -m "Release 0.3.0: Slice 3 — pin reading + dry-run deploy
Slice 3 of SPEC §20 lands `pin show`, `pin check`, and `deploy
<deployment> --dry-run`. Flotilla resolves the live OHM pin from
Gitea, validates every secret reference is readable, computes the
overlay snapshot hash from §8.1 step 1d, and renders the 9-phase
deploy plan with deployment-specific values filled in — all
without touching the target VM.
This commit also lands Slice 2's code (secret references + Secret
Manager bootstrap doc), which was deliberately uncommitted at the
end of Session E pending the operator-side bootstrap gesture. The
0.2.0 CHANGELOG entry survives unchanged (no separate v0.2.0 tag
is being cut); the 0.3.0 entry layers Slice 3 on top.
[…]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
[main 491e72e] Release 0.3.0: Slice 3 — pin reading + dry-run deploy
18 files changed, 1520 insertions(+), 41 deletions(-)
$ git tag v0.3.0 -m "Release 0.3.0 — Slice 3: pin reading + deploy --dry-run"
$ git log --oneline -3
491e72e Release 0.3.0: Slice 3 — pin reading + dry-run deploy
581687a Release 0.1.0: Slice 1 — registry + non-secret overlay
a9a9aaa Release 0.0.1: initial spec
$ git tag -l
v0.0.1
v0.1.0
v0.3.0
```
**Transcript.** This file. Checked `~/git/ohm-infra/` and found the
established convention: `SESSION-{B,C,D,E}-TRANSCRIPT.md`, formatted
as a header blockquote (date, goal, outcome) followed by numbered
`Turn N — User` and `Turn N — Assistant` sections, with narrative
prose plus code/command blocks for key outputs. Session F naturally
follows. Wrote `/Users/benstull/git/ohm-infra/SESSION-F-TRANSCRIPT.md`.
---
## What's load-bearing for the next session
- **v0.3.0 is tagged locally, not pushed.** The §19.2 candidate
"extract generic ohm-rfc-app-flotilla library" still hasn't earned
its session (only OHM exists). Pushing to `git.wiggleverse.org` is
pending whenever the operator does the next-session push gesture.
- **v0.2.0 was never tagged** and isn't going to be. The 0.2.0
CHANGELOG entry stays as record of "Slice 2 code shipped on
2026-05-27 alongside the 0.3.0 release commit". The operator-side
Secret Manager bootstrap gesture in `docs/secret-bootstrap.md` is
still the prerequisite for actually putting secrets into
`wiggleverse-ohm` Secret Manager — and that gesture is still
pending. Slice 4 needs those entries to exist to do a real deploy.
- **Slice 4 = full deploy gesture + `/api/health` verification.**
Per §20.4: real SSH via `gcloud compute ssh`, per-phase execution
per §8.1 with fail-stop semantics, `/api/health` probe loop per §9,
concurrency lock per §8.3, atomic `.env` write per §8.1 step 6.
New schema: `deploys`, `health_snapshots` tables. New CLI verbs:
`deploy <name>` (without `--dry-run`), `deploy abort`, `deploy
status`, `deploy watch`, `deploy log`. **Click structural decision
deferred to Slice 4**: `deploy` is currently a `@main.command` with
a positional `DEPLOYMENT` argument; the other `deploy *` verbs are
`@main.subgroup` shapes that Click doesn't natively support combined
with the positional-arg command. Three options for Slice 4 to pick:
1. Restructure `deploy` into a Click `Group` with a default
command (`deploy run <name>` form, breaks SPEC §12.1 literal).
2. Use a library like `click-default-group` for default-command
behavior.
3. Rename the subcommands (`deploy-abort`, `deploy-status`, etc.)
— wins on Click ergonomics, loses on SPEC literal.
- **`/api/health` may be misbehaving on live OHM.** Per §20.4 scope:
"if `ohm.wiggleverse.org/api/health` is currently misbehaving (see
the session-start observation about the `{"detail":"Not Found"}`
response), the session debugs that as part of bringing the verify
loop green." Slice 4 session should curl `/api/health` early.
- **Pin-source URL alias.** SPEC §13.4 records `ben/ohm-rfc`; Gitea
canonical is `ben.stull/ohm-rfc` and serves a stable 307 redirect.
urllib follows it once the UA header is set. Left §13.4 unchanged
(the alias is what the operator remembers). If Slice 4 hits any
path where the redirect causes ambiguity (e.g., a webhook URL that
needs the canonical form), reopen §13.4.
- **The pin reader is the working example of "live external client"
for future slices.** Two patterns now established for Slice 4's
`gcloud compute ssh` shell-out:
1. Build SSLContext from `certifi.where()` (already in `pin.py`).
2. For HTTP clients, set an explicit User-Agent identifying the
tool — Cloudflare-fronting is common across the Wiggleverse
stack and the default `Python-urllib/<ver>` is blocked.
- **Plan-assembly pattern is reusable.** `assemble_plan` takes
injectable `read_pin` / `read_secret`; Slice 4's real-deploy
function should accept the same seam so the test surface can stub
every external call. The 9-phase shape (`Phase` dataclass) is the
source of truth for both dry-run rendering and the real gesture's
per-phase recording into the `deploys` row's `phases` JSON column
(§4.3, §10.1).
- **Test fixture conventions extended.** `tmp_data_dir` + `conn` +
`runner` from `conftest.py` still serve; the new pattern in
`test_plan.py` is `STUB_SECRET_BYTES` — a byte string used by the
stub Secret Manager reader and grep-asserted absent from every
test output. Same discipline applies to any future stubbed-bytes
surface.
- **Auto-memory unchanged.** Did not write any new memory files this
session. Slice 3 is fully recoverable from the commit + CHANGELOG +
this transcript; nothing about it needs to persist as cross-session
context. If Slice 4 surfaces a non-obvious operator preference or
a load-bearing decision not captured in the spec/code, that's when
to add memory.
- **macOS-vs-Linux note for CI.** The certifi-cafile workaround
matters on macOS framework Python (operator's laptop). Linux CI
Python typically has working system trust roots and would resolve
without certifi. The `_ssl_context()` fallback to
`ssl.create_default_context()` handles the latter; both paths are
exercised by the live network test on macOS and the unit tests on
CI (the unit tests stub `read_gitea_pin` so they don't touch SSL).