fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review

The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.

- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
  stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
  ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
  inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
  ready→publish→done); mock respects optimistic concurrency (client_rev) so
  stale PUTs 409 like the real backend
This commit is contained in:
Ty
2026-09-20 23:36:31 -07:00
parent 41401a7266
commit 693719e85f
8 changed files with 329 additions and 170 deletions
+12
View File
@@ -64,6 +64,11 @@ def _encode(payload: Dict[str, Any]) -> Dict[str, Any]:
return out
# #89: server-managed — only the explicit review lock action may set it. A plain
# PUT must not flip the lock (the deterministic copy is built inside that action).
CLIENT_FORBIDDEN_FIELDS = {"metadata_locked"}
def _row_to_slab(row) -> Dict[str, Any]:
d = dict(row)
for f in JSON_ARRAY_FIELDS:
@@ -72,6 +77,8 @@ def _row_to_slab(row) -> Dict[str, Any]:
d[f] = json.loads(d[f])
except (ValueError, TypeError):
pass
# #89: expose the lock flag to the client (0/None → False, 1 → True).
d["metadata_locked"] = bool(d.get("metadata_locked"))
return d
@@ -155,6 +162,11 @@ async def get_slab(id: str) -> Dict[str, Any]:
async def update_slab(id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
if "client_rev" not in payload:
return _err("validation", "client_rev required", status=400)
# #89: the metadata lock is server-managed via the explicit review lock
# action; a plain PUT may not flip it.
for field in CLIENT_FORBIDDEN_FIELDS:
if field in payload:
return _err("validation", f"{field} is set by the review lock action, not by update", status=400)
conn = get_connection()
current = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (id,)).fetchone()
if current is None: