Ver Fonte

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 há 13 horas atrás
pai
commit
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)}
         />
       )}