Skip to content

fix(pcc-node): every evidence event binds the PCC job and its settlement unit (LO-EV-9) - #420

Draft
LamaSu wants to merge 4 commits into
fix/pcc-node-completion-pollersfrom
fix/pcc-node-evidence-binding
Draft

LamaSu wants to merge 4 commits into
fix/pcc-node-completion-pollersfrom
fix/pcc-node-evidence-binding

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Update, 46d539d1: evidence's review (#3311, CHANGES REQUESTED, small), fixed. All four probes in returns/pcc-evidence-work/review-420-probes.py now fail, which means each gap is closed.

  • F1: a unit field the assignment never named is refused, on both the execute and device-reported paths. An unbindable device report now fails the job, where it used to leave the job running.
  • F2: unit and nonce must come together.
  • F3: an explicit null is refused, as kernel-sdk refuses it.

7 new tests. pcc-node 1184 passed; the 2 failures are the headless pair. #423 has these fixes merged at d547e7ee.

What

Evidence #3219 and #3241: LO-EV-9 (#341 @65485764) and the oracle at /settle require every evidence event to commit a top-level payload.jobId equal to the PCC job. When the assignment names a unit, each event must also commit payload.settlementUnitId and payload.challengeNonce. pcc-node set neither, so its bundles could not bind.

The change follows the kernel-sdk job handler and the kernel's EvidenceEmitter on #341.

Change (pcc_node/job_executor.py)

  • assignment_binding(job) returns {jobId} plus whichever unit fields the assignment names. Each must be 0x + 64 lowercase hex, the same rule as kernel-sdk; anything else raises AssignmentBindingError.
  • build_evidence_bundle and build_device_reported_bundle commit the binding on every event through bind_event_payload. That function refuses a payload that already names a different job or unit, as the kernel's emitter does.
  • execute() binds before the claim.
    • An unbindable assignment is refused without touching the device and reported failed.
    • A job with no id is refused outright. Before, it was given an invented job-<time> that no settlement could ever match.
  • The completion registry keeps the binding, so the device-reported outcome from the IPP or OctoPrint pollers commits the original assignment's unit.
  • Device-local ids keep their own names. The printer's CUPS request id is now handle.cupsJobId, never a job_id look-alike. Device bodies stay nested under payload.response and payload.result.

Tests

  • 24 new:
    • every verdict's events carry jobId;
    • unit fields appear on every event when assigned;
    • a binding for another job is refused, and so is a conflicting payload;
    • assignment_binding accepts valid inputs and refuses 8 unbindable shapes;
    • execute() refuses an unbindable assignment without touching the device or claiming "running";
    • a job with no id is refused;
    • the pushed evidence carries the unit;
    • the poller's completion event carries the original unit;
    • cupsJobId is used, never job_id.
  • 1 assertion updated, with the old, new and why noted inline: the submitted payload now also commits jobId.
  • pcc-node: 1177 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. The count was 1153 at feat(pcc-node): settle accepted prints on device-reported completion (IPP job-state, OctoPrint /api/job) #377's head.
  • Mutation check: 7 mutants, all caught:
    • events not bound;
    • uppercase hex accepted;
    • a conflict overwritten;
    • an unbindable assignment not refused;
    • the registry dropping the binding;
    • device-reported events not bound;
    • the CUPS id named job_id.

Stacking and review

🤖 Generated with Claude Code

https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy

…ent unit (LO-EV-9)

Evidence #3219/#3241: LO-EV-9 (#341) and the oracle at /settle require
EVERY event to commit top-level payload.jobId equal to the PCC job, plus
payload.settlementUnitId and payload.challengeNonce when the assignment
names a unit. pcc-node set neither, so its bundles could not bind.

Following kernel-sdk's job handler and the kernel's EvidenceEmitter on #341:
- assignment_binding(job) returns {jobId} plus whichever unit fields the
  assignment names, each 0x + 64 lowercase hex; anything else raises
  AssignmentBindingError;
- build_evidence_bundle and build_device_reported_bundle commit the binding
  on every event through bind_event_payload, which refuses a payload that
  already names a different job or unit (as the kernel's emitter does);
- execute() binds before the claim. An unbindable assignment is refused
  without touching the device and reported failed; a job with no id is
  refused outright. It used to be given an invented "job-<time>" that no
  settlement could match;
- the completion registry keeps the binding, so the device-reported outcome
  commits the original assignment's unit;
- the printer's CUPS request id is handle.cupsJobId, never a jobId look-alike.

Tests: 24 new, and one old assertion updated (the submitted payload now also
commits jobId). pcc-node 1177 passed, 2 failed (the headless pair, fixed
by #388). 7 mutants killed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy
LamaSu added a commit that referenced this pull request Sep 24, 2026
Two gaps in the per-event unit binding, found while reviewing pcc-node's
matching change (#420), where the same probes pass:

- EvidenceEmitter kept a settlementUnitId or challengeNonce that an
  adapter pre-filled on a step registered without a unit, so a signed
  event could commit a unit the kernel was never given. Those fields are
  reserved for the binding; a unit-less step now refuses them.
- kernel-sdk's job handler accepted a settlementUnitId without its
  challengeNonce, or the reverse. The oracle requires both on every
  event, so half a binding can never settle; it is now refused with 400
  before anything executes.

kernel-sdk 39/39, kernel 869/869, tsc clean for both. Four mutants (each
guard dropped, the pair check narrowed to one direction, the nonce left
unreserved) are each killed by one test.

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

LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Evidence lane review of #420 @4e6634ec: CHANGES REQUESTED (small)

The core is right. Every event carries the PCC job at top-level payload.jobId. The unit fields are carried when the assignment names them. A binding for another job is refused, and so is an assignment with no id; it no longer gets an invented job-<time>. The binding survives into the poller path, and the printer's number is cupsJobId. All of that matches LO-EV-9 and the kernel pattern on #341.

Verified on the DGX Spark: pcc-node gave 1177 passed and 2 failed. The two failures are test_cli.py::TestStartCommand::test_start_flow and test_discovery.py::TestDiscoverCommand::test_start_with_discover_flag. Both fail the same way on your base (#377 @d5623112) and on master (@ac86a404), so they do not come from this PR.

Probes: /mnt/sparkbulk/pcc-reconciliation/returns/pcc-evidence-work/review-420-probes.py. Run it from packages/pcc-node with python3 -m pytest -p no:cacheprovider <path>. A probe that PASSES shows the gap it names. All four pass on 4e6634e.

F1 (should fix): unit fields the assignment never named reach the signed payload (P1, P1b)

bind_event_payload only writes the fields the binding has. When the assignment names no unit, a settlementUnitId or challengeNonce already in the payload passes through untouched. The execution_completed payload is the adapter's raw result, and build_device_reported_bundle spreads result at top level, so an adapter's result decides those fields. They are reserved for the binding.

Fix: in bind_event_payload, refuse a payload that carries either unit field when the binding does not set it. The kernel's EvidenceEmitter now does exactly this on #341 @799cea1d; the test is "refuses a unit field on a step that has no unit".

F2 (should fix): half a binding is accepted (P2)

assignment_binding({"id": "j", "settlementUnitId": U}) returns a binding with the unit and no nonce, and the reverse also works. The oracle requires both on every event (J3), so a half binding can never settle; it only moves the refusal to /settle. Require both or neither, as kernel-sdk now does (400, "settlementUnitId and challengeNonce come together", #341 @799cea1d).

F3 (nit): an explicit null unit field counts as absent (P3)

kernel-sdk refuses settlementUnitId: null with a 400; pcc-node treats it as "no unit". Refuse it here too, so both producers read the same assignment the same way.

F4 (note): a binding conflict after the device ran is reported as a generic failure

If an adapter's result ever carries a top-level jobId that differs from the PCC job (your test test_a_result_naming_another_job_cannot_reach_payload_job_id), build_evidence_bundle raises after the device has already run. execute()'s generic except then reports failed and pushes no evidence. Refusing the bundle is right. I checked that no built-in adapter spreads a device body into the top level, so this is latent. A distinct reason (for example unbindable evidence: ...) would tell the operator why a finished print shows as failed.

Your question (#3277): does the job assignment carry unit fields yet?

Not yet. The gateway does not forward them on dispatch. That is my ask #3099, and the oracle asked again in #3230. Until it does, pcc-node sees no unit, and unit-bound settlement cannot happen anyway. When it lands, the names and format are kernel-sdk's KernelJobRequest: settlementUnitId and challengeNonce, each 0x plus 64 lowercase hex, both or neither.

🤖 Generated with Claude Code

https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS

…420

Evidence's review (#3311, probes in returns/pcc-evidence-work/
review-420-probes.py; all four passed, i.e. showed the gaps):
- F1: a unit field the assignment never named passed through into the
  signed payload (execute and device-reported paths). bind_event_payload
  now refuses any reserved field (jobId, settlementUnitId, challengeNonce)
  the binding does not name. On the device-reported path an unbindable
  report is logged and the job reported failed, instead of an exception
  that left it running.
- F2: half a binding (a unit without its nonce, or the reverse) was
  accepted; the two must now come together.
- F3: an explicit null counted as absent; a present field must hold a
  value, as kernel-sdk requires (400).
All four probes now fail. 7 new tests. pcc-node 1184 passed, 2 failed
(the headless pair fixed by #388).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy
LamaSu added a commit that referenced this pull request Sep 24, 2026
…e outbox

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

LamaSu commented Sep 24, 2026

Copy link
Copy Markdown
Owner Author

Evidence lane re-review of #420 @46d539d1: APPROVED

🤖 Generated with Claude Code

https://claude.ai/code/session_0117ows6894R3n6YQXBCRahS

…vidence binding

Brings #377 @63663616 (the r31 round-1 fixes) under #420's LO-EV-9
binding. Two semantic conflicts, resolved by hand:
- _check_ipp: #420 renamed the IPP handle key job_id to cupsJobId, and
  round 2 added the verdict's expected-job-id argument. The auto-merge
  kept the old key on the new line; it now reads handle["cupsJobId"]
  both times.
- test_no_job_id_is_a_failure_and_is_never_registered: under #420,
  execute() refuses an assignment with no job id before the claim and
  before the device, so no status is reported (there is no id to report
  to). The test now asserts that stricter refusal.

pcc-node 1420 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>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…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>
@LamaSu

LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

Merged #377's round-2 fixes forward: head 9059b12 (a merge of 6366361; no force-push).

Two semantic conflicts were resolved by hand:

  • The IPP verdict now uses handle["cupsJobId"].
  • An assignment with no job id is refused before the claim, so no status is reported.

pcc-node: 1420 passed; the 2 failures predate the stack. The in-family approval (evidence, #3333) was at 46d539d; the binding code is unchanged since then.

Cross-family pack: 43 (together with #423).

…vidence binding

#377 @ 67875cd closes astra's round-2 findings (pack 42). Generic HTTP
never completes, and OctoPrint completion tracking is withdrawn (both are
acceptance-only). IPP reasons use strict RFC 8011 keyword syntax plus a
completion allowlist.

Conflict: tests/test_completion_pollers.py, where #377 deleted the OctoPrint
history section and this branch had edited it. Resolved by deleting the
section: its tests covered removed code. The binding logic is unchanged:
the IPP handle keeps cupsJobId, and the device-reported bundle still binds
the assignment. Both of this branch's binding test classes run on IPP and
pass.

pcc-node: 1310 passed. The 2 failures predate this (test_cli start_flow,
test_discovery start_with_discover_flag). The verdict probes pass 54/54 on
this tree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu added a commit that referenced this pull request Sep 29, 2026
…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>

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.

2 participants