Skip to content

docs: refresh README to reflect built system + Mantle on-chain - #55

Open
loficoded wants to merge 98 commits into
mainfrom
docs/readme-hackathon-refresh
Open

docs: refresh README to reflect built system + Mantle on-chain#55
loficoded wants to merge 98 commits into
mainfrom
docs/readme-hackathon-refresh

Conversation

@loficoded

Copy link
Copy Markdown
Owner

What

Refresh the README so it reflects the built system instead of the stale "Stage P0.1 — foundation only" note.

The old header said scoring, referee and on-chain writes "land in later stages" — but they are all implemented and tested. A judge opening the repo would materially undervalue the project.

Changes (docs-only, no code/behavior change)

  • Replace the P0.1 note with the real pipeline diagram + one-paragraph descriptions of the three engines (Referee / AgentScore / capital router).
  • Add On Mantle (on-chain) section: canonical ERC-8004 registry addresses on Mantle Sepolia (chainId 5003), the msg.sender authorization note, and the Byreal/Nansen side-channel determinism boundary.
  • Add Demo — the 90-second arc section pointing at runArc(db, DEMO_ARC).
  • Expand the Docs index (demo-spine / referee / scoring / router / erc8004 / byreal / nansen).

Why now

Hackathon submission readiness (Mantle: The Turing Test 2026, track Agentic Wallets & Economy). The README is the repo's 60-second first impression for judges.

Claude Opus 4.8 and others added 30 commits June 6, 2026 08:23
Foundation for Vector (Next.js App Router + TS strict, Bun toolchain):

- Single source of truth: lib/config/constants.ts — every scoring/routing/
  timing/signal/policy/capital/chain constant, zod-validated at load and deeply
  frozen (mutation throws; invalid values crash startup).
- Server-only env: zod schema + pure parser (env.schema.ts) with redacted,
  value-free error messages; eager server-only entry (env.ts). Secrets never
  reach the client bundle.
- Neon client: server-only pool singleton; checkDb() is a total function that
  collapses every failure (refused/TLS/disconnect/timeout) to 'down'.
- /api/health: real SELECT 1, returns { ok, db, config_loaded, commit }
  (200 up / 503 down). Pure formatter in lib/health.ts.
- SWR provider polling at the single ui_poll_ms cadence (no sockets).
- Tests (~10% happy / ~90% edge): unit, fuzz (seeded), integration (gated on
  DATABASE_URL), e2e single-source invariant. 54 pass.
- Docs: config.md, env.md, ADR 0001; .env.example; README.

Verified: tsc --noEmit, eslint, prettier --check, bun test, next build — all clean.
P0.1 — App Skeleton & Seeded Config
What
- Full §7.1 schema as SQL DDL (lib/db/migrations/0001_*): agents, rounds,
  intents, policy_events, executions, outcomes, scores, capital_allocations,
  attestations, kill_switch — uuid PKs, timestamptz, numeric-only money/scores.
- Enum domains named separately (agents.status vs executions.status; etc.).
  intents.action {open,close,modify,transfer}; capital_allocations.trigger
  widened to {settle,attestation,crash,operator} per §6.2.
- Invariants in SQL: kill_switch singleton (id=1 + CHECK); attestations unique
  (agent_id, round_id); intents.target_address only on transfer; outcomes.
  execution_id nullable (seeded arc); value bounded to int128, value_decimals
  to uint8; feedback_hash/tx_hash hex-format checks; weights/CaR/fees ranges.
  All FKs ON DELETE RESTRICT (no dangling rows). Read-path indexes for P1.5.
- Migration runner (lib/db/migrate.ts): paired up/down SQL, schema_migrations
  ledger, per-migration transaction (atomic), pg_advisory_lock (serializes
  concurrent runners), idempotent. No ORM (see ADR 0002).
