Skip to content

b9e063fe - Build the v1 public API surface (§7.5) - #2

Draft
TaprootFreak wants to merge 23 commits into
developfrom
feat/v1-api-layer
Draft

b9e063fe - Build the v1 public API surface (§7.5)#2
TaprootFreak wants to merge 23 commits into
developfrom
feat/v1-api-layer

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Builds the API process into the sole public surface of spec-v1.2: the versioned /v1/ REST contract over the kernel.v1 gRPC client. It owns a non-value-bearing database, touches neither the kernel's store nor Bitcoin, and proves nothing itself. Hosted CI (added here) was paused for the rebuild and is switched back on in the final commit.

What this delivers

  • Fail-closed config and an honest discovery documentGET / advertises exactly the surfaces served, derived from one source so the two cannot drift.
  • Capability-gated pull (§5.1/§5.2). GrantProof fully verified in the spec's order; the effective scope is the intersection of grant and request — never widened, never unbounded. 401 vs 410 kept distinct.
  • Role/feature gating (§7.5) — a disabled surface is 404, not served; the active set follows configuration, not a constant.
  • Idempotency byte-exact — a missing key stays missing, an empty one is 400; DTOs refuse unknown fields; the closed ErrorInfo set is enforced (an unknown kernel reason fails loudly).
  • Terminal statesPOST /v1/tx no longer answers 202 for an empty job id or an unknown status.
  • Delivery credential (§7.5) — accepted at the edge on form, carried to the kernel unchanged, never marked verified, never logged. The kernel verifies; the API transports.

Verification

Locally green: fmt, clippy (-D warnings), the full test suite (240+). The carried kernel.proto is SHA-256-pinned against the node's copy so a drift is caught. Hosted CI is restored in the last commit.

This repo is the public REST surface of specification §7.5; the node is
the kernel behind it (§6.1). The scaffold puts the process skeleton in
place without pretending to serve anything it does not.

Configuration is fail-closed. ZKCOINS_BIND_ADDR, ZKCOINS_KERNEL_ADDR and
ZKCOINS_FEATURES are all required: absence is a named startup error, not
a default. There is no fallback bind host, no default kernel address,
and an unknown feature token aborts the boot naming both the token and
the closed set. A test asserts explicitly that a missing bind address
does not quietly become 127.0.0.1.

Two routes exist, GET / and GET /health, and GET /v1/info is covered by
a test that requires it to 404 -- a documented gap rather than a
placeholder handler.

The discovery document deserves a note, because the first version got it
wrong. It emitted all 29 closed §7.5 keys while the router registered
two, so a client reading it and calling /v1/info would get a 404. The
spec is explicit that a producer emits the closed keys "for the surfaces
this deployment exposes" and MUST omit keys for unadvertised roles. The
test asserting 29 keys made that claim a gate: green precisely because
the answer was untrue.

Registration and advertisement now derive from one source, ServedSurface,
so they cannot drift. A new variant without a handler is a compile error;
a served key missing from the inventory aborts at router construction;
and a test walks every advertised path and requires it to be reachable --
that test would have been red against the previous version at 28 of 29
keys. CLOSED_ENDPOINT_KEYS survives as the full inventory for surfaces
not yet built, checked against the spec ordering, and is deliberately not
what GET / returns.

Config::features stays unread for now. Reading it would either advertise
keys with no handlers or filter nothing; the parameter is destructured
by name so that adding a config field is a compile error rather than a
silent omission.

Verified locally: fmt, check --all-targets, clippy -D warnings, and 17
tests passing. The crate did not compile at all before this -- two errors
sat in config.rs and had never been built.
The node is a kernel: gRPC only. This is the REST side, and it holds no protocol
state, no database and no secrets — it translates §7.5 onto `kernel.v1` and back.

Five endpoints, all from the closed §7.5 inventory: `POST /v1/tx`,
`GET /v1/jobs/{id}`, `GET /v1/jobs/{id}/stream`, `POST /v1/jobs/{id}/sign` and
`POST /v1/jobs/{id}/cancel`, mapped onto `SubmitTransition`, `GetJob`,
`StreamJob`, `SignTransition` and `CancelJob`.

**The HTTP status comes only from `ErrorInfo.metadata["http_status"]`.** There is
one error table and it lives in the node; a second one here would be the drift
this project keeps removing. A status without a `kernel.v1` `ErrorInfo`, or with
an unusable `http_status`, fails closed rather than being guessed from the gRPC
code. Building this side is what exposed that the node was sending private
metadata headers instead of the normative detail — fixed there, in its own
commit.

The `.proto` is carried in this repo and pinned by content hash, with a test that
compares byte-for-byte against the node's copy when it is checked out alongside.
A copy without an equality check is a wire-drift source, and wire drift between
two repos is exactly the failure nobody sees until it is live. Codegen sits in
its own `kernel-proto` crate on tonic 0.13.1, matching the node line where
`tonic-build` still owns prost codegen.

Two findings from getting the tests to pass, both worth naming.

The routes were registered under the **spec's** path spelling — `/v1/jobs/<job_id>`
— which axum takes as a literal, so every real request 404'd. The advertised form
and the matcher form are two projections of one source now, derived by rewriting
rather than maintained as two lists, and `GET /` still advertises the spec
spelling because that is the contract.

Worse: the test named "every advertised endpoint is reachable" **passed**
throughout. It requested the advertised string itself, which matched the literal
route, so its input and its expectation came from the same place — a tautology
with a name, like the `MAX_RX_COINS + 1 > MAX_RX_COINS` assertion in the node. It
now substitutes a concrete value into each placeholder and distinguishes a
routing 404 (axum fallback, empty body) from a domain 404 (§7.5 body with a
`reason`), which is what makes it able to fail.

The test double replaces the kernel process, not the REST↔proto logic: it emits
domain errors through the same `ErrorInfo` encoding the real kernel uses — one
detail, `reason`, `domain`, `http_status` — so a passing error test is evidence
about the real wire form. It needs no Plonky2 proof.

`kernel-proto` carries `#![allow(clippy::all)]` at its root, as the node's does.
That crate contains nothing but generator output, so there is no finding to
suppress; the doc comment says the allow stops applying the moment hand-written
logic appears there.
Four more §7.5 endpoints: `GET /v1/info`, `GET /health/ready`,
`GET /v1/chain/accumulator` and `GET /v1/chain/nullifier/<pubkey>`, onto
`GetInfo`, `GetAccumulator` and `GetNullifierPath`.

This is the surface an outsider uses to check that a transition actually
landed. A job reporting `completed` proves it was applied locally and handed to
the broadcast path — not chain inclusion, not the scanner fold, not finality. So
the standard is the same one the node holds itself to: every field is passed
through from the canonical source, and the API computes nothing. The NAV root
stays `Hc("NfLog/Root", size ‖ mth)` as the kernel produced it; recomputing or
"checking" it here would create the second truth this project keeps deleting.

`GET /v1/chain/nullifier/<pubkey>` keeps present and absent apart, and an absent
answer omits `position` and `leaf` rather than sending zeros. A kernel
`internal_error` — a corrupt index, say — surfaces as 500, never as
`present: false`. That third case is the one worth a test, because collapsing an
error into "not there" is how a proof surface starts lying.

`GET /health/ready` reads `ready` and the closed `ready_reason` from `GetInfo`
rather than inventing a second readiness notion. In production today `GetInfo`
fails closed, because `ChainIdentity` is `None` in the node — so this endpoint
answers `503 { ready: false, reason: "dependency_unavailable" }`. That is the
honest answer; a `200 ok` derived from a failed call would be the worst
available. It also refuses to emit a `root` without its `size`: §7.5 pairs them,
and half a pair is a made-up fact.

