Procházet zdrojové kódy

Archives: each print's outcome question is its own run's (archive redesign P1b)

- The outcome prompt is asked and its one-tap token minted on the run
  that ended; plates of one archive each have their own question
- Links, Telegram reactions, the plate-clear default, the dialog and the
  printer card answer the run they ask about; the archive shows its
  latest run's verdict. Links sent before still answer the latest run
- The archive edit takes a run_id for a run's verdict, status and
  failure reason, and GET /archives/{id}?run_id= shows that run; both
  only for runs on printers in the caller's scope
- Each printer card shows its printer's latest run, the deep link and
  the WebSocket prompt carry the run, and the dialog answers it
maziggy před 12 hodinami
rodič
revize
809eaf4f2d

+ 0 - 16
.basedpyright/baseline.json

@@ -27,22 +27,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 23,
-                    "endColumn": 60,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 23,
-                    "endColumn": 29,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportOptionalMemberAccess",
                 "range": {

+ 176 - 69
backend/app/api/routes/archives.py

@@ -32,6 +32,7 @@ from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.spool_usage_history import SpoolUsageHistory
@@ -46,8 +47,6 @@ from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.print_confirmation import (
     is_one_tap_request,
     is_unattended_fetch,
-    retire_confirm_token,
-    stamp_verdict,
 )
 from backend.app.services.print_cost_estimate import default_cost_per_kg as _default_cost_per_kg, price_one_print
 from backend.app.services.print_storage import (
@@ -342,6 +341,34 @@ async def _load_run_aggregates(db: AsyncSession, archive_ids: list[int]) -> dict
     return aggregates
 
 
+# What PATCH with a run_id may change: one run's outcome.
+_RUN_OUTCOME_FIELDS = frozenset({"user_verdict", "failure_reason", "status"})
+
+
+def _with_run_outcome(data: dict, run: PrintLogEntry) -> dict:
+    """An archive response showing one of its runs: that print's printer,
+    outcome, times, plate and verdict instead of the latest run's."""
+    data.update(
+        {
+            "run_id": run.id,
+            "printer_id": run.printer_id if run.printer_id is not None else data["printer_id"],
+            "status": run.status,
+            "started_at": run.started_at,
+            "completed_at": run.completed_at,
+            "plate_id": run.plate_id if run.plate_id is not None else data["plate_id"],
+            "failure_reason": run.failure_reason,
+            "user_verdict": run.user_verdict,
+            "user_verdict_source": run.user_verdict_source,
+            "user_verdict_at": run.user_verdict_at,
+            # A run logged before runs carried the opt-in asks as its archive.
+            "confirm_requested": bool(run.confirm_requested)
+            if run.confirm_requested is not None
+            else data["confirm_requested"],
+        }
+    )
+    return data
+
+
 def archive_to_response(
     archive: PrintArchive,
     duplicates: list[dict] | None = None,
@@ -577,21 +604,67 @@ async def list_last_archive_per_printer(
     ),
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """The most recent archive of every printer, for the printer cards.
-
-    One query for the whole Printers page instead of one list request per card.
-    Leaves out the duplicate detection the full listing does: the card shows a
-    name and an outcome prompt, and on a farm that scan ran once per printer
-    on every page load.
+    """The latest print of every printer, for the printer cards: its archive
+    as that run printed (``run_id``, its outcome and verdict).
+
+    By the printer's latest run: plates of one archive on two printers are
+    each their printer's last print, while the archive names only the one
+    started last. A printer with no run logged gets its latest archive, as
+    before runs. One query for the whole Printers page instead of one list
+    request per card. Leaves out the duplicate detection the full listing
+    does: the card shows a name and an outcome prompt, and on a farm that scan
+    ran once per printer on every page load.
     """
+    from sqlalchemy.orm import selectinload
+
+    from backend.app.models.print_log import INCLUDE_OPEN_RUNS
+
     user, can_read_all = auth_result
-    filters = [PrintArchive.deleted_at.is_(None), PrintArchive.printer_id.isnot(None)]
+    filters: list[ColumnElement[bool]] = [PrintArchive.deleted_at.is_(None)]
     if user is not None and not can_read_all:
         filters.append(PrintArchive.created_by_id == user.id)
-    # Only printers the caller may see (#1727)
+
+    run_filters: list[ColumnElement[bool]] = [PrintLogEntry.printer_id.isnot(None)]
+    # Only printers the caller may see (#1727): the run's, and the archive's
+    # too, which the archive page and its edit go by.
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        run_filters.append(clause)
     if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
-        filters.append(clause)
+        run_filters.append(clause)
+    ranked_runs = (
+        select(
+            PrintLogEntry.id,
+            func.row_number()
+            .over(
+                partition_by=PrintLogEntry.printer_id,
+                # As the archives below: a run without a start (logged
+                # without one) by when it was logged.
+                order_by=(
+                    func.coalesce(PrintLogEntry.started_at, PrintLogEntry.created_at).desc(),
+                    PrintLogEntry.id.desc(),
+                ),
+            )
+            .label("rn"),
+        )
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
+        .where(*run_filters, *filters)
+        .subquery()
+    )
+    # The print running now counts: its card shows it, not the one before.
+    runs = (
+        await db.scalars(
+            select(PrintLogEntry)
+            .where(PrintLogEntry.id.in_(select(ranked_runs.c.id).where(ranked_runs.c.rn == 1)))
+            .execution_options(**{INCLUDE_OPEN_RUNS: True})
+        )
+    ).all()
+    printers_with_runs = {r.printer_id for r in runs}
 
+    archive_filters: list[ColumnElement[bool]] = [*filters, PrintArchive.printer_id.isnot(None)]
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        archive_filters.append(clause)
+    if printers_with_runs:
+        archive_filters.append(PrintArchive.printer_id.not_in(printers_with_runs))
     ranked = (
         select(
             PrintArchive.id,
@@ -608,21 +681,25 @@ async def list_last_archive_per_printer(
             )
             .label("rn"),
         )
-        .where(*filters)
+        .where(*archive_filters)
         .subquery()
     )
-
-    from sqlalchemy.orm import selectinload
-
+    without_runs = set((await db.scalars(select(ranked.c.id).where(ranked.c.rn == 1))).all())
+    archive_ids = {r.archive_id for r in runs} | without_runs
     result = await db.execute(
         select(PrintArchive)
         .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
-        .where(PrintArchive.id.in_(select(ranked.c.id).where(ranked.c.rn == 1)))
-        .order_by(PrintArchive.printer_id)
+        .where(PrintArchive.id.in_(archive_ids))
     )
-    archives = list(result.scalars().all())
-    run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
-    return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in archives]
+    archives = {a.id: a for a in result.scalars().all()}
+    run_aggregates = await _load_run_aggregates(db, list(archives))
+
+    def response(archive: PrintArchive) -> dict:
+        return archive_to_response(archive, run_aggregate=run_aggregates.get(archive.id))
+
+    cards = [_with_run_outcome(response(archives[r.archive_id]), r) for r in runs if r.archive_id in archives]
+    cards += [response(archives[i]) for i in without_runs if i in archives]
+    return sorted(cards, key=lambda card: card["printer_id"])
 
 
 @router.get("/no-3mf-warning")
@@ -1810,11 +1887,17 @@ async def get_archive(
         )
     ),
     printer_scope: PrinterScope = RequestPrinterScope,
+    run_id: int | None = Query(None, description="Show this run's outcome instead of the latest run's"),
 ):
-    """Get a specific archive."""
+    """Get a specific archive. With ``run_id``, as that run of it printed."""
+    from backend.app.services.print_confirmation import run_of_archive
+
     user, can_read_all = auth_result
     service = ArchiveService(db)
     archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
+    run = await run_of_archive(db, archive.id, run_id) if run_id is not None else None
+    if run_id is not None and (run is None or not printer_scope.allows(run.printer_id)):
+        raise HTTPException(404, "Run not found")
 
     # Find duplicates
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
@@ -1826,7 +1909,8 @@ async def get_archive(
         visible=_visible_archive_clauses(user, can_read_all, printer_scope),
     )
     run_aggregates = await _load_run_aggregates(db, [archive.id])
-    return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
+    data = archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
+    return _with_run_outcome(data, run) if run is not None else data
 
 
 @router.get("/{archive_id}/delete-impact")
@@ -1988,6 +2072,23 @@ async def update_archive(
             raise HTTPException(400, "Project not found")
     # #1898: how the verdict arrived is recorded with it, never on its own.
     verdict_source = update_payload.pop("user_verdict_source", None)
+    verdict_given = "user_verdict" in update_payload
+    new_verdict = update_payload.pop("user_verdict", None)
+    # The outcome of one run of the archive (a plate printed on its own): that
+    # run's, and the archive's only when it is the archive's latest.
+    from backend.app.services.print_confirmation import latest_run, record_verdict, run_of_archive
+
+    target_run = None
+    run_outcome: dict = {}
+    if (run_id := update_payload.pop("run_id", None)) is not None:
+        if set(update_payload) - _RUN_OUTCOME_FIELDS:
+            raise HTTPException(400, "run_id applies to a run's outcome only: its verdict, status and failure reason")
+        target_run = await run_of_archive(db, archive_id, run_id)
+        # A run printed on a printer outside the caller's scope is not theirs
+        # to judge, as on the print log (#1727).
+        if target_run is None or not printer_scope.allows(target_run.printer_id):
+            raise HTTPException(404, "Run not found")
+        run_outcome = {field: update_payload.pop(field) for field in list(update_payload)}
     for field, value in update_payload.items():
         setattr(archive, field, value)
 
@@ -2002,18 +2103,6 @@ async def update_archive(
         archive.cost = await _one_print_cost(db, archive, previous_grams=previous_filament_grams)
         repriced = True
 
-    # #1898: a landed verdict retires the one-tap capability token from the
-    # push notification — the links stop changing anything once someone
-    # decided, and report the recorded verdict instead. Clearing the verdict
-    # drops the provenance but leaves the token spent: it was used.
-    if "user_verdict" in update_payload:
-        if update_payload["user_verdict"] is None:
-            archive.user_verdict_source = None
-            archive.user_verdict_at = None
-        else:
-            stamp_verdict(archive, verdict_source or "api")
-            retire_confirm_token(archive)
-
     # #1444: Mirror per-run classification fields to the most recent
     # PrintLogEntry for this archive. PrintLogEntry.failure_reason is captured
     # once at print-completion time from archive.failure_reason — which is
@@ -2031,7 +2120,7 @@ async def update_archive(
     # print that archived without its 3MF, no figure at all.
     # While the archive is being reprinted its newest entry is the previous
     # run's, whose outcome this edit is not about.
-    mirror_fields = {"failure_reason", "status", "filament_used_grams", "user_verdict"}
+    mirror_fields = {"failure_reason", "status", "filament_used_grams"}
     to_mirror = {k: v for k, v in update_payload.items() if k in mirror_fields}
     if to_mirror and previous_status != "printing":
         from backend.app.models.print_log import PrintLogEntry
@@ -2070,6 +2159,26 @@ async def update_archive(
 
         await mirror_archive_cost(db, archive, previous_cost)
 
+    if target_run is not None:
+        for field, value in run_outcome.items():
+            setattr(target_run, field, value)
+    # #1898: the verdict lands on its run (the one asked about, else the
+    # archive's latest; not while the archive is being reprinted, its latest
+    # being the previous print) and retires that run's one-tap token: the links
+    # stop changing anything once someone decided, and report the recorded
+    # verdict instead. Clearing the verdict drops the provenance but leaves the
+    # token spent: it was used.
+    if verdict_given:
+        verdict_run = target_run
+        if verdict_run is None and previous_status != "printing":
+            verdict_run = await latest_run(db, archive_id)
+        await record_verdict(db, archive, new_verdict, source=verdict_source or "api", run=verdict_run)
+    if target_run is not None:
+        from backend.app.services.print_log import mirror_latest_run
+
+        await db.flush()
+        await mirror_latest_run(db, archive_id)
+
     await db.commit()
 
     # Re-fetch with relationships loaded after commit
@@ -3759,26 +3868,29 @@ def _confirm_page(glyph: str, heading: str, body: str) -> str:
     )
 
 
-async def _render_already_answered_page(db: AsyncSession, archive: PrintArchive) -> str:
+async def _render_already_answered_page(
+    db: AsyncSession, archive: PrintArchive, run: PrintLogEntry | None = None
+) -> str:
     """Explain a spent one-tap link instead of calling it invalid.
 
     The plate-clear default, the app and the other link all answer the same
     prompt, so the button in a push notification is routinely tapped after the
     question is settled. Report the verdict on file, when and how it landed,
-    and where to change it.
+    and where to change it: of the run the link asked about, when it did.
     """
     from backend.app.api.routes.settings import get_external_base_url
 
     name = html_escape(archive.print_name or archive.filename or "")
+    answered = run if run is not None else archive
     # The verdict's own timestamp, not the moment the token was spent: a
     # verdict changed later in the app would otherwise be dated by the older
     # event. Pre-#1898 rows have neither, and then the page omits the date.
-    used_at = archive.user_verdict_at or archive.confirm_token_used_at
+    used_at = answered.user_verdict_at or answered.confirm_token_used_at
     if used_at is not None and used_at.tzinfo is not None:
         used_at = used_at.astimezone(timezone.utc)
     when = used_at.strftime("%Y-%m-%d %H:%M UTC") if used_at else None
-    how = _VERDICT_SOURCE_PHRASES.get(archive.user_verdict_source or "")
-    label = _VERDICT_LABELS.get(archive.user_verdict or "")
+    how = _VERDICT_SOURCE_PHRASES.get(answered.user_verdict_source or "")
+    label = _VERDICT_LABELS.get(answered.user_verdict or "")
 
     if label:
         recorded = f"Recorded as <strong>{label}</strong>"
@@ -3792,8 +3904,9 @@ async def _render_already_answered_page(db: AsyncSession, archive: PrintArchive)
         recorded = "This prompt was already answered and the verdict has since been cleared."
 
     base = await get_external_base_url(db)
-    link = html_escape(f"{base}/archives?confirm={archive.id}", quote=True)
-    glyph = "&#10003;" if archive.user_verdict == "good" else "&#10007;" if archive.user_verdict else "&#8505;"
+    run_param = f"&run={run.id}" if run is not None else ""
+    link = html_escape(f"{base}/archives?confirm={archive.id}{run_param}", quote=True)
+    glyph = "&#10003;" if answered.user_verdict == "good" else "&#10007;" if answered.user_verdict else "&#8505;"
     return _confirm_page(
         glyph,
         "Already answered",
@@ -3868,18 +3981,21 @@ def _confirm_response(html: str):
     return HTMLResponse(html, headers={"Cache-Control": "no-store"})
 
 
-async def _load_confirmable_archive(db: AsyncSession, token: str, verdict: str) -> PrintArchive:
-    """Resolve a one-tap capability token, or raise the route's 400/404."""
+async def _load_confirmable_archive(
+    db: AsyncSession, token: str, verdict: str
+) -> tuple[PrintArchive, PrintLogEntry | None]:
+    """Resolve a one-tap capability token, or raise the route's 400/404: the
+    archive with the run the token asks about (None for an archive's own
+    token, minted before runs had theirs)."""
+    from backend.app.services.print_confirmation import confirm_token_holder
+
     if verdict not in ("good", "reject"):
         raise HTTPException(400, "Verdict must be 'good' or 'reject'")
 
-    result = await db.execute(
-        select(PrintArchive).where(PrintArchive.confirm_token == token, PrintArchive.confirm_token.isnot(None))
-    )
-    archive = result.scalar_one_or_none()
-    if not archive:
+    found = await confirm_token_holder(db, token)
+    if found is None:
         raise HTTPException(404, "Confirmation link is invalid or was already used")
-    return archive
+    return found
 
 
 @router.get("/confirm/{token}/{verdict}")
@@ -3908,12 +4024,12 @@ async def confirm_outcome_page(
     including a scanner that runs JavaScript -- the marker is on the buttons,
     not in the message text a scanner reads.
     """
-    archive = await _load_confirmable_archive(db, token, verdict)
+    archive, run = await _load_confirmable_archive(db, token, verdict)
 
-    if archive.confirm_token_used_at is not None:
+    if (run or archive).confirm_token_used_at is not None:
         # Answered already — by hand, by the other link, by the plate-clear
         # default or by a reaction. Report what is on file and change nothing.
-        return _confirm_response(await _render_already_answered_page(db, archive))
+        return _confirm_response(await _render_already_answered_page(db, archive, run))
 
     return _confirm_response(_render_confirm_prompt_page(request, archive, verdict))
 
@@ -3939,25 +4055,16 @@ async def confirm_outcome_by_token(
     button, which performs its own request from the phone and is configured
     with ``method=POST``. Returns a small HTML page for the phone browser.
     """
-    archive = await _load_confirmable_archive(db, token, verdict)
-
-    if archive.confirm_token_used_at is not None:
-        return _confirm_response(await _render_already_answered_page(db, archive))
+    from backend.app.services.print_confirmation import latest_run, record_verdict
 
-    archive.user_verdict = verdict
-    stamp_verdict(archive, "link")
-    retire_confirm_token(archive)
+    archive, run = await _load_confirmable_archive(db, token, verdict)
 
-    # Same mirror as the PATCH route (#1444): verdict-aware statistics read
-    # print_log_entries, so the latest run must carry the verdict too.
-    from backend.app.models.print_log import PrintLogEntry
-
-    latest_entry = await db.scalar(
-        select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
-    )
-    if latest_entry is not None:
-        latest_entry.user_verdict = verdict
+    if (run or archive).confirm_token_used_at is not None:
+        return _confirm_response(await _render_already_answered_page(db, archive, run))
 
+    # The run the link asks about; an archive's own token, sent before runs
+    # had theirs, answers its latest. Verdict-aware statistics read the run.
+    await record_verdict(db, archive, verdict, source="link", run=run or await latest_run(db, archive.id))
     await db.commit()
 
     label = "Good part" if verdict == "good" else "Rejected"

+ 31 - 14
backend/app/main.py

@@ -4507,6 +4507,7 @@ async def _dispatch_outcome_confirmation_safely(
     data: dict,
     archive_id: int,
     archive_data: dict | None = None,
+    run_id: int | None = None,
 ) -> None:
     """``dispatch_outcome_confirmation`` for a caller that has more to do on ``db``.
 
@@ -4517,7 +4518,7 @@ async def _dispatch_outcome_confirmation_safely(
     the reason the email never goes out.
     """
     try:
-        await dispatch_outcome_confirmation(db, printer_id, printer_name, data, archive_id, archive_data)
+        await dispatch_outcome_confirmation(db, printer_id, printer_name, data, archive_id, archive_data, run_id=run_id)
     except Exception as e:
         logging.getLogger(__name__).error("[NOTIFY-BG] Outcome-confirmation dispatch failed: %s", e, exc_info=True)
         try:
@@ -4549,14 +4550,18 @@ async def dispatch_outcome_confirmation(
     data: dict,
     archive_id: int,
     archive_data: dict | None = None,
+    *,
+    run_id: int | None = None,
 ) -> bool:
-    """Emit the post-print outcome prompt for a completed archive (#1898).
+    """Emit the post-print outcome prompt for a completed print (#1898).
 
-    Gated on the archive itself: ``confirm_requested`` is the opt-in (from the
-    queue item, or from ``confirm_outcome_external_prints`` for a print
-    Bambuddy did not start) and ``user_verdict`` being unset is what makes the
-    question still open. Mints the per-archive capability token the one-tap
-    verdict links carry.
+    Gated on the run that ended (``run_id``): ``confirm_requested`` is the
+    opt-in (from the queue item, or from ``confirm_outcome_external_prints``
+    for a print Bambuddy did not start, copied onto the run at its start) and
+    ``user_verdict`` being unset is what makes the question still open. Mints
+    the run's capability token the one-tap verdict links carry: plates of one
+    archive each have their own question. Without a run, the archive's, as
+    before runs.
 
     Lives here rather than inline in ``on_print_complete``'s notification task
     because that task swallows every exception: extracted, the gate and the two
@@ -4567,9 +4572,19 @@ async def dispatch_outcome_confirmation(
     """
     logger = logging.getLogger(__name__)
     from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_log import PrintLogEntry
 
     confirm_archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
-    if not (confirm_archive and confirm_archive.confirm_requested and confirm_archive.user_verdict is None):
+    if confirm_archive is None:
+        return False
+    run = await db.get(PrintLogEntry, run_id) if run_id is not None else None
+    if run is not None and run.archive_id != archive_id:
+        run = None
+    # The run carries the opt-in from its start; one logged before it did
+    # (None) asks as its archive does.
+    asks = confirm_archive if run is None or run.confirm_requested is None else run
+    holder = run if run is not None else confirm_archive
+    if not (asks.confirm_requested and holder.user_verdict is None):
         return False
 
     import secrets as _secrets
@@ -4578,9 +4593,9 @@ async def dispatch_outcome_confirmation(
     # "has a token" no longer means "answerable". A prompt going out now needs a
     # live one: mint a new token whenever the stored one is already spent, or
     # every button in the new message would land on the already-answered page.
-    if not confirm_archive.confirm_token or confirm_archive.confirm_token_used_at is not None:
-        confirm_archive.confirm_token = _secrets.token_urlsafe(32)
-        confirm_archive.confirm_token_used_at = None
+    if not holder.confirm_token or holder.confirm_token_used_at is not None:
+        holder.confirm_token = _secrets.token_urlsafe(32)
+        holder.confirm_token_used_at = None
         await db.commit()
 
     from backend.app.api.routes.settings import get_external_base_url, get_setting
@@ -4597,15 +4612,16 @@ async def dispatch_outcome_confirmation(
             "Set Settings → External URL so they resolve away from this host.",
             base,
         )
-    token = confirm_archive.confirm_token
+    token = holder.confirm_token
     good_url = f"{base}/api/v1/archives/confirm/{token}/good"
     reject_url = f"{base}/api/v1/archives/confirm/{token}/reject"
-    confirm_url = f"{base}/archives?confirm={archive_id}"
+    confirm_url = f"{base}/archives?confirm={archive_id}" + (f"&run={run.id}" if run is not None else "")
 
     await ws_manager.send_print_confirm_request(
         printer_id,
         {
             "archive_id": archive_id,
+            "run_id": run.id if run is not None else None,
             "print_name": confirm_archive.print_name or confirm_archive.filename,
         },
         user_ids=await _outcome_answerers(db, confirm_archive.created_by_id),
@@ -4620,6 +4636,7 @@ async def dispatch_outcome_confirmation(
         reject_url=reject_url,
         confirm_url=confirm_url,
         archive_id=archive_id,
+        run_id=run.id if run is not None else None,
     )
     return True
 
@@ -10238,7 +10255,7 @@ async def _close_out_print(printer_id: int, data: dict):
                 # along with the prompt.
                 if print_status == "completed" and archive_id:
                     await _dispatch_outcome_confirmation_safely(
-                        db, printer_id, printer_name, data, archive_id, archive_data
+                        db, printer_id, printer_name, data, archive_id, archive_data, run_id=completed_run_id
                     )
 
                 # Send user-specific email notification

+ 7 - 0
backend/app/schemas/archive.py

@@ -50,6 +50,10 @@ class ArchiveUpdate(IgnoresNullForRequiredColumns, ArchiveBase):
     # totals: 100 kg is far past any single print and well short of a value
     # that would swamp a chart.
     filament_used_grams: Annotated[float | None, Field(ge=0, le=100_000)] = None
+    # The run (print log entry) a verdict, status or failure reason is for:
+    # plates of one archive are runs of their own. Omitted means the
+    # archive's latest run.
+    run_id: int | None = None
 
 
 class ArchiveDuplicate(BaseModel):
@@ -126,6 +130,9 @@ class ArchiveResponse(BaseModel):
     user_verdict_source: str | None = None
     user_verdict_at: datetime | None = None
     confirm_requested: bool = False
+    # The run (print log entry) whose outcome this response shows, when it
+    # shows one run of the archive rather than its latest (#1898).
+    run_id: int | None = None
 
     # Energy tracking
     energy_kwh: float | None = None

+ 23 - 3
backend/app/services/notification_service.py

@@ -878,6 +878,8 @@ class NotificationService:
         image_data: bytes | None,
         buttons: list[dict] | None,
         archive_id: int | None,
+        *,
+        run_id: int | None = None,
     ) -> tuple[bool, str]:
         """Deliver the outcome prompt the way the provider's verdict mode asks (#3046).
 
@@ -918,8 +920,20 @@ class NotificationService:
             # dispatch_outcome_confirmation mints a live token right before
             # sending. Without one the prompt's links are dead too, and a
             # reaction must not outlive them, so there is nothing to remember.
+            # The run's token when the prompt is for a run, the archive's when
+            # it was sent the way it was before runs had their own.
             archive = await db.get(PrintArchive, archive_id)
-            if archive is None or not archive.confirm_token or archive.confirm_token_used_at is not None:
+            holder = archive
+            if run_id is not None:
+                from backend.app.models.print_log import PrintLogEntry
+
+                holder = await db.get(PrintLogEntry, run_id)
+            if (
+                archive is None
+                or holder is None
+                or not holder.confirm_token
+                or holder.confirm_token_used_at is not None
+            ):
                 logger.warning("Outcome prompt for archive %s went out without a live confirm token", archive_id)
                 return ok, status
             db.add(
@@ -928,7 +942,7 @@ class NotificationService:
                     chat_id=str(((sent or {}).get("chat") or {}).get("id") or config.get("chat_id", "")).strip(),
                     message_id=message_id,
                     archive_id=archive_id,
-                    confirm_token=archive.confirm_token,
+                    confirm_token=holder.confirm_token,
                     has_caption=image_data is not None,
                     message_text=message,
                 )
@@ -1473,6 +1487,7 @@ class NotificationService:
                     # the message, or both. The link preview is off in all of
                     # them (see _send_telegram_confirm_request).
                     _archive_id = (variables or {}).get("archive_id")
+                    _run_id = (variables or {}).get("run_id")
                     return await self._send_telegram_confirm_request(
                         provider,
                         config,
@@ -1481,6 +1496,7 @@ class NotificationService:
                         image_data,
                         tg_buttons,
                         _archive_id if isinstance(_archive_id, int) else None,
+                        run_id=_run_id if isinstance(_run_id, int) else None,
                     )
                 # Every other event keeps Telegram's link preview, for the same
                 # reason as the Slack unfurl in _send_webhook: when the finish
@@ -1956,6 +1972,7 @@ class NotificationService:
         reject_url: str | None = None,
         confirm_url: str | None = None,
         archive_id: int | None = None,
+        run_id: int | None = None,
     ):
         """Ask for a post-print outcome verdict (#1898).
 
@@ -1964,7 +1981,8 @@ class NotificationService:
         feature. good_url / reject_url are the one-tap capability links
         (rendered as ntfy action buttons), confirm_url deep-links into the
         archive's confirmation dialog in the web UI. archive_id lets a Telegram
-        provider in reaction mode (#3046) tie the sent message to the archive.
+        provider in reaction mode (#3046) tie the sent message to the archive,
+        and run_id to the run whose token the links carry.
         """
         providers = await self._get_providers_for_event(db, "on_print_confirm_request", printer_id)
         if not providers:
@@ -1985,6 +2003,8 @@ class NotificationService:
             variables["confirm_url"] = confirm_url
         if archive_id is not None:
             variables["archive_id"] = archive_id
+        if run_id is not None:
+            variables["run_id"] = run_id
 
         image_data = None
         if archive_data:

+ 128 - 48
backend/app/services/print_confirmation.py

@@ -4,7 +4,7 @@ import logging
 from collections.abc import Mapping
 from datetime import datetime, timezone
 
-from sqlalchemy import select
+from sqlalchemy import and_, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.archive import PrintArchive
@@ -100,7 +100,7 @@ def is_one_tap_request(query_params: Mapping[str, str]) -> bool:
     return (query_params.get(ONE_TAP_PARAM) or "") == "1"
 
 
-def stamp_verdict(archive: PrintArchive, source: str) -> None:
+def stamp_verdict(archive: PrintArchive | PrintLogEntry, source: str) -> None:
     """Record a verdict's provenance and the moment it landed (#1898).
 
     Both fields move together on every verdict write, which is what keeps the
@@ -112,7 +112,7 @@ def stamp_verdict(archive: PrintArchive, source: str) -> None:
     archive.user_verdict_at = datetime.now(timezone.utc)
 
 
-def retire_confirm_token(archive: PrintArchive) -> None:
+def retire_confirm_token(archive: PrintArchive | PrintLogEntry) -> None:
     """Spend the one-tap capability token without destroying it.
 
     The token is still single-use: once ``confirm_token_used_at`` is stamped,
@@ -129,79 +129,159 @@ def retire_confirm_token(archive: PrintArchive) -> None:
 VERDICTS = ("good", "reject")
 
 
-async def apply_outcome_verdict(db: AsyncSession, archive: PrintArchive, verdict: str, *, source: str) -> bool:
-    """Record a verdict on an archive that has not been answered yet.
+async def latest_run(db: AsyncSession, archive_id: int) -> PrintLogEntry | None:
+    """The archive's latest finished run by its start: the one its card shows."""
+    return await db.scalar(
+        select(PrintLogEntry)
+        .where(PrintLogEntry.archive_id == archive_id)
+        .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
+        .limit(1)
+    )
+
+
+async def run_of_archive(db: AsyncSession, archive_id: int, run_id: int) -> PrintLogEntry | None:
+    """The archive's finished run ``run_id``, or None when it is not one."""
+    return await db.scalar(
+        select(PrintLogEntry).where(PrintLogEntry.id == run_id, PrintLogEntry.archive_id == archive_id)
+    )
+
+
+async def record_verdict(
+    db: AsyncSession,
+    archive: PrintArchive,
+    verdict: str | None,
+    *,
+    source: str | None,
+    run: PrintLogEntry | None,
+) -> None:
+    """Write a verdict, or clear it (``verdict`` None), on the run it answers.
+
+    Plates of one archive are runs of their own, each with its own question,
+    so the verdict is the run's: its value, provenance and its one-tap token
+    spent. The archive shows its latest run's, so it takes the verdict when
+    ``run`` is that run, or when there is no run (a print logged before runs).
+    Clearing drops the provenance but leaves the tokens spent: they were used.
+    Deliberately does NOT commit.
+    """
+    holders: list[PrintArchive | PrintLogEntry] = []
+    if run is not None:
+        holders.append(run)
+    if run is None or (latest := await latest_run(db, archive.id)) is None or latest.id == run.id:
+        holders.append(archive)
+    for holder in holders:
+        holder.user_verdict = verdict
+        if verdict is None:
+            holder.user_verdict_source = None
+            holder.user_verdict_at = None
+        else:
+            stamp_verdict(holder, source or "api")
+            retire_confirm_token(holder)
+
+
+async def apply_outcome_verdict(
+    db: AsyncSession,
+    archive: PrintArchive,
+    verdict: str,
+    *,
+    source: str,
+    run: PrintLogEntry | None = None,
+) -> bool:
+    """Record a verdict on a print that has not been answered yet.
 
     For verdict paths that arrive with nobody looking at the archive, like the
-    Telegram reaction poller (#3046). It writes what every verdict write in
-    this module writes: the archive's user_verdict with its provenance stamp,
-    the same value mirrored onto the latest PrintLogEntry (verdict-aware
-    statistics read the log, the #1444 mirror), and the one-tap capability
-    token spent.
-
-    First verdict wins. An archive that already carries one is left exactly
-    as it is and False is returned, so a late reaction cannot flip a decision
+    Telegram reaction poller (#3046). ``run`` is the run the prompt asked
+    about; without one, the archive's latest (a prompt sent before runs had
+    their own). Written by :func:`record_verdict`.
+
+    First verdict wins. A print that already carries one is left exactly as
+    it is and False is returned, so a late reaction cannot flip a decision
     somebody made in the meantime. Deliberately does NOT commit.
     """
     if verdict not in VERDICTS:
         raise ValueError(f"Verdict must be one of {VERDICTS}, got {verdict!r}")
     if source not in VERDICT_SOURCES:
         raise ValueError(f"Verdict source must be one of {VERDICT_SOURCES}, got {source!r}")
-    if archive.user_verdict is not None:
+    if run is None:
+        if archive.user_verdict is not None:
+            return False
+        run = await latest_run(db, archive.id)
+    elif run.user_verdict is not None:
         return False
 
-    archive.user_verdict = verdict
-    stamp_verdict(archive, source)
-    retire_confirm_token(archive)
-
-    latest_entry = await db.scalar(
-        select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
-    )
-    if latest_entry is not None:
-        latest_entry.user_verdict = verdict
+    await record_verdict(db, archive, verdict, source=source, run=run)
     return True
 
 
+async def confirm_token_holder(db: AsyncSession, token: str) -> tuple[PrintArchive, PrintLogEntry | None] | None:
+    """The print a one-tap capability token asks about: the run it was minted
+    for, with its archive, or an archive's own token (minted before runs had
+    theirs, answering its latest run). None for a token nobody holds."""
+    if not token:
+        return None
+    run = await db.scalar(select(PrintLogEntry).where(PrintLogEntry.confirm_token == token))
+    if run is not None:
+        archive = await db.get(PrintArchive, run.archive_id) if run.archive_id is not None else None
+        return (archive, run) if archive is not None else None
+    archive = await db.scalar(select(PrintArchive).where(PrintArchive.confirm_token == token))
+    return (archive, None) if archive is not None else None
+
+
 async def resolve_pending_confirmation_as_good(db: AsyncSession, printer_id: int) -> int | None:
-    """Mark the printer's latest pending-confirmation archive as good.
+    """Mark the printer's latest print awaiting its verdict as good.
 
     Backs the opt-in ``confirm_default_good_on_plate_clear`` setting: releasing
     the build plate is the moment the operator moves on to the next job, so an
     unanswered outcome prompt can default to "good part" right there instead of
-    lingering as unconfirmed. Only the LATEST pending archive is resolved — the
+    lingering as unconfirmed. Only the LATEST pending one is resolved -- the
     plate release refers to the print that just came off the plate, not to
-    older unanswered prompts.
+    older unanswered prompts. Found by the run's printer: a plate of the
+    archive printed since on another printer moved the archive there. A run
+    logged before runs carried the opt-in asks, and is answered, as its
+    archive is.
 
-    Mirrors the verdict onto the latest PrintLogEntry (the #1444 mirror) and
-    retires the one-tap capability token. Deliberately does NOT commit — both
-    callers (the clear-plate route and the queue dispatcher) manage their own
-    transaction.
+    Retires the one-tap capability token (:func:`record_verdict`). Deliberately
+    does NOT commit -- both callers (the clear-plate route and the queue
+    dispatcher) manage their own transaction.
 
     Returns the resolved archive id, or None when nothing was pending.
     """
-    archive = await db.scalar(
-        select(PrintArchive)
+    run = await db.scalar(
+        select(PrintLogEntry)
+        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
         .where(
-            PrintArchive.printer_id == printer_id,
-            PrintArchive.status == "completed",
-            PrintArchive.confirm_requested.is_(True),
-            PrintArchive.user_verdict.is_(None),
+            PrintLogEntry.printer_id == printer_id,
+            PrintLogEntry.status == "completed",
+            PrintLogEntry.user_verdict.is_(None),
+            or_(
+                PrintLogEntry.confirm_requested.is_(True),
+                and_(
+                    PrintLogEntry.confirm_requested.is_(None),
+                    PrintArchive.confirm_requested.is_(True),
+                    PrintArchive.user_verdict.is_(None),
+                ),
+            ),
         )
-        .order_by(PrintArchive.id.desc())
+        .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
         .limit(1)
     )
+    archive = await db.get(PrintArchive, run.archive_id) if run is not None else None
     if archive is None:
-        return None
-
-    archive.user_verdict = "good"
-    stamp_verdict(archive, "plate_clear")
-    retire_confirm_token(archive)
-
-    latest_entry = await db.scalar(
-        select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
-    )
-    if latest_entry is not None:
-        latest_entry.user_verdict = "good"
+        # A printer with no such run logged: its archive, as before runs.
+        archive = await db.scalar(
+            select(PrintArchive)
+            .where(
+                PrintArchive.printer_id == printer_id,
+                PrintArchive.status == "completed",
+                PrintArchive.confirm_requested.is_(True),
+                PrintArchive.user_verdict.is_(None),
+            )
+            .order_by(PrintArchive.id.desc())
+            .limit(1)
+        )
+        if archive is None:
+            return None
+        run = await latest_run(db, archive.id)
+    await record_verdict(db, archive, "good", source="plate_clear", run=run)
 
     logger.info("[#1898] Plate clear defaulted archive %s to 'good' (printer %s)", archive.id, printer_id)
     return archive.id

+ 11 - 1
backend/app/services/print_log.py

@@ -161,7 +161,7 @@ _MIRRORED_FROM_RUN = ("status", "started_at", "completed_at", "failure_reason")
 
 async def mirror_latest_run(db: AsyncSession, archive_id: int) -> None:
     """Give the archive its latest run's outcome, by start: status, times,
-    failure reason and plate. Only once none of its runs is printing: until
+    failure reason, plate and verdict. Only once none of its runs is printing: until
     the print's start, end, reconcile and timelapse look their print up by
     its run, they still go by the archive's printer, status and start, which
     a print running elsewhere must not move under them. While one prints,
@@ -191,6 +191,16 @@ async def mirror_latest_run(db: AsyncSession, archive_id: int) -> None:
         setattr(archive, name, getattr(latest, name))
     if latest.plate_id is not None:
         archive.plate_id = latest.plate_id
+    # Its outcome verdict too (#1898), once the run holds its own: it has one,
+    # or was opened at its start with its opt-in. A run logged before then
+    # may never have been given the archive's, which stays. Runs logged
+    # before they kept the verdict's provenance have only its value: the
+    # archive's own provenance stays with that same verdict.
+    owns_verdict = latest.user_verdict is not None or latest.confirm_requested is not None
+    if owns_verdict and (latest.user_verdict != archive.user_verdict or latest.user_verdict_source is not None):
+        archive.user_verdict = latest.user_verdict
+        archive.user_verdict_source = latest.user_verdict_source
+        archive.user_verdict_at = latest.user_verdict_at
 
 
 async def mirror_archive_cost(

+ 13 - 10
backend/app/services/telegram_reactions.py

@@ -25,8 +25,9 @@ from sqlalchemy import delete, select
 
 from backend.app.models.archive import PrintArchive
 from backend.app.models.notification import NotificationProvider, TelegramPendingVerdict
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.services.notification_service import _USER_AGENT, telegram_markdown_escape
-from backend.app.services.print_confirmation import apply_outcome_verdict
+from backend.app.services.print_confirmation import apply_outcome_verdict, confirm_token_holder
 from backend.app.utils.local_time import utcnow_naive
 
 logger = logging.getLogger(__name__)
@@ -78,18 +79,19 @@ def _bot_label(bot_token: str) -> str:
     return f"bot {bot_token.split(':', 1)[0]}"
 
 
-def prompt_is_live(pending: TelegramPendingVerdict, archive: PrintArchive) -> bool:
+def prompt_is_live(pending: TelegramPendingVerdict, holder: PrintArchive | PrintLogEntry) -> bool:
     """Whether a reaction on this prompt may still record a verdict.
 
     Exactly as long as the prompt's one-tap links would: the token the
-    message went out with is still the archive's token and nothing has spent
-    it. A reprint clears the token and the next completion mints a new one,
-    so a reaction on an earlier run's message never answers a later run.
+    message went out with is still its holder's -- the run it asked about, or
+    the archive for a prompt sent before runs had their own -- and nothing has
+    spent it. A reprint is a run of its own with its own token, so a reaction
+    on an earlier run's message never answers a later run.
     """
     return (
         bool(pending.confirm_token)
-        and pending.confirm_token == archive.confirm_token
-        and archive.confirm_token_used_at is None
+        and pending.confirm_token == holder.confirm_token
+        and holder.confirm_token_used_at is None
     )
 
 
@@ -313,10 +315,11 @@ class TelegramReactionPoller:
             if pending is None:
                 return
 
-            archive = await db.get(PrintArchive, pending.archive_id)
-            if archive is None or not prompt_is_live(pending, archive):
+            found = await confirm_token_holder(db, pending.confirm_token or "")
+            archive, run = found if found is not None else (None, None)
+            if archive is None or archive.id != pending.archive_id or not prompt_is_live(pending, run or archive):
                 ignored = "the prompt belongs to an earlier run or was answered elsewhere"
-            elif not await apply_outcome_verdict(db, archive, verdict, source="reaction"):
+            elif not await apply_outcome_verdict(db, archive, verdict, source="reaction", run=run):
                 ignored = "verdict already recorded"
             else:
                 ignored = None

+ 60 - 0
backend/tests/integration/test_telegram_reactions.py

@@ -943,3 +943,63 @@ class TestProviderApi:
             assert (await async_client.delete(f"/api/v1/notifications/{provider.id}")).status_code == 200
 
         assert await _pending_count(db_session) == 0
+
+
+class TestAPromptForOnePlate:
+    """Plates of one archive each have their own prompt and token (#1898)."""
+
+    async def _two_runs(self, db_session, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, confirm_requested=True, with_run=False)
+        runs = [
+            PrintLogEntry(
+                archive_id=archive.id, printer_id=printer.id, status="completed", confirm_requested=True, **fields
+            )
+            for fields in ({"confirm_token": "plate-1"}, {"confirm_token": "plate-2"})
+        ]
+        db_session.add_all(runs)
+        await db_session.commit()
+        return archive, runs
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_sent_prompt_remembers_its_runs_token(
+        self, db_session, printer_factory, archive_factory, telegram_provider
+    ):
+        archive, (first, _second) = await self._two_runs(db_session, printer_factory, archive_factory)
+        provider = await telegram_provider(telegram_verdict_mode="reactions")
+        service = NotificationService()
+        service._http_client = _SendCapture()
+
+        ok, _ = await service._send_to_provider(
+            provider,
+            "Outcome?",
+            "How did it go",
+            db_session,
+            event_type="print_confirm_request",
+            variables={**CONFIRM_VARIABLES, "archive_id": archive.id, "run_id": first.id},
+        )
+
+        assert ok
+        row = await db_session.scalar(select(TelegramPendingVerdict))
+        assert (row.archive_id, row.confirm_token) == (archive.id, "plate-1")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_reaction_answers_its_own_plate(
+        self, poller, db_session, printer_factory, archive_factory, telegram_provider, pending_factory
+    ):
+        archive, runs = await self._two_runs(db_session, printer_factory, archive_factory)
+        first_id, second_id = (r.id for r in runs)
+        provider = await telegram_provider()
+        _track(poller, provider)
+        await pending_factory(provider.id, archive.id, message_id=777, confirm_token="plate-1")
+        poller._http_client = _FakeBotApi([_updates(_reaction(10, 777, THUMBS_DOWN))])
+
+        assert await poller.poll_once(BOT_TOKEN) == "ok"
+
+        db_session.expire_all()
+        first = await db_session.get(PrintLogEntry, first_id)
+        assert (first.user_verdict, first.user_verdict_source) == ("reject", "reaction")
+        assert first.confirm_token_used_at is not None
+        assert (await db_session.get(PrintLogEntry, second_id)).user_verdict is None

+ 326 - 0
backend/tests/integration/test_verdict_per_run.py

@@ -0,0 +1,326 @@
+"""Each print of an archive has its own outcome question (#1898, archive redesign P1b).
+
+Plates of one archive printed on two printers are two runs. Each asks for its
+own verdict with its own one-tap token; an answer lands on the run it was
+asked about, and the archive shows its latest run's.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+T1 = datetime(2026, 10, 30, 9)
+T2 = T1 + timedelta(hours=1)
+
+
+@pytest.fixture
+async def two_plates(db_session, printer_factory):
+    """One archive, plate 1 printed on A, then plate 2 on B; both asked for
+    their outcome and neither is answered."""
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    archive = PrintArchive(
+        printer_id=b.id,
+        filename="two.gcode.3mf",
+        file_path="archives/test/two.gcode.3mf",
+        file_size=1,
+        print_name="two",
+        status="completed",
+        started_at=T2,
+        confirm_requested=True,
+    )
+    db_session.add(archive)
+    await db_session.flush()
+    first = PrintLogEntry(
+        archive_id=archive.id, printer_id=a.id, plate_id=1, status="completed", started_at=T1, confirm_requested=True
+    )
+    second = PrintLogEntry(
+        archive_id=archive.id, printer_id=b.id, plate_id=2, status="completed", started_at=T2, confirm_requested=True
+    )
+    db_session.add_all([first, second])
+    await db_session.commit()
+    return SimpleNamespace(archive=archive.id, a=a.id, b=b.id, first=first.id, second=second.id)
+
+
+async def _row(db_session, model, row_id):
+    db_session.expire_all()
+    return await db_session.get(model, row_id)
+
+
+async def _prompt(db_session, plates, run_id):
+    """The completion's outcome prompt for ``run_id``; returns (ws payload, provider kwargs)."""
+    from backend.app.main import dispatch_outcome_confirmation
+
+    with (
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.notification_service") as notify,
+    ):
+        ws.send_print_confirm_request = AsyncMock()
+        notify.on_print_confirm_request = AsyncMock()
+        sent = await dispatch_outcome_confirmation(db_session, plates.a, "A", {}, plates.archive, run_id=run_id)
+    assert sent
+    return ws.send_print_confirm_request.await_args.args[1], notify.on_print_confirm_request.await_args.kwargs
+
+
+async def test_each_plates_prompt_has_its_own_token(db_session, two_plates):
+    first_ws, first_links = await _prompt(db_session, two_plates, two_plates.first)
+    await _prompt(db_session, two_plates, two_plates.second)
+
+    first_token = (await _row(db_session, PrintLogEntry, two_plates.first)).confirm_token
+    second_token = (await _row(db_session, PrintLogEntry, two_plates.second)).confirm_token
+    assert first_token and second_token and first_token != second_token
+    assert (await _row(db_session, PrintArchive, two_plates.archive)).confirm_token is None
+    assert first_ws["run_id"] == two_plates.first
+    assert first_links["run_id"] == two_plates.first
+    assert first_links["good_url"].endswith(f"/confirm/{first_token}/good")
+    assert first_links["confirm_url"].endswith(f"?confirm={two_plates.archive}&run={two_plates.first}")
+
+
+async def test_an_answered_plate_is_not_asked_again(db_session, two_plates):
+    from backend.app.main import dispatch_outcome_confirmation
+
+    first = await _row(db_session, PrintLogEntry, two_plates.first)
+    first.user_verdict = "good"
+    await db_session.commit()
+
+    with patch("backend.app.main.ws_manager"), patch("backend.app.main.notification_service"):
+        assert not await dispatch_outcome_confirmation(
+            db_session, two_plates.a, "A", {}, two_plates.archive, run_id=two_plates.first
+        )
+
+
+async def test_a_plates_link_answers_that_plate(async_client, db_session, two_plates):
+    await _prompt(db_session, two_plates, two_plates.first)
+    token = (await _row(db_session, PrintLogEntry, two_plates.first)).confirm_token
+
+    response = await async_client.post(f"/api/v1/archives/confirm/{token}/reject")
+    assert response.status_code == 200, response.text
+
+    first = await _row(db_session, PrintLogEntry, two_plates.first)
+    assert (first.user_verdict, first.user_verdict_source) == ("reject", "link")
+    assert first.confirm_token_used_at is not None
+    assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict is None
+    # The archive shows plate 2, its latest, still unanswered.
+    assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict is None
+
+    again = await async_client.get(f"/api/v1/archives/confirm/{token}/good")
+    assert "Already answered" in again.text and "Rejected" in again.text
+    assert f"run={two_plates.first}" in again.text
+
+
+async def test_a_link_sent_before_runs_had_tokens_answers_the_latest(async_client, db_session, two_plates):
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    archive.confirm_token = "archive-token"
+    await db_session.commit()
+
+    assert (await async_client.post("/api/v1/archives/confirm/archive-token/good")).status_code == 200
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    assert (archive.user_verdict, archive.confirm_token_used_at is not None) == ("good", True)
+    assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict == "good"
+    assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
+
+
+async def test_the_dialog_answers_the_plate_it_asks_about(async_client, db_session, two_plates):
+    response = await async_client.patch(
+        f"/api/v1/archives/{two_plates.archive}",
+        json={
+            "user_verdict": "reject",
+            "failure_reason": "spaghetti",
+            "user_verdict_source": "dialog",
+            "run_id": two_plates.first,
+        },
+    )
+    assert response.status_code == 200, response.text
+
+    first = await _row(db_session, PrintLogEntry, two_plates.first)
+    assert (first.user_verdict, first.user_verdict_source, first.failure_reason) == ("reject", "dialog", "spaghetti")
+    second = await _row(db_session, PrintLogEntry, two_plates.second)
+    assert (second.user_verdict, second.failure_reason) == (None, None)
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    assert (archive.user_verdict, archive.failure_reason) == (None, None)
+
+
+async def test_an_answer_for_the_latest_plate_shows_on_the_archive(async_client, db_session, two_plates):
+    response = await async_client.patch(
+        f"/api/v1/archives/{two_plates.archive}",
+        json={"user_verdict": "good", "user_verdict_source": "printer_card", "run_id": two_plates.second},
+    )
+    assert response.status_code == 200, response.text
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    assert (archive.user_verdict, archive.user_verdict_source) == ("good", "printer_card")
+    assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
+
+
+async def test_an_answer_without_a_run_goes_to_the_latest(async_client, db_session, two_plates):
+    response = await async_client.patch(f"/api/v1/archives/{two_plates.archive}", json={"user_verdict": "good"})
+    assert response.status_code == 200, response.text
+
+    assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict == "good"
+    assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
+    assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict == "good"
+
+
+async def test_a_run_of_another_archive_or_other_fields_are_refused(async_client, db_session, two_plates):
+    other = PrintArchive(filename="o.3mf", file_path="archives/test/o.3mf", file_size=1, status="completed")
+    db_session.add(other)
+    await db_session.flush()
+    stranger = PrintLogEntry(archive_id=other.id, status="completed")
+    db_session.add(stranger)
+    await db_session.commit()
+
+    url = f"/api/v1/archives/{two_plates.archive}"
+    assert (await async_client.patch(url, json={"user_verdict": "good", "run_id": stranger.id})).status_code == 404
+    assert (await async_client.patch(url, json={"notes": "x", "run_id": two_plates.first})).status_code == 400
+
+
+async def test_the_archive_can_be_read_as_one_of_its_runs(async_client, db_session, two_plates):
+    first = await _row(db_session, PrintLogEntry, two_plates.first)
+    first.user_verdict = "reject"
+    await db_session.commit()
+
+    response = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id={two_plates.first}")
+    assert response.status_code == 200, response.text
+    body = response.json()
+    assert (body["run_id"], body["printer_id"], body["plate_id"], body["user_verdict"]) == (
+        two_plates.first,
+        two_plates.a,
+        1,
+        "reject",
+    )
+    plain = (await async_client.get(f"/api/v1/archives/{two_plates.archive}")).json()
+    assert (plain["run_id"], plain["user_verdict"]) == (None, None)
+    missing = await async_client.get(f"/api/v1/archives/{two_plates.archive}?run_id=999999")
+    assert missing.status_code == 404
+
+
+async def test_each_printer_card_shows_its_own_plate(async_client, db_session, two_plates):
+    first = await _row(db_session, PrintLogEntry, two_plates.first)
+    first.user_verdict = "good"
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/archives/last-per-printer")
+    assert response.status_code == 200, response.text
+    cards = {card["printer_id"]: card for card in response.json()}
+    assert {pid: (c["id"], c["run_id"], c["plate_id"], c["user_verdict"]) for pid, c in cards.items()} == {
+        two_plates.a: (two_plates.archive, two_plates.first, 1, "good"),
+        two_plates.b: (two_plates.archive, two_plates.second, 2, None),
+    }
+
+
+async def test_clearing_a_printers_plate_answers_its_own_plate(db_session, two_plates):
+    from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
+
+    assert await resolve_pending_confirmation_as_good(db_session, two_plates.a) == two_plates.archive
+    await db_session.commit()
+
+    first = await _row(db_session, PrintLogEntry, two_plates.first)
+    assert (first.user_verdict, first.user_verdict_source) == ("good", "plate_clear")
+    assert (await _row(db_session, PrintLogEntry, two_plates.second)).user_verdict is None
+    assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict is None
+
+
+async def test_a_new_plate_ending_shows_its_open_question_on_the_archive(db_session, two_plates):
+    """The archive's verdict was the previous print's: the latest run's (none
+    yet) replaces it once the new run ends."""
+    from backend.app.services.print_log import mirror_latest_run
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    first = await db_session.get(PrintLogEntry, two_plates.first)
+    archive.user_verdict = "good"
+    archive.user_verdict_source = "dialog"
+    first.user_verdict = "good"
+    await db_session.commit()
+
+    await mirror_latest_run(db_session, two_plates.archive)
+    await db_session.commit()
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    assert (archive.user_verdict, archive.user_verdict_source) == (None, None)
+
+
+async def test_a_run_without_provenance_keeps_the_archives(db_session, two_plates):
+    """Runs logged before they kept how the verdict arrived: the same verdict
+    on the archive keeps its own provenance."""
+    from backend.app.services.print_log import mirror_latest_run
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    second = await db_session.get(PrintLogEntry, two_plates.second)
+    archive.user_verdict = "good"
+    archive.user_verdict_source = "plate_clear"
+    second.user_verdict = "good"
+    await db_session.commit()
+
+    await mirror_latest_run(db_session, two_plates.archive)
+    await db_session.commit()
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    assert (archive.user_verdict, archive.user_verdict_source) == ("good", "plate_clear")
+
+
+async def test_a_run_logged_before_runs_owned_their_verdict_leaves_the_archives(db_session, two_plates):
+    """Its verdict was never copied onto it: the archive's is kept."""
+    from backend.app.services.print_log import mirror_latest_run
+
+    archive = await _row(db_session, PrintArchive, two_plates.archive)
+    second = await db_session.get(PrintLogEntry, two_plates.second)
+    archive.user_verdict = "reject"
+    second.confirm_requested = None
+    await db_session.commit()
+
+    await mirror_latest_run(db_session, two_plates.archive)
+    await db_session.commit()
+
+    assert (await _row(db_session, PrintArchive, two_plates.archive)).user_verdict == "reject"
+
+
+async def test_a_run_on_a_printer_outside_the_callers_scope_is_not_theirs(async_client, db_session, two_plates):
+    """Scoped to printer B, the archive's: plate 1 printed on A is neither
+    read nor judged through the archive, and A's card is not theirs."""
+    from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
+
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(
+        async_client,
+        admin,
+        "scoped",
+        ["archives:read_all", "archives:update_all", "printers:read"],
+        printer_ids=[two_plates.b],
+    )
+    url = f"/api/v1/archives/{two_plates.archive}"
+
+    assert (await async_client.get(f"{url}?run_id={two_plates.first}", headers=_auth(jwt))).status_code == 404
+    patched = await async_client.patch(
+        url, headers=_auth(jwt), json={"user_verdict": "reject", "run_id": two_plates.first}
+    )
+    assert patched.status_code == 404
+    assert (await _row(db_session, PrintLogEntry, two_plates.first)).user_verdict is None
+    # Their own printer's plate is theirs.
+    own = await async_client.patch(url, headers=_auth(jwt), json={"user_verdict": "good", "run_id": two_plates.second})
+    assert own.status_code == 200, own.text
+
+    cards = (await async_client.get("/api/v1/archives/last-per-printer", headers=_auth(jwt))).json()
+    assert [(c["printer_id"], c["run_id"]) for c in cards] == [(two_plates.b, two_plates.second)]
+
+
+async def test_a_card_is_not_shown_for_an_archive_outside_the_callers_scope(async_client, db_session, two_plates):
+    """Scoped to printer A, whose plate it printed, while the archive names
+    B: as before runs, the archive is not theirs to see."""
+    from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
+
+    admin = await _admin_token(async_client)
+    jwt, _ = await _user(
+        async_client, admin, "scoped-a", ["archives:read_all", "printers:read"], printer_ids=[two_plates.a]
+    )
+
+    cards = (await async_client.get("/api/v1/archives/last-per-printer", headers=_auth(jwt))).json()
+    assert cards == []

+ 25 - 0
frontend/src/__tests__/components/ConfirmOutcomeDialog.test.tsx

@@ -189,4 +189,29 @@ describe('ConfirmOutcomeDialog', () => {
       'Recorded when the plate was cleared.'
     );
   });
+
+  it('asks about the plate it was opened for and answers that one', async () => {
+    // Plates of one archive each have their own question: the dialog reads
+    // and answers its run, not the archive's latest.
+    const user = userEvent.setup();
+    let requestedRun: string | null = null;
+    server.use(
+      http.get('/api/v1/archives/42', ({ request }) => {
+        requestedRun = new URL(request.url).searchParams.get('run_id');
+        return HttpResponse.json({ ...baseArchive, run_id: 7 });
+      }),
+    );
+    render(<ConfirmOutcomeDialog archiveId={42} runId={7} onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Bracket')).toBeInTheDocument();
+    });
+    expect(requestedRun).toBe('7');
+
+    await user.click(screen.getByText('Good'));
+
+    await waitFor(() => {
+      expect(patchPayload).toEqual({ user_verdict: 'good', user_verdict_source: 'dialog', run_id: 7 });
+    });
+  });
 });

+ 45 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -928,6 +928,28 @@ describe('Layout', () => {
       expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
     });
 
+    it('opens the dialog on the plate the link names', async () => {
+      // Plates of one archive each have their own question (#1898).
+      let requestedRun: string | null = null;
+      server.use(
+        http.get('/api/v1/archives/42', ({ request }) => {
+          requestedRun = new URL(request.url).searchParams.get('run_id');
+          return HttpResponse.json({ ...archive, run_id: 7 });
+        }),
+      );
+      window.history.replaceState({}, '', '/archives?confirm=42&run=7&status=completed');
+
+      render(<Layout />);
+
+      await screen.findByTestId('confirm-outcome-dialog');
+      await waitFor(() => {
+        expect(requestedRun).toBe('7');
+      });
+      await waitFor(() => {
+        expect(window.location.search).toBe('?status=completed');
+      });
+    });
+
     it('strips the parameter but keeps the rest of the query', async () => {
       window.history.replaceState({}, '', '/archives?confirm=42&status=completed');
 
@@ -1043,5 +1065,28 @@ describe('Layout', () => {
 
       expect(await screen.findByTestId('confirm-outcome-dialog')).toBeInTheDocument();
     });
+
+    it('opens from the WebSocket event on the plate it names', async () => {
+      let requestedRun: string | null = null;
+      server.use(
+        http.get('/api/v1/archives/42', ({ request }) => {
+          requestedRun = new URL(request.url).searchParams.get('run_id');
+          return HttpResponse.json({ ...archive, run_id: 7 });
+        }),
+      );
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside')).toBeInTheDocument();
+      });
+      window.dispatchEvent(
+        new CustomEvent('print-confirm-request', { detail: { archive_id: 42, run_id: 7 } })
+      );
+
+      await screen.findByTestId('confirm-outcome-dialog');
+      await waitFor(() => {
+        expect(requestedRun).toBe('7');
+      });
+    });
   });
 });

