Skip to content

control-plane, frontend: routing suggestions on P4 slice 1 (reviewed, no auto-dispatch) - #50

Open
OfficialAbhinavSingh wants to merge 23 commits into
mainfrom
feat/routing-on-p4
Open

OfficialAbhinavSingh wants to merge 23 commits into
mainfrom
feat/routing-on-p4

Conversation

@OfficialAbhinavSingh

@OfficialAbhinavSingh OfficialAbhinavSingh commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Summary

126a98b ("P4 slice 1") went straight to main with no review: cost-adjusted reputation and suggestion-only routing. This builds on it rather than replacing it — reviews and fixes the slice's own defects, then adds eligibility (probation, operator concurrency cap) and budget-feasibility screening with honest per-agent reasons.

No auto-dispatch anywhere. Suggestions only ever advise; the requester always approves manually with their own picks via the existing /approve flow. Verified at every task review and the final whole-branch review, independently, more than once.

Frontend dropped from this PR. Main shipped its own suggestions UI (3f722b2, SuggestAgents.tsx) against the same /suggestions endpoint while this was in review, so this PR's own frontend (AgentPicks, SuggestionsPanel, useSuggestions) was removed rather than merged in next to it. The endpoint's answer stays a superset of what SuggestAgents reads.

Plan: docs/superpowers/plans/2026-11-02-routing-on-p4.md (4 tasks, all complete):

  • Task 1: reviewed 126a98b cold (12 confirmed defects, 0 Critical, 5 Major), fixed 9 of them across 2 rounds — most notably a silent reputation-pipeline stall if 50 outcomes' costs never arrive from the gateway, a view that dropped challenge-adjudicated outcomes the slice's own doc claimed to cover, a missing index (12.08s → 0.036s on the same query), an unrate-limited write on a GET endpoint, and a zero-cost outcome that was dropping its success/failure signal entirely instead of just its cost signal.
  • Task 2: eligibility.py — probation for a proven falsification (credits-plan falsifications count, since probation is evidence of fraud, not reputation) and an operator concurrency cap, both derived from existing tables, no migration. One known, accepted gap: an agent removed from the roster stops counting toward its operator — needs a migration to close properly, named as a follow-up.
  • Task 3: budget-feasibility screening (ported, mpmath-verified Gamma math) and an honest reason for every agent left out of a suggestion — took a round to get right, since an all-unlucky-draw case in explore mode was initially indistinguishable from genuine structural infeasibility.
  • Task 4: suggestions on the plan review screen — superseded by main's own SuggestAgents UI; this PR's version was dropped (see "Frontend dropped" above).
  • Final whole-branch review: found main had moved again mid-build — a just-shipped "Discard draft" feature (DELETE /v0/plans/{uuid}, a real row delete) collides with this PR's routing_decisions audit table (inherited from 126a98b, references plans, append-only). Fixed by dropping the FK: the audit log now survives a discarded draft, nothing in the discard endpoint itself was touched.