**`chain_inscriptions` is deliberately not served or advertised.**
`ListInscriptions` answers `Unimplemented` in the node until a scanner-written
inscription catalogue exists, and a REST shell that reliably returns 501 is not
progress — it is a second place to look to learn the same absence.

`features` is API configuration, not `kernel_parts`, and its order is now fixed
rather than inherited from the environment variable: an endpoint that returns the
same set in a different order on two calls makes every response comparison lie.
The set is closed, so an unknown token in `ZKCOINS_FEATURES` stops the process at
startup, the same fail-closed line as the missing kernel address.
…grants

Four §7.5 endpoints: the attest-balance and grant challenges and their
redemptions, onto `OpenPullChallenge`, `AttestBalance` and `IssueViewGrant`.

The ownership proof is verified **here**, not in the kernel — that boundary was
already settled, and the gRPC surface carries no ownership-proof field, with a
node test pinning it. So this commit builds a verification that did not exist
anywhere: `chal = H(domain ‖ nonce ‖ chan_bind ‖ subject ‖ expiry ‖
request_hash)`, checked as BIP-340 under the subject's key using the same
`bitcoin`/`secp256k1` line the node uses.

**The domain follows the endpoint, never the body.** `POST /v1/attest/balance`
verifies under `zkCoins/v1/AttestBalanceChallenge`, `POST /v1/grants` under
`zkCoins/v1/IssueGrantChallenge`, taken verbatim from the node's
`ChallengeAction::domain()`. A caller cannot choose it. This matters more than
it looks: the node's action binding is *structural* — separate maps per action —
and that made everything look right, while the cryptographic separation was
missing entirely, because it lives on the other side of the boundary and nobody
had built that side. The test that matters here runs it both ways: a proof signed
for attest does not authorise a grant, and vice versa.

`chan_bind` comes from server configuration, never from a `Host` header or a
client field, and `request_hash` is computed from the parsed body the server
actually sees — not from a hash the client supplies. Both are checked at
redemption, not only at issuance.

Verification runs before any kernel call, so an invalid signature, a wrong
domain, a wrong `chan_bind` or an altered body is `401 unauthorized` **and the
nonce is not consumed**. Otherwise a typo would be a denial of service against
the rightful owner. The tests assert the kernel call count is zero on every
rejection path.

The API holds no challenge state: the store lives in the kernel, and this layer
verifies and forwards.

**One gap, and it is a spec question rather than a defect here.** Reconstructing
`chal` needs the challenge's `expiry`, which the kernel holds and §7.5's
redemption body does not carry — it lists `{ nonce }`. A monolith reads it from
its own store; a two-process split cannot, if the proof must be verified before
the nonce is consumed. The current handler therefore accepts `{ nonce, expiry }`,
which is an extension of the normative body and is called out as such rather than
quietly shipped. Closing it properly is a decision about the spec — either the
body carries the issued `expiry`, or the kernel gains a non-consuming lookup — and
that is not something to invent in an implementation.
Five more §7.5 endpoints, completing the private read surface: the pull
challenge and its redemption, `GET /v1/record/<id>`, `GET /v1/proof/<coin_id>`
and `GET /v1/account/state`, onto `OpenPullChallenge`, `Pull`, `GetRecord`,
`GetCoinProof` and `GetAccountState`.

`POST /v1/pull` carries the challenge `expiry` beside `nonce`, matching the
specification change that made the redeem body usable from a stateless API
layer. It is verified into `chal` before anything is consumed, so a bad proof
never spends the nonce.

The session authority follows the **proof type** and nothing else. It reaches
the kernel explicitly, and a missing value is `malformed_request` rather than a
silent assumption of ownership — the failure that would matter most here is the
one that quietly upgrades a reader to an owner.

The three session failures stay apart, as §7.5 requires: a missing or unusable
bearer is `401 unauthorized` decided here, while unknown, expired or
channel-mismatched sessions are `410 session_expired` from the kernel. A grant
session presented to `GET /v1/account/state` is `401` — read access to a slice
is not a right to the account state.