- Typed repository layer (lib/db/repos/*): parameterized insert/select per
  table over an injected Queryable; zod row validation; numeric as string to
  preserve precision. Parameter binding only — no SQL string concatenation.
- Smoke seed (one idempotent row per table) + data reset; CLI scripts
  db:migrate / db:rollback / db:seed / db:reset.
- docs/data-model.md (tables, enum domains, truth map §7.2, mermaid ER,
  migration runbook) + ADR 0002 (tooling rationale).

Tests
- unit: SQL builder + identifier guard (injection), migration plan/apply
  (BEGIN/COMMIT/ROLLBACK), repo mapping/param-binding/enum/zod-reject.
- fuzz: assertIdent accepts iff safe pattern; buildInsert never inlines values.
- integration (real Neon, throwaway schema): every table+index present, happy
  FK joins + leaderboard, singleton, unique attestation, FK violation, bad
  enum, NOT NULL, target-only-on-transfer, numeric/int128/uint8/negative
  guards, RESTRICT on delete-with-children, reset + re-seed, repo round-trip.
- e2e (real Neon): idempotent re-apply, full down→up integrity, atomic
  rollback on mid-migration failure, two concurrent migrators serialize.

Verification
- tsc --noEmit, eslint, prettier: clean.
- next build: ok (DATABASE_URL set, unchanged P0.1 behavior).
- 105 tests pass / 0 fail across unit+fuzz+integration+e2e.
- `test` script now runs suites as separate processes: bun's mock.module is
  process-global, so mock-based unit tests must not share a process with the
  real-DB suites.
P0.2 — Neon data model & migrations (§7)
Implement Vector's single trust boundary: a typed, signed Intent and an
ordered validation pipeline that is independent of agents and the referee.
Only a structurally valid, authentic Intent crosses B1, so a prompt-injected
agent cannot bypass the gate — free-form model output is never executed.

What
- lib/intent/schema.ts: zod discriminated union on `action`
  (open/modify/close/transfer), .strict(); JSON Schema export; Intent/
  UnsignedIntent types derived via z.infer/z.input (single source of truth,
  no drift under exactOptionalPropertyTypes).
- lib/intent/canonical.ts: numeric-as-string normalization (1 == 1.0 == "1"),
  ISO-8601 ttl + nonce normalization, deterministic key-sorted serialization,
  intent_hash = keccak256(canonical payload). Precision cap rejects absurd
  literals without panic.
- lib/intent/sign.ts + verify.ts: EIP-191 personal_sign over the canonical
  payload via viem; recovery/verification never throw on malformed input —
  a failed auth is a deterministic reject. ERC-1271 left as the single seam
  in verify.ts (out of scope for EOA seed agents).
- lib/intent/validate.ts: first-failing ordered checks
  schema → signature → nonce → ttl → bounds → target_address, returning a
  typed {ok,stage,code}. Nonce single-admission via an atomic reserve guard;
  ttl skew/horizon opt-in. Policy (whitelist, caps, drain detection) is the
  referee's job (P1.1), not this boundary.
- docs/intent-contract.md + docs/examples/signed-intent.json: normative spec
  and a pinned, byte-stable conformance vector.

Reuse-first: crypto via viem (keccak256/EIP-191), JSON Schema via
zod-to-json-schema — no hand-rolled primitives.

Tests
- Happy ~10% / edge ~90%; unit + seeded fuzz (deterministic PRNG) + e2e +
  integration (full path → real Neon `intents`, isolated schema).
- Golden/regression vectors pin payload, hash, and signature.
- lib/intent coverage: 100% functions / 100% lines.

Verification
- tsc, eslint, prettier clean; `bun run test` (unit+fuzz+integration+e2e)
  green incl. DATABASE_URL; `next build` green.
Security audit of the P0.3 boundary found one real, exploitable issue in
normalizeDecimal: the MAX_DECIMAL_DIGITS cap counted only significant digits,
not the positional expansion from a scientific-notation exponent. A tiny
literal such as `size: "1e999999999"` therefore slipped past the cap and was
materialized into a multi-gigabyte string via `'0'.repeat(...)`, hanging or
OOM-ing the process.

This fires at the schema stage (a) of validateIntent — on attacker-controlled
numeric fields (size/leverage/max_slippage/tp/sl) and *before* any signature
verification — so it is an unauthenticated amplification DoS (~12 input bytes
→ gigabytes). Reproduced: "1e8000000" expanded to an 8 MB string; larger
exponents hung the process.

Root cause / fix: bound the full positional span (leading integer + trailing
fractional places) by the same MAX_DECIMAL_DIGITS cap and reject in O(1)
before any allocation. This is the minimal extension of the existing
precision guard — no new dependency, consistent with the module's
canonical-string approach. The cap sits at the same boundary as the digit
cap (1e79 is the largest accepted power of ten; 1e80 is rejected); all
legitimate financial magnitudes pass unchanged.

Tests
- unit: exponent bombs (±) throw `/maximum precision/` in <100ms each; 1e79
  passes, 1e80 rejected (boundary).
- e2e: an exponent bomb routed through validateIntent fails at stage `schema`
  in O(1) (no allocation), proving the gate cannot be hung pre-auth.

Verification: tsc / eslint / prettier clean; full `bun run test`
(unit+fuzz+integration+e2e, with DATABASE_URL) green; `next build` green;
lib/intent coverage remains 100% functions / 100% lines.

Audit notes (reviewed, no change needed):
- signature binds agent_id + nonce (mutation breaks recovery); cross-agent
  replay is rejected at the signature stage.
- ECDSA s-malleability is not exploitable: dedup keys on intent_hash /
  (agent_id,nonce), neither of which includes the signature.
- nonce TOCTOU between read and durable reserve remains the caller's
  responsibility (DB unique index / createNonceGuard.reserve), as documented.
…nonce anti-aliasing

Security/correctness fixes from the P0.3 audit, root-cause and minimal. No
change to the accepted happy-path wire shape; golden vectors unchanged.

What
- canonical.ts `normalizeTimestamp`: reject lenient/ambiguous date strings.
  String input must now be a strict ISO-8601 instant with an explicit timezone
  (`Z` or `±HH:MM`/`±HHMM`); `number` is epoch-ms. Previously any string went to
  `new Date(...)`, so a timezone-less datetime was parsed in the *host's local
  zone* — making the canonical payload host-dependent (signature verification
  fails across hosts with different TZ, and ttl meaning shifts ±offset), and
  garbage like "2031" / "Jan 1 2030" / "01/02/2030" was silently accepted as a
  valid expiry. Determinism is the whole point of the canonical payload.
- validate.ts `inUnitInterval`: compare on the canonical decimal string instead
  of `Number(d)`. A float comparison rounds e.g. "1.0000000000000001" down to 1
  and admits a max_slippage strictly > 1; the gate must honour the exact bytes
  it signed/hashed (numerics are canonical strings end-to-end).
- canonical.ts `normalizeNonce`: reject numeric nonces beyond
  Number.MAX_SAFE_INTEGER. Past 2^53 a JSON number has already lost precision,
  so two distinct nonces (2^53 vs 2^53+1) alias to one canonical token in the
  anti-replay key and wrongly reject a legitimate Intent as a replay. Use a
  string nonce for large/opaque values.

Why these only
- Domain separation (cross-instance/chain signed-Intent replay) and the
  published JSON-Schema↔parser fidelity gap are real but change the signed wire
  format / are architecture decisions — escalated separately, not auto-fixed.
- maxTtlHorizon/isNonceUsed fail-open defaults are by-design opt-in (referee /
  durable store own them); left as-is.

Tests
- canonical: normalizeTimestamp rejects tz-less, bare year, locale dates,
  digit-string, out-of-range fields; accepts Z and numeric offset. normalizeNonce
  rejects unsafe-integer numbers, keeps string nonces of the same magnitude.
- validate: max_slippage "1.0000000000000001" rejected; 0 / 1 / "0.5" accepted.

Verification
- bunx tsc --noEmit, eslint, prettier --check: clean.
- bun test (unit+fuzz 147, integration+e2e 33+1skip on real Neon): green.
- next build: ok.
…urity audit

Root-cause, minimal fixes from the P0.2 audit. The SQL-injection posture
(parameterized binds + assertIdent) and the SQL↔zod schema mapping were already
sound and are unchanged.

What
- repos/_shared.ts `num()`: reject a JS `number` that is not an exactly
  representable safe integer. A non-integer (`0.1 + 0.2` →
  "0.30000000000000004") or an integer past 2^53 (an int128 `attestations.value`
  or a bigint block number passed as a number) has already lost precision before
  `num()` runs; coercing it via `.toString()` silently persisted a corrupted
  money/score/on-chain value into a `numeric` column, violating the project's
  "numeric is exact, never through a float" invariant. Safe integers (e.g.
  `score_r: 50`) and exact strings/bigints still pass; bad inputs now throw so
  the caller supplies an exact string.
- migrate.ts `assertSessionConnection` (new, run at the start of `migrate()`):
  fail closed when the connection does not preserve session state across
  statements — i.e. a transaction-pooled endpoint (Neon `-pooler` / PgBouncer
  transaction mode). The runner relies on two session-scoped guarantees, the
  `pg_advisory_lock` migration mutex and `SET search_path`; on a pooled endpoint
  both silently no-op, so migrations would not be serialized and DDL could land
  in `public` instead of the target schema. Detected generically by setting a
  session GUC and reading it back on a separate statement.
- migrate.ts `loadMigrations`: throw on a duplicate version+direction instead of
  letting readdir order silently pick a winner — completes the documented
  "a malformed set throws before any SQL runs" invariant (only missing-half was
  caught before).
- migrate.ts error handling: a failing ROLLBACK no longer masks the original
  migration error (the real root cause), and a failing advisory-unlock in
  `finally` no longer turns an already-committed migration into a thrown error.
- seed.ts `resetData`: refuse the destructive TRUNCATE-all when current_schema
  is `public`. The helper is exported from `lib/` with no guard; this blocks a
  `public`/production-bound connection being passed in by mistake. Legitimate
  callers run inside a dedicated non-public schema via search_path.

Why these only
- Schema CHECK gaps flagged by the audit (tp/sl ≥ 0, delta ∈ [-1,1]) are
  intentionally omitted per the data-model spec (referee owns intent validation)
  — left as-is. assertIdent reserved-word quoting / 63-byte bound and the
  health-probe in-flight-connection retention are defense-in-depth with no
  current exploit; not changed here.

Tests
- num: rejects non-integer / >2^53 / NaN / Infinity; accepts safe int, string,
  bigint (incl. int128-scale).
- loadMigrations: throws on a duplicate up for a version.
- applyMigration: a failing ROLLBACK still surfaces the original error.
- assertSessionConnection: resolves on a persistent session, throws when state
  is dropped between statements.
- resetData: refuses on `public`, truncates inside a non-public schema.

Verification
- bunx tsc --noEmit, eslint, prettier --check: clean.
- bun test (unit+fuzz 93; integration+e2e 24+1skip on real Neon): green.
- next build: ok.
What
- checkDb (lib/db/client.ts): run the `SELECT 1` probe on a dedicated pooled
  client that is always released, and bound the query server-side with
  `statement_timeout` (set via parameterized set_config). The wall-clock
  Promise.race is kept only as a backstop on the HTTP response time.

Why
- The probe's timeout was a *racing promise*, not cancellation. When the timer
  won, checkDb returned 'down' but the losing `getPool().query('SELECT 1')` kept
  running on its acquired connection with no driver-level bound (the Pool has no
  connectionTimeoutMillis / statement_timeout). Under a slow or hung backend
  each in-flight probe pinned one of the pool's connections for as long as the
  underlying op took — far beyond the 2s the caller was promised. /api/health is
  unauthenticated and force-dynamic, so a burst during a DB blip (or a flood)
  could pin every connection in the shared process-wide pool and amplify a
  transient slowdown into a full outage. The advertised "bounded by 2s" was
  illusory for resource holding.
- Now statement_timeout cancels the query server-side and the `finally` releases
  the client promptly, so 'down' is reported AND the connection is freed. The
  result stays a total function (every failure → 'down', never throws).

Scope
- Only the health probe is touched. The shared getPool() config is left as-is to
  avoid changing connect semantics for the repo/migration pools; bounding a hung
  *connect* (vs query) via Pool connectionTimeoutMillis is noted as an optional
  follow-up. Other audit findings (missing baseline HTTP security headers; the
  health page rendering the fetcher error instead of the db:down payload;
  deepFreeze robustness on frozen/cyclic graphs; explorerTxUrl URL-encoding) are
  defense-in-depth / non-security / latent-until-later-stage and were filtered,
  not fixed.

Tests
- Updated tests/unit/health.route.test.ts: the Neon fake now models the
  connect → client.query → release shape the probe uses (behavioral assertions
  — 200/up, 503/down on reject, down on slow — unchanged).

Verification
- tsc --noEmit, eslint, prettier --check: clean.
- bun test per suite (the project's test command runs them as separate
  processes): unit 63, fuzz 18, integration 17 (real Neon, incl. checkDb
  up/concurrent/timeout), e2e 8 — all green.
- next build: ok.
Implement the referee (architecture §6.3): the single path from a validated,
signed Intent to the rail. A pure, deterministic evaluate() runs a fixed,
ordered rule set and the first failing rule decides ALLOW/CLIP/REJECT/HALT;
runReferee() re-validates via P0.3 and writes one policy_event per decision via
the P0.2 repository.

Rules (in order): kill switch (HALT) → market whitelist (REJECT/hard) →
fresh-wallet transfer block (REJECT/hard) → per-trade size cap (CLIP/soft) →
spend cap (CLIP or REJECT/soft) → leverage cap (CLIP/soft) → drawdown breaker
(HALT). Caps use strict '>'; the drawdown breaker trips on reaching dd_breaker.

Critical invariant: no transfer to a non-whitelisted address is ever ALLOWed or
CLIPped (the drain block), covered by unit, fuzz, and e2e tests.

Reuse, no reimplementation:
- P0.3 lib/intent/validate.ts for pre-validation
- P0.2 lib/db/repos/policy-events.ts for the audit write
- P0.1 CONFIG.policy + fresh_wallet_criteria for caps/whitelist
- exact decimal comparison via a new pure compareDecimal() in canonical.ts
  (no floats), reusing normalizeDecimal

Tests: per-rule isolation + boundaries, ordering, severity mapping (100% line/
func coverage of lib/referee/*), fuzz invariants (domain closure, hard only for
whitelist/transfer, HALT only for kill/drawdown, monotone CLIP, idempotency),
integration (fake + real-Neon gated), and hard e2e (mass parallel drains,
kill-switch race, all-rule conflict, adversarial addresses, extreme magnitudes).
Docs: docs/referee.md.
The Neon pool in lib/db/client.ts is a process singleton (getPool: pool ??= ...).
When the suite runs with DATABASE_URL set, the gated db.integration test primes
that singleton with the real driver and then calls getPool().end() without
clearing the cached reference. The next file, health.route.test, relies on
mock.module('@neondatabase/serverless') to keep checkDb hermetic, but getPool()
hands back the stale (ended/real) pool instead of one built from the mock, so the
probe fails and the route returns 503 -> the "returns 200" test fails. It passed
in isolation and pairwise, which masked the cross-file leak; it reproduces
deterministically on main once DATABASE_URL is set.

Fix at the root: add a test-only resetPool() that drops the cached singleton, and
call it in the health test's beforeAll (so checkDb rebuilds a pool from the mocked
driver) and afterAll (so this file's mock pool never leaks to later files). No
production path uses resetPool; connection lifecycle stays the creator's job.

Verified: full suite with real Neon 192 pass / 0 fail (was 1 fail), deterministic
across repeated runs; without DATABASE_URL 171 pass / 29 skip / 0 fail; typecheck,
lint, format:check clean.
…ominate soft clips)

Security audit (P1.1) found that the single ordered "first-fires-decides" rule
list let an attacker pre-empt a terminal decision with an earlier soft CLIP:
oversizing a trade tripped size_cap (CLIP) before leverage_cap, the spend_cap
REJECT, and the drawdown_breaker HALT — so an over-leveraged / over-budget /
drawdown-breached trade could execute clipped instead of being rejected/halted.

Fix: evaluate in two phases.
  - BLOCKING_RULES (HALT/REJECT) run first; first fire decides. drawdown_breaker
    and the zero-budget spend reject move ahead of the soft caps so a terminal
    decision can never be skipped.
  - CLIPPING_RULES run only if nothing blocked and now *accumulate*: every
    breached cap is clamped in one CLIP (size -> min(max_trade_size, remaining),
    leverage -> max_leverage), so clipping one field can't let another through.
  - spend_cap split into spendCapRejectRule (blocking) + spendCapClipRule
    (clipping); both still report rule_fired='spend_cap'. A lone clip is reported
    verbatim; multiple join rule ids with '+' and record each in detail.clips[].

Also: runReferee now fails closed — an unexpected error during validate/evaluate
records a terminal internal_error REJECT policy_event (err.name only, no message)
before re-throwing, preserving the one-event-per-decision audit invariant.

Tests: rewrote the ordering tests that encoded the bug; added regressions
(drawdown HALT and zero-budget REJECT beat an over-size clip; size+leverage both
clamped; size clamped to the smaller of cap/budget). Strengthened the fuzz
invariant so ANY clip result satisfies ALL caps, not just the rule that fired.
docs/referee.md: two-phase decision matrix + caller-contract / non-guarantees.
Adds a test-only resetPool() to lib/db/client.ts so a test that primes the
process-wide pool (with the real driver, or after getPool().end()) can't leave a
stale pool that later tests in the same process reuse and that defeats their
driver mocks. Resolves the cross-test flake seen in the full suite with a live DB.
(PR #7)
…clip-ordering bypass fix

P1.1 referee — a pure two-phase evaluate() over a typed Intent: blocking rules
(kill switch, market whitelist, fresh-wallet transfer block, drawdown breaker,
zero-budget reject) decide first; clipping rules (size, over-budget spend,
leverage) run only if nothing blocked and accumulate, so the post-clip Intent
satisfies every cap at once. runReferee re-validates via the P0.3 boundary and
records exactly one policy_event, failing closed (terminal internal_error REJECT)
on unexpected errors.

Includes the security hardening from the audit: terminal decisions (HALT/REJECT)
now dominate soft CLIPs, so an attacker can no longer oversize `size` to trip an
early size_cap clip and pre-empt a later leverage/spend reject or a drawdown halt.
(PR #6)
…cale guard

Pre-P1.2 hardening of audit findings, on a dedicated branch.

#1 nonce-replay (durable): migration 0002 adds UNIQUE(agent_id, nonce) on
intents so a replayed Intent insert fails atomically in the DB, independent of
process-local state (P0.3 had only an in-memory guard). New repo primitives
insertIntentReserving (INSERT ... ON CONFLICT DO NOTHING; null = replay) and
isNonceUsed (durable read); buildInsert gains an onConflictDoNothing option and
insertOneOrNull backs it. NULL nonces stay exempt (NULLs distinct).

#3 statement_timeout leak: checkDb now scopes the probe timeout to a
transaction (set_config(..., is_local=true) inside BEGIN/COMMIT) so it is
discarded on commit and never leaks onto the pooled connection.

#4 pool error handler: getPool attaches an idle-client error listener so a Neon
idle-connection drop no longer crashes the process; logs only err.name.

#5b numeric scale guard: the validator rejects a numeric field with finer
fractional scale than its numeric(p,s) column can store (silent-rounding /
integrity guard). Magnitude is intentionally NOT bounded here — the firewall
clips over-large size/leverage (§6.5).

schema: .max() length bounds on agent_id/market/target_address (pre-signature
DoS guard).

Tests: +ON CONFLICT/insertOneOrNull, +scale-guard & magnitude-pass cases,
+schema length bounds, +checkDb transaction-shape and pool-error-swallow
regressions, +intents anti-replay integration on live Neon. Full suite (live
Neon) 271 pass / 1 skip / 0 fail; typecheck/lint/format clean.
…+ intent scale guard

Pre-P1.2 hardening of audit findings (#1 durable UNIQUE(agent_id,nonce) anti-replay,
#3 txn-scoped probe statement_timeout, #4 pool idle-error handler, #5b numeric
fractional-scale validator guard, schema .max() length bounds). Reviewed by a
3-agent swarm (security/correctness, integrity/no-loss, independent re-validation):
all PASS, no blockers. Full suite on live Neon: 271 pass / 1 skip / 0 fail;
typecheck/lint/format clean.
Pure score() (architecture.txt §6.1): RoC -> bounded tanh perf -> capital
risk-weight (anti-Sybil) -> policy/drawdown penalties -> clamp raw_r -> EWMA
over history -> floor-crash on halt/drain to crash_cap. clean_r derived from
hard==0; drain_r from referee rule #3. Inputs validated (RangeError on
NaN/inf/negative/fractional). Deterministic double math, outputs quantized to
column scale.

Persistence: deriveScoreInputs(outcomes, policy_events) + recordScore (insert
scores row + atomic agents.score_current/status update; gates on crash or
score<s_min, never touches operator-halted). updateAgentScore is the only
writer of score_current. Added getLatestScoreByAgent, listPolicyEventsByAgentRound.

Scale reconciliation: penalties read as 0-100 points (matches CONFIG.scoring
constants and the §6.1 'hard = dominant penalty, not crash' distinction);
documented in docs/scoring.md. NEEDS OWNER CONFIRMATION.

Tests: unit (steps/bounds/invariants/invalid-input/monotonicity), golden table,
5000-draw deterministic fuzz, record (FakeDb), integration (real Neon, isolated
schema), e2e (catastrophe/recovery, alpha boundaries, reproducibility).
lib/scoring/* 100% line+func coverage. Full suite 339 pass/1 skip/0 fail on
live Neon; typecheck/lint/format clean.
Audit-driven fixes (real risks only; false positives/non-issues filtered):
- deriveScoreInputs: skip meta events (internal_error/pre_validation/allow) so
  infra faults no longer penalize an agent's reputation; dedup violations per
  intent_id (worst severity) so re-evaluations can't double-count.
- Drain floor-crash keyed on a shared FRESH_WALLET_TRANSFER_BLOCK_RULE const
  exported from the referee rule (was a duplicated literal); regression test
  pins the coupling and that a drain crashes.
- recordScore: idempotent ON CONFLICT DO NOTHING insert + converge the agent
  gate from the persisted score on replay, so a crash that failed to gate is
  healed on retry instead of left fail-open.
- getLatestScoreByAgent: chain EWMA prior by rounds.index (not created_at),
  fixing out-of-order/same-tick nondeterminism.
- listOutcomesByAgentRound: deterministic (created_at, id) tiebreaker.
- scores.components_json: enforce {perf,w,policy,dd} contract at persistence
  (strict zod); fix seed missing the w key.
- docs: concurrency precondition for P1.4 caller, tanh cross-engine note, and
  open owner-decision security notes.

Full suite WITH live Neon: 343 pass / 1 skip / 0 fail; typecheck/lint/format clean.
feat(scoring): P1.2 scoring engine (AgentScore in [0,100])
Pure, deterministic capital allocator (architecture.txt 6.2): softmax merit
target with eligibility gate, hysteresis, max-step, cooldown, plus immediate
crash/HALT gate-out. Pool conserved exactly (sum amount == pool_size) via
integer largest-remainder apportionment, zero drift across rounds. Amounts and
weights are fixed-scale decimal strings over BigInt (never floats).

- lib/router: types, fixed-point arithmetic, route(), persistence layer
- docs/capital-router.md; link from docs/config.md
- unit (route + fixed-point), golden, fuzz, e2e, integration tests
…6.2)

Routes a fixed pool by reputation: softmax merit target, four anti-oscillation
mechanisms (eligibility gate, hysteresis, max-step, cooldown), forced gate-out on
crash/HALT, round-0 bootstrap, and exact integer conservation (Σ amount == pool).
Pure deterministic core (lib/router/route.ts) + thin persistence (record.ts).
capital_allocations was the only per-(agent,round) ledger without
UNIQUE(agent_id,round_id) (scores and attestations both have it), so a
settlement retry or a concurrent pass could append a second full row set and
silently double the round's Σ amount — corrupting both the conservation audit
and the prev-state the next pass routes from.

- Add the UNIQUE(agent_id,round_id) constraint in migration 0003 and drop the
  now-redundant non-unique idx_capital_alloc_agent_round (the constraint carries
  its own backing index). Add CHECK(delta in [-1,1]) as defense-in-depth.
- insertCapitalAllocation now inserts ON CONFLICT DO NOTHING and returns
  CapitalAllocationRow | null, mirroring scores.insertScore.
- recordRoute turns a null (conflict) into idempotency: it stops trusting the
  pass's partial inserts and returns the authoritative persisted ledger, so a
  retry of an already-recorded round is a no-op. Document the
  one-transaction-per-round atomicity contract.
- Regression: integration test asserts a re-run records no duplicate rows and
  keeps Σ amount == pool exactly.
…tent recordRoute

Durability hardening for the capital ledger: a UNIQUE(agent_id,round_id)
constraint (migration 0003) + ON CONFLICT DO NOTHING make recordRoute idempotent
under settlement retries, so a re-run cannot double the round's Σ amount.
The prior allocation a pass routes from comes off the ledger; a corrupted or
adversarial value must fail deterministically, not skew the move policy.

- parseUnits silently read no-digit strings ('', '.', '-', '+', whitespace) as 0;
  it now throws RangeError on a magnitude with no digits (signed parse and
  truncation-beyond-scale are unchanged).
- toUnits rejects a magnitude past Number.MAX_SAFE_INTEGER (the 1e21 toFixed
  cliff) up front, instead of failing cryptically deep in BigInt.
- route() validates prev amount/weight are non-negative, removing the
  parseUnits(accepts neg) / formatUnits(throws on neg) asymmetry and preventing
  a negative row from skewing prevSum/prevW or forcing a false cold start; the
  prev weight is now carried as bigint units end to end.
- isMaterial parses the canonical fixed-point strings via parseUnits, not a lossy
  Number.parseFloat — one parser end to end.
- docs: soften 'cannot oscillate' to per-step monotonicity (hysteresis/cooldown
  bound and damp, they do not eliminate, adversarial self-oscillation); note
  Math.exp is the only non-bit-identical step across runtimes; note Σ
  target_weight need not equal 1 exactly (amount is the source of truth).

Regression: unit tests cover the parseUnits no-digit reject, the toUnits
MAX_SAFE_INTEGER reject, and route() rejecting a negative prev amount/weight.
…s + non-negative prev

Input-validation hardening for the router: parseUnits/toUnits reject malformed
and out-of-range magnitudes, route() rejects a negative prev amount/weight, and
isMaterial uses the one canonical parser — a corrupted ledger row fails
deterministically instead of skewing the move policy.
Drive a frozen scripted arc through the real referee -> scoring -> router
pipeline on a seeded execution rail, producing a byte-reproducible end-to-end
demo (signal -> decide -> intent -> referee -> execution -> outcome -> score
-> [attestation seam] -> capital re-route).

- lib/replay/: scheduler, compose, attack, rail (+fallback), control latch,
  setup (idempotent), orchestrator (runArc), barrel.
- lib/agents/seed/: deterministic seed roster + strategies.
- seed/: frozen DEMO_ARC dataset (pure fn of version/rounds/timing).
- migration 0004: add 'seed' to execution_rail enum (+ guarded down).
- docs/demo-spine.md: determinism contract + seams.
- tests: unit/fuzz/e2e green locally; integration on real Neon (asserts
  conserved pool, leader crash via drain rule #3, reroute to runner-up,
  idempotency). Injected transfer blocked REJECT/hard (rule #3) -> leader
  crashes to crash_cap -> capital reroutes to seed-2.
loficoded added 30 commits June 8, 2026 11:42
Optimistic on-chain mirror written atomically inside settleRound; giveFeedback + reconcile run post-commit so latency never blocks the settle arc. Idempotent via UNIQUE(agent_id,round_id)+ON CONFLICT, tx_hash-IS-NULL submit latch, forward-only reconcile guard. Off-chain detail stored verbatim and served byte-identical so feedback_hash always re-verifies.
Add X-Content-Type-Options: nosniff to the verbatim-bytes endpoint and cover it with route tests (was uncovered).
PUBLIC_BASE_URL reused the rpcUrl validator (also accepts ws(s)), contradicting its contract and its only consumer buildFeedbackUri. Dedicated http(s)-only validator + regression tests.
Add lib/rail/byreal/* - a safe CLI shell-out that settles already-allowed
Intents on the Byreal/Hyperliquid testnet venue and maps fills/PnL onto
executions/outcomes. Stage-2 credibility layer.

Determinism boundary (sec.3): real Byreal outcomes are an opt-in side-channel
(RunArcOptions.credibilityRail) written as executions/outcomes(rail='byreal');
shown alongside the demo but never feed scoring. Scoring reads only seeded
outcomes via listSeedOutcomesByAgentRound. Default-off => arc byte-identical
(golden test unchanged).

- argv-only spawn (no shell), minimal child env (credential sole-custody),
  timeout + output cap, ALLOW/CLIP-only, silent seed fallback, idempotency
  by intent_hash; mainnet refused unless explicitly opted in
- VERIFY V3 resolved; [CORE] open/close/modify TP-SL + read PnL/positions;
  limit orders [ROADMAP]
- unit + fuzz + e2e + DB-gated integration; docs/byreal-rail.md

68 non-DB byreal tests green; typecheck + lint clean; no regressions.
…y, observability

Security-audit follow-ups on the P2.1 Byreal rail. Each is a real risk; false
positives (shell injection, credential leak, prototype pollution, ReDoS, the
mainnet construction guard, determinism/scoring isolation) were verified safe and
left unchanged.

- cli.ts: the SIGKILL escalation timer was cleared by finish() in the same
  synchronous turn it was set, so a CLI that ignores SIGTERM would never be
  force-killed (zombie/fd leak). Clear the timer only on `close`; unref it.
- cli.ts: forward BYREAL_PERPS_NETWORK to the child as defense-in-depth so the
  subprocess is pinned to the validated network (load-bearing guard stays the
  construction-time mainnet refusal).
- parse.ts: clamp a negative fee (venue rebate) to '0' — outcomes.fees is
  CHECK (fees >= 0), so a rebate would abort the insert and silently drop a real
  fill. Canonicalize '-0' and render JS-number fees as fixed-point (never
  exponent). Report a no-fill/no-resting order as 'sent', not 'filled'.
- command.ts: require strictly-positive size/tp/sl, so no numeric can ever land
  in a positional argv slot looking like a flag (defense-in-depth).
- orchestrator.ts: settleCredibility still degrades silently (arc must not
  stall) but now emits an operator signal (name only) so a failing rail or DB
  write is observable instead of vanishing.

Regression tests added for each; typecheck + lint clean; 690 unit tests pass.
…a real DB

Running the full suite against a live Neon Postgres + the public Mantle Sepolia
RPC (the DB/RPC-gated tiers had never been exercised without creds) surfaced
three pre-existing test bugs. All three are test-side; the production code paths
they cover are validated and unchanged.

- byreal.integration: the determinism-boundary assertion queried `scores.value`,
  but the column is `score_r` (migration 0001). The query threw 42703 every run,
  so the load-bearing claim — enabling the live rail leaves scores byte-identical
  — had never actually executed. Fixed to `score_r`; it now runs and passes.
- read-api.e2e: the MockPool returned rows without the microsecond `cursor_t`
  alias the keyset design selects (CURSOR_KEY_SQL), so `paginate` minted an
  undefined cursor and the route 500'd. The real repo+route are correct and the
  integration suite covers the same pagination against real Postgres. Mock now
  mirrors the contract (cursor_t derived from created_at).
- referee.e2e: an astronomically large size (>20 integer digits) is now rejected
  by the storability bound (numeric(38,18)) before it can reach the size_cap
  clip — intended behavior the test predated. Split into two cases: a large but
  storable size CLIPs to the cap; an unstorable size REJECTs at the gate.

Verification: unit 690, fuzz 58, integration 46, e2e 45, on-chain read 9 — all
pass, 0 fail, lint + typecheck clean.
The suite injected a fake giveFeedback/receipt client because a funded testnet
wallet was out of band, so the real on-chain WRITE path (P1.8 task 3) had never
executed. This adds a gated e2e that drives the production clients end to end
against the live registries: register a fresh agent (operator key) ->
assertCanAttest against the live Identity Registry -> giveFeedback (attestor
key) -> wait for the receipt -> read the feedback back and assert the on-chain
bytes (value 73, valueDecimals 0, tag1, tag2, isRevoked) match what was written.

Gated on a funded operator + a *distinct* funded attestor key (+ RPC), so CI
without testnet funds skips cleanly and stays green.

A read-after-write barrier (waitFor) tolerates the public RPC load-balancing
reads to replicas a block or two behind head; without it an immediate ownerOf
after register can miss the mint. This is a test-only concern — production
registers agents at seed time, long before any per-round attestation, and
assertCanAttest fails safe (typed throw, no tx) if a read ever did lag.

Verified: passes twice (non-flaky) against live Mantle Sepolia (chain 5003),
typecheck + lint clean, and skips when keys are absent.
Add lib/rail/byreal/* - a safe CLI shell-out that settles already-allowed
Intents on the Byreal/Hyperliquid testnet venue and maps fills/PnL onto
executions/outcomes. Stage-2 credibility layer.

Determinism boundary (sec.3): real Byreal outcomes are an opt-in side-channel
(RunArcOptions.credibilityRail) written as executions/outcomes(rail='byreal');
shown alongside the demo but never feed scoring. Scoring reads only seeded
outcomes via listSeedOutcomesByAgentRound. Default-off => arc byte-identical
(golden test unchanged).

- argv-only spawn (no shell), minimal child env (credential sole-custody),
  timeout + output cap, ALLOW/CLIP-only, silent seed fallback, idempotency
  by intent_hash; mainnet refused unless explicitly opted in
- VERIFY V3 resolved; [CORE] open/close/modify TP-SL + read PnL/positions;
  limit orders [ROADMAP]
- unit + fuzz + e2e + DB-gated integration; docs/byreal-rail.md

68 non-DB byreal tests green; typecheck + lint clean; no regressions.
…y, observability

Security-audit follow-ups on the P2.1 Byreal rail. Each is a real risk; false
positives (shell injection, credential leak, prototype pollution, ReDoS, the
mainnet construction guard, determinism/scoring isolation) were verified safe and
left unchanged.

- cli.ts: the SIGKILL escalation timer was cleared by finish() in the same
  synchronous turn it was set, so a CLI that ignores SIGTERM would never be
  force-killed (zombie/fd leak). Clear the timer only on `close`; unref it.
- cli.ts: forward BYREAL_PERPS_NETWORK to the child as defense-in-depth so the
  subprocess is pinned to the validated network (load-bearing guard stays the
  construction-time mainnet refusal).
- parse.ts: clamp a negative fee (venue rebate) to '0' — outcomes.fees is
  CHECK (fees >= 0), so a rebate would abort the insert and silently drop a real
  fill. Canonicalize '-0' and render JS-number fees as fixed-point (never
  exponent). Report a no-fill/no-resting order as 'sent', not 'filled'.
- command.ts: require strictly-positive size/tp/sl, so no numeric can ever land
  in a positional argv slot looking like a flag (defense-in-depth).
- orchestrator.ts: settleCredibility still degrades silently (arc must not
  stall) but now emits an operator signal (name only) so a failing rail or DB
  write is observable instead of vanishing.

Regression tests added for each; typecheck + lint clean; 690 unit tests pass.
…ores schema

Split from a mixed real-DB test-fix commit: the byreal integration test asserted
on a non-existent `scores.value` column (should be `score_r`), so the
determinism-boundary assertion had never actually executed against Postgres.
The suite injected a fake giveFeedback/receipt client because a funded testnet
wallet was out of band, so the real on-chain WRITE path (P1.8 task 3) had never
executed. This adds a gated e2e that drives the production clients end to end
against the live registries: register a fresh agent (operator key) ->
assertCanAttest against the live Identity Registry -> giveFeedback (attestor
key) -> wait for the receipt -> read the feedback back and assert the on-chain
bytes (value 73, valueDecimals 0, tag1, tag2, isRevoked) match what was written.

Gated on a funded operator + a *distinct* funded attestor key (+ RPC), so CI
without testnet funds skips cleanly and stays green.

A read-after-write barrier (waitFor) tolerates the public RPC load-balancing
reads to replicas a block or two behind head; without it an immediate ownerOf
after register can miss the mint. This is a test-only concern — production
registers agents at seed time, long before any per-round attestation, and
assertCanAttest fails safe (typed throw, no tx) if a read ever did lag.

Verified: passes twice (non-flaky) against live Mantle Sepolia (chain 5003),
typecheck + lint clean, and skips when keys are absent.
Split from a mixed real-DB test-fix commit (the byreal-specific hunk lives with
the rail feature). Both are test-side only and fail on main today:
- read-api.e2e: the MockPool lacked the microsecond `cursor_t` keyset alias, so
  the keyset-pagination path 500'd instead of exercising the cursor.
- referee.e2e: split the size-magnitude case into a storable-over-cap value
  (clips to the size cap) and an unstorable 26-digit value (rejected at the
  numeric(38,18) storability bound), which the single combined case conflated.
… + env docs

Three issues surfaced by an automated review swarm over the attestation/chain
write path (0 critical; these are the actionable correctness/ops items):

1. submit.ts `toInt128`: the stored `value` column (numeric(39,0)) is wider than
   the registry's `int128 value` argument, but the parse only checked the integer
   grammar — an out-of-range value (corrupt/hand-edited row, or any non-scorer
   writer) would reach the writer and fail as a cryptic ABI-encode throw after
   precondition checks. Range-check it at the chain-write boundary so it is a
   deterministic typed error before any gas is spent. Reuses INT128_MIN/MAX from
   encode.ts. Regression test added (fails closed, zero writes).

2. client.ts self-feedback guard was dead code: `ATTESTOR_PRIVATE_KEY` is
   documented as "distinctness enforced at the client (assertDistinctSigners)",
   but assertDistinctSigners was never called, so a shared operator/attestor key
   would slip through to a guaranteed on-chain revert. Wire it into
   getAttestorAccount — the single entry point to attestor signing — so feedback
   writes fail closed on the misconfig. (The happy path is exercised by the live
   write-path e2e; the negative path is covered by the pure assertDistinctSignerKeys
   unit tests. A dedicated wiring test was intentionally omitted: ENV is a single
   eager process-global, so an in-suite negative test is order-flaky and a
   module mock would contaminate sibling files — not worth the fragility.)

3. .env.example was missing every P2.1 / write-path env var (BYREAL_PERPS_*,
   PUBLIC_BASE_URL) — a deployment footgun. Documented with their semantics.

Verified: typecheck + lint clean; unit+fuzz 749 pass / 0 fail; integration +
e2e green against the throwaway-schema DB and public RPC.
Live perps execution rail behind the deterministic seed: default-OFF, fails
closed (no creds => byte-identical seed scores), no-shell CLI invocation,
hardened subprocess/parse/observability, and a determinism-boundary integration
test verified against real Postgres.
Test-side only (both failed on main): keyset-pagination MockPool cursor alias
and the referee size-magnitude clip-vs-reject split.
…ardening

Gated live write-path e2e (register -> assertCanAttest -> giveFeedback ->
receipt -> read-back) against Mantle Sepolia, plus three audit fixes: int128
range-check at the chain-write boundary, wiring the previously-dead
self-feedback guard into attestor signing, and documenting the new env vars.
P2.1: Byreal Perps CLI rail adapter
Implement a read-only Nansen Smart Money `netflows` signal injected into the
leader's `context.signals.nansen`, behind a TTL cache + slow poller.

- lib/signals/nansen: pure injectable client (typed errors, defensive zod
  parse, bounded body/rows, AbortController timeout), caching/slow-polling
  provider (doubly-gated refresh, in-flight dedup, credit budget, fail-open),
  server-only key loader (returns null when NANSEN_API_KEY unset), barrel.
- lib/replay: nansenSignalsFor() leader-only injection helper; runArc gains an
  opt-in `nansen` provider, polled fire-and-forget per tick (never awaited).
- Invariants: tick never blocks on the network; signal is read-only and cannot
  reach execution; default-off keeps the arc byte-identical.
- Tests: unit (client/provider/inject), fuzz (client + provider), e2e
  (byte-identical signed arc with/without signal), integration (DB arc
  invariance under a flapping provider + NANSEN_API_KEY-gated live call).
- docs/nansen-signal.md.
…dently

`RunArcResult.finalAllocations` is the *set* of per-agent end-state
allocations. The persisted read (`listAllocationsByRound`) orders by
`created_at`, which is not a total order — rows inserted in one routing pass
can tie, so the row order is DB-arbitrary and may differ run-to-run. The
invariance assertion therefore must compare order-independently (same spirit
as the adjacent `crashedAgentIds.sort()`), otherwise it flakes against real
Neon while the actual allocations are identical.
A read-only, fail-open swarm audit surfaced a handful of genuinely reachable
issues (the rest were false positives). Fix only the real ones, smallest change:

client.ts
- Timeout now spans the whole round-trip (connect AND body read). Previously the
  abort timer was cleared once headers arrived, so a slow-drip body could hang
  the fetch forever and pin the provider's in-flight dedup, silently disabling
  all future refreshes. One try now covers fetch+parse; typed errors pass
  through, any abort maps to NansenTimeoutError.
- redirect: 'error' — a single-endpoint credentialed call must never follow a
  3xx that could replay the `apiKey` header to another origin.
- Cap raw rows *scanned* (MAX_ROWS_SCANNED) independently of output, so a 2 MiB
  array of unusable rows can't run hundreds of thousands of zod parses and stall
  the event loop.
- Response size bound measured in UTF-8 bytes (Buffer.byteLength), not UTF-16
  code units, so the 2 MiB cap holds for multi-byte payloads.
- Retry-After parsed via a clamped helper (reject non-finite/negative, cap at
  5 min) so a hostile header can't inject a negative/multi-year delay.

provider.ts
- Isolate the caller-supplied logger behind a `log()` wrapper. A throwing
  observability sink could previously reject the detached fetch (process-killing
  unhandled rejection) or throw synchronously into the tick via the budget path.
  Logging is now best-effort and never load-bearing; the fetch proceeds and
  fail-open holds regardless of sink health.

constants.schema.ts
- Nansen endpoint is now https-only (httpsUrl): the API key rides in a header,
  so a cleartext override must fail at config load.

Tests: +6 regressions (redirect, slow-drip body timeout, scan cap, Retry-After
clamp, throwing-logger fault isolation x2). Full unit 723 pass; nansen
fuzz/e2e/integration green; lint/typecheck/prettier clean.
feat(signals): P2.2 Nansen smart-money signal (read-only, fail-open)
harden(signals): P2.2 Nansen security audit fixes
Pre-existing formatting drift in the byreal rail modules and their
e2e/integration/unit tests left `bun run format:check` red on main.
Pure formatting (no logic change); brings the documented format gate green.

- lib/rail/byreal/{adapter,index,parse}.ts
- tests/{e2e,integration,unit}/byreal.* + attestation.live-write.e2e
`listAllocationsByRound` and `listPolicyEventsByAgentRound` ordered only by
`created_at`, leaving rows that share a timestamp in an engine-defined order —
a latent nondeterminism in the determinism-critical pipeline. Add the `id ASC`
tiebreaker, matching the established pattern in intents/outcomes repos.

Regression test (real Neon, isolated schema) inserts two sibling rows with an
identical created_at, larger id first; it fails on the tiebreak-less query and
passes with the fix.

- lib/db/repos/capital-allocations.ts: ORDER BY created_at ASC, id ASC
- lib/db/repos/policy-events.ts: ORDER BY created_at ASC, id ASC
- tests/integration/repos-order.integration.test.ts (new)
fix(db): total order on per-round reads
ENV is validated eagerly when API route modules are collected at build time,
so a build without DATABASE_URL fails fast. Document it in the Scripts section
so a cold checkout knows to set the variable before building.
docs: note next build requires DATABASE_URL
The header still described "Stage P0.1 — foundation only (scoring, referee and
on-chain writes land in later stages)", which materially understated the project:
referee, scoring, router, ERC-8004 integration, attestation, the Byreal rail and
the Nansen signal are all implemented and tested.

- Replace the stale P0.1 note with the real pipeline diagram and a one-paragraph
  description of each engine (referee / AgentScore / capital router).
- Add an "On Mantle (on-chain)" section: canonical ERC-8004 registry addresses on
  Mantle Sepolia, the msg.sender authorization note, and the Byreal/Nansen
  side-channel determinism boundary.
- Add a "Demo — the 90-second arc" section pointing at runArc(db, DEMO_ARC).
- Expand the Docs index to surface demo-spine / referee / scoring / router /
  erc8004 / byreal / nansen.

Docs-only; no code or behavior change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant