docs(structure): govern .planning/ and register the ops runbook

- docs/STRUCTURE.md: add a .planning/ registry (vision/roadmap/history/breakdown
  roles), broaden the roadmap role, add enforcement items 8-10.
- tools/check_requirements.py: scan .planning/ too - every .planning/**.md must be
  registered, registered files must exist, requirement rows rejected there.
- .planning/: PROJECT.md, ROADMAP.md, MILESTONES.md, and milestones/v0.5/M3/
  {roadmap.md,task_breakdown.md}. M3 breakdown holds only the tasks; decisions live
  in the contracts.
- docs/DEPLOYMENT.md: add the ops runbook (registered as ops).
This commit is contained in:
2026-09-19 10:12:32 -07:00
parent 90e862cfb0
commit 6435199f08
8 changed files with 234 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
# Shipped milestones
Role: history. Historical log of shipped milestones. Cites requirement IDs; defines none.
Not yet written.
+5
View File
@@ -0,0 +1,5 @@
# Project
Role: vision. Overall project vision and current state. Cites requirement IDs; defines none.
Not yet written.
+5
View File
@@ -0,0 +1,5 @@
# Roadmap
Role: roadmap. Phases across all milestones. Cites requirement IDs; defines none.
Not yet written.
+30
View File
@@ -0,0 +1,30 @@
# M3 — roadmap
Role: roadmap. Objectives for v0.5 / M3. Cites requirement IDs; defines none.
## Objective
A Photos-ready slab becomes submission-ready for WooCommerce, and a practice `SLAB-UAT-*` draft
matching the reviewed ready set exists in the store. Create works with inference off.
## Capabilities in scope
- Woo connection (WordPress application password) and taxonomy sync.
- Review to the full ready set, with per-slab pricing and override.
- Inference assist (Call 1 / Call 2), optional and never blocking create.
- Publish: create the Woo product; `SLAB-UAT-*` always a draft.
## Sources of truth
- Behavior and acceptance: `docs/capabilities/CREATE.md`.
- Scope and the M3 ID subset: `docs/MILESTONES.md`.
- Interfaces: `docs/contracts/` (`API.md`, `WOO-API.md`, `DATA-MODEL.md`, `PROMPTS.md`, `UX.md`,
`CONTENT-WOO.md`, `TEST-SPEC.md`).
## Exit
Per `docs/MILESTONES.md` and `docs/capabilities/CREATE.md` (QUALITY-110).
## Note
M3 implementation has not started. Decisions already ruled live in the contracts, not here.
@@ -0,0 +1,36 @@
# M3 — task breakdown
Role: breakdown. Ordered task list for v0.5 / M3. Cites requirement IDs; defines none.
Objectives: `roadmap.md` in this folder. Behavior and acceptance: `docs/capabilities/CREATE.md`.
Scope and IDs: `docs/MILESTONES.md`. Decisions live in `docs/contracts/`, not here.
TDD every slice: the failing test precedes its implementation; never parallelize RED and GREEN of
the same scenario.
| Slice | Delivers | M3 IDs |
|---|---|---|
| S0 | factory, config/startup guards, health/meta, JSON logs, Dockerfile | APP-30, APP-45, APP-55, APP-65, SETTINGS-10, SETTINGS-25, OPS-25 |
| S1 | AES-GCM crypto + Settings (masked, write-only secrets) | SETTINGS-5/15/20/25/30/35/75/85/90, QUALITY-125 |
| S2 | slabs CRUD, optimistic concurrency, soft delete | APP-5/15/20/35/40, REVIEW-45 |
| S3 | photos (multipart → BLOBs, original/processed, 410 after purge) | CAPTURE-95/105/130, PUBLISH-40 |
| S4 | Woo client (application-password Basic) + taxonomy sync + test-woo | METADATA-5/10/15/20/30, SETTINGS-40/50/51/55/60/80, QUALITY-35 |
| S5 | pricing, deterministic templates, server-derived ready | REVIEW-5/10/15/20/25/30/35/40/50, METADATA-70/80/85/90, QUALITY-90/95 |
| S6 | publish (sequence, UAT draft, duplicate/stale/partial failure) | PUBLISH-5/10/15/20/25/30/31/35/36/37/38/40/45/50/55/60 |
| S7 | inference last (Call 1 / Call 2, prompt revisions, template fallback) | METADATA-35/40/45/50/55/60/65/66/75/95/100/105/110/115, SETTINGS-45/70 |
| Cross | frontend API client; Review / Settings / Publish screens | UI for the slices above |
| Exit | inference-off suite, Woo-mock integration, Compose UI E2E, real UAT draft | QUALITY-20/35/50/105/110/120 |
## Frontend layout
New code stays outside the Capture union: `frontend/src/api/*`, `frontend/src/create/*`.
## Verification
Slice bar: `docs/contracts/TEST-SPEC.md`. Docs gate: `python tools/check_requirements.py`.
Exit bar: `docs/MILESTONES.md` and `docs/capabilities/CREATE.md`.
## Not yet validated
- Long-horizon multi-step agentic loops (more than one or two tool round-trips).
- M3 implementation has not started.
+111
View File
@@ -0,0 +1,111 @@
# Deployment
Role: ops. This is the canonical runbook for getting a build from a working tree to a live
dev/test host. `deploy/INVENTORY.md` is the only source for what runs where (hosts, ports,
container names). `docs/UAT-PRACTICE.md` is the human walk once the app is up; this doc is what
makes it up.
## 1. Topology
| Node | Role | Reach |
|---|---|---|
| Ty dev box (Windows) | code, git push to Gitea. No Docker CLI. | LAN + Tailscale |
| Gitea (`gitea.vps1.afterthedemo.com`) | source of truth for the repo. SSH port closed; HTTPS only. | Tailscale |
| `ubudual5060` (UAT host, LAN `192.168.1.202`, Tailscale `100.64.0.9`) | builds and runs Docker images for UAT. | LAN + Tailscale |
The dev box never builds images. The UAT host pulls source from Gitea and builds locally.
Do not install Docker on the dev box to work around this; the pull-build path is the plan.
## 2. Access (already provisioned; do not re-do)
- Dev box → UAT host: `ssh ubudual5060` (key `~/.ssh/ubudual5060`, wired in `~/.ssh/config`).
Unattended, passphrase-less.
- UAT host → Gitea: HTTPS with a read-only token named `5060-pull`, stored in
`~/.git-credentials` on the host. The host clones/pulls with the plain HTTPS URL;
no key material in the repo.
- Deploy checkout on the UAT host: `~/slab-deploy` (branch `main`).
The pre-existing `~/SlabUploader` on that host is a stale work clone — do not use it.
If access is broken, fix it here before touching compose. Do not commit credentials to the repo.
## 3. Build and deploy (M3)
Precondition: the M3 scaffold exists on the branch being deployed — `frontend/Dockerfile`,
`backend/Dockerfile`, and `deploy/.env` on the host. `main` currently
has `frontend/Dockerfile` only; `backend/` lands with M3 implementation.
Prompts live in SQLite (`docs/contracts/PROMPTS.md`); no prompt files ship with the image.
1. Push the branch to Gitea from the dev box.
2. On the UAT host:
```
cd ~/slab-deploy
git fetch origin && git checkout <branch> && git pull
cp deploy/.env.example deploy/.env && chmod 600 deploy/.env # first time only
docker compose -f deploy/docker-compose.yml --env-file deploy/.env build
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d
```
3. Verify with the smoke checks in §5.
### `.env` values
Generate on the UAT host, never in the repo:
- `SLAB_AES_KEY` — `openssl rand -base64 32`
- `WOO_BASE_URL` — the store, `https://` only. FastAPI refuses `http://` at startup.
- `APP_HOSTNAME` — from `deploy/INVENTORY.md`.
- `FRONTEND_PORT` / `FASTAPI_PORT` — see the port override below.
Woo Application Password and inference keys are not in `.env`; they are entered in the app's
Settings and stored AES-GCM encrypted in the app's SQLite.
### Port binding for the dev-test environment
`deploy/docker-compose.yml` binds `127.0.0.1` only (Caddy is the TLS edge in the full stack).
The dev-test environment is reached directly on a port from the project block
`10880`–`10889` (per `deploy/INVENTORY.md`) and **must be reachable from any machine on
`192.168.1.0/24`**, so the deploy must override the port bindings to `0.0.0.0`
(compose override file or `ports:` edit scoped to this deploy). Never bind `:10880`
(Ty's local Node/SvelteKit) or Ty's `:18080`.
M3 takes over port `10881` from the M2 container (`slabuploader-m2-overnight`),
per the replace-prior-container rule in `deploy/INVENTORY.md`.
Remove the M2 container when the M3 one is healthy.
## 4. Local loop (no UAT host needed)
- Frontend: `cd frontend && npm install && npm run dev` (Vite).
- Backend: `uvicorn` from `backend/` once the M3 scaffold lands; needs the same env vars as §3.
- Tests: frontend `npm test` (Playwright config in `frontend/`); backend tests land with M3.
Use the local loop for iteration; use §3 for anything Ty must see on a phone.
## 5. Smoke checks (after every deploy)
Run from the UAT host, then from a phone on the same network (LAN or Tailscale):
1. `curl -fsS http://127.0.0.1:<frontend-port>/api/health` on the host returns ok.
2. Phone browser opens the inventory URL (`deploy/INVENTORY.md`) and the app loads.
3. `docker compose -f deploy/docker-compose.yml --env-file deploy/.env ps` shows both services
up; `docker logs` for `fastapi` shows no startup refusal (WOO_BASE_URL is `https://`).
4. `test-woo` in app Settings reports ok (store reachable, taxonomy synced).
If a check fails, the deploy is not live. Roll back per §6 before debugging on the live port.
## 6. Rollback
1. `docker compose -f deploy/docker-compose.yml --env-file deploy/.env down` on the UAT host.
2. Check out the last known-good ref in `~/slab-deploy` and re-run §3 steps 2–3.
3. Re-run §5. Volumes `slab_db` and `slab_images` persist across rollback; do not remove them
unless the data itself is the problem.
## Where each rule lives
| Rule | Source |
|---|---|
| Hosts, ports, container names | `deploy/INVENTORY.md` |
| Compose shape, env contract | `deploy/docker-compose.yml`, `deploy/.env.example` |
| UAT walk once the app is up | `docs/UAT-PRACTICE.md` |
| M3 scope and exit | `docs/MILESTONES.md`, `docs/REQUIREMENTS.md` |
| Woo auth model | `AGENTS.md`, `docs/contracts/CONTENT-WOO.md` |
+23 -2
View File
@@ -1,7 +1,7 @@
# Structure contract # Structure contract
This file defines the shape of the `docs/` system and how requirements enter it. This file defines the shape of the `docs/` system and the `.planning/` tree, and how requirements
`tools/check_requirements.py` enforces it. enter it. `tools/check_requirements.py` enforces it.
If the repo and this file disagree, fix one deliberately. Do not let them drift. If the repo and this file disagree, fix one deliberately. Do not let them drift.
@@ -21,6 +21,7 @@ Every Markdown file under `docs/` is registered here. `docs/archive/**` and `doc
| `docs/capabilities/CREATE.md` | capability | | `docs/capabilities/CREATE.md` | capability |
| `docs/contracts/CONTENT-WOO.md` | contract | | `docs/contracts/CONTENT-WOO.md` | contract |
| `docs/UAT-PRACTICE.md` | practice | | `docs/UAT-PRACTICE.md` | practice |
| `docs/DEPLOYMENT.md` | ops |
| `docs/capabilities/README.md` | index | | `docs/capabilities/README.md` | index |
| `docs/contracts/README.md` | index | | `docs/contracts/README.md` | index |
| `docs/contracts/WOO-API.md` | contract | | `docs/contracts/WOO-API.md` | contract |
@@ -30,6 +31,18 @@ Every Markdown file under `docs/` is registered here. `docs/archive/**` and `doc
| `docs/contracts/UX.md` | contract | | `docs/contracts/UX.md` | contract |
| `docs/contracts/TEST-SPEC.md` | contract | | `docs/contracts/TEST-SPEC.md` | contract |
Every Markdown file under `.planning/` is registered here. `.planning/` holds planning artifacts
only and is **not** a second requirements home: `docs/REQUIREMENTS.md` and `docs/MILESTONES.md`
remain the point of record, and `.planning/` files cite requirement IDs without defining them.
| Path | Role |
|---|---|
| `.planning/PROJECT.md` | vision |
| `.planning/ROADMAP.md` | roadmap |
| `.planning/MILESTONES.md` | history |
| `.planning/milestones/v0.5/M3/roadmap.md` | roadmap |
| `.planning/milestones/v0.5/M3/task_breakdown.md` | breakdown |
Roles: Roles:
- **index** — navigation only. - **index** — navigation only.
@@ -40,6 +53,11 @@ Roles:
- **capability** — behavior and acceptance for one capability; cites IDs, does not define them. - **capability** — behavior and acceptance for one capability; cites IDs, does not define them.
- **contract** — cross-cutting interface detail; cites IDs, does not define them. - **contract** — cross-cutting interface detail; cites IDs, does not define them.
- **practice** — human walkthrough. - **practice** — human walkthrough.
- **ops** — runbook for hosts, builds, and release; cites inventory and contracts, defines no requirements.
- **vision** — overall project vision and current state; cites IDs, defines none.
- **roadmap** — version or milestone objectives; cites IDs, defines none.
- **breakdown** — ordered task list for a version or milestone; cites IDs, defines none.
- **history** — historical log of shipped milestones; cites IDs, defines none.
## ID namespaces ## ID namespaces
@@ -90,6 +108,9 @@ Nothing outside `REQUIREMENTS.md` may define a requirement. Capability and contr
5. `Test` references resolve: a path given for a `shipped` requirement exists; an ID given resolves to a defined requirement; `—` is allowed. 5. `Test` references resolve: a path given for a `shipped` requirement exists; an ID given resolves to a defined requirement; `—` is allowed.
6. Requirement rows appear only in `REQUIREMENTS.md`. 6. Requirement rows appear only in `REQUIREMENTS.md`.
7. Every ID listed in `MILESTONES.md` exists in `REQUIREMENTS.md`. 7. Every ID listed in `MILESTONES.md` exists in `REQUIREMENTS.md`.
8. Every `.planning/` Markdown file is registered above.
9. Every registered `.planning/` file exists on disk.
10. Requirement rows appear nowhere outside `REQUIREMENTS.md`, including `.planning/`.
## Phase 2 migration (complete) ## Phase 2 migration (complete)
+19 -5
View File
@@ -16,14 +16,20 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
DOCS = ROOT / "docs" DOCS = ROOT / "docs"
PLANNING = ROOT / ".planning"
STRUCTURE = DOCS / "STRUCTURE.md" STRUCTURE = DOCS / "STRUCTURE.md"
REQUIREMENTS = DOCS / "REQUIREMENTS.md" REQUIREMENTS = DOCS / "REQUIREMENTS.md"
MILESTONES = DOCS / "MILESTONES.md" MILESTONES = DOCS / "MILESTONES.md"
EXEMPT_PREFIXES = (("docs", "archive"), ("docs", "_templates")) EXEMPT_PREFIXES = (("docs", "archive"), ("docs", "_templates"))
ROLES = "index|intent|meta|requirements|scope|capability|contract|practice" ROLES = (
REGISTRY_ROW = re.compile(rf"^\|\s*`(docs/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|") "index|intent|meta|requirements|scope|capability|contract|practice|ops"
"|vision|roadmap|history|breakdown"
)
REGISTRY_ROW = re.compile(
rf"^\|\s*`((?:docs|\.planning)/[^`]+\.md)`\s*\|\s*({ROLES})\s*\|"
)
NAMESPACE_ROW = re.compile(r"^\|\s*`([A-Z]+)`\s*\|") NAMESPACE_ROW = re.compile(r"^\|\s*`([A-Z]+)`\s*\|")
REQUIREMENT_ROW = re.compile( REQUIREMENT_ROW = re.compile(
r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$" r"^\|\s*([A-Z]+)-(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$"
@@ -58,13 +64,19 @@ def main() -> int:
if not namespaces: if not namespaces:
errors.append("STRUCTURE.md declares no ID namespaces") errors.append("STRUCTURE.md declares no ID namespaces")
# 1. every non-exempt doc is registered planning_files = sorted(PLANNING.rglob("*.md")) if PLANNING.is_dir() else []
# 1. every non-exempt doc is registered; every planning file is registered
for path in sorted(DOCS.rglob("*.md")): for path in sorted(DOCS.rglob("*.md")):
rel = path.relative_to(ROOT).as_posix() rel = path.relative_to(ROOT).as_posix()
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES: if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES:
continue continue
if rel not in registered: if rel not in registered:
errors.append(f"unregistered doc: {rel}") errors.append(f"unregistered doc: {rel}")
for path in planning_files:
rel = path.relative_to(ROOT).as_posix()
if rel not in registered:
errors.append(f"unregistered planning file: {rel}")
# 2. every registered doc exists # 2. every registered doc exists
for rel in registered: for rel in registered:
@@ -110,7 +122,7 @@ def main() -> int:
errors.append(f"{loc} {rid} invalid Test ref '{ref}' (use a path, an ID, or —)") errors.append(f"{loc} {rid} invalid Test ref '{ref}' (use a path, an ID, or —)")
# 6. requirement rows appear only in REQUIREMENTS.md # 6. requirement rows appear only in REQUIREMENTS.md
for path in sorted(DOCS.rglob("*.md")): for path in sorted(DOCS.rglob("*.md")) + planning_files:
if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES or path in { if path.relative_to(ROOT).parts[:2] in EXEMPT_PREFIXES or path in {
REQUIREMENTS, REQUIREMENTS,
STRUCTURE, STRUCTURE,
@@ -148,8 +160,10 @@ def main() -> int:
print(f" - {error}") print(f" - {error}")
return 1 return 1
planning_registered = sum(1 for rel in registered if rel.startswith(".planning/"))
print( print(
f"PASS check_requirements: {len(registered)} docs, " f"PASS check_requirements: {len(registered) - planning_registered} docs, "
f"{planning_registered} planning, "
f"{len(namespaces)} namespaces, {len(ids)} requirements" f"{len(namespaces)} namespaces, {len(ids)} requirements"
) )
return 0 return 0