# The port, for an agent

You are driving a strategy-verification service over HTTP. This document is the
whole contract. Everything in it was exercised with `curl` against a running
instance before being written down; nothing here is aspirational.

**Base URL** the host that served this document, plus `/v1`.

**Authentication** every endpoint below except `GET /v1` and `GET /docs` takes

```
Authorization: Bearer <your key>
```

A key is issued to one account and names the principal every call acts as. You
never send a principal, a trader id or an account in a body: the key carries it.
Nothing you can put in a request can change whose attempt a run counts as.

All request and response bodies are JSON. Send `Content-Type: application/json`.
Responses are always strictly valid JSON -- no `NaN`, no `Infinity`.

---

## What this service does, so you know what you are driving

It takes a trading strategy described in plain words, agrees an exact,
unambiguous plan with the trader, freezes that plan, and only then runs it
against market data. It reports whether the result can be believed.

Two things follow from that, and they shape every endpoint below.

**The plan is frozen before any data is read.** This is pre-registration. A plan
cannot be edited after it is agreed; a change is a new session. That is the rule
that makes the result mean anything, and the API enforces it rather than asking
you to respect it.

**The service does not tell you whether a strategy is good.** It tells you
whether a result is *real*: computed on the bars it claims, with trades that
could actually have happened. Ranking strategies is measurably no better than
chance here, so no endpoint returns a score or a rank.

---

## The shape of a session

```
POST /sessions                  describe the idea          -> AWAITING_CONFIRMATION
POST /sessions/{id}/answer      settle an open question    -> AWAITING_CONFIRMATION
POST /sessions/{id}/accept      take one of our guesses    -> AWAITING_CONFIRMATION
POST /sessions/{id}/reject      replace one of our guesses -> AWAITING_CONFIRMATION
POST /sessions/{id}/revise      we misread the idea        -> AWAITING_CONFIRMATION
POST /sessions/{id}/confirm     agree the plan             -> FINAL_PLAN or AGREED
POST /sessions/{id}/run         QUEUE it -> 202 + a job    (AGREED only)
GET  /sessions/{id}/report      poll it: QUEUED/RUNNING/DONE/FAILED/INTERRUPTED
GET  /health                    no key; is the worker alive?
GET  /queue                     how much work is waiting
POST /sessions/{id}/abandon     give up on it              -> ABANDONED
POST /sessions/{id}/feedback    tell us how it went        (free, any state)
GET  /sessions/{id}             the session as it stands
GET  /sessions                  every session of this principal
GET  /me                        who you are, and your limits
```

States: `RECEIVED` -> `AWAITING_CONFIRMATION` -> (`FINAL_PLAN`) -> `AGREED`, or
`REJECTED` / `ABANDONED`. `AGREED` and `REJECTED` are final and cannot be written
to.

---

## 1. Describe the idea

```http
POST /v1/sessions
{"idea": "buy SPY when the 20 and 50 day MAs cross, sell on the cross back, days",
 "name": "spy-crossover"}
```

`idea` is free text, up to 20,000 characters. `name` is an optional label.
**This is the only free text the API accepts.** Everything after this names an
id and supplies a value.

Alternatively, if your agent already holds an exact condition tree in the
platform's rule grammar, you may submit `entry` and `exit` (or `config`)
directly on `POST /v1/sessions` (with or without `idea`):

```http
POST /v1/sessions
{"name": "spy-connors-rsi2",
 "instrument": "SPY",
 "bar_interval": "1d",
 "holding_horizon": "days",
 "entry": {"all": [
   {"rule": "above", "left": "close", "right": {"times": [{"ema": 200}, 1.02]}},
   {"rule": "below", "left": {"rsi": 2}, "right": 10}
 ]},
 "exit": {"any": [
   {"rule": "above", "left": "close", "right": {"sma": 5}},
   {"rule": "stop_loss_atr", "mult": 2.5},
   {"rule": "max_bars_held", "bars": 10}
 ]},
 "position": {"cooldown_bars": 2}}
```

Structured submissions are validated immediately (`400 bad_request` on an
unknown rule or malformed operand) and still go through the exact same
restatement and two-step `confirm` pre-registration freeze. Every session
response also includes `data_availability` (`state`: `held`, `derivable`,
`fetchable`, or `no_source`) so you know whether the settled bars are ready
before confirming.

