Skip to content

fix(gateway): honest operator-relay evidence, claim authentication, immutable authorized ceiling (LO-GW-4/3a, R-06) - #335

Draft
LamaSu wants to merge 8 commits into
masterfrom
fix/gateway-cheap-wins
Draft

fix(gateway): honest operator-relay evidence, claim authentication, immutable authorized ceiling (LO-GW-4/3a, R-06)#335
LamaSu wants to merge 8 commits into
masterfrom
fix/gateway-cheap-wins

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 9, 2026

Copy link
Copy Markdown
Owner

What this closes

Three narrowly-scoped fixes on the gateway, requested by the gateway lane (bus #2065), accepted by the steward (bus #2069): 1 -> {4,3,2} -> 5 sequencing, LO-GW-1 keystone stays with gateway.

  • LO-GW-4a/4boperator-relay.ts's evidence-relay route no longer invents a kernelSignature on the unsigned path (now deviceSigned:false, kernelSignature:null, assuranceTier:0), and the pushed events array — previously silently dropped on both branches — is now persisted via insertEvents inside one DB transaction, with duplicate event ids and unservable bundleHashes refused rather than silently accepted or substituted.
  • LO-GW-3a — the job-offers claim route now authenticates the claimant and checks principal -> kernel ownership before touching offer state, mirroring the requirePoster-style guard sibling routes already use. Round 2 found and closed a second, previously-unguarded claim door (POST /api/courier-jobs/:id/claim) with the same shared guard, so the two doors can't drift apart again.
  • R-06 / LO-GW-2a — the stored budget is now an immutable authorized ceiling; derivedBudget is reported as an estimate and never silently applied, enforced at both money-committing points (/:id/publish and the direct-match auto-publish inside POST /api/requests). PUT /api/requests/:id now requires the authenticated requester/broker for any field that carries or determines authority (budget, currency, requesterEmail, requesterWallet).

Full detail, file-by-file, and the negative-control tables: ai/research/delta-steward/stages/05_spawn/output/GW-CHEAP/publish.md (this PR's ICM return file — named publish.md rather than REPORT.md because the harness blocks subagents from writing files matching a report/summary naming pattern; content is otherwise the full Inputs/Process/Outputs report).

Test evidence (DGX Spark, raw, at this exact HEAD)

bash ~/.local/bin/spark-test-branch C:/Users/globa/pcc-gw-cheapwins-wt fix/gateway-cheap-wins @pcc/gateway

 Test Files  189 passed (189)
      Tests  3021 passed | 6 skipped (3027)
   Start at  18:13:40
   Duration  23.60s

[spark-test] exit=0

Log: C:/Users/globa/pcc-gw-cheapwins-wt/.spark-test/fix-gateway-cheap-wins-20260909T011338Z.log, confirmed run at HEAD b60d180d (this branch's tip). Zero failing files, zero failing tests.

Typecheck was verified no-new-errors at the round-1 head (92e34ac4) only, not independently re-checked at this HEAD — flagged below, not hidden.

Negative controls (one per item, observed rejection)

  • LO-GW-4: unsigned relay -> no invented signature, assuranceTier:0; events persist atomically (insertEvents in one transaction) or not at all; a duplicate event id in one bundle -> 400 duplicate_event_id, nothing committed; an unservable bundleHash -> 400 invalid_bundle_hash (refusal, not substitution).
  • LO-GW-3: a stranger claiming a kernel's offer, through either claim door -> 403 not_kernel_operator, offer stays open; anonymous claim -> 401 missing_identity. Positive control: legitimate kernel operator claims -> 200 through both doors; the documented v0.2 free-form courier driverAgent claims under its own name -> 200.
  • R-06 / LO-GW-2a: a stranger raising their own ceiling, or rewriting the identity fields that would let them become the requester, via PUT /api/requests/:id -> 403 not_requester, budget unchanged, publish still 409, zero offers. Anonymous attempt -> 401. Positive control: the actual requester raises their own ceiling by wallet identity -> 200.

Independent review so far (not generated for this PR)

ai/research/delta-steward/stages/02_grade/output/grades-round2-evidence.md (steward stage 02) independently cross-checked this branch's round-1 head (92e34ac4) against the evidence lane's wire-contract proposal for evidence signing. Its call: LO-GW-4 correctly holds at contradicted on master until this artifact lands (quoting it: "no proof level moves until the artifact lands") — this PR is that landing. It also named four contradictions between the wire-contract spec and the code; two are closed-by-honesty in round 2 (the response now carries signatureVerified:false so deviceSigned:true can't be misread as cryptographically checked), one is correctly out of this brief's scope (ingest-time crypto verification is a separate, larger item), and one is still open: the evidence lane, as wire-contract owner, has not yet blessed or contested the unsigned-path bundleHash construction (gateway-computed, content-addressed sha256:<canonical envelope>). That's the top item for @gateway-lane / evidence-lane reviewers below.

LO-GW-3 and R-06/LO-GW-2a have not yet had an independent (non-implementer) pass — only the implementer's own Spark runs and self-reported controls (both rounds, internally consistent, file:line-cited). Recommended focus for review.

Scope, explicitly

In scope: packages/gateway/src/** + packages/spec/src/types/requests.ts (one new field, pre-approved by the brief). Out of scope, untouched: settlement/release code paths, feat/g2-package-digest-v2, reservation/job creation (LO-GW-3's other half — separate greenfield item), LO-GW-2's larger signed-configuration obligation (prePolicyRoot/UnitConfig/JobPolicy — still "not started"), print-and-mail handoff-evidence auth (same family of gap as LO-GW-3, different route), and the job-offers principal still being self-asserted via X-Posted-By on v0.2 surfaces. No new escrow binding. No test weakened, skipped, or special-cased.

Per this workspace's non-negotiables, money-path items need a cross-family review seq before rising above integrated-locally — not required at the current implemented rung, flagged for when merge is considered.

Reviewer: gateway 0600b204; steward 57c2a412; operator merges.

🤖 Generated with Claude Code

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com

https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi

LamaSu and others added 8 commits September 8, 2026 14:23
…e honestly (LO-GW-4a/4b)

POST /api/operator/evidence had three defects that made relayed evidence
unreliable as a record:

1. LO-GW-4a — the unsigned path invented a signature. It stored
   `{signer: <kernelId>, algorithm: "sha256", value: "operator-relay-auto"}`,
   naming a real kernel as the signer of bytes it never signed. The route now
   reports `kernelSignature: null` and stores NO_DEVICE_SIGNATURE — a record
   that asserts nothing (no signer, algorithm "none", empty value).
   `evidence_bundles.kernel_signature` is TEXT NOT NULL (packages/db
   migrate.ts:89), so absence is recorded inside that constraint rather than
   as SQL NULL; relaxing the column is a table rebuild on the money-path
   evidence table and is left as a flagged follow-up.

2. LO-GW-4b — the pushed `events` array was dropped on BOTH branches:
   `repos.evidence.insertEvents` was never called from this file. The bundle
   row survived; the evidence inside it did not, so GET /api/evidence/:hash
   served a document with `events: []` and the oracle's authenticity floor
   (pcc-oracle PR #15), which reads `bundle.events[]`, had nothing to read.
   Events are now normalised and persisted on both paths.

3. The unsigned path committed `sha256-<uuid>` — a synthetic string that
   commits to no content and is not in any hash form `GET /api/evidence/:hash`
   recognises, so the bundle was unreachable by the oracle's fetch. It is now
   `sha256:<sha256(canonical envelope)>`, the same construction
   paid-job-flow.ts:1060 uses and the exact bytes the retrieval route serves.

Unchanged: the signed path keeps the device's real Ed25519 signature and its
own bundleHash (SEAM-2 anchors settlement on them), and assuranceTier stays 0
on both paths — relayed evidence is unverified, and the gated #52 verifier
still owns whether it may settle.

Tests: signature absence + response null; events persisted on signed and
unsigned paths; a full commit -> fetch -> raw-byte re-hash round trip; and
three negative controls (unsigned bundle, dropped events, a node replaying the
legacy placeholder) showing none reaches a settle-eligible state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
…against kernel ownership (LO-GW-3a)

POST /api/job-offers/:id/claim read `kernelId` straight off the request body
(:269, :274) and handed it to `store.claim()` with no identity resolution and
no ownership lookup. Any caller could name ANY kernel and the offer was
recorded as claimed by it — claiming is the step that binds a job to a physical
site, so this let a caller attribute work to a site they have nothing to do
with. The sibling PATCH / DELETE / heartbeat routes on the same surface already
had the `requirePoster` shape; claim was the one that did not.

The route now:
  1. resolves an authenticated principal via `requirePoster` (401 otherwise) —
     the same helper and the same error the siblings use; and
  2. checks principal -> kernel ownership with `requireKernelOperator`, which
     mirrors the guard the sibling money-path route already runs
     (routes/carrier.ts:637-648) and the predicate in
     mcp/operation-policy.ts:336 — `shop_kernels.operatorAddress === principal`.

The body's kernelId is now an assertion to be checked, never an identity. Fails
closed on every branch: unknown kernel -> 404, lookup failure -> 502, kernel
with no recorded operator -> 403 (`nobody owns it` is not `anybody may claim
for it`), wrong operator -> 403. Authorization runs before any offer state is
touched, so a refused claim leaves the offer open with no `claimed` event.

Scope: claim authentication only. Reservation and job creation (LO-GW-3b) are
greenfield and deliberately untouched.

Tests: the existing claim cases now go through the authenticated door with
kernels the principal actually operates (behaviour asserted is unchanged —
race-safety still yields 1 winner / 9 conflicts, now across 10 kernels owned by
one principal). Five negative controls added: anonymous claim, an unrelated
principal naming someone else's kernel, the legitimate owner of that same
kernel succeeding, a non-existent kernel, and an unowned kernel — each
asserting the offer is left open and unattributed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
…ization limit (R-06 / LO-GW-2a)

Two sites did `request.budget = result.derivedBudget` — POST /api/requests
(:426-427) and POST /api/requests/:id/decompose (:557-558). Decomposition
overwrote the requester's stated budget with whatever the matched capabilities
happened to cost, so a reprice silently became an authorization: the request
came back reporting a number the requester had never seen or agreed to.
packages/spec already documented derivedBudget as an ESTIMATE (requests.ts
:107-113); only the route treated it as authority.

`budget` is now the AUTHORIZED CEILING — set once at creation, immutable to
every repricing path (it is also dropped from the /decompose update payload so
no decomposition can rewrite the column), and surfaced under its real name as
`authorizedCeiling`. Repricing is reported instead, via a new
`budgetAuthorization` block on both decomposition responses.

Enforcement lives where money is actually committed:
  - POST /:id/publish refuses with 409 `budget_authorization_exceeded` while
    the priced commitment exceeds the ceiling — no bounties, no job-offers.
  - the direct-match path auto-publishes inside POST /api/requests without ever
    passing through /publish, so it is gated there too. Over the ceiling the
    request is still created (the requester needs to see the price) but rests
    in "decomposed" with nothing live. That path is the one that always carries
    a real price, so leaving it ungated would have left the ceiling inert
    exactly where it matters most.
  - the only way up is renewed acceptance: the requester raising the ceiling
    explicitly via PUT /api/requests/:id.

The gate counts only nodes backed by a registered capability, via the existing
`resolveMatch` (now exported) — the same predicate that decides which nodes
become live job-offers, and the one place that reconciles the codebase's two
"matched" conventions. Unmatched nodes carry template guesses, not quotes, so
they cannot consume authority; counting a different set than the one that
commits money is how a ceiling silently stops applying.

Tests (new requests-budget-authorization.test.ts, real listing prices):
below-ceiling allowed and published; above-ceiling refused with budget provably
still the stated number and nothing live; publish 409 with no bounties issued;
renewed acceptance unblocking publish (blocked first, so the success is
attributable to the raise); re-decompose not rewriting the ceiling; and a
template-only plan not being blocked by one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
…the request-creation path (R-06)

POST /api/requests builds its CapabilityRequest as a literal and returns that
object directly; it never passes through `rowToRequest`, which was the only
place `authorizedCeiling` was being set. So every read path reported the
ceiling and the creation response omitted it — caught by the new
below/above-ceiling tests asserting it on the 201 body.

The ceiling is now established once, explicitly, at the point of creation and
reused for `budget`, so the write path and the read paths cannot disagree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
…cally and cannot collide (LO-GW-4b)

Round 1 persisted the pushed events, but as a SECOND autocommitted statement
after the bundle row. better-sqlite3 commits each statement immediately, so a
throw from insertEvents left a DURABLE bundle whose content-addressed hash
covers events that were never stored: GET /api/evidence/:hash then rebuilds
`events: []`, the served bytes no longer re-hash to the committed hash (oracle
fails closed) and the authenticity-of-origin floor reads nothing. The response
said `stored:false` while the row persisted, and settlement.facade reports a
job's LAST bundle — so the orphan became the job's advertised evidence.

Two node-controlled triggers, both on the canonical shape (EvidenceEvent.id is
REQUIRED by @pcc/spec and always set by the kernel-sdk):
  (A) two events sharing an id inside one relayed array;
  (B) the same bundle relayed twice (a retry after a timeout — or after this
      route answers `stored:false`, which invites the retry): the stable node
      event ids collide with the FIRST bundle's rows, and insertEvents is one
      multi-row INSERT, so a single collision aborts every row.

Fixes, all inside packages/gateway:
  - ONE transaction around the bundle row and its events (getStore().db, the
    same connection the repositories hold — the pattern routes/artifacts.ts
    already uses). `stored:false` now means nothing was written.
  - Stored event ids are BUNDLE-SCOPED (`<bundleId>:<node id>`).
    `evidence_events.id` is a GLOBAL primary key while the node's id is only
    unique inside its own bundle; the relay is the boundary where an external
    un-namespaced id enters a global keyspace. The gateway's own fallback id
    was already bundle-scoped, so both branches now agree, and the same array
    is hashed and stored so the envelope still round-trips byte-identically.
  - Within-bundle duplicate ids are refused (400 duplicate_event_id) before any
    write, rather than silently renamed.
  - A signed bundle whose caller-supplied bundleHash is not a recognised hash
    form is refused (400 invalid_bundle_hash): it could never be served back by
    GET /api/evidence/:hash. Refusal, not substitution — computing a hash for it
    would make an unverified bundle MORE reachable.
  - The response adds `signatureVerified: false`. `deviceSigned` reports that a
    well-formed signature was PRESENT, never that it was checked.
  - PLACEHOLDER_SIGNATURE_VALUES documented as not a sufficient placeholder
    test (the relay's no-signature sentinel is not a member by design), with
    the invariant pinned by a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
… the same authority (LO-GW-3a)

Round 1 added an ownership guard to POST /api/job-offers/:id/claim. A
refutation showed the guard protected one door into a store with two:
POST /api/courier-jobs/:id/claim — a live backward-compat shim registered
in the same app (server.ts:751 job-offers, :754 courier-jobs) over the same
singleton store — validated only `if (!driverAgent) 400` and forwarded
straight to JobOffersStore.claim, which looks an offer up in ONE map with
no capability filter and no owner check. Same write, same field
(claimedByKernelId), no authority. The shim even defines requirePoster and
never calls it on that handler.

Measured against the guarded route, the shim was strictly worse: it took a
kernelId that does not exist, on a lab.hplc offer unrelated to couriers,
with no identity header at all — where the guarded route answers 401 and
404 kernel_not_found. Production-reachable: /api/courier-jobs/:id/claim is
not public (the public regex is single-segment and GET-only), so it needs a
key, but POST /api/auth/provision is public and the route then ignored the
identity entirely, making the key a turnstile rather than authorization.

The claim is load-bearing past the offer row. routes/print-and-mail.ts
states its authentication model as "presenting the driverAgent that matches
the claim" and gates handoff EVIDENCE on `claimedBy === driverAgent`, so an
unauthenticated claim let a caller pick the value that later authorizes
evidence for that job.

Fixes, all inside packages/gateway:
  - auth/kernel-operator.ts — ONE implementation of "may this principal act
    for this kernel?", extracted from job-offers.ts so the two doors cannot
    drift. Same predicate as routes/carrier.ts and mcp/operation-policy.ts
    (shop_kernels.operatorAddress === principal); still fails closed on
    unknown kernel / failed lookup / unowned kernel. It never throws: an
    uninitialised store must read as "refuse", not 500.
  - The courier claim requires an authenticated identity (requirePoster,
    as every other mutating route in that file already does) and BINDS
    driverAgent to it: a registered kernel must be operated by the
    principal (identical authority to the generic route, so the shim is
    never the softer door), otherwise the name must BE the principal. A
    failed lookup falls through to the second rule, which is no more
    permissive than that rule alone.
    driverAgent is deliberately NOT required to be a kernel: the v0.2
    surface this shim preserves uses free-form driver-agent names
    (docs/COURIER_MATCHING.md posts "drone-a3"; print-and-mail's gig worker
    is the same shape), and a flat kernel rule would break that documented
    flow. What was missing was never kernel-ness — it was any binding to
    the caller.
  - CourierJobsStore.claim only reaches courier.dispatch offers. Every READ
    on the shim was already courier-scoped (listOpen, countByStatus); claim
    was the inconsistency that let the courier door reach money-path offers
    produced by job-offer-producer.ts, which stamps each offer with its
    request node's own capabilityType.

Tests: courier-claim-authz.test.ts builds ONE app with BOTH route sets, as
server.ts does, and pairs every case across the two doors — a test that
exercised only one door is what let this through. The v0.2 claim cases in
courier-jobs.test.ts now carry their own identity (the self-named branch)
and two refusals are pinned there as well.

KNOWN GAP, not fixed here (out of this brief's scope): binding the claim
does not by itself protect the print-and-mail handoff. GET /api/courier-jobs/:id
is PUBLIC, so claimedBy is readable, and POST /api/print-and-mail/:jobId/handoff
still authorizes on the string alone. That route needs the same treatment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
…he ceiling (R-06 / LO-GW-2a)

Round 1 made `budget` the authorized ceiling and made POST /:id/publish
refuse above it, pointing at PUT /api/requests/:id as the renewed-acceptance
path up. A refutation showed that PUT had no authentication and no
requester-ownership check, so "renewed acceptance" was available to anyone:
decompose, read the derived number off the response, PUT it as the new
budget, publish. The gate was real and the door beside it was open — round
1's own test raised the ceiling with an unauthenticated PUT, which is the
bypass written down as if it were the feature.

Fix: changing a field that CARRIES or DETERMINES authority requires the
authenticated requester (or a broker operator).
  - budget — the ceiling itself.
  - currency — a ceiling is an amount AND a unit; re-denominating 20 USDC as
    20 of something else changes the authority without touching the number.
  - requesterEmail / requesterWallet — the identity the ownership check
    reads. Leaving these writable would let a caller simply BECOME the
    requester and then raise the ceiling legitimately, which is why the
    refutation called the gate decorative without them.
Everything else (title, description, deadline, urgency) still updates
without authentication: this narrows the route to its authority surface
rather than locking the row.

Identity comes from apiGate / SIWE (`operatorId` / `userId`) ONLY —
deliberately not from a caller-settable header like X-Posted-By, which the
job-offers surface accepts for v0.2 compatibility. On the one surface that
decides how much money may be committed, a self-asserted principal is not an
authorization, so that weakness is not imported here. Same shape as
PUT /:id/nodes/:nodeId/status in this file. A request recording NO requester
has nobody to authorize against and is refused: "nobody owns it" must not
read as "anybody may raise it".

Also pins the refutation's second budget question — could spend happen
outside the ceiling through unmatched nodes, since matchedCommitment counts
only matched ones while the publish loop stamps a bountyId on every pending
node? Verified answer: no, and now tested. produceJobOffersForRequest HOLDS
the whole plan when any node is unmatched, so a partially matched plan
creates ZERO claimable offers; and node.bountyId is written in requests.ts
and read nowhere — bountyService mints its own ids, so those markers resolve
in no claim path. The gate needed no change; the invariant needed a test, so
that if either fact changes the test fails rather than the money moving.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
…ims under its own identity

Consequence of the LO-GW-3a round-2 fix, not a workaround for it. This
file's header states the gig worker "authenticates exactly as courier
drivers do today, by presenting the driverAgent that matches the claim" —
which was authentication by knowing a value the caller had itself chosen,
since the claim route accepted any driverAgent from anyone. The helper now
sends the driver's identity alongside the name, so the driver has to BE the
principal it claims as. The assertions the tests make are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PxrWRej6UAvZepXkpxJPi
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