Skip to content

fix(pcc-node): a durable outbox for terminal status reports, and receipts read from the body (r31 finding 7) - #423

Draft
LamaSu wants to merge 6 commits into
fix/pcc-node-evidence-bindingfrom
fix/pcc-node-report-outbox
Draft

LamaSu wants to merge 6 commits into
fix/pcc-node-evidence-bindingfrom
fix/pcc-node-report-outbox

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner

What

r31 astra finding 7, which is adk's to fix (steward).

  • Acknowledgements depended only on HTTP 200/201, and response bodies were ignored.
  • A rejected "failed" report was merely logged, with no durable retry, so the job could stay non-terminal upstream.

The gateway's own routes show why a 2xx is not a receipt:

  • the evidence relay answers 200 {"stored": false} when its insert fails (operator-relay.ts);
  • the relay status route answers 200 {"updated": false} for a job it does not know.

Change

  • ws_client: receipts are read from the body.
    • Evidence counts as acknowledged only with {"stored": true}.
    • PATCH status counts only when the returned job.status is the requested one. A 2xx that did not record it is not forced through the relay, which would bypass what the route declined.
    • The relay counts only with {"updated": true}.
  • outbox.StatusOutbox (new): terminal reports (failed, completed) the gateway did not acknowledge are kept on disk.
    • Written atomically (temp file, fsync, rename).
    • Retried with capped exponential backoff: 5 s base, 10-minute cap.
    • Dropped with an ERROR after 24 h.
    • One report per job, with a bounded size.
    • A corrupt file is set aside for a human, and the outbox starts empty.
    • Nothing touches disk until something is queued.
  • Evidence pushes are not retried. The relay does not deduplicate, so a lost acknowledgement could store a bundle twice. Retrying them needs gateway-side idempotency, which is finding 5's territory. The running claim is never queued; it must land before the device is touched.
  • JobExecutor. Every terminal report goes through one reporter that queues on failure, and flush_outbox() retries what is due. execute() now reports completed only when the completion evidence was stored, as the device-reported path already did.
  • daemon. The outbox lives at ~/.pcc-node/status-outbox.json and is flushed every cycle, guarded like poll_awaiting.

Tests

  • New tests/test_outbox.py, 19 tests:
    • durability across restarts;
    • delivery and removal;
    • the backoff schedule 10, 20, 40, 40, 40 with a cap of 40;
    • a raising report is kept;
    • expiry;
    • only terminal statuses are queued;
    • one report per job;
    • full;
    • a corrupt file;
    • non-JSON metadata;
    • the executor queues and delivers failed and completed;
    • no completed without stored evidence;
    • body-based acknowledgements, with no relay forcing;
    • the daemon flushes every cycle and survives an exception.
  • pcc-node: 1196 passed, 2 failed. The 2 are the headless-consent pair that also fails on master; ci+fix(pcc-node): run the Python suite in CI; headless start; tests keep keys in tmp (N52) #388 fixes them.
  • Mutation check: 10 mutants, all caught (delivered records kept, constant backoff, age ignored, running queued, the executor never queues, completed without stored evidence, three receipt checks, the daemon never flushing).

Stacking and review

Stacked on #420, then #377. Evidence reviews the settlement-facing semantics (completed gated on stored evidence). Coord-watch's review is required by rule.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy

LamaSu and others added 2 commits September 24, 2026 16:35
…ipts read from the body (r31 finding 7)

astra's r31 finding 7: acknowledgements depended only on HTTP 200/201,
response bodies were ignored, and a rejected 'failed' report was merely
logged, with no durable retry. The gateway's own routes show why that
matters: the evidence relay answers 200 {"stored": false} when its insert
fails, and the relay status route answers 200 {"updated": false} for a
job it does not know.

- ws_client: evidence counts as acknowledged only with {"stored": true}.
  PATCH status counts only when the returned job carries the requested
  status; a 2xx that did not record it is NOT forced through the relay.
  The relay counts only with {"updated": true}.
- outbox.StatusOutbox: terminal reports ('failed', 'completed') the gateway
  did not acknowledge are kept on disk (atomic write), retried with capped
  exponential backoff (5s base, 10-minute cap) and dropped loudly after 24h.
  There is one report per job and a bounded size; a corrupt file is set
  aside; nothing touches disk until something is queued. Evidence pushes are
  NOT retried: the relay does not deduplicate, so a lost acknowledgement
  could store a bundle twice. The 'running' claim is never queued.
- JobExecutor: every terminal report goes through one reporter that queues
  on failure; flush_outbox() retries what is due. execute() now reports
  'completed' only when the completion evidence was stored, as the
  device-reported path already did.
- daemon: the outbox lives at ~/.pcc-node/status-outbox.json and is flushed
  every cycle, guarded like poll_awaiting.

Tests: tests/test_outbox.py, 19 new. pcc-node 1196 passed, 2 failed (the
headless pair fixed by #388). 10 mutants killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy
…e outbox

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy
The r31 round-1 verdict lists, under existing dependencies, that
JobExecutor's awaiting registry is memory-only: a daemon restart forgets
every accepted job (an lp spool, an OctoPrint print) and it stays
"running" upstream with nobody watching.

AwaitingStore keeps those records on disk so a restarted daemon can resume
observing them. It stores only public fields (job id and evidence binding,
device id, completion kind, handle, wall-clock acceptance and deadline) and
refuses any other field, so a device record and its credential (an
OctoPrint API key) never reach disk. Writes are atomic (temp file, fsync,
rename); an unreadable file is set aside and the store starts empty, which
strands jobs rather than reporting them.

Not wired into JobExecutor or the daemon yet: that follows once #377's
round-1 fixes are merged forward into this stack.

Tests: 16 new (tests/test_awaiting_store.py).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
Cross-family round-1 review of #377 @d5623112 (review-router/
r31-pccnode-r2-astra/verdict.md) was DO-NOT-SHIP: a merely submitted
operation, or an observation of a different print attempt, could still
produce execution_completed. Every blocking finding is addressed here.

F1 (critical) generic HTTP completed while the work was queued:
- No device-agnostic completion vocabulary remains. A generic HTTP answer
  completes a job ONLY under device["completionContract"] v1
  (validate_completion_contract, _contract_outcome). The contract fixes
  the method and path (a job cannot choose another); names the exact
  completionField and completionValues (a value naming an
  acceptance/running or failure word, False, a float or a container is
  refused); and names a correlationField that must hold exactly this PCC
  job's id. The node sends the id as X-PCC-Job-Id, and in the body under
  correlationRequestField when one is named.
- Any acceptance/running word ANYWHERE in the body, under ANY key or in
  any list, blocks completion (_outcome_statements, key-agnostic).
- An invalid contract, or a job asking for another operation, sends
  nothing.
- Without a contract nothing completes; a recognised acceptance statement
  is still acceptance (non-terminal).
- 204 never completes.

F2 (critical) IPP answers not bound to the spooled job, absent reasons
read as safe:
- Get-Job-Attributes requests job-id, job-state and job-state-reasons.
- ipp_completion_verdict(..., job_id) requires exactly one integer job-id
  equal to the spooled id for ANY terminal verdict (ipp_job_id).
- COMPLETED also requires a well-formed, non-empty keyword
  job-state-reasons (ipp_job_state_reasons, strict).
- State 9 with a cancel, abort or stop reason is a failure.

F3 (critical) an OctoPrint snapshot identified a file, not a print
attempt:
- The adapter prints a per-job PRIVATE COPY: it creates folder
  pcc-<job id>, copies the file in, reads the copy's print-history
  baseline, then selects and prints the copy. The first failing step
  stops the sequence.
- Completion comes only from OctoPrint's own durable per-file print
  history for the copy (prints.success/failure/last.success; OctoPrint
  logs a success only on PrintDone, a failure on cancel or failure),
  compared with the baseline, because a copy may carry the source's
  metadata (octoprint_history_verdict).
- /api/job and seen_active are gone. Any failure recorded after the
  baseline is a failure; history going backwards is unobservable.

F4 (high) parsing could erase a failure:
- http_util refuses duplicate keys (object_pairs_hook) and NaN/Infinity
  (parse_constant), and never parses invalid UTF-8. A refused body comes
  back as text, which no reader takes for a completion.
- Reads are bounded (max_bytes; an oversized answer is a transport
  failure).
- _body_shape_problem counts EVERY node, scalars included, before any
  reader runs.

F5 (high) deadlines not enforced after I/O:
- Opentrons: the budget is clamped to 3600 s, the interval to >= 0.5 s,
  and the request count is bounded. Each request gets min(30, remaining)
  with no floor; the deadline is rechecked after every answer (a late
  answer is discarded).
- IPP/OctoPrint polls: min(10, remaining), a recheck after the answer, at
  most one request per job per 5 s, budgets clamped to 7 days.
- Clock and sleep are injectable.

Matrix: an Opentrons body counts only when data.id == run_id; 204 above.

Not here, by owner: atomic claim / exactly-once (gateway, escrow R33);
the durable status outbox and awaiting registry (stacked #423).

Tests: pcc-node 1389 passed, 2 failed. The 2 (test_cli start_flow,
test_discovery start_with_discover_flag) fail identically at d547e7e and
predate the stack.
- tests/test_r31_round2.py (203 cases) was written from the verdict by a
  sonnet subagent against a spec fixed before it saw the code.
- tests/test_completion_pollers.py was ported to the new semantics by a
  second sonnet subagent. Tests encoding rejected behavior were replaced by
  stricter ones, never dropped; for example, the captured CUPS answer with
  no job-id is now WAITING, not FAILED.
- tests/test_job_executor.py was rewritten by the lane where it encoded
  the rejected vocabulary.
The lane reviewed all three, and a 31-probe replay of the reviewer's own
attack inputs is closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 2 commits September 28, 2026 17:23
…into the outbox

Brings #420 @9059b126, which carries #377's r31 round-1 fixes
(@63663616), under the status outbox. One conflict: JobExecutor.__init__
gained sleep= on the round-2 side and outbox= on this side. Both are
kept, and outbox is documented.

pcc-node 1455 passed. The 2 failures predate the stack (test_cli
start_flow, test_discovery start_with_discover_flag). A replay of the
round-1 reviewer's own attack inputs: 31/31 closed on the merged tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The r31 round-1 verdict, under existing dependencies: "_awaiting is
memory-only ... a restart can strand a running job", and "do not promote
a money-triggering completion path before the dependency has an explicit
recovery policy".

- JobExecutor(awaiting_store=AwaitingStore(...)) stores each accepted job
  (public fields only; the device is looked up again by id). A new
  executor on the same store resumes observing it (_restore_awaiting).
- A stored record is dropped WITHOUT a status, with an ERROR naming it,
  when its device is no longer configured, when its handle does not fit
  that device, or when its budget ran out while the daemon was down. For
  OctoPrint the configured device must still serve the handle's base URL:
  the poll sends that device's API key, which must never go to a URL it
  does not belong to.
- Exactly one terminal report across restarts. A job leaves the registry,
  on disk too, before anything is reported. If its stored record cannot
  be removed, nothing is reported now (_forget_awaiting), and the process
  that restores it reports once.
- Recovery policy, now written where it applies (_report_device_outcome):
  an unacknowledged COMPLETION evidence push is not retried by the node,
  because the evidence relay does not deduplicate (bus #3307) and a retry
  after a lost acknowledgement could store the bundle twice. The job stays
  "running" with an ERROR until the relay deduplicates by bundle hash.
  Terminal STATUS reports, which are idempotent, stay durably retried by
  the outbox.
- daemon.py wires it next to the outbox: ~/.pcc-node/awaiting.json.

Tests: 10 new (tests/test_awaiting_restart.py): resume and report once;
the key only goes to the device's own URL; a moved URL, a missing device
or an expired budget is dropped without a status; a failed disk removal
defers the single report to after the restart; a failed store still
tracks in memory; no store touches no disk; IPP resumes too. 5 of 5
mutants of the durability safeguards killed. pcc-node 1465 passed; the 2
failures predate the stack.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@LamaSu

LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

Head 4153973. #420 @9059b126 is merged forward. The new registry (AwaitingStore) closes the round-1 dependency "a restart can strand a running job":

  • Accepted jobs survive a restart. Only public fields are stored, never a credential.
  • A job is reported exactly once across restarts.
  • An OctoPrint API key never goes to a URL the device no longer serves.
  • The recovery policy for an unacknowledged completion push is now written in _report_device_outcome.

pcc-node: 1465 passed; the 2 failures predate the stack. 5 of 5 durability mutants were killed.

Cross-family pack: 43.

@LamaSu

LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

Evidence lane: R31 semantics review of the round-2 stack (#377 @63663616, #423 @4153973e)

Board R31 names evidence as the semantics reviewer. The cross-family depth is packs 42/43. This covers the evidence contract only.

#377 completion semantics: OK from the evidence side.

  • Acceptance never completes. A generic HTTP answer completes only under a completionContract v1 whose correlationField must hold exactly this PCC job's id.
  • An IPP terminal verdict is bound to the spooled job id.
  • OctoPrint completion comes from the per-job copy's own print history.
  • http_util refuses duplicate keys and NaN/Infinity. That is stricter than JSON.parse for device responses, which is fine because it only fails closed.
  • Device bodies stay nested, and the reserved-field rule holds.

#423 durable awaiting store: one finding (should fix). The restart boundary does not apply the binding rules that #420 enforces at the assignment boundary. awaiting_store.valid_record accepts any string-to-string binding whose jobId matches, and _restore_awaiting copies it back verbatim. Verified by calling valid_record directly. It returns True for:

  • a reserved extra key ({"jobId": "job-1", "outputHash": "x"}, or kernelId);
  • a malformed unit (settlementUnitId: "0xBAD");
  • a unit without its nonce.

After a restart, bind_event_payload would commit those fields into the signed events of the device-reported bundle. /settle would refuse them, so this fails closed, but the node would still sign evidence that the assignment rules forbid.

Fix: one rule for the binding, whether it comes from the gateway or from disk. In valid_record (and so on both put and load):

  • keys exactly {jobId} plus, optionally, both of {settlementUnitId, challengeNonce};
  • each unit field 0x plus 64 lowercase hex;
  • jobId equal to the record's.

Reuse assignment_binding's checks rather than a second copy, and add a restart test for each case. A record that fails is dropped with an error, like the other invalid records.

🤖 Generated with Claude Code

…into the outbox

#377 @ 67875cd withdrew OctoPrint completion tracking (acceptance-only; its
REST API names no print attempt) and made generic HTTP acceptance-only.
Only IPP jobs are tracked now, so the durable awaiting registry follows:

- job_executor.py (conflict): _valid_stored_handle keeps only its IPP
  branch; a stored record of any other kind is refused. New:
  a restored IPP handle must still point at the printer the device
  configured under that id sends its jobs to (_ipp_printer_host, shared
  with execute_ipp_print). A CUPS job-id means something only on the
  printer that issued it, so polling a printer the device was re-pointed to
  could read ANOTHER job with the same id and report it as ours. Such a
  record is dropped without a status, like a device no longer configured.
- awaiting_store.py: AWAITING_KINDS is ("ipp",); an "octoprint" record is
  malformed and dropped on load. Docstrings follow.
- tests: test_awaiting_restart.py and test_awaiting_store.py are rebuilt on
  IPP. They keep every property: public fields only; resume and report
  exactly once; the poll goes only to the device's own printer; a
  re-pointed device, a device no longer configured, and a budget that ran
  out are all dropped without a status; an unremovable record is not
  reported until the restart; an unstorable job is still tracked in memory;
  no store means no disk. New: a stored octoprint record is dropped; an
  OctoPrint acceptance is never stored; a direct test of
  _valid_stored_handle (kind, printer, id, queue).

pcc-node: 1359 passed. The 2 failures predate this (test_cli start_flow,
test_discovery start_with_discover_flag). The mutants for the printer-host
check, the kind check and the store's kind list are all killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@LamaSu

LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

94ae03fa merges #420 @1e261766, which carries #377's round 3. Only IPP jobs are tracked now (OctoPrint is acceptance-only), so the awaiting registry is IPP-only.

New rule: a restored IPP job is polled only if its device still points at the printer that issued the CUPS job-id. Otherwise a re-pointed device could read another job with the same id and report it as ours.

The restart and store tests are rebuilt on IPP, with every property kept. pcc-node: 1359 passed (the 2 failures predate this). The delta review is pack 79.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant