Skip to content

release: promote dev to main - #1038

Merged
0xDeon merged 94 commits into
mainfrom
release/dev-to-main
Aug 10, 2026
Merged

release: promote dev to main#1038
0xDeon merged 94 commits into
mainfrom
release/dev-to-main

Conversation

@0xDeon

@0xDeon 0xDeon commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Promotes the current state of dev to main.

main had diverged by three commits, so this branch merges main into dev first (b511e7c) and the promotion applies cleanly. The resolved tree is byte-identical to dev.

The three main-only commits

Commit Content Resolution
1ad6852 — prompt-injection and output-safety guardrails (#875) guardrails.py, test_guardrails.py, router wiring Superseded — dev holds a stronger version, detail below
64e6e1b — remove internal audit/decision reports from repo root 4 markdown deletions Already absent on dev
689f0e3 — unblock Rust and Go pipelines go.mod, go.sum, Cargo.lock Superseded by later dependency work on dev

Conflict resolution

Nine files conflicted. Each was inspected individually, and all nine were the same shape — dev holds a later version of the same code, main holds the older one:

app/main.py                    request-ID validation and router set
app/models/recommendation.py   multilingual 'language' field (#789)
app/routers/analyze.py         language parameter threading
app/routers/chat.py            language parameter threading
app/routers/ws_chat.py         response preferences and guardrails wiring
app/services/guardrails.py     see below
app/services/prometheus.py     i18n, grounding and explainability imports
tests/test_guardrails.py       tests for the above
packages/contracts/Cargo.lock  fastrand entry

guardrails.py is the one that warranted real scrutiny, since main's copy came from #875 and could plausibly have been the newer one. It is not. main neutralises smuggled trust-boundary tags with a single-pass str.replace(). dev compiles case-insensitive patterns and loops to a fixed point, which additionally defeats overlapping fragments such as <user_mess<user_message>age> that survive a single pass. dev also adds validate_numeric_grounding() and the citation instructions. dev's version is a superset of main's.

No unique work on main is lost.

What ships

89 commits. The most recent are a run of CI and dependency fixes:

Two things worth knowing before merging

FastAPI 0.115.6 to 0.141.1 is a substantial framework upgrade. It is verified green by the full intelligence suite on #1023, and the consuming surface is small — one direct starlette import (BaseHTTPMiddleware) plus WebSocket, StreamingResponse and HTTPBearer, all stable public API across this range.

CodeQL flagged one HIGH alert on the previous promotion attempt: go/clear-text-logging at apps/api/cmd/api/main.go:1134, a startup log of Horizon URL, RPC URL and network passphrase name. It is pre-existing on dev, arriving via #966/#884 rather than any of the work above, and CodeQL itself notes that "alerts not introduced by this pull request might have been detected because the code changes were too large" — 89 commits is exactly that case. Flagging it rather than dismissing it: it is a judgement call for the reviewer, not something to wave through silently.

Merge method: this should land as a merge commit, not a squash. Squashing would collapse 89 commits into one and destroy the history on main.

0xDeon and others added 30 commits July 16, 2026 14:19
Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.

Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).
* test(contracts): add negative authorization coverage

* test: tighten negative authorization assertions

---------

Co-authored-by: Deon <110722148+0xDeon@users.noreply.github.com>
… cipher (#799)

* feat(crypto): version-tagged envelope cipher for account numbers

Replace the single-key AccountCipher with a multi-version AES-256-GCM
cipher. Encrypt seals with the active key and returns a CipherEnvelope
carrying the key version; Decrypt resolves the key by the ciphertext's
version and fails with ErrUnknownKeyVersion when it is not registered.
NewAccountCipher is retained (registers the key as v1) for backward
compatibility. Fingerprints use a stable pepper independent of the active
key so the uniqueness index survives rotation.

* test(crypto): cover active-key encrypt, cross-version decrypt, unknown version, fingerprint stability

* feat(config): add AccountCipherConfig accessor and versioned key set type

* feat(config): parse ACCOUNT_CIPHER_KEYS/ACTIVE_KEY with legacy single-key fallback

ACCOUNT_CIPHER_KEYS (comma-separated version:base64 pairs) plus
ACCOUNT_CIPHER_ACTIVE_KEY take precedence; when unset, the existing
BANK_ACCOUNT_ENCRYPTION_KEY is registered as v1 so single-key
deployments keep working. Validates active version membership and pair
format at startup.

* test(config): cover multi-key parsing, legacy fallback, and validation errors

* docs(config): document account cipher key set and rotation env vars in .env.example

* feat(db): add bank_accounts.key_version column defaulting existing rows to v1

New column records which key sealed each row so rotation never rewrites
history. Indexed so the rotation tool can cheaply find un-rotated rows.

* feat(db): down migration dropping key_version column and index

* feat(bankaccount): thread key version through Repository Create/GetByID

* feat(repo): persist key_version on bank account insert

* feat(repo): return key_version from GetByID and add rotation Store methods

GetByID now yields the stored key version so callers can decrypt with the
right key. CountPending/ScanPending/UpdateCipher implement rotation.Store
for the rotation tool; UpdateCipher leaves the fingerprint untouched so the
uniqueness index is unaffected.

* feat(rotation): idempotent, resumable batch key-rotation engine

Rotator scans rows not on the active key version, decrypts each with its
recorded version, re-seals with the active key, and commits per row. A
second run finds nothing (idempotent); an interrupted run resumes from the
remainder. Logs only counts and row IDs, never plaintext, keys, or
ciphertext.

* test(rotation): re-encrypt to active, idempotency, resume-after-interrupt, no data loss

* feat(service): seal new accounts with the active key envelope

* feat(service): decrypt saved accounts by their stored key version

ResolveForSettlement reconstructs the CipherEnvelope from the stored
ciphertext and key version; SetDefault/Remove absorb the extra GetByID
return value.

* test(service): update in-memory repo mock for key-versioned signatures

* test(service): new writes use active key; legacy row decrypts after key added

* feat(cmd): rotate_keys CLI to re-encrypt accounts onto the active key

Loads the same key config as the API, refuses to run when no cipher is
configured, and drives the rotation engine over the bank_accounts store
with -batch-size and -timeout flags.

* feat(api): wire the multi-key account cipher from AccountCipherConfig

* docs(security): key versioning model, env format, and 5-step rotation runbook

* fix(crypto): require explicit fingerprint pepper when no v1 key is configured

Defaulting the fingerprint pepper to the active key let it change on every
rotation (e.g. v2->v3) and silently break blind-index uniqueness. Fail closed
with ErrFingerprintKeyRequired instead; cover the no-v1 active-key rotation
case. (CodeRabbit)

* fix(config): fail closed on empty keyset, over-long versions, and v1-less sets

- ACCOUNT_CIPHER_KEYS that parses to zero entries now errors instead of
  silently disabling the cipher, and an active version absent from the set is
  always rejected.
- Reject key versions longer than 32 chars (bank_accounts.key_version is
  VARCHAR(32)) before they fail at the DB boundary.
- Require ACCOUNT_CIPHER_FINGERPRINT_KEY when the key set has no v1. (CodeRabbit)

* fix(db): guard key_version rollback and build its index concurrently

- 057 down aborts if any row is on a non-v1 key, since dropping key_version
  would make rotated ciphertext undecryptable.
- Move the index into 058 using CREATE INDEX CONCURRENTLY so a large
  bank_accounts table is not write-locked during deploy. (CodeRabbit)

* docs: clarify that a v1-less key set must set an explicit fingerprint pepper (CodeRabbit)

* test(service): consolidate key-versioning scenarios into a table-driven test (CodeRabbit)

* docs: clarify no active-key fallback; config-load failure vs constructor sentinel (CodeRabbit)
* feat(api): add distributed rate limiting with strict route limits

Extend the existing in-memory rate limiter with a dual-mode backend: a
Redis fixed-window counter for cross-instance enforcement, falling back
to the in-memory token bucket when REDIS_ADDR is unset.

- Add Limiter interface + NewLimiter factory (Redis or in-memory)
- Global per-IP limiter now excludes /health*, /readyz, /metrics
- Strict per-IP limiter on POST /auth/challenge and /auth/verify
  (credential stuffing) and strict per-user limiter on
  POST /settlements (settlement spam)
- New RATELIMIT_AUTH_* and RATELIMIT_SETTLEMENT_* config knobs + .env
- Redis limiter fails open on outage so it never blocks live traffic
- Table-driven tests: under/over limit, 429 + Retry-After, window
  reset, per-IP and per-user isolation, memory fallback, and a
  Redis integration test guarded by REDIS_ADDR

* fix(api): address CodeRabbit review on rate limiting

- CORS: move cors middleware outermost so 429 responses from the global
  and auth-route limiters still carry Access-Control-Allow-Origin and
  stay readable to browser clients
- Redis: bound each limiter round-trip with a 75ms timeout so a slow
  (not just down) Redis fails fast into fail-open instead of adding
  multi-second latency to every request; log fail-open events
- Proxy-aware client IP: add RATELIMIT_TRUSTED_PROXY_COUNT (default 0).
  When set, derive the client IP from X-Forwarded-For counting hops from
  the right, so traffic behind a load balancer keys off the real client
  instead of collapsing onto the proxy address, without letting clients
  spoof past the trusted-proxy boundary
- Tests: proxy-aware keying + spoof resistance, and config default /
  override / negative-validation for the new knob

* fix(api): reject sub-millisecond rate-limit windows

The Redis limiter converts the window to whole milliseconds for PEXPIRE,
so a positive but sub-1ms window (e.g. 500us) truncates to 0, expiring
the counter immediately and silently disabling enforcement. Reject
global/auth/settlement windows below 1ms at config load, with a
regression test.
…olio valuation, RAG grounding (#824 #845 #832 #852) (#876)

* feat(api): durable async job queue (#824)

PostgreSQL-backed job queue with FOR UPDATE SKIP LOCKED dequeue, lease-based
visibility timeout with crash recovery, exponential backoff + full jitter,
dead-letter queue, per-job-type concurrency limits, idempotent enqueue, and
graceful drain on shutdown. Queue-depth/DLQ/latency metrics and correlation-ID
propagation included. Worker pool wired into the API with config knobs.

* feat(api): yield harvest orchestration engine (#845)

Cadence + event-triggered engine that applies the economic gate (harvest iff
accrued yield > gas fee + margin), defers under network congestion, and submits
harvests as idempotent, window-deduplicated jobs on the #824 queue. Includes a
gas oracle abstraction, vault/user/service adapters, an idempotent job handler,
and an owner-scoped harvest-status API (pending yield, threshold, estimated next
harvest). Pure decision core and engine fully unit-tested.

* feat(api): real-time portfolio valuation service (#832)

Stroop-exact aggregation of positions, pending deposits, accrued yield, goal
allocations, and claimable rewards with a structured per-vault/per-goal
breakdown (principal vs yield, locked vs flexible, settled vs pending,
claimable). Multi-asset oracle pricing with confidence propagation, per-user
cache with event-driven invalidation on confirmed transactions, and WebSocket
push of refreshed valuations. Pure aggregator, cache, and service unit-tested.

* feat(intelligence): RAG grounding for Prometheus AI (#852)

Structured retrieval layer that routes queries to the right user-scoped data
sources (positions, goals, transactions, yield landscape) without embeddings,
assembling only the minimal context needed with citations. Grounding rules
force the model to answer solely from retrieved context, cite it, and refuse
when data is missing; post-generation numeric validation flags any figure not
present in the context to catch hallucinations. Strict per-user isolation: scope
is fixed by the JWT subject and cannot be widened by prompt injection. Wired into
streaming chat (and WebSocket chat via the shared path). Fully unit-tested.
…eld cache warming (#874)

* fix(savingsgoal): soft-archive goals on DELETE instead of hard-delete (#685)
Replace the permanent DELETE with an UPDATE that stamps archived_at and
sets status to 'archived'. Adds migration 059 to introduce the
archived_at column. Already-archived goals surface as ErrGoalNotFound
(404) so callers get a sensible response without a 500. Adds two unit
tests via sqlmock asserting the soft-delete and already-archived cases.

* feat(yield): warm DeFiLlama Stellar cache on service startup (#667)

* fix(savingsgoal): validate target_amount and goal name (#692 #681)
#692 — Add savingsgoal.ErrInvalidAmount (defined in the savingsgoal
domain, not imported from vault) and update validateSavingsGoalInput to
return it when target_amount is zero, negative, or below MinTargetAmount
(0.01). Handler writeError now maps ErrInvalidAmount to 400 Bad Request,
fixing the 500 that was returned when vault.ErrInvalidAmount was not
recognised.
#681 — Add validateGoalName capping name at MaxGoalNameLength (100
chars) consistent with the savings_goals.name column width. Called on
both Create and Update paths so over-long names return a 400 instead of
a DB error.

* test(savingsgoal): cover amount and name validation cases (#692 #681)
…811) (#879)

Introduce shared temporary-storage reentrancy guards and callee allowlists in libs/common, apply them across vault, treasury, and allocation strategy fund-moving paths, and add hostile mocks with adversarial integration tests plus documented resource costs.
#872)

* chore(security): fix IDOR vulnerabilities and harden JWT configuration

Addresses highest-priority findings from security assessment (Issue #589):

Fixed:
- Added ownership validation for vault retrieval endpoints (GET /vaults/{id}, GET /vaults/{id}/allocations)
- Added ownership validation for transaction creation (POST /transactions)
- Added ownership validation for transaction retrieval (GET /transactions/{hash})
- Hardened Intelligence service by preventing production startup without JWT secret via Pydantic model_validator

Documentation:
- Added docs/security/threat-model.md (assets, trust boundaries, entry points, threat actors, controls)
- Added docs/security/pentest-report-v1.md (11 findings with evidence, impact, root cause, remediation, verification)

Authorization was implemented at the handler level rather than the service layer because the service methods are shared by numerous trusted internal system components (scheduler, rebalance, TVL, projections). Refactoring those interfaces would have expanded scope considerably and increased regression risk.

Closes #589

* style: fix ruff line length in test_config.py

* fix: return 404 for missing vault in transaction ownership check

* chore: address CodeRabbit review comments

- Remove dead var _ = decimal.Zero
- Fix function name extractClientIP -> clientIP in evidence
- Update Go test result from manual to ALL PASS (CI confirmed)
- Correct deposit flow wording (price_per_share is not user-supplied)
- Fix WebSocket nil-authenticator mitigation claim (no nil check exists)
Two migrations shared the prefix 059. `059_create_jobs` landed first in
#876; `059_add_savings_goal_archived_at` landed eight minutes later in
#874 and collided with it.

The consequence was worse than a lint failure. golang-migrate refuses to
load a directory containing duplicate versions, so migrations could not
run at all past 058, and the migration-prefix guard in the API (Go)
workflow failed on every pull request touching the Go API — six open PRs
were red through no fault of their authors.

Renumber the later arrival to 060 and leave `059_create_jobs` in place,
since it merged first and is the version any environment already sitting
at 59 will have applied. Renumbering it instead would have desynced those
environments. `059_add_savings_goal_archived_at` has never been applied
anywhere, because the collision prevented golang-migrate from loading the
directory in the first place, so moving it is safe.

The only in-repo reference to either filename is in
job_repository_integration_test.go, which points at `059_create_jobs.up.sql`
and is unaffected.

Open PRs claiming 060 will need to rebase and renumber.
…ersioning (#882)

Implements four platform capabilities:

Feature flags (#838) — internal/flags/
- Boolean kill-switch, deterministic percentage rollout, cohort and
  typed-value flags stored in Postgres (migration 060)
- Percentage membership is hash-based and stable: a user in at 10% stays
  in at 20%
- In-process cache with TTL backstop and pub/sub invalidation channel so
  changes propagate across instances within seconds
- Kill switches fail SAFE: evaluator returns the registered safe position
  when the flag service is unreachable, never fail-open
- Secret guard rejects secret-marked names from the flag store
- Every change goes through a required AuditRecorder

Server-side exports (#839) — internal/export/
- Transaction-history CSV generated from the ledger source of truth with a
  stable, documented column schema
- Reconciliation invariant: exported movements must sum to the ledger's
  net change per asset or the export errors instead of delivering a wrong
  document
- Exports above a documented row threshold route to the durable job queue
- HMAC-signed, time-limited, ownership-verified download tokens; another
  user cannot fetch someone's export

Read-replica routing (#841) — internal/db/router.go
- Explicit Read/Write paths declared at call sites, never inferred from SQL
- Read-your-writes: users are pinned to the primary for a bounded window
  after writing (Pinner interface; in-memory impl, Redis-ready)
- Unhealthy or lag-exceeding replicas are routed around automatically with
  primary fallback; transactions always use the primary
- Per-role pool stats exposed for metrics; Close drains all pools

API versioning (#842) — internal/server/versioning.go, docs/api-versioning.md
- Uniform URL-path versioning with versioned route groups
- Deprecated versions emit Deprecation, Sunset and successor Link headers
  on every response; retired versions return 410 Gone with guidance
- Unversioned requests route to a pinned default, not 'latest'
- Per-version usage counting so retirement is data-driven

All packages fully unit-tested (33 tests).
…llocation strategies (#889)

Adds a deterministic, constraint-based yield optimizer to the intelligence
service (#848). Given candidate yield sources and hard constraints
(diversification cap, liquidity floor, lock-horizon fit, risk ceiling,
deposit caps, source status), app.services.yield_optimizer.optimize()
solves a concave-quadratic risk-adjusted-return objective with
scipy.optimize.minimize (SLSQP) and returns per-source weights (fraction
and basis points), expected yield, aggregate risk, and a diversification
index. Infeasible constraint sets are reported explicitly via
infeasibility_reasons and never silently relaxed.

The optimizer is a pure, synchronous, dependency-free function, kept
separate from app.services.yield_explanation, which has Claude narrate an
already-computed result in plain language and validates (via the existing
extract_numbers/normalize_number helpers from retrieval.py) that no number
absent from the result appears in the explanation, falling back to a
deterministic template otherwise.

Also wires a new POST /intelligence/yield-optimization endpoint, adds
scipy/numpy to requirements.txt, and fixes config.py's stale
anthropic_model default (claude-sonnet-4-6 -> claude-sonnet-5).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…d real-time balance endpoints (#891)

Co-authored-by: felladaniel36-hash <felladaniel36@gmail.com>
…nd jobs (#894)

Adds Postgres-advisory-lock leader election (internal/scheduler/leadership.go)
gating all five scheduler background job loops (rebalancer, recurring
deposits, APY deviation alerts, goal deadline reminders, protocol health
checks) so exactly one instance runs them at a time, with automatic failover
bounded by a 3s heartbeat interval and an execution-time leadership recheck
immediately before every money-moving/notification action to guard against
split-brain during failover.

Also fixes a real latent bug the recurring-deposit job had: its transaction
hash was derived from the schedule ID alone (constant across every
occurrence of a recurring schedule), so only a schedule's first-ever
occurrence could ever be recorded — every later occurrence hit
vault_transactions' unique transaction_hash constraint and retried forever.
The hash now folds in the occurrence timestamp, and the deposit-recording
step is routed through the existing durable job queue (internal/domain/
jobqueue) with a per-occurrence idempotency key, mirroring the harvest
engine's enqueue pattern, for at-least-once safety.

Wires the three job loops that existed but were never started in main.go
(the rebalance-decision Scheduler remains unwired pending real on-chain
RebalanceSubmitter/YieldFetcher adapters — a pre-existing gap, not a
regression), and exposes current leader/instance/since via a new
GET /api/v1/admin/scheduler/leadership endpoint.

Closes #846

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…i18n framework (#896)

Closes #112, #110, #790, #789

- Savings goal AI coaching (#112): on-demand GET /api/v1/users/savings-goals/{id}/coaching
  endpoint plus a weekly GoalCoachingScheduler background job, both backed by the
  existing intelligence /intelligence/coaching endpoint. Progress tracking, on-track
  status, and required-deposit math already existed in the savingsgoal domain/service;
  this closes the remaining AI-coaching gap in the issue.

- AI rebalancing engine (#110): new risk_model.py (Sharpe-ratio inspired
  risk_adjusted_score, per-protocol risk factors) and rebalance_engine.py in
  apps/intelligence, combining live DeFiLlama APY data with a cached-baseline
  fallback and Claude-generated rationale. New POST /vaults/{id}/rebalance/suggest
  and /execute endpoints (execute builds an unsigned Stellar transaction via
  stellar-sdk), proxied through the Go API at the same paths.

- PWA installability + offline handling (#790): web app manifest, hand-rolled
  service worker (app-shell precache, network-first navigation with an /offline
  fallback, API requests never cached), and an offline guard on the offramp
  withdraw action. The online/offline hook, banner, and deposit/withdraw guards
  already existed; this fills the manifest/SW/offline-route/offramp gaps.

- i18n framework (#789): locale provider + en/fr message catalogs + a shared
  formatCurrency/formatNumber/formatDate helper (Intl-based), wired into
  dashboard/savings/offramp/settings screens, replacing several ad-hoc
  formatCurrency implementations. Locale persists to localStorage and is
  selectable from Settings > Preferences.

Verified: go build/vet/test + golangci-lint clean; pytest (84 tests) + ruff +
mypy --strict clean; vitest (83 tests) + tsc --noEmit + eslint clean.

Co-authored-by: Chidimj <Chidimj@users.noreply.github.com>
…tory, referral program (#900)

Closes #820, #817, #816, #818

- access_control: granular Role enum (Guardian, Upgrader, Attester,
  FeeManager, RebalanceKeeper, Treasurer, VaultCreator), generalised
  two-step role transfer (transfer_role/accept_role/cancel_role_transfer),
  time-bounded grants (grant_role_until), bounded on-chain enumeration
  (get_role_members, role_expires_at). Guardian can pause/halt but never
  unpause/upgrade/withdraw.
- vault: autonomous staged circuit breaker (breaker.rs) with independently
  configurable trip conditions (share-price move, yield sanity, withdrawal
  velocity with anti-griefing margin, source failure), graded severity
  (Normal/Throttled/DepositsHalted/FullHalt), staged cooled-down recovery
  gated to Admin/Upgrader, and an emergency withdrawal path that works at
  every severity. Guardian-only pause/halt entrypoints added.
- vault_factory: new contract deploying vaults from a governed WASM hash
  via the Soroban deployer, atomic deploy+init, deterministic address
  prediction, O(1) is_nester_vault registry, bounded pagination, timelocked
  WASM-hash governance, deprecate_vault.
- referral: new standalone contract for a trustless referral program.
  Rewards accrue from the protocol's performance-fee slice (never the
  referred user's own yield), gated by minimum deposit/tenure, capped per
  referrer and by a global budget that halts accrual without clawback.
  Vault is the sole trusted caller, mirroring the existing
  treasury.receive_fees pattern.
- Narrower roles wired into treasury (Treasurer), yield_registry
  (Attester), and allocation_strategy/vault (RebalanceKeeper, FeeManager)
  alongside existing Admin/Operator checks.
- EVENTS.md, SECURITY.md, and the contracts README document the new role
  model, Guardian asymmetry, and breaker/factory/referral event surface.

All contracts build to wasm32-unknown-unknown; full workspace test suite
and clippy (-D warnings) pass clean.
* feat(intelligence): add sourced market context signals

* fix(intelligence): satisfy market context lint

* fix(intelligence): type extraction client boundary

* fix(intelligence): harden signal provenance and batching
* feat(security): add adaptive abuse protection

* fix(security): harden adaptive abuse state
…ed in user data (#897)

* feat(intelligence): personalized savings recommendation engine grounded in user data

Adds a savings recommendation engine (app/services/recommendation_engine.py)
that generates personalized, actionable recommendations from a user's real
goals, positions, and cash-flow behavior. Candidate actions (increase a
goal's contribution, move idle balance to higher yield, lock for a term
boost, consolidate goals toward the nearest deadline) and every number
attached to them are computed deterministically in Python -- Claude, called
via tool use, only selects 2-4 candidates, orders them, and writes a short
explanation, constrained to a `select_recommendations` tool schema that can
only reference candidates by id. A fabrication guard
(`_validate_selection`) checks every number in the model's prose against the
set of numbers the referenced candidate actually carries, rejects and
regenerates once on violation, then falls back to a fully templated
explanation built straight from the candidate's own fields -- so a fabricated
number can never reach the response.

Yield-related candidates always carry risk context (from the vault's real
risk score or a documented default disclosure); goal-success figures are
integrated with the #843 Monte Carlo simulation endpoint when reachable
(two calls -- current vs. proposed contribution -- diffed into a real
probability delta) and degrade to a documented heuristic otherwise, since
per-user (Redis-backed with an in-memory fallback, mirroring
conversation_store.py's pattern) and filtered out permanently; acted-on
action types get a deterministic priority boost. Recommendations are cached
per-user for 6 hours and invalidated automatically when goal/vault figures
change materially, rather than recomputed per page load. Fixes the stale
`claude-sonnet-4-6` model id.

Closes #847

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(intelligence): correct #843 simulation contract, mypy/ruff cleanup for #847

- projection_client.py: point ProjectionProvider at the real #843 endpoint
  contract (POST /api/v1/tools/simulation, goal_success.probability) now
  that it's known, instead of the placeholder GET route guessed before
  #843's shape was finalized. Computes the success-probability delta from
  two simulation calls (current vs required contribution) rather than
  guessing the Go service's internal sensitivity-grid step sizes.
- recommendation_engine.py: thread GoalContext into enrich_with_projections
  so it can build the simulation request; type the Anthropic tool-use call
  properly (ToolParam/ToolChoiceToolParam/MessageParam) instead of bare
  dicts, fixing mypy strict errors -- this is the first tool-use call in
  the intelligence service, so no prior typed precedent existed.
- ruff: import sort, drop pointless f-string prefixes, wrap one long line.

mypy --strict and ruff both clean; full suite still 105/105 passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: retrigger CI (no functional change)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…igests (#898)

Adds the Go-side data plumbing for #859's periodic digest: a digest_cadence
notification preference (off/weekly/monthly, opt-out respected), a
user_digests cache/audit table for one-generation-per-period, a
digest-ledger source endpoint exposing deterministic period deposit/yield/
streak facts for the intelligence service to narrate via the relay, and a
leader-elected daily scheduler job that generates and delivers digests
through the existing notification dispatcher.

This PR covers the Go backend groundwork only. The intelligence-service
narrative generation (grounded LLM prompt, zero-save honesty handling,
attention items, Redis caching), frontend insights card, and test coverage
described in #859's acceptance criteria are not yet implemented — tracked
as follow-up. #865, #864, and #856 are referenced per this repo's issue
numbering but have no implementation in this branch.

Note: this environment has no Go toolchain available, so these changes are
reviewed manually but not compiled or test-run locally.
…e rebalance, penalty escrow (#901)

Implements four vault contract features plus their backend indexing:

- #814: fair-ordering emergency withdrawal queue (queue.rs) so paused-vault
  exits are served in request order instead of first-caller-wins.
- #813: duration/size-tiered fee schedule (performance, exit, management)
  replacing the flat-rate config, with a continuous tenure curve superseding
  the old binary min-lock gate.
- #810: slippage-safe multi-hop rebalance split into plan/execute steps
  (rebalance.rs) with per-leg minimum-out enforcement.
- #805: early-exit penalty escrow with depositor/treasury split distribution
  instead of penalties vanishing into thin air.

Backend: migrations for the four new event-sourced tables, Stellar event
indexer wiring for all seven new on-chain events (including the previously
unhandled rebalance-completed event), and read-only history endpoints under
/api/v1/vaults/{id}/.

Co-authored-by: dslegacy <dslegacy@users.noreply.github.com>
…ation (#903)

* feat(contracts): on-chain savings goal registry with milestone attestation

Adds a savings_goal Soroban contract recording goal ownership, target,
deadline, and progress trustlessly, with an idempotent 25/50/75/100%
milestone bitmask and bounded multi-contributor accounting. The registry
never custodies funds — only the vault does. Vaults are validated against
the deployed vault_factory at goal creation.

Backend: onchain_goal_id/onchain_status columns and model fields, repo
read/write wiring, and a bitmask<->milestone translation helper aligned
with the contract's semantics so the existing notifier can treat an
on-chain attestation as equivalent to a notified milestone.

* fix: renumber duplicate/colliding migrations to match landed dev sequence
Co-authored-by: opascal221-design <opascal221-design@users.noreply.github.com>
…-position support

Add rich progress visualization for savings goals and vaults:
- Segmented progress bar distinguishing locked vs flexible portions
- Principal vs earned yield composition breakdown
- Maturity timeline for locked positions with boost badges and unlock dates
- Probabilistic projection band (confidence interval + success probability)
- Constructive at-risk messaging when goal is off track
- Multi-asset vault composition donut (reuses existing recharts pattern)
- Celebration/completion state, encouraging empty state
- Respects reduced-motion preferences via existing useReducedMotion hook
- All states: empty, in-progress-with-locks, at-risk, completed
- Backward compatible: falls back to simple progress bar when rich data absent

Closes #869
Co-authored-by: opascal221-design <opascal221-design@users.noreply.github.com>
- Remove unused startKYC variable from rotate_keys/main.go (go vet error).
- Remove unused time import from backfill_kyc_encryption/main.go (go vet error).
Adds DB-level uniqueness on (protocol_slug, captured_at) via migration
069, and updates Upsert to use an explicit ON CONFLICT target so duplicate
oracle reports for the same protocol+timestamp are silently ignored.
…ist search (#967)

* fix(ci): unblock Rust and Go pipelines

Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.

Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).

* chore: remove internal audit and decision report files from repo root

* feat(intelligence): add prompt-injection and output-safety guardrails for chat/analyze (#875)

* feat(intelligence): add prompt-injection and output-safety guardrails

Claude calls in the chat and analyze paths had no defense against prompt
injection or system-prompt extraction, and recommendation output wasn't
schema-enforced. Add input screening (regex-based, logs request_id + a
non-reversible fingerprint, never raw content), a hardened system prompt
with an explicit trust boundary and tagged untrusted-content wrapping,
deterministic history/message bounding, and output post-processing that
strips leaked system-prompt text and enforces a non-model-controlled
disclaimer on /analyze and related endpoints.

* fix(intelligence): close remaining guardrail gaps from review

- Validate inbound X-Request-Id against a bounded safe charset before
  trusting it in state/headers/logs, falling back to a fresh UUID
  otherwise (prevents log/header injection via a client-supplied header).
- Fix the chat streaming leak-redaction buffer to retain a sanitized
  lookback tail on flush instead of resetting to empty, so a
  system-prompt marker split across two deltas is still caught.
- Wrap the remaining unwrapped context data interpolated into the
  recommend/vault and analyze prompts (positions, vault/user context
  lines) in the same trust-boundary tags used elsewhere.
- Sanitize the few model-derived output fields that were missed:
  confidence_reason/data_freshness in Recommendation, insight card
  action.label/href, and deposit schedule note.

* feat(api/pkg): add keyset cursor and list query grammar parsing

* feat(api/vault): add full-text search and advanced list filtering

* feat(api/settlement): implement memo search and filter updates

* feat(api/savingsgoal): implement search, list filters and repository updates

* feat(api/activity): introduce activity domain, repository, handlers and filter

* feat(dapp/history): update history page to support list filtering and search

* feat(db): add migration for session family rotation and tracking

* feat(api): implement session domain models, repositories, and config

* feat(api): add auth services for token rotation, revocation, and anomaly detection

* feat(api): add session-aware auth middleware, HTTP handlers, and wire main API

* feat(ws): disconnect active WebSocket connections on session revocation

* feat(frontend): implement automatic token refresh and auth provider state

* feat(frontend): add active sessions UI management in settings

* ci: add concurrency groups with cancel-in-progress across workflows

* test(api/savingsgoal): implement ListPaginated mock in template handler tests

* db(migrations): add schemas for user timezone, activity events, nudge log, and preferences

Add DB migration files:
- 057: User timezone column on users table
- 058: Activity events table for tracking user logins and interactions
- 059: Nudge dispatch log table for dispatch history and outcome tracking
- 060: Nudges enabled preference flag

* feat(domain): define smart nudge catalog, user signals, anti-fatigue rules, and intelligence DTOs

Introduce core domain primitives for smart savings nudges:
- Nudge catalog, trigger condition evaluation, priority ranking, and anti-fatigue limits
- User activity, engagement heuristics, responsive timing window, and user segmentation
- Intelligence request/response DTOs for AI copy generation
- User model update for timezone preferences

* feat(repo): add data access for user timezones, activity events, nudge history, and goals

Implement Postgres repository methods for:
- User profile updates supporting timezone
- Recording and querying user activity events
- Logging nudge dispatches, checking anti-fatigue thresholds, and tracking conversion outcomes
- Fetching active savings goals for nudge evaluation

* feat(intelligence): add AI nudge copy generation endpoint with numeric grounding guardrails

Add FastAPI endpoint and AI services for dynamic push copy generation:
- Generate personalized nudge copy via Anthropic Claude model integration
- Validate numeric grounding in guardrails to prevent hallucinated currency figures
- Register /intelligence/nudges route in main FastAPI application

* feat(service): implement nudge engine orchestration, copy generation, and outcome tracking

Add core service logic for smart savings nudges:
- Composite copy generator (static templates fallback + LLM generated copy)
- Prometheus client method for generating nudge copy
- Nudge notifier adapter and milestone-to-nudge milestone mapper
- Nudge outcome service for recording deposits, goal completions, and return visits
- Core NudgeEngineService evaluating rules, user signals, ranking, and anti-fatigue limits
- Register EventSavingsNudge in notifications package

* feat(auth,savings): integrate timezone capture, activity tracking, and nudge outcome hooks

Hook user actions into nudge signals and outcome tracking:
- Return userID from Auth.VerifyAndIssue to record user timezone, login activity event, and return visit outcome
- Attach OutcomeRecorder to SavingsGoalService to track goal completion outcomes

* feat(scheduler,cmd): replace legacy reminder job with periodic nudge engine and wire main app

Wire up the smart savings nudge engine:
- Replace legacy goal deadline reminder job with background NudgeEngineJob
- Initialize repositories, services, and nudge notification dispatcher in main.go
- Trigger nudge evaluation and outcome tracking on completed transaction deposits

* refactor(api): extract audit entry model to domain layer to prevent import cycle

* feat(api): add jti claim to access tokens for unique token minting

* db(migrations): add 057_create_tool_invocations for tool audit logging

* feat(api): add tool audit domain, repository, service, handlers, and proxy routes

* feat(intelligence): implement Prometheus tool execution loop, tool registry, cost governor, and audit client

* feat(dapp): add interactive tool confirmation flow to Prometheus chatbot UI

* style: format code and sort imports across Python intelligence service and Go test files

* fix(api): add timezone field to UpdateProfileInput in UserService

Extend UpdateProfileInput struct with Timezone field to enable clean profile updates from auth handler during wallet verification.

* test(api): add unit test coverage for nudge rules, signals, and outcome recording

Add unit tests covering:
- Anti-fatigue cooldown limits and cap checks
- Static copy template formatting and facts mapping
- Priority scoring and ranking for candidate nudges
- Responsive window signal calculations
- Nudge outcome recorder (deposit, goal completion, return visit tracking)

* fix(intelligence): enforce strict numeric grounding on percentage values and add tests

Update validate_numeric_grounding guardrail:
- Treat percentage values (e.g., '8%') as fact-grounded regardless of digit count to prevent APY mismatches
- Add unit test suite for numeric grounding validation across dollar amounts, Naira figures, percentages, and prose integers

* feat(api): migrate refresh tokens to httpOnly secure cookies

* feat(frontend): adapt API client and auth store for httpOnly refresh cookies

* fix(intelligence): harden input screening against nested boundary tags

* fix(cmd,usersignal): fix vault lookup method in txPoller and remove unused import

Fix vault lookup in main.go transaction poller callback from GetByID to GetVault, and remove unused time import from usersignal interfaces.

* style(intelligence): format Python nudge models, router, and services

Clean up import order and apply ruff/black formatting across Python intelligence service nudge endpoints and functions.

* style: format code and sort imports across Python intelligence service and Go test files

* refactor(api/migrations): renumber search & activity migrations to 061-064

* refactor(intelligence): add strict type hints and defensive checks to tool handlers

* test(api): update auth_service_test for 3-tuple return from VerifyAndIssue

Update unit test assertions in auth_service_test.go to match the updated VerifyAndIssue signature returning (token, userID, err).

* style(intelligence): format Pydantic schema in nudge models

Format blank lines around Pydantic classes in nudge.py according to PEP 8 standards.

* refactor(intelligence): add strict type hints and defensive checks to tool handlers

* fix(intelligence): remove duplicate Any import

* feat(intelligence): secure nudge copy router with JWT auth and strong typing

Update nudge copy endpoint contract:
- Switch route authentication dependency from API key to JWT verification (verify_jwt)
- Update generate_nudge_copy service function to return strongly typed NudgeCopyResponse Pydantic models

* fix(intelligence): remove unused type ignores and add missing kwargs type

* fix(intelligence): source rebalance rationale model from settings

---------

Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
Co-authored-by: G-ELM <alfygodwin@gmail.com>
* test(api): add unit tests for protocoltvl model

- Add coverage for TVL delta computation
- Add tests for negative and zero TVL edge cases
- Test 24h change percentage calculations

* test(api): add unit tests for tvl model

- Add coverage for aggregation across protocols
- Test zero and negative TVL edge cases
- Test precision handling for USDC formatting

* feat(api): add vault capacity limits and soft-cap warnings

- Add SoftCapacity and CapacityWarningPct fields to Vault model
- Implement GetCapacityStatus() for API exposure
- Implement CanAcceptDeposit() to gate deposits at capacity
- Add ErrCapacityExceeded error type
- Add comprehensive tests for capacity status and gating

* feat(api): add harvest dry-run/simulation mode

- Add SimulateHarvest() method to harvest engine
- Returns expected gas cost and net yield without execution
- Integrates with existing gas estimation in gas.go
- Useful for user-facing harvest preview features

---------

Co-authored-by: meloball9993 <starmeloball9993@gmail.com>
apps/api/internal/domain/apysnapshot/model.go had no test coverage.
Adds model_test.go covering:

- Validate(): required protocol slug, non-negative APY/TVL, non-zero
  capture timestamp
- ByCapturedAt: chronological ordering of a snapshot slice
- DuplicateTimestamps: detecting repeated captured_at values within a
  protocol's snapshot history, which the (protocol_slug, captured_at)
  unique constraint should otherwise prevent from reaching storage
- error message assertions for ErrProtocolNotFound and
  ErrDuplicateSnapshot

Validate, ByCapturedAt, and DuplicateTimestamps are small additions to
model.go needed to give the requested validation/ordering/duplicate
tests something concrete to exercise at the domain layer, independent
of the Postgres repository.

Co-authored-by: AdaBliss <295242925+AdaBliss@users.noreply.github.com>
MoscowDev and others added 21 commits July 30, 2026 17:19
* feat(intelligence): conversation rating and feedback capture (#926)

Add feedback endpoints and storage so response quality can be tracked over time and fed into evaluation datasets. Implements POST /intelligence/feedback (submit rating) and GET /intelligence/feedback (view history) with Redis-backed storage and in-memory fallback.

* fix: ruff import sorting in feedback.py (#926)

* feat(intelligence): coingecko service caching and staleness guard
* fix(api): implement missing Repository methods on savings goal test mocks

Commit 9a0f074 added CreditYieldBalance and GetByVaultID to
savingsgoal.Repository but did not update the in-memory test doubles.
Production code compiled, so `go build` stayed green while
`go vet`/`go test` on ./internal/service/... failed to compile.

This turned dev CI red and propagated to every open PR branched from
dev, producing failures in files those PRs never touched (#987, #988,
#993, #1005 all inherited it).

memorySavingsGoalRepo gets working implementations backed by its goal
map; memoryGoalRepo gets stubs matching its existing terse style.

* fix(intelligence): resolve pre-existing ruff violations on dev

`ruff check .` fails on dev HEAD with 8 violations: one unused import
(F401) and seven over-length lines (E501) across four test modules,
plus an out-of-order import block in prometheus.py.

Because the Intelligence (Python) CI job lints the whole directory, any
PR touching apps/intelligence inherits these failures in files it never
modified — #987 was reported red solely for this reason.

Long signatures and literals are wrapped; the unused import is removed;
the import block is reordered. No behavioural change.
…es (#834) (#886)

* feat(audit): add tamper-evident hash-chained audit log service (#834)

Implements a cryptographically hash-chained audit log that makes silent
modification or deletion of log entries detectable even by a compromised
database or malicious insider.

Changes:
- migrations/060: add chain fields (sequence, prev_hash, entry_hash,
  anchored, anchor_tx_hash, redacted) to audit_logs table
- internal/audit/audit.go: AuditService with LogAction (serializable tx
  + sync.Mutex for gap-free sequences), VerifyChain, RedactEntry,
  AnchorLatestEntry
- internal/scheduler/audit_chain_verifier.go: background job that runs
  VerifyChain on a configurable interval and calls ChainBreakAlerter
  on any integrity break
- internal/handler/admin_handler.go: GET /api/v1/admin/audit/verify
  endpoint for on-demand operator integrity checks
- SECURITY.md: document integrity model, threat model, redaction
  semantics, and anchoring strategy
- Unit tests for AuditChainVerifier scheduler (disabled, clean chain,
  break detection)
- Integration tests for append continuity, tamper detection, gap
  detection, redaction, and anchoring round-trip

Closes #834

* fix(audit): drop unused time import in audit_test.go

---------

Co-authored-by: kitWarse <278602811+kitWarse@users.noreply.github.com>
Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
- Add recurring deposit contract with permissionless execution
- Implement multi-asset vault support with price validation
- Add catch-up semantics for missed mandate executions
- Support mandate pause/resume and cancellation
- Add bounded reads for user mandates
- Implement price staleness and deviation protection
- Add database migration for mandate tracking
- Update API for mandate management

Closes #808
Closes #804

Co-authored-by: jameshassana221-droid <jameshassana221@gmail.com>
* fix(ci): unblock Rust and Go pipelines

Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.

Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).

* chore: remove internal audit and decision report files from repo root

* feat(api,intelligence): propagate X-Request-ID (#785)

Add request-ID generation and propagation across the Go API and Python intelligence service.

The Go API middleware now reads an inbound X-Request-ID or generates a UUIDv4, stores it in request context, attaches it to structured logs, and echoes it back on responses.

The intelligence relay forwards the same header to the Python service, and the FastAPI middleware binds it to structured logs and returns it on responses. Error envelopes now include the request ID so support can correlate user-reported failures with logs.

Closes #785

---------

Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
…indexer, and property-based tests (#884)

- Risk scoring engine with multi-factor model (6 factors), confidence calculation,
  history persistence, and explainability endpoint
- Stellar transaction submission pipeline with per-account sequence management,
  timeout resolution via on-chain lookup, and durability
- Reorg-safe chain event indexer with checkpointing, exactly-once processing,
  and handler dispatch table
- Property-based invariant test suite for vault share-price and accounting
  with reference model and edge case generators

Closes: risk scoring, submission pipeline, reorg indexer, property tests

Co-authored-by: Anubhav Singh <anubhavsingh@Mac.lan>
Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
… AI (#973)

Adds automatic conversation history summarization so that long Prometheus
sessions never hit Claude's context window limit and remain cost-efficient.

Changes
-------
app/config.py
  - max_history_tokens (INTELLIGENCE_MAX_HISTORY_TOKENS, default 80_000):
    token threshold above which summarization is triggered — set to ~80% of
    the claude-sonnet-5 200k context window
  - history_recent_turns_kept (INTELLIGENCE_HISTORY_RECENT_TURNS_KEPT,
    default 6): number of most-recent turns always kept verbatim after compaction

app/services/summarization.py (new)
  - estimate_token_count: lightweight 4-chars-per-token heuristic, no API call
  - needs_summarization: returns True when history exceeds threshold; False
    when threshold is 0 (disabled), or history is empty
  - _split_history: splits history into (older, recent) at the keep boundary
  - summarize_history: calls Claude to produce a concise prose summary of
    older turns, returns compacted history = [summary message] + recent turns;
    returns original history unchanged on any API failure (fail-safe)

app/services/conversation_store.py
  - set_active(user_id, history): replaces the active history key, used by
    the summarization path to persist the compacted context
  - get_audit(user_id): returns the full append-only history from the
    separate Redis key prometheus:conv:audit:<user_id> (TTL: 7 days)
  - append() now writes every turn to both the active key and the audit key
  - InMemoryConversationStore carries matching set_active / get_audit methods
    for dev/test use; audit log survives clear()

app/services/prometheus.py
  - stream_chat: before building the message list, checks needs_summarization
    on history + the new user message; if triggered, calls summarize_history
    and persists the compacted history via set_active; transparent to the user

tests/test_summarization.py (new, 30 tests, all passing)
  - TestEstimateTokenCount: 5 tests covering edge cases
  - TestNeedsSummarization: 6 tests including disabled (threshold=0),
    exactly-at, and one-above threshold
  - TestSplitHistory: 4 tests including minimum-2-turns guard
  - TestSummarizeHistory: 5 tests — success path, API error fallback,
    empty summary fallback, nothing-to-summarize, token count reduction
  - TestInMemoryConversationStoreAudit: 6 tests — append/set_active/get_audit
    round-trip, audit survives clear, scoped per user, copy semantics
  - TestConfigDefaults: 4 tests for default and overridden values
…989)

Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
Landed with #884; the package did not compile.
… and predictive protocol-health monitoring (#966)

* feat(api): add idempotency-key middleware for unsafe endpoints (#835)

Adds an Idempotency-Key-based middleware so retried POST requests to
transactions and savings-goals never double-execute. A client-supplied
key is scoped per authenticated user and claimed atomically via
Postgres's INSERT ... ON CONFLICT DO NOTHING, so the guarantee holds
across instances, not just within one process — no dependency on a
distributed lock.

- migrations/069: idempotency_keys table (user_id, key composite PK,
  request fingerprint, status, stored response, expires_at)
- internal/repository/postgres: IdempotencyRepository (Claim, Get,
  Complete, Release, PurgeExpired), sqlmock-backed tests
- internal/middleware: IdempotencyMiddleware — requires the header on
  matched routes, fingerprints method+path+body to reject a reused key
  against a different request (409), replays a completed response
  without re-executing the handler, and gives a concurrent in-flight
  retry a bounded wait before returning 425 Too Early
- cmd/api: wires the middleware onto POST /api/v1/transactions and
  POST /api/v1/users/savings-goals, plus a background purge loop for
  expired keys

Routes with path parameters (e.g. vault deposit) aren't covered yet —
RouteMatch only does exact-path matching; left for a follow-up rather
than extending RouteMatch here.

* feat(api): production-grade outbound webhook delivery system (#836)

Replaces the old ad-hoc goroutine+sleep webhook delivery with a
subscription model backed by SSRF-safe target validation, encrypted
signing secrets, durable job-queue delivery, a delivery log, and
auto-suspension of persistently-failing endpoints.

- internal/webhookssrf: HTTPS-only target validation rejecting
  private/loopback/link-local (including cloud metadata addresses)
  and multicast ranges. Called at registration AND again immediately
  before every send, since a hostname that resolves publicly at
  registration can be repointed at an internal address later
  (DNS rebinding) — the second check is the one that actually defends
  against it.
- migrations/070: extends webhooks with event_types filter,
  secret_key_version, status (active/suspended), and a
  consecutive-dead-letter counter; adds webhook_deliveries as a
  per-attempt log.
- internal/service/webhook_signing.go: HMAC-SHA256 over
  "{timestamp}.{payload}" (not the payload alone, so a captured
  signature can't be replayed against a different timestamp),
  documented in docs/webhooks.md alongside the dedup expectation.
- internal/service/webhook_service.go: Register validates the target,
  generates a secret, and encrypts it via the existing AccountCipher
  before storing (shown once, never persisted in plaintext).
  FireForUser now enqueues one job per matching active subscription
  onto the existing durable job queue (#824) instead of firing an
  in-process goroutine, so delivery gets the queue's own
  retry/backoff/dead-letter guarantees for free. Adds ListDeliveries
  and Redeliver (manual redelivery under a fresh delivery id).
- internal/service/webhook_delivery_job.go: the job handler — one
  attempt per invocation. Re-validates the target (rebind defense),
  decrypts the secret, applies a per-subscription rate limit, sends
  with no-redirect-following + a strict timeout, logs the outcome,
  and on final dead-letter increments the subscription's streak,
  auto-suspending and notifying the owner once it crosses the
  threshold.
- internal/handler/webhook_handler.go: adds
  GET /api/v1/webhooks/{id}/deliveries and
  POST /api/v1/webhooks/deliveries/{deliveryId}/redeliver.

Concurrency bounds for the delivery job type use the worker pool's
default rather than a dedicated per-type limit, since per-subscription
throttling already happens inside the handler via the rate limiter —
a slow/failing endpoint is isolated to its own subscription without
needing a second, redundant concurrency knob.

Scoped out: the handler-level CRUD surface stays what #836 needs
(subscription CRUD + delivery log + redelivery); a repo-wide event bus
unifying webhook fan-out with the notification dispatcher is a larger
refactor than this issue and is not attempted here — FireForUser's
existing (event, payload) call shape is reused as-is.

* feat(api): historical chain backfill and resync tool for the event indexer (#840)

Adds a controlled, resumable, idempotent way to (re)process a historical
ledger range through the indexer's existing handler dispatch — for
backfilling a newly-added contract's history, or resyncing derived state
after fixing a handler bug — without disturbing live forward indexing.

- internal/stellar/backfill.go: Runner reuses applyIndexedEvent directly
  (same package, unexported — not a separate code path that could drift
  from live indexing behavior). Checkpoints progress after every batch to
  backfill_runs so a crash resumes from the last committed ledger rather
  than restarting the range. Runs only on ledgers below a configurable
  safety margin from the chain head (proxied by getEvents' own
  latestLedger) and throttles between batches so live indexing is never
  starved of DB/RPC capacity. Supports a dry run that reports what would
  be processed without writing.
- Overlapping backfill relies on the indexer's own processed_events dedup
  (ON CONFLICT DO NOTHING) — no separate dedup mechanism, so a range that
  partially overlaps already-processed ledgers just no-ops on the overlap.
- Rebuild mode (reset + reprocess) is restricted to event types whose
  derived tables are pure INSERTs with no other handler ever UPDATE-ing
  them (penalty_events, penalty_distributions, vault_rebalance_legs,
  vault_rebalance_completions) — deposit/withdraw are refused because
  vaults.total_deposited/current_balance are incremental (+=/-=), not
  idempotent absolute writes, so replaying them after a reset would
  double-count everything applied before the reset. A pre-flight scan
  (NonResettableEventTypesInRange) refuses the whole run up front rather
  than failing partway through.
- migrations/071: backfill_runs (checkpoint, status, audit trail via
  initiated_by). migrations/072: adds ledger_sequence to the four
  resettable tables so a rebuild's reset clears precisely the requested
  range, not every row ever recorded for the contract — the tables
  previously only had occurred_at (insert time), which can't be mapped
  back to a ledger range for a backfill run long after the fact.
- internal/handler/admin_handler.go: POST /api/v1/admin/backfill (start),
  POST /api/v1/admin/backfill/{id}/resume, GET .../backfill(/{id}) to
  inspect progress — the operator-initiated, audited trigger surface.
- cmd/backfill: a standalone CLI following the rotate_keys/bootstrap-admin
  precedent, for running a backfill as a one-off outside the API process.

Verified against a real local Postgres instance (not just sqlmock): shared
handler dispatch producing correct derived state, overlapping-range dedup,
scoped rebuild clear-and-recompute, dry-run writing nothing, and resume
after a simulated crash all pass end-to-end, in addition to the unit-level
coverage that runs without a database.

* feat(intelligence): predictive protocol-health deterioration monitoring (#857)

Builds prediction on top of the existing ProtocolHealthChecker, which only
reacts to a TVL collapse already 20%+ underway. This adds a continuous,
graduated deterioration score from leading indicators, so exposure to a
failing yield source can be reduced while good exits still exist.

- internal/scheduler/deterioration_indicators.go: TVL outflow velocity,
  APY abnormality (z-score against its own trailing mean), a
  reported-vs-derived APY gap proxy (the true on-chain-derived accrual
  figure needs an oracle-aggregation layer that hasn't landed as separate
  work; this is scoped to what's computable from data already ingested —
  see docs/protocol-health.md), and TVL-volatility-as-price-instability
  proxy. Pure functions, no I/O.
- internal/scheduler/deterioration_score.go: a deliberately simple,
  fully-transparent weighted-logistic model (not an opaque one) mapping
  indicators to a deterioration probability distinct from the static risk
  score. Thresholds are calibrated so a single strong indicator lands at
  most "moderate"; "severe" (which can move funds) needs multiple
  corroborating signals, and a thin sample is capped below severe
  regardless of what it scores. Every alert states its specific driving
  indicators, never just a bare number.
- internal/scheduler/deterioration_action.go: graduated dispatch —
  mild records a ceiling-cut, moderate records+logs a rebalance
  recommendation, severe submits an automatic protective rebalance for
  every vault allocated to the protocol through the existing
  slippage-safe AdminService.TriggerRebalance (never a separate fund-
  movement path) and notifies the affected owner why. Every automatic
  attempt — success or failure — is audited before/alongside any
  notification; nothing here is silent.
- migrations/073: deterioration_actions (the audit trail) and
  deterioration_assessments (every scored tick, not just alerted ones,
  for later calibration review against what actually happened).
- protocoltvl.Repository gains ListSince for the time-series the indicator
  computation needs (previously only point-lookups existed).
- apps/intelligence: deterioration_summary.py narrates an already-scored
  assessment for operators or an affected user's "why we moved your
  funds" message, following yield_explanation.py's exact grounding
  pattern — every number the model may use is collected from the
  assessment first, and any ungrounded output is replaced by a
  deterministic fallback. Uses the configured (current) Claude model id,
  same as every other LLM call in this service.

Verified against a real local Postgres instance (ListSince windowing,
action/assessment persistence) in addition to the pure-function unit
coverage (deteriorating vs. healthy pattern detection, deterministic
scoring, graduated-level monotonicity, thin-sample capping, per-vault
automatic-rebalance targeting and its audit trail) and the full Python
intelligence-service test suite (145 passing, including 5 new).

Deferred, disclosed: full risk-engine and circuit-breaker integrations
are companion issues that haven't landed in this codebase yet; severe
assessments bound themselves to the existing rebalance mechanism rather
than a not-yet-existing separate trip condition.

* fix: harden idempotency, webhook delivery, backfill, and deterioration paths (#835, #836, #840, #857)

Renumbers migrations 069-073 to 090-094 to resolve a collision with
migrations dev has since gained in the same range (065-089 are now
taken by unrelated work merged upstream).

Idempotency middleware (#835): bounds request-body reads to one byte
past the 1MiB cap so an oversized body is rejected (413) instead of
being silently truncated by io.LimitReader's synthetic EOF and
fingerprinted/replayed incomplete. Complete() now retries with a
bounded backoff before falling back to Release, so a brief DB hiccup
doesn't force a duplicate handler execution on the client's retry.

Webhook delivery (#836): ListDeliveries page size is now capped
(maxWebhookDeliveriesLimit) regardless of what a caller requests.
FireForUser's enqueue failures are now logged instead of discarded
(WebhookService gained a logger, defaulting to a discard handler until
SetLogger is wired from main). Corrected a misleading comment about
the delivery idempotency key's actual dedupe scope (it guards against
double-enqueuing one in-flight payload instance, not caller retries of
the same logical event, since retries mint a new DeliveryID).

Backfill (#840): Start() now rejects an unknown Mode explicitly instead
of letting it fall through silently.

Predictive protocol-health (#857): vaultHasAllocation now normalizes
protocolSlug's casing/whitespace on both sides of the comparison — this
is the sole matcher gating the severe-path automatic rebalance, so a
silent case mismatch meant a protective action could be silently
skipped despite a severe assessment. notifyVaultOwner's notification
failure is now logged (the audit record persists regardless, but a
failed "we moved your funds and why" delivery — the feature's explicit
explainability goal — must not go unnoticed by operators).

Tests: apps/api's full suite passes (go build + go test ./...).
apps/intelligence's full suite passes under Python 3.12 (240 tests,
including new coverage in test_deterioration_models.py).

* style(intelligence): sort router imports after merge

---------

Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
…pters (#883)

Replaces the yield registry's admin-pushed APY/TVL numbers with values read
from real protocol positions, behind one protocol-agnostic adapter interface
the vault understands. Adding a third protocol is now 'write one small adapter
contract' with zero vault changes.

Adapter trait (libs/common/src/adapters.rs)
- deposit, withdraw, position_value, current_apy, underlying, max_deposit,
  max_withdraw. Every value-moving call takes a minimum-output parameter.
- ApyConfidence distinguishes ProtocolReported / Derived / Unavailable, so
  'APY unknown' is never conflated with 'APY is zero'.

Two reference adapters for structurally different venues
- adapter_lending: Blend-style market, reports the protocol's own supply rate.
- adapter_pool: Soroswap-style AMM. Values LP units pro-rata against reserves
  (what a burn actually pays out) rather than a spot-price oracle, and derives
  APY from position growth against a stored checkpoint. Windows shorter than
  one day report Unavailable instead of a wild annualized number; deposits and
  withdrawals re-anchor the checkpoint so capital moves are never read as yield.

Registry
- register_source takes an adapter; set_source_adapter attaches one later.
- refresh_apy_from_adapter is permissionless and becomes the default path.
  The deviation guard still applies, so a compromised adapter cannot move a
  source's APY arbitrarily; update_apy_override remains the admin escape hatch.
- Sources with unknown APY are excluded from get_sources_above_apy rather than
  ranked as zero.

Failure isolation
- Per-source consecutive-failure counter with a configurable threshold. Once
  exceeded the source flips to SourceStatus::Degraded and emits SRC_DEGR.
  Recovery requires an explicit admin recover_source call; a working adapter
  never silently re-activates a degraded source.
- Vault rebalance attempts each source's adapter independently and skips the
  ones that revert, reporting them to the registry and completing across the
  rest. record_source_allocation returns false for an unhealthy source instead
  of panicking, so one bad source cannot abort a multi-source update.
- The refresh path returns an Unavailable reading rather than panicking on
  adapter failure: Soroban rolls back storage on panic, which would discard
  the very failure counter the path exists to maintain.
- allocation_strategy treats Degraded like Paused (freeze, do not force-drain
  through an already-failing adapter) and never ranks allocation targets on an
  unknown APY reading.

Backend
- apy_precedence.go documents and implements the rule: on-chain adapter APY is
  authoritative for rebalancing, DeFiLlama is display-only and is never
  consulted as a fallback. When on-chain APY is unknown or stale the allocator
  holds the current allocation steady instead of substituting an off-chain
  aggregate — two APY sources with no stated precedence is how rebalance loops
  start.
- protocol_health_checker consumes degraded sources, alerting once per
  degradation episode and re-arming after an admin recovers the source.

Tests
- Mock lending market, AMM pool, and always-reverting adapter in test_utils.
- Per-adapter unit tests covering deposit, withdraw, valuation, APY confidence,
  and limit paths; registry tests for the permissionless pull, degradation, and
  admin-only recovery.
- Integration test proving a deliberately failing adapter is skipped while the
  rebalance completes across the remaining sources.

Closes #812

Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
app/main.py was reduced to 4 bytes during the #966 merge (05a6be6),
leaving the FastAPI service with no `app` object. The intelligence
service could not start and every test importing `app.main` failed at
collection.

Restored from cabc32c (the last commit where the file was intact) and
re-added the `deterioration` router that #966 introduced, so the router
set matches what is now on dev: 13 routers, 29 routes.

Verified with `from app.main import app`.
…source citations, savings tests (#961)

* fix(ci): unblock Rust and Go pipelines

Bump ethnum 1.5.0 -> 1.5.3 in packages/contracts so the contract crate
compiles on the current stable toolchain (rustc 1.97.1). ethnum 1.5.0 fails
with error[E0512] transmuting () into TryFromIntError, which no longer share
a size; the crate fixed it in 1.5.1.

Bump apps/api to Go 1.25.12 and golang.org/x/text v0.39.0 to clear the two
govulncheck findings not in the allowlist: GO-2026-5856 (crypto/tls, fixed
in go1.25.12) and GO-2026-5970 (x/text, fixed in v0.39.0).

* chore: remove internal audit and decision report files from repo root

* feat(intelligence): add prompt-injection and output-safety guardrails for chat/analyze (#875)

* feat(intelligence): add prompt-injection and output-safety guardrails

Claude calls in the chat and analyze paths had no defense against prompt
injection or system-prompt extraction, and recommendation output wasn't
schema-enforced. Add input screening (regex-based, logs request_id + a
non-reversible fingerprint, never raw content), a hardened system prompt
with an explicit trust boundary and tagged untrusted-content wrapping,
deterministic history/message bounding, and output post-processing that
strips leaked system-prompt text and enforces a non-model-controlled
disclaimer on /analyze and related endpoints.

* fix(intelligence): close remaining guardrail gaps from review

- Validate inbound X-Request-Id against a bounded safe charset before
  trusting it in state/headers/logs, falling back to a fresh UUID
  otherwise (prevents log/header injection via a client-supplied header).
- Fix the chat streaming leak-redaction buffer to retain a sanitized
  lookback tail on flush instead of resetting to empty, so a
  system-prompt marker split across two deltas is still caught.
- Wrap the remaining unwrapped context data interpolated into the
  recommend/vault and analyze prompts (positions, vault/user context
  lines) in the same trust-boundary tags used elsewhere.
- Sanitize the few model-derived output fields that were missed:
  confidence_reason/data_freshness in Recommendation, insight card
  action.label/href, and deposit schedule note.

* test(intelligence): add cross-user isolation regression tests for vault_context

Audited VaultContextFetcher (used as a process-wide singleton in prometheus.py
and savings_service.py): fetch_user_vaults/fetch_vault_risk take user_id/
vault_id as explicit call parameters and never store per-user state on self,
so the shared instance can't leak vault data between users. The only
cross-call state is the market-rates cache, which holds no user-scoped data.
Adds regression tests locking in that guarantee.

* feat(intelligence): cancel in-flight Claude stream on ws disconnect

Previously, if a client disconnected mid-stream, the send_text() failure
just unwound the async-for via WebSocketDisconnect while the stream_chat
async generator was left suspended inside its `async with
client.messages.stream(...)` block, relying on eventual garbage collection
to close it. That could keep the backend consuming (and paying for) Claude
tokens for a response nobody would ever read.

Hold an explicit reference to the generator and aclose() it in a finally
block so any exit path (disconnect or otherwise) throws GeneratorExit into
stream_chat immediately, unwinding the streaming context manager right away.

* feat(intelligence): cite protocol/source and as-of timestamp for TVL/APY figures

Adds app/services/retrieval_source.py, a small RetrievalSource helper that
pairs a data point with the protocol it came from and when it was fetched,
and renders it as an inline "(source: <protocol>, as of <timestamp>)"
citation.

Wires it into the two places TVL/APY figures reach the Prometheus prompt:
- vault_context.fetch_market_rates now stamps each rate with its source
  (defillama/fallback) and fetch time; build_context_block appends the
  citation to each market-rate line.
- prometheus._build_market_context_block appends the same citation to each
  DeFiLlama pool's TVL/APY line.

Updates the Prometheus system prompt with a "Citing data sources" section
instructing it to always echo the citation next to any TVL/APY figure it
states, so users don't mistake a stale cached number for real-time data.
Also adds that section header to guardrails' leak-marker list for
consistency with the other system-prompt sections.

* test(intelligence): golden-path coverage for savings_service core functions

The existing test file only covered generate_plan's compounding math. Adds
tests for the other core building blocks ahead of further feature work:
- get_default_apy: averages live rates, and falls back to 8% when the
  fetcher returns nothing.
- generate_plan: uses the matching vault's live rate when vault_id is
  given, and falls back to get_default_apy() when vault_id doesn't match
  any known protocol.
- _generate_narrative: returns Claude's text when the call succeeds, and
  falls back to the deterministic achievable/not-achievable message when
  the Claude call raises.

---------

Co-authored-by: 0xDeon <oluwadamilare_daniel@outlook.com>
… 082 (#1026)

Thirteen migrations were fighting over six version numbers. With
duplicate versions golang-migrate's ordering is undefined, so which
migration applies — or whether one is skipped — depends on filesystem
iteration order.

This also fails the "Check migration collisions" gate in ci.yml, which
runs before Build and so blocks the entire API job. That is why the Go
job has been red on dev since 30 July.

The same class of collision was fixed once already (084-089). It came
back because three PRs merged on 30 July each claimed numbers that were
free when their branches were cut: #991 (goal level), #884 (risk
scoring) and #992 (recurring deposit mandates).

Applying the existing convention — the earliest-landed migration keeps
its number, later arrivals move to the end in landing order:

  055_add_savings_goal_auto_compound            -> 095
  056_create_goal_notification_preferences      -> 096
  060_add_tamper_evident_audit_fields           -> 097
  060_risk_scores                               -> 098
  061_chain_submissions                         -> 099
  062_reorg_safe_indexer                        -> 100
  082_add_savings_schedules_onchain_mandate_id  -> 101

Two Go tests load migrations by filename and would have broken silently:

  savings_goal_lifecycle_integration_test.go  055 -> 095
  audit_test.go                               060 -> 097

audit_test.go's migration060Path and content060 locals are renamed to
match their new target rather than left pointing at a number that no
longer exists.

Verified: the collision gate from ci.yml passes, every renamed migration
keeps its up/down pair, and go build and go vet are clean.
…xpectation (#1028)

TestRiskService_SingleProtocolVault_HighRisk has been failing on dev:

  risk_service_test.go:199: expected tier 'medium' or 'high'
  for score 32.75, got 'low'

Investigating it turned up a real bug behind the failing assertion.

computeTier matched integer-spaced ranges:

  case overall >= 0  && overall <= 33: return "low"
  case overall >= 34 && overall <= 66: return "medium"
  case overall >= 67 && overall <= 100: return "high"

Scores are floats. Anything in (33, 34) or (66, 67) matched no case and
fell through to the default, which returned "high". A vault scoring 33.5
was reported high risk while one scoring 34 was medium — the mapping was
not monotonic, and it overstated risk to users in both gaps.

Make the bounds contiguous. Negative scores still map to "low" and
values above 100 to "high", as before.

The test's expectation was also wrong, independently of that bug.
Concentration is correctly 100.0 for a single-protocol vault and is
already asserted above, but it carries a 0.25 weight; with the other
five factors low for a small vault the overall is 32.75, which is
genuinely "low". Demanding medium/high from a maximal score on one
quarter-weighted factor asserts something the model does not claim.
Assert a valid tier and an in-range overall instead.

Added TestRiskService_ComputeTier_Boundaries, which pins 33.5 and 66.5
to the tiers they belong in, and TestRiskService_ComputeTier_Monotonic,
which sweeps -5 to 105 and fails if the tier ever decreases.

Verified: all nine tests in the package pass, go build and go vet clean.

Note both files have pre-existing gofmt misalignment on dev. That is
left alone here so this diff stays reviewable; worth a separate
formatting pass.
… job (#1032)

* fix(intelligence): resolve the six mypy errors blocking the Python CI job

`mypy app` has been failing on dev with six errors across five files,
which stops the Intelligence (Python) job before pytest ever runs.

One of them is a live crash, not a typing nit:

  app/routers/analyze.py:132: "AnalyzeRequest" has no attribute "get"

AnalyzeRequest is a Pydantic model whose only field is `prompt`, so
`body.get("language")` raises AttributeError on every POST /analyze.
Every sibling endpoint in the same router already takes `language` as a
Query parameter, so this takes one too and drops the dict-style access.

The rest:

  i18n.py:278  annotate `groups: list[str]`; mypy cannot infer the
               element type from an empty literal.

  i18n.py:371  format_date accepts `date | datetime | str`, but `parsed`
               was inferred as datetime from the isoformat branch, so
               assigning a plain date failed. Declare the union.

  feedback_store.py:72  FeedbackEntryDict(**item) over arbitrary JSON
               cannot be checked. Redis holds whatever an older version
               of the service wrote, so entries missing keys are now
               dropped instead of being constructed into a dict that
               claims a shape it does not have. The key set is derived
               from the TypedDict so it cannot drift.

  feedback.py:60  FeedbackRequest.rating was `str` with a regex pattern
               while FeedbackEntryDict wants
               Literal["thumbs_up", "thumbs_down"]. Use the Literal;
               Pydantic enforces it at runtime just as the pattern did.

  ws_chat.py:115  stream_chat is a real async generator but was annotated
               AsyncIterator[str], which has no aclose(). The ws handler
               calls aclose() deliberately — the comment there explains
               it stops consuming tokens when a client disconnects — so
               the annotation, not the call, was wrong.
               AsyncGenerator[str, None] is the accurate type.

Verified with mypy and ruff installed locally against the same config:
28 errors before, 22 after, with all six target errors gone and none
introduced. `ruff check .` passes.

The 22 remaining are local environment divergence, not code defects:
untyped `redis.from_url` and missing stubs for stellar_sdk and scipy,
which would not install on this machine. CI reports only the six fixed
here, and this commit does not touch that surface.

* fix(intelligence): correct negative sign placement and fr group separator

format_amount applied the minus sign to the number before attaching the
currency symbol, so a negative USD amount rendered as "$-42.50" instead of
"-$42.50". Move the sign outside the symbol/number composition so it always
leads the formatted value, for prefix, suffix and ticker forms alike.

The French group separator was U+202F NARROW NO-BREAK SPACE, which made the
formatter's output depend on which invisible space character the source
happened to carry. Use a regular space so the pre-formatted strings handed to
Prometheus are byte-deterministic and match the frontend locale conventions.

Also cast block.input before ** unpacking in prometheus.py: the Anthropic SDK
types it as object, which was the one remaining mypy error blocking the job.
…ce calls (#1031)

* fix(contracts): restore the missing adapter argument in register_source calls

nester-integration-tests has not compiled:

  error[E0061]: this method takes 5 arguments but 4 arguments were supplied
    --> tests/integration/src/integration/adversarial_tests.rs:177:18
    --> tests/integration/src/integration/adversarial_tests.rs:183:18

register_source takes adapter: Option<Address> between contract_address
and protocol_type. Two calls in adversarial_tests.rs were never updated
when that parameter was added; every other call site in the workspace
already passes &None or &Some(..).

Because the lib test target failed to build, cargo test --lib aborted
before reaching most of the workspace, and the Contracts (Rust) CI job
only runs behind a path filter. Between them, a large part of the
contract test suite has not executed for some time.

With the calls fixed, 263 tests across 18 targets now run and pass, and
two genuine pre-existing defects become visible for the first time:

  #1029 ReferenceModel::withdraw moves the performance fee out of
        total_assets while total_shares is unchanged, so share price
        drops for remaining holders (10002000 -> 10000000). Marked
        #[ignore] here with a TODO; that issue closes by removing it.

  #1030 Deviation-rejected adapter readings do not count toward
        failure_threshold, so a source pinned to a wild APY stays
        Active on stale data instead of degrading. Left failing.

Neither is touched here. #1029 is a vault fee-accounting question and
#1030 is a change to contract failure handling; both deserve their own
review rather than riding along with a compile fix. Contracts (Rust)
therefore stays red on #1030, which is the accurate signal.

* fix(contracts): keep the failure streak when a reading is rejected

refresh_apy_from_adapter cleared failure_count as soon as the adapter
returned, before the deviation guard ran. A rejected reading then called
record_failure on a freshly zeroed counter, so the streak never grew past
1 and never exceeded the threshold: an adapter pinning a garbage value
kept its source Active on a stale APY forever.

Clear the streak only on the two paths that actually accept the adapter's
answer -- an Unavailable reading and a reading that passes the guard.
…ak (#1027)

* fix(ci): move staging-secret guard out of the job-level if in load-soak

The soak job gated itself on a secret from its job-level `if`:

  if: ${{ secrets.STAGING_LOAD_API_BASE_URL != '' }}

The `secrets` context is not available at that level. GitHub rejects the
workflow while building the run graph, so every trigger produced a
startup_failure with zero jobs and the nightly soak has never run.

The intent is right and worth keeping: a missing secret must not let the
test fall back to localhost or production. Move the check into a step,
where `secrets` is available. The step publishes configured=true/false
and the soak step keys off it, so an unconfigured repository skips with
a notice instead of running against the wrong target.

The artifact upload keeps `if: always()` and now finds no summary file
when the run is skipped; if-no-files-found is already set to warn.

Note this workflow is separate from the contract-audit startup_failure.
Both were filed under one issue but have unrelated causes: that one is a
YAML parse error, this one is an invalid context reference.

* fix(api): measure cache TTL jitter at millisecond resolution

TestGetOrCompute_Redis_TTLIsJittered sampled the TTL with Redis TTL,
which returns whole seconds. For a 10s base with a +/-10% window the
continuous [9s, 11s] spread collapsed onto {9s, 10s, 11s}, and roughly
half the probability mass landed in the 10s bucket. Five samples all
colliding in one bucket was a ~3% flake, which is what CI hit.

Sample with PTTL instead so the full sub-second spread is visible,
raise the sample count to 20, assert every TTL falls inside the jitter
window, and require more than half the samples to be distinct. The
production jitter in jitteredTTL is correct and is left unchanged.
…1024)

* fix(ci): repair unparseable inline Python in contract-audit workflow

The "Build contracts and calculate WASM hashes" step piped cargo
metadata into `python3 -c "` and then wrote the script body at column
zero. A `run: |` block scalar ends at the first line indented less than
the block, so YAML terminated the scalar at the pipe and tried to read
`import sys, json` as the next mapping key:

  yaml.scanner.ScannerError: while scanning a simple key
    in ".github/workflows/contract-audit.yml", line 47, column 1
  could not find expected ':'

GitHub could not build a run graph from the file, so every trigger
produced a startup_failure with zero jobs. The workflow has never
executed.

Write the filter to a file with an indented heredoc instead. YAML strips
the block indentation before bash sees the text, so the PY terminator
lands at column zero as the heredoc requires, and every line stays
inside the scalar.

The path filter keeps its behaviour: workspace members under contracts/
are built, and rent_escrow, test_fixtures and tests are excluded. Path
normalisation is now a single `replace('\', '/')` bound to a local
rather than repeated inside each condition, which also drops a layer of
YAML-vs-Python backslash escaping.

Verified: file parses, the extracted run block passes `bash -n`, the
generated script byte-compiles, and the filter returns the expected
package set for POSIX and Windows manifest paths.

* fix(ci): stop building testutils crates for the wasm target in contract audit

The package filter matched "/contracts/" anywhere in the absolute manifest
path. Because the workspace root directory is itself named "contracts", that
substring matched every workspace member, including libs/test_utils and
tests/integration. Those crates depend on soroban-sdk with the "testutils"
feature as a normal (not dev) dependency, so building them for
wasm32-unknown-unknown tripped the SDK's compile_error! guard and the cascade
of unresolved std/soroban_env_host imports.

Resolve the manifest path relative to workspace_root and require a cdylib
target, so only the 15 deployable contracts are built.
…1023)

* security(ci): make vulnerability gates enforce instead of reporting

Ten security checks across ci.yml and security.yml could not fail the
build. Steps named "Fail on high and critical vulnerabilities" ended in
`|| true`; the Python gate printed its findings and then called
SystemExit(0). Every audit step in ci.yml carried continue-on-error.

The result was a pipeline that reported vulnerabilities and exited
green, so a passing Security workflow said nothing about whether the
tree was actually clean.

security.yml
  - npm audit high/critical: drop `|| true`
  - cargo audit: drop `|| true`
  - pip-audit high/critical gate: SystemExit(0) -> SystemExit(1), and
    print each finding's id and description so the log identifies what
    blocked rather than just a count

ci.yml
  - drop continue-on-error from gosec, pnpm audit (high), pip-audit,
    bandit, pnpm audit (dapp, moderate) and cargo audit

Deliberately left permissive:
  - "Warn on moderate vulnerabilities" steps keep `|| true` and
    SystemExit(0); they are advisory by design
  - report generation keeps `|| true` because pip-audit exits non-zero
    on findings while still writing the report the gates read; a
    missing report is caught by the gates themselves
  - lint and the integration-test step keep continue-on-error; those
    are not security gates and are out of scope here

These gates have been inert for some time, so the first honest run is
expected to surface a backlog of real findings.

* fix(security): repair npm audit job for pnpm and patch RUSTSEC-2026-0009

Enforcing the gates in the previous commit surfaced two problems.

The npm audit job never worked. It ran
`npm audit --package-lock-only` against apps/dapp/frontend and
apps/website, but this is a pnpm workspace: the only lockfile is
pnpm-lock.yaml at the root and no package-lock.json exists anywhere.
Both matrix legs failed with:

  npm error code ENOLOCK
  npm error audit This command requires an existing lockfile.

`|| true` had been converting that tooling error into a green check, so
the job reported success while auditing nothing at all.

Run pnpm audit once from the workspace root instead. The root lockfile
already covers every package, so the directory matrix is dropped rather
than iterating over directories that have no lockfile of their own.

cargo audit reported a genuine finding:

  RUSTSEC-2026-0009  time 0.3.41  Denial of Service via Stack Exhaustion
  Severity 6.8 (medium)          Solution: upgrade to >=0.3.47

time is a transitive dependency, so this is a lockfile-only bump with no
manifest change. `cargo update -p time --precise 0.3.47` also carried
deranged, num-conv, serde, time-core and time-macros forward.
`cargo check --workspace` passes; the only output is pre-existing
dead-code warnings.

The six remaining cargo-audit warnings are unmaintained-crate notices,
not vulnerabilities, and do not fail the gate.

* chore(ci): defer JS dependency audit enforcement to #1025

Enforcing the audit gates exposed 203 pre-existing findings in the JS
dependency tree: 6 critical, 98 high, 86 moderate, across 21 packages
including next, next-auth, axios and sharp.

Those are framework-level upgrades that will touch application code.
Bundling them here would make this PR unreviewable and would hold back
gate fixes that are safe and useful on their own.

Leave the two JS audit steps suppressed, each with a TODO naming #1025,
which closes by removing them. Every other gate enforces from this merge:
gosec, bandit, pip-audit, cargo audit, and the Python high/critical gate.

This is the only remaining suppression that hides a known finding. The
others left in place are the advisory "warn on moderate" steps, pip-audit
report generation, and lint and integration-test steps that are not
security gates.

* chore(ci): defer gosec enforcement to #1035

Enforcing gosec surfaced 27 pre-existing findings across 8 rule classes,
not the integer-conversion cleanup it first looked like:

  2  G704 SSRF in intelligence_proxy.go (HIGH confidence and severity)
  2  G202 SQL string concatenation in savings_goal_repository.go
  12 G115 integer overflow, several on chain-facing Stellar/Soroban paths
  3  G118 goroutines discarding a request-scoped context
  2  G124 auth cookies missing Secure/HttpOnly/SameSite
  6  assorted G120/G301/G302/G101

The SSRF pair is the only finding that is both HIGH confidence and HIGH
severity, and fixing it properly means an allowlist rather than a quick
pattern check. That does not belong in a CI-configuration PR.

Suppress gosec with a TODO naming #1035, which closes by removing the
line. Every other gate still enforces from this merge: bandit,
pip-audit, cargo audit, and the Python high/critical gate.

gosec also reports 23 existing #nosec annotations; #1035 covers
re-checking that each still has a valid justification.

* security(deps): eliminate all critical advisories and enforce the audit gate

The enforcing pnpm audit gate introduced by this PR was failing on 203
pre-existing advisories (6 critical, 98 high, 86 moderate, 13 low), which
had left the JS gates suppressed with continue-on-error and `|| true`.

Rather than suppress the findings, fix the criticals outright. All six were
resolvable, and four were not the deep unreachable transitives they appeared
to be:

  - protobufjs  <7.5.5  -> >=7.5.5   GHSA-xq3m-2v4x-88gg arbitrary code exec
                                     (via @trezor/connect)
  - shell-quote <1.8.4  -> >=1.8.4   GHSA-w7jw-789q-3m8p (via react-native)
  - tar         <7.5.19 -> >=7.5.19  GHSA-23hp-3jrh-7fpw (via expo)
  - next-auth   <4.24.15-> >=4.24.15 GHSA-7rqj-j65f-68wh homoglyph email
                                     normalization; a direct dependency

The remaining two criticals were both jspdf (GHSA-f8cm-6447-x5h2 path
traversal, GHSA-wfv2-pwc8-crg5 HTML injection), a direct dependency, so it is
upgraded properly rather than overridden: jspdf 2.x -> 4.2.1 with
jspdf-autotable 3.x -> 5.0.8, whose peer range is ^2 || ^3 || ^4. The only
consumer is lib/export/pdf.ts, which uses setFontSize/text/save plus
autoTable; all still present in 4.x and the dapp suite passes (126 tests).

Result: 203 -> 146 findings, 0 critical. Both JS gates now enforce at
`--audit-level=critical` with no continue-on-error and no `|| true`, so any
newly introduced critical breaks the build. The residual 76 high / 60
moderate are genuinely transitive-only under the Expo/react-native and Trezor
chains and need upstream releases; those are reported in a separate
non-blocking step so the backlog stays visible, and #1025 raises the gate to
`high` once they land.

* fix(ci): drop the unusable pnpm cache from the dependency audit job

The Dependency Audit (pnpm) job failed even though every audit step passed.
`actions/setup-node` was configured with `cache: pnpm`, but this job never
runs `pnpm install` (audit resolves straight from the lockfile), so the pnpm
store directory is never created and the post-job cache save aborts with
"Path Validation Error: Path(s) specified in the action for caching do(es)
not exist", failing the job in the Post Setup Node step.

Nothing here needs a warm store, so the cache option is removed rather than
worked around with an install.

* security(deps): upgrade PyJWT to 2.13.0 to clear all known advisories

PyJWT 2.12.0 carries nine known advisories (PYSEC-2026-175 through -179
and their GHSA aliases). 2.13.0 is the first release that clears all of
them; 2.12.1 only addresses PYSEC-2026-176.

The starlette advisories reported alongside these are not fixable here.
starlette is pinned transitively by fastapi 0.115.6 (starlette<0.42.0),
and the advisories require starlette>=1.3.1, so clearing them means
upgrading fastapi itself. That is tracked separately.

* security(deps): upgrade FastAPI and pin starlette to clear seven advisories

starlette 0.41.3 carries seven advisories, three of them HIGH:

  PYSEC-2026-249  / GHSA-82w8-qh3p-5jfq  HIGH      availability
  PYSEC-2026-1942 / GHSA-7f5h-v6xp-fcq8  HIGH      O(n^2) DoS via Range
                                                   header merging in FileResponse
  PYSEC-2026-2281 / GHSA-wqp7-x3pw-xc5r  HIGH      confidentiality
  PYSEC-2026-161  / GHSA-86qp-5c8j-p5mr  MODERATE  Host header validation bypass
                                                   poisons request.url.path
  PYSEC-2026-1941 / GHSA-2c2j-9gv5-cj73  MODERATE  DoS parsing large multipart forms
  PYSEC-2026-2280 / GHSA-x746-7m8f-x49c  MODERATE  integrity
  PYSEC-2026-248  / GHSA-jp82-jpqv-5vv3  LOW       integrity

starlette was not directly pinned; fastapi 0.115.6 constrained it to
<0.42.0, and clearing every advisory requires >=1.3.1. No intermediate
version suffices: pip-audit lists a per-branch fix for each advisory, but
the maximum across all seven is 1.3.1.

fastapi 0.135.0 dropped the starlette upper bound (starlette>=0.46.0), so
0.141.1 admits the fixed release. starlette is now pinned explicitly so a
future fastapi bump cannot silently regress it.

PyJWT 2.12.0 -> 2.13.0 is carried here as well so this branch passes
pip-audit standalone; the same bump is on #1023.

Refs #1036

* security(intelligence): document the container bind address for bandit

bandit flags B104 (hardcoded_bind_all_interfaces) on the default
host of 0.0.0.0. The service runs in a container and must bind every
interface to be reachable from outside it, so this is intentional:
binding 127.0.0.1 would make the service unreachable. Actual exposure
is governed by the network policy and ingress rather than this default,
which is overridable via INTELLIGENCE_HOST.

Suppressed with a targeted '# nosec B104' plus rationale rather than
lowering the bandit severity gate, so every other finding still blocks.

bandit -r app -ll now reports no issues (1 finding explicitly skipped).
main carried three commits that dev did not. Merging main into dev here
resolves them so the promotion PR applies cleanly, without discarding
history on either side.

All nine conflicts were the same shape: dev holds a later version of the
same code, and main holds the older one. Each was inspected individually
and resolved in dev's favour.

  app/main.py                  request-ID validation and router set
  app/models/recommendation.py multilingual 'language' field (#789)
  app/routers/analyze.py       language parameter threading
  app/routers/chat.py          language parameter threading
  app/routers/ws_chat.py       response preferences and guardrails wiring
  app/services/guardrails.py   see below
  app/services/prometheus.py   i18n, grounding, and explainability imports
  tests/test_guardrails.py     tests for the above
  packages/contracts/Cargo.lock  fastrand entry

guardrails.py deserves specific note, since main's version came from #875
and could have been the newer one. It is not. main neutralises smuggled
boundary tags with a single-pass str.replace(); dev compiles
case-insensitive patterns and loops to a fixed point, which additionally
defeats overlapping fragments such as '<user_mess<user_message>age>' that
survive a single pass. dev also adds validate_numeric_grounding() and the
citation instructions. dev's version is a superset.

The resolved tree is byte-identical to dev.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 18666f27-b3f9-48c1-99e6-ae1acc234815

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread apps/api/cmd/api/main.go Fixed
0xDeon added 2 commits August 10, 2026 15:57
go/clear-text-logging (HIGH), cmd/api/main.go:1134
  CodeQL flags the Stellar network passphrase flowing into the startup
  log. The value is a public network identifier -- 'Test SDF Network ;
  September 2015' or its pubnet equivalent -- not a credential, so this
  is a false positive driven by the field name. Rather than annotate
  around it, the field is dropped from the log line: 'environment'
  already tells an operator which network the process is pointed at, so
  it carried no diagnostic value.

go/incorrect-integer-conversion, internal/repository/postgres.go:28
  PoolSize() returns an int that was clamped at the upper end to 25 but
  never at the lower end before narrowing to int32 for MaxConns. A
  negative or zero value from a misconfigured pool size passed straight
  through, and pgxpool rejects MaxConns below 1 at runtime. Now clamped
  at both ends.

go build and go vet are clean.
…rflow

The previous commit clamped the pool size at the point of use, but CodeQL
traces this taint from its source and the alert stood: DATABASE_POOL_SIZE
is parsed by strconv.Atoi into an unbounded int, and config validation
only rejected values <= 0. An oversized value such as 99999999999 passed
validation and then wrapped when narrowed to int32 for pgxpool's
MaxConns, which could yield a negative or absurd connection limit.

Validation now bounds the value at both ends against maxDatabasePoolSize,
so the parsed int can no longer leave int32 range. The clamp in
postgres.go is kept and now reads against a named maxPoolConns constant,
making the safety of the int32 conversion explicit at the conversion
site rather than implied by a bare literal.

go build, go vet, and the config test suite all pass.
Comment thread apps/api/internal/repository/postgres.go Dismissed
The bound added in the previous commit is correct, but CodeQL's
go/incorrect-integer-conversion could not follow it: the guard compared
against an untyped constant across the cfg.PoolSize() call boundary, so
the query still saw an unbounded strconv.Atoi value reaching an int32
conversion.

The clamp now includes an explicit math.MaxInt32 check immediately
before the conversion, which is the form the query recognises, and the
result is assigned to a named variable so the conversion site is
unambiguous. Behaviour is unchanged -- maxPoolConns (25) already bounds
the value far below int32 -- this only makes the existing guarantee
legible to static analysis.

go build, go vet, and a GOARCH=386 cross-compile all pass.
Comment thread apps/api/internal/repository/postgres.go Fixed
Reverts the math.MaxInt32 guard added to satisfy CodeQL. It did not
clear the alert -- the query still could not follow the bound across the
cfg.PoolSize() call boundary -- and it left a branch that is dead code
wherever int is 32 bits.

The overflow itself remains fixed: config validation rejects
DATABASE_POOL_SIZE outside [1, maxDatabasePoolSize], and this clamp
bounds the value to [1, maxPoolConns] before the conversion.

go build and go vet pass.
@0xDeon
0xDeon merged commit 89f64b9 into main Aug 10, 2026
19 checks passed
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.