**The grant path is rejected outright, and that is the honest outcome.** §5.1(b)
verification needs the issuer's published `op` public key to check the grant's
signature, and this layer has no way to obtain it: no lookup, no kernel
procedure, no protocol state — and it must not hold any. Checking the discriminator
and the grantee's own signature while skipping the issuer signature would be a
half-verified grant, which is worse than none: it looks like authorisation and
is not. So `POST /v1/pull` with a `GrantProofJson` answers `401 unauthorized`
without calling the kernel, and what is missing to close it properly is written
down rather than approximated.

`record_type` and `transition_kind` stay closed sets on the way out; an
unrecognised value from the kernel is not forwarded. Nothing the kernel computes
is recomputed here.
`POST /v1/bootstrap/challenge`, `/entrust`, `/revoke` and the §7.6 publisher
hand-off, onto `OpenPullChallenge`, `EntrustOperationalBundle`,
`RevokeOperationalBundle` and `Publish`. That completes every §7.5 surface with
something behind it.

The action names the domain at issuance — that is what the body is for there —
but at redemption the domain follows the **endpoint**, as it does for attest and
grants. A proof signed for `revoke` cannot authorise `entrust`, and the test runs
it both ways. Both domains are distinct from the pull domain so a proof cannot be
repurposed at all.

**The entrust body is the only object on this whole surface that carries real
secrets** — five 256-bit keys in 161 bytes. Everything else here is public or
already bound. So the request body is never logged, never traced, never put in an
error message, and a test pins that: a framework default that echoes bodies would
otherwise write five keys into a file. The length and hex form are checked before
the call, so an obviously wrong body does not travel; the kernel checks them
again, which is intentional.

A rejected publish stays a **successful** response carrying the closed reason,
not a 4xx — the network declining an inscription is a result, and the job side
already draws that line the same way. A set v1 fee field is `400
malformed_request` rather than silently ignored.

Four keys stay unserved and unadvertised, each for a reason written in the code:
`chain_inscriptions` and `receipts_stream` because the kernel answers
`Unimplemented` and names its missing precondition, and the four `blossom_*`
keys because §7.4 has no implementation at all — `recovery` is demonstrably not
built. A REST shell over an absent subsystem is a map with invented streets.
…e, not a config field)

The reject path said the API has 'no kernel RPC that returns op_pubkey for a
subject', which points at the wrong fix. Per §1.2 the op key is node-held and
is published as the author of the subject's kind-0 profile (§7.3), so a node
the subject does not control cannot be handed it as a setting and the kernel
cannot supply it either — it knows its own op, not a foreign subject's.

Getting it means resolving that profile and running the §4.3 address binding
on the result. Without all three checks an attacker who knows the subject's
public pk0 and nk_commit publishes a profile naming their own op_pubkey, and
the grant then verifies against the forger's key.

Behaviour is unchanged: GrantProof stays a loud 401.
The endpoint was unadvertised and 404 because the kernel answered Unimplemented:
the NfLog carries winning (pk, r) pairs and a chain position, not the reveal
txid and not the §3.5 format byte. The node now writes an inscription catalogue
at fold time, so the surface can be served from real data rather than from a
projection that would have had to invent two fields.

Two states in the response are nearly homonymous and are not the same thing.
`nullifiers[i].state` is that member's §3.10 state and members of one aggregate
may legitimately differ — a later `Pk` collision is `failed` while earlier
members stay `pending` or `completed`. `confirmation_state` is only the reveal
transaction's depth against the §3.9 six-confirmation floor and never carries
`failed`. A test shows both at once: a `failed` member inside an inscription
whose `confirmation_state` is `completed`.

