Files
JesseMarkowitz 87a40326a2 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.
2026-09-16 05:37:13 -04:00

143 lines
6.5 KiB
Python

"""Exporting an adventure to a bundle, and importing one back.
`app/bundle.py` owns the format and the version handling. These two endpoints
only check ownership, apply the caps, and hand the work over.
## Why the import is one transaction and two phases
`bundle.plan` reads the whole file and returns a checked, normalised tree
without opening a session, touching a row or creating an adventure. Everything a
hand-edited file can get wrong about its own shape — a node on a branch that is
not listed, a fork from a branch listed after it, a head past the story, an
audit record naming a turn that is not there — is a 400 from a function with no
side effects.
Only then does `bundle.materialize` write, and it writes inside the single
transaction this endpoint commits at the end. So there are exactly two outcomes
a caller can see, and M9 requires them to be distinguishable:
the authoritative import failed 4xx, and no campaign exists
the authoritative import succeeded 201, and the campaign is complete
A third state — the campaign landed and a *rebuildable* index did not — is not a
failure of the import and does not roll it back. Passages, the lexical index and
vectors are all a deterministic function of content the file carries, so losing
them costs a rebuild rather than data. It is reported on the response as a
warning, it is visible per source in the Knowledge panel, and Reindex is the
repair. Refusing a whole campaign because a search index would not build would
trade the valuable thing for the cheap one.
"""
import json
from fastapi import Body, Depends, Request, Response
from sqlalchemy.orm import Session
from ... import bundle, head, limits, models, schemas
from ...database import get_db
from .deps import CurrentUser, current_adventure, router
@router.get("/{adventure_id}/export")
def export_adventure(
db: Session = Depends(get_db),
adv: models.Adventure = Depends(current_adventure),
):
"""Returns a full backup: the story, the tree, the state, and the evidence.
`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.
"""
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)
def import_adventure(
request: Request,
payload: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
version = bundle.check_format(payload)
limits.check_row_cap("adventures", db, user)
limits.check_bundle_lists(
story_cards=payload.get("storyCards"),
memories=payload.get("memories"),
actions=payload.get("actions"),
branches=payload.get("branches"),
)
# Check the tree before the adventure row exists, so that an inconsistent
# file returns a 400 rather than leaving a half-imported adventure with a
# gap in its story.
story = bundle.plan(payload, version)
# Count again, this time over what is written. The check above reads the
# file's own lists, and in a v1 file one turn is one entry that carries its
# retries in a `variants` array. `plan()` expands that into one row per
# attempt, because SP4 made every attempt a node. A file of 5,000 turns with
# ten attempts each therefore passes a 5,000-action cap and writes 50,000
# rows, well inside the 20 MB body limit. `plan()` has no side effects and
# the adventure does not exist yet, so this check costs only the planning.
limits.check_bundle_lists(
actions=story["nodes"],
memories=story["memories"],
branches=story["branches"],
)
try:
adventure, report = bundle.materialize(db, payload, story, user.id)
db.commit()
except Exception:
# Explicit, rather than left to the session closing. The planner has
# already refused everything it can see, so anything raising here is a
# write that surprised us — the case where leaving a partial campaign
# behind would be worst, and the case a test can only assert on if the
# rollback is a statement rather than a side effect of teardown.
db.rollback()
raise
db.refresh(adventure)
# A campaign exported while undone imports undone (M3), so the history
# controls have to be right on the response that opens it — otherwise the
# first thing the reader sees about a story with a retained future is a
# greyed-out Redo.
out = schemas.ImportedAdventureOut.model_validate(adventure)
out.can_undo = head.can_undo(db, adventure)
out.can_redo = head.can_redo(db, adventure)
out.import_warnings = [
f"The search index for “{failure['title']}” could not be rebuilt "
f"({failure['detail']}). The file itself imported intact — use Reindex "
f"in the Knowledge panel to try again."
for failure in report["knowledge_index_failures"]
]
return out