+ 14 - 0
frontend/src/__tests__/pages/PrintersPageCardVerdict.test.tsx

@@ -104,6 +104,20 @@ describe('PrintersPage — verdict on the card', () => {
     expect(await screen.findByText('Marked as good part')).toBeInTheDocument();
   });
 
+  it('answers its own print when the archive printed on more printers', async () => {
+    // Plates of one archive are runs of their own; the card's row names its run.
+    const user = userEvent.setup();
+    server.use(
+      http.get('/api/v1/archives/last-per-printer', () => HttpResponse.json([{ ...finishedPrint, run_id: 7 }])),
+    );
+    render(<PrintersPage />);
+
+    await user.click(await screen.findByTitle('Good'));
+
+    await waitFor(() => expect(patched).toHaveLength(1));
+    expect(patched[0].run_id).toBe(7);
+  });
+
   // The card reads its row out of one page-wide answer; another printer's
   // archive must not stand in for its own.
   it('ignores the last print of another printer', async () => {

+ 7 - 1
frontend/src/api/client.ts

@@ -920,6 +920,9 @@ export interface Archive {
   user_verdict_source: VerdictSource | null;
   user_verdict_at: string | null;
   confirm_requested: boolean;
+  // The run whose outcome this shows, when it shows one run of the archive
+  // (a plate printed on its own) rather than its latest (#1898).
+  run_id?: number | null;
   quantity: number;
   energy_kwh: number | null;
   energy_cost: number | null;
@@ -5671,7 +5674,8 @@ export const api = {
     const qs = params.toString();
     return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
   },
-  getArchive: (id: number) => request<Archive>(`/archives/${id}`),
+  getArchive: (id: number, runId?: number | null) =>
+    request<Archive>(`/archives/${id}${runId != null ? `?run_id=${runId}` : ''}`),
   getArchiveRuns: (id: number) => request<PrintLogResponse>(`/archives/${id}/runs`),
   /**
    * Pre-flight for the delete-confirm modal (#1734). Returns the number of
@@ -5713,6 +5717,8 @@ export const api = {
     // Which surface answered. The backend only accepts the sources a client
     // can honestly claim and defaults to 'api' when none is given.
     user_verdict_source?: 'dialog' | 'printer_card';
+    // The run the verdict / outcome is for; omitted means the latest run.
+    run_id?: number;
   }) =>
     request<Archive>(`/archives/${id}`, {
       method: 'PATCH',

+ 11 - 4
frontend/src/components/ConfirmOutcomeDialog.tsx

@@ -10,6 +10,9 @@ import { useToast } from '../contexts/ToastContext';
 
 interface ConfirmOutcomeDialogProps {
   archiveId: number;
+  // The run asked about: plates of one archive each have their own question.
+  // Omitted (an archive card's menu, an older link) means its latest run.
+  runId?: number | null;
   onClose: () => void;
 }
 
@@ -20,7 +23,7 @@ interface ConfirmOutcomeDialogProps {
  * ?confirm=<id> deep link a push notification carries. Rejecting offers an
  * optional reason and a one-click reprint through the regular PrintModal.
  */
-export function ConfirmOutcomeDialog({ archiveId, onClose }: ConfirmOutcomeDialogProps) {
+export function ConfirmOutcomeDialog({ archiveId, runId, onClose }: ConfirmOutcomeDialogProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
@@ -29,13 +32,17 @@ export function ConfirmOutcomeDialog({ archiveId, onClose }: ConfirmOutcomeDialo
   const [showReprint, setShowReprint] = useState(false);
 
   const { data: archive } = useQuery({
-    queryKey: ['archive', archiveId],
-    queryFn: () => api.getArchive(archiveId),
+    queryKey: ['archive', archiveId, runId ?? null],
+    queryFn: () => api.getArchive(archiveId, runId),
   });
 
   const verdictMutation = useMutation({
     mutationFn: (data: { user_verdict: 'good' | 'reject'; failure_reason?: string }) =>
-      api.updateArchive(archiveId, { ...data, user_verdict_source: 'dialog' }),
+      api.updateArchive(archiveId, {
+        ...data,
+        user_verdict_source: 'dialog',
+        ...(runId != null ? { run_id: runId } : {}),
+      }),
     onSuccess: (_updated, variables) => {
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archive', archiveId] });

+ 7 - 0
frontend/src/components/Layout.tsx

@@ -138,6 +138,8 @@ export function Layout() {
   // Post-print outcome confirmation (#1898): archive waiting for a verdict,
   // set by the print_confirm_request WebSocket event.
   const [confirmOutcomeArchiveId, setConfirmOutcomeArchiveId] = useState<number | null>(null);
+  // The run the prompt asks about (a plate of the archive), when it names one.
+  const [confirmOutcomeRunId, setConfirmOutcomeRunId] = useState<number | null>(null);
 
   // Check for updates
   const { data: versionInfo } = useQuery({
@@ -528,6 +530,7 @@ export function Layout() {
       const detail = (event as CustomEvent).detail;
       if (typeof detail?.archive_id === 'number') {
         setConfirmOutcomeArchiveId(detail.archive_id);
+        setConfirmOutcomeRunId(typeof detail.run_id === 'number' ? detail.run_id : null);
       }
     };
     window.addEventListener('print-confirm-request', handleConfirmRequest);
@@ -546,8 +549,11 @@ export function Layout() {
     if (!confirmId || !/^\d+$/.test(confirmId)) {
       return;
     }
+    const runId = params.get('run');
     setConfirmOutcomeArchiveId(Number(confirmId));
+    setConfirmOutcomeRunId(runId && /^\d+$/.test(runId) ? Number(runId) : null);
     params.delete('confirm');
+    params.delete('run');
     const rest = params.toString();
     navigate({ pathname: location.pathname, search: rest ? `?${rest}` : '' }, { replace: true });
   }, [location.pathname, location.search, navigate]);
@@ -1212,6 +1218,7 @@ export function Layout() {
       {canConfirmOutcome && confirmOutcomeArchiveId !== null && (
         <ConfirmOutcomeDialog
           archiveId={confirmOutcomeArchiveId}
+          runId={confirmOutcomeRunId}
           onClose={() => setConfirmOutcomeArchiveId(null)}
         />
       )}

+ 1 - 1
frontend/src/hooks/useWebSocket.ts

@@ -407,7 +407,7 @@ export function useWebSocket() {
         // #1898: a completed print asked for its outcome verdict. Handed to
         // the globally mounted ConfirmOutcomeDialog in Layout via the same
         // CustomEvent relay plate_not_empty uses.
-        const detail = (message as unknown as { data?: { archive_id?: number; print_name?: string } }).data;
+        const detail = (message as unknown as { data?: { archive_id?: number; run_id?: number | null; print_name?: string } }).data;
         if (detail?.archive_id != null) {
           window.dispatchEvent(new CustomEvent('print-confirm-request', { detail }));
         }

+ 4 - 0
frontend/src/pages/PrintersPage.tsx

@@ -2847,6 +2847,9 @@ function PrinterCard({
       api.updateArchive(pendingConfirmArchive!.id, {
         user_verdict: verdict,
         user_verdict_source: 'printer_card',
+        // The card's own print: a plate of the archive on another printer is
+        // that printer's card's question.
+        ...(pendingConfirmArchive!.run_id != null ? { run_id: pendingConfirmArchive!.run_id } : {}),
       }),
     onSuccess: () => {
       showToast(t('confirmOutcome.savedGood'), 'success');
@@ -2905,6 +2908,7 @@ function PrinterCard({
       {showConfirmOutcome && pendingConfirmArchive && (
         <ConfirmOutcomeDialog
           archiveId={pendingConfirmArchive.id}
+          runId={pendingConfirmArchive.run_id}
           onClose={() => setShowConfirmOutcome(false)}
         />
       )}