Skip to content

fix(pcc-node): announce capabilities through the heartbeat, not the store-nothing stub - #390

Draft
LamaSu wants to merge 4 commits into
masterfrom
fix/pcc-node-announce-via-heartbeat
Draft

LamaSu wants to merge 4 commits into
masterfrom
fix/pcc-node-announce-via-heartbeat

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

pcc-node start announced capabilities to POST /api/kernels/<id>/capabilities. That route is a stub: it acknowledges and stores nothing (kernel.facade.ts:610-635), so a node registered from the CLI stayed undiscoverable.

As agreed with gateway (bus #2622 item 3), the announcement now goes to POST /api/kernels/<id>/heartbeat with {status, capabilities:[{type}]}. Gateway's WP-C makes that route the owner-only, tier-clamped catalog writer.

  • Credentials no longer sent. Raw device dicts, including URLs and credentials such as an OctoPrint api_key, stay on the node.
  • Short acknowledgements are logged. A 2xx that records fewer capabilities than were sent logs a warning.
  • Tests. The 4 new negative tests are polarity-proven: they fail on ac86a404. The pcc-node suite is 339 passed and 2 failed; the 2 failures are pre-existing and fixed by ci+fix(pcc-node): run the Python suite in CI; headless start; tests keep keys in tmp (N52) #388 (N52).

Owner: pcc-adk (pcc-node, steward #2636). Review: gateway (the route's owner), then coord-watch.

Gateway review (#3244: SHIP with notes), applied at b7e87acf

  1. The signature is rebuildable. Capability types are sorted, and the docstring states the canonical signed form: compact, key-sorted JSON of {kernelId (from the path), capabilities (the body's types, in order), timestamp (the body's)}. A test verifies a real Ed25519 signature over the message rebuilt from the request alone. Without PyNaCl the announcement goes unsigned, instead of carrying an HMAC nobody else can verify.
  2. Tiers. Every capability sends assuranceTiers: [0]; the node claims no attestation.
  3. Ownership. Unchanged: the heartbeat uses the key that registered the kernel.
  4. Local pytest output. CI runs no Python suites yet; ci+fix(pcc-node): run the Python suite in CI; headless start; tests keep keys in tmp (N52) #388 adds that.
$ python3 -m pytest -q -p no:cacheprovider tests   # packages/pcc-node @ b7e87acf, HOME in tmp
FAILED tests/test_cli.py::TestStartCommand::test_start_flow - assert 1 == 0
FAILED tests/test_discovery.py::TestDiscoverCommand::test_start_with_discover_flag
2 failed, 342 passed in 14.72s

The 2 failures are the headless-consent pair that also fails on master. #388 fixes them at the root cause. Four mutants against the new tests were all killed: types unsorted, no tier-0 claim, signing a different form, and an HMAC stand-in.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy

…tore-nothing stub

`pcc-node start` announced detected capabilities to
POST /api/kernels/<id>/capabilities. That route is a stub
(kernel.facade.ts:610-635): it answers acknowledged and stores nothing, so a
node registered from the CLI never became discoverable. Only the daemon's
heartbeat wrote the catalog. As agreed with gateway (bus #2622 item 3), the
announcement now goes to POST /api/kernels/<id>/heartbeat with
{"status": "online", "capabilities": [{"type": slug}, ...]}. Gateway's WP-C
makes that route the owner-only, tier-clamped catalog writer.

The raw device dicts are no longer sent. Before, the payload carried every
detected device verbatim, including URLs and credentials such as an OctoPrint
api_key. Only capability types leave the node. Slugs are de-duplicated. A
2xx that records fewer capabilities than were sent is logged as a warning.
The optional Ed25519 announcement signature is unchanged.

Tests: TestAnnounceCapabilities now asserts the heartbeat path. New tests:
the stub is never used, device credentials never leave the node, and a short
acknowledgement is logged. fails-on-base: 4 of 4 new negatives fail on
ac86a40; the 2 controls pass on both. pcc-node suite on this branch: 339
passed, 2 failed (the pre-existing pair fixed by #388, N52).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PkeiQMnVePeBGU9svhREFy
LamaSu and others added 2 commits September 24, 2026 16:22
… (gateway review of #390)

Gateway reviewed #390 as SHIP with notes (#3244); this applies notes 1 and 2:
- every announced capability carries assuranceTiers: [0]. The node proves
  nothing by announcing, and the gateway's default for an absent claim is
  wider ([0,1]);
- capability types are sorted before signing and sending, and the docstring
  states the canonical signed form (compact, key-sorted JSON of kernelId
  from the path, the body's capability types in order, the body's
  timestamp), so a verifier can rebuild it from the request alone;
- without PyNaCl the announcement goes unsigned instead of carrying an
  HMAC that no one else can verify.

Tests: 3 new (sorted + tier 0; the signature verifies over the rebuilt
message with a real Ed25519 key; no HMAC stand-in). pcc-node 342 passed,
2 failed (the headless-consent pair fixed by #388). 4 mutants killed.

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

The gateway registers a capability announced by heartbeat only with
declared, well-formed terms (#437). The node claimed assuranceTiers [0]
for every capability, which is itself an invented default, and sent no
pricing. Now the node announces only what the operator declares, per
device, in its own config.

- pcc_node/declared_terms.py (new): declared_tiers and declared_pricing
  mirror #437's declaredTiers and declaredPricing (same reason codes).
  validate_device_terms is stricter: an unknown pricing key (which the
  gateway would drop silently) and a half declaration are errors naming
  the device and the field. announceable_types inverts the executor's
  CAPABILITY_PROTOCOL_MAP, so a type is offered only if a job of that type
  can be routed to the device. announcement_plan refuses two devices
  offering one type under different terms (the gateway keeps one row per
  kernel and type).
- config: load_config checks every declaration, so a malformed one stops
  the node. The kernel-wide pricing default {"base": 10, "per_minute":
  0.15} is removed. merge_detected_devices keeps configured entries whole:
  `start` used to REPLACE them with the detected devices, which would have
  erased every declaration on the next start.
- register: announce_capabilities sends only declared capabilities with
  their terms, signs exactly what it sends, logs and returns the gateway's
  capabilitiesSkipped, and sends nothing when nothing is declared.
  register_kernel no longer sends "devices" (raw dicts, credentials
  included) or "pricing", which the gateway never read. register_devices
  sends only public fields as adapterConfig, and never a URL with user
  info.
- daemon, cli, ws_client: the periodic path announces the declared
  capabilities, or sends a plain liveness heartbeat when none are
  declared. The state file and `pcc-node status` show what was announced,
  what was not and why, and what the gateway refused. `start` stops with
  the file unchanged when the config does not load (it used to generate a
  new config and save it over the operator's). The config wizard asks for
  per-device terms with no defaults.

Tests: test_declared_terms.py (99) mirrors #437's accept and skip cases one
for one, plus the Python-only edges. It also checks that nothing declared
means nothing sent (at announce and at the daemon's periodic path), the
signature over the terms, the no-credential rules, and a byte-identical
config after a failed start. test_cli adds a start that keeps the
configured device and its terms. 21 of 21 mutants of the new rules are
killed. pcc-node: 440 passed. The 2 failures predate this (test_cli
start_flow and test_discovery start_with_discover_flag: the first-run
diagnostics prompt reads EOF under CliRunner).

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

LamaSu commented Sep 29, 2026

Copy link
Copy Markdown
Owner Author

7b8da976: N23's node side (steward #3560; it ships before or with #437). The node announces a capability only with the tiers and pricing its operator declared per device, checked with #437's exact rules (pcc_node/declared_terms.py). There are no defaults: the assuranceTiers: [0] claim and the kernel price {base: 10, per_minute: 0.15} are gone.

Also fixed:

  • start replaced the configured devices, and their terms, with the detected ones; it now keeps them.
  • A config that failed to load was overwritten; start now stops and leaves the file unchanged.
  • register_kernel sent raw device dicts with credentials; it no longer does, and register_devices sends only public fields.
  • The two announce paths mapped devices to different capability types; there is now one map.

Tests: 99 new plus a start test; 21/21 mutants killed; pcc-node 440 passed (the 2 failures predate this). The cross-family review is pack 78.

🤖 Generated with Claude Code

Kit manifests match devices by the kernel's AdapterType names in
compatibility.interfaces (kits #3407 A4). pcc-node advertised its own labels:
a discovered HTTP device went to POST /api/devices/register as
adapterType "http", and an IPP printer typed "printer" as "printer". Neither
is in the union (packages/kernel/src/kernel-config.ts), so a generic-http kit
could never match a pcc-node device (kits #3777, refvertical #3571;
reproduced before the change).

register_devices now sends advertised_adapter_type(device). "http" and
"generic" become "generic-http", and "printer" becomes "ipp". An AdapterType
name passes through. A label with no AdapterType (camera, serial) is
unchanged, so it matches no kit. The executor's internal protocol labels are
untouched.

pcc-node: 450 passed. The 2 failures predate this.

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.

1 participant