v1.1: harden recovery and control boundaries

WP-D and WP-E complete the planned v1.1 implementation packages.

WP-D — recovery honesty:
- backups verify the completed copy with PRAGMA integrity_check
- corruption missed by quick_check is detected by the full check
- existing good backups remain protected
- oversized exports are still delivered but declare whether this version can
  import them, while the 20 MB import limit remains unchanged
- backup was exercised through the real browser UI on both the normal campaign
  database and a campaign-shaped database over 100 MB

WP-E — control-boundary contrast:
- interactive control boundaries meet the WCAG 1.4.11 3:1 target
- the contrast audit is now a failing gate rather than an advisory
- rendered browser measurements pass for the composer, controls, tabs and nav
- text contrast and focus visibility remain intact
- owner reviewed and approved the before/after screenshots

Reports:
- planning/reports/v1.1/V1.1-WP-D-REPORT.md
- planning/reports/v1.1/V1.1-WP-E-REPORT.md

All planned v1.1 work packages A-E are now complete. Release validation has not
yet begun.
This commit is contained in:
JesseMarkowitz
2026-09-16 05:37:13 -04:00
parent 59b5ebc2d8
commit 87a40326a2
21 changed files with 2401 additions and 60 deletions
+20 -9
View File
@@ -39,8 +39,9 @@ turn is blocked.
never leaves a half-written file wearing a backup's name. `os.replace` is
atomic on the same filesystem, which is why the temporary sits in the
destination's own directory rather than in `/tmp`.
3. `PRAGMA quick_check` runs against the finished copy, opened as its own
database, before it is renamed. A backup nobody verified is a belief.
3. `PRAGMA integrity_check` runs against the finished copy, opened as its own
database, before it is renamed. A backup nobody verified is a belief. v1.1
WP-D made this the full check rather than `quick_check`; see `_verify`.
4. An existing file is never overwritten. Each run writes a new name stamped
with the time, so yesterday's backup survives today's mistake — which is most
of what a backup is for.
@@ -189,18 +190,28 @@ def _copy(source_path: Path, working: Path) -> int:
def _verify(working: Path) -> str:
"""Runs `PRAGMA quick_check` against the finished copy.
"""Runs `PRAGMA integrity_check` against the finished copy.
Opened as its own connection, so what is checked is the file on disk rather
than any page cache the copy left behind. `quick_check` rather than
`integrity_check` because it does the structural work — every page reachable,
every record readable — without the full index cross-check, which on a large
database is minutes rather than moments. A backup nobody verified is a
belief; a backup verified slowly enough that nobody takes one is worse.
than any page cache the copy left behind.
**v1.1 WP-D: the full check, not `quick_check`.** M9 chose `quick_check` for
its speed, on the argument that a backup verified slowly enough that nobody
takes one is worse than a fast one. The measurements say the trade was not
needed here: `quick_check` omits the cross-check between a table and its
indexes, and that is a real class of damage it reports as `ok`. A copy whose
index disagrees with its table restores into a database that answers queries
with rows that are not there — the failure a backup exists to prevent.
The cost is small at the sizes this application produces: on the 100-turn
evidence campaign both checks are a few milliseconds, and on a synthetic
database two orders of magnitude larger the difference is still short of a
second (WP-D report §E). A backup nobody verified is a belief; this is the
check that makes it a fact.
"""
connection = sqlite3.connect(f"file:{working}?mode=ro", uri=True)
try:
rows = connection.execute("PRAGMA quick_check").fetchall()
rows = connection.execute("PRAGMA integrity_check").fetchall()
finally:
connection.close()
result = ", ".join(str(row[0]) for row in rows) if rows else "no result"
+28
View File
@@ -129,6 +129,34 @@ MAX_BODY_BYTES = 2 * 1024 * 1024
MAX_IMPORT_BODY_BYTES = 20 * 1024 * 1024
def import_limit_label(limit: int | None = None) -> str:
"""The import ceiling as a reader would say it, e.g. "20 MB".
Derived from the constant rather than written beside it, so the refusal, the
export warning and the documentation cannot drift apart from each other or
from what the middleware actually enforces (v1.1 WP-D).
"""
size = MAX_IMPORT_BODY_BYTES if limit is None else limit
megabytes = size / (1024 * 1024)
return f"{megabytes:.0f} MB" if abs(megabytes - round(megabytes)) < 0.05 else f"{megabytes:.1f} MB"
def oversized_export_warning(export_bytes: int, limit: int | None = None) -> str:
"""What to tell a reader whose export is larger than import will accept.
v1.1 WP-D. The file is written and is not damaged: what it exceeds is this
version's import ceiling, so it cannot be brought back in *here*. Saying that
plainly is the whole point — the alternative is a reader who finds out when
they try to restore it.
"""
size = MAX_IMPORT_BODY_BYTES if limit is None else limit
return (
f"This export is larger than this version's {import_limit_label(size)} import "
f"limit ({export_bytes:,} bytes). The file was exported successfully, but this "
f"version cannot import it."
)
class BodySizeLimitMiddleware:
"""Rejects oversized request bodies by their declared `Content-Length`.
+34 -2
View File
@@ -28,7 +28,9 @@ repair. Refusing a whole campaign because a search index would not build would
trade the valuable thing for the cheap one.
"""
from fastapi import Body, Depends, Request
import json
from fastapi import Body, Depends, Request, Response
from sqlalchemy.orm import Session
from ... import bundle, head, limits, models, schemas
@@ -46,8 +48,38 @@ def export_adventure(
`app/bundle.py` owns the format, in all three of its versions. A backup
outlives the schema, so no call site decides anything about its shape.
**v1.1 WP-D: the export also says whether this version could import it back.**
A campaign large enough to pass `limits.MAX_IMPORT_BODY_BYTES` still exports —
the file is complete and not damaged, and refusing to write it would destroy
the only copy the reader was trying to make. What it cannot do is come back
in here, and the reader is told that at the moment they take it rather than
at the moment they need it.
It travels in headers, not in the body. The body is the bundle, the browser
saves exactly those bytes as the file, and a warning inside it would become
part of a portable story file and of every checksum taken over one.
The size measured is the compact serialisation, because that is both what
this response sends and what the browser POSTs back on import, which is what
`BodySizeLimitMiddleware` weighs. The pretty-printed file the reader
downloads is larger, and is not what import reads.
"""
return bundle.export(db, adv)
payload = bundle.export(db, adv)
# Serialised exactly as Starlette's JSONResponse would, so the bytes counted
# are the bytes sent.
body = json.dumps(payload, ensure_ascii=False, allow_nan=False,
separators=(",", ":")).encode("utf-8")
limit = limits.MAX_IMPORT_BODY_BYTES
importable = len(body) <= limit
headers = {
"X-Export-Bytes": str(len(body)),
"X-Import-Limit-Bytes": str(limit),
"X-Importable-By-This-Version": "true" if importable else "false",
}
if not importable:
headers["X-Export-Warning"] = limits.oversized_export_warning(len(body), limit)
return Response(content=body, media_type="application/json", headers=headers)
@router.post("/import", response_model=schemas.ImportedAdventureOut, status_code=201)