Response `201`:

```json
{
  "session_id": "0e9af5bf80d58302d08f88b48de84be6",
  "state": "AWAITING_CONFIRMATION",
  "round": 1,
  "bucket": "served",
  "restatement": "WHAT YOU TOLD US\n  Instrument   SPY\n  ...",
  "open_decisions": [
    {"id": "d.fast_slow_lengths.cd36",
     "field": "fast/slow lengths",
     "matters_because": "10/30 and 50/200 are different strategies",
     "candidates": ["10/30", "20/50", "50/200"]}
  ],
  "assumed": [
    {"id": "a.timezone.879c", "field": "timezone", "value": "America/New_York",
     "because": "bar boundaries move with the zone",
     "revisit_when": "a daily boundary turns out to matter to your rule",
     "ours": true, "must_answer": false}
  ],
  "unsupported": [],
  "needs_before_confirm": ["d.fast_slow_lengths.cd36"],
  "created": true
}
```

### How to read that

**`needs_before_confirm`** is the list you must act on. It is the only field you
need in order to decide what to do next. When it is empty, the plan is complete
and one `confirm` agrees it.

**`open_decisions`** are things the idea did not say that change which strategy
gets tested. `matters_because` says why it matters -- pass that to the trader
verbatim if you have to ask them.

**`assumed`** are values *we* chose. `ours: true` means nobody has reviewed it.
`must_answer: true` means execution cannot proceed without it and it appears in
`needs_before_confirm`. Everything else is ours to choose and recorded with a
reason; the timezone of an equity ETF is not worth asking a human about.

**`unsupported`** is anything the trader asked for that this service cannot
express. Two kinds, and the difference decides whether a run is possible at all:

| `kind` | Means |
|---|---|
| `measure_without_it` | the result is a different strategy but every number is still correct. It runs; the report discloses it |
| `needs_a_new_measure` | no number in the report would be correct. The session is **refused** |

**`bucket`** says whether this can be served. `served` means yes. Anything else
means no, and `refusal` is then present with the reason -- see *Refusals* below.

---

## 2. Settle what is open

```http
POST /v1/sessions/{id}/answer
{"id": "d.fast_slow_lengths.cd36", "value": "20/50"}
```

```http
POST /v1/sessions/{id}/accept
{"id": "a.timezone.879c"}
```

```http
POST /v1/sessions/{id}/reject
{"id": "a.timezone.879c", "value": "UTC"}
```

Each returns the same shape as `POST /sessions`, with `needs_before_confirm`
updated. All three are optional: leave something open and we will choose it, and
say we chose it.

### When our READING is wrong -- not an answer, a revision

```http
POST /v1/sessions/{id}/revise
{"correction": "the entry lookback is 55 days, not 20, and the channel is
                built from closes rather than highs"}
```

**Use this, not `answer`, when the restatement describes a different strategy
from the one you meant.** `reject` replaces one assumption we made; `revise`
says we misread the idea itself, and sends us back to read it again. The
correction is prose, the same kind of thing the idea was -- you should not
need to know our schema to tell us we got it wrong.

**"no" is not an answer.** If a decision asks whether our reading is correct
and you `answer` it with "no" (or "wrong", "not right", "incorrect"), we do
not record that as settling anything. It comes back in `contested`:

```json
"contested": [
  {"id": "d.is_this_reading_correct.740d",
   "you_said": "no -- I asked for a 55-day entry, not 20",
   "next": "POST /v1/sessions/{id}/revise with {\"correction\": \"...\"}"}
]
```

and `confirm` refuses while it stands. **Nothing may be frozen while a
restatement stands rejected** -- that is the one thing this service cannot
get wrong, because a pre-registered plan the counterparty disowns is worse
than no plan at all. Send the correction to `revise` and confirm the reading
that comes back.

### Ids

An id looks like `d.fast_slow_lengths.cd36` -- a kind (`d` for a decision, `a` for
an assumption), a slug, and four hex characters. **Take them from the response.
Never compose one.** A near-miss is refused rather than guessed, because a guess
would route an answer to the wrong question.

Ids are derived from *what is being decided*, so an unanswered decision keeps the
same id across rounds. If you see the same id again, it is the same question
still open -- not a new one.

### Values

