Skip to content

feat(http-api): request-id'd /notes/~/v1 + OpenAPI/AsyncAPI specs + X-Api-Key auth + every flow through one dispatch#5

Open
arthyn wants to merge 8 commits into
masterfrom
feat/http-api
Open

feat(http-api): request-id'd /notes/~/v1 + OpenAPI/AsyncAPI specs + X-Api-Key auth + every flow through one dispatch#5
arthyn wants to merge 8 commits into
masterfrom
feat/http-api

Conversation

@arthyn
Copy link
Copy Markdown
Owner

@arthyn arthyn commented May 21, 2026

Summary

Replaces the Eyre-channel poke surface with a request/response HTTP API at /notes/~/v1. Every action — whether it lands locally or hops cross-ship — carries a client-generated requestId (@uv); the agent holds the POST open until the host's terminal response-update is forwarded back, the host nacks, or a 20s timeout fires. Modeled on the venter-like pattern from tloncorp/tlon-apps#5334, specialized for notes' single-agent dual-mode host/subscriber shape.

The bigger architectural shift: there's now one dispatch path through the agent (dispatch-v1-action). Local actions, cross-ship actions, the FE, MCP clients, dojo pokes, and the legacy %notes-action mark all funnel through it.

What's in the branch (7 commits, oldest→newest)

# Commit What it does
1 b483497 Types + state-11 + cycle skeleton. New ++ v1 core in sur/notes.hoon (action / command / response / response-update / response-body / response-update-body / incoming-request / poke-status / action-error). state-11 adds requests=(map request-id incoming-request). New marks mar/notes/{action-1,command-1,response-1,response-update-1}.hoon. Agent: HTTP POST + GET routes, per-request behn timeout, cleanup timer, full subscriber↔host lifecycle (no-action-v1 / se-poke-v1 / no-agent-req-watch / no-agent-req-poke). 4 new tests + 55 existing pass.
2 a419c4b FE migration. pokeAction switched from Eyre-channel PUT-then-watch-poke-ack to fetch('POST /notes/~/v1'). All ~40 wrapper call sites (pokeNotebook, pokeNoteAction, etc.) unchanged. pendingPokes infra removed — HTTP holds the request open and returns the typed terminal body inline.
3 22c1acf OpenAPI 3.1 spec. docs/openapi.yaml covers the two endpoints, the discriminated Action / ResponseBody unions, the full r-notes / u-notebook tree returned in %ok responses, ActionError enum, eyre-cookie security scheme. Validates clean under @redocly/cli.
4 9e7201c AsyncAPI 3.0 spec. docs/asyncapi.yaml documents the three SSE subscription channels (per-notebook stream, inbox events, per-request response stream) over Eyre's PUT-to-subscribe + EventSource transport. Plus docs/asyncapi.html, a single-file viewer.
5 8b9bf5e Serve openapi.json from the agent. desk/lib/notes-openapi.hoon embeds the spec; scripts/build-notes-openapi.sh regenerates from docs/openapi.yaml via js-yaml. Public route so MCP proxies can fetch the spec without inventing an auth scheme. JSON because %mcp's converter parses with de:json:html and doesn't accept YAML.
6 12d67a3 X-Api-Key auth. state-12 adds api-key=(unit @t) minted on init / after migration. New a-notes variants %regenerate-api-key / %clear-api-key. +request-authorized accepts either eyre-validated cookie OR matching X-Api-Key header. +handle-v1-post bypasses the +poke %notes-action-1 arm's src.bowl guard when api-key matches — calls +dispatch-v1-action directly. Scry /x/v0/api-key for the local user to inspect/copy. 5 new tests.
7 f2372ce All cross-ship flows through request/response. Previously the membership-wire pokes (handle-send-invite, join-remote, leave-remote) were fire-and-forget with the legacy %notes-command mark. Now they all use +send-v1-request (watch + poke notes-command-1 + behn-wait) and finalize asynchronously when the host's response-update arrives. +poke %notes-command-1 %notify-invite emits a %no-change response-update. no-agent-req-watch kicks off no-start-watch on first successful cross-ship response so freshly-joined subscribers get the broadcast stream. Watch handler relaxed to allow invitee-hosted request paths keyed by inviter's flag. Legacy %notes-command arm removed (pre-Phase-2 sub ships unsupported, per design). Legacy %notes-action arm routes through dispatch-v1-action with a (mix eny.bowl rid-counter)-synthesized rid; state-13 adds rid-counter so test-agent (which doesn't advance bowl.eny) doesn't collide on the per-request watch wire.

Test surface

  • 65 unit tests pass (mcp__sidwyn__run-tests on /tests/app/notes)
    • 11 new across the 7 commits: 4 v1 lifecycle, 5 api-key, 2 from existing tests rewritten to use the v1 cross-ship mark
  • 12 e2e tests pass (npm run test:e2e) — including the cross-ship invite / accept / leave / edit propagation specs
  • Manual smoke — verified end-to-end via curl + via MCP-proxy proxied through Claude Code (tloncorp/mcp upstream pointed at /notes/openapi.json; called submitAction with a 4-deep discriminated body)

Things deliberately out of scope

  • Typed %conflict error. se-update-note's expected-revision mismatch still crashes the poke, surfacing as %error %unknown on the response-update path rather than %error %conflict. Single arm fix; left for later so the FE's conflict-banner refactor can land alongside it.
  • %ok payloads for top-level actions. %create-notebook, %accept-invite, etc. finalize with %no-change. FE relies on the broadcast SSE stream for state sync.
  • Pre-Phase-2 cross-ship compat. Dropping %notes-command means another ship still running pre-Phase-2 notes can't poke this version — an explicit trade-off per option B in the design discussion.

Related upstream

tloncorp/mcp#3 — patches mcp-proxy.hoon's OpenAPI converter to actually resolve $refs into the generated MCP tool's inputSchema. Required for notes_submitAction to expose its Action discriminator union to LLM clients. Without it the inputSchema is empty.

🤖 Generated with Claude Code

arthyn and others added 8 commits May 17, 2026 13:07
Wires a venter-style request/response surface alongside the existing
%notes-action / %notes-command path. Each action carries a request-id
(@UV); the host emits a response-update on a per-request path; the
subscriber forwards it as a response and resolves the held HTTP request
(or per-request SSE subscriber). Modeled on tloncorp/tlon-apps PR 5334
but specialized for notes' single-agent dual-mode host/subscriber shape.

sur/notes.hoon: new v1 core with request-id / action / command /
response / response-update / response-body / response-update-body /
incoming-request / requests / poke-status / action-error. state-11
adds requests=(map request-id incoming-request).

lib/notes-json.hoon: enjs:v1 encoders (response, response-body,
response-update, response-update-body, u-notebook-bare, action-error,
poke-status); dejs:v1.action wrapper. tang-json is a placeholder
emitting an empty array — typed errorType carries the actionable info.

mar/notes/{action-1,command-1,response-1,response-update-1}.hoon:
new versioned marks. Old %notes-action / %notes-command preserved.

app/notes.hoon:
- state-11 + state-10-to-11 migration arm
- HTTP routes POST /notes/~/v1, GET /notes/~/v1/request/<uv>
- finalize-request / finalize-pending / register-request helpers
- give-http-response / http-error helpers
- per-request behn timeout (20s) → %pending
- cleanup-requests behn timer (5m) with channels' retention rules
- se-core: rid / last-update / finalized door fields; se-poke-v1
  wraps se-poke and emits the terminal response-update; se-update
  records the [time u-notebook] for that emission; se-finalize-with
  for typed early-finalize
- no-core: no-action-v1 (watch + poke + behn-wait), no-agent-req-watch
  (transforms response-update → response, finalizes, leaves host
  watch), no-agent-req-poke (poke-ack handling)
- watch paths: host-side /v1/notes/<flag>/request/<requester>/<uv>
  with src.bowl == requester assertion; subscriber-local
  /v1/request/<uv> with terminal-replay-on-subscribe

tests: 4 new v1 tests + helpers (poke-a-v1, poke-c-v1, raw-noun
introspection of cards via lark axes — ;; on cage was 90x slower).
All 59 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches pokeAction (the underlying primitive used by pokeNotebook /
pokeNoteAction / pokeFolderAction / pokeTopLevel — all ~40 call sites)
from the Eyre channel PUT-then-watch-poke-ack flow to a synchronous
HTTP POST against the new v1 API. Wrapper helpers stay the same.

Behavior change: callers now resolve when the agent emits a typed
terminal response (ok / no-change / pending) rather than when the
channel's poke-ack arrives. %error bodies throw with errorType
attached so future conflict-banner work can switch on the failure
mode. Eyre-channel SSE remains for /v0/notes/.../stream subscriptions.

- generateRequestId: crypto.getRandomValues → 96-bit base32 with dot
  separators every 5 chars from the right (canonical @UV form)
- pendingPokes + POKE_ACK_TIMEOUT removed — HTTP holds the request
  open and returns the terminal body inline (or %pending on agent
  20s timeout); no longer need to demux poke-acks out of the SSE
  stream

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand-written from the agent's enjs/dejs encoders and the request /
response wire types in sur/notes.hoon v1 core. Covers the POST and
GET endpoints, the request envelope + response body union, all four
action discriminator levels (a-notes → a-notebook → a-folder/a-note),
the r-notes / u-notebook tree returned in %ok responses, and the
action-error enum. Validates clean under @redocly/cli (2 warnings:
missing license + localhost server — fine for internal use).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to openapi.yaml. Documents the three subscription channels
the browser uses over Eyre's SSE channel:

- /v0/notes/{host}/{name}/stream — per-notebook event stream
- /v0/inbox/stream — cross-cutting invite + notebook-list events
- /v1/request/{requestId} — per-request terminal response (only used
  when the original POST returned %pending)

The Eyre channel transport (PUT-to-subscribe + EventSource over a
shared per-tab channel) is described in the top-level info because
AsyncAPI has no first-class model for it. Schemas mirror openapi.yaml
rather than $ref'ing across files so generators that don't follow
cross-file references still work.

Also gitignores the rendered preview HTML in docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundle docs/openapi.yaml as JSON into desk/lib/notes-openapi.hoon and add
a public GET route at /notes/openapi.json so an MCP proxy can fetch the
spec without inventing an auth scheme. JSON because %mcp-proxy parses
cached specs with de:json:html and rejects YAML.

scripts/build-notes-openapi.sh regenerates the ++json arm from the YAML
via js-yaml; mirrors the index-arm regen pattern in build-notes-ui.sh.

Also adds docs/asyncapi.html — a small single-file viewer for the
subscription spec (fetches asyncapi.yaml client-side).

Verified end-to-end against tloncorp/mcp + a PR-resolved spec converter
(tloncorp/mcp#3): notes upstream registered, hasCachedSpec=true, the
generated notes_submitAction tool's inputSchema fully resolves the
Action discriminator union, a tools/call with a 4-deep nested body
creates a note end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cookie auth is fine for the user driving their own ship's notes UI,
but a bot-ship running %mcp-proxy doesn't have a human to re-paste a
30-day-rotating cookie. State-12 adds an api-key=(unit @t) field
minted on init (and after state-11 → state-12 migration), with a
matching X-Api-Key header bypassing the v1 dispatch's src.bowl guard.

- sur/notes.hoon: state-12 + new %regenerate-api-key / %clear-api-key
  a-notes variants
- app/notes.hoon:
  - state-11-to-12 migration; init/load mint a key if api-key is ~
  - +request-authorized: eyre-cookie OR matching X-Api-Key
  - +handle-v1-post gates on request-authorized then calls dispatch
    directly (bypasses the +poke %notes-action-1 src.bowl check, which
    would otherwise reject unauthenticated-eyre + valid-key)
  - +dispatch-v1-action extracted from +poke %notes-action-1 so both
    the poke path and the HTTP path share one implementation
  - invite/join helpers moved from +poke's |^ to the top level so
    dispatch can reach them
  - scry /x/v0/api-key for inspecting the current key (gated to
    src.bowl == our.bowl)
- tests: 5 new — minted-on-init, regenerate-changes-value,
  clear-disables-bypass, x-api-key-bypasses-cookie,
  x-api-key-wrong-rejects. All 64 tests pass.

Verified manually via curl: no auth → 401, wrong key → 401, right
key without cookie → 200 with typed response body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the membership-wire pokes (handle-send-invite, join-remote,
leave-remote) used the legacy %notes-command mark with no request-id —
fire-and-forget cross-ship pokes. The sender's HTTP response resolved
on local poke-queue, not on the host's actual decision; an %accept-
invite that the host nacked left the FE thinking it worked.

This commit puts every cross-ship flow through the v1 request/response
machinery:

- send-v1-request: shared watch + poke-notes-command-1 + behn-wait
  helper, keyed by flag in the wire so signs route through the standard
  no-agent-req-watch / no-agent-req-poke handlers
- handle-send-invite-v1, join-remote-v1, leave-remote-v1,
  handle-accept-invite-v1 replace the fire-and-forget versions
- dispatch-v1-action %join / %leave / %accept-invite / notebook
  %invite no longer synchronously finalize — the response-update
  closes the loop asynchronously
- +poke %notes-command-1 %notify-invite emits a %no-change
  response-update so the sender's no-agent-req-watch finalizes
- no-agent-req-watch kicks off no-start-watch on the first successful
  cross-ship response when net is %sub with init=|, so a freshly-
  joined subscriber gets the broadcast stream after the join
- Watch handler relaxation: dropped ?> =(our.bowl ship.flag) on
  /v1/notes/{flag}/request/{requester}/{uv}. The path's flag is the
  notebook's identity, not who's hosting the response (the invitee
  hosts response paths keyed by the inviter's flag). The
  src.bowl == requester impersonation check stays.

Compat: dropped the legacy %notes-command poke arm and the four old
helpers (handle-send-invite, join-remote, leave-remote,
handle-accept-invite). External ships running pre-Phase-2 notes can
no longer reach this version. Acceptable per the consistency
directive.

Legacy %notes-action arm now synthesizes a rid from
`(mix eny.bowl rid-counter)` and routes through dispatch-v1-action.
rid-counter is a new state-13 field — tests don't advance bowl.eny
between events, so without the counter consecutive pokes would collide
on the per-request watch wire. State migration: state-12 → state-13
just adds rid-counter=0.

64 tests pass (1 newly-rewritten cross-ship membership-poke test
updated to use notes-command-1). 12 e2e tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… spec

Three issues raised in PR review:

P1 — GET /notes/~/v1/request/{requestId} was unauthenticated. The
request-id is not a capability: stored response bodies can carry
note updates and typed error details, and they're polleable by
anyone who knows (or guesses, or sniffs) the rid until cleanup runs.
+handle-v1-get-request now runs (request-authorized inbound-request)
before serving — same gate as POST. Tests:

- test-v1-get-request-requires-auth: unauthenticated GET → 401
- test-v1-get-request-honors-api-key: GET with matching key → 200

P2 — Failed remote joins left a ghost notebook placeholder. The
pre-v1 %notes-action poke-ack handler used to clean it up; the new
v1 path didn't. +no-cleanup-placeholder rolls back the books entry
(by setting gone=&, which no-abet honors) when net is %sub with
init=|. Wired into both nack paths:

- no-agent-req-poke poke-ack nack
- no-agent-req-watch %watch-ack nack and %fact %error

test-failed-join-cleans-up-placeholder exercises the poke-ack nack
path end-to-end via do-agent against the actual emitted poke wire.

P3 — Served OpenAPI spec was stale: described eyre-cookie-only auth,
missed the new %regenerate-api-key / %clear-api-key action variants.
docs/openapi.yaml updates:

- Top-level description: lists both auth schemes and their use cases
- Action discriminator: adds regenerate-api-key + clear-api-key arms
- securitySchemes: adds xApiKey alongside eyreCookie
- security: lists both (OpenAPI "OR" semantics)

scripts/build-notes-openapi.sh re-run so the embedded ++json arm
matches.

Also adds two small test helpers: +http-get-v1 mirrors http-post-v1
for GET inbound-requests; +http-status extracts a status code from
the response-header fact via !< on the small response-header vase
(the slow `;;(cage ...)` clam this still avoids).

67 tests pass (3 new); curl smoke against live ship confirms 401/401/
404 for no-auth / wrong-key / right-key-but-rid-evicted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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