Skip to content

fix(gateway): register only declared tiers and pricing from heartbeats (board N23) - #437

Draft
LamaSu wants to merge 4 commits into
masterfrom
fix/heartbeat-declared-terms-only
Draft

LamaSu wants to merge 4 commits into
masterfrom
fix/heartbeat-declared-terms-only

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

Board row N23 (composition), the data-layer half. Steward ruling #3538.

KernelFacade.heartbeat serves POST /api/kernels/:kernelId/heartbeat and POST /api/operator/heartbeat, the path pcc-node's daemon calls. It auto-registers the capabilities a kernel announces. When an announcement carried no terms, it filled them in: assuranceTiers ?? [0, 1] and pricing ?? {currency: "USDC", baseCost: "0", minimum: "0"} (kernel.facade.ts 531-533 at master). Every reader then took the defaults as the provider's own offer:

  • The catalog lists a price nobody set ("USDC 0") and tiers nobody declared.
  • The plan re-read (R10, revalidatePlanSnapshots, feat(gateway): live provider re-read for externally authored plans (R10) #355 on the stack) re-reads rows as the provider's terms. So a heartbeat with a real price and no tiers yields a row that R10 sells at tier 1. R10 itself refuses the zero price (non-positive-price).

The change

A NEW announced capability is registered only when it declares both of these:

Term Required shape Stored as
tiers a non-empty list of integers 0..3, at most 16 entries a sorted set
pricing currency ([A-Za-z0-9]{1,16}), baseCost and minimum (the column's type needs both), plus any of perMinute, perGram, perCm3; all plain decimal strings, and at least one non-zero exactly as declared

Anything else is skipped, and the heartbeat response says why.

  • A new additive field, capabilitiesSkipped: [{type, reason}], with reason one of no-declared-tiers, invalid-tiers, no-declared-pricing, invalid-pricing or zero-price.
  • capabilitiesReceived still counts every announcement.
  • Existing rows are untouched. A heartbeat only refreshes their TTL, as before.
  • Capping tiers at the kernel's verified ceiling is N43's handler half (gateway, WP-C), not this PR.

Why skip rather than store an incomplete row. pricing is a NOT NULL column typed {currency, baseCost, minimum, ...}, so "no price" has no type-valid representation. An off-type {} would reach every catalog reader. A typed "terms not declared" state belongs in the capability DTOs (PX-7).

Know this before merging

  • pcc-node's daemon announces capabilities without terms. daemon.py _build_capabilities_from_devices sends {type, deviceId, protocol}.
    • After this PR, a NEW kernel's daemon-announced capabilities are not auto-registered until the daemon declares tiers and pricing.
    • Today those rows carry the invented defaults, and they are unsellable through R10 anyway because of the zero price.
    • Follow-up for pcc-node's owner: declare the terms. The agent package's operator_heartbeat schema also leaves the capability items untyped.
  • Existing defaulted rows in production remain, and one kind is live-sellable. Fixing or retiring them is a production DB write, so it is parked for the operator and not part of this PR. There are two kinds.
    • Both terms defaulted (pricing exactly USDC / "0" / "0", tiers [0, 1]). R10 refuses these on the zero price.
    • A real declared price with DEFAULTED tiers. The kernel sent pricing but not tiers, so the row was stored with tiers [0, 1]. R10 sells these at tier 1 today. They cannot be told apart from a declared [0, 1] by the row alone.
    • Candidates can be found as rows whose id is cap-<kernelId>-<type>, whose description is the heartbeat default (Auto-registered from heartbeat for kernel <kernelId>), and whose tiers are [0, 1]. A kernel that sent its own description is not caught this way.
    • The operator decides whether to retire them or require re-declaration.
  • Gateway's unpushed WP-C fold may touch the same file (kernel.facade.ts; steward #3538).
  • Siblings the sweep found, left alone here and listed for their owners:
    • facades/capability.facade.ts:337: POST /api/capabilities defaults pricing to "USDC 0" (N43's handler half, gateway).
    • routes/onboard.ts:84, routes/wizard.ts:590, routes/setup.ts:655: zero-price defaults.
    • routes/capabilities.ts:679: the TD output defaults tiers to [0, 1, 2, 3].
    • services/agentic-decomposer.ts:238: the legacy decomposer's ?? [0, 1]. Its proposals re-quote at R10.
    • This same insert's location ?? {lat: 0, lng: 0}, outside the ruled scope.

Tests

Suite Result
new heartbeat-declared-terms.test.ts 9 passed
protocol-fixes and kernel-ttl-integration, whose announcements now declare terms passed (31 with the new file)
full gateway 2936 passed, 6 skipped, 0 failed

Two suites fail to load in a worktree without a built @pcc/verifier dist: capture and capture-3d. They are untouched here, and CI builds first.

  • The real-state test runs the real routes and the real facade over an in-memory store, including the exact payload pcc-node's daemon sends. It shows that no row is registered and none offers tier 1.
  • Mutations: 12 of 12 killed. Each was read from its counts:
    • restoring either default;
    • dropping the zero check;
    • weakening the tier range;
    • ignoring the tier skip;
    • dropping normalization;
    • dropping any pricing pattern or presence check;
    • accepting an empty list;
    • dropping the tier-count cap;
    • keeping unknown pricing keys.

Review

  • Gateway reviews on resume (steward #3538).
  • Cross-family: pack 53.

🤖 Generated with Claude Code

…s (board N23)

KernelFacade.heartbeat serves POST /api/kernels/:kernelId/heartbeat and
POST /api/operator/heartbeat. It auto-registers the capabilities a kernel
announces, and it used to fill in missing terms with tiers [0, 1] and
"USDC 0". Every reader then took the defaults as the provider's own offer:
- the catalog showed a price nobody set;
- the plan re-read (R10, #355) would sell tier 1 on a kernel that never
  offered it, because it re-reads the row as the provider's terms.

A new announced capability is now registered only with declared,
well-formed terms:
- tiers: integers 0..3, non-empty, at most 16 entries, kept as a sorted set;
- pricing: a currency, plus baseCost and minimum (the column's type needs
  both), with any variable components, all as plain decimal strings and at
  least one non-zero.
Anything else is skipped and reported in an additive capabilitiesSkipped
field. Existing rows are untouched: a heartbeat only refreshes their TTL.

Skip rather than store an incomplete row: pricing is a NOT NULL column with
a typed shape, so "no price" has no type-valid representation.

Tests:
- New real-state test over the real routes and facade, including
  pcc-node's actual {type, deviceId, protocol} announcement.
- protocol-fixes and kernel-ttl-integration now declare their terms.
- Full gateway suite: 2936 passed, 6 skipped. The capture suites fail to
  load without a built @pcc/verifier dist, as before.
- Mutations: 12 of 12 killed.

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

AGENT_NAME: implementer-bravo

PR #437 astra review (Q1 MEDIUM): a heartbeat's per-capability loop read
`cap.type` via a bare type assertion outside any per-entry try/catch, so a
`null` (or otherwise non-object) entry threw and aborted every later,
otherwise-valid entry in the same announcement. Non-string `type`/
`capability_type` values were also accepted uncoerced into the row id and
the insert. Separately, an insertion exception was swallowed by the
existing non-fatal catch with no skip reason, so the response acknowledged
a capability that was never persisted.

Fix: validate each entry (a plain object with a non-empty string `type` or
`capability_type`) inside the per-entry handling before touching any of its
fields; a malformed entry is now skipped and reported (`invalid-entry`) and
later valid entries still register. A storage exception on insert is now
reported (`storage-failed`) instead of silently acknowledged. No default
tier or price is invented anywhere; failure still means skip-and-report.

Tests: null_announcement_does_not_abort_later_valid_entries,
"a non-string type is skipped and reported...",
failed_insert_is_reported_as_unregistered (spy on the capabilities repo's
insert to force the storage failure). 12/12 passing
(packages/gateway/src/__tests__/heartbeat-declared-terms.test.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AGENT_NAME: implementer-bravo

PR #437 astra review (Q1 MEDIUM): the declared-pricing validator's decimal
check (`[0-9]{1,30}` per side) accepted non-canonical spellings such as
"00.10" and "01". R10 (packages/gateway/src/services/
plan-snapshot-revalidation.ts on the stacked branch, not present on this
one) then rejects those at acceptance-time revalidation as malformed, so
the capability could register here and never actually be sellable —an
availability/interop defect, not a claim of incorrect payment.

Fix: copy R10's canonical grammar verbatim (no leading zeros in the
integer part unless it is exactly "0"; trailing fractional zeros allowed)
plus its 100-char length cap, replacing the old per-side digit-count
check. A non-canonical spelling is skipped and reported
(`invalid-pricing`) — never rewritten to a canonical form.

Also documents (no behavior change) the pre-existing alphanumeric currency
restriction: it rejects dotted variants like "USDC.e" and symbols like
"$"; no supplied contract requires those spellings today, so this is
recorded as a deliberate restriction, not a demonstrated settlement
defect (astra review, Q2).

Test: heartbeat_rejects_noncanonical_leading_zero_price, plus unit-level
grammar cases in the declaredPricing describe block. 14/14 passing
(packages/gateway/src/__tests__/heartbeat-declared-terms.test.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…overage

AGENT_NAME: implementer-bravo

PR #437 astra review (Q5 MEDIUM — sensitive boundary cases remain
unpinned). Test-only, no code change: none of these expose a defect
beyond what M1 (invalid-entry validation) already fixed.

- capability_type alias follows the exact same validation as type: a
  valid alias registers; a non-string runtime type is skipped the same
  way `type` is (exercises the same 437-M1 code path, not a new defect).
- Duplicate-order within one heartbeat: invalid-first + valid-second
  registers the second; valid-first + invalid-second leaves the first
  row unchanged (pre-existing, correct "first successfully inserted
  declaration wins" behavior — pinned, not changed).
- An existing row's TTL advances and its declared terms are preserved on
  both a termless heartbeat for that type and an empty-list heartbeat
  (the bulk no-capabilities-body refresh path).
- The pre-existing "an existing row is only refreshed" test relied on
  the previous test's "fdm" insertion; gave it its own setup under a
  distinct type so it no longer depends on execution order.

20/20 passing (packages/gateway/src/__tests__/heartbeat-declared-terms.test.ts).
Also re-ran all kernel/heartbeat/capability suites in the package: 19
files, 235/235 passing, no regressions.

Co-Authored-By: Claude Sonnet 5 <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