A string, a number or a boolean. Strings must be a single line, at most 500
characters. An empty value is refused: it is indistinguishable from not
answering, and the difference decides whether the result is the trader's choice
or our assumption.

---

## 3. Agree the plan -- **this always takes two calls**

```http
POST /v1/sessions/{id}/confirm
{}
```

**This is the one place the API will surprise you.** `confirm` returns `200` with
one of two `status` values, and both are normal:

```json
{"status": "final_plan",
 "state": "FINAL_PLAN",
 "final_plan": "THIS IS WHAT WE ARE GOING TO TRY\n\n  You did not say, so we chose...",
 "guessed": ["d.fast_slow_lengths.cd36"]}
```

```json
{"status": "agreed",
 "state": "AGREED",
 "config_hash": "b816c24e72ff27e9...",
 "filename": "localhost__spy-crossover__b816c2.config.json",
 "config": {"...": "the frozen plan, in full"}}
```

**The first `confirm` always answers `final_plan`.** It is not an error, and it
does not mean anything is wrong with your idea. It is the page naming every value
that came from us rather than from you, and you agree to THAT.

`guessed` lists only the ones we had to invent because nobody answered -- it is
often empty, and an empty `guessed` does not mean the plan is all yours. The
plan body also names the choices we make the same way every time and still never
got from you:

| ours | why it is not yours |
|---|---|
| `dataset` | which series your rules run over. A proxy and the thing it tracks can differ by years of history and by whose venue risk is in the price |
| `venue_kind` | where the session and timezone below come from |
| `bar_interval` | every window counts BARS, so this decides whether a "20" means 20 days or 20 four-hour bars |
| `session` | sets bars per day, and therefore the real length of every window |
| `timezone` | bar boundaries move with the zone |
| `period` | how much history, which bounds what can be claimed |
| `period_window` | the two dates your period resolved to against the bars we actually hold. This is where "the last 20 years" of a series starting in 2016 becomes 2016-01-01 to 2026-09-21, and the row says so in those words. The run is bounded by these dates and they are part of the plan's hash |

From there:

- **Confirm again.** The plan binds, and everything above is recorded in the
  Config as ours rather than yours.
- **Answer or replace any id instead.** The session returns to
  `AWAITING_CONFIRMATION` and you are shown a new plan. We will not bind a plan
  you have just contradicted.

**There is no one-call path, including for a fully specified idea.** That changed
2026-10-12. A plan used to bind on the first call when `needs_before_confirm`
was empty -- which meant the one page naming our choices never rendered for
exactly the ideas clear enough to run. Clear is not the same as checked: "SPY,
20/50 cross, hold for weeks" is a complete strategy and still rests on all six
rows above. Pre-registration only means something if nobody can freeze a plan
they were not shown.

### What the agreed Config tells you

`config.agreement.how_confirmed` records how the agreement was reached, and it is
part of the hash -- the same rules agreed two different ways are two different
artifacts.

| value | meaning |
|---|---|
| `human_confirmed` | a key whose holder is a person |
| `delegated` | a key an operator issued to an agent, with limits in its scope |
| `open_localhost` | no credential presented; loopback with authentication off |
| `in_process` | never served over HTTP |

`config.unanswered` lists every decision the trader never answered. Those values
are in the plan and they came from us. **If you report the result to a human, say
so** -- a verdict on a plan whose load-bearing numbers were ours, that does not
mention it, is a verdict on a strategy nobody described.

---

## 4. Run it -- **this is asynchronous**

```http
POST /v1/sessions/{id}/run
{}
```

Only from `AGREED`. The body is empty: there is nothing about a run for a
caller to configure. Whether unpinned data is tolerated is an instance
setting, because a dataset that does not match its pin is our problem rather
than yours -- you get `UNCHECKABLE`, and `UNCHECKABLE` is not charged for.

**It returns `202` and does not wait.** A run takes twenty to forty seconds, and
a request that held a connection open for that long would be a connection to
lose and a thread to block.

```json
{"job_id": "9f3c1b...", "job_state": "QUEUED", "queue_position": 0,
 "ran_now": true, "session_id": "...", "config_hash": "..."}
```

`202` means this call queued it. `200` with `ran_now: false` means a job was
already there -- which is the answer to a retry after a timeout, and it costs
nothing: one agreed plan is one run, so a retry never queues a second search or
charges a second attempt.

### Then poll

```http
GET /v1/sessions/{id}/report
```

One shape, five states, so polling is one branch. `job_state` is always present:

| `job_state` | what it means | what to do |
|---|---|---|
| `QUEUED` | waiting. `queue_position` says how many are ahead | poll again |
| `RUNNING` | in progress, `started_at` set | poll again |
| `DONE` | the result is in this response | read it |
| `FAILED` | it broke on our side. `error` and `message` say so | the plan is fine; ask again to retry |
| `INTERRUPTED` | the service restarted mid-run | ask again -- see below |

A second a poll is plenty. Nothing about the result lives in the connection that
asked for it, so a dropped connection, a restarted agent and a reboot all resume
with the same call.

### The job survives; it is not resumed for you

A job the service was running when it restarted comes back as `INTERRUPTED`, not
`QUEUED`. It is deliberately not retried automatically: the attempt is charged to
the ledger *before* the run is stored, so a crash between those two leaves an
attempt charged for a run that does not exist, and we cannot tell which side of
that the crash fell on.

Asking again creates a fresh job. That can over-count an attempt by one, which is
the safe direction *statistically* -- the attempt count only ever makes a result
look **less** significant, never more.

It is not the safe direction commercially, and that is worth saying plainly
since attempts are bought. An interrupted run can leave you charged for a
result you never received. The ordering stays as it is because the alternative
is worse: storing before recording means a crash in the gap hands out a
verified run the attempt ledger never saw, and a result the multiplicity
correction cannot count is a result that reads more significant than it is.

So the remedy is a refund rather than a reversal -- the ledger is append-only
by trigger, not by convention. A charge for a run that does not exist is
visible (an `INTERRUPTED` job with no stored run) and is returned as a grant
on the account, which leaves a record of why the balance moved.

Everything else that fails on our side is already free, by the outcome it is
recorded under: data that would not load or whose hash had drifted, and any run
that produced no verdict at all. Those are written to the ledger for the audit
trail and carry `counts = 0`, so they never reach your balance or the
correction.

### Two different 429s, and you have to tell them apart

| `error` | what it means | did it cost an attempt |
|---|---|---|
| `quota_exhausted` | your balance is 0 | no -- it refused *before* running |
| `rate_limited` | you are asking too fast | **no**, and nothing ran at all |

`rate_limited` is a limit on how fast this one service can be asked anything,
including the free routes. It is about the machine, not about money or
statistics: your balance is untouched and so is the multiplicity count. The
message says so in its first clause, and a `Retry-After` header plus a
`retry_after_seconds` field carry the wait.

The current limit is 120 requests a minute from one ADDRESS, over a sliding
window. Not per key, since 2026-11-04: it used to be bucketed on the presented
bearer, which meant a caller sending a fresh random token on every request
never filled a bucket at all and walked past the limit entirely. The one
property that makes a rate limit a rate limit is that the caller cannot choose
their own bucket, so the bucket is now where the request came from. If several
of your agents share one egress address they share one bucket. No ordinary
agent approaches it -- the review loop's own transcripts run at a
handful of calls a minute -- so if you are hitting it, either something is
looping or your workload is legitimately heavier than the guess. **Ask rather
than retry around it.** The number is a guess at polite behaviour, not a
product boundary, and it is one environment variable.

An unauthenticated request is limited per source address instead, which is
what protects discovery (`GET /v1`, `GET /v1/docs`) from being used to flatten
the service by someone with no key at all. `GET /health` is exempt, so a
monitor cannot throttle itself into reporting an outage.

### When it is DONE

```json
{
  "job_state": "DONE",
  "verdict": "CHECKED",
  "headline": "It made money, but not enough to justify the risk it took.",
  "attempt": 1,
  "checks": [
    {"check": "Data",         "outcome": "caution",       "says": "..."},
    {"check": "Reproduction", "outcome": "not_available", "says": "reproducing a stored run is not available yet"},
    {"check": "Executable",   "outcome": "passed",        "says": "all 38 trades were rebuilt independently from the raw prices and confirmed"},
    {"check": "Consistent",   "outcome": "passed",        "says": "..."},
    {"check": "Value",        "outcome": "failed",        "says": "..."}
  ],
  "scope": "Checked: Data, Executable, Consistent, Value. Not available yet: Reproduction.",
  "reasons": [
    {"text": "It earned less than the simple alternative at the same risk.",
     "evidence": "+6.76% a year, against +9.09% from holding 52% of the asset and the rest in cash -- which would have had the same 18.0% worst drop.",
     "terms": ["same-risk blend", "drawdown"]}
  ],
  "measured": {"first_bar": "2018-01-02 09:00:00", "last_bar": "2022-12-30 09:00:00",
               "bars": 1259, "interval": "1d",
               "agreed_from": "2018-01-01", "agreed_to": "2022-12-31",
               "lead_in_bars": 200, "lead_in_from": "2017-03-17 08:00:00"},
  "recorded": {"run_id": "6e90cdf1...", "outcome": "REJECTED", "counted": true},
  "report": "kovacs__ma_cross__20261104T091233.html",
  "numbers":  {"ann_return_pct": 6.76, "max_dd_pct": 18.0, "trades": 38, "...": "..."},
  "findings": [{"text": "...", "evidence": "..."}],
  "exits":    [{"label": "...", "trades": 38, "share_pct": 100.0, "...": "..."}],
  "data":     {"dataset": "spy_1h_2016_2026", "first_bar": "...", "...": "..."},
  "trades":   [{"n": 1, "entry_bar": "...", "entry_price": 201.3,
                "exit_bar": "...", "exit_price": 207.9, "bars_held": 14,
                "return_pct": 3.28, "...": "..."}]
}
```

`numbers`, `findings`, `exits`, `data` and `trades` are the same figures the
HTML report is drawn from, one trade per row, so you can chart or re-check the
result yourself. Returns are before trading costs.

### How to read a result

**`measured` is which bars the verdict is about.** Check it against the
`period_window` in your frozen plan; they should be the same window, and if
they are not, the result is about a period you did not pre-register. A span
alone could never be checked that way -- 10.8 years ending last month and 10.8
years ending in 2019 are the same number of years.

| field | meaning |
|---|---|
| `first_bar` / `last_bar` | the first and last bar an entry signal could fire on |
| `bars` | how many bars are inside that window, excluding lead-in |
| `agreed_from` / `agreed_to` | the window pinned in the plan before any bar was read. `null` if none could be pinned, which happens for a dataset our store holds no manifest for |
| `lead_in_bars` | bars read BEFORE the window so every indicator has a defined value on its first bar. Read, never traded. A 200-bar average needs 200 of them, and without this the window would quietly lose its first 200 bars |
| `interval` | the interval the bars were served at |


**`verdict`** is one of four, and three of them are not failure:

| verdict | meaning |
|---|---|
| `CHECKED` | the run was done and the checks held. **Not** an endorsement of the strategy |
| `REJECTED` | a check failed. Which one is in `checks` |
| `UNCHECKABLE` | we could not vouch for the inputs, so we will not report a number |
| `BLOCKED` | a check could not run. Distinct from a pass, deliberately |

**Read `checks`, not just the verdict.** All five checks are always listed, in
the same order, because a check that is silently absent reads as a check that
passed. Each has an `outcome` and a `says` sentence explaining it. `scope`
summarises the five in one line.

| outcome | meaning |
|---|---|
| `passed` | the check ran and held |
| `caution` | it ran and held with a reservation, stated in `says` |
| `failed` | it ran and did not hold |
| `not_enough_evidence` | it applies, and there is not enough to say anything. A benchmark comparison over ten trades is arithmetic that would compute and mean nothing |
| `not_applicable` | the check does not apply to this kind of strategy |
| `could_not_run` | an earlier check failed, so this one never got the chance |
| `not_available` | we have not built it yet. Our gap, not yours |

The last four all mean "this said nothing", and they are not the same as each
other. A program should treat none of them as a pass.

Executable is the one that matters most: it rebuilds every trade from the raw
prices independently of the code that produced them.

The full internal record of a run is kept by us for replay and disputes. It is
not returned over the API.

**`reasons`** are written for a human. Each has `text` (the claim) and `evidence`
(the numbers). Pass them through; do not paraphrase the numbers.

**`attempt`** is how many data-touching attempts this principal has made.
Twenty-five tries against the same data produce a best result that looks
significant from noise alone. A run that never reached the bars does not count,
which is why an `UNCHECKABLE` result can report `attempt: 0`.

**The count belongs to the ACCOUNT, not to the key.** One account may hold
several keys -- a laptop, a CI job, an agent -- and they all draw on the same
allowance. A key issued one minute ago will report the attempts its siblings
have already spent, and `GET /v1/me` names the owner in
`attempts_counted_against` beside `attempts_remaining` so the two can be read
together.

This is deliberate and it is the only thing making the limit mean anything. If
each new key started fresh, resetting the counter would cost one API call, and
a correction computed against a count that is always one is a correction that
adjusts for nothing. The limit is on the searching, and the searcher is the
account.

**A queued run is already spent.** `attempts_remaining` subtracts runs that
are QUEUED or RUNNING as well as ones that finished, so the number you read
before starting a run is the number you can still start. The charge itself is
written when a run completes; counting only that would have let you queue as
many runs as you liked against a balance of one, which is what it did until
2026-11-04. A `quota_exhausted` refusal says how many of your attempts are in
flight, because a balance of 0 with no verdict in hand otherwise reads as our
bookkeeping error.

**Attempts do not expire.** `GET /v1/me` reports `attempts_granted` beside
`attempts_remaining`, and the first number only ever goes up: a subscription
period that renews adds to it, so does a top-up bought at any point, and
nothing subtracts but a run. An unspent attempt is still there next month.

What does NOT roll back is the multiple-testing correction. `attempt` above
counts every distinct plan this account has ever had checked, with no window,
so buying more attempts raises the bar the next result has to clear rather
than lowering it. That is the honest arithmetic of searching more: the hurdle
grows as `sqrt(2*ln N)`, which is slow -- from N=20 to N=240 it moves an
annualised Sharpe requirement of roughly 1.25 to 1.58 on eight years of daily
bars -- but it only moves one way.

Two consequences worth stating plainly. Issuing another key does not buy more
attempts. And an operator who wants separate allowances needs separate
ACCOUNTS, not separate keys.

**`report`** is the NAME of a self-contained HTML file, with the equity chart
embedded. A local file on the machine that ran it, not a URL, and there is no
HTTP route to it on purpose -- serving a report means authenticating file
access and defending a path.

It is a name and not a path on purpose, since 2026-11-04. The field used to be
`report_path` and carried an absolute path inside the service's state
directory: a caller on another machine cannot open it, so it disclosed our
layout and bought them nothing. If you are running the service yourself the
file is under the reports directory of your own `PLATFORM_STATE_DIR`; if you
are not, quote `recorded.run_id` and ask.

### Other endpoints worth knowing

```http
GET /v1/health      no key needed. Reports whether the WORKER is alive, not
                    just the socket -- a queue with no consumer accepts jobs
                    forever and completes none
GET /v1/queue       how much work is waiting
GET /app            no key needed to load. A browser page for a person with a
                    key and no agent: the same calls as this document, made
                    for them, with the result drawn as charts
```

### Tell us how it went

A finished result and a refusal both carry a `feedback` field asking whether
what we gave you was what you needed. Answering is free, never counts as an
attempt, and changes nothing about the session -- it is read by a person, later.

```http
POST /v1/sessions/{id}/feedback
{"rating": "up"}
{"rating": "down", "comment": "what was wrong, or missing"}
```

- `rating` is `up` or `down`. A `down` must carry a `comment`: a thumbs-down
  with no words cannot be acted on, so it is refused with a 400.
- `comment` is free text, up to 4,000 characters.
- Optional `source` names your client (`"source": "my-agent"`). It is
  recorded, not trusted.
- Works in any state. We record the stage it was given at -- the reading, the
  agreed plan, the result, or a refusal -- from the session itself, not from
  anything you send.
- 201 with `{"recorded": true, "stage": ..., "given": n}`. A session takes at
  most 20, after which you get 429 `feedback_limit`.

If you are an agent, say what actually happened: what you expected, what came
back, and what you had to work around. "All good" teaches us nothing.

## Company research -- no session, one attempt

The second thing this service does, and a separate path from everything above.
Nothing is agreed, frozen or run, so there is no session. Ask for a ticker:

```http
GET /v1/research/AAPL         the view as labelled JSON
GET /v1/research/AAPL/page    the same view as one self-contained HTML page,
                              in the `html` field of a JSON body
```

**SEC filings only, and no prices.** `company` is the registration record,
the panels are the latest annual figures, quarterly results and Form 4 insider
activity, and every panel's `kind` is `filed, point-in-time`: dated by its
filing, which is when the public could first have seen it. `price_chart` links
to a public chart; there is no price in the response.

**Allow up to 90 seconds.** Each view is fetched live from the SEC and nothing
is cached; three measured 30 to 47 seconds. A shorter client timeout will cut
off a view that was about to succeed.

**A view recommends nothing.** No score, no rating, no target, no valuation
model. It is not verified data either: no rule may reference it, and it
deliberately does not read the store your verifications run on.

**`failures` lists any panel a source did not answer for.** The rest of the view
is still good. A panel listed there is missing, not empty, so do not read its
absence as "nothing to report".

| answer | meaning | what to do |
|---|---|---|
| `400 bad_symbol` | not the shape of a ticker. Nothing was fetched | use a ticker: `AAPL`, `BRK.B` |
| `404 no_such_symbol` | not in the SEC's list of company tickers. Some funds, QQQ among them, are missing from it too | check it |
| `422 not_a_stock` | a fund or a commodity trust: no margins or insiders to read | stocks only |
| `422 not_available` | the SEC answered and an honest view cannot be built | do not retry |
| `429 quota_exhausted` | no attempts left, and this company has not been paid for today. Nothing was fetched | stop; this is not transient |
| `429 rate_limited` | your hourly allowance (30 by default), a view already running on this key, or two running service-wide | wait for `Retry-After`. Nothing was charged |
| `502 source_unavailable` | a vendor failed. Your request was fine | retry later |

**A complete view costs one attempt**, from the same balance as a verification,
and `charged` in the response says whether this one did. The same company again
the same UTC day is free, so reading the JSON and then the page, or retrying
after a client timeout, costs one attempt. Every refusal above is free, and so is
a view with anything in `failures`.

**A view does not raise your statistical bar.** It reduces your balance and stays
out of the count behind your verification verdicts. That is not a licence to read
it as free evidence: looking at thirty companies and acting on the best-looking
one is still a search over thirty, and nothing here counts it for you.

## 5. Refusals

When `bucket` is not `served`, the session carries a `refusal`:

```json
{"bucket": "needs_a_measure_we_cannot_yet_trust",
 "refusal": {"reason": "We can express the rules, but not taking half the position off at 1R...",
             "suggestion": "We will not issue a verdict we cannot stand behind...",
             "terminal": false}}
```

| bucket | what it means for you |
|---|---|
| `served` | proceed |
| `definable_not_yet_defined` | the rule is computable but not yet implemented. Ask the trader to pick from what we do have |
| `needs_data_we_lack` | we hold no data for it. Not your problem to solve |
| `needs_a_measure_we_cannot_yet_trust` | we could run it and every number would be wrong. **Do not retry** |
| `different_instrument_class` | options, portfolios -- a different product |
| `not_computable` | no bar-derived form exists. `terminal: true`. Nothing will change this |

`terminal: false` means it is recorded and may be built. `terminal: true` means
it never will be, because the gap is in the idea rather than in the service.

---

## Status codes

| code | meaning | what to do |
|---|---|---|
| `200` / `201` | fine. Note that `confirm` has two outcomes at `200` | read `status` |
| `400` | your input was refused. `message` says what and why | fix and retry |
| `401` | no key, or a key that is unknown, revoked or expired | check the `Authorization` header. Unknown, revoked and expired answer identically on purpose, so this is not an oracle for which |
| `403` | authenticated, but out of scope -- e.g. a key that may not confirm | do not retry |
| `404` | no such session, no such report, or not yours; on research, no such symbol | check the handle |
| `409` | wrong state -- confirming from `AGREED`, running before agreeing | read the message; the state machine is deliberate |
| `429` | **two different refusals.** The message distinguishes them: a rate limit, or the attempt allowance spent | a rate limit carries `Retry-After` and nothing was charged. A spent allowance is not transient -- stop |
| `422` | research only: a fund, or a company we cannot build an honest view of | see "Company research" |
| `500` | **our fault, not yours.** The message says so | do not change the idea. Retry once, then report it to the address in `support` |
| `502` | research only: a data vendor failed | retry later |

A `400` always explains itself. The commonest cause is an invisible character
pasted out of a web page -- the message names it and says to retype the line.

