Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ CRON_SECRET=replace-with-a-long-random-string
# can leave one protocol scored and another neither scored nor recorded as failed.
# Must leave room for cold start, pool connect, upserts, streak queries and the alert
# POST. Raise it only against observed cycle durations, never by arithmetic alone.
# 50s, raised from 42s in #104 against three curl'ed deployed cycles (totalMs 15.5-16.8s,
# HTTP wall 17.3-17.7s, so ~1.0-1.7s of overhead beyond the cycle). That makes the
# 50s, raised from 42s to fit the fifth target, against three curl'ed deployed
# cycles (totalMs 15.5-16.8s, HTTP wall 17.3-17.7s, so ~1.0-1.7s of overhead beyond
# the cycle). That makes the
# five-target registry feasible — 5 x 10s attempts = 50s <= 50s — while the worst-case
# function wall stays ~52s against the 60s ceiling. A SIXTH target would need 60s, which
# IS the ceiling, so the budget cannot buy one: that needs a lower attempt timeout or
Expand Down
23 changes: 12 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ These override any default behavior and are enforced in code and review:
static Findings (hand-written, reviewed in a PR); and **live ungraded state** — measured every
cycle, published as a typed field, never graded. The third exists because pause/frozen state is
real, changing, on-chain data that changes how a score should be read while nothing on chain
lets us grade it (`OperationalState`, `core/src/operational-state.ts`, decided in #15). Its rule:
lets us grade it (`OperationalState`, `core/src/operational-state.ts`; the reasoning is in
`methodology/publishing-rules.md`). Its rule:
a live ungraded field must be **published beside the score wherever the score appears**, or the
decision not to grade it becomes a decision to hide it. Nothing in that category may be reachable
from `scoreFactors` — the adapters' `computeRiskFactors` must produce a byte-identical factor map
Expand Down Expand Up @@ -160,8 +161,8 @@ These override any default behavior and are enforced in code and review:
moment anyone reads it. `weights.test.ts` asserts the absence at runtime and `scoring.test.ts`
asserts such a category publishes **no** weight table in `methodology/`, so the two cannot drift
into agreeing on a number nobody reviewed. It is a state to leave, not to live in — `dex` passed
through it (#100 → #102) and **no category is in it today**; it is kept for the next one admitted
the same way.
through it — admitted as a factor set, weighted in a later review — and **no category is in it
today**; it is kept for the next one admitted the same way.
- **An adapter is a FOLDER of four files, and only `index.ts` is API.** `adapters/<protocol>/`
holds `types.ts` (mainnet wiring, constants, raw on-chain shape, options), `fetch.ts` (everything
touching RPC/Horizon, plus decoders, behind one `fetch*` entry point), `score.ts` (the five
Expand All @@ -186,22 +187,22 @@ These override any default behavior and are enforced in code and review:
runs adapters through the `toTarget<T>()` wrapper (see [`indexer/src/index.ts`](indexer/src/index.ts))
so a heterogeneous adapter list shares one typed run loop. `core/src/adapter.ts` carries
`ADAPTER_INTERFACE_VERSION` — bump it for breaking interface changes rather than rewriting
every adapter at once. It is at **4**: v2 added the required `operationalState(raw)` method
(#15), v3 the required `metadata.category` field and the `TCategory` parameter that scopes
`operationalState`'s vocabulary to it (#76), v4 made the factor map a function of that same
parameter (#104). All required rather than optional, deliberately — an optional member is one
every adapter at once. It is at **4**: v2 added the required `operationalState(raw)` method,
v3 the required `metadata.category` field and the `TCategory` parameter that scopes
`operationalState`'s vocabulary to it, and v4 made the factor map a function of that same
parameter. All required rather than optional, deliberately — an optional member is one
every future adapter can skip, which is the retrofit debt the constant exists to make visible.
- **An adapter declares its category; it does NOT declare its factor map.**
`computeRiskFactors`/`score` speak `FactorMapFor<TCategory>`, derived from `CATEGORY_FACTORS`, so
an adapter that declares `'dex'` owes exactly the factors `methodology/dex.md` publishes and
cannot return lending's five or a key no rulebook has. This replaces the defaulted `TFactors`
parameter #103 added under deadline and flagged as unreviewed: the review (#104) found the
parameter added under deadline and flagged as unreviewed: the review found the
parameter was never tied to `TCategory`, so `Adapter<Raw, 'dex', RiskFactorMap>` compiled. It is
now a settled decision with the alternatives and the two corrected #103 claims recorded in
now a settled decision, with the alternatives and the two claims it corrects recorded in
[`architecture/monorepo-layout.md`](architecture/monorepo-layout.md), and guarded by
`@ts-expect-error` probes in `core/src/weights.test.ts`.
- **Precision at the adapter boundary, one open `FactorMap` from the indexer onward.** That split is
the answer #103 left open. `IndexTarget.run`, `RunRecord`, `HistoryEntry` and `ProtocolDetail` all
the answer that review left open. `IndexTarget.run`, `RunRecord`, `HistoryEntry` and `ProtocolDetail` all
carry `FactorMap` — storage and transport enforce no rulebook and must not claim a key set they
are not the source of. `getProtocolDetail`'s read-side cast asserts only the non-null the
`risk_scores_shape` CHECK guarantees; it used to assert lending's five keys, which was a lie about
Expand Down Expand Up @@ -271,7 +272,7 @@ computed per response from the body rather than being a constant.
> bounded worker pool (`STENION_CYCLE_CONCURRENCY`, default 2) and each gets the end of the budget
> less one full attempt reserved per queued wave (`targetDeadline`). A rule where a target's
> deadline shrinks as the registry grows can fail protocols that already work — that is what
> `budgetMs / targetCount` did, and #68 removed it. The replacement's ceiling is the explicit
> `budgetMs / targetCount` did, and it was removed. The replacement's ceiling is the explicit
> condition `ceil(targets / concurrency) * attemptTimeoutMs <= budgetMs`, checked by
> `cycleFeasibility()` and warned about every cycle, never discovered by adding a pool.
>
Expand Down
34 changes: 16 additions & 18 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ commitment — priorities shift as protocols launch and as the project finds fun
unregistered for want of a target slot, and say so on the registry. See "Beyond lending" below.
Same label, same adapter, third pool; a config entry and no new scoring code.

**Four scored markets, not five.** The Blend V2 pool investigation (#65) found five unregistered
**Four scored markets, not five.** The Blend V2 pool investigation found five unregistered
pools clearing the market-size floor and registered exactly one of them. The other four are
published as **assessed-and-not-scored** coverage entries because their oracles fail the
oracle-legibility precondition — see "Four Blend V2 markets" under _Protocols investigated and
skipped_. Those four are unranked and carry no numeral; only the four above are in the ranking.

- **Operational state, published beside every score and deliberately not scored.** Both adapters
had always read a pause/frozen signal (Blend's `PoolConfig.status`, K2's `router.is_paused()`)
and neither had ever used it. Resolved in #15 as a decision **not** to grade it: nothing on chain
and neither had ever used it. Resolved as a decision **not** to grade it: nothing on chain
separates an admin freezing a pool to contain a threat from an admin abandoning it, and the two
protocols' restricted states are not even the same shape — Blend never blocks a withdrawal at any
of its seven statuses, while K2's pause blocks withdrawals, repayments and liquidations alike. So
Expand Down Expand Up @@ -134,8 +134,8 @@ commitment — priorities shift as protocols launch and as the project finds fun
that work was the footnote at the bottom of this file, which no visitor reads: absence told a
reader nothing, so someone searching for a protocol learned only that it wasn't there. Four
entries at launch — Templar, K2's two sub-floor markets, and Nectar — each backed by an
investigation actually recorded in this repo. **Eight today**: #69 added the four Blend V2 markets
whose oracles cannot be graded (Orbit, Forex, Spectra PTs, Solv), under a status of their own
investigation actually recorded in this repo. **Eight today**: the oracle-legibility precondition
added the four Blend V2 markets whose oracles cannot be graded (Orbit, Forex, Spectra PTs, Solv), under a status of their own
rather than folded into the size floor, because their exclusion has nothing to do with their size.

Three properties are load-bearing. **Nothing in the section renders a numeral**, so "not scored"
Expand Down Expand Up @@ -250,7 +250,7 @@ adds a market**, so it is first in fact and not only in presentation.

| Dial | Where it is | Why it cannot move |
| ---------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `STENION_CYCLE_BUDGET_MS` | 50,000 | Raised from 42,000 in #104 against three `curl`ed deployed cycles. 60,000 is the `maxDuration` ceiling itself, so the next step up is the cliff. |
| `STENION_CYCLE_BUDGET_MS` | 50,000 | Raised from 42,000 against three `curl`ed deployed cycles. 60,000 is the `maxDuration` ceiling itself, so the next step up is the cliff. |
| `STENION_CYCLE_CONCURRENCY` | 1 | Shipped at 2 and reverted the same day when `mainnet.sorobanrpc.com` — free, shared, keyless — started refusing the target behind the burst. Raising it again needs a **deployed** RPC-tolerance measurement, never an estimate. |
| `STENION_ATTEMPT_TIMEOUT_MS` | 10,000 | Already lowered 15s → 10s to buy the fourth target. The registry's most expensive attempt (an Aquarius pool: 37 requests against Blend's 16) sits at an estimated 5.5–8.7s deployed, so a lower cap would time out a healthy target on a slow day. |

Expand Down Expand Up @@ -371,7 +371,7 @@ adds a market**, so it is first in fact and not only in presentation.
already there, but the API exposes only `safetyScore` per history point. Charting a single
factor over time — watching `oracleSafety` sawtooth on its own axis — is deliberately deferred
until the window question above is settled, because it multiplies the same payload by five.
- **Concurrent targets within a cycle — DONE (#68).** The indexer no longer runs targets
- **Concurrent targets within a cycle — DONE.** The indexer no longer runs targets
sequentially and no longer divides one budget between them. `STENION_CYCLE_CONCURRENCY` targets
(default 2) run through a bounded worker pool, and each target's deadline is the end of
`STENION_CYCLE_BUDGET_MS` minus one full attempt reserved for each wave still queued behind it.
Expand Down Expand Up @@ -401,8 +401,8 @@ adds a market**, so it is first in fact and not only in presentation.
than discovered by someone adding a pool. Past it, behaviour degrades to whole attempts first-come
with the tail failing cleanly and visibly on `/api/v1/health`, not a squeeze for everyone.

**That ceiling has since been reached.** #104 registered the fifth target and raised the budget
42s → 50s to fit it, which was the last raise available under the 60s `maxDuration`. The registry
**That ceiling has since been reached.** Registering the fifth target the first dex market —
raised the budget 42s → 50s to fit it, which was the last raise available under the 60s `maxDuration`. The registry
is now full — see **the blocker at the top of this section**, which is where the exit from it is
tracked.

Expand Down Expand Up @@ -504,8 +504,8 @@ adds a market**, so it is first in fact and not only in presentation.
withdrawals don't mean anything for an AMM. Scoring other categories means designing a taxonomy
that fits how each one actually fails, not stretching the lending model over them:
- **DEXs / AMMs** (Soroswap, Phoenix, Aquarius) — **SHIPPED for Aquarius: rulebook complete
(#100 admitted the factor set, #102 reviewed the weight table) and one market registered and
scored (#104).** See [`methodology/dex.md`](methodology/dex.md). The `dex` category is
(one review admitted the factor set, a second reviewed the weight table) and one market
registered and scored.** See [`methodology/dex.md`](methodology/dex.md). The `dex` category is
versioned at 1 on its own counter and published in full — **two** factors: `adminKeySafety` at
**0.55** (seven named roles plus the two-step upgrade deadline, sharing lending's factor key
deliberately) and `assetControlSafety` at **0.45** (whether a SAC issuer can freeze or claw
Expand All @@ -517,8 +517,7 @@ adds a market**, so it is first in fact and not only in presentation.
pool (`CA6PUJLB…`), scoring **24** — `adminKeySafety` 10, `assetControlSafety` 40 — and
carrying `category: 'dex'` and `methodology_version: 1` on every run. It renders as its own
ranked block on the registry, numbered 01 of dex, because a position numeral is scoped to one
rulebook (#78) and this is the first time that rule has had two categories to enforce it
against.
rulebook, and this is the first time that rule has had two categories to enforce it against.

**What is NOT covered: the other 339 Aquarius pools, and every other DEX.** The census, re-read
at ledger 64,182,824 on 2026-08-29, is 340 pools across 304 token sets — 272 constant-product,
Expand All @@ -538,15 +537,15 @@ adds a market**, so it is first in fact and not only in presentation.
against are in
[`architecture/deploy-architecture.md`](architecture/deploy-architecture.md).

**The `Adapter` interface change #103 left unreviewed has been reviewed, and revised.** #103
**The `Adapter` interface change that shipped unreviewed has been reviewed, and revised.** It
added a third type parameter, `TFactors`, to make the first non-lending adapter compile at all,
and flagged the decision as unreviewed. #104 found the parameter was never tied to `TCategory`
and flagged the decision as unreviewed. The review found the parameter was never tied to `TCategory`
— `Adapter<Raw, 'dex', RiskFactorMap>` compiled, so a dex adapter could publish lending's five
factors, and so could one with invented keys. The factor map is now **derived** from the
category, `FactorMapFor<TCategory>`, read straight out of `CATEGORY_FACTORS`, so an adapter
cannot disagree with its own rulebook. `ADAPTER_INTERFACE_VERSION` is **4**. The four frozen
lending snapshots and both lending adapters are byte-identical either side of it. Decision
record, with the two #103 claims it corrects, in
record, with the two claims it corrects, in
[`architecture/monorepo-layout.md`](architecture/monorepo-layout.md).

**`@stenion/db` now carries any category's factor map.** `RunRecord.factors`,
Expand Down Expand Up @@ -663,9 +662,8 @@ part of the discipline, not a failure. Four notable cases:
change here:** an oracle that starts publishing the two parameters makes its pool scorable under
the existing rulebook, and the work is a `BLEND_POOLS` entry plus a deleted coverage entry in one
PR. Etherfuse (`CDMAVJPF…`) was the fifth of the five and is _not_ on this list — it runs an
aggregator, so it is scored, and [#65](https://github.com/stenion-lab/stenion/issues/65)
**registered it**: it is a ranked entry above, and the pool investigation is closed at one market
registered and four published here.
aggregator, so it is scored, and it **is registered**: it is a ranked entry above, and the pool
investigation is closed at one market registered and four published here.

**The curation question that investigation raised is still open, and this pass did not close it.**
Three of the five are tiny — read on 2026-08-26, Solv held $175.69, Spectra PTs $9.88, and Forex
Expand Down
9 changes: 5 additions & 4 deletions TAXONOMY.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ the top.** A minimum over an empty set is `0`, not `100`.
[market-size floor](methodology/lending.md#the-market-size-floor), as `below-size-floor`. Neither renders a
numeral.
- **(c)** `operationalState` ([`core/src/operational-state.ts`](core/src/operational-state.ts),
decided in #15): which user operations a market's contracts are currently refusing, published
the decision recorded in `methodology/publishing-rules.md`): which user operations a market's
contracts are currently refusing, published
beside the name and score everywhere either appears. Both adapter suites assert a **byte-identical
factor map** across every restricted state their protocol can be in, so route (c) cannot leak into
a score.
Expand Down Expand Up @@ -257,7 +258,7 @@ is comparable only with one produced by the same rulebook — each category is s
factors under its own weights, so the same numeral in two categories is two different measurements
wearing the same digits.

**Lending as the worked example — the enforcement is structural, not a rendering habit** (#78):
**Lending as the worked example — the enforcement is structural, not a rendering habit:**

- [`dashboard/app/lib/registry-query.ts`](dashboard/app/lib/registry-query.ts)'s `buildRegistryView`
publishes **`RankedCategoryGroup[]` and no flat ranked array**. There is nowhere for a
Expand Down Expand Up @@ -294,7 +295,7 @@ second table, or a dashboard constant.

**Lending as the worked example.** The chain runs adapter → `CATEGORY_FACTORS` → the published
rulebook, with no hand-written copy in it. Adapters read `LENDING_FACTORS.<factor>.weight` and never
a literal (#77). `scoring.test.ts` then parses `methodology/<category>.md` rather than restating
a literal. `scoring.test.ts` then parses `methodology/<category>.md` rather than restating
it: it locates
a category's section by `## ${CATEGORY_FACTORS[category].label}` — the heading text comes from the
code, so the document and the code cannot disagree about what the section is called — and pulls the
Expand Down Expand Up @@ -333,7 +334,7 @@ stored rows under new rules.

**Lending as the worked example.** `METHODOLOGY_VERSIONS` in
[`core/src/category.ts`](core/src/category.ts) is a `Record<ProtocolCategory, number>` — a category
added to `PROTOCOL_CATEGORIES` is a compile error until it has an entry (#76), the same shape
added to `PROTOCOL_CATEGORIES` is a compile error until it has an entry, the same shape
`CATEGORY_FACTORS` and `CATEGORY_OPERATIONS` use. Counters are independent and each starts at 1, so
**the integer alone does not identify a rulebook**: the indexer stamps `risk_scores.category` beside
`risk_scores.methodology_version`, resolved from the target's own category, and an adapter has no say
Expand Down
Loading
Loading