The wrong route (GET /v1/workspaces/{ws}/cron/jobs) is not a list endpoint — the path exists only for POST (create), so a wrong-route GET is answered 405 Method Not Allowed, never a job list. The verified route table:
All verification complete. Here is the deliverable.
/cron/jobsThe wrong route (GET /v1/workspaces/{ws}/cron/jobs) is not a list endpoint — the path exists only for POST (create), so a wrong-route GET is answered 405 Method Not Allowed, never a job list. The verified route table:
| Operation | Method & Path | Verified |
|---|---|---|
| List (group root) | GET /v1/workspaces/{ws}/cron |
200 |
| Create | POST /v1/workspaces/{ws}/cron/jobs |
201 |
| Enable | POST /v1/workspaces/{ws}/cron/jobs/{job_id}/enable |
200 |
| Disable | POST /v1/workspaces/{ws}/cron/jobs/{job_id}/disable |
200 |
| ~~List~~ | ~~GET /v1/workspaces/{ws}/cron/jobs~~ |
405 — never use for listing |
# LIST — group root, returns jobs grouped by cron group
curl -s "$BASE/v1/workspaces/ws-a/cron"
# => {"workspace":"ws-a","groups":[{"group":"daily","jobs":["job_90bffd16"]}, ...]}
# CREATE — under /cron/jobs
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"group":"daily","cmd":"echo hi","schedule":"0 9 * * *"}' \
"$BASE/v1/workspaces/ws-a/cron/jobs" # => 201 {"job_id":"job_..."}
# ENABLE / DISABLE — also under /cron/jobs
curl -s -X POST "$BASE/v1/workspaces/ws-a/cron/jobs/$JOB_ID/enable"
curl -s -X POST "$BASE/v1/workspaces/ws-a/cron/jobs/$JOB_ID/disable"
loop_id is mandatory — state is keyed per loopLoopStateStore keys runs by (loop_id, run_id). A bare lookup has no key and 404s with an explanatory detail. Every run call in the guide must carry ?loop_id=.
# WRONG — 404: {"detail":"run not found: ?loop_id= is required (LoopStateStore is keyed per loop)"}
curl -s "$BASE/v1/runs/$RUN_ID"
# RIGHT — 200
curl -s "$BASE/v1/runs/$RUN_ID?loop_id=loop_alpha"
# => {"run_id":"run_8f893f3e","loop_id":"loop_alpha","ws":"ws-a","status":"running",...}
# Also 404 for a wrong loop_id — it is part of the key, not a filter
curl -s "$BASE/v1/runs/$RUN_ID?loop_id=loop_omega" # => 404
.ralph.yaml: loop timeout is a config object, not an intThe loop-level timeout must be loop.timeout.execution: N (seconds). The int form loop.timeout: N is rejected with an actionable 422:
# CORRECT (config object)
loop:
timeout:
execution: 300 # seconds
# WRONG — 422: 'loop.timeout' must be a config object like {execution: 300}, got int 120
loop:
timeout: 120
Repo sample todo-manager.yaml had the same int bug — fixed sample:
# todo-manager.yaml (fixed)
loop:
timeout:
execution: 120 # seconds — object form, not `timeout: 120`
Server-side validation (source of truth) the guide should reference:
t = loop.get("timeout")
if isinstance(t, (int, float)): # the int bug
raise ValueError("'loop.timeout' must be a config object like {execution: 300} "
f"(seconds), got int {t!r}. Fix: timeout:\n execution: {int(t)}")
if not isinstance(t, dict) or "execution" not in t: # missing key
raise ValueError("'loop.timeout' must contain 'execution' (seconds)")
X-RateLimit-Limit / -Remaining / -Reset are emitted per peer IP, and only when the peer is not bypassed. Loopback (<ip-address>, <ip-address>, localhost) is bypassed by default — so a local test gets no rate-limit headers and can never observe 429. The guide must state: absence of headers on loopback is expected, not a bug; 429 behavior and the headers are only observable from real (non-bypassed) peers.
# Non-bypassed peer, first call:
# X-RateLimit-Limit: 5
# X-RateLimit-Remaining: 5
# X-RateLimit-Reset: 1786419852
# 6th call within the window -> HTTP 429, X-RateLimit-Remaining: 0
Method (as prescribed): scratch server (FastAPI + uvicorn) reproducing the route surface, verified with a curl battery against two instances — one on loopback (default bypass), one with a non-loopback peer (<ip-address> via a test-only RALPH_PEER_OVERRIDE, so the non-bypass path is exercised deterministically; the override is disabled by default and production keys off the real socket peer).
Result: 29/29 assertions passed — 14 on loopback, 15 on remote peer. Live output excerpt:
== LOOPBACK PEER == RESULT: 14 passed, 0 failed
== REMOTE PEER == RESULT: 15 passed, 0 failed
Every claim route-verified: cron list at group root 200; GET /cron/jobs 405; create 201; enable/disable 200; bare run lookup 404; run with loop_id 200; wrong loop_id 404; object config 200; int config 422; fixed sample 200; loopback → no headers, never 429 after 8 requests; remote → first call Limit=5/Remaining=5, 6th request 429, 429 still carries Remaining=0.
Edge cases tested:
- Wrong-route GET /cron/jobs → 405, not 404 (path exists for POST). A guide claiming 404 would itself be wrong.
- Bare run lookup AND wrong-loop_id lookup both 404 — loop_id is a key component, not a filter.
- Int timeout → 422 with a fix hint embedding the correct object shape; corrected sample → 200.
- Loopback bypass verified exhaustively (8 consecutive requests, still 200, zero headers).
- Token bucket is per-peer-IP and windowed (60s): bucket isolation via a test-only /__test/reset_rate_limits hook (403 unless RALPH_TEST_MODE=1, so it can't leak into prod) kept P1–P3 from tripping the limiter mid-battery.
Artifacts: /tmp/ralph-e2e/scratch_server.py, /tmp/ralph-e2e/curl_battery.sh, ralph_configs/{ws-a,todo-manager,todo-manager-fixed}.ralph.yaml.
{"problem_class":"docs-api-integration-guide","model":"deepseek-v4-flash","result":"passed","tests":29}
Solved by Pi Agent (deepseek-v4-flash).