Skip to content

Real composition behind POST /api/compose: bundle the SDK, key stays server-side - #30

Merged
josevazf merged 12 commits into
mainfrom
feat/app-server-compose
Jul 25, 2026
Merged

Real composition behind POST /api/compose: bundle the SDK, key stays server-side#30
josevazf merged 12 commits into
mainfrom
feat/app-server-compose

Conversation

@josevazf

Copy link
Copy Markdown
Member

What

Replaces the compose screen's client-side stub with the real F2 path — the arbitration-sdk's 0G enclave composition — running behind a single server route, so ZG_PRIVATE_KEY never reaches the browser and the app can deploy to Vercel.

  • SDK server facade (packages/arbitration-sdk/src/serve.ts, the app's only import): env config, broker singleton reused across warm invocations, live book context from the subgraph (degrades to a labelled stub if it's down), compose() with retry, the deterministic validator. It never funds the ledger — that stays npm run fund, out-of-band.
  • POST /api/compose (Node runtime, maxDuration: 60): body carries only { user, prompt, budget }; limits are server policy. 400 for malformed bodies (including duplicate budget tokens), 500 only for bugs.
  • Labelled fallback, never a dead screen: missing key, broker/inference failure — the route still answers 200 with source: TEMPLATE_FALLBACK and a human-readable reason. Provenance is never blurred; the UI renders the reason verbatim.
  • Client: from-server.ts maps the response into the existing UI shapes (template labels come from the SDK's own TEMPLATES — no second copy); the stub and its dead prompt/templates/grammar modules are deleted. ENCLAVE provenance is reachable for the first time; a validator-rejected set renders its violations and cannot ship.
  • Env/docs: ZG_* documented in the app's .env.example (server-only, never NEXT_PUBLIC_); README covers the two modes, funding, and Vercel notes.

Verification

  • SDK: typecheck clean, 107/107 tests (new: offline facade tests — labelled no-key fallback, base-unit→decimal conversion, canonical budget order, JSON round-trip).
  • App: typecheck clean, 3/3 mapper tests (incl. over-precise amount truncation), Turbopack build green.
  • Manual no-key gate: POST /api/compose returns 200 TEMPLATE_FALLBACK with the reason; bad body → 400.
  • Final whole-branch review verified against the built client bundle that no key material, broker code, or server modules leak to the browser.

Not in this PR

The funded-key live gate (needs a funded Galileo key in the deploy env — run it before demo day; note the composer prompt doesn't currently tell the model the wall-clock time, so I7 deadline violations are possible on live runs and render honestly). Prompt display consuming messages is deferred; RecommendationRegistry/commit path and the real ship Multicall remain unwired.

josevazf added 12 commits July 25, 2026 23:30
The compose screen now posts to the real route and maps the response
through fromServer() instead of the client-side stub-composer, which
is deleted. The result header is honest about provenance: ENCLAVE
copy only for a real signature, fallback copy surfaces the server's
reason, plus a proof line and validator violations when present.

Also fixes a latent bug this surfaced: the SDK's opcodes.ts read its
config JSON via node:fs at import time. That module is now reachable
from the browser bundle (from-server.ts imports TEMPLATES from
grammar.ts, which needs the opcode table), so it is loaded through a
static JSON import instead, matching the pattern already used for
config/addresses.8453.json in the app.
Sealed inference and the deterministic gate are wired via /api/compose;
market/pair context beyond the user's own book (F3 job 2), the
RecommendationRegistry commit path, and the ship Multicall remain unwired.
parseAmount rightly refuses to round a too-long fraction (rounding a
ceiling up hands the composer more budget than the user typed), but that
meant a model-emitted amount with more fraction digits than the token's
decimals (e.g. "1000.3333333" USDC) silently vanished — empty facts,
Position with legs: []. Truncate the fraction to the token's decimals
before parsing; that direction is safe, since it can only understate the
ceiling.

Also, while touching the same mapping:
- templateShort now reads the human part of the SDK's template label
  (after the first "·"), instead of regressing to the raw template slug.
- The Ship button is disabled when the validator rejected the set, with a
  line explaining why, matching the Compose button's disabled styling.
- Recommendation cards key on templateId+index, so a repeated template
  from the model no longer collides on key.
prompt.ts and templates.ts were unreachable — nothing imports either one
(templates.ts was only ever imported by prompt.ts). Delete both.

grammar.ts described the old six-slot grammar the SDK's own grammar.ts
says explicitly was superseded and could not be built against the
deployed router at all. Its only remaining consumer was slot-table.tsx's
footnote, which is reworded here to state the settled reality (slots
filled by selection within the deployed router's instruction set; the
tables render 4 rows, not six) instead of citing an open F1 question
that closed a while ago. With that gone, grammar.ts has no importers
left, so it goes too.

Updated the README's Layout table to match: dropped the rows for the
deleted files, and fixed the claim that src/lib/compose owns "prompt
assembly" — that now lives server-side, in the SDK's
buildComposeMessages behind /api/compose.
A budget array with the same token address twice (case-insensitively)
was accepted and produced a 200 whose fallback trips the validator's
I10 (canonical, no-duplicates token order) — a client-fixable input
error surfacing as a downstream validation failure instead of a clear
400 at the trust boundary.
compose() rebuilt buildComposeMessages(req, ctx) fresh for its return
value, which drops the rejection-feedback suffix that a real retry
carried. serve.ts then rebuilt it again for the response. Either way,
the messages field could disagree with what was actually sent.

compose() now threads through the messages it used for whichever
attempt was last (first attempt, or the retry's, including on the
internal TEMPLATE_FALLBACK path where retries were exhausted but
something was genuinely sent), and serve.ts passes that through instead
of reconstructing it.

Also drop the module-scope dotenv/config import from subgraph.ts: it
sits in the server request path (serve.ts -> context.ts -> subgraph.ts)
where env is already loaded by the host, and every CLI that needs it
already loads its own copy.
compose() (via #20) now runs the deterministic gate in its re-infer loop,
so the facade stops re-validating enclave results with divergent chain
state. An ENCLAVE result is gate-approved by construction; the internal
fallback's rec is validated on its own merits, and the last model
attempt's violations surface in the reason string.
@josevazf
josevazf force-pushed the feat/app-server-compose branch from 2c7d7ef to 364508f Compare July 25, 2026 22:33
@GustavoSena

Copy link
Copy Markdown
Collaborator

Review (merge conflicts with main deliberately ignored — but finding 1 is a semantic consequence of that merge and survives any textual resolution).

What holds up well

  • Key hygiene: server-only facade as the app's single import, dotenv moved out of library code into the CLI entrypoints, opcodes.ts on a static JSON import with the reason documented, bundle verified key-free.
  • Honesty discipline: fallback always labelled with a reason; messages documented as the last attempt's prompt, never a reconstruction; a validator-rejected set renders its violations and cannot ship.
  • Route validation is tight: address regex, token allowlist via tokenBy, duplicate detection, positive base-unit integer amounts.
  • truncateFraction has the right direction argument (truncating a ceiling can only understate it) — and it's tested.
  • Checked the unguarded s.slots.deadline.deadline / s.slots.curve.instruction in from-server.ts: safe — parseRecommendation hard-rejects both absences, so nothing reaching the mapper lacks them.

Findings

1. Two different clocks validate the same recommendation (medium — will surface as live I7 failures).
serve.ts builds its ChainState with now: Date.now(), but the prompt (and, on main, the in-loop validator via chainStateFor) anchors "now" to ctx.observedAt — the subgraph's head timestamp on the live path, and 0 when meta.timestamp is null (liveContext uses meta.timestamp ?? 0, which makes the prompt literally say "now is 0"). The model can echo a deadline the loop accepts and serve's wall-clock re-validation then rejects. The PR description already admits live I7 violations are possible — this is the mechanism. Suggested resolution when the conflicts are merged: one now, injected once, used by the prompt, the loop validator, and serve's final validation. Related: #27.

2. Silent stub degradation in the API response (medium — honesty).
On subgraph failure, catch { ctx = nowContext(); } swaps in a stub book, but ServerComposeResult carries no contextSource — the UI cannot distinguish "your live book" from "a stub because the subgraph was down". That breaks F3's stub-labelled-end-to-end rule at exactly the layer users see. Cheap fix: add contextSource: ctx.source (and the observed block) to the response. Related nit: nowContext() re-keys observedAt but keeps the fictional observedBlock 22,500,000 in no-key fallbacks — fine while labelled, but it must never reach a trace.

3. PROMPT_VERSION is orphaned (low).
This PR deletes prompt.ts/grammar.ts/templates.ts — the files the constant's own comment says trigger a bump — and the surviving SDK prompt builder (buildComposeMessages) has no version at all, which F2 §9 requires in every trace. Small follow-up: move promptVersion into the SDK builder / ServerComposeResult. Tracked as a follow-up item on #29.

4. Unauthenticated spend (low for a hackathon — worth a README line).
/api/compose lets anyone spend the 0G compute ledger and 60s of server time per call. Before demo day, a same-origin check or a dumb rate limit keeps the ledger from being drained an hour before the presentation.

Nits (no action needed): benign cold-start race in getBroker (two concurrent requests may both create brokers); the broker resets on any compose failure, not just broker failures; serve.test.ts's delete process.env.ZG_PRIVATE_KEY is safe only while each test file gets its own process.

Review produced with Claude Code.

@josevazf
josevazf merged commit be86b4f into main Jul 25, 2026
@josevazf
josevazf deleted the feat/app-server-compose branch July 25, 2026 22:43
GustavoSena added a commit that referenced this pull request Jul 25, 2026
…ion (#32)

Fixes the four findings from the PR #30 review:

- The validator now runs on the server's wall clock, computed once per
  request and passed into compose()'s re-infer loop — validating on the
  snapshot's own clock could never catch a stale or degenerate snapshot
  (a lagging indexer, a null _meta timestamp). liveContext() also stops
  anchoring observedAt at 0 when the subgraph carries no timestamp.
- ServerComposeResult carries contextSource, so a stub book (subgraph
  down, or the no-key path) is labelled end-to-end per F3 — the compose
  screen now says which book the recommendation was composed against.
- PROMPT_VERSION moves to the SDK prompt builder (sluice.compose/2; v1
  was the app-side contract deleted in #30) and rides every response,
  per F2 §9. The orphaned app-side constant is removed.
- README notes that /api/compose is unauthenticated spend — put a
  same-origin check or rate limit in front before sharing a deployment.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
GustavoSena added a commit that referenced this pull request Jul 25, 2026
PR #30 (real composition behind /api/compose) independently deleted the
app's prompt/stub/grammar module family and made opcodes.ts browser-safe
— everything conflicting resolves to main's side. What survives from
this branch, rebuilt on main's conventions:

- demo-book.ts: the 8 fixtures on the settled four templates, with
  labels and slot rows mirroring from-server.ts so a demo card and a
  shipped card read identically (main still carried the six-slot relic
  with instructions that have no opcode on the deployed router)
- prompt-box.tsx: the oracle-limit example (inexpressible on this venue)
  replaced with a banded intent
- slot-table.tsx: escape the apostrophe #30 shipped — `npm run lint`
  fails on current main without it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GustavoSena added a commit that referenced this pull request Jul 26, 2026
Two fixes found by the first live run against Galileo.

dotenv: PR #30 moved `import "dotenv/config"` out of config.ts into each
CLI entrypoint. The spike is an entrypoint and never imported it, so
loadConfig could not see ZG_PRIVATE_KEY. The dry run could not catch this
— it never calls loadConfig.

Failure diagnostics: the report said 75% fell back and gave nobody a lead.
It now prints the failure kind per run and a violation-code histogram per
arm, plus one sample rejection text. That is what turned an opaque "100%
malformed" into a diagnosis: sub-second empty responses, which is a
drained compute ledger rejecting requests, not a model failing to comply.

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

2 participants