From 3b79bd6f7b92eeb8113a4ae4f0150c69d82b40d0 Mon Sep 17 00:00:00 2001 From: vidaks <39301796+vidaks@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:30:05 +0200 Subject: [PATCH 1/3] feat: add acquisition verify gate (is a grabbed file the requested work?) verify(requested, file) decides match / mismatch / unverifiable at work granularity, for the identifier-verified acquisition gate (plan 22): - deterministic path: the file's embedded OPF ISBN -> Hardcover work, compared to the requested work via the shared canonical_id. - no resolvable ISBN: the file is identified through the resolver's adjudicator (its own title/author + colophon text), and that work is compared to the requested one; below 0.8 confidence it is held as unverifiable rather than matched or rejected on a guess. Reuses epub + hardcover + matcher + resolver; read-only, no grimmory DB. CLI: colophon verify --hcid | --title (exit 0/3/4). epub.inspect now also returns opf_author. 14 tests (deterministic + LLM). --- CHANGELOG.md | 11 ++++ colophon/cli.py | 25 +++++++- colophon/epub.py | 3 +- colophon/verify.py | 134 +++++++++++++++++++++++++++++++++++++++++++ tests/test_epub.py | 1 + tests/test_verify.py | 134 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 colophon/verify.py create mode 100644 tests/test_verify.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5218eb1..eaefe67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ All notable changes to this project are documented here. The format is based on new `COLOPHON_BOOKS_ROOT` (the host path the library mounts from); unset ⇒ feature off. New module `colophon/epub.py` (stdlib-only) plus `hardcover.book_by_isbn` and `grimmory.epub_path` helpers. +- **Acquisition-side `verify`** — a read-only `verify(requested, file)` (new module + `colophon/verify.py`; CLI `colophon verify --hcid ` / `--title`) answering + *is this downloaded file the requested work?* at work granularity, for the + identifier-verified acquisition gate (plan 22). Deterministic path: the file's embedded + OPF ISBN → Hardcover work, compared to the requested work via the shared `canonical_id`. + No resolvable ISBN: the file is identified through the resolver's adjudicator (search by + the file's own title/author, fold in its colophon text — the LLM only ever selects a real + candidate) and that work is compared to the requested one; below `_LLM_MIN` (0.8) + confidence the file is held as `unverifiable`. Reuses `epub` + `hardcover` + `matcher` + + `resolver`; no writes, no grimmory DB at runtime. Exits 0/3/4 (match/mismatch/unverifiable). + `epub.inspect` now also returns `opf_author` (dc:creator). ## [0.2.0] — 2026-06-04 diff --git a/colophon/cli.py b/colophon/cli.py index de9bbac..9ad22a4 100644 --- a/colophon/cli.py +++ b/colophon/cli.py @@ -17,6 +17,7 @@ from .grimmory import Grimmory, GrimmoryError from .heal import assert_preconditions, heal_book, revert_run, PreconditionError from .store import Store +from .verify import verify def _fmt(snap, keys=("title", "series_name", "series_number", "isbn_13", @@ -227,6 +228,22 @@ def cmd_maintain(args, g, store): return 0 if (res and res["ok"]) else 1 +def cmd_verify(args, g, store): + """Acquisition gate: is the file at the requested work? Read-only — prints a + JSON verdict (match/mismatch/unverifiable) and exits 0/3/4 for scripting. The gate's + hook shim reads the JSON, not the exit code.""" + requested = {} + if args.hcid: + requested["hcid"] = args.hcid + if args.title: + requested["title"] = args.title + if args.author: + requested["authors"] = args.author + result = verify(requested, args.file) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return {"match": 0, "mismatch": 3, "unverifiable": 4}.get(result["verdict"], 4) + + def main(argv=None): p = argparse.ArgumentParser(prog="colophon", description="autonomous library metadata healer (Phase 0)") sub = p.add_subparsers(dest="cmd", required=True) @@ -266,13 +283,19 @@ def main(argv=None): mt.add_argument("--apply", action="store_true", help="actually write (default: dry-run)") mt.add_argument("--force", action="store_true", help="re-query cached-unresolvable mis-seeds this run") mt.add_argument("--email", action="store_true", help="always email the summary (a daily heartbeat)") + ve = sub.add_parser("verify", help="acquisition gate: is a downloaded file the requested work? (read-only)") + ve.add_argument("file", help="path to the downloaded book file") + ve.add_argument("--hcid", help="requested Hardcover work id (primary anchor)") + ve.add_argument("--title", help="requested title (degraded fallback when no --hcid)") + ve.add_argument("--author", help="requested author(s), used with --title") args = p.parse_args(argv) g, store = Grimmory(), Store() fn = {"precheck": cmd_precheck, "heal": cmd_heal, "log": cmd_log, "revert": cmd_revert, "backfill": cmd_backfill, "audit": cmd_audit, "resolve": cmd_resolve, "series-audit": cmd_series_audit, - "oversight": cmd_oversight, "maintain": cmd_maintain}[args.cmd] + "oversight": cmd_oversight, "maintain": cmd_maintain, + "verify": cmd_verify}[args.cmd] try: return fn(args, g, store) except (GrimmoryError, PreconditionError) as e: diff --git a/colophon/epub.py b/colophon/epub.py index 87635d7..612a2c8 100644 --- a/colophon/epub.py +++ b/colophon/epub.py @@ -53,7 +53,7 @@ def _dc(opf, tag): def inspect(path): - """Return {opf_isbns, colophon_text, opf_title, publisher, year} or None. + """Return {opf_isbns, colophon_text, opf_title, opf_author, publisher, year} or None. `colophon_text` is "" when no page looks like a colophon (e.g. indie EPUBs with only a cover) — the caller should treat empty as "no usable colophon signal".""" @@ -98,6 +98,7 @@ def inspect(path): "opf_isbns": _isbns(opf), "colophon_text": best_text[:_MAX_COLOPHON] if best_hits else "", "opf_title": _dc(opf, "title"), + "opf_author": _dc(opf, "creator"), "publisher": _dc(opf, "publisher"), "year": (_dc(opf, "date") or "")[:4] or None, } diff --git a/colophon/verify.py b/colophon/verify.py new file mode 100644 index 0000000..036ca87 --- /dev/null +++ b/colophon/verify.py @@ -0,0 +1,134 @@ +"""Acquisition-side verification — is a grabbed file the requested work? + +Read-only, no writes, no grimmory DB. Given a requested identity (a Hardcover +work id — *primary*; or title/author — *degraded fallback*) and a downloaded book +file, decide match / mismatch / unverifiable at **work** granularity. Reuses +`epub.inspect` + `hardcover` + `matcher`; never originates an identifier. + +This is the comparator the acquisition gate (plan 22) calls per grab. The gate +acts on the returned verdict (promote / hold) — never on a process exit code. + +Deterministic happy path (no LLM): the file's embedded OPF ISBN resolves to a +Hardcover book; compare its work to the requested work (id, via the shared +canonical). A file with no resolvable ISBN is held as `unverifiable` — the +title/colophon LLM adjudication is a later increment, not a guess. +""" +from . import epub, hardcover, matcher + +MATCH = "match" +MISMATCH = "mismatch" +UNVERIFIABLE = "unverifiable" + +# Below this adjudicator confidence the file is held (unverifiable), never matched or +# rejected on a guess. LLM verdicts are also capped below the deterministic-ISBN 0.97. +_LLM_MIN = 0.8 + + +def _result(verdict, confidence, reason, **evidence): + out = {"verdict": verdict, "confidence": round(confidence, 2), "reason": reason} + out.update({k: v for k, v in evidence.items() if v is not None}) + return out + + +def _canon(book): + """Work-level key for a Hardcover book dict: its canonical id, else its own id.""" + return str(book.get("canonical_id") or book.get("hcid")) + + +def _same_work(a, b): + """Two Hardcover book dicts are the same work when their ids or canonicals agree + (an edition/duplicate record points at the work via `canonical_id`).""" + return _canon(a) == _canon(b) or str(a.get("hcid")) == str(b.get("hcid")) + + +def _file_book_from_isbns(isbns): + """First OPF ISBN that resolves to a Hardcover book → (isbn, book), else (None, None).""" + for isbn in isbns: + try: + book = hardcover.book_by_isbn(isbn) + except Exception: + continue + if book and book.get("hcid"): + return isbn, book + return None, None + + +def verify(requested, file_path): + """requested: {"hcid": } (primary) or {"title": ..., "authors": ...} (fallback). + file_path: the downloaded book file. Returns a verdict dict + {"verdict", "confidence", "reason", ...evidence}.""" + sig = epub.inspect(file_path) + if not sig: + return _result(UNVERIFIABLE, 0.0, + "no embedded signals (non-EPUB or unreadable file)", + source="no-file-signal") + + isbns = sig.get("opf_isbns") or [] + req_hcid = str(requested["hcid"]) if requested.get("hcid") else None + req_title = requested.get("title") + + isbn, file_book = _file_book_from_isbns(isbns) + + # --- Deterministic path: the file's embedded ISBN resolves to a Hardcover work --- + if file_book: + file_hcid = str(file_book.get("hcid")) + file_title = file_book.get("title") + if req_hcid: + try: + req_book = hardcover.book_by_id(req_hcid) + except Exception: + req_book = None + same = _same_work(file_book, req_book) if req_book else (file_hcid == req_hcid) + verdict = MATCH if same else MISMATCH + rel = "same work as" if same else "different work from" + return _result(verdict, 0.97 if same else 0.9, + f"file ISBN {isbn} -> hc#{file_hcid} {file_title!r}; {rel} requested hc#{req_hcid}", + source="isbn-id", isbn=isbn, file_hcid=file_hcid, file_title=file_title) + if req_title: + same = matcher._title_match(file_title or "", req_title) + verdict = MATCH if same else MISMATCH + rel = "~ requested title" if same else "!= requested title" + return _result(verdict, 0.80 if same else 0.75, + f"file ISBN {isbn} -> hc#{file_hcid} {file_title!r} {rel} {req_title!r} (no id given)", + source="isbn-title", isbn=isbn, file_hcid=file_hcid, file_title=file_title) + return _result(UNVERIFIABLE, 0.0, "no requested identity supplied", source="no-request") + + # --- No resolvable embedded ISBN: identify the file via the resolver's adjudicator + # (search Hardcover by the file's own title/author, fold in its colophon text), then + # compare that work to the requested one. The LLM only ever selects a real candidate. --- + opf_title, opf_author = sig.get("opf_title"), sig.get("opf_author") + if not opf_title: + return _result(UNVERIFIABLE, 0.0, + "no embedded ISBN and no title in the file to adjudicate on", + source="no-signal") + + from . import resolver # lazy: keeps verify's import surface light (epub/hardcover/matcher) + try: + prop = resolver.resolve({"book_id": None, "title": opf_title, "authors": opf_author or ""}, + file_signals=sig) + except Exception as e: + return _result(UNVERIFIABLE, 0.0, f"adjudication failed: {str(e)[:80]}", source="llm-error") + + conf = prop.get("confidence") or 0.0 + chosen = str(prop["chosen_id"]) if prop.get("chosen_id") else None + if prop.get("action") != "propose" or not chosen or conf < _LLM_MIN: + return _result(UNVERIFIABLE, round(conf, 2), + f"file not confidently identified ({prop.get('reason') or prop.get('action')})", + source="llm-lowconf", opf_title=opf_title, chosen_id=chosen) + + file_title = prop.get("chosen_title") + if req_hcid: + try: + same = _same_work(hardcover.book_by_id(chosen), hardcover.book_by_id(req_hcid)) + except Exception: + same = chosen == req_hcid + elif req_title: + same = matcher._title_match(file_title or "", req_title) + else: + return _result(UNVERIFIABLE, 0.0, "no requested identity supplied", source="no-request") + + verdict = MATCH if same else MISMATCH + rel = "same work as" if same else "different work from" + return _result(verdict, round(min(conf, 0.9), 2), + f"file adjudicated -> hc#{chosen} {file_title!r}; {rel} requested", + source="llm-adjudicated", file_hcid=chosen, file_title=file_title) diff --git a/tests/test_epub.py b/tests/test_epub.py index fb74a3a..e83f783 100644 --- a/tests/test_epub.py +++ b/tests/test_epub.py @@ -79,6 +79,7 @@ def test_colophon_text_found(self): def test_metadata_fields(self): r = epub.inspect(self._epub(['urn:isbn:9781466849358'])) self.assertEqual(r["opf_title"], "Lock In") + self.assertEqual(r["opf_author"], "John Scalzi") self.assertEqual(r["publisher"], "Tor") self.assertEqual(r["year"], "2014") diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..112c8fc --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,134 @@ +"""verify() — work-level acquisition gate. Network-free (epub/hardcover mocked). +Run: python -m unittest -v tests.test_verify""" +import unittest +from unittest import mock + +from colophon import resolver, verify + + +def _book(hcid, title="T", canonical_id=None): + return {"hcid": hcid, "title": title, "canonical_id": canonical_id, "authors": []} + + +def _run(requested, sig, isbn_map=None, by_id=None): + isbn_map, by_id = isbn_map or {}, by_id or {} + with mock.patch.object(verify.epub, "inspect", return_value=sig), \ + mock.patch.object(verify.hardcover, "book_by_isbn", side_effect=lambda i: isbn_map.get(i)), \ + mock.patch.object(verify.hardcover, "book_by_id", side_effect=lambda h: by_id.get(str(h))): + return verify.verify(requested, "x.epub") + + +def _run_llm(requested, sig, resolve_ret, by_id=None): + """No-ISBN path: book_by_isbn resolves nothing, resolver.resolve is stubbed.""" + by_id = by_id or {} + with mock.patch.object(verify.epub, "inspect", return_value=sig), \ + mock.patch.object(verify.hardcover, "book_by_isbn", return_value=None), \ + mock.patch.object(verify.hardcover, "book_by_id", side_effect=lambda h: by_id.get(str(h))), \ + mock.patch.object(resolver, "resolve", return_value=resolve_ret): + return verify.verify(requested, "x.epub") + + +class Verify(unittest.TestCase): + def test_match_by_id(self): + # the live Endymion case: file ISBN -> the requested work id + r = _run({"hcid": "438682"}, {"opf_isbns": ["9780307781925"]}, + isbn_map={"9780307781925": _book("438682", "The Rise of Endymion")}, + by_id={"438682": _book("438682", "The Rise of Endymion")}) + self.assertEqual(r["verdict"], verify.MATCH) + self.assertEqual(r["source"], "isbn-id") + + def test_match_via_canonical(self): + # file resolves to a duplicate record whose canonical IS the requested work + r = _run({"hcid": "100"}, {"opf_isbns": ["978"]}, + isbn_map={"978": _book("200", "T", canonical_id="100")}, + by_id={"100": _book("100", "T")}) + self.assertEqual(r["verdict"], verify.MATCH) + + def test_mismatch_box_set(self): + # requested a single book; the file embeds the omnibus ISBN -> different work + r = _run({"hcid": "438682"}, {"opf_isbns": ["9999"]}, + isbn_map={"9999": _book("555", "Hyperion Cantos 01-04")}, + by_id={"438682": _book("438682", "The Rise of Endymion")}) + self.assertEqual(r["verdict"], verify.MISMATCH) + self.assertEqual(r["file_hcid"], "555") + + def test_unverifiable_no_file_signal(self): + r = _run({"hcid": "1"}, None) # epub.inspect returns None (non-EPUB / unreadable) + self.assertEqual(r["verdict"], verify.UNVERIFIABLE) + self.assertEqual(r["source"], "no-file-signal") + + def test_unverifiable_isbn_unresolved_no_title(self): + # ISBN doesn't resolve and the file has no title to adjudicate on → held, no LLM + r = _run({"hcid": "1"}, {"opf_isbns": ["nope"]}, isbn_map={}) + self.assertEqual(r["verdict"], verify.UNVERIFIABLE) + self.assertEqual(r["source"], "no-signal") + + def test_llm_match(self): + sig = {"opf_isbns": [], "opf_title": "The Rise of Endymion", "opf_author": "Dan Simmons"} + prop = {"action": "propose", "chosen_id": "438682", + "chosen_title": "The Rise of Endymion", "confidence": 0.95} + r = _run_llm({"hcid": "438682"}, sig, prop, + by_id={"438682": _book("438682", "The Rise of Endymion")}) + self.assertEqual(r["verdict"], verify.MATCH) + self.assertEqual(r["source"], "llm-adjudicated") + self.assertLessEqual(r["confidence"], 0.9) # capped below the deterministic 0.97 + + def test_llm_mismatch_summary(self): + # the "Summary of Hooked" trap: a confident but different work + sig = {"opf_isbns": [], "opf_title": "Summary of Hooked", "opf_author": "X"} + prop = {"action": "propose", "chosen_id": "888238", + "chosen_title": "Summary of Hooked", "confidence": 0.92} + r = _run_llm({"hcid": "111"}, sig, prop, + by_id={"888238": _book("888238"), "111": _book("111")}) + self.assertEqual(r["verdict"], verify.MISMATCH) + + def test_llm_lowconf_is_unverifiable(self): + sig = {"opf_isbns": [], "opf_title": "Ambiguous", "opf_author": ""} + prop = {"action": "propose", "chosen_id": "1", "chosen_title": "Ambiguous", "confidence": 0.6} + r = _run_llm({"hcid": "1"}, sig, prop, by_id={"1": _book("1")}) + self.assertEqual(r["verdict"], verify.UNVERIFIABLE) + self.assertEqual(r["source"], "llm-lowconf") + + def test_llm_nomatch_is_unverifiable(self): + r = _run_llm({"hcid": "1"}, {"opf_isbns": [], "opf_title": "Obscure Indie", "opf_author": ""}, + {"action": "none", "reason": "no candidates"}) + self.assertEqual(r["verdict"], verify.UNVERIFIABLE) + + def test_llm_error_is_unverifiable(self): + sig = {"opf_isbns": [], "opf_title": "T", "opf_author": ""} + with mock.patch.object(verify.epub, "inspect", return_value=sig), \ + mock.patch.object(verify.hardcover, "book_by_isbn", return_value=None), \ + mock.patch.object(resolver, "resolve", side_effect=RuntimeError("api down")): + r = verify.verify({"hcid": "1"}, "x.epub") + self.assertEqual(r["verdict"], verify.UNVERIFIABLE) + self.assertEqual(r["source"], "llm-error") + + def test_no_title_short_circuits_before_llm(self): + with mock.patch.object(verify.epub, "inspect", return_value={"opf_isbns": []}), \ + mock.patch.object(verify.hardcover, "book_by_isbn", return_value=None), \ + mock.patch.object(resolver, "resolve") as res: + r = verify.verify({"hcid": "1"}, "x.epub") + res.assert_not_called() + self.assertEqual(r["source"], "no-signal") + + def test_title_fallback_match(self): + r = _run({"title": "The Rise of Endymion"}, {"opf_isbns": ["978"]}, + isbn_map={"978": _book("438682", "The Rise of Endymion")}) + self.assertEqual(r["verdict"], verify.MATCH) + self.assertEqual(r["source"], "isbn-title") + + def test_title_fallback_mismatch(self): + r = _run({"title": "Some Other Book"}, {"opf_isbns": ["978"]}, + isbn_map={"978": _book("438682", "The Rise of Endymion")}) + self.assertEqual(r["verdict"], verify.MISMATCH) + + def test_isbn_lookup_raising_is_swallowed(self): + # a flaky hardcover call must not crash the gate — it falls through to unverifiable + with mock.patch.object(verify.epub, "inspect", return_value={"opf_isbns": ["x"]}), \ + mock.patch.object(verify.hardcover, "book_by_isbn", side_effect=RuntimeError("429")): + r = verify.verify({"hcid": "1"}, "x.epub") + self.assertEqual(r["verdict"], verify.UNVERIFIABLE) + + +if __name__ == "__main__": + unittest.main() From eb3e75d8527767386e4ec76880c159f38a95810a Mon Sep 17 00:00:00 2001 From: vidaks <39301796+vidaks@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:05:19 +0200 Subject: [PATCH 2/3] feat: grimmory book delete for the acquisition verify gate (enforce) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan-22 gate's enforce mode removes a confirmed wrong-work grab. Adds: - Grimmory.delete_books(ids) — DELETE /api/v1/books {"ids":[…]} (drops the catalog record + covers; grimmory leaves the file, the caller removes it). - book_ids_by_filename(name) — resolve a just-landed grab to its record(s) by exact book_file.file_name (SQL-escaped), so the gate can target it. Returns a list; the caller refuses to delete on a >1 (filename-collision) result. --- colophon/grimmory.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/colophon/grimmory.py b/colophon/grimmory.py index 29f59a5..14c73f4 100644 --- a/colophon/grimmory.py +++ b/colophon/grimmory.py @@ -114,6 +114,19 @@ def refresh(self, book_ids, refresh_covers=True, replace_mode="REPLACE_ALL"): raise GrimmoryError(f"refresh task start returned {st}: {resp[:200]}") return resp + def delete_books(self, book_ids): + """Hard-delete catalog records by id (DELETE /api/v1/books {"ids":[…]}). + Removes the grimmory record + covers; the book FILE on disk is NOT removed + by grimmory (its deletes are file-safe) — the caller deletes the file. Used + only by the plan-22 acquisition gate to drop a confirmed wrong-work grab.""" + ids = [int(b) for b in book_ids] + if not ids: + return None + st, resp = self._call("DELETE", "/books", {"ids": ids}) + if st not in (200, 204): + raise GrimmoryError(f"DELETE /books {ids} returned {st}: {resp[:200]}") + return resp + # --- read-only snapshot ------------------------------------------------------ SNAPSHOT_COLS = [ @@ -136,6 +149,16 @@ def _db(sql): return r.stdout +def book_ids_by_filename(file_name): + """book_id(s) whose `book_file.file_name` matches exactly — read-only. Lets the + acquisition gate resolve a just-landed grab to its grimmory record(s) before + deleting it. Single quotes in the name are SQL-escaped (titles like + "Salvation's Child").""" + safe = (file_name or "").replace("'", "''") + out = _db(f"SELECT book_id FROM book_file WHERE file_name='{safe}';").strip() + return [int(x) for x in out.split() if x.strip().isdigit()] + + def snapshot(book_id): """Current metadata for a book as a dict, or None if it doesn't exist.""" bid = int(book_id) From 645b5c7d0a02a6af0a62b2bb6b08a993a877931c Mon Sep 17 00:00:00 2001 From: vidaks <39301796+vidaks@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:25:17 +0200 Subject: [PATCH 3/3] fix: grimmory book delete is a query param (?ids=), not a JSON body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete_books sent DELETE /books with a {"ids":[…]} body, which grimmory rejects ("Required request parameter 'ids' for method parameter type Set is not present") and surfaces as a generic 500 — so every enforce deletion would have silently failed. `ids` is a @RequestParam Set; the real call is DELETE /api/v1/books?ids=. Verified end-to-end against a disposable throwaway record (HTTP 200 {"deleted":[…]}, record + file gone). grimmory removes the files too, so the worker's unlink is now a fallback for failedFileDeletions. --- colophon/grimmory.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/colophon/grimmory.py b/colophon/grimmory.py index 14c73f4..9b6b295 100644 --- a/colophon/grimmory.py +++ b/colophon/grimmory.py @@ -115,16 +115,17 @@ def refresh(self, book_ids, refresh_covers=True, replace_mode="REPLACE_ALL"): return resp def delete_books(self, book_ids): - """Hard-delete catalog records by id (DELETE /api/v1/books {"ids":[…]}). - Removes the grimmory record + covers; the book FILE on disk is NOT removed - by grimmory (its deletes are file-safe) — the caller deletes the file. Used - only by the plan-22 acquisition gate to drop a confirmed wrong-work grab.""" + """Hard-delete books by id: DELETE /api/v1/books?ids=. `ids` is a + @RequestParam Set — a QUERY parameter, NOT a JSON body (a body 500s; + confirmed against a live throwaway record). grimmory removes the record AND + its files; the response reports any `failedFileDeletions`, for which the + caller's own file unlink is the fallback. Used only by the plan-22 gate.""" ids = [int(b) for b in book_ids] if not ids: return None - st, resp = self._call("DELETE", "/books", {"ids": ids}) + st, resp = self._call("DELETE", "/books?ids=" + ",".join(str(i) for i in ids), None) if st not in (200, 204): - raise GrimmoryError(f"DELETE /books {ids} returned {st}: {resp[:200]}") + raise GrimmoryError(f"DELETE /books?ids={ids} returned {st}: {resp[:200]}") return resp