Skip to content

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

Merged
TaprootFreak merged 74 commits into
developfrom
feat/v1-api-layer
Aug 17, 2026
Merged

b9e063fe - Build the v1 public API surface (§7.5)#2
TaprootFreak merged 74 commits into
developfrom
feat/v1-api-layer

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

EN:
This PR adds the hosted v1 REST process on top of kernel RPC: discovery, role gates, pull, grants, Blossom, and bootstrap.
Grant-pull verifies capability before allocating a subject lock, and unpublished subjects stay 401.
Grant-revoke challenges are evicted and capped in process; Blossom only acknowledges a complete pair after a store-root fsync.
Operator docs now match the lazy kernel dial and this repo's develop-target CI.

DE:
Dieser PR legt den gehosteten v1-REST-Prozess auf den Kernel-RPC: Discovery, Rollen-Gates, Pull, Grants, Blossom und Bootstrap.
Grant-Pull prüft die Capability, bevor ein Subject-Lock entsteht; unveröffentlichte Subjects bleiben 401.
Grant-Revoke-Challenges werden im Prozess evictet und gedeckelt; Blossom bestätigt ein vollständiges Paar erst nach Store-Root-fsync.
Die Operator-Doku entspricht dem lazy Kernel-Dial und der CI dieses Repos gegen develop.

Details

Surface

  • Fail-closed config and an honest GET / discovery document.
  • Capability-gated pull (§5.1/§5.2): OwnershipProof or GrantProof; effective scope is the intersection of grant and request. Decode failures on the grant arm are 401. Unknown subjects are probed before mutex_for so grant spam cannot grow the lock map.
  • API-local grant revoke (POST /v1/grants/revoke{,/challenge}): single-use nonce, expired-entry eviction, 4096 outstanding cap, 410 after a valid proof on an expired nonce.
  • Role/feature gating: a disabled surface is 404, not served.
  • Blossom append-only store: no DELETE; parent and store-root sync_all before acknowledging a complete pair.
  • Bootstrap validates the operational secret before the kernel dial; subject directory updates stay under the per-subject lock.
  • Delivery credential is accepted on form, carried unchanged, never marked verified, never logged.
  • Kernel proto SHA-256 pin is the CI identity.

Verification

Local: cargo fmt --all --check, cargo clippy --all-targets --all-features -- -D warnings, cargo test --all-features (677 tests). Hosted Lint & Build runs when the PR leaves draft.

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.
…auth

A GrantProof-authorized pull could never authorize against the real running
server because AppState.subject_ops was only ever populated by tests, never
in production, so every grant pull failed 401 for lack of a subject→op_pubkey
entry.

Populate subject_ops on the authenticated /v1/bootstrap/entrust success path:
derive the operational x-only public key from the entrusted bundle (op secret
at bundle bytes 65..97) and insert it keyed by the entrusting subject. This is
the documented, legitimate population route — the node co-located with the api
holds op_sk for that subject at exactly that moment, so no new trust assumption
and no Nostr profile-resolution infrastructure is needed. The op secret is used
only to derive the public key and dropped; only the x-only pubkey is stored.

Rework the SubjectOpDirectory rationale to describe this entrust-time population,
and add a test that a successful entrust populates the directory and unblocks a
subsequent GrantProof pull that previously 401'd.
…tops verifying grant proofs (§7.7 cease-use)
…allenge}) with single-use nonce and grant->subject binding (§5.2)
Re-exposes the kernel's open Class-B token-provenance read (spec §4.6 / §7.5 /
§7.8) publicly through the API, so a token's issuer-originated terms stay
resolvable after its issuer's node is gone.

- sync the kernel proto twin with GetTokenProvenance and its request/response
  messages (byte-identical to the node's), and re-pin the proto SHA-256.
- add the KernelRpc client method and the GetTokenProvenance procedure to the
  kernel-error mapping (malformed_request / not_found / rate_limited /
  internal_error per §7.8).
- new provenance handler projecting the kernel response to the §7.5 JSON schema
  (name as raw-byte hex; v1 and v2), with an all-or-nothing v1/v2 field check.
- register the token_provenance surface as always-served and never
  features-gated (§6.4): it returns provenance or 404, never feature_disabled.
- extend the §7.5 inventory, discovery, probe map, and rest-surface doc.

Tests cover the schema encoding, the REST paths (v1/v2 held, 404,
400-before-kernel-call, never-feature-gated) and the inventory/discovery
invariants; the full api suite is green.
…e doc

A review found the provenance projection forwarded two kernel fields without the
width/format validation the file applies elsewhere:

- decimals is a proto uint32 but §7.5 requires a u8; a value above 255 now fails
  closed to internal_error instead of serving an out-of-schema 200.
- cap_total must be a decimal u128 string; a non-numeric or overflowing kernel
  value now fails closed instead of passing through verbatim.

Adds tests for both fail-closed paths, and updates the rest-surface doc counts
and the inventory row for the token-provenance endpoint.
Discovery tests never call most KernelRpc methods on that double.
Each unused method now returns internal_error once so the stub
arms are covered.
Permission errors in the blossom store no longer look like absence.
Kernel job_id must match the path before any job object is forwarded.
Grant-revoke expiry is checked after a valid proof and before grant
decode, so a malformed grant cannot mask challenge_expired.
Adds fail-closed tests for blossom store lock recovery and IO errors,
sibling proto-pin checks, and startup config/bind failures. Drops the
racy complete-pair and global-shutdown tests that hung or were not
portable on macOS.
Adds a handler double for unused kernel RPCs and unit tests for
idempotency-key parsing, sign/cancel, job JSON projection, and
fail-closed form errors. Drops an Unpin-incompatible SSE drain test.
Add unit tests for unauthorized/malformed ownership paths, last-nullifier
width, disabled-route panics, and explorer-only Blossom advertisement.
Mark proven-unreachable TOCTOU arms with coverage_nightly, fix clippy
-D warnings, and retry the flaky note-install race.
Absence of ZKCOINS_BLOSSOM_STORE leaves blossom_get/head/upload unadvertised,
not four keys. Origin-local GET / and GET /health stay distinct from the
kernel-less grant-revoke pair.
Reject an invalid secp256k1 op secret with 400 and no RPC. Map a
pre-epoch clock on Blossom upload to an internal error instead of
panic. Refresh operator comments onto startup.rs and drop unused wget.
token_provenance is always-on. Blossom GET/HEAD need the store and
explorer; upload needs the store and wallet or explorer.
GET/HEAD need the store and explorer; upload needs the store and wallet
or explorer. A store without a role is a feature_disabled stub. The
opening line now names the REST surface that actually ships.
LNURL mappings stay a database concern. The process ships REST on the
kernel RPC; the opening docs now match that.
The closed table is 33 routes. The SHA-256 proto pin is the CI identity;
the sibling node compare stays optional and local.
Crate docs now distinguish value-bearing kernel state from local
directories. The kernel module is the client only. Rate limits stay
Kernel ErrorInfo, not an API limiter.
OpenPullChallenge uses the proto empty-string pull discriminator.
Entrust and revoke share a per-subject lock through the cache write.
Blossom now fail-closes on directory sync before acknowledging a put.
Re-read the published op after dropping the lock and refuse a
concurrently revoked grant immediately before the kernel Pull.
Grant-pull probes subject_ops before mutex_for so unknown subjects
cannot grow the lock map. Decode failures stay 401. Grant-revoke
challenges evict expired entries and refuse above 4096 outstanding.
House rule requires an exact sha2 pin. Remaining handler rustdoc is
English so the public tree matches the rest of the crate.
Operator comments now match the crate language. The bookworm
protobuf-compiler pin comment matches the installed package.
Keep .env.example committable. Local env files must not be trackable.
Names on disk are not durable until the store-root sync succeeds.
Retries after a failed sync now fail closed instead of returning Ok.
The status table now matches the closed capability row and the
handler. Unpublished subjects still fail closed with 401.
Operator docs no longer claim an eager kernel dial before serve.
CONTRIBUTING now names the develop target and this repo's CI job.
@TaprootFreak
TaprootFreak marked this pull request as ready for review August 14, 2026 17:29
@TaprootFreak
TaprootFreak marked this pull request as draft August 14, 2026 17:32
The CI runner finished both hard_links before the watcher saw the
final blob. Delete the note temp as soon as it appears instead.
@TaprootFreak
TaprootFreak marked this pull request as ready for review August 14, 2026 17:48
@TaprootFreak

Copy link
Copy Markdown
Contributor Author

EN:
Ready after 8 review passes.
This PR adds the v1 REST surface over kernel RPC, with grant-pull auth, process-local revoke, and fail-closed Blossom durability.

DE:
Bereit nach 8 Review-Durchläufen.
Dieser PR legt die v1-REST-Fläche über den Kernel-RPC, mit Grant-Pull-Auth, prozesslokalem Revoke und fail-closed Blossom-Dauerhaftigkeit.

Details
  • Conformance and logic were reviewed independently until both were clean at HEAD 2d0c09e.
  • Open review threads: none. Issue comments: one prior status note, no open request.
  • Mergeable: MERGEABLE. Base: develop.
  • Required check Lint & Build is green on this head (run 31825675480). An earlier run failed on the known put_install_note_other_error_retains_blob race; the watcher now deletes the note temp as soon as it appears.
  • Rejected as out of scope or already decided: durable RevokedGrantSet, eprintln on fatal startup, multi-network until docs #135 merges, pinning Ubuntu CI protobuf to the Debian bookworm package, leftover handbook-style nits, pre-existing env-reading startup tests, unused ownership::hex32.

* feat(api): allow any verified blossom op when ALLOWED_OPS is *

A dedicated test node cannot pre-list every fixture wallet. The sole
token * accepts any kind-24242 that already verifies; mixing * with
hex keys is a start error. Empty still 403s.

* style(api): rustfmt blossom allow-any ACL condition

* docs(api): document blossom * allow-any and satisfy clippy

The rest-surface table and image comments still described only
hex keys. Record the sole-* token and use BTreeSet::contains so
-D warnings stays green.

* test(api): cover blossom allow-any upload ACL

Config empty-ops stays deny-all. A verified unlisted kind-24242
uploads under allow_any and is still 401 without Authorization.

@TaprootFreakAI TaprootFreakAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v1 public REST surface over kernel RPC: discovery, role gates, grant-pull, process-local revoke, fail-closed Blossom. Lint & Build is green, mergeable, not a draft. Author already recorded multiple review passes. Approving for merge into develop.

@TaprootFreakAI

Copy link
Copy Markdown
Collaborator

Approved. CI green, mergeable. I cannot merge from this account (review-only) — please merge when ready.

@TaprootFreak
TaprootFreak merged commit 6b6cdd6 into develop Aug 17, 2026
1 check passed
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