Review round (Viscous106, 2026-11-02)

  • Fixed: operator concurrency cap now counts only wallet-funded dispatches (free credits no longer push a rival operator's roster to "at cap"); record_missing_costs is capped per pass by count and by wall-clock time, so a backlog or a slow gateway can't make one pass of the reputation updater run unbounded; migration 0022's DROP CONSTRAINT is now IF EXISTS; merged current main (frontend dropped, as above — no textual conflicts with 3f722b2/49bec0a/07fa701/126a98b remained to resolve once the duplicate frontend was gone).
  • Recorded as an open gap, not coded around: migration 0020 counts a rejected challenge as a validated success regardless of who the challenger is. An operator could bond a second wallet as challenger against its own agent's unaudited release, pick only releases it already knows are good, and farm counted successes with no real audit. There's no reliable way in this codebase to prove two addresses share a controller, so a filter on the obvious case (the agent's own registered operator) would be cosmetic. Added to docs/reputation-v0.md's "Not manipulation-resistant" list; needs a protocol-level answer (paper §8.2/§8.7), not a query change.
  • Not done, lower priority: /v0/agents/reputation and /suggestions still expose other requesters' in-flight activity through the eligibility reasons; suggestion_attempts is never pruned; test_routes_routing.py still runs a shared-DB TRUNCATE.
  • Still open, needs a decision, not code: splitting this into smaller PRs.

Type of change

  • feat — new functionality

What I verified

  • Every task reviewed independently, 3 of 4 needed real fix rounds for genuine findings (not nitpicks) — see the plan file's own task list above for the headline ones.
  • Control-plane: ruff/format/pyright clean, full suite green including the docker/postgres-marked tests CI runs separately.
  • settings.spec.ts:51, previously noted here as pre-existing on main (root cause: LiveSettings.tsx re-keyed the preferences form on save, so the "Preferences saved." message was lost before it was seen) — fixed on main by frontend: keep the Preferences saved note after a save #51, merged in with everything else.
  • Not verified: real-traffic behavior of the probation/concurrency-cap logic and the budget-feasibility threshold — this has no production reputation data yet (same as 126a98b itself).

Open items for you / Yash (not fixed here, flagged on purpose)

  • /v0/agents/reputation (and /suggestions) leak more than intended: polling either lets anyone back out a requester's category, outcome timing, and exact cost. The design doc calls the reputation endpoint "public, like the agent list" — this is a scope question (should it be?), not purely a bug. Not touched.
  • Operator concurrency cap / probation is derived from the current roster, not a durable per-operator record — an operator can evade probation by removing the caught agent from agents.toml/agents.json. Needs a migration to track operator history independent of roster membership.
  • Migration 0020's self-challenge gap, above — needs a protocol-level answer, not code.

… on its own

Its autouse fixture truncated plans before anything had migrated a fresh
database, so the file passed only after another postgres test file had run
(5 errors, UndefinedTable, when run alone). It now uses test_reputation's
run(), which migrates and then empties every table, like the other ledger
tests.
…king the ledger

Each of task_outcomes' per-dispatch lookups (WHERE task_id = ? AND kind = ?
ORDER BY id LIMIT 1) had no usable index: money_events_on_chain is partial
(tx IS NOT NULL), so every lookup was a full scan and the view cost tasks x
events. At 4,000 dispatches and 12,000 events the reputation pass's pending
query took 12.1 s and the credit-only count behind the unauthenticated
GET /v0/agents/reputation took 4.8 s; with money_events (task_id, kind, id)
they take 0.036 s and 0.017 s, and EXPLAIN shows all four lookups use it.

The migration backfill test holds back every migration that needs
money_events, so 0018 joins 0006 and 0015 there.
…dited ones (paper §8.2)

task_outcomes counted a bad verdict, or a pass and a release. The paper
validates an outcome that was audited or challenged and adjudicated, so
three adjudicated cases produced no row: released unaudited then a
challenge upheld; a challenge upheld before release, then returned for
the challenge; a challenge rejected and the task released, unaudited.

0019 replaces the view (same columns). An upheld challenge is a failure;
with no pass-and-release to keep, it sits at the upheld resolution. A
rejected challenge plus a release is a success at the later of the two.
Every audited case keeps exactly its old success and position, which a new
regression test pins. docs/reputation-v0.md's table says the same.
… outcome

record_missing_costs read the same oldest 50 pending outcomes every pass
and stopped at the first error of any kind. 50 outcomes whose commitment
the gateway never saw therefore held every slot for good: with 51 failures
and only the newest commitment known, its cost was never asked for, and
one malformed answer stopped the queue the same way.

Each pass now tries every pending outcome, oldest first. GatewayCosts tells
a gateway that cannot answer at all (unreachable, a non-400/422 error, not
JSON, not a list: GatewayUnavailable, which still ends the pass) from an
unusable answer about one task (another task's commitment, a missing,
garbled or negative cost, a row that is not an object, a 400/422:
BadCommitment, which skips that task only). A cost that cannot be read is
still left pending, never taken as zero.

Also: a write is counted only when the INSERT ... ON CONFLICT DO NOTHING
inserted a row (another instance may have won the race), and a gateway row
that is not an object is a BadCommitment instead of an AttributeError that
escaped the pass and skipped refresh_posteriors.
…3 requires

Posterior.observe accepted cost == 0. A run of zero costs decays tau toward
0, and after ~14,000 of them density overflows to inf, which Starlette's
JSON cannot carry, so GET /v0/agents/reputation would 500. It now refuses
cost <= 0. No existing test used a zero cost.

task_costs still allows 0 (it copies the commitment as it is), so the fold
now reads only cost_micros > 0: a zero-cost outcome is left out like one
whose cost is unknown, instead of raising on every refresh.
GET /v0/plans/{uuid}/suggestions?explore=true appends a replay record per
task to routing_decisions, which is append-only, on every call, with no
limit: any signed-in account with one draft plan could grow it without
bound. Exploring calls are now limited the way create_plan limits the
planner: a per-requester advisory lock, a count of the last hour's calls
in a new suggestion_attempts table (planning_attempts' shape), 429 past
SUGGESTIONS_PER_HOUR (default 60), and the attempt row written in the same
transaction as the decisions. Greedy calls write nothing and are not
limited.
…from the paper

- Cost is folded only from validated outcomes, not every completed task as
  the doc claimed; the paper (§8.3) uses all completed tasks, and whether
  unaudited tasks' costs should count is now stated as open.
- Greedy as the default is this slice's divergence from the paper, which
  allows exploration suppression only per task (§9.4: "§9.5 explains why it
  cannot be the default"); defensible while the §9.5 subsidy does not exist.
- "What this is not" now names §8.5 partial pooling and §9.4 retry
  tolerance, neither of which is built.
- GET /v0/agents/reputation lists credit-only pairs too, not only pairs with
  counted outcomes.
Fix round 2 for D12. Paper §8.3's c > 0 constrains only the cost (Gamma)
update; the success (Beta) update of §8.2 has no cost precondition. The
round-1 fold read only cost_micros > 0, so a validated failure whose
receipt metered 0 (a run that died before its first billed call) left no
trace at all, and that agent kept its prior and could outrank agents with
a real failing record.

Posterior.observe now takes cost=None for an outcome with no usable cost:
alpha/beta decay and increment and n counts it, kappa/tau are left alone.
It still refuses a cost <= 0. The fold reads every wallet outcome whose
cost is recorded (pending ones still wait) and passes None for a 0.

The round-1 test that pinned the dropped outcome (n == 1) asserted the
wrong behaviour and is replaced. docs/reputation-v0.md says the same, and
its table gains the pass + upheld challenge + no release row (F6).
…suggestion answers

Explore-mode rows say their numbers are this draw's, not the agent's estimate.
Each pick now carries cost_is_prior (the existing _cost_is_the_prior check), and
a cost with nothing metered behind it shows as "no metered cost yet". Answers
from an older control plane (no ineligible, no cost_is_prior) are read with safe
defaults instead of taking the review page down. The draw switch is disabled
while a request is in flight, and the re-read after a save shows as updating.
main gained discard-draft (DELETE /v0/plans/{uuid}), plan idempotency
(its own migration 0018_plan_idempotency) and a redeem_admin fix.

- Renumber this branch's migrations after main's 0018: 0018_money_events_task
  -> 0019, 0019_task_outcomes_adjudicated -> 0020, 0020_suggestion_attempts ->
  0021. Update the references to them by number (test_money's held-back set,
  reputation.py's docstring, docs/reputation-v0.md). None of them was applied
  anywhere outside tests.
- frontend/e2e/live/plans.ts: ApiCall keeps both sides' fields (query from
  here, key from main) and the recorded call fills both.
routing_decisions (0016) referenced plans with no ON DELETE action, and an
exploring draw is only made for a draft. Main's discard-draft really deletes
the plan row, so any draft that had a "Try a random draw" failed to discard
with a ForeignKeyViolation (500).

Migration 0022 drops routing_decisions_plan_uuid_fkey. plan_uuid keeps its
value and NOT NULL; the replay log is an audit record that outlives the
discarded draft. The append-only trigger is unchanged and the new test checks
it still refuses UPDATE and DELETE on the surviving rows.
readSuggestions defaulted a missing cost_is_prior to true, so against a
control plane without the field (origin/main's current one) every agent with
a real, measured cost read "no metered cost yet". A missing field is unknown,
not "only the prior".

cost_is_prior is now boolean | null: true and false are kept as sent, and
anything else (absent, null, malformed) reads as null. The picks show null
as "cost not reported": no dollar figure and no claim about the prior.
@OfficialAbhinavSingh

Copy link
Copy Markdown
Member Author

@Viscous106 this builds suggestion-only routing on top of your P4 slice (126a98b) rather than replacing it -- reviewed it cold first (9 defects fixed, 2 rounds), then added eligibility + feasibility + a frontend UI. No auto-dispatch anywhere, checked repeatedly.

Caught your discard-draft change (447f521) landing mid-build -- it collided with routing_decisions' FK to plans, fixed by dropping the FK (migration 0022), didn't touch discard_plan itself.

Two things in the description are your call, not mine: the reputation endpoint's privacy scope, and the probation-evasion-via-roster-removal gap (needs a migration to close properly). Also found settings.spec.ts:51 failing on your 0ec2c79 independent of anything here -- likely cause traced in the description if you want it.

@Aspirant200715

Aspirant200715 commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Hey Abhinav, great work on PR #50 the query speedup and the suggestion routing logic look super clean!
I saw your notes under "Open items for you / Yash". I’d like to pick up the Playwright fix for settings.spec.ts:51 first our entire CI gate suite is 100% green across the board (thanks for tracing the LiveSettings.tsx form remount issue!).
Right after that, I can take on the operator history & probation migration to close the roster-evasion gap.

@Viscous106 Viscous106 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The control-plane half is careful work. The suggestion-only boundary holds (the only writes are suggestion_attempts and routing_decisions, with no dispatch, escrow or assignment path), and the owner check, 404 and 409 handling are right. The posteriors read funding='wallet' only. The feasibility-probability maths checks out, and the rate limit is transactional. The eligibility tests are good guard tests, and the docs honestly mark the parameters as ours. But the PR conflicts with main, one check is failing, and there are three issues I'd want settled first. I read the diff in full and ran nothing.

High

  1. The frontend duplicates what main already has. Commit 3f722b2 already added SuggestAgents.tsx, suggest.ts, getSuggestions and a Suggestions type, and edited DraftPlanEditor.tsx. This PR adds a second getSuggestions, SuggestionsResponse/Suggestion types, useSuggestions and a SuggestionsPanel, and edits the same DraftPlanEditor.tsx, plans.ts and run.spec.ts regions. A naive conflict resolution leaves two suggestion panels or a duplicate-export typecheck failure. Please drop this PR's frontend and rebuild it on SuggestAgents, or replace SuggestAgents deliberately.
  2. The operator concurrency cap counts free-credit dispatches (eligibility.py:119-134, _UNRESOLVED_BY_AGENT, no plans.funding filter). Someone with a free-credit grant could dispatch a few cheap credit plans to a rival operator's agents and make that operator's whole roster show "operator at concurrency cap" for everyone. That breaks invariant 6 (free-credit work must not influence routing). The probation rule counts credit plans deliberately and documents why. The cap has no such reason, and no test covers it. Count only wallet-funded dispatches, and add a guard test.
  3. Migration 0020 widens the "validated outcome" rule (invariant 4): a rejected challenge plus an unaudited release now counts as a success. If rejection means the bond is forfeited to the operator, an operator could self-challenge its own unaudited releases and farm counted successes. Please confirm the economics against paper §8.2 and §8.7, and add a test or a docs caveat. docs/reputation-v0.md says "not manipulation-resistant" but doesn't cover this path.

Medium
4. Conflicts with main (client.ts, types.ts, DraftPlanEditor.tsx, e2e/live/plans.ts, run.spec.ts, changed by 3f722b2, 49bec0a and 07fa701; routes_routing.py changed by 126a98b). Migrations 0019-0022 are still free. Rebase and re-check.
5. A CI check fails, and I can't tell which. Candidates: a prettier format:check failure on the long lines, npm ci (jsdom 30 needs Node ^22.22.2 or ^24.15.0 but package.json says >=22.12), pyright on the new cast/isinstance code in reputation.py, or a duplicate symbol from finding 1.
6. record_missing_costs lost its LIMIT, so every pass makes one sequential gateway request per pending outcome with no time bound. It runs under the updater, and the unauthenticated GET /v0/agents/reputation reads the same data. Add a time budget or a larger cap.
7. It's oversized, and CLAUDE.md says one concern per PR. Suggested split: (a) reputation hardening with 0019 and 0020, (b) eligibility and the routes, (c) the frontend, rebased on SuggestAgents.

Low: "72% success · about $0.15 a run" shows a decayed posterior mean as "success" with no "estimated" wording. The "at cap" and "in probation" reasons expose other requesters' in-flight activity. suggestion_attempts is never pruned. test_routes_routing.py runs TRUNCATE on a shared DB. Migration 0022's DROP CONSTRAINT has no IF EXISTS.

Not verified: tests, migrations against Postgres, the paper's §8.2 and §7.5 text, and the full frontend tests.

main already shows routing suggestions (SuggestAgents, 3f722b2) against the
same /suggestions endpoint, so this branch's own frontend is dropped rather
than merged next to it: frontend/ is now exactly main's. The endpoint's
answer stays a superset of what SuggestAgents reads (it only adds
cost_is_prior and the agents left out with their reasons).
…ator cap

The cap counted every unresolved dispatch, so anyone with a free-credit grant
could send a few cheap plans to a rival operator's agents and have its whole
roster shown as "operator at concurrency cap" to every requester. That is
free-credit work influencing routing, which invariant 6 rules out. Only
wallet-funded dispatches count now; probation still counts every plan, for the
reason its docstring gives. The new test fails on the old query.
… not fixed

Counting a rejected challenge as evidence (migration 0020) assumes the
challenger is adversarial and disinterested. Nothing enforces that: an
operator could bond a second wallet as challenger against its own agent's
unaudited release, pick only releases it already knows are good, and farm
counted successes with no real audit. The database has no way to prove two
addresses share a controller, so a filter on the obvious case (the agent's own
registered operator) would be cosmetic rather than a real fix. Recorded in
the existing manipulation-resistance list rather than coded around.
It read every pending-cost task in one pass with no bound, one sequential
gateway request each, so a backlog (an outage, or heavy volume) made one pass
of the reputation updater take arbitrarily long, every REPUTATION_POLL_S, on
top of GET /v0/agents/reputation reading the same table. Two new settings cap
it: REPUTATION_COST_BATCH (default 200 tasks, a query LIMIT) and
REPUTATION_COST_BUDGET_S (default 10s wall-clock, checked before each
request). Either stops the pass early; the rest waits for the next poll.
Every other DDL statement in this file is idempotent (CREATE ... IF NOT
EXISTS); this was the one that was not.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

3 participants