The triple cursor is all-or-nothing by construction rather than by a check at
the end, so a response carrying a proper subset of `next_height` /
`next_tx_index` / `next_vin_index` is not representable. Page continuation is
tested across three pages with a page boundary falling inside a reveal
transaction that carries several `vin_index` inscriptions — that is where
duplicates and gaps appear.

`limit` outside 1..1000 is rejected rather than clamped, and the §7.5 defaults
are named constants carrying their spec reference rather than an implicit
`unwrap_or_default` that hides the value.
Four of the nine unserved §7.5 keys — `blossom_get`, `blossom_head`,
`blossom_upload`, `blossom_delete`. §7.8 has no Blossom procedure: the surface
is explicitly API-local and carries its own storage, so it does not go through
the kernel.

The store is content-addressed on the filesystem under a root that comes from
the operating configuration. There is no default path and no fallback: without
the variable the surface is unconfigured, the routes are not mounted and the
four discovery keys are not advertised — the same shape the repo already uses
for other unconfigured surfaces.

The path parameter is validated as exactly 64 lowercase hex characters *before*
it becomes a filename, so traversal is excluded by construction rather than by
filtering. Writes go to a temporary file in the same directory and are renamed
into place: an interrupted upload must not leave a half blob under a valid
address, which is the one promise a content-addressed store makes.

The `x` tag of the kind-24242 authorization event is checked against the bytes
actually received, not against a header or a value inside the event. That check
is the whole authorization: anyone who could swap the body without breaking the
tag could store arbitrary content under someone else's signature.

Every §7.4 rejection is its own check with its own status — 401 for signature,
kind, `t` tag, `x` tag, expiry and the clock window; 403 for the wrong `op` key
on upload or a delete by someone other than the original uploader; 413 over the
size limit. A blob with no uploader note is not deletable rather than deletable
by anyone.

The three `X-ZkCoins-*` binding headers are all-or-nothing and validated even
though no receipt follows: §4.6 replication is not built, so the optional
`receipt` field stays absent rather than becoming an empty object or an
invented attestation.
The kernel now writes receipts after durable persistence and filters them by
the server-side session subject and resolved scope, so the last non-legacy §7.5
key can be served. The three that remain are legacy 410 surfaces.

The auth rule here is deliberately not the one next door. §7.5 admits **any**
still-valid ownership *or* grant pull session on this stream, while
`GET /v1/account/state` admits ownership sessions only — a scoped grant must
never see full account state, but it may see the receipts inside its scope. A
test holds that difference, because a suite that only ever opens one kind of
session looks equally green whichever way the check goes.

Subject and scope come from the server-side session state. The request carries
no subject anywhere, and a test asserts that supplying one changes nothing.

The error shapes stay separated the way §7.5 separates them: a missing or
non-session bearer is `401 unauthorized`, while an unknown, expired or
`chan_bind`-mismatching session is `410 session_expired`. Collapsing them would
leave a client unable to tell "authenticate again" from "open a new session".

No recovery buffer and no sequence numbers: §4.9 makes the stream a latency
accelerator, never the sole source of truth, and a client recovers missed
receipts through the ordinary pull endpoint. Building either would promise
something the spec deliberately does not.
The stack has four running services and still could not do a full run, because
one part was not containerised: `docs/local-stack.md` in the node repo told the
reader the API "runs alongside". That is not a stack — it is a request that the
reader supply the missing piece.

Multi-stage build on the pinned toolchain, non-root at runtime, `protoc` pinned
to the distribution's exact version rather than floating (the first build
failed on a version that does not exist in bookworm; the one in the image is
`3.21.12-3+deb12u1`). The exposed port is the one `main.rs` actually binds, not
one copied from the node's Dockerfile.

No defaults are baked into the image. Every variable `Config::from_env` reads
fail-closed is documented in the header with its meaning, whether it is
required, and the source line — including the all-or-nothing Blossom triple,
where setting the store without its companions is a start error rather than a
half-configured surface.
The API was the only one of the four repositories without any workflow at
all — no formatting check, no clippy, no build, no test ran on it anywhere
except a developer's terminal. It now carries the same four gates the tree
is verified against locally: `cargo fmt --all --check`, clippy over
`--all-targets --all-features` with warnings denied, a build, and the test
suite.