**Where to report a `500`.** `GET /v1/me` carries a `support` field with the
address to write to. It is not on the website, because an address in public
HTML is harvested, so this response is the first place you can be told it --
which means you have to be holding a key to find out, and on a `500` you are.
Quote the status, the message and what you sent. A `500` is never a statement
about your idea: an unreadable idea comes back as a refusal with a reason, and
nothing about it is charged.

---

## Things that will waste your time if you do not know them

**Markup is refused.** `<script>`, `<img>`, `javascript:` and HTML tags in an
idea are rejected with `400`. A strategy description has no use for them, and we
decline to carry the payload rather than escape it. `rsi < 30` is fine.

**Invisible characters are refused.** Unicode tag characters, bidi overrides,
zero-width joiners, control characters. They are rejected rather than stripped,
because silently altering the text would mean the Config does not say what the
trader wrote.

**`POST /run` does not return a result.** It returns `202` and a job. This is
the single thing most likely to trip an agent written against an older version
of this document, which promised a verdict from the POST.

**You cannot run a plan before it is agreed.** `409`. A run before the plan is
frozen would be a result the plan could still be fitted to.

**You cannot change an agreed plan.** `409`. Start a new session.

**Session handles are secrets.** 32 hex characters, and presenting one is
presenting authority over that session. Do not log them or put them anywhere a
third party reads.

**Do not suggest parameters.** If you report a result to a trader, you may
suggest *methods this service offers* -- another span, a walk-forward run, a
benchmark comparison. **Do not suggest a different window, indicator or
threshold**, even when you can see what would work. The moment the value that
rescues a strategy comes from the verifier's side, the result is a verdict on our
own work; and a suggestion is a search step with the attempt counter switched
off. If a result shows a strategy is broken, say so and stop. Improving it is the
trader's job.

---

## A complete session, in `curl`

```bash
B=https://api.dualquant.ai/v1          # or wherever this document came from
H='Content-Type: application/json'
A="Authorization: Bearer $DQ_KEY"

S=$(curl -s -X POST $B/sessions -H "$H" -H "$A" \
     -d '{"idea":"buy SPY when the 20 and 50 day MAs cross, sell on the cross back, days"}' \
     | python -c "import json,sys; print(json.load(sys.stdin)['session_id'])")

curl -s -X POST $B/sessions/$S/confirm -H "$H" -H "$A" -d '{}'   # -> status: final_plan
curl -s -X POST $B/sessions/$S/confirm -H "$H" -H "$A" -d '{}'   # -> status: agreed
curl -s -X POST $B/sessions/$S/run     -H "$H" -H "$A" -d '{}'   # -> 202, a job
# then poll until job_state is DONE
until curl -s -H "$A" $B/sessions/$S/report | grep -q '"job_state": "DONE"'; do sleep 1; done
curl -s -H "$A" $B/sessions/$S/report                        # -> the verdict
```

Measured output from exactly that sequence:

```
VERDICT  REJECTED
         It made money, but not enough to justify the risk it took.

Data          passed        the bars are the exact data named, internally
                            consistent, inside the venue's trading session
                            and covering the agreed period
Reproduction  not available reproducing a stored run is not available yet
Executable    passed        all 460 trades were rebuilt independently from
                            the raw prices and confirmed
Consistent    passed        results were similar across different periods of
                            the history, within what chance alone produces
Value         failed        -1.30 points a year against a passive mix of the
                            asset and cash with the same worst loss
```

The report also carries what the numbers show about the strategy, what we can
check next on the plan as it stands, and a definition of every term it uses.

---

## Two limits worth knowing

**Reproduction is not built.** Rung 02 recomputes a stored external run from
the same raw bars, and it does not exist yet. Every verdict names it and
reports it as not run, because a check that is silently omitted reads exactly
like a check that passed. The omission is ours and never a fault of your
strategy.

**A verdict does not say which reader interpreted your words.** A language
model reads the description once, at intake, and the Config records which one
-- but that note is not carried in any response yet. If it matters to you, send
a structured condition tree instead of prose and nothing is interpreted at all.

---

## This is the whole interface

One transport, and it is this one. `GET /v1` returns a machine-readable
manifest of the verbs and `GET /v1/openapi.json` the schema; both are keyless,
so an agent bootstraps from the base URL alone and needs nothing else from us.

`GET /v1/me` carries the support address, your attempts remaining, and what
your key may do.
