From 52bc587ba33a92c42bc97fe76dd373a3454d55db Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Wed, 16 Sep 2026 18:48:36 +0530 Subject: [PATCH 1/3] Say a venue is being read, instead of reporting it as holding nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell opens in 0.11s and then reads the venues behind it — 5 to 9 seconds for a wallet across nine chains. For that whole window the status line said `0 positions` and the `/` menu said `0 tokens`. Not blank: zero, about an account nobody had answered for yet. `isLoaded` was the wrong question. It says a load finished, not which venues it was built from, so a venue connected after the shell opened fell straight through it — the store held it at once, the book did not. `covers()` and `coversAll()` ask the answerable question, and the venue list they take is the build's, not the store's: a venue tula dropped is skipped before `refresh` records it, so asking about the store could never come true and `/shock` went dark for the rest of the session with a `/refresh` that could not fix it. `reading…` and `not read` are different claims, so the session carries the third state rather than the UI guessing: a refresh that threw was a spinner over nothing, with no command offered. The busy row counts its parts — `reading wallet · 4 of 9 chains` — because one unchanging label is what a hang looks like. A chain that failed still counts: the reader is no longer waiting for it. The count is caught at the session boundary, since a promise derived from `.finally()` rejects when the callback throws, and a UI listener must not be able to delete a chain's positions and blame the node that answered. Also: the model is never handed the epoch as a book's `fetched_at`; Aave's stable-rate gap is retired with the live check `breadth/09` requires for a retirement; and `/kraken`'s meta description catches up with its own page. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++ ROADMAP.md | 2 +- scripts/conformance.live.ts | 23 +++++++++ scripts/guard-test.sh | 2 +- site/app/kraken/page.tsx | 2 +- src/agent/engine.ts | 3 +- src/agent/tools.ts | 2 +- src/cli/engine-adapter.ts | 5 +- src/cli/session.ts | 55 ++++++++++++++++++++- src/cli/shell.test.ts | 87 ++++++++++++++++++++++++++++++++++ src/connectors/aave.ts | 13 +++-- src/connectors/types.ts | 13 ++++- src/connectors/wallet.ts | 14 ++++-- src/site-claims.test.ts | 20 +++++++- src/ui/app.tsx | 57 +++++++++++++++++----- tasks/breadth/09-aave-depth.md | 21 ++++---- 16 files changed, 290 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b27725..1782b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message ## [Unreleased] +### Fixed + +- **A venue being read no longer reports itself as holding nothing.** For the seconds a venue takes to answer — a wallet spread over nine chains is the slow one — the status line said `0 positions` and the `/` menu said `0 tokens`. Both now say the venue is being read, and say `not read` with the command to run if the read failed rather than leaving a spinner over nothing. The same gap covered a venue connected after the shell opened: the store held it at once, the book did not, and every surface read the difference as an empty account. +- **The line that says what is being read counts its parts.** `reading wallet` sat unchanged for the whole read, which is what a hang looks like; it now reads `reading wallet · 4 of 9 chains`, counting a chain that failed as one no longer being waited for. +- **`/shock`'s asset list is offered again to anyone holding a key for a venue this build dropped.** It answered "nothing is read yet" for the rest of the session, and the `/refresh` it suggested could never make it true. +- **The model is never handed a date for a book it has not read.** Asked before the first read, `fetched_at` stated the epoch as a fact. + ## [0.3.0] - 2026-09-16 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 2863781..da10198 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -176,7 +176,7 @@ of them mentioned in no file at all. | What | Where | |---|---| | Aave V4 — the Hubs on Ethereum | [`breadth/08`](./tasks/breadth/08-aave-v4.md) | -| The Safety Module, isolation mode, stable-rate debt | [`breadth/09`](./tasks/breadth/09-aave-depth.md) | +| Umbrella and the legacy Safety Module, isolation mode | [`breadth/09`](./tasks/breadth/09-aave-depth.md) | | Staking and yield-bearing tokens, NFTs | [`breadth/11`](./tasks/breadth/11-wallet-depth.md) | | The EVM chains outside the nine, HyperEVM, Solana | [`breadth/12`](./tasks/breadth/12-chain-reach.md) | | Binance's cross-margin liquidation level, COIN-M and Portfolio Margin, earn products, held balances, sub-accounts | [`breadth/13`](./tasks/breadth/13-binance-depth.md) | diff --git a/scripts/conformance.live.ts b/scripts/conformance.live.ts index 1ed363b..9eae214 100644 --- a/scripts/conformance.live.ts +++ b/scripts/conformance.live.ts @@ -214,6 +214,29 @@ async function aave(): Promise { ], }) + // The stable-rate gap was retired in 0.3.1, and `breadth/09` says a retirement + // needs the check that proves it rather than a reading of a changelog. Aave + // v3.2 removed every Pool function for stable-rate mode and stopped + // instantiating a stable debt token on a new listing, so the proof is that the + // interface itself no longer carries one. `IPool.sol` is the file that would + // have to grow it back. + const poolApi = await request(`${ADDRESS_BOOK_RAW}/../lib/aave-v3-origin/src/contracts/interfaces/IPool.sol`, followed, DEADLINE_MS) + const poolSource = poolApi.ok ? await poolApi.text() : '' + const stableFns = ['swapBorrowRateMode', 'rebalanceStableBorrowRate'].filter((fn) => poolSource.includes(fn)) + findings.push({ + verdict: poolSource === '' ? 'unreachable' : stableFns.length === 0 ? 'holds' : 'contradicted', + belief: 'Aave states no stable-rate borrowing, so there is no gap left to declare about it', + lines: [ + poolSource === '' + ? 'Could not read IPool.sol; nothing here checked the retirement.' + : stableFns.length === 0 + ? 'IPool carries neither swapBorrowRateMode nor rebalanceStableBorrowRate: the mode is gone ' + + 'from the interface, and a gap naming it would describe a product the venue does not offer.' + : `IPool still carries ${stableFns.join(' and ')}. Stable-rate borrowing is reachable again — ` + + 'restore the declared gap in aave.ts before the next release.', + ], + }) + // One check per chain tula actually reads, because "the pool moved" and "there // is a market here we never call" are different failures and only the second // scales with the chain list. `DEPLOYMENTS` is the connector's own list, so diff --git a/scripts/guard-test.sh b/scripts/guard-test.sh index f59b515..8c8e34b 100755 --- a/scripts/guard-test.sh +++ b/scripts/guard-test.sh @@ -266,7 +266,7 @@ command -v bun >/dev/null 2>&1 || { fail=1 } if command -v bun >/dev/null 2>&1; then - awk '{print} /^ fetched_at: at\(f.loadedAt\),$/ {print " probe_unmarked: f.failures[0] ?? null,"}' \ + awk '{print} /^ failed_venues: f.failures.map\(untrusted\),$/ {print " probe_unmarked: f.failures[0] ?? null,"}' \ "$TOOLS_SAVED" >"$TOOLS" if ! grep -qF 'probe_unmarked' "$TOOLS"; then # The anchor moved, so the probe planted nothing and the check below would diff --git a/site/app/kraken/page.tsx b/site/app/kraken/page.tsx index 124ff2b..39a6386 100644 --- a/site/app/kraken/page.tsx +++ b/site/app/kraken/page.tsx @@ -5,7 +5,7 @@ import { Terminal } from '@/components/Terminal' const PATH = '/kraken' const TITLE = 'Kraken read-only API key for portfolio tracking' const SUMMARY = - 'Which Kraken API key permissions to turn on so a tool can read balances but never withdraw, and what tula refuses.' + 'Which Kraken API key permissions to turn on so a tool can read balances but never trade or withdraw, and what tula refuses.' export const metadata = guideMetadata(PATH, TITLE, SUMMARY) diff --git a/src/agent/engine.ts b/src/agent/engine.ts index 79a4097..0c32ea4 100644 --- a/src/agent/engine.ts +++ b/src/agent/engine.ts @@ -44,5 +44,6 @@ export interface RiskEngine { */ coverage(): Disclosure venues(): VenueStatus[] - freshness(): { oldest: Date | null; loadedAt: Date; failures: string[]; priceError: string | null } + /** `loadedAt` is null before any load: the model quotes figures verbatim, so an unread book must not carry a date. */ + freshness(): { oldest: Date | null; loadedAt: Date | null; failures: string[]; priceError: string | null } } diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 263a481..63e110e 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -411,7 +411,7 @@ export function executeTool(engine: RiskEngine, name: string, input: unknown): u return seal({ venues, oldest_data: f.oldest === null ? null : at(f.oldest), - fetched_at: at(f.loadedAt), + fetched_at: f.loadedAt === null ? null : at(f.loadedAt), failed_venues: f.failures.map(untrusted), price_error: marked(f.priceError), // The whole of what no other result carries. Three different diff --git a/src/cli/engine-adapter.ts b/src/cli/engine-adapter.ts index 04918b2..962d531 100644 --- a/src/cli/engine-adapter.ts +++ b/src/cli/engine-adapter.ts @@ -115,7 +115,10 @@ export function riskEngineFor(session: Session): RiskEngine { freshness: () => ({ oldest: session.stalest(), - loadedAt: session.current.loadedAt, + // `EMPTY.loadedAt` is the epoch, and the model quotes a figure verbatim — + // read cold it would state the epoch's age as a fact. Unknown is a value + // the tool result already carries. + loadedAt: session.isLoaded ? session.current.loadedAt : null, failures: session.current.failures, priceError: session.current.priceError, }), diff --git a/src/cli/session.ts b/src/cli/session.ts index e36cd51..0e0ebbb 100644 --- a/src/cli/session.ts +++ b/src/cli/session.ts @@ -64,7 +64,7 @@ export interface LoadResult { export type LoadStep = /** `account` only where the venue holds more than one, so the wait names the * address being read rather than repeating a label nothing else could be. */ - | { kind: 'venue'; venue: string; account?: string } + | { kind: 'venue'; venue: string; account?: string; done?: number; total?: number } | { kind: 'prices'; assets: number } const EMPTY: LoadResult = { @@ -217,6 +217,7 @@ function attributed(p: Position, account: { id: string; label: string } | undefi export class Session { private cached: LoadResult = EMPTY private hasLoaded = false + private inFlight = false /** * Told what the load is on: venues are read in turn, each behind a 15s @@ -260,12 +261,43 @@ export class Session { return this.hasLoaded } + /** + * Whether a read is running now. `covers()` being false has two causes that + * look identical on screen and are not: a read in flight, and a read that + * threw. Drawn as the first, the second is a spinner over nothing, with no + * command offered — the disclosure rule inverted. + */ + get isLoading(): boolean { + return this.inFlight + } + + /** + * Whether the cache answers for this venue. `isLoaded` is a fact about the + * session — that some load finished — not about the venue set the cache was + * built from, and the two came apart wherever a venue was connected after the + * shell opened: the store gained it at once, the cache did not, and every + * surface read the gap as a venue holding nothing. + */ + covers(venueId: string): boolean { + return this.hasLoaded && this.cached.connected.includes(venueId) + } + + /** + * The same question for a whole set: any venue it does not answer for makes a + * count unknowable. Vacuous over an empty set — the caller holds the list, and + * a caller with nothing stored already has its own answer to give. + */ + coversAll(venueIds: readonly string[]): boolean { + return this.hasLoaded && venueIds.every((id) => this.cached.connected.includes(id)) + } + async ensureLoaded(): Promise { if (this.hasLoaded) return this.cached return this.refresh() } async refresh(): Promise { + this.inFlight = true try { const positions: Position[] = [] const failures: string[] = [] @@ -329,9 +361,27 @@ export class Session { `${venueId}: ${account ? `${label} — ` : ''}${unprefixed(text, connector.venue.name, venueId)}`, ) } + // Caught here rather than at each call site: this runs inside + // `.finally()` on the chain's own promise, and a promise derived from + // `finally` rejects if the callback throws — discarding the fulfilled + // value. A listener that threw would delete that chain's positions and + // report the node that answered as the one that failed. + const step = (done: number, total: number) => { + try { + this.onProgress?.({ + kind: 'venue', + venue: venueId, + ...(account ? { account: label } : {}), + done, + total, + }) + } catch { + // A label is not worth a row. + } + } let read: readonly Position[] = [] try { - read = await connector.fetchPositions(held.credentials, scope) + read = await connector.fetchPositions(held.credentials, scope, step) } catch (err) { // A venue spread over several chains has several independent ways to // fail, and catching per connector made the whole book hostage to @@ -426,6 +476,7 @@ export class Session { this.hasLoaded = true return this.cached } finally { + this.inFlight = false // Cleared however the load ends, or a label outlives the work it named. this.onProgress?.(null) } diff --git a/src/cli/shell.test.ts b/src/cli/shell.test.ts index ee9e168..02dc835 100644 --- a/src/cli/shell.test.ts +++ b/src/cli/shell.test.ts @@ -197,6 +197,93 @@ describe('load progress', () => { null, ]) }) + + /** + * A venue spread over several chains held one unchanging label for the whole + * read, and an unchanging label is what a hang looks like. The count is + * reported per part settled — including a part that failed, because a reader + * is no longer waiting for that one either. + */ + test('a venue that reads in parts counts them, failures included', async () => { + const parts: Connector = { + ...testConnector, + venue: { id: 'parts', kind: 'cex', name: 'Parts' }, + async fetchPositions(_creds, _refresh, onPart) { + onPart?.(1, 3) + onPart?.(2, 3) + onPart?.(3, 3) + return [] + }, + } + const session = await sessionOf(new Map([['parts', parts]])) + const steps: (LoadStep | null)[] = [] + session.onProgress = (step) => steps.push(step) + await session.refresh() + expect(steps.filter((s) => s?.kind === 'venue')).toEqual([ + { kind: 'venue', venue: 'parts' }, + { kind: 'venue', venue: 'parts', done: 1, total: 3 }, + { kind: 'venue', venue: 'parts', done: 2, total: 3 }, + { kind: 'venue', venue: 'parts', done: 3, total: 3 }, + ]) + }) +}) + +describe('what the cache answers for', () => { + /** + * `isLoaded` says a load finished; it says nothing about which venues that + * load was built from. The two came apart wherever a venue was connected + * after the shell opened: the store gained it at once, the cache did not, and + * every surface read the gap as a venue holding nothing. + */ + test('a venue connected after a load is not covered by it', async () => { + const session = await freshSession() + await session.refresh() + expect(session.isLoaded).toBe(true) + expect(session.covers('testvenue')).toBe(true) + + await secrets.put('emptyvenue', { apiKey: 'k' }) + // The load is still finished, and still answers for nothing about this one. + expect(session.isLoaded).toBe(true) + expect(session.covers('emptyvenue')).toBe(false) + expect(session.coversAll(['testvenue', 'emptyvenue'])).toBe(false) + + await session.refresh() + expect(session.covers('emptyvenue')).toBe(true) + expect(session.coversAll(['testvenue', 'emptyvenue'])).toBe(true) + }) + + test('nothing is covered before the first load', async () => { + const session = await freshSession() + expect(session.isLoaded).toBe(false) + expect(session.covers('testvenue')).toBe(false) + expect(session.coversAll(['testvenue'])).toBe(false) + expect(session.coversAll([])).toBe(false) + }) + + test('an empty set is covered once a load has happened', async () => { + const session = await freshSession() + await session.refresh() + expect(session.coversAll([])).toBe(true) + }) + + /** + * A venue this build dropped is skipped before `refresh` records it, so it can + * never appear in the cache — and a caller that asked about the whole store got + * an answer that could not become true. `/shock`'s asset list went dark for the + * rest of the session for anyone still holding a retired venue's key, and the + * remedy it offered was the `/refresh` that could not fix it. + */ + test('a retired venue in the store never becomes covered, so callers must ask about the build', async () => { + const session = await freshSession() + await secrets.put('circle', { apiKey: 'k' }) + await session.refresh() + const stored = await secrets.listVenues() + expect(stored).toContain('circle') + expect(session.covers('circle')).toBe(false) + expect(session.coversAll(stored)).toBe(false) + // The build's own list is the answerable question, and it is answered. + expect(session.coversAll([...CONNECTORS.keys()].filter((id) => stored.includes(id)))).toBe(true) + }) }) describe('dispatchCommand', () => { diff --git a/src/connectors/aave.ts b/src/connectors/aave.ts index 4c7b3c0..3d0fcb9 100644 --- a/src/connectors/aave.ts +++ b/src/connectors/aave.ts @@ -27,7 +27,7 @@ import { words, } from './evm.js' import { assetOn } from './symbols.js' -import { PartialRead, type Connector, type ConnectorCredentials, type KeyScope } from './types.js' +import { PartialRead, type Connector, type ConnectorCredentials, type KeyScope, type PartProgress, type Refresh } from './types.js' import { plural } from '../core/format.js' export const AAVE: Venue = { @@ -627,7 +627,11 @@ export const aaveConnector: Connector = { return { canRead: true, canTrade: false, canWithdraw: false } }, - async fetchPositions(creds: ConnectorCredentials): Promise { + async fetchPositions( + creds: ConnectorCredentials, + _refresh?: Refresh, + onPart?: PartProgress, + ): Promise { const address = creds['address'] if (!address) throw new TulaError('Aave needs a public address.') @@ -638,8 +642,11 @@ export const aaveConnector: Connector = { instances: INSTANCES.filter((i) => i.chain.id === chain.id), })).filter((g) => g.instances.length > 0) + let settled = 0 const read = await Promise.allSettled( - byChain.map((g) => readMarkets(g.chain, g.instances, address)), + byChain.map((g) => + readMarkets(g.chain, g.instances, address).finally(() => onPart?.(++settled, byChain.length)), + ), ) const positions = read.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])) diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 202d2a6..0c9536e 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -253,6 +253,13 @@ export interface Connectable { verifyScope(creds: ConnectorCredentials): Promise } +/** + * How far through its own parts a venue is. A count, never a list of the parts + * still outstanding: the busy row sits under the cursor, and AGENTS.md's rule is + * that nothing there may move on its own beyond what the reader asked to watch. + */ +export type PartProgress = (done: number, total: number) => void + export interface Connector { readonly venue: Venue @@ -301,8 +308,12 @@ export interface Connector { /** * `refresh` is the refresh this read belongs to; one made per refresh and * dropped with it, so nothing shared through it outlives the refresh. + * + * `onPart` is for a venue spread over several chains: it reports how many have + * settled, so the busy row can count rather than sit on one unchanging label + * for as long as the slowest chain takes. */ - fetchPositions(creds: ConnectorCredentials, refresh?: Refresh): Promise + fetchPositions(creds: ConnectorCredentials, refresh?: Refresh, onPart?: PartProgress): Promise } export function connectable(connector: Connector): Connectable { diff --git a/src/connectors/wallet.ts b/src/connectors/wallet.ts index 87b50d4..53831fa 100644 --- a/src/connectors/wallet.ts +++ b/src/connectors/wallet.ts @@ -22,7 +22,7 @@ import { words, } from './evm.js' import { assetOn, canonical } from './symbols.js' -import { PartialRead, type Connector, type ConnectorCredentials, type KeyScope } from './types.js' +import { PartialRead, type Connector, type ConnectorCredentials, type KeyScope, type PartProgress, type Refresh } from './types.js' import { typed } from '../core/surface.js' import { host, request } from '../core/http.js' @@ -348,7 +348,11 @@ export const walletConnector: Connector = { return { canRead: true, canTrade: false, canWithdraw: false } }, - async fetchPositions(creds: ConnectorCredentials): Promise { + async fetchPositions( + creds: ConnectorCredentials, + _refresh?: Refresh, + onPart?: PartProgress, + ): Promise { const address = creds['address'] if (!address) throw new TulaError('A wallet needs a public address.') @@ -361,12 +365,16 @@ export const walletConnector: Connector = { // rate-limiting one chain must not take the others off the book, and the // reader has to be told which one went or they will go and replace a node // that is answering. + let settled = 0 const read = await Promise.allSettled( CHAINS.map(async (chain) => { const list = lists.get(tokenListUrl(chain))! if (list.status === 'rejected') throw list.reason return readChain(chain, address, chainTokens(list.value, chain)) - }), + // Counted on settle, not on success: a chain that failed is one the + // reader is no longer waiting for, and a count that skipped it would + // stop short of its total and read as a hang. + }).map((p) => p.finally(() => onPart?.(++settled, CHAINS.length))), ) const positions = read.flatMap((r) => (r.status === 'fulfilled' ? r.value : [])) diff --git a/src/site-claims.test.ts b/src/site-claims.test.ts index 2161b19..21d48eb 100644 --- a/src/site-claims.test.ts +++ b/src/site-claims.test.ts @@ -114,6 +114,20 @@ const RETRACTED = [ 'its last line says which of the two you got', // The check ran once a day until it moved to every shell start. 'once a day', + // Binance's futures permission grants futures *trading*, so `verifyScope` + // refuses any key that could read them: the page advertised a capability the + // connector's own comment said was unreachable, and README said the opposite. + 'USD-M futures', + // Aave removed stable-rate borrowing in v3.2. A gap that names a product the + // venue no longer offers cannot hide a liquidation. + 'stable-rate debt', + // Hyperliquid documents the 95% trigger for portfolio margin only. The + // unified ratio's 95% is the app's wording, and saying the docs publish it + // would be citing a source that does not say it. + 'Unified Account Ratio passes 95%.', + // Kraken proves trade access now, so no surface may send a reader away + // believing only withdrawal is checked. + 'read balances but never withdraw', ] as const describe('the caveat travels with the claim', () => { @@ -999,8 +1013,12 @@ describe('the release notes agree with the build they describe', () => { */ test('every Aave market label the notes quote is one the build emits', () => { const connector = read('src/connectors/aave.ts') + // Non-vacuity is proven against the whole file, not against this release: a + // release need not mention Aave, and requiring one to would make the next + // release that does not the thing that fails. What must not happen is the + // pattern quietly matching nothing anywhere. + expect((notes.match(/`aave-[a-z]+`/g) ?? []).length).toBeGreaterThan(0) const quoted = [...new Set((unreleased.match(/`aave-[a-z]+`/g) ?? []).map((m) => m.slice(1, -1)))] - expect(quoted.length).toBeGreaterThan(0) for (const label of quoted) { const suffix = label.slice('aave-'.length) expect({ label, emitted: connector.includes(`\${AAVE.id}-${suffix}`) }).toEqual({ diff --git a/src/ui/app.tsx b/src/ui/app.tsx index d38c4cd..eb85c01 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -115,6 +115,8 @@ const QUEUE_ROWS = 3 /** While something runs: what Enter and Esc do now that the line takes keys. */ const PLACEHOLDER_BUSY = 'type the next one · Enter queues it · Esc stops a question' +/** A command reads to its own deadline; `stop()` says so rather than stopping it. */ +const PLACEHOLDER_READING = 'type the next one · Enter queues it · each read ends at its deadline' /** * Rows an entry gets in the transcript before the rest is collapsed to a count. @@ -291,9 +293,15 @@ interface Forget { /** A load's step, in the voice the tool labels are written in. */ function loadLabel(step: LoadStep): string { - return step.kind === 'venue' - ? `reading ${step.venue}${step.account ? ` (${step.account})` : ''}` - : `pricing ${step.assets} asset${step.assets === 1 ? '' : 's'}` + if (step.kind !== 'venue') return `pricing ${step.assets} asset${step.assets === 1 ? '' : 's'}` + const where = `reading ${step.venue}${step.account ? ` (${step.account})` : ''}` + // A venue over nine chains held one unchanging label for the whole read, and + // an unchanging label is what a hang looks like. The count only ever goes up, + // and it is a count rather than the name of whichever chain is outstanding: + // a label that churns through nine names is the motion AGENTS.md rules out. + return step.total !== undefined && step.total > 1 && step.done !== undefined + ? `${where} · ${step.done} of ${step.total} chains` + : where } /** @@ -891,6 +899,12 @@ export function App({ detail: `FAILED — ${failure.split(': ').slice(1).join(': ')}`, } } + // Until the cache answers for this venue it holds an unknown number of + // things, not zero of them — and a read that threw is not one still running. + if (!session.covers(id)) { + const detail = session.isLoading ? 'reading…' : `not read — ${typed('refresh')}` + return { id, connected: true, addressOnly, detail } + } const mine = positions.filter((p) => belongsToVenue(p.venue, id)) // reduce() over no rows answers with its seed, so a venue connected and // holding nothing drew the current time as the age of data it does not @@ -905,7 +919,7 @@ export function App({ detail: stalest ? `${held} · ${freshness(stalest, now)}` : held, } }) - }, [session, connectors, connected, entries.length]) + }, [session, session.isLoaded, connectors, connected, entries.length]) useEffect(() => { // A store this cannot read is not a store with no price source in it: the @@ -962,14 +976,18 @@ export function App({ venueEntries.find((v) => v.id === id)?.detail ?? 'no longer read by this build — forgetting it removes the key', })), - assets: session.isLoaded + // The venues this build reads, not the whole store: a venue tula dropped is + // skipped before `refresh` records it, so asking about the store would be + // permanently unanswerable for anyone still holding a retired venue's key — + // and `registry.ts` would tell them to run the `/refresh` that cannot fix it. + assets: session.coversAll(storedVenues(connected, connectors.keys()).read) ? [...held] .sort(([a], [b]) => a.localeCompare(b)) .map(([name, venues]) => ({ name, summary: `held at ${sentenceList([...venues])}` })) : null, accounts: (venue) => accounts[venue] ?? null, } - }, [session, venueEntries, connected, accounts, entries.length]) + }, [session, session.isLoaded, venueEntries, connected, connectors, accounts, entries.length]) const menu: Menu | null = useMemo(() => { // Nothing runs off the menu while a credential is waiting to be named, and @@ -1092,10 +1110,22 @@ export function App({ // answered with nothing is still one that is connected. const { read, removed } = storedVenues(connected, connectors.keys()) const stalest = session.stalest() - const parts = [ - `${read.length} venue${read.length === 1 ? '' : 's'}`, - `${positions.length} position${positions.length === 1 ? '' : 's'}`, - ] + const parts = [`${read.length} venue${read.length === 1 ? '' : 's'}`] + // Nothing is counted while a stored venue is one the cache was not built + // from. `0 positions` there is not an empty book, it is a book nobody has + // answered for yet. With nothing stored it is the true answer, and the + // block below already says why — so this is only about a venue that will + // answer, including one connected after the shell opened. + if (read.length > 0 && !session.coversAll(read)) { + // A read that threw leaves this false with nothing running. Drawn as + // `reading…` that is a spinner over nothing; the rule is that a gap which + // does not close gets the command that closes it. + parts.push(session.isLoading ? 'reading…' : `not read · ${typed('refresh')}`) + if (removed.length > 0) parts.push(`${removed.length} removed`) + parts.push(agent ? 'opus 5' : 'commands only') + return parts.join(' · ') + } + parts.push(`${positions.length} position${positions.length === 1 ? '' : 's'}`) if (stalest) parts.push(freshness(stalest)) // Removed, failed and never-asked are three different things. Counting a // venue this build dropped among the failures reported an outage about a @@ -1108,7 +1138,7 @@ export function App({ if (removed.length > 0) parts.push(`${removed.length} removed`) parts.push(agent ? 'opus 5' : 'commands only') return parts.join(' · ') - }, [session, agent, entries.length, streaming, connected, connectors, tick]) + }, [session, session.isLoaded, agent, entries.length, streaming, connected, connectors, tick]) /** * Says what is about to be forgotten and what it would take to get it back, @@ -1510,6 +1540,7 @@ export function App({ } finally { runningCommand.current = false setStopNote('') + setActivity('') setWorking(false) } }, [session, connectors, venueEntries, push, setWorking]) @@ -2372,7 +2403,9 @@ export function App({ forgetting || search ? '' : busy - ? PLACEHOLDER_BUSY + ? runningCommand.current + ? PLACEHOLDER_READING + : PLACEHOLDER_BUSY : cells(PLACEHOLDER_HINTED) + 2 <= textWidth ? PLACEHOLDER_HINTED : PLACEHOLDER diff --git a/tasks/breadth/09-aave-depth.md b/tasks/breadth/09-aave-depth.md index 15a15f5..0c6af3b 100644 --- a/tasks/breadth/09-aave-depth.md +++ b/tasks/breadth/09-aave-depth.md @@ -13,7 +13,9 @@ therefore hand-built by the rule `ROADMAP.md` states about the tail. ## Acceptance -- **The Safety Module** — staked AAVE, ABPT and GHO. Each stake contract answers +- **Umbrella and the legacy Safety Module** — staked AAVE, ABPT and GHO. Umbrella + replaced the Safety Module in 2025 and the legacy stake tokens still run beside + it, so both are the gap and neither name alone states it. Each stake contract answers `balanceOf`, so it is three addresses in a batch already going out. An unstaked balance carries a cooldown; whether that is a `spot` row or an `UNAVAILABLE` reason is the question to settle, and @@ -66,13 +68,16 @@ therefore hand-built by the rule `ROADMAP.md` states about the tail. pools as a legacy shim, and its return is a dynamic struct whose first word is an offset rather than a field — `decodeString` cannot be pointed at it either. The two collateral views are statically encoded and answer everything. -- **Stable-rate debt**, or the declaration retired with evidence. Word `[9]` of - `getReserveData` is still a live token address on mainnet, so it reads with - one more `balanceOf`. But Aave disabled stable-rate borrowing, and a column - that is always zero is worse than no column: confirm against the live markets - in `scripts/conformance.live.ts`, and if nothing can carry a balance, drop the - gap rather than build for it. **Retiring a declaration is a finding, not a - shortcut — it needs the check that proves it, in the file that reruns.** +- ~~**Stable-rate debt**, or the declaration retired with evidence.~~ **Retired + in 0.3.1.** Aave v3.2 removed every Pool function for stable-rate mode and + stopped instantiating a stable debt token on a listing, so nothing can carry a + balance and a column that is always zero is worse than no column. The gap is + gone from `aave.ts`, from `/aave` and from the table above. The check that + proves it reruns in `scripts/conformance.live.ts`: it asserts `IPool` carries + neither `swapBorrowRateMode` nor `rebalanceStableBorrowRate`, and reports + `contradicted` — restore the declaration — if either comes back. + **Retiring a declaration is a finding, not a shortcut — it needs the check + that proves it, in the file that reruns.** - Every chain Aave v3 is deployed on that tula does not read is [`12`](./12-chain-reach.md)'s, not this file's. - Each gap closed removes its `doesNotRead` entry in the same change, and the From 8c05676a8a33dfc5d913950bb3aacc9d4aaea642 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Wed, 16 Sep 2026 19:02:20 +0530 Subject: [PATCH 2/3] tula v0.3.1 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 ++++- README.md | 2 +- SECURITY.md | 2 +- package.json | 2 +- site/app/install/page.tsx | 2 +- site/lib/site.ts | 2 +- src/version.ts | 2 +- 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1782b81..744acf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message ## [Unreleased] +## [0.3.1] - 2026-09-16 + ### Fixed - **A venue being read no longer reports itself as holding nothing.** For the seconds a venue takes to answer — a wallet spread over nine chains is the slow one — the status line said `0 positions` and the `/` menu said `0 tokens`. Both now say the venue is being read, and say `not read` with the command to run if the read failed rather than leaving a spinner over nothing. The same gap covered a venue connected after the shell opened: the store held it at once, the book did not, and every surface read the difference as an empty account. @@ -351,7 +353,8 @@ what breaks first. - `KeyScope` is tri-state. Kraken exposes no endpoint reporting a key's permissions, and every endpoint gated on trade permission mutates an order, so `canTrade` is `unknown` rather than guessed at. Withdraw scope is provable, and is proven. - Kraken margin and open orders are not read yet, so on a margin account this is not a complete Kraken picture. -[Unreleased]: https://github.com/hsnice16/tula/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/hsnice16/tula/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/hsnice16/tula/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/hsnice16/tula/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/hsnice16/tula/compare/v0.1.3...v0.2.0 [0.1.3]: https://github.com/hsnice16/tula/compare/v0.1.2...v0.1.3 diff --git a/README.md b/README.md index 6ca0db3..99ef69a 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ the sigstore-backed attestation proving this repository's release workflow built it wherever the GitHub CLI can — saying so either way. Check one by hand: ```bash -gh attestation verify tula-v0.3.0-darwin-arm64.tar.gz --repo hsnice16/tula \ +gh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula \ --signer-workflow hsnice16/tula/.github/workflows/release.yml ``` diff --git a/SECURITY.md b/SECURITY.md index 6dbc34b..1ffe072 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -288,7 +288,7 @@ keyless, so there is no signing key for this project to generate, publish, rotat or lose. ```bash -gh attestation verify tula-v0.3.0-darwin-arm64.tar.gz --repo hsnice16/tula \ +gh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula \ --signer-workflow hsnice16/tula/.github/workflows/release.yml ``` diff --git a/package.json b/package.json index c3ddd22..c558eb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hsnice16/tula", - "version": "0.3.0", + "version": "0.3.1", "description": "Your true exposure, what breaks first, and more, across every venue at once.", "license": "MIT", "type": "module", diff --git a/site/app/install/page.tsx b/site/app/install/page.tsx index 31d3de8..b980a11 100644 --- a/site/app/install/page.tsx +++ b/site/app/install/page.tsx @@ -358,7 +358,7 @@ export default function Page() { { - "curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.3.0/tula-v0.3.0-darwin-arm64.tar.gz\ngh attestation verify tula-v0.3.0-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml" + "curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.3.1/tula-v0.3.1-darwin-arm64.tar.gz\ngh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml" }

diff --git a/site/lib/site.ts b/site/lib/site.ts index a1a2097..1b9279e 100644 --- a/site/lib/site.ts +++ b/site/lib/site.ts @@ -10,7 +10,7 @@ export const NAME = 'tula' * the two disagree and `release-cut.sh` bumps this with them: a frame offered * as the tool's own output cannot print a release that was never cut. */ -export const VERSION = '0.3.0' +export const VERSION = '0.3.1' /** * GA4, for the site alone. Held here rather than read from `process.env`: an diff --git a/src/version.ts b/src/version.ts index 841d48d..59fe281 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,5 +1,5 @@ export const APP_NAME = 'tula' -export const APP_VERSION = '0.3.0' +export const APP_VERSION = '0.3.1' /** * SemVer says a hyphen means pre-release, and `release.yml` already reads it From e5a2c0af70edf255eb5cdb07a59197d47757abc3 Mon Sep 17 00:00:00 2001 From: Himanshu Singh Date: Wed, 16 Sep 2026 19:39:03 +0530 Subject: [PATCH 3/3] Footer: put the link rows back to 32px The rows were raised to 44px for a tap target, against the reasoning already written above the constant: a 32px row keeps each target above WCAG 2.2's 24px minimum, and stacked links cannot borrow target from the space around them without handing their taps to the next. At 44px the space between links read the same as the space to the section heading, so Pages, Guides and More links stopped looking like headings over lists. The comment was left saying 32 while the code said 44, which is the drift the comment rule exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- site/components/Footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index 3ecae1b..3c42a6d 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -17,7 +17,7 @@ const PEERLIST = { * them without handing their taps to the next. */ const LINK = - 'flex min-h-11 items-center text-dim underline decoration-rule decoration-dotted underline-offset-4 hover:text-accent' + 'flex min-h-8 items-center text-dim underline decoration-rule decoration-dotted underline-offset-4 hover:text-accent' function Column({ title,