`--all-targets` is deliberate and must stay. Without it clippy lints the
library targets only, so the test and fixture code — which is most of what
decides whether a green run means anything — would never be linted at all.

The workflow lands in the same paused state as the other repositories:
`workflow_dispatch` is live so a run can still be started by hand, and the
`pull_request:` trigger sits commented out immediately below, verbatim, so
switching hosted CI back on at the end of the rebuild is a deletion rather
than a rewrite. Nothing else is parked: every gate above is the one that
will run on the first PR after the trigger is restored.

The toolchain step installs the pin from `rust-toolchain`
(nightly-2026-06-18) rather than a floating channel. With `-D warnings` a
moving channel turns a new lint into a red build on a tree nobody touched.

There is no `notify-failure` job: this repository does not hold the
Telegram secrets. The comment above the job records what to model it on
once it does.
Two halves of the capability-gated pull surface were missing, and the
second one mattered more than the first.

`GrantProof` verification was marked not implemented, so every
grant-bearing request was refused outright — the authorised mode of §5
had no path at all. It now runs the §5.2 checks in the order the
specification lays down: decode the `zkgrant`, recompute `grant_message`
from the fixed field concatenation and verify BIP-340 under the
subject's published `op_pubkey`, bind the grantee's identity and its
signature over the challenge, then expiry and revocation. Any failure
refuses before the kernel is dialled, so a bad proof can never burn a
nonce.

The scope was the real defect. Ownership was requested with an unbounded
scope regardless of what the presented capability actually covered — a
release wider than the grant it came from, which is precisely what §5
forbids. The effective scope is now the intersection: `max` on
`not_before`, `min` on `not_after`, an explicit foreign asset refused
rather than merged, and an empty result refused instead of silently
falling through. A wildcard request clamps to the narrower grant; it
never widens it. The resolved scope is recorded server-side on the
session, and later use reads it from there rather than from the request.

`grant_message_digest` keeps its eight parameters and an explicit lint
exemption: that list *is* the normative formula, and folding it into a
struct would invite the field order of the struct to be mistaken for the
binding one — the exact confusion §5.2 warns about. `verify_grant_proof`
went the other way: `public_hosts`, `now` and the revocation set are
environment rather than evidence, so they moved into a context struct.

One of the existing tests promised in its comment to send a real
`zkgrant` from a subject with no published op, but sent malformed
bech32 — so it exercised the 400 path and never reached the check it
claimed to cover. It now sends a properly signed grant, and two further
negative cases (manipulated grantee signature, wrong `chan_bind`) close
gaps that would have passed silently.
Three fail-closed defects on the public REST surface, all of the same
family: something absent was quietly treated as something valid.

**Role and feature gating.** All twenty-five core surfaces were
registered unconditionally; only the four Blossom routes consulted
configuration. §7.5 requires a disabled function not to be served, and
§6.1 gives roles whose functions are off by default. The active set now
follows configuration, a disabled surface answers `404`, and `GET /`
advertises exactly what is served — the discovery document is derived
from the same source as registration, so the two cannot drift.

**Idempotency.** A missing `Idempotency-Key` header became `None` and
was then handed to the kernel as an empty string, which the kernel
rejects — so absent and empty collapsed into one failure. They are now
distinct: absent stays absent, and a header that is present but empty is
`400 malformed_request` at the edge. The request DTOs additionally
refuse unknown fields, nested objects included: §7.5 says the body is
exactly this object, and a field nobody reads is not a detail the server
gets to ignore.

**Closed error set.** The `ErrorInfo` consumer accepted any non-empty
`reason`, while §7.8 defines a closed set. An unrecognised reason is a
protocol violation by the kernel, not a client mistake, so it fails
loudly as `500 internal_error` rather than travelling onto the public
wire under a code no client can interpret.

**Terminal states.** `POST /v1/tx` answered `202 accepted` without
checking that the kernel returned a usable `job_id` and a valid status —
an empty id arrived at the client as success. It now refuses, matching
what the attestation path already did.
The API's role for `OutputTemplate.delivery` is deliberately small:
transport, not verifier. §6.1 puts every credential check in the kernel,
and the proto comment says so at the field. What the API owns is the
shape of the boundary, and that it owns strictly.

The REST DTO mirrors the closed tagged union of §7.5 — an invoice or a
full kind-0 profile event — under the same `deny_unknown_fields`
discipline as the rest of the body, nested objects included. An unknown
`type`, an unknown field, or a missing required member is
`400 malformed_request` at the edge, before the kernel is dialled. Form
is the API's business; content is the kernel's. Conversion to the proto
is field-for-field with no interpretation: nothing trimmed, nothing
normalised, absent and empty kept distinct.

Nothing of the credential reaches a log line or an error message. The
retention rule that binds the kernel binds the API as
strictly-never-keep: `pk0` links a recipient to its genesis nullifier on
Bitcoin, and a transport layer has no reason to remember what it was
never asked to understand. A test submits a credential and greps the
captured output for the material that must not appear.

The closed `ErrorInfo` set gains the kernel's rejection reason for
failed credential checks, matched against the node's error contract.

The carried proto is the verbatim node copy; its SHA-256 pin moves in
the same change, which is exactly the drift the pin exists to catch.
The v1 rebuild is done and verified locally, so the paused workflow comes
back the way it was written to — a deletion. The PAUSED note goes and the
`pull_request:` trigger that sat commented out verbatim returns, with
`workflow_dispatch` kept for a hand-started run. Every gate above is the
one that ran before the pause.
@TaprootFreak TaprootFreak changed the title b9e063fe - Scaffold the API process: fail-closed config and an honest discovery document b9e063fe - Build the v1 public API surface (§7.5) Aug 2, 2026
…s honest

A review of the diff raised findings across three areas; these are the
concrete ones (the API↔kernel verification-boundary findings are a
separate, documented follow-up).

Every `Json<T>` handler now goes through one `JsonBody<T>` extractor that
maps axum's parse, content-type and body-limit rejections onto the
closed §7.5 error surface — before, only `POST /v1/tx` did, so the rest
leaked axum's own 400/415/422. A body over the limit stays `413
payload_too_large`, not `400`; only a genuine parse or content-type
error is `400`.

A deactivated but known route answers `404 feature_disabled` with the
machine code its own docs promise, instead of a generic axum 404, while
staying out of discovery. `build_router` returns a `Result` rather than
`panic!`-ing on a store-open error, so `main` reports it like every other
startup failure. `ApiError::internal` no longer puts the internal text —
store paths, OS errors, kernel-contract detail — into the public 500
body; the cause is logged, the response is constant. Job-terminal states
run through one validator (closed status set, status↔payload
exclusivity, SSE event↔status correlation, attest requires `accepted`),
and `unauthorized↔401` / `session_expired↔410` are validated as pairs so
a contradictory kernel combination fails closed as 500.

Blossom store writes use unique temp names and atomic no-replace, and
blocking file I/O moves off the reactor via `spawn_blocking`. The
`proto_identity` sibling-node comparison is documented as local-only
(CI's real gate is pin==file), so an absent `../node` is a named skip,
not a silent green. `memo`'s absent==empty is documented as the §1.5
normalisation it is, not "unchanged".
…e job/scope validation

A second review pass found real gaps in the error surface and the
job/scope validation, plus a CI build break.

The kernel returns `ErrorInfo` inside a `google.rpc.Status.details`
envelope, but the API decoded a bare `Any`, so a normal `job_not_found`
from the node failed to decode and surfaced as `500 internal_error`. The
API now decodes the `google.rpc.Status` envelope and requires exactly one
`ErrorInfo` detail, and the tests build the status with the production
encoder instead of the shape the API happened to expect. The full
`(gRPC code, reason, http status)` mapping is validated, and API-only or
job-payload-only codes are rejected as contract violations.

Two internal-error leaks are closed: a kernel `internal_error` mapped
through `ApiError::new` carried the kernel status text (which can contain
paths) onto the wire, and terminal job errors copied their `message` into
`GET /v1/jobs` and terminal SSE frames. Both now normalise to the
constant public message and log the cause operator-side; the chain-route
tests assert the neutralised body instead of the old leaking one.

Job results are validated per `kind`: `attest_balance` requires a
non-empty attestation and no transition fields, transition jobs require
their digests and carry no attestation, and terminal states must have an
empty `phase`. Pull scope is canonicalised (strictly ascending, unique
ids, non-empty interval) before any challenge/redeem RPC, and an owner-
only endpoint rejects a real GrantProof with `401` instead of a premature
`400`. Blossom uses the normative `malformed_request` code, serialises
delete/put/recovery per blob id, and no longer 500s on a concurrent
idempotent upload.

CI installs `protoc` before the Rust steps, since `kernel-proto`'s
build.rs needs it and ubuntu-latest has no protobuf-compiler by default.
…lock map

Follow-up review found the kind-closed job validation rejected a valid
terminal job error: the node emits `dependency_not_final` as a terminal
failure, but the new check treated it as illegal. It is now in the
allowed terminal error set, with a test.

The per-blob lock map introduced last round grew without bound — entries
were never removed, so a long-running process leaked a lock per distinct
blob id. Entries are now released once no waiter holds them, so the map
stays bounded.
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

Review status

This PR has had five independent review passes (conformance and logic). Over the passes it gained
the correct google.rpc.Status/ErrorInfo envelope decoding (so a real node job_not_found no
longer surfaces as 500), full neutralisation of internal-error messages on the poll and SSE paths
(no kernel cause on the wire), the complete (code, reason, http status) contract validation,
per-kind job-result validation, canonical pull-scope validation before any kernel RPC, a proper
401 for a real GrantProof on owner-only endpoints, and Blossom fixes (normative malformed_request,
per-blob serialisation, bounded lock map). CI installs protoc and is green.

It stays in Draft for these named follow-ups:

  • Kernel/API verification boundary (kernel.v2). BIP-340, grant expiry/revocation, scope
    intersection and challenge-expiry verification belong in the kernel; the API should be transport.
    This moves kernel.v1 messages and is a block of its own.
  • GrantProof / account-state production writer. The subject-op directory the grant check reads
    has no production writer yet, so grant-scoped pulls are not reachable end to end.
  • Blossom multi-instance safety. The store is correct for a single process; two instances
    against one store need a cross-process lock.
  • Proto-identity in CI. The pin proves the carried contract is unchanged, but not byte-identity
    to the node, until CI checks the node out at a pinned ref.

Entfernt DELETE /blossom/<sha256>, retention_hold, ReplicaReceipt-Header und
Orphan-Prune auf open. Upload liefert nur noch { blob_id }; Store löscht keine
empfangenen Blobs mehr.
Add the spec-mandated creator_pubkey (Pk0) to the issuance wire object and
decode it for both token standards, so the kernel can derive the asset_id and
the genesis owner binding. Re-pin the carried kernel.proto digest to match the
new field, and pre-create the Blossom store directory owned by the runtime
user so a fresh volume mount is writable.
GET /v1/account/state emitted send_counter as a JSON string while the /jobs
endpoint and the SDK contract both treat it as a number, so clients rejected
the response. Emit it as a number (a small per-account u64 counter, precision
-safe) and update the shape test.
Accept and forward the genesis_pubkey field on a receive request (rejected for
mint/send), and update the carried kernel.proto SHA-256 pin for the new field.
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